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>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
排行榜设置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
|
|||||||
@@ -198,6 +198,16 @@ export function WorkspaceNavMenu() {
|
|||||||
用户排行榜
|
用户排行榜
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/strategy/admin/user-leaderboard/settings")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Settings2Icon />
|
||||||
|
排行榜设置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin")}
|
onClick={() => navigate("/page/workspace/admin")}
|
||||||
|
|||||||
@@ -176,10 +176,6 @@ export async function getUserLeaderboard(
|
|||||||
since: string;
|
since: string;
|
||||||
until: string;
|
until: string;
|
||||||
days?: number;
|
days?: number;
|
||||||
exclude_user_ids?: string[];
|
|
||||||
include_scheduled?: boolean;
|
|
||||||
include_admins?: boolean;
|
|
||||||
include_failed?: boolean;
|
|
||||||
},
|
},
|
||||||
limit = 20,
|
limit = 20,
|
||||||
): Promise<AdminLeaderboardResponse> {
|
): Promise<AdminLeaderboardResponse> {
|
||||||
@@ -188,13 +184,7 @@ export async function getUserLeaderboard(
|
|||||||
since: range.since,
|
since: range.since,
|
||||||
until: range.until,
|
until: range.until,
|
||||||
limit: String(limit),
|
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(
|
return parse(
|
||||||
await apiFetch(`${base()}/leaderboard?${params}`),
|
await apiFetch(`${base()}/leaderboard?${params}`),
|
||||||
"Failed to load leaderboard",
|
"Failed to load leaderboard",
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ export function useUserLeaderboard(
|
|||||||
since: string;
|
since: string;
|
||||||
until: string;
|
until: string;
|
||||||
days?: number;
|
days?: number;
|
||||||
exclude_user_ids?: string[];
|
|
||||||
include_scheduled?: boolean;
|
|
||||||
include_admins?: boolean;
|
|
||||||
include_failed?: boolean;
|
|
||||||
},
|
},
|
||||||
limit = 20,
|
limit = 20,
|
||||||
) {
|
) {
|
||||||
@@ -33,10 +29,6 @@ export function useUserLeaderboard(
|
|||||||
"leaderboard",
|
"leaderboard",
|
||||||
range.since,
|
range.since,
|
||||||
range.until,
|
range.until,
|
||||||
range.exclude_user_ids ?? [],
|
|
||||||
range.include_scheduled ?? true,
|
|
||||||
range.include_admins ?? true,
|
|
||||||
range.include_failed ?? true,
|
|
||||||
limit,
|
limit,
|
||||||
],
|
],
|
||||||
queryFn: () => getUserLeaderboard(range, limit),
|
queryFn: () => getUserLeaderboard(range, limit),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -245,6 +246,20 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/settings/llm-metrics",
|
path: "/page/strategy/settings/llm-metrics",
|
||||||
adminOnly: true,
|
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",
|
id: "mgmt-users",
|
||||||
label: "用户管理",
|
label: "用户管理",
|
||||||
|
|||||||
@@ -61,6 +61,22 @@ export interface CitationDisplayUpdate {
|
|||||||
cleanup_words?: string[];
|
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> {
|
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||||
@@ -127,6 +143,36 @@ export async function loadCitationDisplaySettings(): Promise<CitationDisplaySett
|
|||||||
return (await response.json()) as CitationDisplaySettings;
|
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(
|
export async function updateCitationDisplaySettings(
|
||||||
body: CitationDisplayUpdate,
|
body: CitationDisplayUpdate,
|
||||||
): Promise<CitationDisplaySettings> {
|
): Promise<CitationDisplaySettings> {
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
loadAppearanceSettings,
|
loadAppearanceSettings,
|
||||||
loadCitationDisplaySettings,
|
loadCitationDisplaySettings,
|
||||||
|
loadLeaderboardSettings,
|
||||||
loadPrivateSquareStatus,
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
|
updateLeaderboardSettings,
|
||||||
updatePrivateSquareSettings,
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
|
type LeaderboardSettingsUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -19,6 +22,7 @@ const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
|||||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||||
|
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||||
|
|
||||||
export function usePromptPrefixSettings() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
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,
|
Bot,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Crown,
|
Crown,
|
||||||
|
FileDown,
|
||||||
Gauge,
|
Gauge,
|
||||||
Layers3,
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -17,13 +18,11 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
Search,
|
Search,
|
||||||
ShieldOff,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
UserRound,
|
UserRound,
|
||||||
Users,
|
Users,
|
||||||
X,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -41,27 +40,20 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
import type {
|
||||||
AdminLeaderboardDimension,
|
AdminLeaderboardDimension,
|
||||||
|
AdminLeaderboardOverview,
|
||||||
AdminLeaderboardQuestion,
|
AdminLeaderboardQuestion,
|
||||||
AdminLeaderboardSkill,
|
AdminLeaderboardSkill,
|
||||||
AdminLeaderboardTrendPoint,
|
AdminLeaderboardTrendPoint,
|
||||||
AdminLeaderboardUser,
|
AdminLeaderboardUser,
|
||||||
} from "@/core/admin/api";
|
} from "@/core/admin/api";
|
||||||
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
||||||
type RangeState = { since: string; until: string; days?: number };
|
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";
|
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
|
||||||
|
|
||||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||||
@@ -161,6 +153,75 @@ function rankTabDescription(tab: UserRankTab): string {
|
|||||||
return "使用排行按运行次数与工具调用次数合计排序。";
|
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 {
|
function initials(user: AdminLeaderboardUser): string {
|
||||||
const source = user.email || user.user_id || "?";
|
const source = user.email || user.user_id || "?";
|
||||||
return source.slice(0, 1).toUpperCase();
|
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({
|
function PodiumCard({
|
||||||
user,
|
user,
|
||||||
rank,
|
rank,
|
||||||
@@ -292,98 +378,59 @@ function SpotlightCarousel({
|
|||||||
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
|
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-lg border border-[#D8DEE9] bg-white shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
|
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_360px]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(active)}
|
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">
|
<div className="flex items-center 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">
|
<div className="flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white">
|
||||||
<Crown className="size-3.5" />
|
{initials(active)}
|
||||||
榜单焦点
|
</div>
|
||||||
</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="min-w-0">
|
<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">
|
<div className="truncate font-semibold text-[#231815] dark:text-white">{active.email || active.user_id}</div>
|
||||||
{initials(active)}
|
<div className="mt-1 text-sm font-medium text-[#C1121F]">{scoreLabel(active, tab)}</div>
|
||||||
</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>
|
</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>
|
</button>
|
||||||
<div className="flex flex-col justify-between p-4">
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
{visible.map((user, itemIndex) => (
|
||||||
<div className="text-sm font-semibold text-[#202124] dark:text-white">焦点队列</div>
|
<button
|
||||||
<div className="text-xs text-[#6B7280] dark:text-slate-400">自动轮播 Top 5</div>
|
key={user.user_id}
|
||||||
</div>
|
type="button"
|
||||||
<div className="space-y-2">
|
onClick={() => {
|
||||||
{visible.map((user, itemIndex) => (
|
setIndex(itemIndex);
|
||||||
<button
|
onSelect(user);
|
||||||
key={user.user_id}
|
}}
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={() => {
|
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
|
||||||
setIndex(itemIndex);
|
itemIndex === index
|
||||||
onSelect(user);
|
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
|
||||||
}}
|
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
|
||||||
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",
|
<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>
|
||||||
<span className="text-xs font-semibold text-[#6B7280]">{itemIndex + 1}</span>
|
</div>
|
||||||
<span className="min-w-0">
|
<div className="mt-1 truncate text-sm font-medium text-[#231815] dark:text-white">{user.email || user.user_id}</div>
|
||||||
<span className="block truncate text-[#202124] dark:text-white">{user.email || user.user_id}</span>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -391,7 +438,8 @@ function SpotlightCarousel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
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, "");
|
const chartId = useId().replace(/:/g, "");
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
|
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: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
|
||||||
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
|
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
|
||||||
] as const;
|
] as const;
|
||||||
const activeSeries = series.find((item) => item.key === metric) ?? series[0];
|
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
|
||||||
const metricMax = Math.max(1, ...visible.map((point) => Number(point[metric] ?? 0)));
|
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 latest = visible[visible.length - 1];
|
||||||
const previous = visible[visible.length - 2];
|
const previous = visible[visible.length - 2];
|
||||||
const latestValue = Number(latest?.[metric] ?? 0);
|
const latestValue = Number(latest?.[activeMetric] ?? 0);
|
||||||
const previousValue = Number(previous?.[metric] ?? 0);
|
const previousValue = Number(previous?.[activeMetric] ?? 0);
|
||||||
const delta = latestValue - previousValue;
|
const delta = latestValue - previousValue;
|
||||||
const width = 920;
|
const width = 920;
|
||||||
const height = 280;
|
const height = 280;
|
||||||
@@ -422,20 +471,21 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
|||||||
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
||||||
const selectedPath = visible
|
const selectedPath = visible
|
||||||
.map((point, index) => {
|
.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)}`;
|
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
|
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(
|
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],
|
visible[0],
|
||||||
);
|
);
|
||||||
|
const displayedSeries = metric === "all" ? series : [activeSeries];
|
||||||
const chart = (large = false) => (
|
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")}>
|
<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="使用趋势折线图">
|
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : "h-72")} role="img" aria-label="使用趋势折线图">
|
||||||
<defs>
|
<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="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
|
||||||
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
|
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -459,24 +509,38 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<path d={selectedArea} fill={`url(#trend-area-${chartId}-${metric})`} />
|
{metric !== "all" && <path d={selectedArea} fill={`url(#trend-area-${chartId}-${activeMetric})`} />}
|
||||||
<path d={selectedPath} fill="none" stroke={activeSeries.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" />
|
{displayedSeries.map((item) => {
|
||||||
{visible.map((point, index) => {
|
const max = Math.max(1, ...visible.map((point) => Number(point[item.key] ?? 0)));
|
||||||
const value = Number(point[metric] ?? 0);
|
const path = visible
|
||||||
const isPeak = point.date === peak.date;
|
.map((point, index) => {
|
||||||
const isLast = index === visible.length - 1;
|
const value = Number(point[item.key] ?? 0);
|
||||||
|
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, max).toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
return (
|
return (
|
||||||
<circle
|
<g key={item.key}>
|
||||||
key={`${metric}-${point.date}`}
|
<path d={path} fill="none" stroke={item.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" opacity={metric === "all" ? 0.9 : 1} />
|
||||||
cx={xFor(index)}
|
{metric !== "all" &&
|
||||||
cy={yFor(value, metricMax)}
|
visible.map((point, index) => {
|
||||||
r={isPeak || isLast ? 5 : 3}
|
const value = Number(point[item.key] ?? 0);
|
||||||
fill="#fff"
|
const isPeak = point.date === peak.date;
|
||||||
stroke={activeSeries.color}
|
const isLast = index === visible.length - 1;
|
||||||
strokeWidth={isPeak || isLast ? "3" : "2"}
|
return (
|
||||||
>
|
<circle
|
||||||
<title>{`${point.date} ${activeSeries.label}: ${fmtNumber(value)}`}</title>
|
key={`${item.key}-${point.date}`}
|
||||||
</circle>
|
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) => {
|
{visible.map((point, index) => {
|
||||||
@@ -494,7 +558,22 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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) => (
|
{series.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -503,8 +582,8 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
"rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||||||
metric === item.key
|
metric === item.key
|
||||||
? "border-[#4F46E5] bg-white shadow-sm dark:border-indigo-300/40 dark:bg-[#0B2A50]"
|
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
|
||||||
: "border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#17395F] dark:bg-[#071F3B]",
|
: "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>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>时间趋势分析</DialogTitle>
|
<DialogTitle>时间趋势分析</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
当前查看 {activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[metric] ?? 0))}。
|
当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{chart(true)}
|
{chart(true)}
|
||||||
@@ -534,13 +613,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
<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="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="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||||||
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#0F766E]" : "text-[#E11D48]")}>
|
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#0F766E]" : "text-[#E11D48]")}>
|
||||||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{chart(false)}
|
{chart(false)}
|
||||||
@@ -769,9 +848,9 @@ function QuestionPanel({
|
|||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: number }) {
|
function Metric({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-[#F8FAFD] px-3 py-2 dark:bg-[#0B2A50]">
|
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]">
|
||||||
<div className="text-xs text-[#5F6368] dark:text-slate-400">{label}</div>
|
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
|
||||||
<div className="mt-1 font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(value)}</div>
|
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -808,13 +887,13 @@ function UserDetailPanel({
|
|||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="space-y-4 p-4">
|
<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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<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}
|
{data.user.email || data.user.user_id}
|
||||||
</div>
|
</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)}
|
最近活跃 {fmtTime(data.user.last_active_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -823,7 +902,7 @@ function UserDetailPanel({
|
|||||||
<ArrowRight className="ml-1 size-3.5" />
|
<ArrowRight className="ml-1 size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.qa_count} />
|
||||||
<Metric label="运行次数" value={data.user.run_count} />
|
<Metric label="运行次数" value={data.user.run_count} />
|
||||||
<Metric label="技能调用" value={data.user.skill_call_count} />
|
<Metric label="技能调用" value={data.user.skill_call_count} />
|
||||||
@@ -831,211 +910,34 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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="mb-3 flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
|
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
|
||||||
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
|
<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>
|
</div>
|
||||||
<TrendBars points={data.trends} />
|
|
||||||
</section>
|
</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} />
|
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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() {
|
export default function AdminUserLeaderboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
|
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
|
||||||
const [tab, setTab] = useState<UserRankTab>("activity");
|
const [tab, setTab] = useState<UserRankTab>("activity");
|
||||||
const [query, setQuery] = useState("");
|
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 [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
const { data: allUsers = [] } = useAllUsers(true);
|
|
||||||
const apiRange = useMemo(
|
const apiRange = useMemo(
|
||||||
() => ({ ...rangeForApi(range), exclude_user_ids: excludedUserIds, ...statsConfig }),
|
() => rangeForApi(range),
|
||||||
[range, excludedUserIds, statsConfig],
|
[range],
|
||||||
);
|
);
|
||||||
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
|
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
|
||||||
|
|
||||||
@@ -1074,28 +976,18 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
setRange(defaultRange(days));
|
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 (
|
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">
|
<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 className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<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" />
|
<Award className="size-4" />
|
||||||
管理员分析
|
管理员分析
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
|
<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 消耗、技能热度和近期问题,用于运营和审计。
|
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1110,6 +1002,45 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
<ArrowLeft className="mr-1 size-4" />
|
<ArrowLeft className="mr-1 size-4" />
|
||||||
返回系统
|
返回系统
|
||||||
</Button>
|
</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">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1143,21 +1074,6 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="space-y-5 p-6">
|
<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 && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
|
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<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={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)} 次`} />
|
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
||||||
</div>
|
</div>
|
||||||
|
<InsightStrip overview={overview} />
|
||||||
|
|
||||||
<SpotlightCarousel
|
<SpotlightCarousel
|
||||||
users={rankedUsers}
|
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 AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||||
|
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
|
||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
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
|
<Route
|
||||||
path="admin/user-activity"
|
path="admin/user-activity"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ methods' ``user_id`` parameter is designed for.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ from app.gateway.deps import (
|
|||||||
)
|
)
|
||||||
from deerflow.agents.memory import BuiltinFileProvider
|
from deerflow.agents.memory import BuiltinFileProvider
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
||||||
from deerflow.persistence.engine import get_session_factory
|
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))
|
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):
|
def _run_time_filter(since: datetime, until: datetime):
|
||||||
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
|
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
|
||||||
|
|
||||||
@@ -309,7 +333,7 @@ async def _query_user_leaderboard(
|
|||||||
select(
|
select(
|
||||||
ToolCallMetricRow.user_id.label("user_id"),
|
ToolCallMetricRow.user_id.label("user_id"),
|
||||||
func.count(ToolCallMetricRow.id).label("tool_call_count"),
|
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))
|
.where(_tool_time_filter(since, until))
|
||||||
.group_by(ToolCallMetricRow.user_id)
|
.group_by(ToolCallMetricRow.user_id)
|
||||||
@@ -382,7 +406,11 @@ async def _query_call_rankings(
|
|||||||
include_admins: bool = True,
|
include_admins: bool = True,
|
||||||
) -> list[AdminLeaderboardSkill]:
|
) -> list[AdminLeaderboardSkill]:
|
||||||
exclude_user_ids = exclude_user_ids or set()
|
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")
|
call_count = func.count(ToolCallMetricRow.id).label("call_count")
|
||||||
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
|
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -557,6 +585,7 @@ async def _query_recent_questions(
|
|||||||
include_scheduled: bool = True,
|
include_scheduled: bool = True,
|
||||||
include_admins: bool = True,
|
include_admins: bool = True,
|
||||||
include_failed: bool = True,
|
include_failed: bool = True,
|
||||||
|
cleanup_system_questions: bool = True,
|
||||||
) -> list[AdminLeaderboardQuestion]:
|
) -> list[AdminLeaderboardQuestion]:
|
||||||
exclude_user_ids = exclude_user_ids or set()
|
exclude_user_ids = exclude_user_ids or set()
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -578,17 +607,22 @@ async def _query_recent_questions(
|
|||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
return [
|
questions: list[AdminLeaderboardQuestion] = []
|
||||||
AdminLeaderboardQuestion(
|
for r in rows:
|
||||||
text=(r[0] or "")[:500],
|
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||||
user_id=r[1] or "",
|
if cleanup_system_questions and not _is_displayable_question(text):
|
||||||
email=r[2] or "",
|
continue
|
||||||
thread_id=r[3] or "",
|
questions.append(
|
||||||
run_id=r[4] or "",
|
AdminLeaderboardQuestion(
|
||||||
asked_at=_iso(r[5]),
|
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(
|
async def _query_frequent_questions(
|
||||||
@@ -600,6 +634,7 @@ async def _query_frequent_questions(
|
|||||||
include_scheduled: bool = True,
|
include_scheduled: bool = True,
|
||||||
include_admins: bool = True,
|
include_admins: bool = True,
|
||||||
include_failed: bool = True,
|
include_failed: bool = True,
|
||||||
|
cleanup_system_questions: bool = True,
|
||||||
) -> list[AdminLeaderboardQuestion]:
|
) -> list[AdminLeaderboardQuestion]:
|
||||||
exclude_user_ids = exclude_user_ids or set()
|
exclude_user_ids = exclude_user_ids or set()
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -627,15 +662,20 @@ async def _query_frequent_questions(
|
|||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
return [
|
questions: list[AdminLeaderboardQuestion] = []
|
||||||
AdminLeaderboardQuestion(
|
for r in rows:
|
||||||
text=(r[0] or "")[:500],
|
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||||
user_id=r[2] or "",
|
if cleanup_system_questions and not _is_displayable_question(text):
|
||||||
email=r[3] or "",
|
continue
|
||||||
asked_at=_iso(r[4]),
|
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(
|
async def _query_trends(
|
||||||
@@ -736,7 +776,7 @@ async def _query_overview(
|
|||||||
tool_row = (await session.execute(
|
tool_row = (await session.execute(
|
||||||
select(
|
select(
|
||||||
func.count(ToolCallMetricRow.id),
|
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)
|
).where(*tool_where)
|
||||||
)).one()
|
)).one()
|
||||||
|
|
||||||
@@ -760,9 +800,9 @@ async def get_user_leaderboard(
|
|||||||
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
||||||
until: str | None = Query(default=None, description="Inclusive range end, 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"),
|
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_scheduled: bool | None = Query(default=None, description="Include runs created by scheduled tasks"),
|
||||||
include_admins: bool = Query(default=True, description="Include admin accounts in leaderboard aggregation"),
|
include_admins: bool | None = Query(default=None, description="Include admin accounts in leaderboard aggregation"),
|
||||||
include_failed: bool = Query(default=True, description="Include failed runs and LLM calls"),
|
include_failed: bool | None = Query(default=None, description="Include failed runs and LLM calls"),
|
||||||
limit: int = Query(default=20, ge=5, le=100),
|
limit: int = Query(default=20, ge=5, le=100),
|
||||||
) -> AdminLeaderboardResponse:
|
) -> AdminLeaderboardResponse:
|
||||||
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
|
"""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:
|
if session_factory is None:
|
||||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
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)
|
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:
|
async with session_factory() as session:
|
||||||
common = {
|
common = {
|
||||||
"exclude_user_ids": excluded,
|
"exclude_user_ids": excluded,
|
||||||
@@ -785,6 +830,10 @@ async def get_user_leaderboard(
|
|||||||
"include_scheduled": include_scheduled,
|
"include_scheduled": include_scheduled,
|
||||||
"include_admins": include_admins,
|
"include_admins": include_admins,
|
||||||
}
|
}
|
||||||
|
question_common = {
|
||||||
|
**common,
|
||||||
|
"cleanup_system_questions": settings.cleanup_system_questions,
|
||||||
|
}
|
||||||
overview = await _query_overview(session, since_dt, until_dt, **common)
|
overview = await _query_overview(session, since_dt, until_dt, **common)
|
||||||
users = await _query_user_leaderboard(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)
|
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)
|
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)
|
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)
|
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)
|
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, **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)
|
trends = await _query_trends(session, since_dt, until_dt, **common)
|
||||||
|
|
||||||
return AdminLeaderboardResponse(
|
return AdminLeaderboardResponse(
|
||||||
@@ -832,21 +881,35 @@ async def get_user_analytics(
|
|||||||
if session_factory is None:
|
if session_factory is None:
|
||||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
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)
|
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||||
async with session_factory() as session:
|
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:
|
if not users:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id)
|
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)
|
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)
|
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)
|
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit, **common)
|
||||||
if not models:
|
if not models:
|
||||||
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit)
|
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)
|
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)
|
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)
|
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)
|
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
|
||||||
|
|
||||||
return AdminUserAnalyticsResponse(
|
return AdminUserAnalyticsResponse(
|
||||||
range_days=range_days,
|
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 app.gateway.deps import get_optional_user_from_request
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.config.system_settings import (
|
from deerflow.config.system_settings import (
|
||||||
|
LeaderboardSettings,
|
||||||
PromptPrefixSettings,
|
PromptPrefixSettings,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
load_system_settings,
|
load_system_settings,
|
||||||
@@ -58,6 +59,22 @@ class CitationDisplayUpdateRequest(BaseModel):
|
|||||||
cleanup_words: list[str] | None = None
|
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:
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||||
return PromptPrefixResponse(
|
return PromptPrefixResponse(
|
||||||
enabled=settings.enabled,
|
enabled=settings.enabled,
|
||||||
@@ -92,6 +109,44 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
|||||||
return _to_response(saved.prompt_prefix)
|
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)
|
@router.get("/citation-display", response_model=CitationDisplayResponse)
|
||||||
async def get_citation_display() -> CitationDisplayResponse:
|
async def get_citation_display() -> CitationDisplayResponse:
|
||||||
"""Return global citation display cleanup settings."""
|
"""Return global citation display cleanup settings."""
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ class PrivateSquareSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
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):
|
class SystemSettings(BaseModel):
|
||||||
"""Top-level system settings document."""
|
"""Top-level system settings document."""
|
||||||
|
|
||||||
@@ -85,6 +96,7 @@ class SystemSettings(BaseModel):
|
|||||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||||
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||||
|
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user