import { ArrowLeftRight, History, RefreshCcw, RotateCcw } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { adminApi, ApiError } from '@/lib/api' import { countLineDiff } from '@/lib/markdown-diff' import { parseMarkdownDocument } from '@/lib/markdown-document' import type { PostRevisionDetail, PostRevisionRecord } from '@/lib/types' type RestoreMode = 'full' | 'markdown' | 'metadata' const META_LABELS: Record = { title: '标题', slug: 'Slug', description: '摘要', category: '分类', postType: '类型', image: '封面', images: '图片集', pinned: '置顶', status: '状态', visibility: '可见性', publishAt: '定时发布', unpublishAt: '下线时间', canonicalUrl: 'Canonical', noindex: 'Noindex', ogImage: 'OG 图', redirectFrom: '旧地址', redirectTo: '重定向', tags: '标签', } function stableValue(value: unknown) { if (Array.isArray(value) || (value && typeof value === 'object')) { return JSON.stringify(value) } return String(value ?? '') } function summarizeMetadataChanges(leftMarkdown: string, rightMarkdown: string) { const left = parseMarkdownDocument(leftMarkdown).meta const right = parseMarkdownDocument(rightMarkdown).meta return Object.entries(META_LABELS) .filter(([key]) => stableValue(left[key as keyof typeof left]) !== stableValue(right[key as keyof typeof right])) .map(([, label]) => label) } export function RevisionsPage() { const [revisions, setRevisions] = useState([]) const [selected, setSelected] = useState(null) const [detailsCache, setDetailsCache] = useState>({}) const [liveMarkdown, setLiveMarkdown] = useState('') const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [restoring, setRestoring] = useState(null) const [slugFilter, setSlugFilter] = useState('') const [compareTarget, setCompareTarget] = useState('current') const loadRevisions = useCallback(async (showToast = false) => { try { if (showToast) { setRefreshing(true) } const next = await adminApi.listPostRevisions({ slug: slugFilter.trim() || undefined, limit: 120, }) startTransition(() => { setRevisions(next) }) if (showToast) { toast.success('版本历史已刷新。') } } catch (error) { if (error instanceof ApiError && error.status === 401) { return } toast.error(error instanceof ApiError ? error.message : '无法加载版本历史。') } finally { setLoading(false) setRefreshing(false) } }, [slugFilter]) useEffect(() => { void loadRevisions(false) }, [loadRevisions]) const openDetail = useCallback(async (id: number) => { try { const detail = detailsCache[id] ?? (await adminApi.getPostRevision(id)) let liveMarkdownValue = '' try { const live = await adminApi.getPostMarkdown(detail.item.post_slug) liveMarkdownValue = live.markdown } catch { liveMarkdownValue = '' } startTransition(() => { setDetailsCache((current) => ({ ...current, [id]: detail })) setSelected(detail) setLiveMarkdown(liveMarkdownValue) setCompareTarget('current') }) } catch (error) { toast.error(error instanceof ApiError ? error.message : '无法加载该版本详情。') } }, [detailsCache]) useEffect(() => { if (!selected) { return } if (compareTarget === 'current' || !compareTarget) { return } const revisionId = Number(compareTarget) if (!Number.isFinite(revisionId) || detailsCache[revisionId]) { return } void adminApi .getPostRevision(revisionId) .then((detail) => { startTransition(() => { setDetailsCache((current) => ({ ...current, [revisionId]: detail })) }) }) .catch((error) => { toast.error(error instanceof ApiError ? error.message : '无法加载比较版本。') }) }, [compareTarget, detailsCache, selected]) const summary = useMemo(() => { const uniqueSlugs = new Set(revisions.map((item) => item.post_slug)) return { count: revisions.length, slugs: uniqueSlugs.size, } }, [revisions]) const compareCandidates = useMemo( () => selected ? revisions.filter((item) => item.post_slug === selected.item.post_slug && item.id !== selected.item.id) : [], [revisions, selected], ) const comparisonMarkdown = useMemo(() => { if (!selected) { return '' } if (compareTarget === 'current') { return liveMarkdown } const revisionId = Number(compareTarget) return Number.isFinite(revisionId) ? detailsCache[revisionId]?.markdown ?? '' : '' }, [compareTarget, detailsCache, liveMarkdown, selected]) const comparisonLabel = useMemo(() => { if (compareTarget === 'current') { return '当前线上版本' } const revisionId = Number(compareTarget) const detail = Number.isFinite(revisionId) ? detailsCache[revisionId] : undefined return detail ? `版本 #${detail.item.id}` : '比较版本' }, [compareTarget, detailsCache]) const diffStats = useMemo(() => { if (!selected || !comparisonMarkdown) { return { additions: 0, deletions: 0 } } return countLineDiff(comparisonMarkdown, selected.markdown ?? '') }, [comparisonMarkdown, selected]) const metadataChanges = useMemo(() => { if (!selected || !comparisonMarkdown) { return [] as string[] } return summarizeMetadataChanges(comparisonMarkdown, selected.markdown ?? '') }, [comparisonMarkdown, selected]) const runRestore = useCallback( async (mode: RestoreMode) => { if (!selected) { return } try { setRestoring(`${selected.item.id}:${mode}`) await adminApi.restorePostRevision(selected.item.id, mode) toast.success(`已按 ${mode} 模式回滚到版本 #${selected.item.id}`) await loadRevisions(false) await openDetail(selected.item.id) } catch (error) { toast.error(error instanceof ApiError ? error.message : '恢复版本失败。') } finally { setRestoring(null) } }, [loadRevisions, openDetail, selected], ) if (loading) { return (
) } return (
版本历史

文章版本快照、Diff 与局部回滚

每次保存、导入、删除、恢复前后都会留下一份 Markdown 快照。现在支持比较当前线上版本或任意历史版本,并可选择 full / markdown / metadata 三种恢复模式。

setSlugFilter(event.target.value)} placeholder="按 slug 过滤,例如 hello-world" className="w-[280px]" />
快照列表 当前共 {summary.count} 条快照,覆盖 {summary.slugs} 篇文章。
{summary.count}
文章 操作 操作者 时间 操作 {revisions.map((item) => (
{item.post_title ?? item.post_slug}
{item.post_slug}
{item.operation}
{item.revision_reason ?? '自动记录'}
{item.actor_username ?? item.actor_email ?? 'system'} {item.created_at}
))}
当前选中版本 支持查看快照、对比当前线上版本或另一历史版本,并按不同模式回滚。 {selected ? ( <>
{selected.item.operation} #{selected.item.id}

{selected.item.post_title ?? selected.item.post_slug}

{selected.item.post_slug} · {selected.item.created_at}

Diff 摘要
+{diffStats.additions} -{diffStats.deletions} metadata 变更 {metadataChanges.length}
基线:{comparisonLabel} {metadataChanges.length ? ` · 变化字段:${metadataChanges.join('、')}` : ' · Frontmatter 无变化'}
恢复模式
{(['full', 'markdown', 'metadata'] as RestoreMode[]).map((mode) => ( ))}
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 frontmatter / SEO / 生命周期等元信息。