882 lines
20 KiB
Vue
Raw Normal View History

2025-12-04 10:04:21 +08:00
<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>