前端: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
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user