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

647 lines
16 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="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>