diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 5cebcc9f..9dce22c4 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -269,6 +269,14 @@ export function PageSidebar({ 用户排行榜 )} + {isAdmin && ( + navigate("/page/strategy/admin/user-leaderboard/settings")} + > + + 排行榜设置 + + )} openSettings("account")}> 个人信息 diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 2c7c085f..bc84f4a0 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -198,6 +198,16 @@ export function WorkspaceNavMenu() { 用户排行榜 )} + {isAdmin && ( + + navigate("/page/strategy/admin/user-leaderboard/settings") + } + > + + 排行榜设置 + + )} {isAdmin && ( navigate("/page/workspace/admin")} diff --git a/frontend-web/src/core/admin/api.ts b/frontend-web/src/core/admin/api.ts index f27cb0d1..0bafec09 100644 --- a/frontend-web/src/core/admin/api.ts +++ b/frontend-web/src/core/admin/api.ts @@ -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 { @@ -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", diff --git a/frontend-web/src/core/admin/hooks.ts b/frontend-web/src/core/admin/hooks.ts index c6ffdc2e..fe2d7958 100644 --- a/frontend-web/src/core/admin/hooks.ts +++ b/frontend-web/src/core/admin/hooks.ts @@ -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), diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index d2fe690a..c3b700e3 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -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: "用户管理", diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 579de5c4..d94e38a4 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -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 { const response = await apiFetch( `${getBackendBaseURL()}/api/system-settings/private-square`, @@ -127,6 +143,36 @@ export async function loadCitationDisplaySettings(): Promise { + 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 { + 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 { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 85cf0b41..6cf4f5af 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -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"] }); + }, + }); +} diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index 8c27f297..8b298575 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -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 = { @@ -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 ( +
+ {items.map(([label, value, unit]) => ( +
+
{label}
+
+ {Number(value).toFixed(unit === "%" || String(value).includes(".") ? 1 : 0)} + {unit} +
+
+ ))} +
+ ); +} + function PodiumCard({ user, rank, @@ -292,98 +378,59 @@ function SpotlightCarousel({ const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab))); return ( -
-
+
+
+
+ + 榜单焦点 +
+ 自动轮播 Top 5 +
+
-
-
-
焦点队列
-
自动轮播 Top 5
-
-
- {visible.map((user, itemIndex) => ( - - ))} -
-
- {visible.map((user, itemIndex) => ( -
+
+ {visible.map((user, itemIndex) => ( + + ))}
@@ -391,7 +438,8 @@ function SpotlightCarousel({ } function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { - const [metric, setMetric] = useState>("runs"); + type TrendMetric = keyof Pick; + const [metric, setMetric] = useState<"all" | TrendMetric>("all"); const chartId = useId().replace(/:/g, ""); if (points.length === 0) { return
暂无趋势数据
; @@ -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) => (
- + @@ -459,24 +509,38 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { ); })} - - - {visible.map((point, index) => { - const value = Number(point[metric] ?? 0); - const isPeak = point.date === peak.date; - const isLast = index === visible.length - 1; + {metric !== "all" && } + {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 ( - - {`${point.date} ${activeSeries.label}: ${fmtNumber(value)}`} - + + + {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 ( + + {`${point.date} ${item.label}: ${fmtNumber(value)}`} + + ); + })} + ); })} {visible.map((point, index) => { @@ -494,7 +558,22 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { return (
-
+
+ {series.map((item) => (
-
最新 {activeSeries.label}
+
最新 {metric === "all" ? "运行" : activeSeries.label}
{fmtNumber(latestValue)}
= 0 ? "text-[#0F766E]" : "text-[#E11D48]")}> 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
- 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[metric] ?? 0))} + 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
{chart(false)} @@ -769,9 +848,9 @@ function QuestionPanel({ function Metric({ label, value }: { label: string; value: number }) { return ( -
-
{label}
-
{fmtNumber(value)}
+
+
{label}
+
{fmtNumber(value)}
); } @@ -808,13 +887,13 @@ function UserDetailPanel({ return (
-
+
-
+
{data.user.email || data.user.user_id}
-
+
最近活跃 {fmtTime(data.user.last_active_at)}
@@ -823,7 +902,7 @@ function UserDetailPanel({
-
+
@@ -831,211 +910,34 @@ function UserDetailPanel({
-
-
- - 个人使用趋势 +
+
+ + 个人维度 +
+
+ + +
-
- +
); } -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 ( -
-
-
-
- - 统计排除名单 -
-

- 名单中的账号不参与当前排行榜、KPI、趋势和问题统计。 -

-
- {excludedUserIds.length > 0 && ( - - )} -
- -
-
- - onQueryChange(event.target.value)} - placeholder="搜索账号邮箱或 ID" - className="h-9 bg-white pl-8 dark:bg-[#0B2A50]" - /> - {query.trim() && candidates.length > 0 && ( -
- - {candidates.map((user) => ( - - ))} - -
- )} -
- -
- {selectedUsers.length === 0 ? ( - 暂无排除账号 - ) : ( - selectedUsers.map((user) => ( - - {user.email} - - - )) - )} -
-
-
- ); -} - -function StatsRulePanel({ - config, - onChange, - excludedCount, -}: { - config: StatsConfig; - onChange: (patch: Partial) => 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 ( -
-
-
-
- - 统计规则 -
-

- 当前排除 {fmtNumber(excludedCount)} 个账号,以下开关会影响榜单、趋势、模型、技能和问题统计。 -

-
- 管理员配置 -
-
- {rules.map((rule) => ( - - ))} -
-
- ); -} - export default function AdminUserLeaderboardPage() { const navigate = useNavigate(); const [range, setRange] = useState(() => defaultRange(30)); const [tab, setTab] = useState("activity"); const [query, setQuery] = useState(""); - const [excludeQuery, setExcludeQuery] = useState(""); - const [excludedUserIds, setExcludedUserIds] = useState([]); - const [statsConfig, setStatsConfig] = useState({ - include_scheduled: true, - include_admins: true, - include_failed: true, - }); const [selectedUserId, setSelectedUserId] = useState(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 ( -
+
-
+
-
+
管理员分析

用户排行榜

-

+

统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。

@@ -1110,6 +1002,45 @@ export default function AdminUserLeaderboardPage() { 返回系统 + + +
- setExcludedUserIds([])} - /> - setStatsConfig((current) => ({ ...current, ...patch }))} - /> - {isLoading && (
@@ -1179,6 +1095,7 @@ export default function AdminUserLeaderboardPage() {
+ 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) => { + 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 ( +
+
+
+
+
+
+ + 管理员设置 +
+

排行榜设置

+

+ 这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。 +

+
+
+ +
+
+
+ + +
+
+
+
+
+ + 统计排除名单 +
+

+ 名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。 +

+
+ {excludedIds.length > 0 && ( + + )} +
+ +
+
+ + setQuery(event.target.value)} + placeholder="搜索账号邮箱或 ID" + className="h-10 border-[#F1D6D6] bg-white pl-9" + /> + {query.trim() && candidates.length > 0 && ( +
+ + {candidates.map((user) => ( + + ))} + +
+ )} +
+
+ {selectedUsers.length === 0 ? ( + 暂无排除账号 + ) : ( +
+ {selectedUsers.map((user) => ( + + {userLabel(user)} + + + ))} +
+ )} +
+
+
+ +
+
+
+
+ + 统计规则 +
+

+ 关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。 +

+
+ {isLoading && 加载中} +
+ +
+ {[ + ["include_scheduled", "统计定时任务", "关闭后,定时任务产生的运行、问答、Token、模型调用、最新问题都不统计。"], + ["include_admins", "统计管理员账号", "关闭后,管理员账号不参与排行和整体指标。"], + ["include_failed", "统计失败运行", "关闭后,失败运行和失败模型调用不进入榜单。"], + ["cleanup_system_questions", "隐藏系统标签问题", "开启后, 等附件/系统标签不会出现在问题列表。"], + ].map(([key, title, desc]) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 88c3eedf..adf6005d 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -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() { ) } /> + + ) : ( + + ) + } + /> .*?", 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, diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index c0047549..bee05639 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -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.""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index 943f6ba0..0f3ab08c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -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 .") + 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")