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

569 lines
14 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="market-page">
<div class="market-shell">
<header class="market-header">
<div>
<h1 class="title">资讯广场</h1>
<p class="desc">展示绑定到菜单资讯广场的文章支持搜索与标签筛选</p>
</div>
<button class="btn" type="button" @click="() => loadArticles(true)" :disabled="loading">
{{ loading ? '加载中...' : '刷新' }}
</button>
</header>
<section class="toolbar">
<div class="toolbar-left">
<div class="input-shell">
<input
v-model.trim="keyword"
class="input"
type="search"
placeholder="搜索标题/描述/作者..."
/>
</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">
<div class="stat-label">文章</div>
<div class="stat-value">{{ filteredArticles.length }}</div>
</div>
<div class="stat">
<div class="stat-label">标签数</div>
<div class="stat-value">{{ availableTags.length - 1 }}</div>
</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="cards-grid">
<div v-for="it in filteredArticles" :key="it.slug" class="card-wrap">
<MarketCard
:cover="it.cover || defaultCover"
:title="it.title || '未命名文章'"
:tags="it.tagList || []"
:created-at="it.createdAt"
:owner-name="it.author?.username || '官方'"
:owner-avatar="it.author?.image || ''"
:views="it.views ?? 0"
:likes="getLikes(it)"
:favorited="!!it.favorited"
:visit-href="''"
:detail-href="`/articles/${it.slug}`"
@toggle-like="() => toggleFavorite(it)"
@view="() => handleViewed(it)"
/>
<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>
</div>
</div>
<div ref="sentinelRef" class="sentinel">
<span v-if="loadingMore">正在加载更多...</span>
<span v-else-if="!hasMore">已到底部</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { useApi, useAuthToken } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import MarketCard from '../components/home/MarketCard.vue'
import { useArticleNavContext } from '@/composables/useArticleNavContext'
interface ArticleItem {
slug: string
title: string
description?: string | null
cover?: string | null
tagList?: string[]
isTop?: boolean
isFeatured?: 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 { user: authUser, fetchMe } = useAuth() as any
const { token } = useAuthToken()
const navCtx = useArticleNavContext()
const articles = ref<ArticleItem[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const defaultCover = '/cover.jpg'
const keyword = ref('')
const selectedTag = ref<string | null>(null)
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 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 handleViewed(it: ArticleItem): void {
it.views = (it.views ?? 0) + 1
}
async function toggleFavorite(it: ArticleItem): Promise<void> {
alert('请在文章详情页完成收藏操作')
}
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/news', {
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/news', {
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
hydrateFilters()
navCtx.setList('market', merged.map((a) => a.slug).filter(Boolean))
} catch (err: any) {
console.error('[Market] 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 hydrateFilters(): void {
if (!selectedTag.value && availableTags.value.length) {
selectedTag.value = null
}
}
async function ensureUser(): Promise<void> {
if (!token.value) return
if (!authUser.value) {
try {
await fetchMe()
} catch (err) {
console.warn('[Market] fetchMe failed', err)
}
}
}
async function updateFlags(
article: ArticleItem,
patch: { isTop?: boolean; isFeatured?: 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('[Market] 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 })
}
async function toggleFeatured(article: ArticleItem): Promise<void> {
const next = !article.isFeatured
await updateFlags(article, { isFeatured: next })
}
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>
.market-page {
margin-top: 5%;
padding: 20px 0 32px;
display: flex;
justify-content: center;
background: var(--soft-page-bg);
min-height: 100vh;
}
.market-shell {
width: min(1200px, 96vw);
display: flex;
flex-direction: column;
gap: 14px;
}
.market-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 800;
}
.desc {
margin: 4px 0 0;
color: #6b7280;
}
.btn {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
transition: all 0.18s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toolbar {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 10px 12px;
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-shell {
position: relative;
}
.input {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid #d1d5db;
min-width: 240px;
font-size: 14px;
background: #fff;
}
.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 {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #f8fafc;
min-width: 90px;
}
.stat-label {
color: #6b7280;
font-size: 12px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.card-wrap {
position: relative;
}
.admin-flags {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
gap: 6px;
z-index: 2;
}
.fire-btn {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
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: 4px;
transition: all 0.15s ease;
}
.fire-btn svg {
width: 18px;
height: 18px;
}
.fire-btn.active {
border-color: #ee3921;
}
.fire-btn.readonly {
cursor: default;
opacity: 0.9;
box-shadow: none;
}
.flag-btn {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid #d1d5db;
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
}
.flag-btn.active {
background: #111827;
color: #fff;
border-color: #111827;
}
@media (max-width: 1280px) {
.cards-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.cards-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.cards-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
.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;
}
@media (max-width: 640px) {
.toolbar {
flex-direction: column;
}
.toolbar-right {
width: 100%;
justify-content: flex-start;
}
}
</style>