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
+1164 -254

No files matched your search

+4 -1
View File
@@ -1,7 +1,7 @@
# Workspace/editor # Workspace/editor
.vscode/ .vscode/
frontend-web/.vscode/ frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared) # Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json .claude/settings.local.json
@@ -84,6 +84,9 @@ offline-backend-20260512/backend/tests/*
deploy deploy
.history .history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only) # Repo-root offline dependency pack (local only)
/dist/ /dist/
+1
View File
@@ -74,6 +74,7 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "11.14.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+3
View File
@@ -19,6 +19,7 @@ import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage, PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage"; } from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage"; import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { ChatRuntime } from "./pages/WorkspaceRoutes";
@@ -66,6 +67,8 @@ export function App() {
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */} {/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} /> <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. */} {/* Public read-only roundtable share — landing + full result report. */}
<Route <Route
path="/roundtable-share/:shareCode" path="/roundtable-share/:shareCode"
@@ -14,6 +14,7 @@ import {
StarIcon, StarIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { 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 = [ const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({ ...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`, key: `tg:${group}`,
@@ -702,6 +715,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)} {desensitize(agent.description)}
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +761,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)} {desensitize(agent.description)}
</p> </p>
)} )}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons} {showActionRow && actionButtons}
@@ -30,6 +30,10 @@ import {
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; 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"; import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [ const languageOptions: { value: Locale; label: string }[] = [
@@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
</div> </div>
)} )}
</SettingsSection> </SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
</>
)}
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
"use client"; "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 { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -458,12 +458,24 @@ const CTX_LABELS: Record<string, string> = {
topOfMind: "当前重点", topOfMind: "当前重点",
}; };
const CTX_ICONS: Record<string, React.ElementType> = {
workContext: Briefcase,
personalContext: UserRound,
topOfMind: Sparkles,
};
const HISTORY_LABELS: Record<string, string> = { const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期", recentMonths: "近期",
earlierContext: "早期背景", earlierContext: "早期背景",
longTermBackground: "长期背景", longTermBackground: "长期背景",
}; };
const HISTORY_ICONS: Record<string, React.ElementType> = {
recentMonths: Clock,
earlierContext: History,
longTermBackground: Archive,
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = { const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: { zh: {
preference: "偏好", preference: "偏好",
@@ -572,12 +584,15 @@ function MemoryV1View({
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
{categoryLabel} {categoryLabel}
</span> </span>
<div className="flex flex-col items-end gap-1"> <div
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider"> className="flex flex-col items-end gap-1"
{Math.round(fact.confidence * 100)}% title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
>
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
置信度 {Math.round(fact.confidence * 100)}%
</span> </span>
{/* Confidence Progress Bar */} {/* 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 <div
className="h-full rounded-full bg-primary transition-all duration-500" className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }} style={{ width: `${Math.round(fact.confidence * 100)}%` }}
@@ -611,44 +626,77 @@ function MemoryV1View({
</TabsContent> </TabsContent>
{/* 用户画像 & 历史摘要 Tab */} {/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4"> <TabsContent value="profile" className="mt-3 space-y-5">
{/* 用户画像 (userContext) */} {/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]"> <section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<h3 className="text-base font-medium">用户画像</h3> <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 ? ( {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"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ctxEntries.map(([key, value]) => ( {ctxEntries.map(([key, value]) => {
<div key={key} className="text-sm"> const Icon = CTX_ICONS[key] ?? UserRound;
<dt className="font-medium text-muted-foreground mb-0.5"> return (
{CTX_LABELS[key] ?? key} <div
</dt> key={key}
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd> 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> >
))} <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
</dl> <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) */} {/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]"> <section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<h3 className="text-base font-medium">历史摘要</h3> <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 ? ( {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"> <div className="space-y-3">
{historyEntries.map(([key, value]) => ( {historyEntries.map(([key, value]) => {
<div key={key} className="text-sm"> const Icon = HISTORY_ICONS[key] ?? History;
<dt className="font-medium text-muted-foreground mb-0.5"> return (
{HISTORY_LABELS[key] ?? key} <div
</dt> key={key}
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd> 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"
</div> >
))} <span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
</dl> <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> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -22,6 +22,7 @@ import {
Trash2Icon, Trash2Icon,
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -631,6 +632,12 @@ export function SkillRow({
const metaNodes = ( 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 && ( {skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0 {skill.use_count > 0
@@ -109,13 +109,20 @@ export function SkillDetailDialog({
const canManage = useMemo(() => canManageSkill(skill), [skill]); const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(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 { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, error: contentError,
} = useSkillContent(open ? skillName : null); } = useSkillContent(open ? skillName : null);
const filesEnabled = open && isOwner && activeTab === "files"; const filesEnabled = open && canViewFiles && activeTab === "files";
const { const {
data: filesData, data: filesData,
isLoading: isFilesLoading, isLoading: isFilesLoading,
@@ -227,7 +234,7 @@ export function SkillDetailDialog({
展示适配 展示适配
</TabsTrigger> </TabsTrigger>
)} )}
{isOwner && ( {canViewFiles && (
<TabsTrigger value="files" className="text-xs"> <TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" /> <FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle} {tx.filesSectionTitle}
@@ -349,7 +356,7 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{isOwner && ( {canViewFiles && (
<TabsContent <TabsContent
value="files" value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -9,16 +9,12 @@ import {
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
ShieldCheckIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
TrophyIcon, TrophyIcon,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -149,36 +145,6 @@ export function WorkspaceNavMenu() {
<Settings2Icon /> <Settings2Icon />
{t.common.settings} {t.common.settings}
</DropdownMenuItem> </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 && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -199,14 +165,6 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移 Checkpoint 迁移
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
+2
View File
@@ -5,6 +5,8 @@ export interface Agent {
name: string; name: string;
description: string; description: string;
user_id: string | null; user_id: string | null;
/** Publisher username (email local part); null for built-in agents. */
owner_username?: string | null;
published: boolean; published: boolean;
is_private: boolean; is_private: boolean;
builtin: boolean; builtin: boolean;
@@ -27,12 +27,10 @@ import {
ListChecks, ListChecks,
ListTodo, ListTodo,
MessageSquareCode, MessageSquareCode,
MessageSquarePlus,
Network, Network,
NotebookPen, NotebookPen,
Plus, Plus,
Settings, Settings,
ShieldCheck,
Sparkles, Sparkles,
Target, Target,
Trophy, Trophy,
@@ -216,27 +214,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: ListChecks, icon: ListChecks,
path: "/page/strategy/scheduled/tasks", 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", id: "mgmt-llm-metrics",
label: "大模型调用统计", label: "大模型调用统计",
+3
View File
@@ -49,6 +49,9 @@ export interface Skill {
// Multitenancy fields — owner_user_id is null for built-in / public skills. // Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side. // ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null; 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; published?: boolean;
builtin?: boolean; builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
@@ -9,6 +9,7 @@ import {
Bot, Bot,
CalendarDays, CalendarDays,
Crown, Crown,
ExternalLink,
FileDown, FileDown,
Gauge, Gauge,
Layers3, Layers3,
@@ -68,10 +69,10 @@ const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15", soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]", text: "text-[#C1121F] dark:text-[#7FACF2]",
bar: "bg-[#C1121F]", bar: "bg-[#C1121F] dark:bg-[#2563EB]",
border: "border-[#F1D6D6] dark:border-[#462123]", border: "border-[#F1D6D6] dark:border-[#213D5E]",
}, },
rose: { rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15", soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -284,7 +285,7 @@ function StatCard({
}) { }) {
const color = accentClasses[accent]; const color = accentClasses[accent];
return ( 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"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span> <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)}> <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 ( return (
<section className="grid gap-3 md:grid-cols-5"> <section className="grid gap-3 md:grid-cols-5">
{items.map((item) => ( {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 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-[#D8B8B8]">{item.label}</div> <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"> <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)} {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> </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> </div>
))} ))}
</section> </section>
@@ -365,14 +366,14 @@ function GroupComparison({
const groups = buildGroupStats(users, rules); const groups = buildGroupStats(users, rules);
return ( 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 className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <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> </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;未命中的用户归入其他。 输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p> </p>
</div> </div>
@@ -380,12 +381,12 @@ function GroupComparison({
value={rulesText} value={rulesText}
onChange={(event) => onRulesChange(event.target.value)} onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two" 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>
<div className="grid gap-3 xl:grid-cols-3"> <div className="grid gap-3 xl:grid-cols-3">
{groups.map((group) => ( {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="mb-3 flex items-center justify-between gap-2">
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div> <div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
<Badge variant="outline">{group.userCount} 人</Badge> <Badge variant="outline">{group.userCount} 人</Badge>
@@ -402,9 +403,9 @@ function GroupComparison({
key={user.user_id} key={user.user_id}
type="button" type="button"
onClick={() => onSelectUser(user)} 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="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span> <span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button> </button>
@@ -438,8 +439,8 @@ function PodiumCard({
if (!user) { if (!user) {
return ( 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="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]">第 {rank} 名</div> <div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div> <div className="text-sm text-[#80868B]">暂无数据</div>
</div> </div>
); );
@@ -450,8 +451,8 @@ function PodiumCard({
type="button" type="button"
onClick={() => onSelect(user)} onClick={() => onSelect(user)}
className={cn( 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]", "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] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]", 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"> <div className="flex items-start justify-between gap-3">
@@ -462,11 +463,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>} {user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div> </div>
<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)} {initials(user)}
</div> </div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</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"> <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.run_count)}</span>
<span>问答 {fmtNumber(user.qa_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 [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, ""); const chartId = useId().replace(/:/g, "");
if (points.length === 0) { 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 visible = points.slice(-32);
const series = [ const series = [
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" }, { 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]", 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]", 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]", description: "每天模型输入输出 Token 总量" }, { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
] as const; ] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric; const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0]; 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", "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", large && "px-4 py-3",
metric === "all" metric === "all"
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", : "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")}> <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))} {fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span> </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> </button>
)} )}
{series.map((item) => ( {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", "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", large && "px-4 py-3",
metric === item.key metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)} )}
title={item.description} 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")}> <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))} {fmtNumber(totalFor(item.key))}
</span> </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> </button>
))} ))}
</div> </div>
@@ -583,15 +584,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
</DialogHeader> </DialogHeader>
<div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div> <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="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="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">当前查看</div> <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-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="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)} 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div> </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))} 峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div> </div>
</div> </div>
@@ -679,7 +680,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return ( return (
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
{metricCards({ includeAll: false })} {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> <span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
{analysisDialog( {analysisDialog(
"个人使用趋势", "个人使用趋势",
@@ -693,7 +694,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return ( return (
<div className="space-y-4"> <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,全部趋势会同时展示四条多色折线。 每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p> </p>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -704,13 +705,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
)} )}
</div> </div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]"> <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="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div> <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="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)} 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div> </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))} 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div> </div>
</div> </div>
@@ -735,11 +736,11 @@ function UserTable({
}) { }) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab))); const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
if (users.length === 0) { 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 ( 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) => { {users.map((user, index) => {
const score = userScore(user, tab); const score = userScore(user, tab);
return ( return (
@@ -748,11 +749,11 @@ function UserTable({
type="button" type="button"
onClick={() => onSelect(user)} onClick={() => onSelect(user)}
className={cn( 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]", "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-[#C1121F]/10", 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} {rankOffset + index + 1}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -765,9 +766,9 @@ function UserTable({
</div> </div>
</div> </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 <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)}%` }} style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/> />
</div> </div>
@@ -801,7 +802,7 @@ function SkillRanking({
}) { }) {
const max = Math.max(1, ...items.map((item) => item.call_count)); const max = Math.max(1, ...items.map((item) => item.call_count));
return ( 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="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -810,7 +811,7 @@ function SkillRanking({
<Badge variant="outline">{items.length} 项</Badge> <Badge variant="outline">{items.length} 项</Badge>
</div> </div>
<div className="space-y-3"> <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) => ( {items.slice(0, 10).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3"> <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> <span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -821,7 +822,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </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 <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -859,7 +860,7 @@ function DimensionRanking({
}) { }) {
const max = Math.max(1, ...items.map((item) => item.count)); const max = Math.max(1, ...items.map((item) => item.count));
return ( 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="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -868,7 +869,7 @@ function DimensionRanking({
<Badge variant="outline">{items.length} 项</Badge> <Badge variant="outline">{items.length} 项</Badge>
</div> </div>
<div className="space-y-3"> <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) => ( {items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3"> <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> <span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -879,7 +880,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </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 <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -913,8 +914,8 @@ function QuestionPanel({
showUser?: boolean; showUser?: boolean;
}) { }) {
return ( 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]"> <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-[#462123]"> <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"> <div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" /> <MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2> <h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -922,8 +923,8 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge> <Badge variant="outline">{items.length}</Badge>
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]"> <div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>} {items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => ( {items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3"> <div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div> <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 }) { function Metric({ label, value }: { label: string; value: number }) {
return ( return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]"> <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-[#D8B8B8]">{label}</div> <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 className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div> </div>
); );
@@ -971,7 +972,7 @@ function UserDetailPanel({
if (isLoading || !data) { if (isLoading || !data) {
return ( 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" /> <Loader2 className="size-4 animate-spin" />
加载用户详情 加载用户详情
</div> </div>
@@ -981,13 +982,13 @@ function UserDetailPanel({
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-4 p-4"> <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="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white"> <div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
{data.user.email || data.user.user_id} {data.user.email || data.user.user_id}
</div> </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)} 最近活跃 {fmtTime(data.user.last_active_at)}
</div> </div>
</div> </div>
@@ -996,7 +997,7 @@ function UserDetailPanel({
<ArrowRight className="ml-1 size-3.5" /> <ArrowRight className="ml-1 size-3.5" />
</Button> </Button>
</div> </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.qa_count} />
<Metric label="运行次数" value={data.user.run_count} /> <Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} /> <Metric label="技能调用" value={data.user.skill_call_count} />
@@ -1016,7 +1017,7 @@ function UserDetailPanel({
</TabsContent> </TabsContent>
<TabsContent value="trend" className="min-h-[420px]"> <TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <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> </div>
<TrendBars points={data.trends} compact /> <TrendBars points={data.trends} compact />
@@ -1226,31 +1227,36 @@ export default function AdminUserLeaderboardPage() {
}, [apiRange.since, apiRange.until, reportModelName]); }, [apiRange.since, apiRange.until, reportModelName]);
return ( 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"> <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-end justify-between gap-4"> <div className="flex flex-col gap-4">
<div> {/* 标题维度:仅承载页面标题与说明 */}
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]"> <div className="flex flex-wrap items-start justify-between gap-4">
<Award className="size-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> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => navigate("/page/workspace/chats/new")} onClick={() => navigate("/page/workspace/chats/new")}
className="h-9" className="h-9 shrink-0"
> >
<ArrowLeft className="mr-1 size-4" /> <ArrowLeft className="mr-1 size-4" />
返回系统 返回系统
</Button> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1271,6 +1277,16 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" /> <FileDown className="mr-1 size-4" />
导出 Excel 导出 Excel
</Button> </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}> <Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={!data} className="h-9"> <Button variant="outline" size="sm" disabled={!data} className="h-9">
@@ -1279,16 +1295,16 @@ export default function AdminUserLeaderboardPage() {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"> <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> <DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription> <DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。 基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription> </DialogDescription>
</DialogHeader> </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"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}> <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="选择报告模型" /> <SelectValue placeholder="选择报告模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1302,7 +1318,7 @@ export default function AdminUserLeaderboardPage() {
</Select> </Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>} {reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</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> </div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}> <Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />} {reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
@@ -1311,24 +1327,24 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
{data && reportStatus === "idle" && !reportText && ( {data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3"> <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"> <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>
<div className="space-y-2"> <div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => ( {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"> <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> <span className="ml-1">{clampText(item.text, 58)}</span>
</div> </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> </div>
</section> </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"> <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>
<div className="space-y-2"> <div className="space-y-2">
@@ -1337,34 +1353,34 @@ export default function AdminUserLeaderboardPage() {
{index + 1}. {clampText(item.text, 72)} {index + 1}. {clampText(item.text, 72)}
</div> </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> </div>
</section> </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"> <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>
<div className="space-y-2"> <div className="space-y-2">
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => ( {[...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"> <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="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> </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> </div>
</section> </section>
</div> </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 && ( {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> </div>
)} )}
{reportGenerating && !reportText && ( {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" /> <Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告,内容会流式显示 正在调用大模型生成报告,内容会流式显示
</div> </div>
@@ -1375,13 +1391,13 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
)} )}
{reportText && ( {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={[]} /> <MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{reportText && ( {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 <Button
variant="outline" variant="outline"
onClick={exportReportAsWord} onClick={exportReportAsWord}
@@ -1394,6 +1410,8 @@ export default function AdminUserLeaderboardPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label> <label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input <Input
@@ -1402,7 +1420,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()} onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))} 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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -1413,7 +1431,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()} onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))} 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> </div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}> <Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1425,6 +1443,7 @@ export default function AdminUserLeaderboardPage() {
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}> <Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
近 90 天 近 90 天
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1432,12 +1451,12 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6"> <div className="space-y-5 p-6">
{isLoading && ( {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"> <div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span> <span>正在统计排行榜数据</span>
</div> </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>
</div> </div>
@@ -1450,12 +1469,12 @@ export default function AdminUserLeaderboardPage() {
)} )}
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && ( {!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"> <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> <span>排行榜正在后台统计</span>
</div> </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 || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"} {data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
</div> </div>
</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 className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2> <h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1534,7 +1553,7 @@ export default function AdminUserLeaderboardPage() {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID" 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>
</div> </div>
@@ -1558,8 +1577,8 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
</section> </section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <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-[#462123]"> <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" /> <UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2> <h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div> </div>
@@ -1575,7 +1594,7 @@ export default function AdminUserLeaderboardPage() {
</section> </section>
</div> </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="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" /> <TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() {
}; };
return ( 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"> <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 className="flex flex-wrap items-center justify-between gap-3">
<div> <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" /> <SlidersHorizontal className="size-4" />
管理员设置 管理员设置
</div> </div>
<h1 className="text-2xl font-semibold">排行榜设置</h1> <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> </p>
</div> </div>
@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6"> <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 className="mb-4 flex items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold"> <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> </div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。 名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p> </p>
</div> </div>
@@ -111,21 +111,21 @@ export default function AdminUserLeaderboardSettingsPage() {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号邮箱或 ID" 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 && ( {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"> <ScrollArea className="max-h-72">
{candidates.map((user) => ( {candidates.map((user) => (
<button <button
key={user.id} key={user.id}
type="button" type="button"
onClick={() => addUser(user.id)} 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="min-w-0">
<span className="block truncate font-medium">{user.email}</span> <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> </span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge> <Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button> </button>
@@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
</div> </div>
)} )}
</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 ? ( {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"> <div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => ( {selectedUsers.map((user) => (
<span <span
key={user.id} 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)} title={userLabel(user)}
> >
<span className="truncate">{userLabel(user)}</span> <span className="truncate">{userLabel(user)}</span>
@@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
</div> </div>
</section> </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 className="mb-4 flex items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold"> <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> </div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。 关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p> </p>
</div> </div>
@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() {
].map(([key, title, desc]) => ( ].map(([key, title, desc]) => (
<label <label
key={key} 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>
<span className="block font-medium">{title}</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> </span>
<Switch <Switch
checked={Boolean(settings?.[key as keyof typeof settings])} checked={Boolean(settings?.[key as keyof typeof settings])}
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function PrivateSquareSettingsPage() { export default function PrivateSquareSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePrivateSquareStatus(); const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings(); const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -95,9 +99,15 @@ export default function PrivateSquareSettingsPage() {
}; };
return ( 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> <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 className="text-muted-foreground mt-1 text-sm">
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场; 管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。 其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
}, },
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function PromptPrefixSettingsPage() { export default function PromptPrefixSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePromptPrefixSettings(); const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings(); const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -83,9 +87,15 @@ export default function PromptPrefixSettingsPage() {
}; };
return ( 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> <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 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]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function RecommendedQuestionsPage() { export default function RecommendedQuestionsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { theme } = useTheme(); const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions(); const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion(); const createMutation = useCreateRecommendedQuestion();
@@ -176,10 +180,16 @@ export default function RecommendedQuestionsPage() {
: { success: undefined, error: undefined }; : { success: undefined, error: undefined };
return ( 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 className="flex items-center justify-between">
<div> <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 className="text-muted-foreground mt-1 text-sm">
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。 管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
</p> </p>
@@ -250,6 +250,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Public Leaderboard** (`/api/public/leaderboard`) | **Unauthenticated** read-only user leaderboard snapshot powering the public publish page (`#/public/leaderboard`). `GET /` returns the same `AdminLeaderboardResponse` as the admin endpoint, reusing `_load_or_schedule_daily_leaderboard` and honoring the system-wide leaderboard settings (excluded users, scheduled/admin/failed inclusion). No admin controls, no mutations. Router: `app/gateway/routers/public_leaderboard.py`. |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -45,6 +45,7 @@ from app.gateway.routers import (
multi_agent, multi_agent,
notifications, notifications,
public_embed, public_embed,
public_leaderboard,
public_scheduled_tasks, public_scheduled_tasks,
recommend, recommend,
recommended_questions, recommended_questions,
@@ -1244,6 +1245,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# surface results without a DeerFlow login. # surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router) app.include_router(public_scheduled_tasks.router)
# Public read-only user leaderboard publish page (/api/public/leaderboard)
app.include_router(public_leaderboard.router)
# Public embed Q&A status polling (/api/public/embed/*) # Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router) app.include_router(public_embed.router)
app.include_router(html_page_favorites.router) app.include_router(html_page_favorites.router)
@@ -39,6 +39,7 @@ class AgentResponse(BaseModel):
name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.") name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.")
description: str = Field(default="", description="Agent description") description: str = Field(default="", description="Agent description")
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.") user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
published: bool = Field(default=False, description="Whether a user-created agent is public") published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one") is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
builtin: bool = Field(default=False, description="True when user_id is null") builtin: bool = Field(default=False, description="True when user_id is null")
@@ -159,6 +160,41 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin" return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _resolve_usernames(user_ids: set[str]) -> dict[str, str]:
"""Batch-resolve owner user ids to friendly usernames (email local part).
One lookup per distinct id; tolerates missing accounts (deleted users) and
an uninitialised auth provider by simply omitting those ids from the map.
"""
user_ids = {uid for uid in user_ids if uid}
if not user_ids:
return {}
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return {}
names: dict[str, str] = {}
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
return names
def _validate_display_name(name: str) -> str: def _validate_display_name(name: str) -> str:
normalized = name.strip() normalized = name.strip()
if not normalized: if not normalized:
@@ -482,6 +518,7 @@ async def _build_list_responses(
assignments = await tag_store.list_assignments("agent", ids) assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id) pin_ids = await store.list_pin_ids(user_id)
usernames = await _resolve_usernames({r.user_id for r in responses if r.user_id})
for resp in responses: for resp in responses:
cached = extras.get(resp.id) cached = extras.get(resp.id)
if cached is not None: if cached is not None:
@@ -491,6 +528,8 @@ async def _build_list_responses(
resp.tags = assignments.get(resp.id, []) resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids resp.is_pinned = resp.id in pin_ids
if resp.user_id:
resp.owner_username = usernames.get(resp.user_id)
return responses return responses
@@ -0,0 +1,97 @@
"""Public (unauthenticated) read-only user leaderboard.
Mounted under the ``/api/public/`` prefix so the auth middleware lets this route
through without a session cookie. Powers the public "publish" page for the user
leaderboard — a view-only snapshot with no admin controls and no mutations.
The snapshot honors the same system-wide leaderboard settings (excluded users,
scheduled/admin/failed inclusion) as the authenticated admin endpoint, so any
accounts an admin chose to hide stay hidden on the public page as well.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, HTTPException, Query
from app.gateway.routers.admin_users import (
AdminLeaderboardResponse,
_load_or_schedule_daily_leaderboard,
_resolve_range,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
logger = logging.getLogger(__name__)
# Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``).
router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"])
def _mask_email(email: str | None) -> str:
"""Reduce an email to its local part so the public page can show a
recognizable handle without leaking the full address (domain).
The leaderboard only makes sense if it names its top users, so the local
part is kept intentionally; the privacy gain is dropping the domain and the
raw account id from the unauthenticated payload.
"""
if not email:
return ""
return email.split("@", 1)[0].strip() or ""
def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
"""Strip PII from a leaderboard snapshot before it leaves over the public,
unauthenticated endpoint: email → local part, account/thread ids → opaque
short hashes (kept stable so they still work as React keys)."""
def hashed(value: str | None) -> str:
if not value:
return ""
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12]
seen_users: set[int] = set()
for bucket in (resp.users_by_activity, resp.users_by_questions, resp.users_by_tokens):
for user in bucket:
if id(user) in seen_users:
continue
seen_users.add(id(user))
user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id)
for bucket in (resp.recent_questions, resp.frequent_questions):
for question in bucket:
question.email = _mask_email(question.email)
question.user_id = hashed(question.user_id)
question.thread_id = ""
question.run_id = ""
return resp
@router.get("", response_model=AdminLeaderboardResponse)
@router.get("/", response_model=AdminLeaderboardResponse)
async def get_public_leaderboard(
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return a read-only leaderboard snapshot for the public publish page."""
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
resp = await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
)
return _anonymize(resp)
@@ -56,6 +56,44 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin" return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _attach_owner_usernames(responses: list["SkillResponse"]) -> list["SkillResponse"]:
"""Resolve each custom skill's ``owner_user_id`` to a friendly username.
One lookup per distinct owner id; tolerates deleted accounts and an
uninitialised auth provider by leaving ``owner_username`` as None.
"""
owner_ids = {r.owner_user_id for r in responses if r.owner_user_id}
if not owner_ids:
return responses
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return responses
names: dict[str, str] = {}
for uid in owner_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
for resp in responses:
if resp.owner_user_id:
resp.owner_username = names.get(resp.owner_user_id)
return responses
def _can_view_skill_source( def _can_view_skill_source(
category: SkillCategory, category: SkillCategory,
owner_user_id: str | None, owner_user_id: str | None,
@@ -125,6 +163,7 @@ class SkillResponse(BaseModel):
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)") featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin") detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
@@ -786,6 +825,7 @@ async def list_skills(
) )
) )
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = await _attach_owner_usernames(responses)
responses = _sort_skill_responses(responses, sort) responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except HTTPException: except HTTPException:
@@ -1176,6 +1216,7 @@ async def list_custom_skills(
display_sample=adapter.get("sample") if adapter else None, display_sample=adapter.get("sample") if adapter else None,
) )
) )
responses = await _attach_owner_usernames(responses)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except Exception as e: except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True) logger.error("Failed to list custom skills: %s", e, exc_info=True)
@@ -79,6 +79,52 @@ def _sqlite_conn_str_for_postgres_fallback(config) -> str:
return paths[0] if paths else "store.db" return paths[0] if paths else "store.db"
def _pool_size_env(name: str, default: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
return max(0, int(raw))
except ValueError:
logger.warning("Invalid %s=%r; using default %d", name, raw, default)
return default
@contextlib.asynccontextmanager
async def _postgres_saver_from_pool(conn_string: str) -> AsyncIterator[Checkpointer]:
"""Yield an ``AsyncPostgresSaver`` backed by a self-healing connection pool.
``AsyncPostgresSaver.from_conn_string`` opens a *single* long-lived
connection. If that one connection dies (Postgres restart, idle timeout,
network blip, or a prior broken pipeline), every later checkpointer call
fails permanently with "the connection is closed" until the process is
restarted. A pool with ``check=check_connection`` validates each connection
on checkout and transparently replaces dead ones, so the runtime recovers
on its own.
"""
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
pool = AsyncConnectionPool(
conninfo=conn_string,
min_size=_pool_size_env("DEER_FLOW_PG_POOL_MIN", 1),
max_size=_pool_size_env("DEER_FLOW_PG_POOL_MAX", 10),
max_idle=300.0,
max_lifetime=3600.0,
check=AsyncConnectionPool.check_connection,
kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row},
open=False,
)
await pool.open(wait=True)
try:
saver = AsyncPostgresSaver(pool)
await saver.setup()
yield saver
finally:
await pool.close()
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpointer]: async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpointer]:
try: try:
@@ -121,15 +167,14 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
return return
try: try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # noqa: F401
except ImportError as exc: except ImportError as exc:
raise ImportError(POSTGRES_INSTALL) from exc raise ImportError(POSTGRES_INSTALL) from exc
if not config.connection_string: if not config.connection_string:
raise ValueError(POSTGRES_CONN_REQUIRED) raise ValueError(POSTGRES_CONN_REQUIRED)
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver: async with _postgres_saver_from_pool(config.connection_string) as saver:
await saver.setup()
if not _dual_read_enabled(config): if not _dual_read_enabled(config):
yield saver yield saver
return return
@@ -205,15 +250,14 @@ async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpoi
if db_config.backend == "postgres": if db_config.backend == "postgres":
try: try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # noqa: F401
except ImportError as exc: except ImportError as exc:
raise ImportError(POSTGRES_INSTALL) from exc raise ImportError(POSTGRES_INSTALL) from exc
if not db_config.postgres_url: if not db_config.postgres_url:
raise ValueError("database.postgres_url is required for the postgres backend") raise ValueError("database.postgres_url is required for the postgres backend")
async with AsyncPostgresSaver.from_conn_string(db_config.postgres_url) as saver: async with _postgres_saver_from_pool(db_config.postgres_url) as saver:
await saver.setup()
yield saver yield saver
return return
+1 -1
View File
@@ -200,7 +200,7 @@ checkpointer:
type: postgres type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。 # true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。 # 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: true use_postgres: false
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: backend/.deer-flow/data/deerflow.db sqlite_connection_string: backend/.deer-flow/data/deerflow.db
dual_read_enabled: true dual_read_enabled: true
+246
View File
@@ -0,0 +1,246 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
axios:
specifier: ^1.16.1
version: 1.17.0
packages:
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.17.0:
resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
asynckit@0.4.0: {}
axios@1.17.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
debug@4.4.3:
dependencies:
ms: 2.1.3
delayed-stream@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
gopd@1.2.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
ms@2.1.3: {}
proxy-from-env@2.1.0: {}