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

947 lines
23 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.

<!-- pages/articles/[slug].vue -->
<template>
<article class="doc">
<!-- 加载中 -->
<div v-if="pending" class="state">加载中…</div>
<!-- 未找到 -->
<div v-else-if="!article" class="state">文章不存在或已被删除</div>
<!-- 内容 -->
<div v-else>
<!-- 顶部 Hero -->
<section class="hero" ref="heroRef" :data-transition-slug="article.slug">
<div class="hero__inner" :class="{ 'editing-mode': isEditing }">
<div v-if="isEditing" class="edit-hero-card">
<div class="edit-grid">
<div class="edit-form">
<label class="field">
<span>标题</span>
<input
v-model.trim="editorForm.title"
type="text"
class="input edit-title"
placeholder="输入标题…"
/>
</label>
<label class="field">
<span>摘要</span>
<textarea
v-model.trim="editorForm.description"
rows="3"
class="input edit-desc"
placeholder="一句话描述你的文章…"
/>
</label>
<label class="field">
<span>标签</span>
<div class="tag-input-row">
<input
v-model.trim="tagInput"
class="input"
type="text"
placeholder="用逗号分隔多个标签"
@keydown.enter.prevent="addTag"
@blur="addTag"
/>
<button class="btn ghost small" type="button" @click="addTag">添加</button>
</div>
<div v-if="editorForm.tagList.length" class="tag-chips">
<span v-for="(tag, idx) in editorForm.tagList" :key="tag + idx" class="chip">
{{ tag }}
<button type="button" @click="removeTag(idx)">×</button>
</span>
</div>
</label>
<div class="edit-actions">
<button class="btn ghost" type="button" @click="cancelEdit" :disabled="submitting">取消</button>
<button class="btn primary" type="button" @click="saveArticle" :disabled="submitting">
{{ submitting ? '保存中' : '保存修改' }}
</button>
</div>
</div>
<div class="edit-cover">
<div class="cover-thumb" :style="{ backgroundImage: `url(${editorForm.coverPreview || editorForm.cover || '/cover.jpg'})` }"></div>
<div class="cover-actions">
<label class="btn ghost small">
上传封面
<input type="file" accept="image/*" @change="onPickCover" hidden />
</label>
<button v-if="editorForm.cover || editorForm.coverPreview" class="btn ghost small" type="button" @click="removeCover">
移除
</button>
</div>
</div>
</div>
</div>
<template v-else>
<h1 class="hero__title" :data-hero-title="article.slug">
{{ article.title || '未命名文章' }}
</h1>
<div v-if="(article.tagList || []).length" class="hero__caps">
<span
v-for="t in article.tagList"
:key="t"
class="cap"
:class="{ rec: isRec(t) }"
>
{{ t }}
</span>
</div>
<div class="hero__meta">
<span v-if="article.author?.username" class="m">
作者{{ article.author.username }}
</span>
<span v-if="article.createdAt" class="m">
发布于{{ fmtDate(article.createdAt) }}
</span>
<span v-if="article.updatedAt" class="m">
更新于{{ fmtDate(article.updatedAt) }}
</span>
<span v-if="article.views !== undefined" class="m">
阅读量{{ article.views ?? 0 }}
</span>
<span v-if="article.description" class="m m-desc">
{{ article.description }}
</span>
<template v-if="!isEditing">
<button
type="button"
class="fav-action"
:class="{ active: article.favorited }"
@click="toggleFavorite"
:disabled="favoritePending"
>
<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>
{{ article.favorited ? '已收藏' : '收藏' }} ·
{{ article.favoritesCount || 0 }}
</button>
<button
v-if="canEdit"
type="button"
class="edit-action"
@click="goEditArticle"
>
编辑文章
</button>
</template>
</div>
</template>
</div>
</section>
<!-- 正文 -->
<section class="content">
<template v-if="isEditing">
<div class="editor-main">
<div class="editor-main-head">
<h3>正文编辑</h3>
<span class="muted">所见即所得直接改正文</span>
</div>
<RichEditor v-model="editorForm.body" />
</div>
</template>
<template v-else>
<div class="content-body" v-html="article.body" />
</template>
</section>
</div>
</article>
</template>
<script setup>
definePageMeta({
layout: 'article',
pageTransition: false // 禁用默认转场,使用自定义共享元素转场
})
import { ref, watch, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { navigateTo } from '#app'
import { useApi, useAuthToken } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { useRightDrawer } from '@/composables/useRightDrawer'
import { useUpload } from '@/composables/useUpload'
import { useArticleNavContext } from '@/composables/useArticleNavContext'
import { useSharedTransition } from '@/composables/useSharedTransition'
import { useImageExtractor } from '@/composables/useImageExtractor'
import { useToast } from '@/composables/useToast'
import RichEditor from '@/components/RichEditor.vue'
const route = useRoute()
const api = useApi()
const toast = useToast()
const routerInstance = useRouter()
const { token } = useAuthToken()
const { user: authUser } = useAuth()
const { uploadImage } = useUpload()
const { extractFirstImage } = useImageExtractor()
const isLoggedIn = computed(() => !!token.value)
const drawer = useRightDrawer()
const navCtx = useArticleNavContext()
const transition = useSharedTransition()
const article = ref(null)
const pending = ref(true)
const favoritePending = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
const editorTip = ref('')
const editorForm = ref({
title: '',
description: '',
body: '',
tagList: [],
cover: '',
coverPreview: '',
})
const coverFile = ref(null)
const tagInput = ref('')
const heroRef = ref(null)
/** 推荐/精选/热门/置顶/rec -> 炫彩标签 */
function isRec(tag) {
const t = String(tag || '').toLowerCase()
return ['推荐', '精选', '热门', '置顶', 'rec'].some(k =>
t.includes(k.toLowerCase()),
)
}
function fmtDate(d) {
try {
return new Date(d).toLocaleDateString()
} catch {
return d
}
}
/**
* 同步当前文章信息到右侧抽屉
*/
function syncDrawer(a) {
if (!a) return
drawer.setFromDoc({
title: a.title || '未命名文章',
description: a.description || '',
tags: Array.isArray(a.tagList) ? a.tagList : [],
path: route.fullPath,
slug: a.slug || route.params.slug || '',
favorited: a.favorited,
favoritesCount: a.favoritesCount,
prevSlug: prevSlug.value,
nextSlug: nextSlug.value,
authorUsername: a.author?.username || ''
})
}
async function fetchArticle(forceSlug) {
pending.value = true
article.value = null
try {
const slug = forceSlug || route.params.slug
if (!slug) {
pending.value = false
return
}
const res = await api.get(`/articles/${slug}`)
// 后端返回 { article: { ... } }
const data = res?.article || res || null
article.value = data
// 确保有可用的列表上下文(如果是直接打开详情页,回退到全站列表)
await ensureNavContext(slug)
navCtx.setCurrent(slug)
syncDrawer(data)
syncEditorForm(data)
// 等待 DOM 更新后执行进入动画
await nextTick()
if (heroRef.value && slug) {
transition.animateEnter(slug, heroRef.value)
}
} catch (e) {
console.error('[ArticleDetail] fetch failed:', e)
article.value = null
// 出错时清空抽屉标题
drawer.setFromDoc({
title: '文章加载失败',
description: '',
tags: [],
path: route.fullPath,
slug: route.params.slug || ''
})
} finally {
pending.value = false
}
}
// 首次 + 路由参数变化时拉取文章
watch(
() => route.params.slug,
(val) => fetchArticle(val),
{ immediate: true }
)
const currentSlug = computed(() => String(route.params.slug || ''))
const prevSlug = computed(() => {
const slugs = navCtx.state.value?.slugs || []
const idx = slugs.indexOf(currentSlug.value)
return idx > 0 ? slugs[idx - 1] : ''
})
const nextSlug = computed(() => {
const slugs = navCtx.state.value?.slugs || []
const idx = slugs.indexOf(currentSlug.value)
return idx >= 0 && idx < slugs.length - 1 ? slugs[idx + 1] : ''
})
function goPrev() {
if (!prevSlug.value || !String(prevSlug.value).trim()) {
toast.info('已经是第一篇了', '没有上一篇文章')
return
}
navCtx.setCurrent(prevSlug.value)
routerInstance.push(`/articles/${prevSlug.value}`)
.then(() => fetchArticle(prevSlug.value))
.catch((err) => {
console.error('[ArticleDetail] goPrev failed', err)
})
}
function goNext() {
if (!nextSlug.value || !String(nextSlug.value).trim()) {
toast.info('已经是最后一篇了', '没有下一篇文章')
return
}
navCtx.setCurrent(nextSlug.value)
routerInstance.push(`/articles/${nextSlug.value}`)
.then(() => fetchArticle(nextSlug.value))
.catch((err) => {
console.error('[ArticleDetail] goNext failed', err)
})
}
drawer.setActions({
favorite: () => toggleFavorite(),
prev: () => goPrev(),
next: () => goNext()
})
// nav 上下文变化时同步抽屉上的前后按钮
watch([prevSlug, nextSlug], () => {
if (!article.value) return
drawer.setFromDoc({
title: article.value.title || '未命名文章',
description: article.value.description || '',
tags: Array.isArray(article.value.tagList) ? article.value.tagList : [],
path: route.fullPath,
slug: article.value.slug || route.params.slug || '',
favorited: article.value.favorited,
favoritesCount: article.value.favoritesCount,
prevSlug: prevSlug.value,
nextSlug: nextSlug.value,
authorUsername: article.value.author?.username || ''
})
})
// 当没有列表上下文时,回退拉取一页全站文章作为导航序列
async function ensureNavContext(slug) {
const hasList = Array.isArray(navCtx.state.value?.slugs) && navCtx.state.value.slugs.length > 0
const inList = slug && navCtx.state.value?.slugs?.includes(slug)
console.log('[ArticleDetail] ensureNavContext 检查:', {
slug,
hasList,
inList,
currentListId: navCtx.state.value?.listId,
currentSlugs: navCtx.state.value?.slugs,
slugsLength: navCtx.state.value?.slugs?.length
})
if (hasList && inList) {
console.log('[ArticleDetail] ensureNavContext - 已有列表上下文,跳过加载')
return
}
console.log('[ArticleDetail] ensureNavContext - 没有列表上下文,加载全站文章')
try {
const res = await api.get('/articles', { limit: 100, offset: 0 })
const list = Array.isArray(res?.articles) ? res.articles : []
const slugs = list.map((a) => a.slug).filter(Boolean)
if (slug && !slugs.includes(slug)) slugs.push(slug)
console.log('[ArticleDetail] ensureNavContext - 加载了', slugs.length, '篇文章')
if (slugs.length) navCtx.setList('fallback', slugs)
} catch (err) {
console.warn('[ArticleDetail] ensureNavContext failed', err)
} finally {
navCtx.setCurrent(slug || route.params.slug || '')
}
}
// 如果在同一组件里切换 slug<NuxtLink> 跳到新文章),自动重新拉取 + 同步抽屉
watch(
() => route.params.slug,
() => {
fetchArticle()
}
)
async function toggleFavorite() {
if (!article.value?.slug) return
if (!isLoggedIn.value) {
navigateTo('/login')
return
}
if (favoritePending.value) return
favoritePending.value = true
const slug = article.value.slug
const currentlyFav = Boolean(article.value.favorited)
try {
if (currentlyFav) {
await api.del(`/articles/${slug}/favorite`)
} else {
await api.post(`/articles/${slug}/favorite`)
}
article.value.favorited = !currentlyFav
const delta = article.value.favorited ? 1 : -1
article.value.favoritesCount = Math.max(
0,
(article.value.favoritesCount || 0) + delta,
)
syncDrawer(article.value)
} catch (err) {
console.error('[ArticleDetail] toggle favorite failed:', err)
toast.error('收藏操作失败', err?.statusMessage || err?.message || '请稍后重试')
} finally {
favoritePending.value = false
}
}
function addTag() {
if (!tagInput.value?.trim()) return
tagInput.value
.split(/[,]/)
.map(s => s.trim())
.filter(Boolean)
.forEach((tag) => {
if (!editorForm.value.tagList.includes(tag)) {
editorForm.value.tagList.push(tag)
}
})
tagInput.value = ''
}
function removeTag(index) {
editorForm.value.tagList.splice(index, 1)
}
function onPickCover(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (editorForm.value.coverPreview) {
URL.revokeObjectURL(editorForm.value.coverPreview)
}
coverFile.value = file
editorForm.value.coverPreview = URL.createObjectURL(file)
}
function removeCover() {
if (editorForm.value.coverPreview) {
URL.revokeObjectURL(editorForm.value.coverPreview)
}
coverFile.value = null
editorForm.value.coverPreview = ''
editorForm.value.cover = ''
}
async function saveArticle() {
if (!article.value?.slug) return
if (!editorForm.value.title?.trim() || !editorForm.value.body?.trim()) {
editorTip.value = '标题与正文均不能为空'
return
}
submitting.value = true
editorTip.value = ''
try {
let coverUrl = editorForm.value.cover || ''
if (coverFile.value) {
coverUrl = await uploadImage(coverFile.value)
}
// 如果没有封面,尝试从正文中提取第一张图片
if (!coverUrl && editorForm.value.body) {
const firstImage = extractFirstImage(editorForm.value.body)
if (firstImage) {
coverUrl = firstImage
console.log('📸 自动提取封面图片:', firstImage)
}
}
const payload = {
article: {
title: editorForm.value.title.trim(),
description: editorForm.value.description?.trim() || '',
body: editorForm.value.body,
tagList: editorForm.value.tagList,
cover: coverUrl || null,
},
}
const res = await api.put(`/articles/${article.value.slug}`, payload)
const updated = res?.article || res
article.value = updated
syncDrawer(updated)
syncEditorForm(updated)
isEditing.value = false
} catch (error) {
console.error('[ArticleDetail] save article failed:', error)
editorTip.value = error?.statusMessage || '保存失败,请稍后重试'
} finally {
submitting.value = false
}
}
const previewHtml = computed(() => {
return editorForm.value.body || article.value?.body || ''
})
const canEdit = computed(() => {
return (
isLoggedIn.value &&
!!article.value?.author?.username &&
authUser.value?.username === article.value?.author?.username
)
})
function syncEditorForm(data) {
if (!data) return
editorForm.value.title = data.title || ''
editorForm.value.description = data.description || ''
editorForm.value.body = data.body || ''
editorForm.value.tagList = Array.isArray(data.tagList)
? [...data.tagList]
: Array.isArray(data.tags)
? [...data.tags]
: []
editorForm.value.cover = data.cover || ''
editorForm.value.coverPreview = ''
coverFile.value = null
}
function goEditArticle() {
if (!article.value) return
// 确保每次进入编辑模式都同步一份最新数据,避免重复编辑时预览数据为空
syncEditorForm(article.value)
isEditing.value = true
editorTip.value = ''
}
function cancelEdit() {
isEditing.value = false
editorTip.value = ''
syncEditorForm(article.value)
}
</script>
<style scoped>
.doc {
--w: 920px;
color: #111827;
background: #fff;
}
/* 顶部 Hero */
.hero {
position: relative;
padding: 54px 16px 32px;
background: #fff;
}
.hero__inner {
max-width: var(--w);
margin: 0 auto;
text-align: center;
}
.hero__inner.editing-mode {
text-align: left;
max-width: 980px;
}
.hero__cover {
max-width: 800px;
margin: 0 auto 24px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.12);
}
.hero__cover-img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
}
.hero__title {
font-size: clamp(26px, 4.4vw, 40px);
line-height: 1.25;
font-weight: 800;
letter-spacing: 0.2px;
}
/* 标签 */
.hero__caps {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-top: 10px;
margin-bottom: 8px;
}
.cap {
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
background: #f3f4f6;
color: #374151;
border: 1px solid #e5e7eb;
}
.cap.rec {
color: #fff;
border: 0;
background: linear-gradient(
90deg,
#7c3aed,
#6366f1,
#60a5fa,
#22d3ee,
#7c3aed
);
background-size: 200% 200%;
animation: capFlow 2.1s linear infinite;
}
/* 元信息 */
.hero__meta {
margin-top: 6px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
color: #6b7280;
}
.m {
font-size: 13px;
}
.fav-action {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e5e7eb;
border-radius: 999px;
padding: 4px 12px;
background: #fff;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all .2s ease;
}
.fav-action svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.fav-action:hover:not(:disabled) {
border-color: #111827;
color: #111827;
}
.fav-action.active {
color: #e11d48;
border-color: #e11d48;
}
.fav-action:disabled {
opacity: .5;
cursor: not-allowed;
}
.edit-action {
border: 1px solid #c7d2fe;
background: #eef2ff;
color: #4338ca;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
transition: background .2s ease, border-color .2s ease;
}
.edit-action:hover {
border-color: #4338ca;
background: #e0e7ff;
}
.m-desc {
display: block;
width: 100%;
max-width: var(--w);
margin-top: 2px;
color: #4b5563;
}
/* 正文 */
.content {
max-width: var(--w);
margin: 20px auto 64px;
padding: 0 16px 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.input,
.editor-input,
.editor-textarea {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #fff;
font-size: 14px;
}
.editor-textarea {
resize: vertical;
min-height: 80px;
}
.edit-hero-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 14px;
}
.edit-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 14px;
align-items: start;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-title {
font-size: 20px;
font-weight: 800;
}
.edit-desc {
resize: vertical;
}
.tag-input-row {
display: flex;
gap: 8px;
}
.edit-cover {
display: flex;
flex-direction: column;
gap: 8px;
}
.cover-thumb {
width: 100%;
height: 150px;
border-radius: 12px;
background-size: cover;
background-position: center;
border: 1px solid #e5e7eb;
}
.cover-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.edit-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 12px;
background: #eef2ff;
color: #4338ca;
border: 1px solid #e0e7ff;
font-size: 12px;
}
.chip button {
border: none;
background: transparent;
cursor: pointer;
color: inherit;
font-weight: 700;
}
.editor-main {
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 12px;
background: #fff;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
display: flex;
flex-direction: column;
gap: 10px;
}
.editor-main-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.editor-main-head h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.editor-main-head .muted {
font-size: 13px;
color: #6b7280;
}
.btn {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
font-weight: 700;
transition: all 0.18s ease;
}
.btn.primary {
background: #111827;
color: #fff;
border-color: #111827;
}
.btn.ghost {
background: #fff;
color: #111827;
}
.content-body {
font-size: 16px;
line-height: 1.9;
color: #111827;
}
.content-body :deep(h2) {
margin: 1.6em 0 0.6em;
font-size: 24px;
font-weight: 800;
}
.content-body :deep(h3) {
margin: 1.4em 0 0.4em;
font-size: 20px;
font-weight: 700;
}
.content-body :deep(p) {
margin: 0.8em 0;
}
.content-body :deep(ul),
.content-body :deep(ol) {
margin: 0.6em 0 0.8em 1.2em;
}
.content-body :deep(blockquote) {
margin: 1em 0;
padding: 0.6em 1em;
border-left: 4px solid #e5e7eb;
background: #f9fafb;
color: #374151;
border-radius: 8px;
}
.content-body :deep(code) {
background: #f3f4f6;
padding: 0.1em 0.35em;
border-radius: 6px;
}
.content-body :deep(pre) {
background: #0b1020;
color: #e5e7eb;
padding: 16px;
border-radius: 12px;
overflow: auto;
}
.content-body :deep(img) {
max-width: 100%;
height: auto;
border-radius: 12px;
margin: 14px 0;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
}
/* 状态文案 */
.state {
max-width: var(--w);
margin: 80px auto;
padding: 16px;
text-align: center;
font-size: 14px;
color: #6b7280;
}
.btn {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
font-weight: 700;
transition: all 0.18s ease;
}
.btn.primary {
background: #111827;
color: #fff;
border-color: #111827;
}
.btn.ghost {
background: #fff;
color: #111827;
}
@media (max-width: 960px) {
.edit-grid {
grid-template-columns: 1fr;
}
}
@keyframes capFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>