Files
pure-note/web/src/pages/AdminEdit.tsx
T
wangairnan b94e9b1721 fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
2026-09-08 17:32:57 +08:00

306 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 { 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<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
// 仅失效列表与公共缓存;不失效当前编辑详情(['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<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">
<Suspense fallback={<div className="flex h-full items-center justify-center text-sm text-zinc-500">编辑器加载中…</div>}>
<Editor
value={form.content}
onChange={(v) => set('content', v)}
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
onUploadEnd={(err) => setUploadMsg(err ?? null)}
/>
</Suspense>
</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>
)
}