feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -0,0 +1,420 @@
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">
fullmarkdownmetadata 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>
}