Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
This commit is contained in:
@@ -114,3 +114,14 @@ export function formatWorkerProgress(
|
||||
|
||||
return progress.message ?? (details || null);
|
||||
}
|
||||
|
||||
export function getWorkerProgressPercent(
|
||||
job: Pick<WorkerJobRecord, "result">,
|
||||
): number | null {
|
||||
const progress = getWorkerProgress(job);
|
||||
if (typeof progress?.percent !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress.percent)));
|
||||
}
|
||||
|
||||
@@ -10,15 +10,22 @@ import {
|
||||
Star,
|
||||
Tags,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
} from "lucide-react";
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTime } from "@/lib/admin-format";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,9 +33,9 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
} from "@/components/ui/table";
|
||||
import { adminApi, ApiError } from "@/lib/api";
|
||||
import { buildFrontendUrl } from "@/lib/frontend-url";
|
||||
import {
|
||||
formatCommentScope,
|
||||
formatPostStatus,
|
||||
@@ -37,8 +44,12 @@ import {
|
||||
formatPostVisibility,
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
} from '@/lib/admin-format'
|
||||
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
||||
} from "@/lib/admin-format";
|
||||
import type {
|
||||
AdminAnalyticsResponse,
|
||||
AdminDashboardResponse,
|
||||
WorkerOverview,
|
||||
} from "@/lib/types";
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -46,17 +57,21 @@ function StatCard({
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
note: string
|
||||
icon: typeof Rss
|
||||
label: string;
|
||||
value: number;
|
||||
note: string;
|
||||
icon: typeof Rss;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="flex items-start justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">
|
||||
{value}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
@@ -64,75 +79,81 @@ function StatCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatAiSourceLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'chatgpt-search':
|
||||
return 'ChatGPT Search'
|
||||
case 'perplexity':
|
||||
return 'Perplexity'
|
||||
case 'copilot-bing':
|
||||
return 'Copilot / Bing'
|
||||
case 'gemini':
|
||||
return 'Gemini'
|
||||
case 'claude':
|
||||
return 'Claude'
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'duckduckgo':
|
||||
return 'DuckDuckGo'
|
||||
case 'kagi':
|
||||
return 'Kagi'
|
||||
case 'direct':
|
||||
return 'Direct'
|
||||
case "chatgpt-search":
|
||||
return "ChatGPT Search";
|
||||
case "perplexity":
|
||||
return "Perplexity";
|
||||
case "copilot-bing":
|
||||
return "Copilot / Bing";
|
||||
case "gemini":
|
||||
return "Gemini";
|
||||
case "claude":
|
||||
return "Claude";
|
||||
case "google":
|
||||
return "Google";
|
||||
case "duckduckgo":
|
||||
return "DuckDuckGo";
|
||||
case "kagi":
|
||||
return "Kagi";
|
||||
case "direct":
|
||||
return "Direct";
|
||||
default:
|
||||
return value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null);
|
||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
|
||||
null,
|
||||
);
|
||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
||||
adminApi.dashboard(),
|
||||
adminApi.getWorkersOverview(),
|
||||
adminApi.analytics(),
|
||||
])
|
||||
]);
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
setWorkerOverview(nextWorkerOverview)
|
||||
setAnalytics(nextAnalytics)
|
||||
})
|
||||
setData(next);
|
||||
setWorkerOverview(nextWorkerOverview);
|
||||
setAnalytics(nextAnalytics);
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.success('仪表盘已刷新。')
|
||||
toast.success("仪表盘已刷新。");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : "无法加载仪表盘。",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard(false)
|
||||
}, [loadDashboard])
|
||||
void loadDashboard(false);
|
||||
}, [loadDashboard]);
|
||||
|
||||
if (loading || !data || !workerOverview || !analytics) {
|
||||
return (
|
||||
@@ -147,24 +168,24 @@ export function DashboardPage() {
|
||||
<Skeleton className="h-[420px] rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: '文章总数',
|
||||
label: "文章总数",
|
||||
value: data.stats.total_posts,
|
||||
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||
icon: Rss,
|
||||
},
|
||||
{
|
||||
label: '待审核评论',
|
||||
label: "待审核评论",
|
||||
value: data.stats.pending_comments,
|
||||
note: '等待审核处理',
|
||||
note: "等待审核处理",
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: '发布待办',
|
||||
label: "发布待办",
|
||||
value:
|
||||
data.stats.draft_posts +
|
||||
data.stats.scheduled_posts +
|
||||
@@ -174,30 +195,32 @@ export function DashboardPage() {
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: '分类数量',
|
||||
label: "分类数量",
|
||||
value: data.stats.total_categories,
|
||||
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
label: 'AI 分块',
|
||||
label: "AI 分块",
|
||||
value: data.stats.ai_chunks,
|
||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||
note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
{
|
||||
label: 'Worker 活动',
|
||||
label: "Worker 活动",
|
||||
value: workerOverview.active_jobs,
|
||||
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
|
||||
icon: Workflow,
|
||||
},
|
||||
]
|
||||
];
|
||||
const aiTrafficShare =
|
||||
analytics.content_overview.page_views_last_7d > 0
|
||||
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100
|
||||
: 0
|
||||
const topAiSource = analytics.ai_referrers_last_7d[0]
|
||||
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length
|
||||
? (analytics.ai_discovery_page_views_last_7d /
|
||||
analytics.content_overview.page_views_last_7d) *
|
||||
100
|
||||
: 0;
|
||||
const topAiSource = analytics.ai_referrers_last_7d[0];
|
||||
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -207,14 +230,15 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">运营总览</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={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
打开 AI 问答
|
||||
</a>
|
||||
@@ -225,7 +249,7 @@ export function DashboardPage() {
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
{refreshing ? "刷新中..." : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,9 +265,7 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近文章</CardTitle>
|
||||
<CardDescription>
|
||||
最近同步到前台的文章内容。
|
||||
</CardDescription>
|
||||
<CardDescription>最近同步到前台的文章内容。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||
</CardHeader>
|
||||
@@ -265,9 +287,13 @@ export function DashboardPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{post.title}</span>
|
||||
{post.pinned ? <Badge variant="success">置顶</Badge> : null}
|
||||
{post.pinned ? (
|
||||
<Badge variant="success">置顶</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{post.slug}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
@@ -275,12 +301,18 @@ export function DashboardPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
|
||||
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
|
||||
<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>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{post.created_at}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -291,19 +323,19 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点状态</CardTitle>
|
||||
<CardDescription>
|
||||
快速查看前台站点与 AI 索引状态。
|
||||
</CardDescription>
|
||||
<CardDescription>快速查看前台站点与 AI 索引状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.site.site_name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data.site.site_url}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
||||
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
||||
<Badge variant={data.site.ai_enabled ? "success" : "warning"}>
|
||||
{data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +346,9 @@ export function DashboardPage() {
|
||||
评测
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||
<span className="text-3xl font-semibold">
|
||||
{data.stats.total_reviews}
|
||||
</span>
|
||||
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +357,9 @@ export function DashboardPage() {
|
||||
友链
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
||||
<span className="text-3xl font-semibold">
|
||||
{data.stats.total_links}
|
||||
</span>
|
||||
<Tags className="mb-1 h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,25 +371,35 @@ export function DashboardPage() {
|
||||
</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-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-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-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-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>
|
||||
<Badge variant="outline">
|
||||
不公开 {data.stats.unlisted_posts}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -362,7 +408,9 @@ export function DashboardPage() {
|
||||
最近一次 AI 索引
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
||||
{data.site.ai_last_indexed_at
|
||||
? formatDateTime(data.site.ai_last_indexed_at)
|
||||
: "站点还没有建立过索引。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -372,9 +420,13 @@ export function DashboardPage() {
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
GEO / AI 来源概览
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{analytics.ai_discovery_page_views_last_7d}</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{analytics.ai_discovery_page_views_last_7d}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
|
||||
最近 7 天来自 ChatGPT
|
||||
Search、Perplexity、Copilot/Bing、Gemini、Claude
|
||||
的页面访问。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
@@ -384,23 +436,41 @@ export function DashboardPage() {
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">访问占比</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">基于近 7 天全部 page_view</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">最高来源</div>
|
||||
<div className="mt-2 text-base font-semibold">
|
||||
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
访问占比
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">
|
||||
{Math.round(aiTrafficShare)}%
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
|
||||
基于近 7 天全部 page_view
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">已识别来源</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">当前已聚合的 AI 搜索渠道</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最高来源
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold">
|
||||
{topAiSource
|
||||
? formatAiSourceLabel(topAiSource.referrer)
|
||||
: "暂无"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{topAiSource
|
||||
? `${topAiSource.count} 次访问`
|
||||
: "等待来源数据"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
已识别来源
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">
|
||||
{totalAiSourceBuckets}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
当前已聚合的 AI 搜索渠道
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,15 +478,24 @@ export function DashboardPage() {
|
||||
<div className="mt-4 space-y-3">
|
||||
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
|
||||
const width = `${Math.max(
|
||||
(item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100,
|
||||
(item.count /
|
||||
Math.max(
|
||||
analytics.ai_discovery_page_views_last_7d,
|
||||
1,
|
||||
)) *
|
||||
100,
|
||||
8,
|
||||
)}%`
|
||||
)}%`;
|
||||
|
||||
return (
|
||||
<div key={item.referrer} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span>
|
||||
<span className="text-muted-foreground">{item.count}</span>
|
||||
<span className="font-medium">
|
||||
{formatAiSourceLabel(item.referrer)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
@@ -425,11 +504,13 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-muted-foreground">最近 7 天还没有识别到 AI 搜索来源流量。</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
最近 7 天还没有识别到 AI 搜索来源流量。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -440,12 +521,17 @@ export function DashboardPage() {
|
||||
Worker 健康
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。
|
||||
当前排队 {workerOverview.queued}、运行{" "}
|
||||
{workerOverview.running}、失败 {workerOverview.failed}。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
|
||||
to={
|
||||
workerOverview.failed > 0
|
||||
? "/workers?status=failed"
|
||||
: "/workers"
|
||||
}
|
||||
data-testid="dashboard-worker-open"
|
||||
>
|
||||
查看队列
|
||||
@@ -459,24 +545,36 @@ export function DashboardPage() {
|
||||
data-testid="dashboard-worker-card-queued"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Queued
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.queued}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=running"
|
||||
data-testid="dashboard-worker-card-running"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Running
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.running}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=failed"
|
||||
data-testid="dashboard-worker-card-failed"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Failed
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.failed}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -489,11 +587,17 @@ export function DashboardPage() {
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.worker_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<div>Q {item.queued} · R {item.running}</div>
|
||||
<div>
|
||||
Q {item.queued} · R {item.running}
|
||||
</div>
|
||||
<div>ERR {item.failed}</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -510,11 +614,11 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>待审核评论</CardTitle>
|
||||
<CardDescription>
|
||||
在当前管理端直接查看审核队列。
|
||||
</CardDescription>
|
||||
<CardDescription>在当前管理端直接查看审核队列。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||
<Badge variant="warning">
|
||||
{data.pending_comments.length} 条待处理
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -543,7 +647,9 @@ export function DashboardPage() {
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{comment.post_slug}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{comment.created_at}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -556,11 +662,11 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>待审核友链</CardTitle>
|
||||
<CardDescription>
|
||||
等待审核和互链确认的申请。
|
||||
</CardDescription>
|
||||
<CardDescription>等待审核和互链确认的申请。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
||||
<Badge variant="warning">
|
||||
{data.pending_friend_links.length} 条待处理
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.pending_friend_links.map((link) => (
|
||||
@@ -591,9 +697,7 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近评测</CardTitle>
|
||||
<CardDescription>
|
||||
最近同步到前台评测页的内容。
|
||||
</CardDescription>
|
||||
<CardDescription>最近同步到前台评测页的内容。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.recent_reviews.map((review) => (
|
||||
@@ -604,11 +708,14 @@ export function DashboardPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
|
||||
{formatReviewType(review.review_type)} ·{" "}
|
||||
{formatReviewStatus(review.status)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">{review.rating}/5</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{review.rating}/5
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{review.review_date}
|
||||
</p>
|
||||
@@ -620,5 +727,5 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ 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 { formatWorkerProgress } from "@/lib/worker-progress";
|
||||
import { formatDateTime } from "@/lib/admin-format";
|
||||
import {
|
||||
formatWorkerProgress,
|
||||
getWorkerProgressPercent,
|
||||
} from "@/lib/worker-progress";
|
||||
import type {
|
||||
AdminSiteSettingsResponse,
|
||||
AiProviderConfig,
|
||||
@@ -2119,7 +2123,9 @@ export function SiteSettingsPage() {
|
||||
最近索引时间
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{form.ai_last_indexed_at ?? "索引尚未建立。"}
|
||||
{form.ai_last_indexed_at
|
||||
? formatDateTime(form.ai_last_indexed_at)
|
||||
: "索引尚未建立。"}
|
||||
</p>
|
||||
{reindexJobId ? (
|
||||
<p className="mt-3 text-xs leading-6 text-muted-foreground">
|
||||
@@ -2133,6 +2139,18 @@ export function SiteSettingsPage() {
|
||||
"任务已经开始,正在等待下一次进度更新。"}
|
||||
</p>
|
||||
) : null}
|
||||
{reindexJobResult &&
|
||||
getWorkerProgressPercent({ result: reindexJobResult }) !==
|
||||
null ? (
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{
|
||||
width: `${getWorkerProgressPercent({ result: reindexJobResult })}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user