647 lines
16 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<template>
<div class="tutorial-page">
<div class="tutorial-shell">
<header class="tutorial-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">
<input
v-model.trim="keyword"
class="input"
type="search"
placeholder="搜索标题/描述/作者..."
/>
<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="list">
<article
v-for="it in filteredArticles"
:key="it.slug"
class="list-item"
:ref="el => setCardRef(it.slug, el)"
:data-transition-slug="it.slug"
@click="handleCardClick(it.slug)"
>
<div class="thumb" :data-hero-cover="it.slug">
<img :src="it.cover || defaultCover" alt="cover" loading="lazy" />
</div>
<div class="list-main">
<div class="list-title" :data-hero-title="it.slug">{{ it.title || '未命名文章' }}</div>
<div class="list-desc" v-if="it.description">{{ it.description }}</div>
<div class="list-meta">
<span>作者{{ it.author?.username || '官方' }}</span>
<span>浏览{{ it.views ?? 0 }}</span>
<span>点赞{{ getLikes(it) }}</span>
<span v-if="it.createdAt">发布{{ formatDate(it.createdAt) }}</span>
</div>
<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>
</div>
<div class="list-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>
</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
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 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/tutorial', {
limit: PAGE_SIZE,
offset: offset.value,
mode: currentMode.value,
})) as ArticlesResponse
let list = Array.isArray(res.articles) ? res.articles : []
// 初次且 AND 没数据时尝试 OR
if (offset.value === 0 && currentMode.value === 'and' && list.length === 0) {
currentMode.value = 'or'
const orRes = (await api.get('/articles/menu/tutorial', {
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('docs', merged.map((a) => a.slug).filter(Boolean))
} catch (err: any) {
console.error('[Docs] 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}`)
}
async function ensureUser(): Promise<void> {
if (!token.value) return
if (!authUser.value) {
try {
await fetchMe()
} catch (err) {
console.warn('[Docs] 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('[Docs] 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 })
}
onMounted(async () => {
await ensureUser()
await loadArticles(true)
setupObserver()
})
onBeforeUnmount(() => {
if (observer) observer.disconnect()
})
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)
}
}
</script>
<style scoped>
.tutorial-page {
margin-top: 5%;
padding: 20px 0 32px;
display: flex;
justify-content: center;
background: var(--soft-page-bg);
min-height: 100vh;
}
.tutorial-shell {
width: min(1100px, 96vw);
display: flex;
flex-direction: column;
gap: 14px;
}
.tutorial-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 {
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;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
display: grid;
grid-template-columns: 120px 1fr auto;
gap: 12px;
padding: 14px;
border-radius: 12px;
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
align-items: center;
}
.list-item:hover {
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
}
.thumb {
width: 120px;
height: 90px;
border-radius: 10px;
overflow: hidden;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e7eb;
}
.thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.list-main {
display: flex;
flex-direction: column;
gap: 6px;
}
.list-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.list-desc {
color: #4b5563;
line-height: 1.5;
}
.list-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
color: #6b7280;
font-size: 13px;
}
.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;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.fire-btn {
width: 36px;
height: 36px;
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: 18px;
height: 18px;
}
.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;
}
.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: 720px) {
.tutorial-header {
flex-direction: column;
align-items: flex-start;
}
.list-item {
grid-template-columns: 1fr;
}
.list-actions {
justify-content: flex-end;
}
}
</style>