enhance admin leaderboard and previews

This commit is contained in:
Administrator committed 2026-06-05 01:51:48 +08:00
1 parent a5897caec4
commit 92f5cb8fe3
11 files changed
+937 -282

No files matched your search

@@ -25,7 +25,7 @@ import {
Users,
type LucideIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
@@ -49,19 +50,22 @@ import type {
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: {
soft: "bg-[#EEF2FF] dark:bg-indigo-400/15",
text: "text-[#4F46E5] dark:text-indigo-200",
bar: "bg-[#4F46E5]",
border: "border-[#C7D2FE] dark:border-indigo-300/25",
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#C1121F]",
border: "border-[#F1D6D6] dark:border-[#462123]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -83,7 +87,7 @@ const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar
},
};
const RANK_COLORS = ["#4F46E5", "#0F766E", "#F59E0B", "#E11D48", "#7C3AED"] as const;
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
function toDateInput(date: Date): string {
const year = date.getFullYear();
@@ -241,7 +245,7 @@ function StatCard({
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#06213F]", color.border)}>
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", 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)}>
@@ -260,21 +264,47 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
const activeUsers = Math.max(1, overview.active_users);
const totalRuns = Math.max(1, overview.total_runs);
const items = [
["人均使用", overview.total_runs / activeUsers, "次/活跃用户"],
["问答密度", overview.total_questions / totalRuns, "问答/运行"],
["平均 Token", overview.total_tokens / totalRuns, "Token/运行"],
["技能覆盖", overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0, "%"],
["失败率", (overview.error_runs / totalRuns) * 100, "%"],
{
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(([label, value, unit]) => (
<div key={label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(value).toFixed(unit === "%" || String(value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{unit}</span>
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
</div>
))}
</section>
@@ -363,13 +393,13 @@ function PodiumCard({
}) {
const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
2: "border-[#D2E3FC] bg-[#E8F0FE] text-[#4285F4] dark:border-[#4285F4]/40 dark:bg-[#4285F4]/10 dark:text-[#8AB4F8]",
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-[#06213F]/70">
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#462123] dark:bg-[#230D0F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
@@ -381,8 +411,8 @@ function PodiumCard({
type="button"
onClick={() => onSelect(user)}
className={cn(
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#06213F]",
active ? "border-[#4285F4] ring-2 ring-[#D2E3FC] dark:border-[#8AB4F8] dark:ring-[#4285F4]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#230D0F]",
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]",
)}
>
<div className="flex items-start justify-between gap-3">
@@ -393,11 +423,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#4285F4] text-base font-semibold text-white shadow-sm">
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] 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-[#4285F4] dark:text-[#8AB4F8]">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{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>
@@ -408,7 +438,7 @@ function PodiumCard({
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendPoint[]; compact?: boolean }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, "");
@@ -418,10 +448,10 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const visible = points.slice(-32);
const series = [
{ key: "runs", label: "运行", color: "#4F46E5", soft: "bg-[#EEF2FF] text-[#4F46E5]" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]" },
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" },
] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
@@ -452,9 +482,11 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
visible[0],
);
const displayedSeries = metric === "all" ? series : [activeSeries];
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
const activeDays = visible.filter((point) => point.runs > 0 || point.questions > 0 || point.tokens > 0).length;
const chart = (large = false) => (
<div className={cn("overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : "h-72")} role="img" aria-label="使用趋势折线图">
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : compact ? "h-56" : "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" />
@@ -471,7 +503,7 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#17395F]"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
@@ -526,8 +558,61 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</div>
);
if (compact) {
return (
<div className="min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-4">
{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:border-[#C1121F]",
metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
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="mt-2 block truncate text-base font-semibold tabular-nums text-[#231815] dark:text-white">
{fmtNumber(totalFor(item.key))}
</span>
<span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Maximize2 className="mr-1 size-4" />
放大
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(1180px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>个人使用趋势</DialogTitle>
<DialogDescription>
每个折线点代表一天。运行=发起次数,问答=有效提问,活跃=有行为的用户数,Token=模型输入输出消耗。
</DialogDescription>
</DialogHeader>
{chart(true)}
</DialogContent>
</Dialog>
</div>
{chart(false)}
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button
@@ -583,13 +668,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</Dialog>
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#17395F] dark:bg-[#071F3B]">
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {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-[#0F766E]" : "text-[#E11D48]")}>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -616,7 +701,7 @@ function UserTable({
}
return (
<div className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
{users.map((user, index) => {
const score = userScore(user, tab);
return (
@@ -625,11 +710,11 @@ function UserTable({
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#EEF0F1] px-4 py-3 text-left last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]",
selectedUserId === user.user_id && "bg-[#E8F0FE] dark:bg-[#4285F4]/10",
"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 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10",
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#F1F3F4] text-sm font-semibold text-[#5F6368] dark:bg-[#11345E] dark:text-slate-200">
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
{index + 1}
</div>
<div className="min-w-0">
@@ -642,9 +727,9 @@ function UserTable({
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full bg-[#4285F4]"
className="h-2 rounded-full bg-[#C1121F]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
@@ -678,7 +763,7 @@ function SkillRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<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" />
@@ -698,7 +783,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -736,7 +821,7 @@ function DimensionRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<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" />
@@ -756,7 +841,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -790,8 +875,8 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -799,7 +884,7 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
@@ -881,16 +966,13 @@ function UserDetailPanel({
</div>
</section>
<Tabs defaultValue="skills" className="space-y-3">
<Tabs defaultValue="questions" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
@@ -899,7 +981,10 @@ function UserDetailPanel({
<TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势
</div>
<TrendBars points={data.trends} />
<TrendBars points={data.trends} compact />
</TabsContent>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3">
@@ -920,14 +1005,23 @@ export default function AdminUserLeaderboardPage() {
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false);
const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen });
const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL);
const [reportText, setReportText] = useState("");
const [reportError, setReportError] = useState<string | null>(null);
const [reportStatus, setReportStatus] = useState<"idle" | "streaming" | "done" | "cached" | "error">("idle");
const [reportGeneratedAt, setReportGeneratedAt] = useState<string | null>(null);
const [reportResolvedModel, setReportResolvedModel] = useState<string | null>(null);
const reportAbortRef = useRef<AbortController | null>(null);
const reportGenerating = reportStatus === "streaming";
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -964,6 +1058,92 @@ export default function AdminUserLeaderboardPage() {
setRange(defaultRange(days));
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
reportAbortRef.current?.abort();
reportAbortRef.current = null;
}
};
const startReportGeneration = async () => {
reportAbortRef.current?.abort();
const controller = new AbortController();
reportAbortRef.current = controller;
const selectedModelName = reportModelName === DEFAULT_REPORT_MODEL ? undefined : reportModelName;
let terminalEventSeen = false;
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(selectedModelName ?? null);
setReportStatus("streaming");
try {
await streamUserLeaderboardReport({
range: apiRange,
limit: 40,
modelName: selectedModelName,
signal: controller.signal,
onEvent: (event) => {
if (event.event === "meta") {
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
return;
}
if (event.event === "cached") {
terminalEventSeen = true;
setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus("cached");
return;
}
if (event.event === "text") {
setReportText((value) => value + event.data.chunk);
return;
}
if (event.event === "done") {
terminalEventSeen = true;
if (event.data.report) setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus(event.data.cached ? "cached" : "done");
return;
}
if (event.event === "error") {
terminalEventSeen = true;
setReportError(event.data.message);
setReportStatus("error");
}
},
});
if (!terminalEventSeen && !controller.signal.aborted) {
setReportStatus("done");
}
} catch (err) {
if (!controller.signal.aborted) {
setReportError(err instanceof Error ? err.message : String(err));
setReportStatus("error");
}
} finally {
if (reportAbortRef.current === controller) {
reportAbortRef.current = null;
}
}
};
useEffect(() => {
return () => reportAbortRef.current?.abort();
}, []);
useEffect(() => {
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(null);
setReportStatus("idle");
}, [apiRange.since, apiRange.until, reportModelName]);
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
@@ -1010,21 +1190,11 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!data || reportMutation.isPending}
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
className="h-9"
>
{reportMutation.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Sparkles className="mr-1 size-4" />
)}
生成报告
<Button variant="outline" size="sm" disabled={!data} className="h-9">
<Sparkles className="mr-1 size-4" />
AI 报告
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
@@ -1034,32 +1204,61 @@ export default function AdminUserLeaderboardPage() {
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
<SelectValue placeholder="选择报告模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_REPORT_MODEL}>默认模型</SelectItem>
{reportModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
{reportGenerating ? "生成中" : "生成报告"}
</Button>
</div>
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportMutation.isPending && (
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div>
)}
{reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告
正在调用大模型生成报告,内容会流式显示
</div>
)}
{reportMutation.error && (
{reportError && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-3 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
生成失败:{reportMutation.error.message}
生成失败:{reportError}
</div>
)}
{reportMutation.data?.report && (
{reportText && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report}
{reportText}
</pre>
)}
</ScrollArea>
{reportMutation.data?.report && (
{reportText && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report,
reportText,
"text/markdown;charset=utf-8",
)
}
@@ -1079,7 +1278,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<div className="flex flex-col gap-1">
@@ -1090,7 +1289,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1124,21 +1323,29 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="indigo" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<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)} 次`} />
</div>
<InsightStrip overview={overview} />
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setShowGroupStats((value) => !value)}>
<Users className="mr-1 size-4" />
{showGroupStats ? "隐藏分组统计" : "显示分组统计"}
</Button>
</div>
{showGroupStats && (
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
)}
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1177,7 +1384,7 @@ export default function AdminUserLeaderboardPage() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]"
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
/>
</div>
</div>
@@ -1189,8 +1396,8 @@ export default function AdminUserLeaderboardPage() {
/>
</section>
<section className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center gap-2 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
@@ -1206,7 +1413,7 @@ export default function AdminUserLeaderboardPage() {
</section>
</div>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<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" />
@@ -39,6 +39,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
@@ -953,10 +954,8 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
)}
{activeTabMeta?.kind === "html" ? (
htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
<HtmlPreviewFrame
html={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
@@ -18,6 +18,7 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
@@ -100,22 +101,6 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
/**
* Sandboxed preview of a generated HTML document. The iframe has no
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
* of the server-side sanitization that already stripped script vectors.
*/
function HtmlPreviewFrame({ html }: { html: string }) {
return (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={html}
className="h-[70vh] w-full rounded-md border bg-white"
/>
);
}
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */