feat: add admin user leaderboard analytics

This commit is contained in:
Administrator committed 2026-06-04 19:35:11 +08:00
1 parent 1e4ef070cf
commit 8f80b7c1ef
7 files changed
+1060 -1

No files matched your search

+111
View File
@@ -35,6 +35,88 @@ export interface AdminMemoryResponse {
buckets: AdminMemoryBucket[];
}
export interface AdminLeaderboardUser {
user_id: string;
email: string;
system_role: string;
thread_count: number;
run_count: number;
qa_count: number;
llm_call_count: number;
tool_call_count: number;
skill_call_count: number;
total_tokens: number;
input_tokens: number;
output_tokens: number;
last_active_at: string;
}
export interface AdminLeaderboardSkill {
name: string;
call_count: number;
user_count: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
last_used_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
questions: number;
tokens: number;
users: number;
}
export interface AdminLeaderboardOverview {
total_users: number;
active_users: number;
total_threads: number;
total_runs: number;
total_questions: number;
total_tokens: number;
total_skill_calls: number;
total_tool_calls: number;
error_runs: number;
}
export interface AdminLeaderboardResponse {
range_days: number;
since: string;
until: string;
overview: AdminLeaderboardOverview;
users_by_activity: AdminLeaderboardUser[];
users_by_questions: AdminLeaderboardUser[];
users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -73,6 +155,35 @@ export async function getUserMemory(
);
}
export async function getUserLeaderboard(
days: number,
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
days: number,
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
"Failed to load user analytics",
);
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+17
View File
@@ -6,6 +6,8 @@ import {
deleteAdminMemoryEntry,
deleteAdminV1Fact,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
@@ -13,6 +15,21 @@ import {
updateAdminV1Section,
} from "./api";
export function useUserLeaderboard(days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "leaderboard", days, limit],
queryFn: () => getUserLeaderboard(days, limit),
});
}
export function useUserAnalytics(userId: string | null, days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, days, limit],
queryFn: () => getUserAnalytics(userId!, days, limit),
enabled: !!userId,
});
}
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
return useQuery({
@@ -35,6 +35,7 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -259,6 +260,13 @@ 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: "用户活动审计",
@@ -1,7 +1,8 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
}
export default function AdminUserActivityPage() {
const [searchParams] = useSearchParams();
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [tab, setTab] = useState<"qa" | "memory">("qa");
const targetUserId = searchParams.get("user");
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
);
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setTab("qa");
}
}, [targetUserId]);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
@@ -0,0 +1,470 @@
"use client";
import {
Activity,
BarChart3,
Clock3,
Loader2,
Medal,
MessageSquareText,
MousePointerClick,
Search,
Sparkles,
TrendingUp,
UserRound,
Users,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
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,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens";
function fmtNumber(value?: number | null): string {
return Number(value ?? 0).toLocaleString();
}
function fmtTime(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
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({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<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" />
</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>
);
}
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,
tab,
}: {
users: AdminLeaderboardUser[];
activeUserId: string | null;
onSelect: (user: AdminLeaderboardUser) => void;
tab: UserRankTab;
}) {
if (users.length === 0) {
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
}
return (
<div className="flex flex-col">
{users.map((user, index) => {
const main =
tab === "questions"
? `${fmtNumber(user.qa_count)} 次问答`
: tab === "tokens"
? `${fmtNumber(user.total_tokens)} tokens`
: `${fmtNumber(scoreOf(user))} 次使用`;
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",
)}
>
<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>
<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>
)}
</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>
</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>
</div>
</button>
);
})}
</div>
);
}
function RankingTable({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
}) {
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>
</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="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>
</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>
</div>
</div>
))}
</div>
</div>
);
}
function QuestionList({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
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>
</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 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">
{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>}
</div>
</div>
))}
</div>
</div>
);
}
function UserDetailPanel({
userId,
days,
onOpenAudit,
}: {
userId: string | null;
days: number;
onOpenAudit: (userId: string) => void;
}) {
const { data, isLoading } = useUserAnalytics(userId, days, 12);
if (!userId) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
点击排行榜中的用户查看个人统计
</div>
);
}
if (isLoading || !data) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
);
}
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="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>
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
审计详情
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
<Metric label="总 Tokens" value={data.user.total_tokens} />
</div>
</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>
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<QuestionList 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 [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { data, isLoading, error } = useUserLeaderboard(days, 30);
const users = useMemo(() => {
const source =
tab === "questions"
? data?.users_by_questions
: tab === "tokens"
? data?.users_by_tokens
: data?.users_by_activity;
const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
);
}, [data, query, tab]);
const overview = data?.overview;
return (
<div className="flex size-full min-h-0 bg-muted/20">
<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>
<h1 className="text-xl font-semibold">用户排行榜</h1>
<p className="text-muted-foreground mt-1 text-sm">
汇总用户活跃、问答、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>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-4 p-6">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
加载统计数据
</div>
)}
{!isLoading && error && (
<div className="text-destructive rounded-lg border bg-card p-6 text-sm">
加载失败:{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)} 次`} />
</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">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户排行</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>
</div>
<UserRankList
users={users}
activeUserId={selectedUserId}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
</div>
<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>
</div>
<div className="h-[560px]">
<UserDetailPanel
userId={selectedUserId}
days={days}
onOpenAudit={(userId) => {
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
}}
/>
</div>
</div>
</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>
</>
)}
</div>
</ScrollArea>
</main>
</div>
);
}
+11
View File
@@ -24,6 +24,7 @@ import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -244,6 +245,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/user-leaderboard"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={