style: polish admin user leaderboard
This commit is contained in:
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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user