refine admin leaderboard experience
This commit is contained in:
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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user