style: polish admin user leaderboard

This commit is contained in:
Administrator committed 2026-06-04 20:22:37 +08:00
1 parent 7657c45aae
commit 5590f7d85a
7 files changed
+508 -261

No files matched your search

@@ -8,6 +8,7 @@ import {
Folder,
FolderOpen,
Settings,
Trophy,
User,
Wrench,
} from "lucide-react";
@@ -260,6 +261,14 @@ export function PageSidebar({
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/user-leaderboard")}
>
<Trophy className="size-4" />
用户排行榜
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
@@ -15,9 +15,10 @@ import {
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
UsersIcon,
} from "lucide-react";
TagsIcon,
TrophyIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -187,10 +188,20 @@ export function WorkspaceNavMenu() {
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
+8 -4
View File
@@ -156,11 +156,13 @@ export async function getUserMemory(
}
export async function getUserLeaderboard(
days: number,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(days),
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
@@ -171,11 +173,13 @@ export async function getUserLeaderboard(
export async function getUserAnalytics(
userId: string,
days: number,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(days),
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
+13 -6
View File
@@ -15,17 +15,24 @@ import {
updateAdminV1Section,
} from "./api";
export function useUserLeaderboard(days: number, limit = 20) {
export function useUserLeaderboard(
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "leaderboard", days, limit],
queryFn: () => getUserLeaderboard(days, limit),
queryKey: ["admin-activity", "leaderboard", range.since, range.until, limit],
queryFn: () => getUserLeaderboard(range, limit),
});
}
export function useUserAnalytics(userId: string | null, days: number, limit = 20) {
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, days, limit],
queryFn: () => getUserAnalytics(userId!, days, limit),
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
queryFn: () => getUserAnalytics(userId!, range, limit),
enabled: !!userId,
});
}
@@ -35,7 +35,6 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -260,13 +259,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
@@ -2,8 +2,11 @@
import {
Activity,
ArrowRight,
Award,
BarChart3,
Clock3,
CalendarDays,
Crown,
Loader2,
Medal,
MessageSquareText,
@@ -22,13 +25,6 @@ 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardQuestion,
@@ -40,6 +36,62 @@ import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens";
type RangeState = { since: string; until: string; days?: number };
type GoogleAccent = "blue" | "red" | "yellow" | "green";
const GOOGLE_COLORS = ["#4285F4", "#DB4437", "#F4B400", "#0F9D58"] as const;
const accentClasses: Record<GoogleAccent, { soft: string; text: string; bar: string; border: string }> = {
blue: {
soft: "bg-[#E8F0FE] dark:bg-[#4285F4]/15",
text: "text-[#4285F4] dark:text-[#8AB4F8]",
bar: "bg-[#4285F4]",
border: "border-[#D2E3FC] dark:border-[#4285F4]/35",
},
red: {
soft: "bg-[#FCE8E6] dark:bg-[#DB4437]/15",
text: "text-[#DB4437] dark:text-[#F28B82]",
bar: "bg-[#DB4437]",
border: "border-[#FAD2CF] dark:border-[#DB4437]/35",
},
yellow: {
soft: "bg-[#FEF7E0] dark:bg-[#F4B400]/15",
text: "text-[#B06000] dark:text-[#FDD663]",
bar: "bg-[#F4B400]",
border: "border-[#FEEFC3] dark:border-[#F4B400]/35",
},
green: {
soft: "bg-[#E6F4EA] dark:bg-[#0F9D58]/15",
text: "text-[#0F9D58] dark:text-[#81C995]",
bar: "bg-[#0F9D58]",
border: "border-[#CEEAD6] dark:border-[#0F9D58]/35",
},
};
function toDateInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultRange(days = 30): RangeState {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - days + 1);
return { since: toDateInput(start), until: toDateInput(end), days };
}
function rangeForApi(range: RangeState) {
const normalized = range.since && range.until ? range : defaultRange(30);
const start = new Date(`${normalized.since}T00:00:00`);
const end = new Date(`${normalized.until}T23:59:59`);
return {
since: start.toISOString(),
until: end.toISOString(),
days: Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1),
};
}
function fmtNumber(value?: number | null): string {
return Number(value ?? 0).toLocaleString();
@@ -51,117 +103,198 @@ function fmtTime(value?: string | null): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function scoreOf(user: AdminLeaderboardUser): number {
return user.run_count + user.tool_call_count;
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function OverviewCard({
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
return user.run_count + user.tool_call_count;
}
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`;
return `${fmtNumber(userScore(user, tab))} 次使用`;
}
function initials(user: AdminLeaderboardUser): string {
const source = user.email || user.user_id || "?";
return source.slice(0, 1).toUpperCase();
}
function StatCard({
icon: Icon,
label,
value,
hint,
note,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
hint: string;
note: string;
accent: GoogleAccent;
}) {
const color = accentClasses[accent];
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#06213F]", color.border)}>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">{label}</span>
<Icon className="text-muted-foreground size-4" />
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
<Icon className="size-4" />
</span>
</div>
<div className="mt-3 text-2xl font-semibold tabular-nums">{value}</div>
<div className="text-muted-foreground mt-1 text-xs">{hint}</div>
<div className="mt-3 text-2xl font-semibold tracking-normal text-[#202124] tabular-nums dark:text-white">
{value}
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">{note}</div>
</div>
);
}
function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.runs));
return (
<div className="flex h-24 items-end gap-1">
{points.length === 0 ? (
<div className="text-muted-foreground flex w-full items-center justify-center text-sm">
暂无趋势数据
</div>
) : (
points.slice(-30).map((p) => (
<div
key={p.date}
title={`${p.date}: ${p.runs} 次使用,${p.questions} 次问答`}
className="min-w-2 flex-1 rounded-t bg-primary/70"
style={{ height: `${Math.max(8, (p.runs / max) * 96)}px` }}
/>
))
)}
</div>
);
}
function UserRankList({
users,
activeUserId,
onSelect,
function PodiumCard({
user,
rank,
tab,
active,
onSelect,
}: {
users: AdminLeaderboardUser[];
activeUserId: string | null;
onSelect: (user: AdminLeaderboardUser) => void;
user?: AdminLeaderboardUser;
rank: 1 | 2 | 3;
tab: UserRankTab;
active: boolean;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
if (users.length === 0) {
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
2: "border-[#D2E3FC] bg-[#E8F0FE] text-[#4285F4] dark:border-[#4285F4]/40 dark:bg-[#4285F4]/10 dark:text-[#8AB4F8]",
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
}[rank];
if (!user) {
return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#06213F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
);
}
return (
<div className="flex flex-col">
<button
type="button"
onClick={() => onSelect(user)}
className={cn(
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#06213F]",
active ? "border-[#4285F4] ring-2 ring-[#D2E3FC] dark:border-[#8AB4F8] dark:ring-[#4285F4]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
)}
>
<div className="flex items-start justify-between gap-3">
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium", styles)}>
{rank === 1 ? <Crown className="size-3.5" /> : <Medal className="size-3.5" />}
第 {rank} 名
</span>
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#4285F4] text-base font-semibold text-white shadow-sm">
{initials(user)}
</div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#4285F4] dark:text-[#8AB4F8]">{scoreLabel(user, tab)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span>
<span>技能 {fmtNumber(user.skill_call_count)}</span>
</div>
</div>
</button>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.runs));
if (points.length === 0) {
return <div className="flex h-28 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
}
return (
<div className="flex h-28 items-end gap-1.5">
{points.slice(-32).map((point, index) => (
<div key={point.date} className="group flex min-w-2 flex-1 items-end">
<div
title={`${point.date}: 使用 ${point.runs} 次,问答 ${point.questions} 次`}
className="w-full rounded-t-sm transition"
style={{
height: `${Math.max(8, (point.runs / max) * 112)}px`,
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length],
}}
/>
</div>
))}
</div>
);
}
function UserTable({
users,
tab,
selectedUserId,
onSelect,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
selectedUserId: string | null;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
if (users.length === 0) {
return <div className="py-14 text-center text-sm text-[#5F6368]">暂无用户统计</div>;
}
return (
<div className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white dark:border-[#213D5E] dark:bg-[#06213F]">
{users.map((user, index) => {
const main =
tab === "questions"
? `${fmtNumber(user.qa_count)} 次问答`
: tab === "tokens"
? `${fmtNumber(user.total_tokens)} tokens`
: `${fmtNumber(scoreOf(user))} 次使用`;
const score = userScore(user, tab);
return (
<button
key={user.user_id}
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b px-4 py-3 text-left hover:bg-muted/70",
activeUserId === user.user_id && "bg-primary/5",
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#EEF0F1] px-4 py-3 text-left last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]",
selectedUserId === user.user_id && "bg-[#E8F0FE] dark:bg-[#4285F4]/10",
)}
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-sm font-semibold">
{index < 3 ? <Medal className="size-4 text-primary" /> : index + 1}
<div className="flex size-8 items-center justify-center rounded-md bg-[#F1F3F4] text-sm font-semibold text-[#5F6368] dark:bg-[#11345E] dark:text-slate-200">
{index + 1}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{user.email || user.user_id}</span>
{user.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
<span className="truncate font-medium text-[#202124] dark:text-white">{user.email || user.user_id}</span>
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span>会话 {fmtNumber(user.thread_count)}</span>
<span>运行 {fmtNumber(user.run_count)}</span>
<span>技能 {fmtNumber(user.skill_call_count)}</span>
<span>最近 {fmtTime(user.last_active_at)}</span>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
最近活跃 {fmtTime(user.last_active_at)}
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div
className="h-2 rounded-full bg-[#4285F4]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
会话 {fmtNumber(user.thread_count)} · LLM {fmtNumber(user.llm_call_count)}
</div>
</div>
<div className="text-right">
<div className="font-semibold tabular-nums">{main}</div>
<div className="text-muted-foreground mt-1 text-xs">
LLM {fmtNumber(user.llm_call_count)}
<div className="font-semibold text-[#202124] tabular-nums dark:text-white">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(user.total_tokens)} tokens
</div>
</div>
</button>
@@ -171,7 +304,7 @@ function UserRankList({
);
}
function RankingTable({
function SkillRanking({
title,
icon: Icon,
items,
@@ -182,39 +315,54 @@ function RankingTable({
items: AdminLeaderboardSkill[];
empty: string;
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Icon className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">{empty}</div>
)}
{items.map((item, index) => (
<div key={item.name} className="grid grid-cols-[32px_minmax(0,1fr)_auto] gap-3 px-4 py-3">
<span className="text-muted-foreground text-sm tabular-nums">{index + 1}</span>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.slice(0, 10).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="text-muted-foreground mt-1 text-xs">
{fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
<span className="text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div
className="h-2 rounded-full"
style={{
width: `${Math.max(4, (item.call_count / max) * 100)}%`,
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length],
}}
/>
</div>
</div>
<div className="text-right text-sm">
<div className="font-semibold tabular-nums">{fmtNumber(item.call_count)}</div>
<div className="text-muted-foreground text-xs">
成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)}
<div className="text-right">
<div className="text-sm font-semibold tabular-nums text-[#202124] dark:text-white">
{fmtNumber(item.call_count)}
</div>
<div className="text-xs text-[#5F6368] dark:text-slate-400">
失败 {fmtNumber(item.error_count)}
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function QuestionList({
function QuestionPanel({
title,
items,
showUser = true,
@@ -224,19 +372,20 @@ function QuestionList({
showUser?: boolean;
}) {
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<MessageSquareText className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
<section className="rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">暂无问题记录</div>
)}
{items.map((item, index) => (
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.slice(0, 8).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6">{clampText(item.text, 180)}</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
@@ -244,32 +393,42 @@ function QuestionList({
</div>
))}
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-[#F8FAFD] px-3 py-2 dark:bg-[#0B2A50]">
<div className="text-xs text-[#5F6368] dark:text-slate-400">{label}</div>
<div className="mt-1 font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div>
);
}
function UserDetailPanel({
userId,
days,
range,
onOpenAudit,
}: {
userId: string | null;
days: number;
range: ReturnType<typeof rangeForApi>;
onOpenAudit: (userId: string) => void;
}) {
const { data, isLoading } = useUserAnalytics(userId, days, 12);
const { data, isLoading } = useUserAnalytics(userId, range, 12);
if (!userId) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
点击排行榜中的用户查看个人统计
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-[#5F6368] dark:text-slate-400">
<UserRound className="size-8" />
点击排行榜中的用户,查看个人问答、技能和趋势。
</div>
);
}
if (isLoading || !data) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
@@ -278,15 +437,20 @@ function UserDetailPanel({
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-4">
<div className="rounded-lg border bg-card 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]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold">{data.user.email || data.user.user_id}</div>
<div className="text-muted-foreground mt-1 text-xs">最近活跃 {fmtTime(data.user.last_active_at)}</div>
<div className="truncate text-lg font-semibold text-[#202124] dark:text-white">
{data.user.email || data.user.user_id}
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
最近活跃 {fmtTime(data.user.last_active_at)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
审计详情
<ArrowRight className="ml-1 size-3.5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
@@ -295,41 +459,33 @@ function UserDetailPanel({
<Metric label="技能调用" value={data.user.skill_call_count} />
<Metric label="总 Tokens" value={data.user.total_tokens} />
</div>
</div>
</section>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
个人使用趋势
</div>
<MiniBars points={data.trends} />
</div>
<TrendBars points={data.trends} />
</section>
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<QuestionList title="最近提问" items={data.recent_questions} showUser={false} />
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</div>
</ScrollArea>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-muted/60 px-3 py-2">
<div className="text-muted-foreground text-xs">{label}</div>
<div className="mt-1 font-semibold tabular-nums">{fmtNumber(value)}</div>
</div>
);
}
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [days, setDays] = useState(30);
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { data, isLoading, error } = useUserLeaderboard(days, 30);
const apiRange = useMemo(() => rangeForApi(range), [range]);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
const users = useMemo(() => {
const rankedUsers = useMemo(() => {
const source =
tab === "questions"
? data?.users_by_questions
@@ -343,122 +499,169 @@ export default function AdminUserLeaderboardPage() {
}, [data, query, tab]);
const overview = data?.overview;
const topUsers = rankedUsers.slice(0, 3);
const setQuickRange = (days: number) => {
setRange(defaultRange(days));
};
return (
<div className="flex size-full min-h-0 bg-muted/20">
<div className="flex size-full min-h-0 bg-[#F8FAFD] text-[#202124] dark:bg-[#031A33] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b bg-background px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="border-b border-[#DADCE0] bg-white px-6 py-5 dark:border-[#17395F] dark:bg-[#06213F]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">用户排行榜</h1>
<p className="text-muted-foreground mt-1 text-sm">
汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。
<div className="mb-2 flex items-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Award className="size-4" />
管理员分析
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
<SelectTrigger className="w-36">
<Clock3 className="mr-2 size-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
<SelectItem value="90">近 90 天</SelectItem>
<SelectItem value="365">近 1 年</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
type="date"
value={range.since}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">结束日期</label>
<Input
type="date"
value={range.until}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
近 7 天
</Button>
<Button variant="outline" size="sm" onClick={() => setQuickRange(30)}>
近 30 天
</Button>
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
近 90 天
</Button>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-4 p-6">
<div className="space-y-5 p-6">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" />
加载统计数据
</div>
)}
{!isLoading && error && (
<div className="text-destructive rounded-lg border bg-card p-6 text-sm">
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-5 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
加载失败:{error.message}
</div>
)}
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<OverviewCard icon={Users} label="总用户" value={fmtNumber(overview.total_users)} hint={`${fmtNumber(overview.active_users)} 位在当前周期活跃`} />
<OverviewCard icon={MousePointerClick} label="使用次数" value={fmtNumber(overview.total_runs)} hint={`${fmtNumber(overview.total_threads)} 个会话`} />
<OverviewCard icon={MessageSquareText} label="问答次数" value={fmtNumber(overview.total_questions)} hint={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<OverviewCard icon={Sparkles} label="技能/工具调用" value={fmtNumber(overview.total_skill_calls)} hint={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
<StatCard icon={Users} accent="blue" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="green" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="yellow" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="red" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(360px,0.8fr)]">
<div className="rounded-lg border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">根据当前榜单维度实时切换。</p>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用排行</TabsTrigger>
<TabsTrigger value="questions">问答排行</TabsTrigger>
<TabsTrigger value="tokens">消耗排行</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="grid gap-3 lg:grid-cols-3">
<PodiumCard user={topUsers[0]} rank={1} tab={tab} active={selectedUserId === topUsers[0]?.user_id} onSelect={(user) => setSelectedUserId(user.user_id)} />
<PodiumCard user={topUsers[1]} rank={2} tab={tab} active={selectedUserId === topUsers[1]?.user_id} onSelect={(user) => setSelectedUserId(user.user_id)} />
<PodiumCard user={topUsers[2]} rank={3} tab={tab} active={selectedUserId === topUsers[2]?.user_id} onSelect={(user) => setSelectedUserId(user.user_id)} />
</div>
</section>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,0.85fr)]">
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户排行</h2>
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户"
className="h-9 w-52 pl-8"
/>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用</TabsTrigger>
<TabsTrigger value="questions">问答</TabsTrigger>
<TabsTrigger value="tokens">消耗</TabsTrigger>
</TabsList>
</Tabs>
<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) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]"
/>
</div>
</div>
<UserRankList
users={users}
activeUserId={selectedUserId}
<UserTable
users={rankedUsers}
tab={tab}
selectedUserId={selectedUserId}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
</div>
</section>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<UserRound className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户详情</h2>
<section className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center gap-2 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
<div className="h-[560px]">
<div className="h-[620px]">
<UserDetailPanel
userId={selectedUserId}
days={days}
range={apiRange}
onOpenAudit={(userId) => {
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
}}
/>
</div>
</section>
</div>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">时间趋势</h2>
</div>
<div className="flex items-center gap-2 text-xs text-[#5F6368] dark:text-slate-400">
<CalendarDays className="size-3.5" />
{range.since} 至 {range.until}
</div>
</div>
<TrendBars points={data.trends} />
</section>
<div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
</div>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
时间趋势
</div>
<MiniBars points={data.trends} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RankingTable title="最常用技能" icon={Sparkles} items={data.skills} empty="当前周期暂无技能调用" />
<RankingTable title="最常用工具" icon={Activity} items={data.tools} empty="当前周期暂无工具调用" />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<QuestionList title="最近经常问的问题" items={data.frequent_questions} />
<QuestionList title="最新问题" items={data.recent_questions} />
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</>
)}
@@ -146,8 +146,27 @@ def _iso(value) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value or "")
def _range_start(days: int) -> datetime:
return _now() - timedelta(days=max(1, min(days, 365)))
def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
if not value:
return fallback
text = value.strip()
if not text:
return fallback
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
end = _parse_datetime(until, _now())
start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
if start > end:
raise HTTPException(status_code=400, detail="since must be before until")
span_days = max(1, min(365, (end - start).days + 1))
return start, end, span_days
def _question_count_expr():
@@ -403,6 +422,8 @@ async def _query_overview(session, since: datetime, until: datetime, *, user_id:
async def get_user_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
@@ -411,21 +432,20 @@ async def get_user_leaderboard(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
overview = await _query_overview(session, since, until)
users = await _query_user_leaderboard(session, since, until)
skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit)
frequent_questions = await _query_frequent_questions(session, since, until, limit=limit)
trends = await _query_trends(session, since, until)
overview = await _query_overview(session, since_dt, until_dt)
users = await _query_user_leaderboard(session, since_dt, until_dt)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit)
trends = await _query_trends(session, since_dt, until_dt)
return AdminLeaderboardResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
overview=overview,
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
@@ -443,6 +463,8 @@ async def get_user_analytics(
user_id: str,
request: Request,
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
@@ -451,22 +473,21 @@ async def get_user_analytics(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
users = await _query_user_leaderboard(session, since, until, user_id=user_id)
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since, until, user_id=user_id)
skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit)
trends = await _query_trends(session, since, until, user_id=user_id)
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)
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)
return AdminUserAnalyticsResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
user=users[0],
overview=overview,
skills=skills,