AI-News/frontend/app/pages/community.vue
2025-12-04 10:04:21 +08:00

744 lines
18 KiB
Vue
Raw 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="community-page">
<div class="community-shell">
<header class="community-header">
<div class="header-meta">
<h1 class="title">社区</h1>
<p class="desc">社区动态流 · 热门/最新内容支持搜索标签筛选置顶</p>
</div>
<div class="header-actions">
<button class="cta-btn" type="button" @click="goCreateArticle">
发布文章参与讨论
</button>
<button class="btn" type="button" @click="() => loadArticles(true)" :disabled="loading">
{{ loading ? '加载中...' : '刷新' }}
</button>
</div>
</header>
<section class="toolbar">
<div class="toolbar-left">
<div class="input-shell">
<input
v-model.trim="keyword"
class="input"
type="search"
placeholder="搜索标题 / 描述 / 作者..."
/>
<span class="input-icon">🔍</span>
</div>
<div class="chips-filter">
<button
v-for="tag in availableTags"
:key="tag || 'all'"
type="button"
class="chip"
:class="{ active: selectedTag === tag }"
@click="selectedTag = tag"
>
{{ tag || '全部' }}
</button>
</div>
</div>
<div class="toolbar-right">
<div class="stat-pill">
<span class="stat-label">文章</span>
<span class="stat-value">{{ filteredArticles.length }}</span>
</div>
<div class="stat-pill">
<span class="stat-label">标签</span>
<span class="stat-value">{{ availableTags.length - 1 }}</span>
</div>
</div>
</section>
<div v-if="loading" class="state">加载中...</div>
<div v-else-if="error" class="state error">加载失败{{ error }}</div>
<div v-else-if="filteredArticles.length === 0" class="state">暂无动态</div>
<div v-else class="list">
<article
v-for="it in filteredArticles"
:key="it.slug"
class="feed-card"
:ref="el => setCardRef(it.slug, el)"
:data-transition-slug="it.slug"
@click="goDetail(it.slug)"
>
<div class="feed-top">
<div class="feed-author">
<div class="avatar">{{ (it.author?.username || '佚名').slice(0, 1).toUpperCase() }}</div>
<div>
<div class="author-name">{{ it.author?.username || '匿名用户' }}</div>
<div class="author-meta">
<span v-if="it.createdAt">发布 {{ formatDate(it.createdAt) }}</span>
<span v-if="it.tagList?.length"> · {{ it.tagList.join(' / ') }}</span>
</div>
</div>
</div>
<div class="feed-actions">
<button
v-if="isAdmin || it.isTop"
class="fire-btn"
:class="{ active: it.isTop, readonly: !isAdmin }"
:disabled="!isAdmin || savingMap[it.slug]"
@click.stop="isAdmin ? toggleTop(it) : undefined"
aria-label="置顶"
>
<svg viewBox="0 0 1024 1024" aria-hidden="true">
<path
d="M437.376 42.667c232.277 92.587 240 364.245 240.256 382.293v.896l72.107-71.85c325.973 401.365 24.576 573.952-110.507 627.328 1.792-3.413 3.499-6.997 5.248-10.709 21.376-45.44 33.237-91.776 29.995-137.088a173.355 173.355 0 0 0-59.734-120.576l-8.192-6.912c-40.192-32-64-65.024-75.477-97.621a119.168 119.168 0 0 1-7.424-46.763l.683-6.485 23.765-94.848-83.627 51.285c-151.381 92.885-201.387 213.547-171.179 342.485 10.155 43.435 28.715 84.395 52.48 122.027-184.917-78.379-225.963-201.941-216.491-322.432 7.979-101.931 75.392-182.016 146.432-257.408l17.792-18.773C391.509 274.688 487.723 177.92 437.376 42.667Z"
:fill="it.isTop ? '#ee3921' : '#cdcdcd'"
/>
<path
d="M449.92 620.16c13.568 47.957 46.507 98.389 105.984 145.792 24.363 19.413 36.01 42.667 38.016 70.827 2.133 29.483-6.485 63.317-22.613 97.493l-5.291 10.752-5.461 10.155-3.669 6.187-5.291.597c-16.64 1.707-35.797 2.731-57.216 2.731-21.291 0-39.467-1.024-54.485-2.688l-5.845-.725c-7.531-8.789-14.613-18.86-20.651-29.227-30.272-52.523-45.632-86.923-53.739-121.398-18.304-78.208 2.219-149.931 77.056-214.4Z"
:fill="it.isTop ? '#ee3921' : '#cdcdcd'"
:opacity="it.isTop ? '0.5' : '0.5'"
/>
</svg>
</button>
<!-- <button type="button" class="link-btn" @click.stop="goDetail(it.slug)">查看</button> -->
</div>
</div>
<div class="feed-body">
<div class="feed-title" :data-hero-title="it.slug">{{ it.title || '未命名文章' }}</div>
<div class="feed-desc" v-if="it.description">{{ it.description }}</div>
<div class="feed-meta">
<span>浏览 {{ it.views ?? 0 }}</span>
<span> · 点赞 {{ getLikes(it) }}</span>
</div>
</div>
<div class="feed-foot">
<div class="tag-list" v-if="it.tagList?.length">
<span v-for="t in it.tagList" :key="`${it.slug}-${t}`" class="tag-chip">{{ t }}</span>
</div>
<button class="join-btn" type="button" @click.stop="handleCardClick(it.slug)">
参与讨论
</button>
</div>
</article>
<div ref="sentinelRef" class="sentinel">
<span v-if="loadingMore">正在加载更多...</span>
<span v-else-if="!hasMore">已到底部</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi, useAuthToken } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { useArticleNavContext } from '@/composables/useArticleNavContext'
import { useSharedTransition } from '@/composables/useSharedTransition'
interface ArticleItem {
slug: string
title: string
description?: string | null
cover?: string | null
tagList?: string[]
isTop?: boolean
sortWeight?: number
createdAt?: string
views?: number
favorited?: boolean
favoritesCount?: number
favorites_count?: number
author?: {
username?: string
image?: string | null
}
}
interface ArticlesResponse {
articles?: ArticleItem[]
}
const api = useApi()
const router = useRouter()
const { user: authUser, fetchMe } = useAuth() as any
const { token } = useAuthToken()
const navCtx = useArticleNavContext()
const transition = useSharedTransition()
const articles = ref<ArticleItem[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const keyword = ref('')
const selectedTag = ref<string | null>(null)
const defaultCover = '/cover.jpg'
const offset = ref(0)
const hasMore = ref(true)
const currentMode = ref<'and' | 'or'>('and')
const PAGE_SIZE = 20
const sentinelRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
const savingMap = ref<Record<string, boolean>>({})
const cardRefs = new Map<string, HTMLElement>()
const isAdmin = computed(() => {
const roles = authUser.value?.roles
return Array.isArray(roles) && roles.includes('admin')
})
function getLikes(it: ArticleItem): number {
return Number(it.favoritesCount ?? it.favorites_count ?? 0)
}
function formatDate(v?: string): string {
if (!v) return ''
try {
const d = new Date(v)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString()
} catch {
return ''
}
}
async function loadArticles(reset = false): Promise<void> {
if (loading.value || loadingMore.value) return
if (reset) {
loading.value = true
articles.value = []
offset.value = 0
hasMore.value = true
currentMode.value = 'and'
error.value = ''
} else {
if (!hasMore.value) return
loadingMore.value = true
}
try {
const res = (await api.get('/articles/menu/community', {
limit: PAGE_SIZE,
offset: offset.value,
mode: currentMode.value,
})) as ArticlesResponse
let list = Array.isArray(res.articles) ? res.articles : []
if (offset.value === 0 && currentMode.value === 'and' && list.length === 0) {
currentMode.value = 'or'
const orRes = (await api.get('/articles/menu/community', {
limit: PAGE_SIZE,
offset: 0,
mode: currentMode.value,
})) as ArticlesResponse
list = Array.isArray(orRes.articles) ? orRes.articles : []
}
const merged = reset ? list : [...articles.value, ...list]
articles.value = merged
offset.value += list.length
hasMore.value = list.length === PAGE_SIZE
navCtx.setList('community', merged.map((a) => a.slug).filter(Boolean))
} catch (err: any) {
console.error('[Community] load articles failed', err)
error.value = err?.statusMessage || err?.message || '加载失败'
} finally {
loading.value = false
loadingMore.value = false
}
}
const availableTags = computed(() => {
const set = new Set<string>()
for (const a of articles.value) {
for (const t of a.tagList || []) {
const tag = String(t || '').trim()
if (tag) set.add(tag)
}
}
const list = ['全部', ...Array.from(set)]
return list.map((t) => (t === '全部' ? null : t)) as (string | null)[]
})
const filteredArticles = computed(() => {
const kw = keyword.value.toLowerCase()
const list = articles.value.filter((a) => {
const matchTag = selectedTag.value ? (a.tagList || []).includes(selectedTag.value) : true
const matchKw = kw
? [a.title, a.description, a.author?.username]
.map((v) => (v || '').toLowerCase())
.some((v) => v.includes(kw))
: true
return matchTag && matchKw
})
return [...list].sort((a, b) => {
const topA = a.isTop ? 1 : 0
const topB = b.isTop ? 1 : 0
if (topA !== topB) return topB - topA
const wA = Number(a.sortWeight ?? 0)
const wB = Number(b.sortWeight ?? 0)
if (wA !== wB) return wB - wA
const tA = new Date(a.createdAt || 0).getTime()
const tB = new Date(b.createdAt || 0).getTime()
return tB - tA
})
})
function setCardRef(slug: string, el: any): void {
if (el && slug) {
cardRefs.set(slug, el as HTMLElement)
}
}
function handleCardClick(slug: string): void {
const cardEl = cardRefs.get(slug)
if (cardEl) {
transition.markSource(slug, cardEl)
}
goDetail(slug)
}
function goDetail(slug: string): void {
const cardEl = cardRefs.get(slug)
if (cardEl) {
transition.markSource(slug, cardEl)
}
router.push(`/articles/${slug}`)
}
function goCreateArticle(): void {
router.push('/articles/new')
}
async function ensureUser(): Promise<void> {
if (!token.value) return
if (!authUser.value) {
try {
await fetchMe()
} catch (err) {
console.warn('[Community] fetchMe failed', err)
}
}
}
async function updateFlags(
article: ArticleItem,
patch: { isTop?: boolean; sortWeight?: number },
): Promise<void> {
if (!isAdmin.value || !article?.slug) return
if (savingMap.value[article.slug]) return
savingMap.value[article.slug] = true
try {
await api.put(`/admin/articles/${article.slug}`, { article: patch })
Object.assign(article, patch)
} catch (err: any) {
console.error('[Community] update flags failed', err)
alert(err?.statusMessage || '更新失败')
} finally {
delete savingMap.value[article.slug]
}
}
async function toggleTop(article: ArticleItem): Promise<void> {
const next = !article.isTop
const sortWeight = next ? Math.floor(Date.now() / 1000) : 0
await updateFlags(article, { isTop: next, sortWeight })
}
function setupObserver(): void {
if (!('IntersectionObserver' in window)) return
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadArticles(false)
}
}
},
{ rootMargin: '200px 0px 200px 0px', threshold: 0 },
)
if (sentinelRef.value) {
observer.observe(sentinelRef.value)
}
}
onMounted(async () => {
await ensureUser()
await loadArticles(true)
setupObserver()
})
onBeforeUnmount(() => {
if (observer) observer.disconnect()
})
</script>
<style scoped>
.community-page {
margin-top: 5%;
padding: 20px 0 32px;
display: flex;
justify-content: center;
background: var(--soft-page-bg);
min-height: 100vh;
}
.community-shell {
width: min(1100px, 96vw);
display: flex;
flex-direction: column;
gap: 14px;
}
.community-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.header-meta {
flex: 1 1 520px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 800;
}
.desc {
margin: 4px 0 0;
color: #6b7280;
}
.btn {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
transition: all 0.18s ease;
font-weight: 600;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cta-btn {
padding: 10px 16px;
border-radius: 14px;
border: none;
background: linear-gradient(120deg, #ff7a7a, #ffb347 50%, #7ad7f0);
color: #fff;
cursor: pointer;
font-weight: 800;
letter-spacing: 0.5px;
box-shadow: 0 12px 30px rgba(255, 122, 122, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cta-btn:hover {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(255, 179, 71, 0.4);
}
.toolbar {
background: #fff;
border: 1px solid #e0e7ff;
border-radius: 14px;
padding: 12px 14px;
display: flex;
justify-content: space-between;
gap: 12px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
flex: 1 1 60%;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d1d5db;
min-width: 240px;
font-size: 14px;
background: #fff;
padding-left: 32px;
}
.input-shell {
position: relative;
}
.input-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 12px;
}
.chips-filter {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.chip {
border: 1px solid #e5e7eb;
background: #f9fafb;
color: #374151;
border-radius: 999px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.chip.active {
background: #111827;
color: #fff;
border-color: #111827;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.stat-pill {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #f0f4ff;
min-width: 90px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.stat-label {
color: #6b7280;
font-size: 12px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 40px;
align-items: start;
}
.feed-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #fff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
padding: 12px 12px 3px 12px;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.feed-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.feed-author {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #a855f7, #6366f1);
color: #fff;
display: grid;
place-items: center;
font-weight: 700;
}
.author-name {
font-weight: 700;
color: #0f172a;
}
.author-meta {
color: #9ca3af;
font-size: 12px;
}
.feed-actions {
display: flex;
align-items: center;
gap: 8px;
}
.feed-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.feed-title {
font-size: 18px;
font-weight: 800;
}
.feed-desc {
color: #4b5563;
line-height: 1.5;
}
.feed-meta {
color: #6b7280;
font-size: 13px;
}
.feed-foot {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
background: #eef2ff;
color: #374151;
font-size: 12px;
}
.fire-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid #e5e7eb;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 6px;
transition: all 0.15s ease;
}
.fire-btn svg {
width: 16px;
height: 16px;
}
.fire-btn.active {
border-color: #ee3921;
}
.fire-btn.readonly {
cursor: default;
opacity: 0.9;
box-shadow: none;
}
.link-btn {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid #111827;
background: #111827;
color: #fff;
cursor: pointer;
transition: all 0.15s ease;
}
.link-btn:hover {
background: #0b1224;
}
.join-btn {
padding: 6px 12px;
border-radius: 10px;
border: 1px solid #e0e7ff;
background: #eef2ff;
color: #4338ca;
cursor: pointer;
font-size: 13px;
}
.join-btn:hover {
border-color: #6366f1;
color: #111827;
}
.state {
padding: 28px 10px;
text-align: center;
color: #6b7280;
}
.state.error {
color: #b91c1c;
}
.sentinel {
text-align: center;
color: #9ca3af;
padding: 12px 0;
font-size: 13px;
grid-column: 1 / -1;
}
@media (max-width: 960px) {
.community-shell {
width: 100%;
padding: 0 10px;
}
.community-header {
align-items: flex-start;
}
.list {
grid-template-columns: 1fr;
}
}
</style>