2025-12-04 10:04:21 +08:00

882 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="home-plaza" id="home-plaza-section">
<!-- Header -->
<div class="plaza-header">
<div class="plaza-header-left">
<h2 class="plaza-title">首页广场</h2>
<p class="plaza-subtitle">精选 AI 应用与最新玩法每次来都有新发现</p>
</div>
<div class="plaza-header-right">
<button
class="plaza-btn primary"
type="button"
@click="handleProtectedAction"
>
我要上架
</button>
<button
class="plaza-btn outline"
type="button"
@click="openBenefit"
>
领取福利
</button>
</div>
</div>
<!-- Magazine Grid -->
<div class="plaza-grid">
<!-- Left: Feature Card (Magazine Cover Style) -->
<div
class="plaza-feature"
:data-transition-slug="featureArticle?.slug"
:data-hero-title="featureArticle?.slug"
:ref="(el) => registerCardRef(featureArticle?.slug || '', el)"
@click="handleArticleClick(featureArticle)"
:style="{
backgroundImage: `url(${featureArticle?.cover || '/cover.jpg'})`
}"
>
<div class="feature-overlay"></div>
<div class="feature-content">
<div class="feature-badge">本周主推</div>
<h3 class="feature-title" :data-hero-title="featureArticle?.slug">
{{ featureArticle?.title || "探索 AI 的无限可能" }}
</h3>
<p class="feature-desc">
{{
featureArticle?.description ||
"深入了解最新的 AI 技术趋势和应用场景,发现更多精彩内容..."
}}
</p>
<div class="feature-footer">
<div class="feature-author">
<img
:src="featureArticle?.author?.image || '/avatar-placeholder.png'"
alt="Author"
class="author-avatar"
/>
<div class="author-info">
<span class="author-name">{{ featureArticle?.author?.username || "官方编辑" }}</span>
<span class="publish-date">{{ formatDateFull(featureArticle?.createdAt) }}</span>
</div>
</div>
<div class="feature-actions">
<button
class="plaza-like-btn"
:class="{ active: featureArticle?.favorited }"
@click.stop="toggleLike(featureArticle, $event)"
:title="featureArticle?.favorited ? '取消喜欢' : '喜欢'"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21s-6.7-4.5-9.4-7.2A6 6 0 1 1 12 6a6 6 0 1 1 9.4 7.8C18.7 16.5 12 21 12 21Z" />
</svg>
点赞 {{ formatCount(featureArticle?.favoritesCount || featureArticle?.likes) }}
</button>
<button class="feature-cta">立即体验</button>
</div>
</div>
</div>
</div>
<!-- Right: List (Clean Info Stream) -->
<div class="plaza-list">
<div
v-for="(article, index) in listArticles"
:key="article.slug"
class="plaza-item-card"
:data-transition-slug="article.slug"
:data-hero-title="article.slug"
:ref="(el) => registerCardRef(article.slug, el)"
@click="handleArticleClick(article)"
:style="{ '--delay': `${index * 0.1}s` }"
>
<!-- Visual Anchor Bar -->
<div class="item-anchor-bar" :style="{ background: getAnchorColor(index) }"></div>
<div class="item-content">
<h4 class="item-title" :data-hero-title="article.slug">
{{ article.title }}
</h4>
<p class="item-desc">{{ article.description }}</p>
<div class="item-meta">
<span class="meta-tag" v-if="article.tagList?.[0]">{{ article.tagList[0] }}</span>
<span class="meta-divider">·</span>
<span>{{ formatCount(article.views) }} 浏览</span>
<span class="meta-divider">·</span>
<!-- Like Button for List Item -->
<button
class="item-like-btn"
:class="{ active: article.favorited }"
@click.stop="toggleLike(article, $event)"
:title="article.favorited ? '取消喜欢' : '喜欢'"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21s-6.7-4.5-9.4-7.2A6 6 0 1 1 12 6a6 6 0 1 1 9.4 7.8C18.7 16.5 12 21 12 21Z" />
</svg>
点赞 {{ formatCount(article.favoritesCount || article.likes) }}
</button>
<span class="meta-divider">·</span>
<span>{{ formatDateFull(article.createdAt) }}</span>
</div>
</div>
<div
class="item-thumbnail"
v-if="article.cover"
:style="{ backgroundImage: `url(${article.cover})` }"
></div>
</div>
</div>
</div>
<!-- Login Modal -->
<div v-if="showLoginModal" class="plaza-login-mask" @click.self="closeLoginModal">
<div class="plaza-login-modal">
<div class="plaza-login-title">请先登录后再操作</div>
<div class="plaza-login-actions">
<button
type="button"
class="modal-btn ghost"
@click="closeLoginModal"
>
取消
</button>
<button type="button" class="modal-btn primary" @click="goLogin">
前往登录
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance,
} from "vue";
import { navigateTo } from "#app";
import { useApi } from "@/composables/useApi";
import { useArticleNavContext } from "@/composables/useArticleNavContext";
import { useSharedTransition } from "@/composables/useSharedTransition";
interface Article {
slug: string;
title: string;
description?: string | null;
cover?: string | null;
tagList?: string[];
createdAt?: string;
views?: number;
likes?: number;
favoritesCount?: number;
favorites_count?: number;
favorited?: boolean;
author?: {
username?: string;
image?: string | null;
};
}
const props = defineProps<{
articles: Article[];
isLoggedIn?: boolean;
}>();
const emit = defineEmits<{
(e: 'like-changed', payload: { slug: string; favorited: boolean; favoritesCount: number }): void
}>();
const localArticles = ref<Article[]>([]);
watch(
() => props.articles,
(val) => {
const arr = Array.isArray(val) ? val : [];
localArticles.value = arr.map((article) => ({
...article,
tagList: Array.isArray(article.tagList) ? article.tagList : [],
}));
},
{ immediate: true, deep: true },
);
const featureArticle = computed(() => localArticles.value[0]);
const listArticles = computed(() => localArticles.value.slice(1, 6)); // 右侧列表最多 5 条,不补齐,避免重复展示同一篇
const formatCount = (n?: number | null) =>
Number.isFinite(n as number) ? Number(n).toLocaleString("zh-CN") : "0";
const formatDateFull = (dateStr?: string) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const getAnchorColor = (index: number) => {
const colors = [
'#ec4899', // Pink
'#8b5cf6', // Purple
'#3b82f6', // Blue
'#06b6d4', // Cyan
'#10b981' // Emerald
];
return colors[index % colors.length];
};
const showLoginModal = ref(false);
const navCtx = useArticleNavContext();
const transition = useSharedTransition();
const cardRefs = ref<Partial<Record<string, HTMLElement>>>({});
const api = useApi();
const isAuthed = computed(() => Boolean(props.isLoggedIn));
function toHTMLElement(
el: Element | ComponentPublicInstance | null
): HTMLElement | null {
if (!el) return null;
if (el instanceof HTMLElement) return el;
const maybeEl = (el as any)?.$el;
return maybeEl instanceof HTMLElement ? maybeEl : null;
}
function registerCardRef(
slug: string,
el: Element | ComponentPublicInstance | null
) {
if (!slug) return;
const htmlEl = toHTMLElement(el);
if (htmlEl) {
cardRefs.value[slug] = htmlEl;
markCardPosition(slug, htmlEl);
} else {
delete cardRefs.value[slug];
}
}
function markCardPosition(slug: string, el?: HTMLElement) {
const targetEl = el || cardRefs.value[slug];
if (!slug || !targetEl) return;
transition.markSource(slug, targetEl);
}
function refreshCardPositions() {
Object.entries(cardRefs.value).forEach(([slug, el]) => {
if (el) markCardPosition(slug, el);
});
transition.cleanupOldPositions();
}
onMounted(() => {
nextTick(refreshCardPositions);
window.addEventListener("resize", refreshCardPositions);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", refreshCardPositions);
refreshCardPositions();
});
watch(
() => props.articles,
() => nextTick(refreshCardPositions),
{ deep: true }
);
function handleProtectedAction() {
if (!isAuthed.value) {
showLoginModal.value = true;
return;
}
navigateTo("/articles/new");
}
function closeLoginModal() {
showLoginModal.value = false;
}
function goLogin() {
showLoginModal.value = false;
navigateTo("/login");
}
function openBenefit() {
window.open('https://api.wgetai.com/', '_blank');
}
function handleArticleClick(article?: Article) {
if (!article?.slug) return;
navCtx.setCurrent(article.slug);
markCardPosition(article.slug);
navigateTo(`/articles/${article.slug}`);
}
async function toggleLike(article: Article, event?: Event) {
if (event) event.stopPropagation();
if (!article?.slug) return;
if (!isAuthed.value) {
showLoginModal.value = true;
return;
}
const isFavorited = Boolean(article.favorited);
article.favorited = !isFavorited;
// Update count (handle both likes and favoritesCount for compatibility)
const count = article.favoritesCount || article.likes || 0;
const newCount = count + (isFavorited ? -1 : 1);
const applyState = (favorited: boolean, c: number) => {
localArticles.value.forEach((item) => {
if (item.slug === article.slug) {
item.favorited = favorited;
item.favoritesCount = c;
item.likes = c;
}
});
emit('like-changed', { slug: article.slug, favorited, favoritesCount: c });
};
applyState(!isFavorited, newCount);
try {
const method = isFavorited ? 'DELETE' : 'POST';
if (method === 'POST') {
await api.post(`/articles/${article.slug}/favorite`);
} else {
await api.del(`/articles/${article.slug}/favorite`);
}
} catch (e) {
// Revert
applyState(isFavorited, count);
console.error('[HomePlaza] toggle like failed', e);
}
}
</script>
<style scoped>
.home-plaza {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 32px;
background: #fff; /* Changed to pure white */
border-radius: var(--card-radius-lg);
box-shadow: 0 4px 24px rgba(0,0,0,0.02);
position: relative;
z-index: 2;
}
/* Header */
.plaza-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 24px;
border-bottom: 1px solid var(--color-border-light);
margin-bottom: 32px;
}
.plaza-title {
font-size: 28px;
font-weight: 800;
color: var(--color-text-main);
margin: 0 0 6px 0;
letter-spacing: -0.5px;
}
.plaza-subtitle {
font-size: 14px;
color: var(--color-text-sub);
margin: 0;
}
.plaza-header-right {
display: flex;
gap: 12px;
}
.plaza-btn {
padding: 8px 20px;
border-radius: var(--btn-radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.plaza-btn.primary {
background: var(--grad-primary-btn);
color: #fff;
border: none;
box-shadow: var(--shadow-btn);
}
.plaza-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
.plaza-btn.outline {
background: #fff;
border: 1px solid #e2e8f0;
color: var(--color-text-main);
}
.plaza-btn.outline:hover {
border-color: var(--color-primary-end);
color: var(--color-primary-end);
background: #f8fafc;
}
/* Grid Layout */
.plaza-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 32px;
}
/* Feature Card */
.plaza-feature {
position: relative;
height: 480px;
border-radius: var(--card-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 40px;
color: #fff;
background-repeat: no-repeat;
background-size: 180%;
background-position: center center;
cursor: pointer;
transition:
transform 0.35s var(--ease-spring),
background-size 0.5s ease;
will-change: transform, background-size;
}
.plaza-feature:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: var(--shadow-card-hover);
background-size: 190%;
}
.plaza-feature::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.45),
rgba(0, 0, 0, 0.18) 50%,
rgba(0, 0, 0, 0.02)
);
opacity: 1;
mix-blend-mode: normal;
z-index: 1;
transition: opacity 0.3s ease;
}
.plaza-feature:hover::after {
opacity: 0.9;
}
.feature-overlay {
/* Additional gradient for text readability if needed */
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.42), transparent 62%);
z-index: 2;
pointer-events: none;
}
.feature-content {
position: relative;
z-index: 3;
}
.feature-badge {
display: inline-block;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(8px);
border-radius: 99px;
font-size: 12px;
font-weight: 600;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.feature-title {
font-size: 36px;
font-weight: 800;
margin: 0 0 16px 0;
line-height: 1.1;
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.feature-desc {
font-size: 16px;
opacity: 0.95;
margin: 0 0 32px 0;
max-width: 90%;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.feature-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.feature-author {
display: flex;
align-items: center;
gap: 12px;
}
.author-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.8);
}
.author-info {
display: flex;
flex-direction: column;
}
.author-name {
font-size: 14px;
font-weight: 600;
}
.publish-date {
font-size: 12px;
opacity: 0.8;
}
.feature-actions {
display: flex;
align-items: center;
gap: 12px;
}
.feature-cta {
padding: 12px 28px;
background: #fff;
color: #000;
border: none;
border-radius: 99px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.feature-cta:hover {
transform: scale(1.05);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
/* Like Button in Feature Card */
.plaza-like-btn {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
color: #fff;
padding: 8px 16px;
border-radius: 99px;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(4px);
font-weight: 600;
font-size: 14px;
}
.plaza-like-btn:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.05);
}
.plaza-like-btn.active {
background: #fff;
color: #e11d48;
border-color: #fff;
}
.plaza-like-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
transition: transform 0.2s ease;
}
.plaza-like-btn:hover svg {
transform: scale(1.1);
}
/* List Section */
.plaza-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.plaza-item-card {
display: flex;
align-items: center;
padding: 16px 20px;
background: var(--color-bg-list-card);
border-radius: var(--card-radius-md);
transition: all var(--duration-hover) ease;
cursor: pointer;
position: relative;
overflow: hidden;
animation: fade-in-up 0.5s ease backwards;
animation-delay: var(--delay, 0s);
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.plaza-item-card:hover {
transform: translateY(-2px);
background: #fff;
box-shadow: var(--shadow-card-hover);
}
.item-anchor-bar {
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 4px;
border-radius: 0 4px 4px 0;
opacity: 0.6;
transition: all 0.2s ease;
}
.plaza-item-card:hover .item-anchor-bar {
opacity: 1;
width: 6px;
}
.item-content {
flex: 1;
padding-left: 16px;
min-width: 0;
}
.item-title {
font-size: 16px;
font-weight: 700;
color: var(--color-text-main);
margin: 0 0 6px 0;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-desc {
font-size: 13px;
color: var(--color-text-sub);
margin: 0 0 10px 0;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-meta {
display: flex;
align-items: center;
font-size: 12px;
color: #94a3b8;
gap: 6px;
}
.meta-tag {
color: var(--color-primary-end);
background: rgba(59, 130, 246, 0.1);
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.meta-divider {
opacity: 0.5;
}
/* Like Button in List Item */
.item-like-btn {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: #94a3b8;
padding: 0;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
}
.item-like-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
transition: transform 0.2s ease;
}
.item-like-btn:hover {
color: #64748b;
}
.item-like-btn:hover svg {
transform: scale(1.1);
}
.item-like-btn.active {
color: #e11d48;
}
.item-thumbnail {
width: 80px;
height: 60px;
border-radius: 10px;
background-size: cover;
background-position: center;
margin-left: 16px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.05);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.plaza-item-card:hover .item-thumbnail {
transform: scale(1.08);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
/* Mobile */
@media (max-width: 1024px) {
.plaza-grid {
grid-template-columns: 1fr;
}
.plaza-feature {
height: 400px;
}
}
@media (max-width: 640px) {
.home-plaza {
padding: 20px;
}
.plaza-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.plaza-header-right {
width: 100%;
display: flex;
gap: 12px;
}
.plaza-btn {
flex: 1;
text-align: center;
}
.plaza-feature {
height: 360px;
padding: 24px;
}
.feature-title {
font-size: 28px;
}
.feature-desc {
font-size: 14px;
-webkit-line-clamp: 3;
}
}
/* Login Modal */
.plaza-login-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.plaza-login-modal {
background: #fff;
padding: 32px;
border-radius: 24px;
width: 90%;
max-width: 360px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.plaza-login-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 24px;
color: #1e293b;
}
.plaza-login-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.modal-btn {
padding: 10px 24px;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.modal-btn.primary {
background: var(--color-primary-end);
color: #fff;
}
.modal-btn.ghost {
background: #f1f5f9;
color: #64748b;
}
</style>