Files
ONE/frontend/src/admin/EditorView.vue
T
Sakurasan dd2994189a 安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端:
- ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试
- 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器
- Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限
- 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB

前端:
- EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章)
- v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser
- 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复
- 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复
- 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
2026-09-21 23:59:09 +08:00

809 lines
25 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.
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const route = useRoute()
const router = useRouter()
const id = computed(() => (route.params.id ? Number(route.params.id) : 0))
const isEdit = computed(() => id.value > 0)
const loading = ref(true)
const saving = ref(false)
const savedAt = ref('')
const error = ref('')
const tagInput = ref('')
const dirty = ref(false)
const DRAFT_KEY = 'one.draft.new'
const form = reactive({
kind: 'long',
title: '',
slug: '',
summary: '',
cover_url: '',
content_md: '',
tags: [],
status: 'draft',
published_at: '',
reading_minutes: null,
override_minutes: false
})
// ---------- mode: 'wysiwyg' | 'md' ----------
const mode = ref('wysiwyg')
// ---------- Milkdown Crepe ----------
const editorEl = ref(null)
let crepe = null
// ---------- 阅读时长 ----------
function estimateMinutes(text) {
if (!text || !text.trim()) return 1
const cjk = (text.match(/[㐀-鿿 -〿＀-￯]/g) || []).length
const latin = (text.match(/[A-Za-z0-9']+/g) || []).length
return Math.max(1, Math.floor(cjk / 400) + Math.floor(latin / 220))
}
const cjkChars = computed(() => (form.content_md.match(/[㐀-鿿 -〿＀-￯]/g) || []).length)
const latinWords = computed(() => (form.content_md.match(/[A-Za-z0-9']+/g) || []).length)
const paragraphs = computed(() => {
const t = form.content_md.trim()
if (!t) return 0
return t.split(/\n\s*\n/).filter((p) => p.trim()).length
})
const autoMinutes = computed(() => estimateMinutes(form.content_md))
const finalMinutes = computed(() =>
form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : autoMinutes.value
)
// ---------- outline ----------
const outline = computed(() => {
const lines = form.content_md.split('\n')
const out = []
for (const ln of lines) {
const m = /^(#{1,3})\s+(.+?)\s*$/.exec(ln)
if (!m) continue
out.push({ level: m[1].length, text: m[2].trim() })
}
return out
})
// ---------- 时间 ----------
function isoToLocal(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(
d.getMinutes()
)}`
}
function localToIso(local) {
if (!local) return ''
const d = new Date(local)
if (Number.isNaN(d.getTime())) return ''
return d.toISOString()
}
const publishedLocal = ref('')
// ---------- 载入 ----------
async function loadData() {
if (isEdit.value) {
const p = await adminApi.post(id.value)
form.kind = p.kind || 'long'
form.title = p.title || ''
form.slug = p.slug || ''
form.summary = p.summary || ''
form.cover_url = p.cover_url || ''
form.content_md = p.content_md || ''
form.tags = p.tags ? [...p.tags] : []
form.status = p.status || 'draft'
publishedLocal.value = isoToLocal(p.published_at)
if (p.reading_minutes && p.reading_minutes !== estimateMinutes(p.content_md || '')) {
form.override_minutes = true
form.reading_minutes = p.reading_minutes
}
} else {
const raw = localStorage.getItem(DRAFT_KEY)
if (raw) {
try {
const d = JSON.parse(raw)
Object.assign(form, d)
savedAt.value = '本地草稿已恢复'
} catch (e) {
localStorage.removeItem(DRAFT_KEY)
}
}
publishedLocal.value = isoToLocal(new Date().toISOString())
}
}
async function initEditor() {
crepe = new Crepe({
root: editorEl.value,
defaultValue: form.content_md,
features: {
[CrepeFeature.AI]: false,
[CrepeFeature.Latex]: false
},
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
}
}
})
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
form.content_md = markdown
})
})
await crepe.create()
setTimeout(() => {
dirty.value = false
watchForm()
}, 0)
}
onMounted(async () => {
try {
await loadData()
} catch (e) {
error.value = e.message || '载入失败'
}
loading.value = false
await nextTick()
if (mode.value === 'wysiwyg') {
await initEditor()
}
})
onBeforeUnmount(() => {
clearTimeout(timer)
if (stopWatch) stopWatch()
if (crepe) {
crepe.destroy()
crepe = null
}
})
// ---------- mode switching ----------
async function switchMode(next) {
if (next === mode.value) return
if (next === 'md') {
// WYSIWYG → MD: tear down crepe
if (crepe) {
crepe.destroy()
crepe = null
}
} else {
// MD → WYSIWYG: spin up crepe with the current markdown
await nextTick()
if (!crepe && editorEl.value) {
crepe = new Crepe({
root: editorEl.value,
defaultValue: form.content_md,
features: { [CrepeFeature.AI]: false, [CrepeFeature.Latex]: false },
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
}
}
})
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
form.content_md = markdown
})
})
await crepe.create()
}
}
mode.value = next
}
// ---------- 自动保存 ----------
let timer
let stopWatch
function watchForm() {
stopWatch = watch(
form,
() => {
dirty.value = true
clearTimeout(timer)
timer = setTimeout(autosave, 1500)
},
{ deep: true }
)
watch(publishedLocal, () => {
dirty.value = true
clearTimeout(timer)
timer = setTimeout(autosave, 1500)
})
}
async function autosave() {
if (loading.value) return
const payload = buildPayload()
if (!payload.content_md.trim() && !payload.title.trim() && !isEdit.value) return
saving.value = true
error.value = ''
try {
if (isEdit.value) {
const saved = await adminApi.updatePost(id.value, payload)
applySaved(saved)
} else {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...form, tags: [...form.tags] }))
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = '自动保存失败:' + (e.message || '')
} finally {
saving.value = false
}
}
function applySaved(saved) {
form.slug = saved.slug
form.status = saved.status
publishedLocal.value = isoToLocal(saved.published_at)
}
// /admin/1 → /admin/2 时组件被复用(随后 :key 触发重建)。
// 必须 sync:父级换 key 会先销毁本实例,pre-flush 回调来不及跑;
// 这里取消旧定时器并把未落盘改动写回原文章 id,防止串写
watch(
id,
(next, prev) => {
clearTimeout(timer)
if (stopWatch) stopWatch()
if (prev > 0 && Number.isFinite(next) && next > 0 && dirty.value) {
adminApi.updatePost(prev, buildPayload()).catch(() => {})
}
},
{ flush: 'sync' }
)
function buildPayload() {
return {
kind: form.kind,
title: form.title,
slug: form.slug,
summary: form.summary,
cover_url: form.cover_url,
content_md: form.content_md,
tags: [...form.tags],
status: form.status,
published_at: localToIso(publishedLocal.value) || new Date().toISOString(),
reading_minutes: form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : null
}
}
async function save(status) {
if (status) form.status = status
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
error.value = '先写点什么再保存'
return
}
saving.value = true
error.value = ''
clearTimeout(timer)
try {
const payload = buildPayload()
if (isEdit.value) {
applySaved(await adminApi.updatePost(id.value, payload))
} else {
const created = await adminApi.createPost(payload)
localStorage.removeItem(DRAFT_KEY)
dirty.value = false
router.replace(`/admin/${created.id}`)
return
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
async function publishNow() {
await save('published')
}
async function unpublish() {
await save('draft')
}
// 强制立即保存(⌘S)
function onGlobalKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
// ---------- unsaved 守卫 ----------
// 关闭/刷新页面
function onBeforeUnload(e) {
if (!dirty.value) return
e.preventDefault()
e.returnValue = ''
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
// 路由内 SPA 离开
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('当前改动还没保存,确定离开吗?')
})
// ---------- 标签 ----------
function addTag() {
const v = tagInput.value.trim().replace(/[,,]$/, '')
if (!v) return
if (!form.tags.includes(v)) form.tags.push(v)
tagInput.value = ''
}
function onTagInput(e) {
if (e.key === 'Enter' || e.key === ',' || e.key === ',') {
e.preventDefault()
addTag()
}
}
function removeTag(t) {
form.tags = form.tags.filter((x) => x !== t)
}
// ---------- cover ----------
function clearCover() {
form.cover_url = ''
}
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
const showLinkInput = ref(false)
const showImageInput = ref(false)
const linkText = ref('')
const linkUrl = ref('')
const imageUrl = ref('')
const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) {
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
nextTick(() => {
if (!mdPane.value) return
const newPos = selStart + text.length
mdPane.value.focus()
mdPane.value.setSelectionRange(newPos, newPos)
})
}
function withSelection(fn) {
const el = mdPane.value
const start = el ? el.selectionStart : form.content_md.length
const end = el ? el.selectionEnd : form.content_md.length
fn(start, end)
}
function tbBold() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '加粗'
insertAtCursor(`**${sel}**`, s, e)
})
}
function tbItalic() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '斜体'
insertAtCursor(`*${sel}*`, s, e)
})
}
function tbStrike() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '删除'
insertAtCursor(`~~${sel}~~`, s, e)
})
}
function tbCode() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || 'code'
insertAtCursor('`' + sel + '`', s, e)
})
}
function tbCodeBlock() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '代码块'
insertAtCursor('\n```\n' + sel + '\n```\n', s, e)
})
}
function tbH2() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '二级标题'
insertAtCursor(`\n## ${sel}\n`, s, e)
})
}
function tbH3() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '三级标题'
insertAtCursor(`\n### ${sel}\n`, s, e)
})
}
function tbQuote() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '引用'
insertAtCursor('\n> ' + sel.replace(/\n/g, '\n> ') + '\n', s, e)
})
}
function tbUl() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '列表项'
insertAtCursor(
'\n' + sel.split('\n').map((l) => '- ' + l).join('\n') + '\n',
s,
e
)
})
}
function tbOl() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '列表项'
insertAtCursor(
'\n' +
sel
.split('\n')
.map((l, i) => (i + 1) + '. ' + l)
.join('\n') +
'\n',
s,
e
)
})
}
function tbTask() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '任务'
insertAtCursor('\n- [ ] ' + sel + '\n', s, e)
})
}
function tbHr() {
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
}
function openLink() {
showLinkInput.value = true
linkText.value = ''
linkUrl.value = ''
}
function commitLink() {
if (!linkUrl.value) return
const text = linkText.value || linkUrl.value
const md = `[${text}](${linkUrl.value})`
withSelection((s, e) => insertAtCursor(md, s, e))
showLinkInput.value = false
}
function openImage() {
showImageInput.value = true
imageUrl.value = ''
imageAlt.value = ''
}
function commitImage() {
if (!imageUrl.value) return
const md = `![${imageAlt.value || ''}](${imageUrl.value})`
withSelection((s, e) => insertAtCursor(md, s, e))
showImageInput.value = false
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
// 工具栏按钮的统一定义(用作 v-for 渲染)
const tbGroups = computed(() => [
{
label: '格式',
items: [
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
]
},
{
label: '结构',
items: [
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
]
},
{
label: '插入',
items: [
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
]
}
])
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else class="editor">
<header style="display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;">
<div>
<h1 style="font-family: var(--serif); font-size: 22px;">{{ isEdit ? '编辑文章' : '写新的' }}</h1>
<div class="save-bar" :class="{ saving, saved: !saving && !dirty && savedAt, dirty: !saving && dirty }">
<span class="dot"></span>
<span v-if="saving">保存中…</span>
<span v-else-if="dirty">有未保存的改动</span>
<span v-else-if="savedAt">{{ savedAt }}</span>
<span v-else>还没改过</span>
</div>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn" @click="form.status === 'published' ? unpublish() : save()">
存草稿
</button>
<button v-if="form.status !== 'published'" class="btn btn-primary" @click="publishNow">
发布
</button>
<button v-else class="btn" @click="unpublish">转为草稿</button>
</div>
</header>
<p v-if="error" style="color: var(--admin-danger); font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
<div class="editor-grid">
<div>
<!-- kind switch + title -->
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 10px;">
<button class="chip" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">长文</button>
<button class="chip" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">短文</button>
<span style="font-size: 12px; color: var(--admin-muted); margin-left: 4px;">
{{ form.kind === 'short' ? '一两段话,时间线里不显示标题' : '有标题、有结构,适合讲完整一件事' }}
</span>
<span style="flex: 1;"></span>
<div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
</div>
</div>
<input
v-model="form.title"
class="input"
style="font-family: var(--serif); font-size: 19px; padding: 10px 12px; margin-bottom: 12px;"
:placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'"
/>
<!-- 工具栏:仅 Markdown 模式显示 -->
<div v-if="mode === 'md'" class="toolbar">
<div v-for="g in tbGroups" :key="g.label" class="tb-group">
<span class="tb-label">{{ g.label }}</span>
<button
v-for="it in g.items.filter((i) => i.show)"
:key="it.key"
type="button"
class="tb-btn"
:class="{ italic: it.italic, mono: it.mono }"
:title="it.title"
:aria-label="it.title"
@click="it.run()"
>{{ it.label }}</button>
</div>
</div>
<!-- 链接/图片输入条 -->
<div v-if="showLinkInput" class="tb-popover">
<input
v-model="linkText"
placeholder="链接文字(可省)"
class="input"
spellcheck="false"
aria-label="链接文字"
@keydown.enter="commitLink"
/>
<input
v-model="linkUrl"
placeholder="https://…"
type="url"
inputmode="url"
class="input"
spellcheck="false"
aria-label="链接 URL"
@keydown.enter="commitLink"
/>
<button class="btn btn-primary" @click="commitLink">插入</button>
<button class="btn" @click="showLinkInput = false">取消</button>
</div>
<div v-if="showImageInput" class="tb-popover">
<input
v-model="imageAlt"
placeholder="alt 文字"
class="input"
spellcheck="false"
aria-label="图片 alt"
@keydown.enter="commitImage"
/>
<input
v-model="imageUrl"
placeholder="图片 URL"
type="url"
inputmode="url"
class="input"
spellcheck="false"
aria-label="图片 URL"
@keydown.enter="commitImage"
/>
<button class="btn btn-primary" @click="commitImage">插入</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea
v-show="mode === 'md'"
ref="mdPane"
class="md-pane"
v-model="form.content_md"
placeholder="直接写 Markdown…"
aria-label="Markdown 正文"
></textarea>
<div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span>
<span><strong>{{ latinWords }}</strong> 词</span>
<span><strong>{{ paragraphs }}</strong> 段</span>
<span><strong>{{ finalMinutes }}</strong>′ 读时长</span>
<span style="margin-left: auto; color: var(--admin-faint);">⌘S 保存</span>
</div>
</div>
<aside>
<!-- outline -->
<div v-if="outline.length" class="outline" style="margin-bottom: 18px;">
<div class="ttl">大纲</div>
<ul>
<li
v-for="(o, i) in outline"
:key="i"
:class="{ h3: o.level >= 3 }"
:title="o.text"
>{{ o.text }}</li>
</ul>
</div>
<!-- meta -->
<div class="panel" style="padding: 16px 18px;">
<div class="field">
<label for="cover">封面图</label>
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
</div>
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
</div>
<div class="field">
<label>类型</label>
<p style="margin: 0; font-size: 14px;">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
</div>
<div class="field">
<label for="slug">Slug(链接)</label>
<input id="slug" v-model="form.slug" class="input" placeholder="留空自动生成" />
<p v-if="form.slug" style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted); word-break: break-all;">/post/{{ form.slug }}</p>
</div>
<div class="field">
<label for="summary">摘要</label>
<textarea
id="summary"
v-model="form.summary"
class="textarea"
rows="3"
placeholder="长文显示在时间线里的一段话"
></textarea>
</div>
<div class="field">
<label>标签</label>
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">
<span v-for="t in form.tags" :key="t" class="chip">
{{ t }}
<button class="x" @click="removeTag(t)" :aria-label="`移除标签 ${t}`">×</button>
</span>
</div>
<input
v-model="tagInput"
class="input"
placeholder="输入后回车添加"
aria-label="添加标签"
@keydown="onTagInput"
@blur="addTag"
/>
</div>
<div class="field">
<label for="pub">发布时间</label>
<input id="pub" v-model="publishedLocal" type="datetime-local" class="input" />
</div>
<div class="field">
<label for="mins">阅读时长(分钟)</label>
<div style="display: flex; align-items: center; gap: 10px;">
<input
id="mins"
v-model="form.reading_minutes"
type="number"
min="1"
class="input"
:disabled="!form.override_minutes"
:placeholder="String(autoMinutes)"
/>
<label style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--admin-muted); white-space: nowrap; margin: 0;">
<input v-model="form.override_minutes" type="checkbox" />
手动指定
</label>
</div>
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">自动估算:{{ finalMinutes }} 分钟</p>
</div>
<div class="field">
<label>状态</label>
<p style="margin: 0; font-size: 14px;">
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--admin-faint); margin-right: 6px; vertical-align: 2px;" :style="form.status === 'published' ? { background: 'var(--admin-accent)' } : {}"></span>
{{ form.status === 'published' ? '已发布' : '草稿' }}
</p>
</div>
<button class="btn" style="width: 100%;" @click="save()">立即保存</button>
<RouterLink to="/admin" style="display: inline-block; margin-top: 14px; font-size: 13px; color: var(--admin-accent);">← 返回列表</RouterLink>
</div>
</aside>
</div>
</section>
</template>