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