421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
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<string, string> = {
|
||
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<PostRevisionRecord[]>([])
|
||
const [selected, setSelected] = useState<PostRevisionDetail | null>(null)
|
||
const [detailsCache, setDetailsCache] = useState<Record<number, PostRevisionDetail>>({})
|
||
const [liveMarkdown, setLiveMarkdown] = useState('')
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [restoring, setRestoring] = useState<string | null>(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 (
|
||
<div className="space-y-6">
|
||
<Skeleton className="h-32 rounded-3xl" />
|
||
<Skeleton className="h-[580px] rounded-3xl" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||
<div className="space-y-3">
|
||
<Badge variant="secondary">版本历史</Badge>
|
||
<div>
|
||
<h2 className="text-3xl font-semibold tracking-tight">文章版本快照、Diff 与局部回滚</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||
每次保存、导入、删除、恢复前后都会留下一份 Markdown 快照。现在支持比较当前线上版本或任意历史版本,并可选择 full / markdown / metadata 三种恢复模式。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Input
|
||
value={slugFilter}
|
||
onChange={(event) => setSlugFilter(event.target.value)}
|
||
placeholder="按 slug 过滤,例如 hello-world"
|
||
className="w-[280px]"
|
||
/>
|
||
<Button variant="secondary" onClick={() => void loadRevisions(true)} disabled={refreshing}>
|
||
<RefreshCcw className="h-4 w-4" />
|
||
{refreshing ? '刷新中...' : '刷新'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[1.12fr_0.88fr]">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||
<div>
|
||
<CardTitle>快照列表</CardTitle>
|
||
<CardDescription>
|
||
当前共 {summary.count} 条快照,覆盖 {summary.slugs} 篇文章。
|
||
</CardDescription>
|
||
</div>
|
||
<Badge variant="outline">{summary.count}</Badge>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>文章</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
<TableHead>操作者</TableHead>
|
||
<TableHead>时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{revisions.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell>
|
||
<div className="space-y-1">
|
||
<div className="font-medium">{item.post_title ?? item.post_slug}</div>
|
||
<div className="font-mono text-xs text-muted-foreground">{item.post_slug}</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="space-y-1">
|
||
<Badge variant="secondary">{item.operation}</Badge>
|
||
<div className="text-xs text-muted-foreground">
|
||
{item.revision_reason ?? '自动记录'}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">
|
||
{item.actor_username ?? item.actor_email ?? 'system'}
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
|
||
<History className="h-4 w-4" />
|
||
查看 / 对比
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>当前选中版本</CardTitle>
|
||
<CardDescription>支持查看快照、对比当前线上版本或另一历史版本,并按不同模式回滚。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{selected ? (
|
||
<>
|
||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="secondary">{selected.item.operation}</Badge>
|
||
<Badge variant="outline">#{selected.item.id}</Badge>
|
||
</div>
|
||
<p className="mt-3 font-medium">{selected.item.post_title ?? selected.item.post_slug}</p>
|
||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||
{selected.item.post_slug} · {selected.item.created_at}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<LabelRow title="比较基线" />
|
||
<Select value={compareTarget} onChange={(event) => setCompareTarget(event.target.value)}>
|
||
<option value="current">当前线上版本</option>
|
||
{compareCandidates.map((item) => (
|
||
<option key={item.id} value={String(item.id)}>
|
||
#{item.id} · {item.created_at}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
|
||
<div className="flex items-center gap-2 text-foreground">
|
||
<ArrowLeftRight className="h-4 w-4" />
|
||
<span>Diff 摘要</span>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge variant="success">+{diffStats.additions}</Badge>
|
||
<Badge variant="secondary">-{diffStats.deletions}</Badge>
|
||
<Badge variant="outline">metadata 变更 {metadataChanges.length}</Badge>
|
||
</div>
|
||
<div className="mt-3 text-xs leading-6 text-muted-foreground">
|
||
基线:{comparisonLabel}
|
||
{metadataChanges.length ? ` · 变化字段:${metadataChanges.join('、')}` : ' · Frontmatter 无变化'}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
|
||
<div className="font-medium text-foreground">恢复模式</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{(['full', 'markdown', 'metadata'] as RestoreMode[]).map((mode) => (
|
||
<Button
|
||
key={mode}
|
||
size="sm"
|
||
disabled={restoring !== null || !selected.item.has_markdown}
|
||
onClick={() => void runRestore(mode)}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
{restoring === `${selected.item.id}:${mode}` ? '恢复中...' : mode}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 text-xs leading-6 text-muted-foreground">
|
||
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 frontmatter / SEO / 生命周期等元信息。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<LabelRow title={comparisonLabel} />
|
||
<Textarea
|
||
value={comparisonMarkdown}
|
||
readOnly
|
||
className="min-h-[280px] font-mono text-xs leading-6"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<LabelRow title={`版本 #${selected.item.id}`} />
|
||
<Textarea
|
||
value={selected.markdown ?? ''}
|
||
readOnly
|
||
className="min-h-[280px] font-mono text-xs leading-6"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
|
||
先从左侧选择一个版本,右侧会显示对应快照内容与 Diff 摘要。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LabelRow({ title }: { title: string }) {
|
||
return <div className="text-sm font-medium text-foreground">{title}</div>
|
||
}
|