feat: 公开用户排行榜页面 + 修复 checkpointer 连接与前端依赖

- 新增公开排行榜页面与后端 public_leaderboard 路由
- 完善 skills/agents 路由
- checkpointer Postgres 改用连接池(自愈), 解决连接关闭后定时任务连环失败
- 修复前端 mermaid 依赖未声明导致 Vite 解析失败 (新增 pnpm-lock)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-08 17:09:28 +08:00
1 parent 8113b822c5
commit 80d2db6315
26 files changed
+1877 -967

No files matched your search

+3
View File
@@ -19,6 +19,7 @@ import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
@@ -66,6 +67,8 @@ export function App() {
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only user leaderboard publish page — no authentication. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
@@ -14,6 +14,7 @@ import {
StarIcon,
TagsIcon,
Trash2Icon,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
@@ -497,6 +498,18 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</>
);
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。
const publisherNode =
!isBuiltin && agent.owner_username ? (
<div
className="text-muted-foreground flex items-center gap-1 text-xs"
title={`发布者:${desensitize(agent.owner_username)}`}
>
<UserIcon className="size-3 shrink-0" />
<span className="truncate">发布者:{desensitize(agent.owner_username)}</span>
</div>
) : null;
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
@@ -585,15 +598,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
@@ -702,6 +715,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +761,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</p>
)}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
@@ -934,13 +949,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
@@ -30,6 +30,10 @@ import {
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
@@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
</div>
)}
</SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
</>
)}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert, Briefcase, UserRound, Sparkles, Clock, History, Archive } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -458,12 +458,24 @@ const CTX_LABELS: Record<string, string> = {
topOfMind: "当前重点",
};
const CTX_ICONS: Record<string, React.ElementType> = {
workContext: Briefcase,
personalContext: UserRound,
topOfMind: Sparkles,
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const HISTORY_ICONS: Record<string, React.ElementType> = {
recentMonths: Clock,
earlierContext: History,
longTermBackground: Archive,
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: {
preference: "偏好",
@@ -572,12 +584,15 @@ function MemoryV1View({
<Icon className="w-3.5 h-3.5" />
{categoryLabel}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
{Math.round(fact.confidence * 100)}%
<div
className="flex flex-col items-end gap-1"
title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
>
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
置信度 {Math.round(fact.confidence * 100)}%
</span>
{/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
<div className="h-1 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
@@ -611,44 +626,77 @@ function MemoryV1View({
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
<TabsContent value="profile" className="mt-3 space-y-5">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">用户画像</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<UserRound className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">用户画像</h3>
</div>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无画像信息,对话后系统会自动归纳。
</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ctxEntries.map(([key, value]) => {
const Icon = CTX_ICONS[key] ?? UserRound;
return (
<div
key={key}
className="group rounded-lg border border-border/50 bg-gradient-to-br from-card to-card/40 p-3.5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:from-[#022248]/40 dark:to-[#022248]/10"
>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{CTX_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">历史摘要</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<History className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">历史摘要</h3>
</div>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无历史摘要。
</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="space-y-3">
{historyEntries.map(([key, value]) => {
const Icon = HISTORY_ICONS[key] ?? History;
return (
<div
key={key}
className="relative rounded-lg border border-border/50 bg-card/60 p-3.5 pl-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:bg-[#022248]/30"
>
<span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{HISTORY_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
</TabsContent>
</Tabs>
</div>
@@ -22,6 +22,7 @@ import {
Trash2Icon,
TrendingUpIcon,
Upload,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -631,6 +632,12 @@ export function SkillRow({
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UserIcon className="size-3" />
发布者:{desensitize(skill.owner_username)}
</div>
)}
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
@@ -649,15 +656,15 @@ export function SkillRow({
const actionButtons = (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -842,13 +849,13 @@ export function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<SkillDetailDialog
skillName={skill.name}
open={detailOpen}
@@ -109,13 +109,20 @@ export function SkillDetailDialog({
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
// Admins may browse any skill's files (the backend authorizes this); owners
// may browse their own custom skill. Edit-ability is decided separately by
// the backend's `filesData.editable` flag.
const canViewFiles = useMemo(() => {
if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open ? skillName : null);
const filesEnabled = open && isOwner && activeTab === "files";
const filesEnabled = open && canViewFiles && activeTab === "files";
const {
data: filesData,
isLoading: isFilesLoading,
@@ -227,7 +234,7 @@ export function SkillDetailDialog({
展示适配
</TabsTrigger>
)}
{isOwner && (
{canViewFiles && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
@@ -349,7 +356,7 @@ export function SkillDetailDialog({
</TabsContent>
)}
{isOwner && (
{canViewFiles && (
<TabsContent
value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -9,16 +9,12 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -149,36 +145,6 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -199,14 +165,6 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
+12 -10
View File
@@ -1,10 +1,12 @@
import type { Tag } from "@/core/tags";
export interface Agent {
export interface Agent {
id: string;
name: string;
description: string;
user_id: string | null;
/** Publisher username (email local part); null for built-in agents. */
owner_username?: string | null;
published: boolean;
is_private: boolean;
builtin: boolean;
@@ -17,15 +19,15 @@ export interface Agent {
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
/** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents {
@@ -27,12 +27,10 @@ import {
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
Network,
NotebookPen,
Plus,
Settings,
ShieldCheck,
Sparkles,
Target,
Trophy,
@@ -216,27 +214,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-private-square",
label: "私密广场配置",
icon: ShieldCheck,
path: "/page/strategy/settings/private-square",
adminOnly: true,
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
+3
View File
@@ -49,6 +49,9 @@ export interface Skill {
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
// Publisher username (email local part) resolved from owner_user_id by the
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean;
builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
@@ -9,6 +9,7 @@ import {
Bot,
CalendarDays,
Crown,
ExternalLink,
FileDown,
Gauge,
Layers3,
@@ -68,10 +69,10 @@ const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#C1121F]",
border: "border-[#F1D6D6] dark:border-[#462123]",
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
text: "text-[#C1121F] dark:text-[#7FACF2]",
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
border: "border-[#F1D6D6] dark:border-[#213D5E]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -284,7 +285,7 @@ function StatCard({
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}>
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
<div className="flex items-center justify-between gap-3">
<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)}>
@@ -337,13 +338,13 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
return (
<section className="grid gap-3 md:grid-cols-5">
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span>
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
</div>
))}
</section>
@@ -365,14 +366,14 @@ function GroupComparison({
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]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<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]" />
<Users className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
分组统计
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p>
</div>
@@ -380,12 +381,12 @@ function GroupComparison({
value={rulesText}
onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#011226]"
/>
</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 key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]">
<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>
@@ -402,9 +403,9 @@ function GroupComparison({
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]"
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-[#02244C]"
>
<span className="font-semibold text-[#C1121F]">{index + 1}</span>
<span className="font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}</span>
<span className="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button>
@@ -438,8 +439,8 @@ function PodiumCard({
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-[#462123] dark:bg-[#230D0F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<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-[#02244C]/70">
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
);
@@ -450,8 +451,8 @@ function PodiumCard({
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-[#230D0F]",
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]",
"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-[#02244C]",
active ? "border-[#C1121F] dark:border-[#2563EB] ring-2 ring-[#F1D6D6] dark:border-[#7FACF2] dark:ring-[#2563EB]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
)}
>
<div className="flex items-start justify-between gap-3">
@@ -462,11 +463,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm">
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] 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-[#C1121F] dark:text-[#F2B8BB]">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{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>
@@ -482,15 +483,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, "");
if (points.length === 0) {
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
}
const visible = points.slice(-32);
const series = [
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" },
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
@@ -533,15 +534,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
large && "px-4 py-3",
metric === "all"
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)}
>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F]">全部趋势</span>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span>
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#D8B8B8]">同时显示运行、问答、活跃与 Token</span>}
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
</button>
)}
{series.map((item) => (
@@ -553,8 +554,8 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
large && "px-4 py-3",
metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)}
title={item.description}
>
@@ -562,7 +563,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : compact ? "text-base" : "text-lg")}>
{fmtNumber(totalFor(item.key))}
</span>
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>}
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
</button>
))}
</div>
@@ -583,15 +584,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
</DialogHeader>
<div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">当前查看</div>
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -679,7 +680,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return (
<div className="min-w-0 space-y-3">
{metricCards({ includeAll: false })}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
{analysisDialog(
"个人使用趋势",
@@ -693,7 +694,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -704,13 +705,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
)}
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -735,11 +736,11 @@ function UserTable({
}) {
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="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
}
return (
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
{users.map((user, index) => {
const score = userScore(user, tab);
return (
@@ -748,11 +749,11 @@ function UserTable({
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10",
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#213D5E] dark:hover:bg-[#011226]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#2563EB]/10",
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
{rankOffset + index + 1}
</div>
<div className="min-w-0">
@@ -765,9 +766,9 @@ function UserTable({
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full bg-[#C1121F]"
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
@@ -801,7 +802,7 @@ function SkillRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<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" />
@@ -810,7 +811,7 @@ function SkillRanking({
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{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>
@@ -821,7 +822,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
@@ -859,7 +860,7 @@ function DimensionRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<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" />
@@ -868,7 +869,7 @@ function DimensionRanking({
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -879,7 +880,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
@@ -913,8 +914,8 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<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>
@@ -922,8 +923,8 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
@@ -942,8 +943,8 @@ function QuestionPanel({
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div>
);
@@ -971,7 +972,7 @@ function UserDetailPanel({
if (isLoading || !data) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368]">
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
@@ -981,13 +982,13 @@ function UserDetailPanel({
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
{data.user.email || data.user.user_id}
</div>
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
最近活跃 {fmtTime(data.user.last_active_at)}
</div>
</div>
@@ -996,7 +997,7 @@ function UserDetailPanel({
<ArrowRight className="ml-1 size-3.5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#462123] dark:bg-[#19090A]">
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#213D5E] dark:bg-[#011226]">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
@@ -1016,7 +1017,7 @@ function UserDetailPanel({
</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]" />
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
个人使用趋势
</div>
<TrendBars points={data.trends} compact />
@@ -1226,31 +1227,36 @@ export default function AdminUserLeaderboardPage() {
}, [apiRange.since, apiRange.until, reportModelName]);
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]">
<Award className="size-4" />
管理员分析
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-col gap-4">
{/* 标题维度:仅承载页面标题与说明 */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
管理员分析
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/chats/new")}
className="h-9"
className="h-9 shrink-0"
>
<ArrowLeft className="mr-1 size-4" />
返回系统
</Button>
</div>
{/* 操作维度:与标题区分开的控制工具条(操作按钮 + 时间筛选) */}
<div className="flex flex-wrap items-end justify-between gap-3 rounded-xl border border-[#F1D6D6] bg-white/70 px-3 py-3 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -1271,6 +1277,16 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open("#/public/leaderboard", "_blank", "noopener")}
className="h-9"
title="打开免登录的公开发布页"
>
<ExternalLink className="mr-1 size-4" />
公开页
</Button>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={!data} className="h-9">
@@ -1279,16 +1295,16 @@ export default function AdminUserLeaderboardPage() {
</Button>
</DialogTrigger>
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#462123]">
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#213D5E]">
<DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#011226]">
<SelectValue placeholder="选择报告模型" />
</SelectTrigger>
<SelectContent>
@@ -1302,7 +1318,7 @@ export default function AdminUserLeaderboardPage() {
</Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
@@ -1311,24 +1327,24 @@ export default function AdminUserLeaderboardPage() {
</div>
{data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F]" />
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注
</div>
<div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span>
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span>
</div>
))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题
</div>
<div className="space-y-2">
@@ -1337,34 +1353,34 @@ export default function AdminUserLeaderboardPage() {
{index + 1}. {clampText(item.text, 72)}
</div>
))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F]" />
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
使用频率
</div>
<div className="space-y-2">
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="truncate">{item.name}</span>
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span>
<span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{fmtNumber(item.call_count)} 次</span>
</div>
))}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
</div>
)}
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#462123] dark:bg-[#19090A]">
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#213D5E] dark:bg-[#011226]">
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div>
)}
{reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告,内容会流式显示
</div>
@@ -1375,13 +1391,13 @@ export default function AdminUserLeaderboardPage() {
</div>
)}
{reportText && (
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]">
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#02244C] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] dark:border-[#2563EB] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] dark:text-[#7FACF2] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#011226] dark:[&_code]:bg-[#011226] dark:[&_strong]:text-[#7FACF2] dark:[&_th]:bg-[#011226]">
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
</div>
)}
</ScrollArea>
{reportText && (
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#462123]">
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#213D5E]">
<Button
variant="outline"
onClick={exportReportAsWord}
@@ -1394,6 +1410,8 @@ export default function AdminUserLeaderboardPage() {
)}
</DialogContent>
</Dialog>
</div>
<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
@@ -1402,7 +1420,7 @@ export default function AdminUserLeaderboardPage() {
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-[#19090A]"
className="h-9 w-36 bg-white dark:bg-[#011226]"
/>
</div>
<div className="flex flex-col gap-1">
@@ -1413,7 +1431,7 @@ export default function AdminUserLeaderboardPage() {
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-[#19090A]"
className="h-9 w-36 bg-white dark:bg-[#011226]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1425,6 +1443,7 @@ export default function AdminUserLeaderboardPage() {
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
近 90 天
</Button>
</div>
</div>
</div>
</div>
@@ -1432,12 +1451,12 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
{isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span>
</div>
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
</div>
</div>
@@ -1450,12 +1469,12 @@ export default function AdminUserLeaderboardPage() {
)}
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
<Loader2 className="size-4 animate-spin text-[#C1121F]" />
<Loader2 className="size-4 animate-spin text-[#C1121F] dark:text-[#7FACF2]" />
<span>排行榜正在后台统计</span>
</div>
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
</div>
</div>
@@ -1495,7 +1514,7 @@ export default function AdminUserLeaderboardPage() {
/>
)}
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1534,7 +1553,7 @@ export default function AdminUserLeaderboardPage() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
className="h-9 w-64 bg-white pl-8 dark:bg-[#011226]"
/>
</div>
</div>
@@ -1558,8 +1577,8 @@ export default function AdminUserLeaderboardPage() {
</div>
</section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
@@ -1575,7 +1594,7 @@ export default function AdminUserLeaderboardPage() {
</section>
</div>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<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" />
@@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() {
};
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<SlidersHorizontal className="size-4" />
管理员设置
</div>
<h1 className="text-2xl font-semibold">排行榜设置</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
</p>
</div>
@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<ShieldOff className="size-4 text-[#C1121F]" />
<ShieldOff className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p>
</div>
@@ -111,21 +111,21 @@ export default function AdminUserLeaderboardSettingsPage() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号邮箱或 ID"
className="h-10 border-[#F1D6D6] bg-white pl-9"
className="h-10 border-[#F1D6D6] bg-white pl-9 dark:border-[#213D5E] dark:bg-[#011226] dark:text-white"
/>
{query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]">
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#213D5E] dark:bg-[#02244C]">
<ScrollArea className="max-h-72">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => addUser(user.id)}
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]"
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#213D5E] dark:hover:bg-[#361315]"
>
<span className="min-w-0">
<span className="block truncate font-medium">{user.email}</span>
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span>
<span className="block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
@@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
</div>
)}
</div>
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#011226]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
<span className="text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无排除账号</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]"
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]"
title={userLabel(user)}
>
<span className="truncate">{userLabel(user)}</span>
@@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<Save className="size-4 text-[#C1121F]" />
<Save className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p>
</div>
@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() {
].map(([key, title, desc]) => (
<label
key={key}
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]"
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#213D5E] dark:bg-[#011226]"
>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#7FACF2]">{desc}</span>
</span>
<Switch
checked={Boolean(settings?.[key as keyof typeof settings])}
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PrivateSquareSettingsPage() {
export default function PrivateSquareSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme();
@@ -95,9 +99,15 @@ export default function PrivateSquareSettingsPage() {
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className="text-xl font-semibold">私密广场配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>私密广场配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PromptPrefixSettingsPage() {
export default function PromptPrefixSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme();
@@ -83,9 +87,15 @@ export default function PromptPrefixSettingsPage() {
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Activity,
Award,
Crown,
Gauge,
Loader2,
Medal,
MessageSquareText,
Sparkles,
TrendingUp,
Users,
type LucideIcon,
} from "lucide-react";
import type {
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { getBackendBaseURL } from "@/core/config";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
function fmtNumber(value: number | undefined | null): string {
const n = Number(value ?? 0);
if (!Number.isFinite(n)) return "0";
return n.toLocaleString("zh-CN");
}
function usernameOf(email: string | undefined | null): string {
if (!email) return "匿名用户";
const at = email.indexOf("@");
return at > 0 ? email.slice(0, at) : email;
}
function fmtDate(value: string | undefined | null): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("zh-CN", { hour12: false });
}
async function fetchPublicLeaderboard(days: number): Promise<AdminLeaderboardResponse> {
const res = await fetch(
`${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=20`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `加载排行榜失败 (HTTP ${res.status})`);
}
return (await res.json()) as AdminLeaderboardResponse;
}
export default function PublicLeaderboardPage() {
const [data, setData] = useState<AdminLeaderboardResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const days = 30;
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchPublicLeaderboard(days)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
const pending = data?.status === "running" || data?.status === "refreshing";
// The snapshot may still be computing on first load — poll briefly until ready.
useEffect(() => {
if (!pending) return;
const timer = setInterval(() => {
fetchPublicLeaderboard(days)
.then((res) => setData(res))
.catch(() => undefined);
}, 3000);
return () => clearInterval(timer);
}, [pending, days]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
公开榜单
</div>
<h1 className="text-2xl font-semibold tracking-normal sm:text-3xl">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗与技能热度。
{data?.since && data?.until && (
<span className="ml-1">
({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)})
</span>
)}
</p>
</header>
{loading && !data && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-5 animate-spin" />
正在加载排行榜数据
</div>
)}
{error && (
<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}
</div>
)}
{data && (
<div className="space-y-6">
{pending && (
<div className="rounded-lg border border-[#F1D6D6] bg-white px-4 py-3 text-sm text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]">
<Loader2 className="mr-1 inline size-4 animate-spin" />
排行榜正在后台统计,数据会自动刷新。
</div>
)}
{/* 概览指标 */}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={Users} label="活跃用户" value={fmtNumber(data.overview.active_users)} sub={`共 ${fmtNumber(data.overview.total_users)} 名用户`} />
<StatCard icon={Activity} label="运行次数" value={fmtNumber(data.overview.total_runs)} sub={`${fmtNumber(data.overview.total_threads)} 个会话`} />
<StatCard icon={MessageSquareText} label="问答次数" value={fmtNumber(data.overview.total_questions)} sub={`${fmtNumber(data.overview.total_skill_calls)} 次技能调用`} />
<StatCard icon={Gauge} label="Token 消耗" value={fmtNumber(data.overview.total_tokens)} sub={`${fmtNumber(data.overview.total_tool_calls)} 次工具调用`} />
</section>
{/* 三类用户排名 */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<RankCard
title="活跃榜"
icon={Activity}
users={data.users_by_activity}
metric={(u) => u.run_count}
unit="次运行"
/>
<RankCard
title="问答榜"
icon={MessageSquareText}
users={data.users_by_questions}
metric={(u) => u.qa_count}
unit="次问答"
/>
<RankCard
title="Token 榜"
icon={Gauge}
users={data.users_by_tokens}
metric={(u) => u.total_tokens}
unit="Token"
/>
</section>
{/* 技能热度 + 高频问题 */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SkillCard skills={data.skills} />
<QuestionsCard
title="高频问题"
icon={TrendingUp}
questions={data.frequent_questions.map((q) => q.text)}
/>
</section>
</div>
)}
<footer className="mt-10 border-t border-[#F1D6D6] pt-4 text-center text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
</footer>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
sub,
}: {
icon: LucideIcon;
label: string;
value: string;
sub?: string;
}) {
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{label}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-[#231815] dark:text-white">
{value}
</div>
{sub && <div className="mt-1 truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{sub}</div>}
</div>
);
}
function RankCard({
title,
icon: Icon,
users,
metric,
unit,
}: {
title: string;
icon: LucideIcon;
users: AdminLeaderboardUser[];
metric: (u: AdminLeaderboardUser) => number;
unit: string;
}) {
const top = users.slice(0, 10);
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{title}
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ol className="space-y-1.5">
{top.map((user, index) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-[#FFF1F1] dark:hover:bg-[#361315]"
>
<RankBadge rank={index + 1} />
<span className="min-w-0 flex-1 truncate text-sm" title={usernameOf(user.email)}>
{usernameOf(user.email)}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-[#C1121F] dark:text-[#7FACF2]">
{fmtNumber(metric(user))}
</span>
<span className="shrink-0 text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{unit}</span>
</li>
))}
</ol>
)}
</div>
);
}
function RankBadge({ rank }: { rank: number }) {
const color = RANK_COLORS[Math.min(rank - 1, RANK_COLORS.length - 1)];
if (rank <= 3) {
const Icon = rank === 1 ? Crown : Medal;
return (
<span
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-white"
style={{ backgroundColor: color }}
>
<Icon className="size-3.5" />
</span>
);
}
return (
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-[#FFF1F1] text-xs font-semibold text-[#9F343A] dark:text-[#7FACF2] dark:bg-[#361315] dark:text-[#7FACF2]">
{rank}
</span>
);
}
function SkillCard({ skills }: { skills: AdminLeaderboardSkill[] }) {
const top = skills.slice(0, 10);
const max = Math.max(1, ...top.map((s) => s.call_count));
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
技能热度
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ul className="space-y-2.5">
{top.map((skill) => (
<li key={skill.name}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate" title={skill.name}>
{skill.name}
</span>
<span className="shrink-0 font-semibold tabular-nums text-[#C1121F] dark:text-[#7FACF2]">
{fmtNumber(skill.call_count)} 次
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#FFF1F1] dark:bg-[#361315]">
<div
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB] dark:bg-[#7FACF2]"
style={{ width: `${Math.max(4, Math.round((skill.call_count / max) * 100))}%` }}
/>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function QuestionsCard({
title,
icon: Icon,
questions,
}: {
title: string;
icon: LucideIcon;
questions: string[];
}) {
const top = questions.slice(0, 10);
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{title}
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ol className="space-y-2">
{top.map((text, index) => (
<li key={`${index}-${text.slice(0, 12)}`} className="flex gap-2 text-sm leading-6">
<span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}.</span>
<span className="min-w-0 text-[#5F6368] dark:text-slate-300">{text}</span>
</li>
))}
</ol>
)}
</div>
);
}
@@ -59,7 +59,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function RecommendedQuestionsPage() {
export default function RecommendedQuestionsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion();
@@ -176,10 +180,16 @@ export default function RecommendedQuestionsPage() {
: { success: undefined, error: undefined };
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">推荐问题配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>推荐问题配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
</p>