refine admin leaderboard experience

This commit is contained in:
Administrator committed 2026-06-04 23:41:19 +08:00
1 parent fe0ede178e
commit 88c83b4dd7
13 files changed
+754 -405

No files matched your search

@@ -269,6 +269,14 @@ export function PageSidebar({
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
>
<Settings className="size-4" />
排行榜设置
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
@@ -198,6 +198,16 @@ export function WorkspaceNavMenu() {
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard/settings")
}
>
<Settings2Icon />
排行榜设置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
-10
View File
@@ -176,10 +176,6 @@ export async function getUserLeaderboard(
since: string;
until: string;
days?: number;
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
},
limit = 20,
): Promise<AdminLeaderboardResponse> {
@@ -188,13 +184,7 @@ export async function getUserLeaderboard(
since: range.since,
until: range.until,
limit: String(limit),
include_scheduled: String(range.include_scheduled ?? true),
include_admins: String(range.include_admins ?? true),
include_failed: String(range.include_failed ?? true),
});
for (const userId of range.exclude_user_ids ?? []) {
params.append("exclude_user_ids", userId);
}
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
-8
View File
@@ -20,10 +20,6 @@ export function useUserLeaderboard(
since: string;
until: string;
days?: number;
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
},
limit = 20,
) {
@@ -33,10 +29,6 @@ export function useUserLeaderboard(
"leaderboard",
range.since,
range.until,
range.exclude_user_ids ?? [],
range.include_scheduled ?? true,
range.include_admins ?? true,
range.include_failed ?? true,
limit,
],
queryFn: () => getUserLeaderboard(range, limit),
@@ -35,6 +35,7 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -245,6 +246,20 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard-settings",
label: "排行榜设置",
icon: Settings,
path: "/page/strategy/admin/user-leaderboard/settings",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
@@ -61,6 +61,22 @@ export interface CitationDisplayUpdate {
cleanup_words?: string[];
}
export interface LeaderboardSettings {
exclude_user_ids: string[];
include_scheduled: boolean;
include_admins: boolean;
include_failed: boolean;
cleanup_system_questions: boolean;
}
export interface LeaderboardSettingsUpdate {
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
cleanup_system_questions?: boolean;
}
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/private-square`,
@@ -127,6 +143,36 @@ export async function loadCitationDisplaySettings(): Promise<CitationDisplaySett
return (await response.json()) as CitationDisplaySettings;
}
export async function loadLeaderboardSettings(): Promise<LeaderboardSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/leaderboard`,
);
if (!response.ok) {
throw new Error(`Failed to load leaderboard settings: ${response.status}`);
}
return (await response.json()) as LeaderboardSettings;
}
export async function updateLeaderboardSettings(
body: LeaderboardSettingsUpdate,
): Promise<LeaderboardSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/leaderboard`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update leaderboard settings" }));
throw new Error(detail.detail ?? "Failed to update leaderboard settings");
}
return (await response.json()) as LeaderboardSettings;
}
export async function updateCitationDisplaySettings(
body: CitationDisplayUpdate,
): Promise<CitationDisplaySettings> {
@@ -3,14 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
loadAppearanceSettings,
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
} from "./api";
@@ -19,6 +22,7 @@ const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
export function usePromptPrefixSettings() {
return useQuery({
@@ -102,3 +106,25 @@ export function useUpdateCitationDisplaySettings() {
},
});
}
export function useLeaderboardSettings() {
return useQuery({
queryKey: LEADERBOARD_KEY,
queryFn: () => loadLeaderboardSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateLeaderboardSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: LeaderboardSettingsUpdate) =>
updateLeaderboardSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(LEADERBOARD_KEY, data);
queryClient.invalidateQueries({ queryKey: ["admin-activity", "leaderboard"] });
},
});
}
@@ -9,6 +9,7 @@ import {
Bot,
CalendarDays,
Crown,
FileDown,
Gauge,
Layers3,
Loader2,
@@ -17,13 +18,11 @@ import {
MessageSquareText,
MousePointerClick,
Search,
ShieldOff,
Settings2,
Sparkles,
TrendingUp,
UserRound,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
@@ -41,27 +40,20 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
type StatsConfig = {
include_scheduled: boolean;
include_admins: boolean;
include_failed: boolean;
};
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
@@ -161,6 +153,75 @@ function rankTabDescription(tab: UserRankTab): string {
return "使用排行按运行次数与工具调用次数合计排序。";
}
function escapeCsv(value: string | number): string {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function downloadText(filename: string, content: string, type = "text/plain;charset=utf-8") {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
const header = ["排名", "用户", "角色", "当前分数", "运行次数", "问答次数", "技能调用", "工具调用", "Token", "最近活跃"];
const rows = users.map((user, index) => [
index + 1,
user.email || user.user_id,
user.system_role,
userScore(user, tab),
user.run_count,
user.qa_count,
user.skill_call_count,
user.tool_call_count,
user.total_tokens,
user.last_active_at,
]);
downloadText(
`user-leaderboard-${new Date().toISOString().slice(0, 10)}.csv`,
`\ufeff${[header, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n")}`,
"text/csv;charset=utf-8",
);
}
function exportReport(data: {
overview: AdminLeaderboardOverview;
users: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
questions: AdminLeaderboardQuestion[];
range: RangeState;
}) {
const lines = [
`用户排行榜统计报告`,
`时间范围:${data.range.since} 至 ${data.range.until}`,
``,
`总用户:${fmtNumber(data.overview.total_users)}`,
`活跃用户:${fmtNumber(data.overview.active_users)}`,
`使用次数:${fmtNumber(data.overview.total_runs)}`,
`问答次数:${fmtNumber(data.overview.total_questions)}`,
`Token:${fmtNumber(data.overview.total_tokens)}`,
``,
`Top 用户`,
...data.users.slice(0, 10).map((user, index) => `${index + 1}. ${user.email || user.user_id} - 使用 ${fmtNumber(user.run_count)} / 问答 ${fmtNumber(user.qa_count)} / Token ${fmtNumber(user.total_tokens)}`),
``,
`Top 技能`,
...data.skills.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.call_count)} 次`),
``,
`Top 模型`,
...data.models.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.count)} 次 / Token ${fmtNumber(item.total_tokens)}`),
``,
`近期问题`,
...data.questions.slice(0, 10).map((item, index) => `${index + 1}. ${item.text}`),
];
downloadText(`user-leaderboard-report-${new Date().toISOString().slice(0, 10)}.txt`, lines.join("\n"));
}
function initials(user: AdminLeaderboardUser): string {
const source = user.email || user.user_id || "?";
return source.slice(0, 1).toUpperCase();
@@ -196,6 +257,31 @@ function StatCard({
);
}
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, "%"],
];
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>
<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>
</div>
</div>
))}
</section>
);
}
function PodiumCard({
user,
rank,
@@ -292,98 +378,59 @@ function SpotlightCarousel({
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
return (
<section className="overflow-hidden rounded-lg border border-[#D8DEE9] bg-white shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
<div className="grid lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Crown className="size-4 text-[#C1121F]" />
榜单焦点
</div>
<span className="text-xs text-[#8B6B6B]">自动轮播 Top 5</span>
</div>
<div className="grid gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<button
type="button"
onClick={() => onSelect(active)}
className="min-h-52 bg-[#111827] p-5 text-left text-white dark:bg-[#08111F]"
className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 text-left transition hover:border-[#C1121F] dark:border-[#462123] dark:bg-[#19090A]"
>
<div className="flex items-start justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-medium text-white shadow-sm">
<Crown className="size-3.5" />
榜单焦点
</span>
<span className="rounded-full bg-white/10 px-2 py-1 text-xs text-white/70">
{index + 1} / {visible.length}
</span>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-[minmax(0,1fr)_260px]">
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white">
{initials(active)}
</div>
<div className="min-w-0">
<div className="mb-4 flex size-14 items-center justify-center rounded-xl bg-white text-xl font-semibold text-[#111827] shadow-sm">
{initials(active)}
</div>
<div className="truncate text-2xl font-semibold">{active.email || active.user_id}</div>
<div className="mt-2 text-sm font-medium text-[#A7F3D0]">{scoreLabel(active, tab)}</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-[#2DD4BF]"
style={{ width: `${Math.min(100, Math.max(8, (userScore(active, tab) / maxScore) * 100))}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{[
["运行", active.run_count],
["问答", active.qa_count],
["技能", active.skill_call_count],
["Token", active.total_tokens],
].map(([label, value]) => (
<div key={label} className="rounded-md border border-white/10 bg-white/8 px-3 py-2">
<div className="text-xs text-white/55">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums text-white">{fmtNumber(Number(value))}</div>
</div>
))}
<div className="truncate font-semibold text-[#231815] dark:text-white">{active.email || active.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F]">{scoreLabel(active, tab)}</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#462123]">
<div
className="h-full rounded-full bg-[#C1121F]"
style={{ width: `${Math.min(100, Math.max(8, (userScore(active, tab) / maxScore) * 100))}%` }}
/>
</div>
</button>
<div className="flex flex-col justify-between p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-[#202124] dark:text-white">焦点队列</div>
<div className="text-xs text-[#6B7280] dark:text-slate-400">自动轮播 Top 5</div>
</div>
<div className="space-y-2">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
onClick={() => {
setIndex(itemIndex);
onSelect(user);
}}
className={cn(
"grid w-full grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-[#F8FAFD] dark:hover:bg-[#0B2A50]",
itemIndex === index && "bg-[#EEF2FF] dark:bg-indigo-400/10",
)}
>
<span className="text-xs font-semibold text-[#6B7280]">{itemIndex + 1}</span>
<span className="min-w-0">
<span className="block truncate text-[#202124] dark:text-white">{user.email || user.user_id}</span>
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-[#E5E7EB] dark:bg-[#17395F]">
<span
className="block h-full rounded-full bg-[#4F46E5]"
style={{ width: `${Math.min(100, Math.max(6, (userScore(user, tab) / maxScore) * 100))}%` }}
/>
</span>
</span>
<span className="text-right text-xs font-medium tabular-nums text-[#4F46E5]">{fmtNumber(userScore(user, tab))}</span>
</button>
))}
</div>
<div className="mt-3 flex gap-1">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
aria-label={`切换到第 ${itemIndex + 1} 位用户`}
onClick={() => setIndex(itemIndex)}
className={cn(
"h-1.5 flex-1 rounded-full transition",
itemIndex === index ? "bg-[#4F46E5]" : "bg-[#DADCE0] dark:bg-[#17395F]",
)}
/>
))}
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
onClick={() => {
setIndex(itemIndex);
onSelect(user);
}}
className={cn(
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
itemIndex === index
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#C1121F]">#{itemIndex + 1}</span>
<span className="text-xs font-medium tabular-nums text-[#7A5A5A] dark:text-[#D8B8B8]">{fmtNumber(userScore(user, tab))}</span>
</div>
<div className="mt-1 truncate text-sm font-medium text-[#231815] dark:text-white">{user.email || user.user_id}</div>
</button>
))}
</div>
</div>
</section>
@@ -391,7 +438,8 @@ function SpotlightCarousel({
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const [metric, setMetric] = useState<keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">>("runs");
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]">暂无趋势数据</div>;
@@ -404,12 +452,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
] as const;
const activeSeries = series.find((item) => item.key === metric) ?? series[0];
const metricMax = Math.max(1, ...visible.map((point) => Number(point[metric] ?? 0)));
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?.[metric] ?? 0);
const previousValue = Number(previous?.[metric] ?? 0);
const latestValue = Number(latest?.[activeMetric] ?? 0);
const previousValue = Number(previous?.[activeMetric] ?? 0);
const delta = latestValue - previousValue;
const width = 920;
const height = 280;
@@ -422,20 +471,21 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const gridLines = [0, 0.25, 0.5, 0.75, 1];
const selectedPath = visible
.map((point, index) => {
const value = Number(point[metric] ?? 0);
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[metric] ?? 0) > Number(best[metric] ?? 0) ? point : best),
(best, point) => (Number(point[activeMetric] ?? 0) > Number(best[activeMetric] ?? 0) ? point : best),
visible[0],
);
const displayedSeries = metric === "all" ? series : [activeSeries];
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="使用趋势折线图">
<defs>
<linearGradient id={`trend-area-${chartId}-${metric}`} x1="0" x2="0" y1="0" y2="1">
<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>
@@ -459,24 +509,38 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</g>
);
})}
<path d={selectedArea} fill={`url(#trend-area-${chartId}-${metric})`} />
<path d={selectedPath} fill="none" stroke={activeSeries.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" />
{visible.map((point, index) => {
const value = Number(point[metric] ?? 0);
const isPeak = point.date === peak.date;
const isLast = index === visible.length - 1;
{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 (
<circle
key={`${metric}-${point.date}`}
cx={xFor(index)}
cy={yFor(value, metricMax)}
r={isPeak || isLast ? 5 : 3}
fill="#fff"
stroke={activeSeries.color}
strokeWidth={isPeak || isLast ? "3" : "2"}
>
<title>{`${point.date} ${activeSeries.label}: ${fmtNumber(value)}`}</title>
</circle>
<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) => {
@@ -494,7 +558,22 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button
type="button"
onClick={() => setMetric("all")}
className={cn(
"rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
metric === "all"
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F]">全部趋势</span>
<span className="mt-2 block text-lg font-semibold tabular-nums text-[#231815] dark:text-white">
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span>
</button>
{series.map((item) => (
<button
key={item.key}
@@ -503,8 +582,8 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
className={cn(
"rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
metric === item.key
? "border-[#4F46E5] bg-white shadow-sm dark:border-indigo-300/40 dark:bg-[#0B2A50]"
: "border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#17395F] dark:bg-[#071F3B]",
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
>
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
@@ -525,7 +604,7 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
<DialogHeader>
<DialogTitle>时间趋势分析</DialogTitle>
<DialogDescription>
当前查看 {activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[metric] ?? 0))}。
当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。
</DialogDescription>
</DialogHeader>
{chart(true)}
@@ -534,13 +613,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</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">最新 {activeSeries.label}</div>
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {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]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[metric] ?? 0))}
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
{chart(false)}
@@ -769,9 +848,9 @@ function QuestionPanel({
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-[#F8FAFD] px-3 py-2 dark:bg-[#0B2A50]">
<div className="text-xs text-[#5F6368] dark:text-slate-400">{label}</div>
<div className="mt-1 font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(value)}</div>
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div>
);
}
@@ -808,13 +887,13 @@ function UserDetailPanel({
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-[#202124] dark:text-white">
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
{data.user.email || data.user.user_id}
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
最近活跃 {fmtTime(data.user.last_active_at)}
</div>
</div>
@@ -823,7 +902,7 @@ function UserDetailPanel({
<ArrowRight className="ml-1 size-3.5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#462123] dark:bg-[#19090A]">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
@@ -831,211 +910,34 @@ function UserDetailPanel({
</div>
</section>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
个人使用趋势
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Layers3 className="size-4 text-[#C1121F]" />
个人维度
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<Metric label="模型数" value={data.models.length} />
<Metric label="Agent 数" value={data.agents.length} />
<Metric label="状态数" value={data.run_statuses.length} />
</div>
<TrendBars points={data.trends} />
</section>
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</div>
</ScrollArea>
);
}
function ExclusionSelector({
users,
excludedUserIds,
query,
onQueryChange,
onAdd,
onRemove,
onClear,
}: {
users: UserListItem[];
excludedUserIds: string[];
query: string;
onQueryChange: (value: string) => void;
onAdd: (userId: string) => void;
onRemove: (userId: string) => void;
onClear: () => void;
}) {
const excluded = new Set(excludedUserIds);
const selectedUsers = users.filter((user) => excluded.has(user.id));
const candidates = users
.filter((user) => !excluded.has(user.id))
.filter((user) => {
const needle = query.trim().toLowerCase();
if (!needle) return false;
return `${user.email} ${user.id}`.toLowerCase().includes(needle);
})
.slice(0, 6);
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<ShieldOff className="size-4 text-[#5F6368] dark:text-slate-300" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
名单中的账号不参与当前排行榜、KPI、趋势和问题统计。
</p>
</div>
{excludedUserIds.length > 0 && (
<Button size="sm" variant="outline" onClick={onClear}>
清空名单
</Button>
)}
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-[320px_minmax(0,1fr)]">
<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) => onQueryChange(event.target.value)}
placeholder="搜索账号邮箱或 ID"
className="h-9 bg-white pl-8 dark:bg-[#0B2A50]"
/>
{query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-lg dark:border-[#213D5E] dark:bg-[#06213F]">
<ScrollArea className="max-h-64">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => {
onAdd(user.id);
onQueryChange("");
}}
className="flex w-full items-center justify-between gap-3 border-b border-[#EEF0F1] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]"
>
<span className="min-w-0">
<span className="block truncate font-medium text-[#202124] dark:text-white">{user.email}</span>
<span className="block truncate text-xs text-[#5F6368] dark:text-slate-400">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
))}
</ScrollArea>
</div>
)}
</div>
<div className="flex min-h-9 flex-wrap items-center gap-2 rounded-md bg-[#F8FAFD] px-3 py-2 dark:bg-[#0B2A50]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#5F6368] dark:text-slate-400">暂无排除账号</span>
) : (
selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[260px] items-center gap-1 rounded-full border border-[#D2E3FC] bg-[#E8F0FE] px-2.5 py-1 text-xs text-[#1967D2] dark:border-[#4285F4]/35 dark:bg-[#4285F4]/15 dark:text-[#8AB4F8]"
title={user.email}
>
<span className="truncate">{user.email}</span>
<button type="button" onClick={() => onRemove(user.id)} aria-label={`移除 ${user.email}`}>
<X className="size-3.5" />
</button>
</span>
))
)}
</div>
</div>
</section>
);
}
function StatsRulePanel({
config,
onChange,
excludedCount,
}: {
config: StatsConfig;
onChange: (patch: Partial<StatsConfig>) => void;
excludedCount: number;
}) {
const rules: Array<{
key: keyof StatsConfig;
title: string;
description: string;
}> = [
{
key: "include_scheduled",
title: "统计定时任务问答",
description: "开启后,定时任务触发的运行、问答、Token、模型调用会进入榜单。",
},
{
key: "include_admins",
title: "统计管理员账号",
description: "关闭后,管理员账号不会参与排行榜和整体指标。",
},
{
key: "include_failed",
title: "统计失败运行",
description: "关闭后,失败运行不进入使用、问答、趋势和维度排行。",
},
];
return (
<section className="rounded-lg border border-[#D8DEE9] bg-white p-4 shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<Settings2 className="size-4 text-[#4F46E5]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
当前排除 {fmtNumber(excludedCount)} 个账号,以下开关会影响榜单、趋势、模型、技能和问题统计。
</p>
</div>
<Badge variant="outline">管理员配置</Badge>
</div>
<div className="grid gap-3 md:grid-cols-3">
{rules.map((rule) => (
<label
key={rule.key}
className="flex min-h-28 cursor-pointer flex-col justify-between rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]"
>
<span className="flex items-start justify-between gap-3">
<span>
<span className="block text-sm font-medium text-[#202124] dark:text-white">{rule.title}</span>
<span className="mt-1 block text-xs leading-5 text-[#6B7280] dark:text-slate-400">{rule.description}</span>
</span>
<Switch
checked={config[rule.key]}
onCheckedChange={(checked) => onChange({ [rule.key]: checked } as Partial<StatsConfig>)}
aria-label={rule.title}
/>
</span>
</label>
))}
</div>
</section>
);
}
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [excludeQuery, setExcludeQuery] = useState("");
const [excludedUserIds, setExcludedUserIds] = useState<string[]>([]);
const [statsConfig, setStatsConfig] = useState<StatsConfig>({
include_scheduled: true,
include_admins: true,
include_failed: true,
});
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { data: allUsers = [] } = useAllUsers(true);
const apiRange = useMemo(
() => ({ ...rangeForApi(range), exclude_user_ids: excludedUserIds, ...statsConfig }),
[range, excludedUserIds, statsConfig],
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
@@ -1074,28 +976,18 @@ export default function AdminUserLeaderboardPage() {
setRange(defaultRange(days));
};
const addExcludedUser = (userId: string) => {
setExcludedUserIds((current) =>
current.includes(userId) ? current : [...current, userId],
);
};
const removeExcludedUser = (userId: string) => {
setExcludedUserIds((current) => current.filter((id) => id !== userId));
};
return (
<div className="flex size-full min-h-0 bg-[#F8FAFD] text-[#202124] dark:bg-[#031A33] dark:text-white">
<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">
<div className="border-b border-[#DADCE0] bg-white px-6 py-5 dark:border-[#17395F] dark:bg-[#06213F]">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]">
<Award className="size-4" />
管理员分析
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
@@ -1110,6 +1002,45 @@ export default function AdminUserLeaderboardPage() {
<ArrowLeft className="mr-1 size-4" />
返回系统
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
className="h-9"
>
<Settings2 className="mr-1 size-4" />
排行榜设置
</Button>
<Button
variant="outline"
size="sm"
disabled={!data}
onClick={() => exportUsersCsv(rankedUsers, tab)}
className="h-9"
>
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Button
variant="outline"
size="sm"
disabled={!data || !overview}
onClick={() => {
if (!data || !overview) return;
exportReport({
overview,
users: rankedUsers,
skills: data.skills,
models: data.models,
questions: data.recent_questions,
range,
});
}}
className="h-9"
>
<FileDown className="mr-1 size-4" />
导出报告
</Button>
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
@@ -1143,21 +1074,6 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
<ExclusionSelector
users={allUsers}
excludedUserIds={excludedUserIds}
query={excludeQuery}
onQueryChange={setExcludeQuery}
onAdd={addExcludedUser}
onRemove={removeExcludedUser}
onClear={() => setExcludedUserIds([])}
/>
<StatsRulePanel
config={statsConfig}
excludedCount={excludedUserIds.length}
onChange={(patch) => setStatsConfig((current) => ({ ...current, ...patch }))}
/>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" />
@@ -1179,6 +1095,7 @@ export default function AdminUserLeaderboardPage() {
<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} />
<SpotlightCarousel
users={rankedUsers}
@@ -0,0 +1,204 @@
"use client";
import { ArrowLeft, Save, Search, ShieldOff, SlidersHorizontal, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import {
useLeaderboardSettings,
useUpdateLeaderboardSettings,
} from "@/core/system-settings/hooks";
function userLabel(user?: UserListItem) {
return user?.email || user?.id || "";
}
export default function AdminUserLeaderboardSettingsPage() {
const navigate = useNavigate();
const { data: users = [] } = useAllUsers(true);
const { data: settings, isLoading } = useLeaderboardSettings();
const updateSettings = useUpdateLeaderboardSettings();
const [query, setQuery] = useState("");
const excludedIds = settings?.exclude_user_ids ?? [];
const selectedUsers = useMemo(
() => excludedIds.map((id) => users.find((user) => user.id === id) ?? ({ id, email: id, system_role: "user" } as UserListItem)),
[excludedIds, users],
);
const candidates = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return [];
return users
.filter((user) => !excludedIds.includes(user.id))
.filter((user) => `${user.email} ${user.id}`.toLowerCase().includes(needle))
.slice(0, 8);
}, [excludedIds, query, users]);
const patch = async (body: Record<string, unknown>) => {
try {
await updateSettings.mutateAsync(body);
toast.success("排行榜设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const addUser = (userId: string) => {
void patch({ exclude_user_ids: [...excludedIds, userId] });
setQuery("");
};
const removeUser = (userId: string) => {
void patch({ exclude_user_ids: excludedIds.filter((id) => id !== userId) });
};
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">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]">
<SlidersHorizontal className="size-4" />
管理员设置
</div>
<h1 className="text-2xl font-semibold">排行榜设置</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate("/page/strategy/admin/user-leaderboard")}>
<ArrowLeft className="mr-1 size-4" />
返回排行榜
</Button>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<ShieldOff className="size-4 text-[#C1121F]" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p>
</div>
{excludedIds.length > 0 && (
<Button size="sm" variant="outline" onClick={() => void patch({ exclude_user_ids: [] })}>
清空名单
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[340px_minmax(0,1fr)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#A87575]" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号邮箱或 ID"
className="h-10 border-[#F1D6D6] bg-white pl-9"
/>
{query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]">
<ScrollArea className="max-h-72">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => addUser(user.id)}
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]"
>
<span className="min-w-0">
<span className="block truncate font-medium">{user.email}</span>
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
))}
</ScrollArea>
</div>
)}
</div>
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]"
title={userLabel(user)}
>
<span className="truncate">{userLabel(user)}</span>
<button type="button" onClick={() => removeUser(user.id)} aria-label={`移除 ${userLabel(user)}`}>
<X className="size-3.5" />
</button>
</span>
))}
</div>
)}
</div>
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<Save className="size-4 text-[#C1121F]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p>
</div>
{isLoading && <Badge variant="outline">加载中</Badge>}
</div>
<div className="grid gap-3 md:grid-cols-2">
{[
["include_scheduled", "统计定时任务", "关闭后,定时任务产生的运行、问答、Token、模型调用、最新问题都不统计。"],
["include_admins", "统计管理员账号", "关闭后,管理员账号不参与排行和整体指标。"],
["include_failed", "统计失败运行", "关闭后,失败运行和失败模型调用不进入榜单。"],
["cleanup_system_questions", "隐藏系统标签问题", "开启后,<uploaded_files> 等附件/系统标签不会出现在问题列表。"],
].map(([key, title, desc]) => (
<label
key={key}
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]"
>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span>
</span>
<Switch
checked={Boolean(settings?.[key as keyof typeof settings])}
disabled={!settings || updateSettings.isPending}
onCheckedChange={(checked) => void patch({ [key]: checked })}
aria-label={title}
/>
</label>
))}
</div>
</section>
</div>
</ScrollArea>
</main>
</div>
);
}
+11
View File
@@ -25,6 +25,7 @@ import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -255,6 +256,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/user-leaderboard/settings"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
@@ -14,6 +14,7 @@ methods' ``user_id`` parameter is designed for.
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
@@ -29,6 +30,7 @@ from app.gateway.deps import (
)
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory
@@ -191,6 +193,28 @@ def _question_count_expr():
return func.sum(case((RunRow.first_human_message.is_not(None), 1), else_=0))
_SYSTEM_MARKER_RE = re.compile(r"<(?:uploaded_files|attachments|files|system|metadata)>.*?</(?:uploaded_files|attachments|files|system|metadata)>", re.I | re.S)
_TAG_RE = re.compile(r"</?[^>]+>")
def _clean_question_text(value: str | None) -> str:
text = str(value or "").strip()
if not text:
return ""
text = _SYSTEM_MARKER_RE.sub(" ", text)
text = _TAG_RE.sub(" ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _is_displayable_question(value: str | None) -> bool:
text = _clean_question_text(value)
if not text:
return False
lowered = text.lower()
return lowered not in {"uploaded_files", "attachments", "files"}
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
@@ -309,7 +333,7 @@ async def _query_user_leaderboard(
select(
ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)).label("skill_call_count"),
func.sum(case((func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, "")).is_not(None), 1), else_=0)).label("skill_call_count"),
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
@@ -382,7 +406,11 @@ async def _query_call_rankings(
include_admins: bool = True,
) -> list[AdminLeaderboardSkill]:
exclude_user_ids = exclude_user_ids or set()
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
name_col = (
func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, ""))
if field == "skill"
else ToolCallMetricRow.tool_name
)
call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
stmt = (
@@ -557,6 +585,7 @@ async def _query_recent_questions(
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
@@ -578,17 +607,22 @@ async def _query_recent_questions(
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
questions: list[AdminLeaderboardQuestion] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
if cleanup_system_questions and not _is_displayable_question(text):
continue
questions.append(
AdminLeaderboardQuestion(
text=text[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
)
)
for r in rows
]
return questions
async def _query_frequent_questions(
@@ -600,6 +634,7 @@ async def _query_frequent_questions(
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
@@ -627,15 +662,20 @@ async def _query_frequent_questions(
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[2] or "",
email=r[3] or "",
asked_at=_iso(r[4]),
questions: list[AdminLeaderboardQuestion] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
if cleanup_system_questions and not _is_displayable_question(text):
continue
questions.append(
AdminLeaderboardQuestion(
text=text[:500],
user_id=r[2] or "",
email=r[3] or "",
asked_at=_iso(r[4]),
)
)
for r in rows
]
return questions
async def _query_trends(
@@ -736,7 +776,7 @@ async def _query_overview(
tool_row = (await session.execute(
select(
func.count(ToolCallMetricRow.id),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)),
func.sum(case((func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, "")).is_not(None), 1), else_=0)),
).where(*tool_where)
)).one()
@@ -760,9 +800,9 @@ async def get_user_leaderboard(
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
include_scheduled: bool = Query(default=True, description="Include runs created by scheduled tasks"),
include_admins: bool = Query(default=True, description="Include admin accounts in leaderboard aggregation"),
include_failed: bool = Query(default=True, description="Include failed runs and LLM calls"),
include_scheduled: bool | None = Query(default=None, description="Include runs created by scheduled tasks"),
include_admins: bool | None = Query(default=None, description="Include admin accounts in leaderboard aggregation"),
include_failed: bool | None = Query(default=None, description="Include failed runs and LLM calls"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
@@ -771,8 +811,13 @@ async def get_user_leaderboard(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, range_days = _resolve_range(days, since, until)
excluded = {uid for uid in (exclude_user_ids or []) if uid}
excluded = {uid for uid in settings.exclude_user_ids if uid}
excluded.update({uid for uid in (exclude_user_ids or []) if uid})
include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
include_admins = settings.include_admins if include_admins is None else include_admins
include_failed = settings.include_failed if include_failed is None else include_failed
async with session_factory() as session:
common = {
"exclude_user_ids": excluded,
@@ -785,6 +830,10 @@ async def get_user_leaderboard(
"include_scheduled": include_scheduled,
"include_admins": include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
overview = await _query_overview(session, since_dt, until_dt, **common)
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
@@ -794,8 +843,8 @@ async def get_user_leaderboard(
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **common)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse(
@@ -832,21 +881,35 @@ async def get_user_analytics(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
}
tool_common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id)
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id, **common)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit)
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id)
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
range_days=range_days,
@@ -12,6 +12,7 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
LeaderboardSettings,
PromptPrefixSettings,
SystemSettings,
load_system_settings,
@@ -58,6 +59,22 @@ class CitationDisplayUpdateRequest(BaseModel):
cleanup_words: list[str] | None = None
class LeaderboardSettingsResponse(BaseModel):
exclude_user_ids: list[str] = Field(default_factory=list)
include_scheduled: bool = True
include_admins: bool = True
include_failed: bool = True
cleanup_system_questions: bool = True
class LeaderboardSettingsUpdateRequest(BaseModel):
exclude_user_ids: list[str] | None = None
include_scheduled: bool | None = None
include_admins: bool | None = None
include_failed: bool | None = None
cleanup_system_questions: bool | None = None
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
@@ -92,6 +109,44 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix)
def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
return LeaderboardSettingsResponse(
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])),
include_scheduled=settings.include_scheduled,
include_admins=settings.include_admins,
include_failed=settings.include_failed,
cleanup_system_questions=settings.cleanup_system_questions,
)
@router.get("/leaderboard", response_model=LeaderboardSettingsResponse)
async def get_leaderboard_settings(request: Request) -> LeaderboardSettingsResponse:
"""Return global leaderboard aggregation settings. Admin only."""
await _require_admin(request)
return _leaderboard_to_response(load_system_settings().leaderboard)
@router.put("/leaderboard", response_model=LeaderboardSettingsResponse)
async def update_leaderboard_settings(
body: LeaderboardSettingsUpdateRequest,
request: Request,
) -> LeaderboardSettingsResponse:
"""Update global leaderboard aggregation settings. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "exclude_user_ids" in fields and fields["exclude_user_ids"] is not None:
update["exclude_user_ids"] = list(dict.fromkeys([str(uid).strip() for uid in fields["exclude_user_ids"] if str(uid).strip()]))
for key in ("include_scheduled", "include_admins", "include_failed", "cleanup_system_questions"):
if key in fields and fields[key] is not None:
update[key] = bool(fields[key])
leaderboard = current.leaderboard.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"leaderboard": leaderboard}))
return _leaderboard_to_response(saved.leaderboard)
@router.get("/citation-display", response_model=CitationDisplayResponse)
async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings."""
@@ -78,6 +78,17 @@ class PrivateSquareSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class LeaderboardSettings(BaseModel):
"""Admin-configurable defaults for the user leaderboard analytics."""
exclude_user_ids: list[str] = Field(default_factory=list, description="User ids excluded from leaderboard aggregation.")
include_scheduled: bool = Field(default=True, description="Whether scheduled task runs participate in leaderboard statistics.")
include_admins: bool = Field(default=True, description="Whether admin accounts participate in leaderboard statistics.")
include_failed: bool = Field(default=True, description="Whether failed runs and provider calls participate in leaderboard statistics.")
cleanup_system_questions: bool = Field(default=True, description="Hide attachment/system marker questions such as <uploaded_files>.")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -85,6 +96,7 @@ class SystemSettings(BaseModel):
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
model_config = ConfigDict(extra="ignore")