feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
166
admin/src/pages/audit-page.tsx
Normal file
166
admin/src/pages/audit-page.tsx
Normal 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
420
admin/src/pages/revisions-page.tsx
Normal file
420
admin/src/pages/revisions-page.tsx
Normal 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">
|
||||
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 frontmatter / SEO / 生命周期等元信息。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<LabelRow title={comparisonLabel} />
|
||||
<Textarea
|
||||
value={comparisonMarkdown}
|
||||
readOnly
|
||||
className="min-h-[280px] font-mono text-xs leading-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelRow title={`版本 #${selected.item.id}`} />
|
||||
<Textarea
|
||||
value={selected.markdown ?? ''}
|
||||
readOnly
|
||||
className="min-h-[280px] font-mono text-xs leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一个版本,右侧会显示对应快照内容与 Diff 摘要。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelRow({ title }: { title: string }) {
|
||||
return <div className="text-sm font-medium text-foreground">{title}</div>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
499
admin/src/pages/subscriptions-page.tsx
Normal file
499
admin/src/pages/subscriptions-page.tsx
Normal 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>filters(JSON)</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>metadata(JSON,可选)</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user