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

@@ -1,4 +1,4 @@
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -15,6 +15,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
@@ -56,6 +57,29 @@ function formatSuccess(value: boolean | null) {
return value ? '成功' : '失败'
}
function formatPercent(value: number) {
return `${Math.round(value)}%`
}
function formatDuration(value: number | null) {
if (value === null || !Number.isFinite(value) || value <= 0) {
return '暂无'
}
if (value < 1000) {
return `${Math.round(value)} ms`
}
const seconds = value / 1000
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}`
}
const minutes = Math.floor(seconds / 60)
const restSeconds = Math.round(seconds % 60)
return `${minutes}${restSeconds}`
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -147,22 +171,49 @@ export function AnalyticsPage() {
},
]
const contentStatCards = [
{
label: '累计页面访问',
value: String(data.content_overview.total_page_views),
note: `近 24 小时 ${data.content_overview.page_views_last_24h} 次,近 7 天 ${data.content_overview.page_views_last_7d}`,
icon: Eye,
},
{
label: '累计完读次数',
value: String(data.content_overview.total_read_completes),
note: `近 7 天新增 ${data.content_overview.read_completes_last_7d} 次 read_complete`,
icon: BarChart3,
},
{
label: '近 7 天平均进度',
value: formatPercent(data.content_overview.avg_read_progress_last_7d),
note: '基于 read_progress / read_complete 事件估算内容消费深度',
icon: Search,
},
{
label: '近 7 天平均阅读时长',
value: formatDuration(data.content_overview.avg_read_duration_ms_last_7d),
note: '同一会话在文章页停留并产生阅读进度的平均时长',
icon: Clock3,
},
]
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"> AI </h2>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 便
AI 访便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
@@ -184,6 +235,12 @@ export function AnalyticsPage() {
))}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{contentStatCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
@@ -246,11 +303,69 @@ export function AnalyticsPage() {
))}
</TableBody>
</Table>
</CardContent>
</Card>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
page_view / read_complete
</CardDescription>
</div>
<Badge variant="outline">{data.popular_posts.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.popular_posts.length ? (
data.popular_posts.map((post) => (
<TableRow key={post.slug}>
<TableCell>
<div className="space-y-1">
<a
href={buildFrontendUrl(`/articles/${post.slug}`)}
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
{post.title}
</a>
<p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div>
</TableCell>
<TableCell>{post.page_views}</TableCell>
<TableCell>{post.read_completes}</TableCell>
<TableCell>{formatPercent(post.avg_progress_percent)}</TableCell>
<TableCell>{formatDuration(post.avg_duration_ms)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-sm text-muted-foreground">
访
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
@@ -319,35 +434,70 @@ export function AnalyticsPage() {
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
24 7
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24 AI
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
24 访
</p>
<p className="mt-3 text-3xl font-semibold">
{data.overview.avg_ai_latency_ms_last_7d !== null
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
: '暂无'}
{data.content_overview.page_views_last_24h}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{data.content_overview.read_completes_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatPercent(data.content_overview.avg_read_progress_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatDuration(data.content_overview.avg_read_duration_ms_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 page_view referrer host
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.top_referrers.length ? (
data.top_referrers.map((item) => (
<div
key={item.referrer}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="line-clamp-1 font-medium">{item.referrer}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,166 @@
import { RefreshCcw } 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 { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AuditLogRecord } from '@/lib/types'
export function AuditPage() {
const [logs, setLogs] = useState<AuditLogRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [keyword, setKeyword] = useState('')
const loadLogs = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listAuditLogs({ limit: 120 })
startTransition(() => {
setLogs(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)
}
}, [])
useEffect(() => {
void loadLogs(false)
}, [loadLogs])
const filteredLogs = useMemo(() => {
const normalized = keyword.trim().toLowerCase()
if (!normalized) {
return logs
}
return logs.filter((log) =>
[
log.action,
log.target_type,
log.target_id ?? '',
log.target_label ?? '',
log.actor_username ?? '',
log.actor_email ?? '',
]
.join(' ')
.toLowerCase()
.includes(normalized),
)
}, [keyword, logs])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 rounded-3xl" />
<Skeleton className="h-[520px] 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"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="按动作 / 对象 / 操作者过滤"
className="w-[280px]"
/>
<Button variant="secondary" onClick={() => void loadLogs(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> 120 </CardDescription>
</div>
<Badge variant="outline">{filteredLogs.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-muted-foreground">{log.created_at}</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{log.target_type}</div>
<div className="font-mono text-xs text-muted-foreground">
{log.target_label ?? log.target_id ?? '—'}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{log.actor_username ?? 'system'}</div>
<div className="text-xs text-muted-foreground">
{log.actor_email ?? log.actor_source ?? '未记录'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[320px] text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-5">
{log.metadata ? JSON.stringify(log.metadata, null, 2) : '—'}
</pre>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import {
ArrowUpRight,
BrainCircuit,
Clock3,
FolderTree,
MessageSquareWarning,
RefreshCcw,
@@ -24,10 +25,13 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import {
formatCommentScope,
formatPostStatus,
formatFriendLinkStatus,
formatPostType,
formatPostVisibility,
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
@@ -120,6 +124,16 @@ export function DashboardPage() {
note: '等待审核处理',
icon: MessageSquareWarning,
},
{
label: '发布待办',
value:
data.stats.draft_posts +
data.stats.scheduled_posts +
data.stats.offline_posts +
data.stats.expired_posts,
note: `草稿 ${data.stats.draft_posts} / 定时 ${data.stats.scheduled_posts} / 下线 ${data.stats.offline_posts + data.stats.expired_posts}`,
icon: Clock3,
},
{
label: '分类数量',
value: data.stats.total_categories,
@@ -149,7 +163,7 @@ export function DashboardPage() {
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" />
AI
</a>
@@ -188,6 +202,7 @@ export function DashboardPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
@@ -207,6 +222,12 @@ export function DashboardPage() {
<TableCell className="uppercase text-muted-foreground">
{formatPostType(post.post_type)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
</div>
</TableCell>
<TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
</TableRow>
@@ -257,6 +278,34 @@ export function DashboardPage() {
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
<p className="text-xs text-muted-foreground">稿</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
<p className="text-xs text-muted-foreground">线</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline"> {data.stats.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge>
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
@@ -275,7 +324,7 @@ export function DashboardPage() {
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
<Badge variant="warning">{data.pending_comments.length} </Badge>

View File

@@ -8,9 +8,13 @@ import { Label } from '@/components/ui/label'
export function LoginPage({
submitting,
localLoginEnabled,
proxyAuthEnabled,
onLogin,
}: {
submitting: boolean
localLoginEnabled: boolean
proxyAuthEnabled: boolean
onLogin: (payload: { username: string; password: string }) => Promise<void>
}) {
const [username, setUsername] = useState('admin')
@@ -30,7 +34,7 @@ export function LoginPage({
线
</CardTitle>
<CardDescription className="max-w-xl text-base leading-7">
AI
API
</CardDescription>
</div>
</CardHeader>
@@ -60,44 +64,58 @@ export function LoginPage({
</CardTitle>
<CardDescription>
{localLoginEnabled
? '当前登录复用后端管理员账号;如果前面接了 TinyAuth / Pocket ID也可以直接由反向代理完成 SSO。'
: proxyAuthEnabled
? '当前后台已切到代理侧 SSO 模式,请从受保护的后台域名入口进入。'
: '当前后台未开放本地账号密码登录,请检查部署配置。'}
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
{localLoginEnabled ? (
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
) : (
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-7 text-muted-foreground">
<p> Caddy + TinyAuth + Pocket ID </p>
<p> SSO </p>
<Button className="w-full" size="lg" onClick={() => window.location.reload()}>
</Button>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,4 +1,13 @@
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
import {
CheckSquare,
Copy,
Image as ImageIcon,
RefreshCcw,
Replace,
Square,
Trash2,
Upload,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -9,6 +18,11 @@ import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import {
formatCompressionPreview,
maybeCompressImageWithPrompt,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types'
function formatBytes(value: number) {
@@ -30,10 +44,18 @@ export function MediaPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const [replacingKey, setReplacingKey] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
const [prefixFilter, setPrefixFilter] = useState('all')
const [uploadPrefix, setUploadPrefix] = useState('post-covers/')
const [searchTerm, setSearchTerm] = useState('')
const [provider, setProvider] = useState<string | null>(null)
const [bucket, setBucket] = useState<string | null>(null)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const loadItems = useCallback(async (showToast = false) => {
try {
@@ -62,6 +84,12 @@ export function MediaPage() {
void loadItems(false)
}, [loadItems])
useEffect(() => {
setSelectedKeys((current) =>
current.filter((key) => items.some((item) => item.key === key)),
)
}, [items])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
@@ -70,6 +98,40 @@ export function MediaPage() {
return items.filter((item) => item.key.toLowerCase().includes(keyword))
}, [items, searchTerm])
const allFilteredSelected =
filteredItems.length > 0 && filteredItems.every((item) => selectedKeys.includes(item.key))
async function prepareFiles(files: File[], targetPrefix = uploadPrefix) {
if (!compressBeforeUpload) {
return files
}
const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
const normalizeCover =
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
const result: File[] = []
for (const file of files) {
const compressed = normalizeCover
? await normalizeCoverImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `封面规范化上传(${file.name}`,
})
: await maybeCompressImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `媒体库上传(${file.name}`,
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
}
result.push(compressed.file)
}
return result
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -78,7 +140,7 @@ export function MediaPage() {
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
@@ -88,27 +150,119 @@ export function MediaPage() {
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
}
try {
setBatchDeleting(true)
const result = await adminApi.batchDeleteMediaObjects(selectedKeys)
if (result.failed.length) {
toast.warning(`已删除 ${result.deleted.length} 个,失败 ${result.failed.length} 个。`)
} else {
toast.success(`已删除 ${result.deleted.length} 个对象。`)
}
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '批量删除失败。')
} finally {
setBatchDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
({selectedKeys.length})
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Provider{provider ?? '未配置'} / Bucket{bucket ?? '未配置'}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<CardContent className="space-y-3">
<div className="grid gap-3 lg:grid-cols-[220px_220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="uploads/"></option>
</Select>
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="uploads/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<Input
type="file"
multiple
accept="image/*"
onChange={(event) => {
const files = Array.from(event.target.files || [])
setUploadFiles(files)
}}
/>
<Button
type="button"
variant="outline"
onClick={() => setCompressBeforeUpload((current) => !current)}
>
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button>
<Input
className="w-[96px]"
value={compressQuality}
onChange={(event) => setCompressQuality(event.target.value)}
placeholder="0.82"
disabled={!compressBeforeUpload}
/>
<Button
disabled={!uploadFiles.length || uploading}
onClick={async () => {
try {
setUploading(true)
const files = await prepareFiles(uploadFiles)
const result = await adminApi.uploadMediaObjects(files, {
prefix: uploadPrefix,
})
toast.success(`上传完成,共 ${result.uploaded.length} 个文件。`)
setUploadFiles([])
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '上传失败。')
} finally {
setUploading(false)
}
}}
>
<Upload className="h-4 w-4" />
{uploading ? '上传中...' : '上传'}
</Button>
</div>
{uploadFiles.length ? (
<p className="text-xs text-muted-foreground">
{uploadFiles.length}
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
: ''}
</p>
) : null}
</CardContent>
</Card>
@@ -116,64 +270,138 @@ export function MediaPage() {
<Skeleton className="h-[520px] rounded-3xl" />
) : (
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{filteredItems.map((item) => (
<Card key={item.key} className="overflow-hidden">
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
return (
<Card key={item.key} className="overflow-hidden">
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
<button
type="button"
className="absolute left-2 top-2 rounded-xl border border-border/80 bg-background/80 p-1"
onClick={() => {
setSelectedKeys((current) => {
if (current.includes(item.key)) {
return current.filter((key) => key !== item.key)
}
return [...current, item.key]
})
}}
>
{selected ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key ? '删除中...' : '删除'}
</Button>
</div>
</CardContent>
</Card>
))}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" asChild>
<label htmlFor={replaceInputId} className="cursor-pointer">
<Replace className="h-4 w-4" />
</label>
</Button>
<input
id={replaceInputId}
className="hidden"
type="file"
accept="image/*"
onChange={async (event) => {
const file = event.target.files?.item(0)
event.currentTarget.value = ''
if (!file) {
return
}
try {
setReplacingKey(item.key)
const [prepared] = await prepareFiles(
[file],
item.key.startsWith('review-covers/')
? 'review-covers/'
: item.key.startsWith('post-covers/')
? 'post-covers/'
: 'uploads/',
)
const result = await adminApi.replaceMediaObject(item.key, prepared)
startTransition(() => {
setItems((current) =>
current.map((currentItem) =>
currentItem.key === item.key
? { ...currentItem, url: result.url }
: currentItem,
),
)
})
toast.success('已替换媒体对象。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '替换失败。')
} finally {
setReplacingKey(null)
}
}}
/>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) =>
current.filter((currentItem) => currentItem.key !== item.key),
)
setSelectedKeys((current) =>
current.filter((key) => key !== item.key),
)
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key
? '删除中...'
: replacingKey === item.key
? '替换中...'
: '删除'}
</Button>
</div>
</CardContent>
</Card>
)
})}
{!filteredItems.length ? (
<Card className="xl:col-span-2 2xl:col-span-3">
@@ -185,6 +413,37 @@ export function MediaPage() {
) : null}
</div>
)}
{filteredItems.length ? (
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6 text-sm text-muted-foreground">
<p>
{filteredItems.length} {selectedKeys.length}
</p>
<Button
variant="outline"
onClick={() => {
if (allFilteredSelected) {
setSelectedKeys((current) =>
current.filter(
(key) => !filteredItems.some((item) => item.key === key),
),
)
return
}
setSelectedKeys((current) => {
const next = new Set(current)
filteredItems.forEach((item) => next.add(item.key))
return Array.from(next)
})
}}
>
{allFilteredSelected ? <Square className="h-4 w-4" /> : <CheckSquare className="h-4 w-4" />}
{allFilteredSelected ? '取消全选' : '全选当前筛选'}
</Button>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
type PreviewState = {
@@ -124,7 +125,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
</Button>
{slug ? (
<Button variant="outline" asChild>
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Save,
Trash2,
Upload,
WandSparkles,
X,
} from 'lucide-react'
@@ -38,10 +39,22 @@ import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime, formatPostType, postTagsToList } from '@/lib/admin-format'
import {
emptyToNull,
formatDateTime,
formatPostStatus,
formatPostType,
formatPostVisibility,
postTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
import type {
AdminPostMetadataResponse,
@@ -59,6 +72,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
savedMarkdown: string
@@ -73,6 +95,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
}
}
@@ -86,6 +117,15 @@ type CreatePostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
}
@@ -141,8 +181,6 @@ const createMetadataProposalFields: MetadataProposalField[] = [
'category',
'tags',
]
const FRONTEND_DEV_ORIGIN = 'http://localhost:4321'
const defaultCreateForm: CreatePostFormState = {
title: '',
slug: '',
@@ -152,6 +190,15 @@ const defaultCreateForm: CreatePostFormState = {
image: '',
imagesText: '',
pinned: false,
status: 'draft',
visibility: 'public',
publishAt: '',
unpublishAt: '',
canonicalUrl: '',
noindex: false,
ogImage: '',
redirectFromText: '',
redirectTo: '',
tags: '',
markdown: '# 未命名文章\n',
}
@@ -219,11 +266,7 @@ function resolveCoverPreviewUrl(value: string) {
}
if (trimmed.startsWith('/')) {
if (import.meta.env.DEV) {
return new URL(trimmed, FRONTEND_DEV_ORIGIN).toString()
}
return new URL(trimmed, window.location.origin).toString()
return buildFrontendUrl(trimmed)
}
return trimmed
@@ -409,20 +452,29 @@ function stripFrontmatter(markdown: string) {
return normalized.slice(endIndex + 5).trimStart()
}
function extractPublishedFlag(markdown: string) {
function extractPostStatus(markdown: string) {
const normalized = markdown.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) {
return true
return 'published'
}
const endIndex = normalized.indexOf('\n---\n', 4)
if (endIndex === -1) {
return true
return 'published'
}
const frontmatter = normalized.slice(4, endIndex)
const match = frontmatter.match(/^published:\s*(true|false)\s*$/m)
return match?.[1] !== 'false'
const statusMatch = frontmatter.match(/^status:\s*(.+)\s*$/m)
if (statusMatch?.[1]) {
return statusMatch[1].replace(/^['"]|['"]$/g, '').trim() || 'published'
}
const publishedMatch = frontmatter.match(/^published:\s*(true|false)\s*$/m)
if (publishedMatch) {
return publishedMatch[1] === 'false' ? 'draft' : 'published'
}
return 'published'
}
function buildMarkdownForSave(form: PostFormState) {
@@ -441,7 +493,17 @@ function buildMarkdownForSave(form: PostFormState) {
lines.push(`post_type: ${JSON.stringify(form.postType.trim() || 'article')}`)
lines.push(`pinned: ${form.pinned ? 'true' : 'false'}`)
lines.push(`published: ${extractPublishedFlag(form.markdown) ? 'true' : 'false'}`)
lines.push(`status: ${JSON.stringify(form.status.trim() || extractPostStatus(form.markdown))}`)
lines.push(`visibility: ${JSON.stringify(form.visibility.trim() || 'public')}`)
lines.push(`noindex: ${form.noindex ? 'true' : 'false'}`)
if (form.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(form.publishAt.trim())}`)
}
if (form.unpublishAt.trim()) {
lines.push(`unpublish_at: ${JSON.stringify(form.unpublishAt.trim())}`)
}
if (form.image.trim()) {
lines.push(`image: ${JSON.stringify(form.image.trim())}`)
@@ -466,6 +528,26 @@ function buildMarkdownForSave(form: PostFormState) {
})
}
if (form.canonicalUrl.trim()) {
lines.push(`canonical_url: ${JSON.stringify(form.canonicalUrl.trim())}`)
}
if (form.ogImage.trim()) {
lines.push(`og_image: ${JSON.stringify(form.ogImage.trim())}`)
}
const redirectFrom = parseImageList(form.redirectFromText)
if (redirectFrom.length) {
lines.push('redirect_from:')
redirectFrom.forEach((item) => {
lines.push(` - ${JSON.stringify(item)}`)
})
}
if (form.redirectTo.trim()) {
lines.push(`redirect_to: ${JSON.stringify(form.redirectTo.trim())}`)
}
return `${lines.join('\n')}\n---\n\n${stripFrontmatter(form.markdown).trim()}\n`
}
@@ -483,6 +565,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
markdown,
savedMarkdown: markdown,
@@ -497,6 +588,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
},
}
@@ -511,6 +611,15 @@ function hasMetadataDraftChanges(form: PostFormState) {
form.image !== form.savedMeta.image ||
form.imagesText !== form.savedMeta.imagesText ||
form.pinned !== form.savedMeta.pinned ||
form.status !== form.savedMeta.status ||
form.visibility !== form.savedMeta.visibility ||
form.publishAt !== form.savedMeta.publishAt ||
form.unpublishAt !== form.savedMeta.unpublishAt ||
form.canonicalUrl !== form.savedMeta.canonicalUrl ||
form.noindex !== form.savedMeta.noindex ||
form.ogImage !== form.savedMeta.ogImage ||
form.redirectFromText !== form.savedMeta.redirectFromText ||
form.redirectTo !== form.savedMeta.redirectTo ||
form.tags !== form.savedMeta.tags
)
}
@@ -534,7 +643,15 @@ function buildCreatePayload(form: CreatePostFormState): CreatePostPayload {
image: emptyToNull(form.image),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: emptyToNull(form.status) ?? 'draft',
visibility: emptyToNull(form.visibility) ?? 'public',
publishAt: emptyToNull(form.publishAt),
unpublishAt: emptyToNull(form.unpublishAt),
canonicalUrl: emptyToNull(form.canonicalUrl),
noindex: form.noindex,
ogImage: emptyToNull(form.ogImage),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: emptyToNull(form.redirectTo),
}
}
@@ -549,7 +666,15 @@ function buildCreateMarkdownForWindow(form: CreatePostFormState) {
image: form.image.trim(),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: form.status.trim() || 'draft',
visibility: form.visibility.trim() || 'public',
publishAt: form.publishAt.trim(),
unpublishAt: form.unpublishAt.trim(),
canonicalUrl: form.canonicalUrl.trim(),
noindex: form.noindex,
ogImage: form.ogImage.trim(),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: form.redirectTo.trim(),
tags: form.tags
.split(',')
.map((item) => item.trim())
@@ -571,7 +696,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
images: parsed.meta.images.length ? parsed.meta.images : parseImageList(form.imagesText),
pinned: parsed.meta.pinned,
published: extractPublishedFlag(markdown),
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFrom: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom
: parseImageList(form.redirectFromText),
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length
? parsed.meta.tags
: form.tags
@@ -591,6 +726,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: nextMarkdown,
}
@@ -609,6 +755,17 @@ function applyPolishedCreateState(form: CreatePostFormState, markdown: string):
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: parsed.body || stripFrontmatter(markdown),
}
@@ -629,6 +786,8 @@ export function PostsPage() {
const { slug } = useParams()
const importInputRef = useRef<HTMLInputElement | null>(null)
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
const [posts, setPosts] = useState<PostRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -642,6 +801,8 @@ export function PostsPage() {
useState(false)
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null)
@@ -896,6 +1057,15 @@ export function PostsPage() {
image: emptyToNull(editor.image),
images: parseImageList(editor.imagesText),
pinned: editor.pinned,
status: emptyToNull(editor.status) ?? 'draft',
visibility: emptyToNull(editor.visibility) ?? 'public',
publishAt: emptyToNull(editor.publishAt),
unpublishAt: emptyToNull(editor.unpublishAt),
canonicalUrl: emptyToNull(editor.canonicalUrl),
noindex: editor.noindex,
ogImage: emptyToNull(editor.ogImage),
redirectFrom: parseImageList(editor.redirectFromText),
redirectTo: emptyToNull(editor.redirectTo),
})
const updatedMarkdown = await adminApi.updatePostMarkdown(editor.slug, persistedMarkdown)
@@ -1082,6 +1252,68 @@ export function PostsPage() {
}
}, [createForm])
const uploadEditorCover = useCallback(async (file: File) => {
try {
setUploadingEditorCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '文章封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setEditor((current) => (current ? { ...current, image: url } : current))
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingEditorCover(false)
}
}, [])
const uploadCreateCover = useCallback(async (file: File) => {
try {
setUploadingCreateCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '新建封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setCreateForm((current) => ({ ...current, image: url }))
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingCreateCover(false)
}
}, [])
const editorPolishHunks = useMemo(
() =>
editorPolish
@@ -1596,6 +1828,32 @@ export function PostsPage() {
void importMarkdownFiles(event.target.files)
}}
/>
<input
ref={editorCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadEditorCover(file)
}
event.currentTarget.value = ''
}}
/>
<input
ref={createCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadCreateCover(file)
}
event.currentTarget.value = ''
}}
/>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
@@ -1842,7 +2100,10 @@ export function PostsPage() {
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">{editor.title || editor.slug}</CardTitle>
<Badge variant="secondary">{formatPostType(editor.postType)}</Badge>
<Badge variant="outline">{formatPostStatus(editor.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(editor.visibility)}</Badge>
{editor.pinned ? <Badge variant="success"></Badge> : null}
{editor.noindex ? <Badge variant="warning">noindex</Badge> : null}
{markdownDirty ? <Badge variant="warning"></Badge> : null}
</div>
<CardDescription className="font-mono text-xs">{editor.slug}</CardDescription>
@@ -1912,6 +2173,60 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={editor.status}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, status: event.target.value } : current,
)
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={editor.visibility}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, visibility: event.target.value } : current,
)
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={editor.publishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, publishAt: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={editor.unpublishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, unpublishAt: event.target.value } : current,
)
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={editor.tags}
@@ -1954,10 +2269,18 @@ export function PostsPage() {
/>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => editorCoverInputRef.current?.click()}
disabled={uploadingEditorCover}
>
<Upload className="h-4 w-4" />
{uploadingEditorCover ? '上传中...' : '上传封面'}
</Button>
<Button
variant="outline"
onClick={() => void generateEditorCover()}
disabled={generatingEditorCover}
disabled={generatingEditorCover || uploadingEditorCover}
>
<WandSparkles className="h-4 w-4" />
{generatingEditorCover
@@ -1998,6 +2321,64 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空则使用默认文章地址。">
<Input
value={editor.canonicalUrl}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, canonicalUrl: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则前台自动生成 SVG 分享图。">
<Input
value={editor.ogImage}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, ogImage: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug不带 /articles/ 前缀。">
<Textarea
value={editor.redirectFromText}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectFromText: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="强制跳转目标" hint="适合旧文跳新文;留空表示当前 slug 为主地址。">
<Input
value={editor.redirectTo}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectTo: event.target.value } : current,
)
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.noindex}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, noindex: event.target.checked } : current,
)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">noindex</div>
<div className="text-sm text-muted-foreground">
访
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
@@ -2200,6 +2581,8 @@ export function PostsPage() {
<Badge variant="outline">{createForm.markdown.split(/\r?\n/).length} </Badge>
<Badge variant="secondary">AI </Badge>
<Badge variant="outline">AI </Badge>
<Badge variant="outline">{formatPostStatus(createForm.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(createForm.visibility)}</Badge>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-6 text-muted-foreground">
AI slug稿
@@ -2252,6 +2635,52 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={createForm.status}
onChange={(event) =>
setCreateForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={createForm.visibility}
onChange={(event) =>
setCreateForm((current) => ({ ...current, visibility: event.target.value }))
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={createForm.publishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, publishAt: event.target.value }))
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={createForm.unpublishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, unpublishAt: event.target.value }))
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={createForm.tags}
@@ -2291,10 +2720,18 @@ export function PostsPage() {
/>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => createCoverInputRef.current?.click()}
disabled={uploadingCreateCover}
>
<Upload className="h-4 w-4" />
{uploadingCreateCover ? '上传中...' : '上传封面'}
</Button>
<Button
variant="outline"
onClick={() => void generateCreateCover()}
disabled={generatingCreateCover}
disabled={generatingCreateCover || uploadingCreateCover}
>
<WandSparkles className="h-4 w-4" />
{generatingCreateCover
@@ -2333,6 +2770,57 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空时使用默认文章地址。">
<Input
value={createForm.canonicalUrl}
onChange={(event) =>
setCreateForm((current) => ({ ...current, canonicalUrl: event.target.value }))
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则由前台自动生成。">
<Input
value={createForm.ogImage}
onChange={(event) =>
setCreateForm((current) => ({ ...current, ogImage: event.target.value }))
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug。">
<Textarea
value={createForm.redirectFromText}
onChange={(event) =>
setCreateForm((current) => ({
...current,
redirectFromText: event.target.value,
}))
}
/>
</FormField>
<FormField label="强制跳转目标" hint="可选:创建即作为跳转占位。">
<Input
value={createForm.redirectTo}
onChange={(event) =>
setCreateForm((current) => ({ ...current, redirectTo: event.target.value }))
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.noindex}
onChange={(event) =>
setCreateForm((current) => ({ ...current, noindex: event.target.checked }))
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> noindex</div>
<div className="text-sm text-muted-foreground">
sitemap / RSS
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"

View File

@@ -18,6 +18,10 @@ import {
formatReviewType,
reviewTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = {
@@ -216,7 +220,15 @@ export function ReviewsPage() {
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const result = await adminApi.uploadReviewCoverImage(file)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '评测封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadReviewCoverImage(compressed.file)
startTransition(() => {
setForm((current) => ({ ...current, cover: result.url }))
})
@@ -506,7 +518,7 @@ export function ReviewsPage() {
<input
ref={reviewCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]

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>
}

View File

@@ -83,10 +83,12 @@ function normalizeSettingsResponse(
input: AdminSiteSettingsResponse,
): AdminSiteSettingsResponse {
const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
const searchSynonyms = Array.isArray(input.search_synonyms) ? input.search_synonyms : []
return {
...input,
ai_providers: aiProviders,
search_synonyms: searchSynonyms,
ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
}
@@ -151,6 +153,12 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
mediaR2AccessKeyId: form.media_r2_access_key_id,
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
seoDefaultOgImage: form.seo_default_og_image,
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
notificationWebhookUrl: form.notification_webhook_url,
notificationCommentEnabled: form.notification_comment_enabled,
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
searchSynonyms: form.search_synonyms,
}
}
@@ -587,6 +595,94 @@ export function SiteSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SEO</CardTitle>
<CardDescription>
OG Twitter Webhook
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
<Input
value={form.seo_default_og_image ?? ''}
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
/>
</Field>
<Field label="Twitter / X Handle" hint="例如 @initcool。">
<Input
value={form.seo_default_twitter_handle ?? ''}
onChange={(event) =>
updateField('seo_default_twitter_handle', event.target.value)
}
/>
</Field>
<div className="lg:col-span-2">
<Field label="Webhook URL" hint="评论和友链申请会向这个地址推送 JSON。">
<Input
value={form.notification_webhook_url ?? ''}
onChange={(event) =>
updateField('notification_webhook_url', event.target.value)
}
/>
</Field>
</div>
<div className="lg:col-span-2 grid gap-4 md:grid-cols-2">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.notification_comment_enabled}
onChange={(event) =>
updateField('notification_comment_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Webhook
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.notification_friend_link_enabled}
onChange={(event) =>
updateField('notification_friend_link_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Webhook
</p>
</div>
</label>
</div>
<div className="lg:col-span-2">
<Field
label="搜索同义词"
hint="每行一组逗号分隔。例如ai, llm, gpt 或 rust, cargo, crates。"
>
<Textarea
value={form.search_synonyms.join('\n')}
onChange={(event) =>
updateField(
'search_synonyms',
event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
)
}
/>
</Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>

View File

@@ -0,0 +1,499 @@
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } 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 { Label } from '@/components/ui/label'
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 type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'discord', label: 'Discord Webhook' },
{ value: 'telegram', label: 'Telegram Bot API' },
{ value: 'ntfy', label: 'ntfy' },
] as const
const DEFAULT_FILTERS = {
event_types: ['post.published', 'digest.weekly', 'digest.monthly'],
}
function prettyJson(value: unknown) {
if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0)) {
return ''
}
return JSON.stringify(value, null, 2)
}
function emptyForm() {
return {
channelType: 'email',
target: '',
displayName: '',
status: 'active',
notes: '',
filtersText: prettyJson(DEFAULT_FILTERS),
metadataText: '',
}
}
function parseOptionalJson(label: string, raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
try {
return JSON.parse(trimmed) as Record<string, unknown>
} catch {
throw new Error(`${label} 不是合法 JSON`)
}
}
function normalizePreview(value: unknown) {
const text = prettyJson(value)
return text || '—'
}
export function SubscriptionsPage() {
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
const [actioningId, setActioningId] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState(emptyForm())
const loadData = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const [nextSubscriptions, nextDeliveries] = await Promise.all([
adminApi.listSubscriptions(),
adminApi.listSubscriptionDeliveries(),
])
startTransition(() => {
setSubscriptions(nextSubscriptions)
setDeliveries(nextDeliveries)
})
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)
}
}, [])
useEffect(() => {
void loadData(false)
}, [loadData])
const activeCount = useMemo(
() => subscriptions.filter((item) => item.status === 'active').length,
[subscriptions],
)
const queuedOrRetryCount = useMemo(
() => deliveries.filter((item) => item.status === 'queued' || item.status === 'retry_pending').length,
[deliveries],
)
const resetForm = useCallback(() => {
setEditingId(null)
setForm(emptyForm())
}, [])
const submitForm = useCallback(async () => {
try {
setSubmitting(true)
const payload = {
channelType: form.channelType,
target: form.target,
displayName: form.displayName || null,
status: form.status,
notes: form.notes || null,
filters: parseOptionalJson('filters', form.filtersText),
metadata: parseOptionalJson('metadata', form.metadataText),
}
if (editingId) {
await adminApi.updateSubscription(editingId, payload)
toast.success('订阅目标已更新。')
} else {
await adminApi.createSubscription(payload)
toast.success('订阅目标已创建。')
}
resetForm()
await loadData(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存订阅失败。')
} finally {
setSubmitting(false)
}
}, [editingId, form, loadData, resetForm])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[640px] 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"> / / Digest</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Webhook / Discord / Telegram / ntfy retry pending
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="secondary"
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('weekly')
const result = await adminApi.sendSubscriptionDigest('weekly')
toast.success(`周报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
} finally {
setDigesting(null)
}
}}
>
<Send className="h-4 w-4" />
{digesting === 'weekly' ? '入队中...' : '发送周报'}
</Button>
<Button
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('monthly')
const result = await adminApi.sendSubscriptionDigest('monthly')
toast.success(`月报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
} finally {
setDigesting(null)
}
}}
>
<BellRing className="h-4 w-4" />
{digesting === 'monthly' ? '入队中...' : '发送月报'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.98fr_1.02fr]">
<Card>
<CardHeader>
<CardTitle>{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'}</CardTitle>
<CardDescription>
{subscriptions.length} {activeCount} / {queuedOrRetryCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select
value={form.channelType}
onChange={(event) => setForm((current) => ({ ...current, channelType: event.target.value }))}
>
{CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
placeholder={form.channelType === 'email' ? 'name@example.com' : 'https://...'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.displayName}
onChange={(event) =>
setForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="例如 站长邮箱 / Discord 运维群"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select
value={form.status}
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="pending">pending</option>
<option value="unsubscribed">unsubscribed</option>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.notes}
onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))}
placeholder="用途、机器人说明、负责人等"
/>
</div>
</div>
<div className="space-y-2">
<Label>filtersJSON</Label>
<Textarea
value={form.filtersText}
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
placeholder='{"event_types":["post.published","digest.weekly"]}'
className="min-h-32 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>metadataJSON</Label>
<Textarea
value={form.metadataText}
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
placeholder='{"owner":"ops","source":"manual"}'
className="min-h-28 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
{editingId ? (
<Button variant="outline" disabled={submitting} onClick={resetForm}>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> filters / metadata</CardDescription>
</div>
<Badge variant="outline">{subscriptions.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{item.channel_type}
</div>
</div>
</TableCell>
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
<div>{item.target}</div>
<div className="mt-1 text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
{item.status}
</Badge>
<div className="text-xs text-muted-foreground">
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
{normalizePreview(item.filters)}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.testSubscription(item.id)
toast.success('测试通知已入队。')
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> attempts / next retry / response</CardDescription>
</div>
<Badge variant="outline">{deliveries.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell>
<div className="font-medium">{item.event_type}</div>
<div className="text-xs text-muted-foreground">#{item.subscription_id ?? '—'}</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{item.channel_type}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">{item.target}</div>
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === 'sent' ? 'success' : item.status === 'retry_pending' ? 'warning' : 'secondary'}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>attempts: {item.attempts_count}</div>
<div>next: {item.next_retry_at ?? '—'}</div>
</TableCell>
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
{item.response_text ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}