Files
ONE/frontend/src/admin/EditorView.vue
T

1126 lines
37 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 { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { EditorView as CMView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { markdown as markdownLang } from '@codemirror/lang-markdown'
import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
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' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
},
// 图片上传:wysiwyg 里的粘贴 / 拖拽 / 插图按钮全部走后台文件上传,
// onUpload 返回 URL 后由 Crepe 自己插节点
[CrepeFeature.ImageBlock]: {
onUpload: (file) => uploadOne(file)
}
}
})
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
}
if (cmView) {
cmView.destroy()
cmView = 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
}
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
await nextTick()
mountCM()
syncCM()
} 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()) {
toastErr('先写点什么再保存')
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
toastOk('已创建')
router.replace(`/admin/${created.id}`)
return
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
toastOk(label + ' · ' + savedAt.value)
} catch (e) {
toastErr(e.message || '保存失败')
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('当前改动还没保存,确定离开吗?')
})
// ---------- 标签 ----------
// 标签是长文的组织方式;切到短文时把已选的清掉(后端落库时也会强制丢弃)
watch(
() => form.kind,
(k) => {
if (k === 'short') form.tags = []
}
)
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)
// ---------- CodeMirror 源码模式 ----------
// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
let cmView = null
const cmTheme = CMView.theme({
'&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
'.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
'.cm-line': { padding: '0 2px' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
'.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
'.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
'.cm-emphasis': { fontStyle: 'italic' },
'.cm-strong': { fontWeight: '700' },
'.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
})
function handleEditorFiles(files, view) {
const list = [...files].filter((f) => f.type.startsWith('image/'))
if (!list.length) return false
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' }
})
;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
return true
}
function mountCM() {
if (cmView || !mdPane.value) return
cmView = new CMView({
parent: mdPane.value,
state: EditorState.create({
doc: form.content_md,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
markdownLang(),
cmTheme,
CMView.updateListener.of((u) => {
if (u.docChanged) form.content_md = u.state.doc.toString()
}),
CMView.domEventHandlers({
paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
drop: (e, view) => {
if (e.dataTransfer?.files?.length) {
e.preventDefault()
return handleEditorFiles(e.dataTransfer.files, view)
}
return false
}
})
]
})
})
}
// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
function syncCM() {
if (!cmView) return
const cur = cmView.state.doc.toString()
if (cur === form.content_md) return
cmView.dispatch({
changes: { from: 0, to: cur.length, insert: form.content_md }
})
}
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) {
if (cmView) {
if (selStart == null) {
const s = cmView.state.selection.main
selStart = s.from
selEnd = s.to
}
cmView.dispatch({
changes: { from: selStart, to: selEnd, insert: text },
selection: { anchor: selStart + text.length }
})
cmView.focus()
return
}
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
}
function withSelection(fn) {
if (cmView) {
const sel = cmView.state.selection.main
fn(sel.from, sel.to)
return
}
fn(form.content_md.length, form.content_md.length)
}
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
}
// ---------- 上传(封面 / 编辑器插图共用) ----------
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false)
// 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
const MAX_UPLOAD_MB = 50
function validateFile(file) {
const ext = (file.name.split('.').pop() || '').toLowerCase()
if (!ALLOW_UPLOAD_EXT.includes(ext)) {
return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
}
if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
}
return ''
}
// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
async function uploadOne(file, opts = {}) {
const bad = validateFile(file)
if (bad) {
toast(bad, 'err')
throw new Error(bad)
}
const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
try {
const data = await adminApi.uploadFile(file, (p) =>
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败')
toastDone(tid, `${file.name} 上传完成`)
if (opts.cmView) {
const v = opts.cmView
const pos = v.state.selection.main.from
v.dispatch({ changes: { from: pos, insert: `![](${f.url})\n` } })
}
return f.url
} catch (e) {
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败'
throw e
}
}
// ---------- 外链图片转存 ----------
// xLog 的 Cloud:正文里的外链 <img> 一键抓回自己的存储(后端走 SSRF
// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
const importing = ref(false)
function externalImages(md) {
const own = [site.uploads_public_base, location.origin].filter(Boolean)
const out = []
const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
let m
while ((m = re.exec(md))) {
const u = m[1]
if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
}
return [...new Set(out)]
}
async function importExternal() {
if (importing.value) return
const urls = externalImages(form.content_md)
if (!urls.length) {
toast('正文里没有外链图片', 'info')
return
}
importing.value = true
const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
try {
const data = await adminApi.importFiles(urls)
// 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
const errs = data.errors || {}
let done = 0
let changed = false
form.content_md = form.content_md.replace(
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
(whole, alt, u) => {
if (!ok.has(u)) return whole
done++
toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
changed = true
return `![${alt}](${ok.get(u).url})`
}
)
if (changed) syncCM()
const fail = Object.keys(errs).length
if (fail) {
toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
} else {
toastDone(tid, `转存完成:${done} 张已入自己的存储`)
}
} catch (e) {
toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
} finally {
importing.value = false
}
}
const coverInput = ref(null)
async function onCoverPick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = '' // 允许重复选同一个文件
if (!file) return
uploadingImage.value = true
try {
form.cover_url = await uploadOne(file)
} finally {
uploadingImage.value = false
}
}
const imageInput = ref(null)
async function onImagePick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
uploadingImage.value = true
try {
imageUrl.value = await uploadOne(file)
if (!imageAlt.value) imageAlt.value = file.name.replace(/\.[^.]+$/, '')
commitImage()
} finally {
uploadingImage.value = false
}
}
// md 面板的粘贴 / 拖拽图片:上传后在原光标处插入 markdown。
// 上传是异步的,先记光标位置,逐张插入时用 pos 追踪偏移。
function captureSel() {
const el = mdPane.value
const s = el ? el.selectionStart : form.content_md.length
const e = el ? el.selectionEnd : s
return { s, e: Math.max(s, e) }
}
async function onMdFiles(files, sel) {
let pos = sel.s
for (const f of files) {
try {
const url = await uploadOne(f)
const md = `![](${url})\n`
insertAtCursor(md, pos, pos)
pos += md.length
} catch (_) {
/* uploadOne 已把错误写进 error */
}
}
}
function onMdPaste(e) {
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function onMdDrop(e) {
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
// 工具栏按钮的统一定义(用作 v-for 渲染)
const tbGroups = computed(() => [
{
label: '格式',
items: [
{ key: 'b', title: '加粗', run: tbBold, show: mode.value === 'md',
icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
{ key: 'i', title: '斜体', run: tbItalic, show: mode.value === 'md',
icon: 'M19 4h-9M14 20H5M15 4L9 20' },
{ key: 's', title: '删除线', run: tbStrike, show: mode.value === 'md',
icon: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16' },
{ key: 'code', title: '行内代码', run: tbCode, show: mode.value === 'md',
icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
{ key: 'cb', title: '代码块', run: tbCodeBlock, show: mode.value === 'md',
icon: 'M9 10l-2 2.5L9 15M15 10l2 2.5-2 2.5M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z' }
]
},
{
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', title: '引用', run: tbQuote, show: mode.value === 'md',
icon: 'M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1zM15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1z', fill: true },
{ key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
{ key: 'ol', title: '有序列表', run: tbOl, show: mode.value === 'md',
icon: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1' },
{ key: 'task', title: '任务列表', run: tbTask, show: mode.value === 'md',
icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
{ key: 'hr', title: '分隔线', run: tbHr, show: mode.value === 'md',
icon: 'M5 12h14' }
]
},
{
label: '插入',
items: [
{ key: 'link', title: '链接', run: openLink, show: mode.value === 'md',
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
{ key: 'img', title: '图片', run: openImage, show: mode.value === 'md',
icon: 'M21 15l-5-5L5 21M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 8.5h.01' },
{ key: 'date', title: '插入今天日期', run: insertDate, show: mode.value === 'md',
icon: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' }
]
}
])
</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>
<button
class="btn"
:disabled="importing"
title="把正文里的外链图片抓回自己的存储"
@click="importExternal"
>
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
</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"
:title="it.title"
:aria-label="it.title"
@click="it.run()"
>
<svg v-if="it.icon" viewBox="0 0 24 24" aria-hidden="true"
:fill="it.fill ? 'currentColor' : 'none'"
:stroke="it.fill ? 'none' : 'currentColor'" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path :d="it.icon" />
</svg>
<span v-else class="tb-text">{{ it.label }}</span>
</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"
/>
<div style="display: flex; gap: 6px;">
<button class="btn btn-primary" style="flex: 1;" @click="commitImage">插入</button>
<button class="btn" style="flex: 1;" :disabled="uploadingImage" @click="imageInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<input ref="imageInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
<div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
<!-- 轻提示栈 -->
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
<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>
<div class="cover-row">
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<button class="btn" type="button" :disabled="uploadingImage" @click="coverInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
</div>
<input ref="coverInput" type="file" accept="image/*" hidden @change="onCoverPick" />
<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 v-if="form.kind !== 'short'" 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>