Files
pure-note/web/src/pages/AdminEdit.tsx
T
wangairnan e9316c9169 前端:React 19 + Vite 8 + Tailwind 4 博客与管理后台 SPA
- 渲染管线(§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
2026-09-08 08:14:52 +08:00

298 lines
12 KiB
TypeScript
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.
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>
)
}