- 渲染管线(§8.2):react-markdown + remark-gfm + rehype-sanitize (GitHub schema 扩展 hljs class 与 checked)+ rehype-highlight 纯 class 高亮;链接强制 target=_blank + rel=nofollow noopener noreferrer; schema 快照与恶意 Markdown 冒烟测试(vitest 6 用例) - 编辑器:CodeMirror 6 源码编辑 + 分屏实时预览 + 工具栏 + 粘贴/拖拽图片上传;slug 首存定稿(冲突 409 就地高亮)、 摘要留空自动截取、2s 防抖自动保存 - 页面:博客首页(置顶/分页/标签云)、详情(面包屑/上一篇下一篇)、 标签页、登录、管理列表/回收站/设置/改密、404 - lib:api 客户端(统一包络 + 内存态 CSRF,禁 localStorage)、 AuthContext(/api/me 重取)、暗色主题(class 切换 + 系统跟随) - index.html 内嵌 Go template 占位符供服务端 meta 注入; vite base 固定 '/'(防深链白屏);/api 代理 127.0.0.1:8080
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { useNavigate, useParams } from 'react-router'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
|
||
import { api, ApiError } from '../lib/api'
|
||
import type { NoteItem } from '../lib/api'
|
||
import Editor from '../components/Editor'
|
||
import MarkdownViewer from '../components/MarkdownViewer'
|
||
import { slugPreview } from '../lib/utils'
|
||
|
||
interface NoteFull extends NoteItem {
|
||
content: string
|
||
prev?: unknown
|
||
next?: unknown
|
||
}
|
||
|
||
type Form = {
|
||
title: string
|
||
slug: string
|
||
summary: string
|
||
content: string
|
||
status: 'public' | 'private'
|
||
tags: string
|
||
pinned: boolean
|
||
}
|
||
|
||
const emptyForm: Form = {
|
||
title: '',
|
||
slug: '',
|
||
summary: '',
|
||
content: '',
|
||
status: 'private',
|
||
tags: '',
|
||
pinned: false,
|
||
}
|
||
|
||
export default function AdminEdit() {
|
||
const { id } = useParams()
|
||
const noteId = id ? Number(id) : null
|
||
const isNew = noteId === null
|
||
const navigate = useNavigate()
|
||
const qc = useQueryClient()
|
||
|
||
const { data: existing, isLoading } = useQuery({
|
||
queryKey: ['admin', 'note', noteId],
|
||
queryFn: () => api<NoteFull>(`/api/admin/notes/${noteId}`),
|
||
enabled: !isNew,
|
||
})
|
||
|
||
const [form, setForm] = useState<Form>(emptyForm)
|
||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
||
const [showPreview, setShowPreview] = useState(true)
|
||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||
const dirtyRef = useRef(false)
|
||
const slugTouchedRef = useRef(false)
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (existing) {
|
||
setForm({
|
||
title: existing.title,
|
||
slug: existing.slug,
|
||
summary: existing.summary,
|
||
content: existing.content,
|
||
status: existing.status === 'public' ? 'public' : 'private',
|
||
tags: existing.tags.join(', '),
|
||
pinned: existing.pinned,
|
||
})
|
||
slugTouchedRef.current = false
|
||
dirtyRef.current = false
|
||
}
|
||
}, [existing])
|
||
|
||
const set = <K extends keyof Form>(key: K, value: Form[K]) => {
|
||
setForm((f) => ({ ...f, [key]: value }))
|
||
dirtyRef.current = true
|
||
if (key === 'slug') slugTouchedRef.current = true
|
||
setErrorMsg(null)
|
||
}
|
||
|
||
const save = useMutation({
|
||
mutationFn: async (f: Form) => {
|
||
const payload = {
|
||
title: f.title,
|
||
slug: f.slug,
|
||
summary: f.summary,
|
||
content: f.content,
|
||
status: f.status,
|
||
tags: f.tags.split(/[,,]/).map((t) => t.trim()).filter(Boolean),
|
||
pinned: f.pinned,
|
||
}
|
||
if (isNew) {
|
||
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
||
return api<NoteFull>('/api/admin/notes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ ...payload, slug: '' }),
|
||
})
|
||
}
|
||
return api<NoteFull>(`/api/admin/notes/${noteId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
},
|
||
onSuccess: (saved) => {
|
||
setSavedAt(Date.now())
|
||
setSlugFieldError(null)
|
||
setErrorMsg(null)
|
||
dirtyRef.current = false
|
||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||
if (isNew) {
|
||
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
|
||
}
|
||
},
|
||
onError: (e) => {
|
||
if (e instanceof ApiError && e.code === 'slug_conflict') {
|
||
setSlugFieldError(e.message)
|
||
} else {
|
||
setErrorMsg(e instanceof Error ? e.message : '保存失败')
|
||
}
|
||
},
|
||
})
|
||
|
||
const canSave = form.title.trim().length > 0
|
||
const autosaveReady = !isNew || form.content.trim().length > 0
|
||
|
||
// 自动保存:防抖 2s(§8.2)
|
||
useEffect(() => {
|
||
if (!canSave || !autosaveReady || !dirtyRef.current) return
|
||
if (timerRef.current) clearTimeout(timerRef.current)
|
||
timerRef.current = setTimeout(() => {
|
||
if (dirtyRef.current) save.mutate(form)
|
||
}, 2000)
|
||
return () => {
|
||
if (timerRef.current) clearTimeout(timerRef.current)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [form, canSave, autosaveReady])
|
||
|
||
const wrapSelection = useCallback(
|
||
(before: string, after = before, placeholder = '文本') => {
|
||
const el = document.querySelector<HTMLElement>('.cm-content')
|
||
// 简化实现:在内容末尾/光标处理由 CodeMirror 自行接管,这里直接改源码
|
||
void el
|
||
set('content', form.content + `${before}${placeholder}${after}`)
|
||
},
|
||
[form.content],
|
||
)
|
||
|
||
if (!isNew && isLoading) {
|
||
return <p className="py-12 text-center text-zinc-400">加载中…</p>
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||
<div className="mb-3 flex items-center gap-3">
|
||
<input
|
||
value={form.title}
|
||
onChange={(e) => set('title', e.target.value)}
|
||
placeholder="标题(必填)"
|
||
className="min-w-0 flex-1 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-xl font-semibold outline-none hover:border-zinc-200 focus:border-blue-500 dark:hover:border-zinc-800"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPreview((v) => !v)}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs text-zinc-600 dark:border-zinc-700 dark:text-zinc-400"
|
||
title="切换预览"
|
||
>
|
||
{showPreview ? <EyeOff size={13} /> : <Eye size={13} />}
|
||
{showPreview ? '隐藏预览' : '显示预览'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => save.mutate(form)}
|
||
disabled={!canSave || save.isPending}
|
||
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
||
>
|
||
{save.isPending ? '保存中…' : '保存'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||
<button type="button" onClick={() => wrapSelection('**')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Bold size={14} /></button>
|
||
<button type="button" onClick={() => wrapSelection('*')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Italic size={14} /></button>
|
||
<button type="button" onClick={() => set('content', form.content + '[标题](https://)')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Link2 size={14} /></button>
|
||
<button type="button" onClick={() => set('content', form.content + '\n```\n代码\n```\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Code size={14} /></button>
|
||
<button type="button" onClick={() => set('content', form.content + '\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Table size={14} /></button>
|
||
<span className="mx-1 text-zinc-300 dark:text-zinc-700">|</span>
|
||
<span>粘贴 / 拖拽图片即可上传</span>
|
||
<span className="ml-auto">
|
||
{save.isPending
|
||
? '保存中…'
|
||
: savedAt
|
||
? `已保存 ${new Date(savedAt).toLocaleTimeString('zh-CN')}`
|
||
: isNew
|
||
? '未保存'
|
||
: '无改动'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 gap-3">
|
||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||
<div className="min-h-0 flex-1">
|
||
<Editor
|
||
value={form.content}
|
||
onChange={(v) => set('content', v)}
|
||
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
|
||
onUploadEnd={(err) => setUploadMsg(err ?? null)}
|
||
/>
|
||
</div>
|
||
{uploadMsg && <p className="text-xs text-zinc-400">{uploadMsg}</p>}
|
||
{showPreview && (
|
||
<div className="hidden min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 p-4 lg:block dark:border-zinc-800">
|
||
<MarkdownViewer source={form.content} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<aside className="w-64 shrink-0 space-y-4 overflow-auto rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||
<div>
|
||
<label className="text-xs font-medium text-zinc-500">slug</label>
|
||
<input
|
||
value={form.slug}
|
||
onChange={(e) => set('slug', e.target.value)}
|
||
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
|
||
className={`mt-1 w-full rounded-md border px-2 py-1.5 font-mono text-xs outline-none ${
|
||
slugFieldError
|
||
? 'border-red-500 bg-red-50 dark:bg-red-950'
|
||
: 'border-zinc-300 dark:border-zinc-700'
|
||
}`}
|
||
/>
|
||
{slugFieldError && <p className="mt-1 text-xs text-red-600">{slugFieldError}</p>}
|
||
<p className="mt-1 text-[10px] leading-relaxed text-zinc-400">
|
||
留空保持不变;首次保存自动生成后定稿
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium text-zinc-500">标签(逗号分隔)</label>
|
||
<input
|
||
value={form.tags}
|
||
onChange={(e) => set('tags', e.target.value)}
|
||
placeholder="随笔, go"
|
||
className="mt-1 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium text-zinc-500">摘要(留空自动截取正文)</label>
|
||
<textarea
|
||
value={form.summary}
|
||
onChange={(e) => set('summary', e.target.value)}
|
||
rows={3}
|
||
className="mt-1 w-full resize-none rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-zinc-500">公开</span>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={form.status === 'public'}
|
||
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
|
||
className={`relative h-5 w-9 rounded-full transition ${
|
||
form.status === 'public' ? 'bg-green-500' : 'bg-zinc-300 dark:bg-zinc-700'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
|
||
form.status === 'public' ? 'left-[1.15rem]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-zinc-500">置顶</span>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={form.pinned}
|
||
onClick={() => set('pinned', !form.pinned)}
|
||
className={`relative h-5 w-9 rounded-full transition ${
|
||
form.pinned ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-700'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
|
||
form.pinned ? 'left-[1.15rem]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
{errorMsg && <p className="text-xs text-red-600">{errorMsg}</p>}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|