import { lazy, Suspense, 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 { invalidatePublic } from '../lib/invalidate' import MarkdownViewer from '../components/MarkdownViewer' import { slugPreview } from '../lib/utils' // CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载 const Editor = lazy(() => import('../components/Editor')) 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(`/api/admin/notes/${noteId}`), enabled: !isNew, }) const [form, setForm] = useState
(emptyForm) const [savedAt, setSavedAt] = useState(null) const [errorMsg, setErrorMsg] = useState(null) const [slugFieldError, setSlugFieldError] = useState(null) const [showPreview, setShowPreview] = useState(true) const [uploadMsg, setUploadMsg] = useState(null) const dirtyRef = useRef(false) const slugTouchedRef = useRef(false) const timerRef = useRef | 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 = (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('/api/admin/notes', { method: 'POST', body: JSON.stringify({ ...payload, slug: '' }), }) } return api(`/api/admin/notes/${noteId}`, { method: 'PUT', body: JSON.stringify(payload), }) }, onSuccess: (saved) => { setSavedAt(Date.now()) setSlugFieldError(null) setErrorMsg(null) dirtyRef.current = false // 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]), // 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4) void qc.invalidateQueries({ queryKey: ['admin', 'notes'] }) invalidatePublic(qc) 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('.cm-content') // 简化实现:在内容末尾/光标处理由 CodeMirror 自行接管,这里直接改源码 void el set('content', form.content + `${before}${placeholder}${after}`) }, [form.content], ) if (!isNew && isLoading) { return

加载中…

} return (
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" />