- Step1 消息列表头部:深链 taskId 模式取到目的树后,左侧标题换成「策划流程」 彩色层级小图(任务→目的→…→叙事,每级配色对齐流程图节点 + 类型小图标), 点击打开任务目的流程图弹窗;仅第一步、有数据时出现。 - TaskPurposeDialog 流程图:横向(LR)布局 + 右下角缩略图(MiniMap)。 - FlowGraphPanel 深蓝主题适配(随 ThemeContext):容器/头部/画布点阵/连线/图例/ 小地图/节点详情弹窗 + 节点配色(FLOW_NODE_TYPE_META_DARK),浅色红主题不受影响。 - 圆桌侧栏深色兜底:neutral 家族 + 米色 hex(#FAF6F5/#ECE7E4/#E8D0CC) 映射到深蓝 token(关系图背板、第N步 tag 等不再泛白)。 附带提交工作区其余暂存改动(AI 写作等),不含 config.yaml。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
912 lines
41 KiB
TypeScript
912 lines
41 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Activity,
|
||
Award,
|
||
BarChart3,
|
||
Bot,
|
||
CalendarDays,
|
||
Crown,
|
||
Gauge,
|
||
Layers3,
|
||
Loader2,
|
||
Maximize2,
|
||
Medal,
|
||
MessageSquareText,
|
||
MousePointerClick,
|
||
Search,
|
||
Sparkles,
|
||
TrendingUp,
|
||
Users,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Pagination } from "@/components/ui/pagination";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import type {
|
||
AdminLeaderboardDimension,
|
||
AdminLeaderboardOverview,
|
||
AdminLeaderboardResponse,
|
||
AdminLeaderboardSkill,
|
||
AdminLeaderboardTrendPoint,
|
||
AdminLeaderboardUser,
|
||
} from "@/core/admin/api";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
import { usePublicBackground } from "@/core/embed/background";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||
|
||
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
|
||
const LEADERBOARD_PAGE_SIZE = 10;
|
||
// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
|
||
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
|
||
const RANGE_DAYS = 7;
|
||
|
||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||
crimson: {
|
||
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
|
||
text: "text-[#C1121F] dark:text-[#7FACF2]",
|
||
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
|
||
border: "border-[#F1D6D6] dark:border-[#213D5E]",
|
||
},
|
||
rose: {
|
||
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
|
||
text: "text-[#E11D48] dark:text-rose-200",
|
||
bar: "bg-[#E11D48]",
|
||
border: "border-[#FECDD3] dark:border-rose-300/25",
|
||
},
|
||
amber: {
|
||
soft: "bg-[#FFFBEB] dark:bg-amber-300/15",
|
||
text: "text-[#B45309] dark:text-amber-100",
|
||
bar: "bg-[#F59E0B]",
|
||
border: "border-[#FDE68A] dark:border-amber-200/25",
|
||
},
|
||
teal: {
|
||
soft: "bg-[#ECFDF5] dark:bg-teal-300/15",
|
||
text: "text-[#0F766E] dark:text-teal-100",
|
||
bar: "bg-[#0F766E]",
|
||
border: "border-[#A7F3D0] dark:border-teal-200/25",
|
||
},
|
||
};
|
||
|
||
function fmtNumber(value?: number | null): string {
|
||
return Number(value ?? 0).toLocaleString();
|
||
}
|
||
|
||
function fmtTime(value?: string | null): string {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||
}
|
||
|
||
function fmtDate(value?: string | null): string {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
|
||
if (tab === "questions") return user.qa_count;
|
||
if (tab === "tokens") return user.total_tokens;
|
||
if (tab === "skills") return user.skill_call_count;
|
||
if (tab === "efficiency") {
|
||
return user.run_count > 0 ? Math.round((user.qa_count / user.run_count) * 100) : 0;
|
||
}
|
||
return user.run_count + user.tool_call_count;
|
||
}
|
||
|
||
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
|
||
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
|
||
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`;
|
||
if (tab === "skills") return `${fmtNumber(user.skill_call_count)} 次技能`;
|
||
if (tab === "efficiency") {
|
||
return `${(user.run_count > 0 ? user.qa_count / user.run_count : 0).toFixed(1)} 问答/运行`;
|
||
}
|
||
return `${fmtNumber(userScore(user, tab))} 次使用`;
|
||
}
|
||
|
||
function rankTabDescription(tab: UserRankTab): string {
|
||
if (tab === "questions") return "问答排行按用户提出有效问题的运行次数排序。";
|
||
if (tab === "tokens") return "消耗排行按模型输入与输出 Token 总量排序。";
|
||
if (tab === "skills") return "技能排行按带有技能名称的工具调用次数排序。";
|
||
if (tab === "efficiency") return "效率排行 = 问答次数 / 运行次数,用来衡量每次运行承载的问答密度。";
|
||
return "使用排行按运行次数与工具调用次数合计排序。";
|
||
}
|
||
|
||
function initials(user: AdminLeaderboardUser): string {
|
||
const source = user.email || user.user_id || "?";
|
||
return source.slice(0, 1).toUpperCase();
|
||
}
|
||
|
||
async function fetchPublicLeaderboard(days: number): Promise<AdminLeaderboardResponse> {
|
||
const res = await fetch(
|
||
`${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=100`,
|
||
{ headers: { Accept: "application/json" } },
|
||
);
|
||
if (!res.ok) {
|
||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||
throw new Error(err.detail ?? `加载排行榜失败 (HTTP ${res.status})`);
|
||
}
|
||
return (await res.json()) as AdminLeaderboardResponse;
|
||
}
|
||
|
||
export default function PublicLeaderboardPage() {
|
||
const [data, setData] = useState<AdminLeaderboardResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [tab, setTab] = useState<UserRankTab>("activity");
|
||
const [query, setQuery] = useState("");
|
||
const [userPage, setUserPage] = useState(1);
|
||
const days = RANGE_DAYS;
|
||
// 被第三方 iframe 嵌套时,允许嵌套方通过 ?bg=/?text= 或 postMessage 改背景/文字色。
|
||
const pageBg = usePublicBackground();
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
fetchPublicLeaderboard(days)
|
||
.then((res) => {
|
||
if (!cancelled) {
|
||
setData(res);
|
||
setError(null);
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [days]);
|
||
|
||
const pending = data?.status === "running" || data?.status === "refreshing";
|
||
|
||
// The snapshot may still be computing on first load — poll briefly until ready.
|
||
useEffect(() => {
|
||
if (!pending) return;
|
||
const timer = setInterval(() => {
|
||
fetchPublicLeaderboard(days)
|
||
.then((res) => setData(res))
|
||
.catch(() => undefined);
|
||
}, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [pending, days]);
|
||
|
||
const rankedUsers = useMemo(() => {
|
||
const base = data?.users_by_activity ?? [];
|
||
const source =
|
||
tab === "questions"
|
||
? data?.users_by_questions
|
||
: tab === "tokens"
|
||
? data?.users_by_tokens
|
||
: tab === "skills"
|
||
? [...base].sort((a, b) => b.skill_call_count - a.skill_call_count)
|
||
: tab === "efficiency"
|
||
? [...base].sort((a, b) => userScore(b, "efficiency") - userScore(a, "efficiency"))
|
||
: data?.users_by_activity;
|
||
const needle = query.trim().toLowerCase();
|
||
return (source ?? []).filter((user) =>
|
||
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
|
||
);
|
||
}, [data, query, tab]);
|
||
|
||
const overview = data?.overview;
|
||
const hasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0;
|
||
const topUsers = rankedUsers.slice(0, 3);
|
||
const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE));
|
||
const visibleUsers = useMemo(() => {
|
||
const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE;
|
||
return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE);
|
||
}, [rankedUsers, userPage]);
|
||
|
||
useEffect(() => {
|
||
setUserPage(1);
|
||
}, [query, tab]);
|
||
|
||
useEffect(() => {
|
||
setUserPage((page) => Math.min(page, userPageCount));
|
||
}, [userPageCount]);
|
||
|
||
return (
|
||
<div
|
||
className="flex size-full min-h-0 w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white"
|
||
style={pageBg}
|
||
>
|
||
<ScrollArea className="min-h-0 flex-1">
|
||
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||
{/* 标题区:仅展示,不含任何操作按钮 */}
|
||
<header className="mb-6">
|
||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
|
||
<Award className="size-4" />
|
||
公开榜单
|
||
</div>
|
||
<h1 className="text-2xl font-semibold tracking-normal sm:text-3xl">用户排行榜</h1>
|
||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||
展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗、技能与工具热度、模型与 Agent 使用及近期问题。
|
||
{data?.since && data?.until && (
|
||
<span className="ml-1">
|
||
({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)})
|
||
</span>
|
||
)}
|
||
</p>
|
||
</header>
|
||
|
||
{loading && !data && (
|
||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-sm text-[#5F6368] dark:text-slate-400">
|
||
<Loader2 className="size-5 animate-spin" />
|
||
正在加载排行榜数据
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-5 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
|
||
加载失败:{error}
|
||
</div>
|
||
)}
|
||
|
||
{data && overview && (
|
||
<div className="space-y-6">
|
||
{/* 仅在首次统计、尚无数据时提示;已有数据时后台静默刷新,不再常驻横幅 */}
|
||
{pending && !hasRows && (
|
||
<div className="rounded-lg border border-[#F1D6D6] bg-white px-4 py-3 text-sm text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]">
|
||
<Loader2 className="mr-1 inline size-4 animate-spin" />
|
||
排行榜正在后台统计,数据会自动刷新。
|
||
</div>
|
||
)}
|
||
|
||
{/* 概览指标 */}
|
||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
|
||
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
|
||
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
|
||
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
||
</section>
|
||
|
||
{/* 衍生洞察 */}
|
||
<InsightStrip overview={overview} />
|
||
|
||
{/* 榜单前三 */}
|
||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
|
||
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">{rankTabDescription(tab)}</p>
|
||
</div>
|
||
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
|
||
<TabsList>
|
||
<TabsTrigger value="activity">使用排行</TabsTrigger>
|
||
<TabsTrigger value="questions">问答排行</TabsTrigger>
|
||
<TabsTrigger value="tokens">消耗排行</TabsTrigger>
|
||
<TabsTrigger value="skills">技能排行</TabsTrigger>
|
||
<TabsTrigger value="efficiency">效率排行</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
<div className="grid gap-3 lg:grid-cols-3">
|
||
<PodiumCard user={topUsers[0]} rank={1} tab={tab} />
|
||
<PodiumCard user={topUsers[1]} rank={2} tab={tab} />
|
||
<PodiumCard user={topUsers[2]} rank={3} tab={tab} />
|
||
</div>
|
||
</section>
|
||
|
||
{/* 完整排行榜 */}
|
||
<section className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||
<div>
|
||
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
|
||
<p className="mt-1 text-xs text-[#6B7280] dark:text-slate-400">{rankTabDescription(tab)}</p>
|
||
</div>
|
||
</div>
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
|
||
<Input
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder="搜索用户"
|
||
className="h-9 w-56 bg-white pl-8 dark:bg-[#011226]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<UserTable users={visibleUsers} tab={tab} rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE} />
|
||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
|
||
<span>
|
||
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
|
||
</span>
|
||
<Pagination
|
||
page={userPage}
|
||
pageSize={LEADERBOARD_PAGE_SIZE}
|
||
total={rankedUsers.length}
|
||
onPageChange={setUserPage}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 时间趋势 */}
|
||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||
<h2 className="font-semibold text-[#202124] dark:text-white">时间趋势</h2>
|
||
</div>
|
||
{data.since && data.until && (
|
||
<div className="flex items-center gap-2 text-xs text-[#5F6368] dark:text-slate-400">
|
||
<CalendarDays className="size-3.5" />
|
||
{fmtDate(data.since).slice(0, 10)} 至 {fmtDate(data.until).slice(0, 10)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<TrendBars points={data.trends} />
|
||
</section>
|
||
|
||
{/* 技能与工具热度 */}
|
||
<div className="grid gap-5 xl:grid-cols-2">
|
||
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
|
||
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
|
||
</div>
|
||
|
||
{/* 模型 / Agent / 运行状态 */}
|
||
<div className="grid gap-5 xl:grid-cols-3">
|
||
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
|
||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
|
||
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<footer className="mt-10 border-t border-[#F1D6D6] pt-4 text-center text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
|
||
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
|
||
</footer>
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
icon: Icon,
|
||
label,
|
||
value,
|
||
note,
|
||
accent,
|
||
}: {
|
||
icon: LucideIcon;
|
||
label: string;
|
||
value: string;
|
||
note: string;
|
||
accent: LeaderboardAccent;
|
||
}) {
|
||
const color = accentClasses[accent];
|
||
return (
|
||
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
|
||
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
|
||
<Icon className="size-4" />
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 text-2xl font-semibold tracking-normal text-[#202124] tabular-nums dark:text-white">
|
||
{value}
|
||
</div>
|
||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">{note}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
|
||
const activeUsers = Math.max(1, overview.active_users);
|
||
const totalRuns = Math.max(1, overview.total_runs);
|
||
const items = [
|
||
{
|
||
label: "人均使用",
|
||
value: overview.total_runs / activeUsers,
|
||
unit: "次/活跃用户",
|
||
description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
|
||
},
|
||
{
|
||
label: "问答密度",
|
||
value: overview.total_questions / totalRuns,
|
||
unit: "问答/运行",
|
||
description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
|
||
},
|
||
{
|
||
label: "平均 Token",
|
||
value: overview.total_tokens / totalRuns,
|
||
unit: "Token/运行",
|
||
description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
|
||
},
|
||
{
|
||
label: "技能覆盖",
|
||
value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
|
||
unit: "%",
|
||
description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
|
||
},
|
||
{
|
||
label: "失败率",
|
||
value: (overview.error_runs / totalRuns) * 100,
|
||
unit: "%",
|
||
description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
|
||
},
|
||
];
|
||
return (
|
||
<section className="grid gap-3 md:grid-cols-5">
|
||
{items.map((item) => (
|
||
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
|
||
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
|
||
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
|
||
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
|
||
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
|
||
</div>
|
||
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
|
||
</div>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PodiumCard({
|
||
user,
|
||
rank,
|
||
tab,
|
||
}: {
|
||
user?: AdminLeaderboardUser;
|
||
rank: 1 | 2 | 3;
|
||
tab: UserRankTab;
|
||
}) {
|
||
const styles = {
|
||
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
|
||
2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
|
||
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
|
||
}[rank];
|
||
|
||
if (!user) {
|
||
return (
|
||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
|
||
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
|
||
<div className="text-sm text-[#80868B]">暂无数据</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium", styles)}>
|
||
{rank === 1 ? <Crown className="size-3.5" /> : <Medal className="size-3.5" />}
|
||
第 {rank} 名
|
||
</span>
|
||
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||
</div>
|
||
<div>
|
||
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
|
||
{initials(user)}
|
||
</div>
|
||
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
|
||
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
|
||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||
<span>运行 {fmtNumber(user.run_count)}</span>
|
||
<span>问答 {fmtNumber(user.qa_count)}</span>
|
||
<span>技能 {fmtNumber(user.skill_call_count)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UserTable({
|
||
users,
|
||
tab,
|
||
rankOffset = 0,
|
||
}: {
|
||
users: AdminLeaderboardUser[];
|
||
tab: UserRankTab;
|
||
rankOffset?: number;
|
||
}) {
|
||
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
|
||
if (users.length === 0) {
|
||
return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
{users.map((user, index) => {
|
||
const score = userScore(user, tab);
|
||
return (
|
||
<div
|
||
key={user.user_id}
|
||
className="grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 dark:border-[#213D5E]"
|
||
>
|
||
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
|
||
{rankOffset + index + 1}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium text-[#202124] dark:text-white">{user.email || user.user_id}</span>
|
||
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||
</div>
|
||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||
最近活跃 {fmtTime(user.last_active_at)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||
<div
|
||
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
|
||
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||
会话 {fmtNumber(user.thread_count)} · LLM {fmtNumber(user.llm_call_count)}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="font-semibold text-[#202124] tabular-nums dark:text-white">{scoreLabel(user, tab)}</div>
|
||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||
{fmtNumber(user.total_tokens)} tokens
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkillRanking({
|
||
title,
|
||
icon: Icon,
|
||
items,
|
||
empty,
|
||
}: {
|
||
title: string;
|
||
icon: LucideIcon;
|
||
items: AdminLeaderboardSkill[];
|
||
empty: string;
|
||
}) {
|
||
const max = Math.max(1, ...items.map((item) => item.call_count));
|
||
return (
|
||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||
</div>
|
||
<Badge variant="outline">{items.length} 项</Badge>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||
{items.slice(0, 10).map((item, index) => (
|
||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
|
||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
|
||
<span className="text-xs text-[#5F6368] dark:text-slate-400">
|
||
{fmtNumber(item.user_count)} 人
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||
<div
|
||
className="h-2 rounded-full"
|
||
style={{
|
||
width: `${Math.max(4, (item.call_count / max) * 100)}%`,
|
||
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-sm font-semibold tabular-nums text-[#202124] dark:text-white">
|
||
{fmtNumber(item.call_count)}
|
||
</div>
|
||
<div className="text-xs text-[#5F6368] dark:text-slate-400">
|
||
失败 {fmtNumber(item.error_count)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DimensionRanking({
|
||
title,
|
||
icon: Icon,
|
||
items,
|
||
empty,
|
||
}: {
|
||
title: string;
|
||
icon: LucideIcon;
|
||
items: AdminLeaderboardDimension[];
|
||
empty: string;
|
||
}) {
|
||
const max = Math.max(1, ...items.map((item) => item.count));
|
||
return (
|
||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||
</div>
|
||
<Badge variant="outline">{items.length} 项</Badge>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||
{items.slice(0, 8).map((item, index) => (
|
||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
|
||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
|
||
<span className="text-xs text-[#5F6368] dark:text-slate-400">
|
||
{fmtNumber(item.user_count)} 人
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||
<div
|
||
className="h-2 rounded-full"
|
||
style={{
|
||
width: `${Math.max(4, (item.count / max) * 100)}%`,
|
||
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||
成功 {fmtNumber(item.success_count)} · 失败 {fmtNumber(item.error_count)} · {fmtNumber(item.total_tokens)} tokens
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-sm font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(item.count)}</div>
|
||
<div className="text-xs text-[#5F6368] dark:text-slate-400">次</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
||
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
|
||
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
||
const chartId = useId().replace(/:/g, "");
|
||
if (points.length === 0) {
|
||
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
|
||
}
|
||
|
||
const visible = points.slice(-32);
|
||
const series = [
|
||
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" },
|
||
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" },
|
||
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" },
|
||
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
|
||
] as const;
|
||
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
|
||
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
|
||
const metricMax = Math.max(1, ...visible.map((point) => Number(point[activeMetric] ?? 0)));
|
||
const latest = visible[visible.length - 1];
|
||
const previous = visible[visible.length - 2];
|
||
const latestValue = Number(latest?.[activeMetric] ?? 0);
|
||
const previousValue = Number(previous?.[activeMetric] ?? 0);
|
||
const delta = latestValue - previousValue;
|
||
const width = 920;
|
||
const height = 280;
|
||
const padding = { top: 22, right: 28, bottom: 36, left: 48 };
|
||
const chartWidth = width - padding.left - padding.right;
|
||
const chartHeight = height - padding.top - padding.bottom;
|
||
const xFor = (index: number) =>
|
||
padding.left + (visible.length <= 1 ? chartWidth / 2 : (index / (visible.length - 1)) * chartWidth);
|
||
const yFor = (value: number, max: number) => padding.top + chartHeight - (value / max) * chartHeight;
|
||
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
||
const selectedPath = visible
|
||
.map((point, index) => {
|
||
const value = Number(point[activeMetric] ?? 0);
|
||
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
|
||
})
|
||
.join(" ");
|
||
const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
|
||
const peak = visible.reduce(
|
||
(best, point) => (Number(point[activeMetric] ?? 0) > Number(best[activeMetric] ?? 0) ? point : best),
|
||
visible[0],
|
||
);
|
||
const displayedSeries = metric === "all" ? series : [activeSeries];
|
||
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
|
||
const metricCards = ({ large = false }: { large?: boolean } = {}) => (
|
||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMetric("all")}
|
||
className={cn(
|
||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||
large && "px-4 py-3",
|
||
metric === "all"
|
||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||
)}
|
||
>
|
||
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
|
||
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
|
||
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
|
||
</span>
|
||
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
|
||
</button>
|
||
{series.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => setMetric(item.key)}
|
||
className={cn(
|
||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||
large && "px-4 py-3",
|
||
metric === item.key
|
||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||
)}
|
||
title={item.description}
|
||
>
|
||
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
|
||
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : "text-lg")}>
|
||
{fmtNumber(totalFor(item.key))}
|
||
</span>
|
||
{large && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const analysisDialog = (title: string, description: ReactNode) => (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
<Maximize2 className="mr-1 size-4" />
|
||
放大分析
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-4 overflow-hidden p-5 sm:max-w-none">
|
||
<DialogHeader className="shrink-0 pr-10">
|
||
<DialogTitle>{title}</DialogTitle>
|
||
<DialogDescription>{description}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="shrink-0">{metricCards({ large: true })}</div>
|
||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
|
||
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
|
||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||
</div>
|
||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||
</div>
|
||
</div>
|
||
{chart(true)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
|
||
const chart = (large = false) => (
|
||
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "flex min-h-0 p-5")}>
|
||
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-full min-h-[420px]" : "h-72")} role="img" aria-label="使用趋势折线图">
|
||
<defs>
|
||
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
|
||
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
|
||
</linearGradient>
|
||
</defs>
|
||
{gridLines.map((ratio) => {
|
||
const y = padding.top + chartHeight * ratio;
|
||
return (
|
||
<g key={ratio}>
|
||
<line
|
||
x1={padding.left}
|
||
x2={width - padding.right}
|
||
y1={y}
|
||
y2={y}
|
||
stroke="currentColor"
|
||
className="text-[#E5E7EB] dark:text-[#3F3F46]"
|
||
strokeWidth="1"
|
||
/>
|
||
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
|
||
{fmtNumber(Math.round(metricMax * (1 - ratio)))}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{metric !== "all" && <path d={selectedArea} fill={`url(#trend-area-${chartId}-${activeMetric})`} />}
|
||
{displayedSeries.map((item) => {
|
||
const max = Math.max(1, ...visible.map((point) => Number(point[item.key] ?? 0)));
|
||
const path = visible
|
||
.map((point, index) => {
|
||
const value = Number(point[item.key] ?? 0);
|
||
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, max).toFixed(2)}`;
|
||
})
|
||
.join(" ");
|
||
return (
|
||
<g key={item.key}>
|
||
<path d={path} fill="none" stroke={item.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" opacity={metric === "all" ? 0.9 : 1} />
|
||
{metric !== "all" &&
|
||
visible.map((point, index) => {
|
||
const value = Number(point[item.key] ?? 0);
|
||
const isPeak = point.date === peak.date;
|
||
const isLast = index === visible.length - 1;
|
||
return (
|
||
<circle
|
||
key={`${item.key}-${point.date}`}
|
||
cx={xFor(index)}
|
||
cy={yFor(value, max)}
|
||
r={isPeak || isLast ? 5 : 3}
|
||
fill="#fff"
|
||
stroke={item.color}
|
||
strokeWidth={isPeak || isLast ? "3" : "2"}
|
||
>
|
||
<title>{`${point.date} ${item.label}: ${fmtNumber(value)}`}</title>
|
||
</circle>
|
||
);
|
||
})}
|
||
</g>
|
||
);
|
||
})}
|
||
{visible.map((point, index) => {
|
||
if (index !== 0 && index !== visible.length - 1 && index % Math.ceil(visible.length / 6) !== 0) return null;
|
||
return (
|
||
<text key={point.date} x={xFor(index)} y={height - 12} textAnchor="middle" className="fill-[#6B7280] text-[11px]">
|
||
{point.date.slice(5)}
|
||
</text>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
|
||
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
|
||
</p>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="min-w-0 flex-1">{metricCards()}</div>
|
||
{analysisDialog(
|
||
"时间趋势分析",
|
||
<>当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。</>,
|
||
)}
|
||
</div>
|
||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||
</div>
|
||
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||
</div>
|
||
</div>
|
||
{chart(false)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|