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:
1 parent
8113b822c5
commit
80d2db6315
26 files changed
+1164
-254
No files matched your search
+4
-1
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={() =>
|
||||||
|
|||||||
@@ -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: "大模型调用统计",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+50
-6
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+246
@@ -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: {}
|
||||||
Reference in new issue
Block a user