947 lines
23 KiB
Vue
947 lines
23 KiB
Vue
<!-- 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>
|