style: polish admin user leaderboard
This commit is contained in:
1 parent
7657c45aae
commit
5590f7d85a
7 files changed
+501
-254
No files matched your search
@@ -8,6 +8,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
|
Trophy,
|
||||||
User,
|
User,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -260,6 +261,14 @@ export function PageSidebar({
|
|||||||
<Wrench className="size-4" />
|
<Wrench className="size-4" />
|
||||||
{t.settings.sections.tools}
|
{t.settings.sections.tools}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/user-leaderboard")}
|
||||||
|
>
|
||||||
|
<Trophy className="size-4" />
|
||||||
|
用户排行榜
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
|
TrophyIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -187,6 +188,16 @@ export function WorkspaceNavMenu() {
|
|||||||
大模型调用统计
|
大模型调用统计
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/strategy/admin/user-leaderboard")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrophyIcon />
|
||||||
|
用户排行榜
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin")}
|
onClick={() => navigate("/page/workspace/admin")}
|
||||||
|
|||||||
@@ -156,11 +156,13 @@ export async function getUserMemory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserLeaderboard(
|
export async function getUserLeaderboard(
|
||||||
days: number,
|
range: { since: string; until: string; days?: number },
|
||||||
limit = 20,
|
limit = 20,
|
||||||
): Promise<AdminLeaderboardResponse> {
|
): Promise<AdminLeaderboardResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
days: String(days),
|
days: String(range.days ?? 30),
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
});
|
});
|
||||||
return parse(
|
return parse(
|
||||||
@@ -171,11 +173,13 @@ export async function getUserLeaderboard(
|
|||||||
|
|
||||||
export async function getUserAnalytics(
|
export async function getUserAnalytics(
|
||||||
userId: string,
|
userId: string,
|
||||||
days: number,
|
range: { since: string; until: string; days?: number },
|
||||||
limit = 20,
|
limit = 20,
|
||||||
): Promise<AdminUserAnalyticsResponse> {
|
): Promise<AdminUserAnalyticsResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
days: String(days),
|
days: String(range.days ?? 30),
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
});
|
});
|
||||||
return parse(
|
return parse(
|
||||||
|
|||||||
@@ -15,17 +15,24 @@ import {
|
|||||||
updateAdminV1Section,
|
updateAdminV1Section,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
export function useUserLeaderboard(days: number, limit = 20) {
|
export function useUserLeaderboard(
|
||||||
|
range: { since: string; until: string; days?: number },
|
||||||
|
limit = 20,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["admin-activity", "leaderboard", days, limit],
|
queryKey: ["admin-activity", "leaderboard", range.since, range.until, limit],
|
||||||
queryFn: () => getUserLeaderboard(days, 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({
|
return useQuery({
|
||||||
queryKey: ["admin-activity", "analytics", userId, days, limit],
|
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
|
||||||
queryFn: () => getUserAnalytics(userId!, days, limit),
|
queryFn: () => getUserAnalytics(userId!, range, limit),
|
||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
Trophy,
|
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -260,13 +259,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/admin/scheduled-tasks",
|
path: "/page/strategy/admin/scheduled-tasks",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "mgmt-user-leaderboard",
|
|
||||||
label: "用户排行榜",
|
|
||||||
icon: Trophy,
|
|
||||||
path: "/page/strategy/admin/user-leaderboard",
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-user-activity",
|
id: "mgmt-user-activity",
|
||||||
label: "用户活动审计",
|
label: "用户活动审计",
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
Award,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock3,
|
CalendarDays,
|
||||||
|
Crown,
|
||||||
Loader2,
|
Loader2,
|
||||||
Medal,
|
Medal,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
@@ -22,13 +25,6 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
import type {
|
||||||
AdminLeaderboardQuestion,
|
AdminLeaderboardQuestion,
|
||||||
@@ -40,6 +36,62 @@ import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type UserRankTab = "activity" | "questions" | "tokens";
|
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 {
|
function fmtNumber(value?: number | null): string {
|
||||||
return Number(value ?? 0).toLocaleString();
|
return Number(value ?? 0).toLocaleString();
|
||||||
@@ -51,117 +103,198 @@ function fmtTime(value?: string | null): string {
|
|||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
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 {
|
function clampText(value: string, max = 120): string {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
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,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
note,
|
||||||
|
accent,
|
||||||
}: {
|
}: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
hint: string;
|
note: string;
|
||||||
|
accent: GoogleAccent;
|
||||||
}) {
|
}) {
|
||||||
|
const color = accentClasses[accent];
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-muted-foreground text-sm">{label}</span>
|
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
|
||||||
<Icon className="text-muted-foreground size-4" />
|
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-2xl font-semibold tabular-nums">{value}</div>
|
<div className="mt-3 text-2xl font-semibold tracking-normal text-[#202124] tabular-nums dark:text-white">
|
||||||
<div className="text-muted-foreground mt-1 text-xs">{hint}</div>
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">{note}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
function PodiumCard({
|
||||||
const max = Math.max(1, ...points.map((p) => p.runs));
|
user,
|
||||||
return (
|
rank,
|
||||||
<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,
|
tab,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
users: AdminLeaderboardUser[];
|
user?: AdminLeaderboardUser;
|
||||||
activeUserId: string | null;
|
rank: 1 | 2 | 3;
|
||||||
onSelect: (user: AdminLeaderboardUser) => void;
|
|
||||||
tab: UserRankTab;
|
tab: UserRankTab;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: (user: AdminLeaderboardUser) => void;
|
||||||
}) {
|
}) {
|
||||||
if (users.length === 0) {
|
const styles = {
|
||||||
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
|
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 (
|
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) => {
|
{users.map((user, index) => {
|
||||||
const main =
|
const score = userScore(user, tab);
|
||||||
tab === "questions"
|
|
||||||
? `${fmtNumber(user.qa_count)} 次问答`
|
|
||||||
: tab === "tokens"
|
|
||||||
? `${fmtNumber(user.total_tokens)} tokens`
|
|
||||||
: `${fmtNumber(scoreOf(user))} 次使用`;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={user.user_id}
|
key={user.user_id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(user)}
|
onClick={() => onSelect(user)}
|
||||||
className={cn(
|
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",
|
"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]",
|
||||||
activeUserId === user.user_id && "bg-primary/5",
|
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">
|
<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 < 3 ? <Medal className="size-4 text-primary" /> : index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-medium">{user.email || user.user_id}</span>
|
<span className="truncate font-medium text-[#202124] dark:text-white">{user.email || user.user_id}</span>
|
||||||
{user.system_role === "admin" && (
|
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||||||
<Badge variant="outline" className="shrink-0 text-xs">
|
|
||||||
管理员
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||||
<span>会话 {fmtNumber(user.thread_count)}</span>
|
最近活跃 {fmtTime(user.last_active_at)}
|
||||||
<span>运行 {fmtNumber(user.run_count)}</span>
|
</div>
|
||||||
<span>技能 {fmtNumber(user.skill_call_count)}</span>
|
</div>
|
||||||
<span>最近 {fmtTime(user.last_active_at)}</span>
|
<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>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-semibold tabular-nums">{main}</div>
|
<div className="font-semibold text-[#202124] tabular-nums dark:text-white">{scoreLabel(user, tab)}</div>
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||||
LLM {fmtNumber(user.llm_call_count)}
|
{fmtNumber(user.total_tokens)} tokens
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -171,7 +304,7 @@ function UserRankList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RankingTable({
|
function SkillRanking({
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
items,
|
items,
|
||||||
@@ -182,39 +315,54 @@ function RankingTable({
|
|||||||
items: AdminLeaderboardSkill[];
|
items: AdminLeaderboardSkill[];
|
||||||
empty: string;
|
empty: string;
|
||||||
}) {
|
}) {
|
||||||
|
const max = Math.max(1, ...items.map((item) => item.call_count));
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
|
||||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<Icon className="text-muted-foreground size-4" />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-semibold">{title}</h2>
|
<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>
|
||||||
<div className="divide-y">
|
<div className="space-y-3">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
|
||||||
<div className="text-muted-foreground py-10 text-center text-sm">{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">
|
||||||
{items.map((item, index) => (
|
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||||||
<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="min-w-0">
|
||||||
<div className="truncate font-medium">{item.name}</div>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
|
||||||
{fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)}
|
<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>
|
</div>
|
||||||
<div className="text-right text-sm">
|
<div className="text-right">
|
||||||
<div className="font-semibold tabular-nums">{fmtNumber(item.call_count)}</div>
|
<div className="text-sm font-semibold tabular-nums text-[#202124] dark:text-white">
|
||||||
<div className="text-muted-foreground text-xs">
|
{fmtNumber(item.call_count)}
|
||||||
成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)}
|
</div>
|
||||||
|
<div className="text-xs text-[#5F6368] dark:text-slate-400">
|
||||||
|
失败 {fmtNumber(item.error_count)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestionList({
|
function QuestionPanel({
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
showUser = true,
|
showUser = true,
|
||||||
@@ -224,19 +372,20 @@ function QuestionList({
|
|||||||
showUser?: boolean;
|
showUser?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<section className="rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
|
||||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
|
||||||
<MessageSquareText className="text-muted-foreground size-4" />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-semibold">{title}</h2>
|
<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>
|
||||||
<div className="divide-y">
|
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
|
||||||
<div className="text-muted-foreground py-10 text-center text-sm">暂无问题记录</div>
|
{items.slice(0, 8).map((item, index) => (
|
||||||
)}
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
<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-sm leading-6 text-[#202124] dark:text-slate-100">{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="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>}
|
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
|
||||||
<span>{fmtTime(item.asked_at)}</span>
|
<span>{fmtTime(item.asked_at)}</span>
|
||||||
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
|
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
|
||||||
@@ -244,32 +393,42 @@ function QuestionList({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserDetailPanel({
|
function UserDetailPanel({
|
||||||
userId,
|
userId,
|
||||||
days,
|
range,
|
||||||
onOpenAudit,
|
onOpenAudit,
|
||||||
}: {
|
}: {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
days: number;
|
range: ReturnType<typeof rangeForApi>;
|
||||||
onOpenAudit: (userId: string) => void;
|
onOpenAudit: (userId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading } = useUserAnalytics(userId, days, 12);
|
const { data, isLoading } = useUserAnalytics(userId, range, 12);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return (
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
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" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
加载用户详情
|
加载用户详情
|
||||||
</div>
|
</div>
|
||||||
@@ -278,15 +437,20 @@ function UserDetailPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="rounded-lg border bg-card 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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-lg font-semibold">{data.user.email || data.user.user_id}</div>
|
<div className="truncate text-lg font-semibold text-[#202124] dark:text-white">
|
||||||
<div className="text-muted-foreground mt-1 text-xs">最近活跃 {fmtTime(data.user.last_active_at)}</div>
|
{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>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
|
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
|
||||||
审计详情
|
审计详情
|
||||||
|
<ArrowRight className="ml-1 size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
<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="技能调用" value={data.user.skill_call_count} />
|
||||||
<Metric label="总 Tokens" value={data.user.total_tokens} />
|
<Metric label="总 Tokens" value={data.user.total_tokens} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-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">
|
<div className="mb-3 flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
|
||||||
<TrendingUp className="text-muted-foreground size-4" />
|
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||||
个人使用趋势
|
个人使用趋势
|
||||||
</div>
|
</div>
|
||||||
<MiniBars points={data.trends} />
|
<TrendBars points={data.trends} />
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
|
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
|
||||||
<QuestionList title="最近提问" items={data.recent_questions} showUser={false} />
|
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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() {
|
export default function AdminUserLeaderboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [days, setDays] = useState(30);
|
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
|
||||||
const [tab, setTab] = useState<UserRankTab>("activity");
|
const [tab, setTab] = useState<UserRankTab>("activity");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
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 =
|
const source =
|
||||||
tab === "questions"
|
tab === "questions"
|
||||||
? data?.users_by_questions
|
? data?.users_by_questions
|
||||||
@@ -343,122 +499,169 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
}, [data, query, tab]);
|
}, [data, query, tab]);
|
||||||
|
|
||||||
const overview = data?.overview;
|
const overview = data?.overview;
|
||||||
|
const topUsers = rankedUsers.slice(0, 3);
|
||||||
|
|
||||||
|
const setQuickRange = (days: number) => {
|
||||||
|
setRange(defaultRange(days));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="border-b bg-background px-6 py-4">
|
<div className="border-b border-[#DADCE0] bg-white px-6 py-5 dark:border-[#17395F] dark:bg-[#06213F]">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">用户排行榜</h1>
|
<div className="mb-2 flex items-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<Award className="size-4" />
|
||||||
汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。
|
管理员分析
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
|
||||||
|
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
|
|
||||||
<SelectTrigger className="w-36">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<Clock3 className="mr-2 size-4" />
|
<div className="flex flex-col gap-1">
|
||||||
<SelectValue />
|
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
||||||
</SelectTrigger>
|
<Input
|
||||||
<SelectContent>
|
type="date"
|
||||||
<SelectItem value="7">近 7 天</SelectItem>
|
value={range.since}
|
||||||
<SelectItem value="30">近 30 天</SelectItem>
|
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
|
||||||
<SelectItem value="90">近 90 天</SelectItem>
|
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
|
||||||
<SelectItem value="365">近 1 年</SelectItem>
|
/>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="flex flex-col gap-4 p-6">
|
<div className="space-y-5 p-6">
|
||||||
{isLoading && (
|
{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" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
加载统计数据
|
加载统计数据
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && error && (
|
{!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}
|
加载失败:{error.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && data && overview && (
|
{!isLoading && data && overview && (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<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)} 位在当前周期活跃`} />
|
<StatCard icon={Users} accent="blue" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
|
||||||
<OverviewCard icon={MousePointerClick} label="使用次数" value={fmtNumber(overview.total_runs)} hint={`${fmtNumber(overview.total_threads)} 个会话`} />
|
<StatCard icon={MousePointerClick} accent="green" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
|
||||||
<OverviewCard icon={MessageSquareText} label="问答次数" value={fmtNumber(overview.total_questions)} hint={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
|
<StatCard icon={MessageSquareText} accent="yellow" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
|
||||||
<OverviewCard icon={Sparkles} label="技能/工具调用" value={fmtNumber(overview.total_skill_calls)} hint={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
<StatCard icon={Sparkles} accent="red" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(360px,0.8fr)]">
|
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-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">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="text-muted-foreground size-4" />
|
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||||
<h2 className="font-semibold">用户排行</h2>
|
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative">
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
<Input
|
||||||
<Input
|
value={query}
|
||||||
value={query}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
placeholder="搜索用户邮箱或 ID"
|
||||||
placeholder="搜索用户"
|
className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]"
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
<UserRankList
|
<UserTable
|
||||||
users={users}
|
users={rankedUsers}
|
||||||
activeUserId={selectedUserId}
|
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
selectedUserId={selectedUserId}
|
||||||
onSelect={(user) => setSelectedUserId(user.user_id)}
|
onSelect={(user) => setSelectedUserId(user.user_id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<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 px-4 py-3">
|
<div className="flex items-center gap-2 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
|
||||||
<UserRound className="text-muted-foreground size-4" />
|
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||||
<h2 className="font-semibold">用户详情</h2>
|
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[560px]">
|
<div className="h-[620px]">
|
||||||
<UserDetailPanel
|
<UserDetailPanel
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
days={days}
|
range={apiRange}
|
||||||
onOpenAudit={(userId) => {
|
onOpenAudit={(userId) => {
|
||||||
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
|
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
<div className="mb-3 flex items-center gap-2 font-semibold">
|
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
|
||||||
<TrendingUp className="text-muted-foreground size-4" />
|
<QuestionPanel title="最新问题" items={data.recent_questions} />
|
||||||
时间趋势
|
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -146,8 +146,27 @@ def _iso(value) -> str:
|
|||||||
return value.isoformat() if isinstance(value, datetime) else str(value or "")
|
return value.isoformat() if isinstance(value, datetime) else str(value or "")
|
||||||
|
|
||||||
|
|
||||||
def _range_start(days: int) -> datetime:
|
def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
|
||||||
return _now() - timedelta(days=max(1, min(days, 365)))
|
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():
|
def _question_count_expr():
|
||||||
@@ -403,6 +422,8 @@ async def _query_overview(session, since: datetime, until: datetime, *, user_id:
|
|||||||
async def get_user_leaderboard(
|
async def get_user_leaderboard(
|
||||||
request: Request,
|
request: Request,
|
||||||
days: int = Query(default=30, ge=1, le=365),
|
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),
|
limit: int = Query(default=20, ge=5, le=100),
|
||||||
) -> AdminLeaderboardResponse:
|
) -> AdminLeaderboardResponse:
|
||||||
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
|
"""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:
|
if session_factory is None:
|
||||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||||
|
|
||||||
until = _now()
|
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||||
since = _range_start(days)
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
overview = await _query_overview(session, since, until)
|
overview = await _query_overview(session, since_dt, until_dt)
|
||||||
users = await _query_user_leaderboard(session, since, until)
|
users = await _query_user_leaderboard(session, since_dt, until_dt)
|
||||||
skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
|
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit)
|
||||||
tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", 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, until, user_id=None, 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, until, limit=limit)
|
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit)
|
||||||
trends = await _query_trends(session, since, until)
|
trends = await _query_trends(session, since_dt, until_dt)
|
||||||
|
|
||||||
return AdminLeaderboardResponse(
|
return AdminLeaderboardResponse(
|
||||||
range_days=days,
|
range_days=range_days,
|
||||||
since=_iso(since),
|
since=_iso(since_dt),
|
||||||
until=_iso(until),
|
until=_iso(until_dt),
|
||||||
overview=overview,
|
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_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],
|
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,
|
user_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
days: int = Query(default=30, ge=1, le=365),
|
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),
|
limit: int = Query(default=20, ge=5, le=100),
|
||||||
) -> AdminUserAnalyticsResponse:
|
) -> AdminUserAnalyticsResponse:
|
||||||
"""Return one user's admin analytics profile."""
|
"""Return one user's admin analytics profile."""
|
||||||
@@ -451,22 +473,21 @@ async def get_user_analytics(
|
|||||||
if session_factory is None:
|
if session_factory is None:
|
||||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||||
|
|
||||||
until = _now()
|
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||||
since = _range_start(days)
|
|
||||||
async with session_factory() as session:
|
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:
|
if not users:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
overview = await _query_overview(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, until, user_id=user_id, field="skill", limit=limit)
|
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, until, user_id=user_id, field="tool", 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, until, user_id=user_id, 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, until, user_id=user_id)
|
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id)
|
||||||
|
|
||||||
return AdminUserAnalyticsResponse(
|
return AdminUserAnalyticsResponse(
|
||||||
range_days=days,
|
range_days=range_days,
|
||||||
since=_iso(since),
|
since=_iso(since_dt),
|
||||||
until=_iso(until),
|
until=_iso(until_dt),
|
||||||
user=users[0],
|
user=users[0],
|
||||||
overview=overview,
|
overview=overview,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
|
|||||||
Reference in new issue
Block a user