947 lines
23 KiB
Vue
Raw Normal View History

2025-12-04 10:04:21 +08:00
<!-- 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>