enhance leaderboard reporting and grouping

This commit is contained in:
Administrator committed 2026-06-05 00:02:05 +08:00
1 parent 88c83b4dd7
commit a5897caec4
4 files changed
+386 -182

No files matched your search

+23
View File
@@ -133,6 +133,11 @@ export interface AdminUserAnalyticsResponse {
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
}
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -208,6 +213,24 @@ export async function getUserAnalytics(
);
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST",
}),
"Failed to generate leaderboard report",
);
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+13
View File
@@ -5,6 +5,7 @@ import {
addAdminV1Fact,
deleteAdminMemoryEntry,
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
@@ -47,6 +48,18 @@ export function useUserAnalytics(
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
range,
limit,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => generateUserLeaderboardReport(range, limit ?? 30),
});
}
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
return useQuery({
@@ -40,7 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
@@ -49,7 +49,7 @@ import type {
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
@@ -189,37 +189,36 @@ function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
);
}
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 parseGroupRules(value: string): string[] {
return value
.split(/[\n,,;;\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function userGroupKey(user: AdminLeaderboardUser, rules: string[]): string {
const source = `${user.email || ""} ${user.user_id || ""}`.toLowerCase();
const matched = rules.find((rule) => source.includes(rule.toLowerCase()));
return matched || "其他";
}
function buildGroupStats(users: AdminLeaderboardUser[], rules: string[]) {
const groups = new Map<string, AdminLeaderboardUser[]>();
for (const user of users) {
const key = userGroupKey(user, rules);
groups.set(key, [...(groups.get(key) || []), user]);
}
return [...groups.entries()]
.map(([name, members]) => ({
name,
members: members.sort((a, b) => userScore(b, "activity") - userScore(a, "activity")),
userCount: members.length,
runs: members.reduce((sum, user) => sum + user.run_count, 0),
questions: members.reduce((sum, user) => sum + user.qa_count, 0),
tokens: members.reduce((sum, user) => sum + user.total_tokens, 0),
skills: members.reduce((sum, user) => sum + user.skill_call_count, 0),
}))
.sort((a, b) => b.runs + b.questions - (a.runs + a.questions));
}
function initials(user: AdminLeaderboardUser): string {
@@ -282,6 +281,73 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
);
}
function GroupComparison({
users,
rulesText,
onRulesChange,
onSelectUser,
}: {
users: AdminLeaderboardUser[];
rulesText: string;
onRulesChange: (value: string) => void;
onSelectUser: (user: AdminLeaderboardUser) => void;
}) {
const rules = parseGroupRules(rulesText);
const groups = buildGroupStats(users, rules);
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Users className="size-4 text-[#C1121F]" />
分组统计
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p>
</div>
<Input
value={rulesText}
onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]"
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
{groups.map((group) => (
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#462123] dark:bg-[#19090A]">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
<Badge variant="outline">{group.userCount} 人</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<Metric label="运行" value={group.runs} />
<Metric label="问答" value={group.questions} />
<Metric label="技能" value={group.skills} />
<Metric label="Token" value={group.tokens} />
</div>
<div className="mt-3 space-y-1.5">
{group.members.slice(0, 5).map((user, index) => (
<button
key={user.user_id}
type="button"
onClick={() => onSelectUser(user)}
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#230D0F]"
>
<span className="font-semibold text-[#C1121F]">{index + 1}</span>
<span className="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button>
))}
</div>
</div>
))}
</div>
</section>
);
}
function PodiumCard({
user,
rank,
@@ -342,101 +408,6 @@ function PodiumCard({
);
}
function SpotlightCarousel({
users,
tab,
onSelect,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
const [index, setIndex] = useState(0);
const visible = users.slice(0, 5);
const active = visible[index % Math.max(visible.length, 1)];
useEffect(() => {
if (visible.length <= 1) return;
const timer = window.setInterval(() => {
setIndex((value) => (value + 1) % visible.length);
}, 5000);
return () => window.clearInterval(timer);
}, [visible.length]);
useEffect(() => {
if (index >= visible.length) setIndex(0);
}, [index, visible.length]);
if (!active) {
return (
<section className="rounded-lg border border-dashed border-[#DADCE0] bg-white p-5 dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="text-sm text-[#5F6368]">暂无可轮播用户</div>
</section>
);
}
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Crown className="size-4 text-[#C1121F]" />
榜单焦点
</div>
<span className="text-xs text-[#8B6B6B]">自动轮播 Top 5</span>
</div>
<div className="grid gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<button
type="button"
onClick={() => onSelect(active)}
className="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-center gap-3">
<div className="flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white">
{initials(active)}
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-[#231815] dark:text-white">{active.email || active.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F]">{scoreLabel(active, tab)}</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#462123]">
<div
className="h-full rounded-full bg-[#C1121F]"
style={{ width: `${Math.min(100, Math.max(8, (userScore(active, tab) / maxScore) * 100))}%` }}
/>
</div>
</button>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
onClick={() => {
setIndex(itemIndex);
onSelect(user);
}}
className={cn(
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
itemIndex === index
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#C1121F]">#{itemIndex + 1}</span>
<span className="text-xs font-medium tabular-nums text-[#7A5A5A] dark:text-[#D8B8B8]">{fmtNumber(userScore(user, tab))}</span>
</div>
<div className="mt-1 truncate text-sm font-medium text-[#231815] dark:text-white">{user.email || user.user_id}</div>
</button>
))}
</div>
</div>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
@@ -910,20 +881,34 @@ function UserDetailPanel({
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Layers3 className="size-4 text-[#C1121F]" />
个人维度
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<Metric label="模型数" value={data.models.length} />
<Metric label="Agent 数" value={data.agents.length} />
<Metric label="状态数" value={data.run_statuses.length} />
</div>
</section>
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
<Tabs defaultValue="skills" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势
</div>
<TrendBars points={data.trends} />
</TabsContent>
<TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3">
<DimensionRanking title="个人模型" icon={Layers3} items={data.models} empty="暂无模型调用" />
<DimensionRanking title="个人 Agent" icon={Bot} items={data.agents} empty="暂无 Agent 运行" />
<DimensionRanking title="个人状态" icon={Gauge} items={data.run_statuses} empty="暂无运行状态" />
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
);
@@ -934,12 +919,15 @@ export default function AdminUserLeaderboardPage() {
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false);
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -1006,10 +994,11 @@ export default function AdminUserLeaderboardPage() {
variant="outline"
size="sm"
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
className="h-9"
className="h-9 w-9 px-0"
title="排行榜设置"
aria-label="排行榜设置"
>
<Settings2 className="mr-1 size-4" />
排行榜设置
<Settings2 className="size-4" />
</Button>
<Button
variant="outline"
@@ -1021,31 +1010,74 @@ export default function AdminUserLeaderboardPage() {
<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>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!data || reportMutation.isPending}
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
className="h-9"
>
{reportMutation.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Sparkles className="mr-1 size-4" />
)}
生成报告
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportMutation.isPending && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告
</div>
)}
{reportMutation.error && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-3 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
生成失败:{reportMutation.error.message}
</div>
)}
{reportMutation.data?.report && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report}
</pre>
)}
</ScrollArea>
{reportMutation.data?.report && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report,
"text/markdown;charset=utf-8",
)
}
>
<FileDown className="mr-1 size-4" />
下载报告
</Button>
</div>
)}
</DialogContent>
</Dialog>
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
type="date"
value={range.since}
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
@@ -1055,6 +1087,8 @@ export default function AdminUserLeaderboardPage() {
<Input
type="date"
value={range.until}
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
@@ -1097,10 +1131,11 @@ export default function AdminUserLeaderboardPage() {
</div>
<InsightStrip overview={overview} />
<SpotlightCarousel
<GroupComparison
users={rankedUsers}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">