enhance admin leaderboard and previews

This commit is contained in:
Administrator committed 2026-06-05 01:51:48 +08:00
1 parent a5897caec4
commit 92f5cb8fe3
11 files changed
+937 -282

No files matched your search

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import {
ArrowDownIcon,
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
@@ -12,11 +12,11 @@ import {
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -48,10 +48,15 @@ import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { env } from "@/env";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -61,15 +66,15 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
}
@@ -714,54 +719,68 @@ export function ArtifactFilePreview({
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
return <ArtifactLink {...rest} className={className} href={resolvedHref ?? props.href} />;
},
};
}, [content]);
useEffect(() => {
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<ArtifactLink
{...rest}
className={className}
href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
@@ -770,7 +789,11 @@ export function ArtifactFilePreview({
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
@@ -783,16 +806,17 @@ export function ArtifactFilePreview({
return (
<div
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
"size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -1,18 +1,22 @@
"use client";
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
@@ -22,23 +26,23 @@ function extractTextFromChildren(children: ReactNode): string {
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
@@ -61,14 +65,14 @@ export function MarkdownContent({
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref(
@@ -88,33 +92,45 @@ export function MarkdownContent({
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<a
{...rest}
href={resolvedHref ?? props.href}
href={finalHref}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
onClick={(event) => {
// HashRouter would turn a `#section` click into a route change → 404.
// Scroll to the heading ourselves instead.
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
if (!content) return null;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}
if (!content) return null;
return (
<div data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
</div>
);
}
+82
View File
@@ -136,8 +136,18 @@ export interface AdminUserAnalyticsResponse {
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
cached?: boolean;
model_name?: string | null;
}
export type AdminLeaderboardReportStreamEvent =
| { event: "meta"; data: { cached?: boolean; model_name?: string | null } }
| { event: "cached"; data: { report: string; generated_at?: string | null; model_name?: string | null } }
| { event: "thinking"; data: { chunk: string } }
| { event: "text"; data: { chunk: string } }
| { event: "done"; data: { cached?: boolean; generated_at?: string | null; model_name?: string | null; report?: string } }
| { event: "error"; data: { message: string } };
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -216,6 +226,7 @@ export async function getUserAnalytics(
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
modelName?: string,
): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
@@ -223,6 +234,7 @@ export async function generateUserLeaderboardReport(
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST",
@@ -231,6 +243,76 @@ export async function generateUserLeaderboardReport(
);
}
function parseSseFrame(frame: string): AdminLeaderboardReportStreamEvent | null {
let event = "message";
const dataLines: string[] = [];
for (const rawLine of frame.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (!dataLines.length) return null;
const data = JSON.parse(dataLines.join("\n")) as AdminLeaderboardReportStreamEvent["data"];
return { event, data } as AdminLeaderboardReportStreamEvent;
}
export async function streamUserLeaderboardReport({
range,
limit = 30,
modelName,
signal,
onEvent,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
modelName?: string;
signal?: AbortSignal;
onEvent: (event: AdminLeaderboardReportStreamEvent) => void;
}) {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
const response = await apiFetch(`${base()}/leaderboard/report/stream?${params}`, {
method: "POST",
signal,
});
if (!response.ok) {
const err = (await response.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to stream leaderboard report (HTTP ${response.status})`);
}
if (!response.body) {
throw new Error("当前浏览器不支持流式报告读取");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() ?? "";
for (const frame of frames) {
const parsed = parseSseFrame(frame);
if (parsed) onEvent(parsed);
}
if (done) break;
}
const tail = buffer.trim();
if (tail) {
const parsed = parseSseFrame(tail);
if (parsed) onEvent(parsed);
}
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+3 -1
View File
@@ -53,10 +53,12 @@ export function useGenerateUserLeaderboardReport() {
mutationFn: ({
range,
limit,
modelName,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => generateUserLeaderboardReport(range, limit ?? 30),
modelName?: string;
}) => generateUserLeaderboardReport(range, limit ?? 30, modelName),
});
}
+71 -2
View File
@@ -3,8 +3,8 @@ export function extractTitleFromMarkdown(markdown: string) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return title;
}
return undefined;
}
@@ -52,6 +52,36 @@ function decodeLinkPart(value: string): string {
}
}
/** True for hrefs that point to an anchor within the current document (`#section`). */
export function isInPageAnchor(href: string | undefined | null): href is string {
return !!href && href.startsWith("#") && !href.startsWith("#reference-");
}
function escapeAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Scrolls to an in-page anchor by id without mutating `window.location.hash`.
*
* The app runs under a HashRouter, so letting an `<a href="#...">` click change
* the hash would be interpreted as a route change and 404. We resolve the target
* element ourselves and scroll to it instead. Scoped to the nearest
* `[data-markdown-anchor-root]` so headings in other messages don't collide.
*/
export function scrollToInPageAnchor(anchor: HTMLElement, href: string | undefined | null): void {
if (!isInPageAnchor(href)) return;
const id = decodeLinkPart(href.slice(1)).trim();
if (!id) return;
const scope = anchor.closest<HTMLElement>("[data-markdown-anchor-root]");
const selector = `[id="${escapeAttrValue(id)}"]`;
const target =
(scope?.querySelector<HTMLElement>(selector) ?? null) ??
document.getElementById(id) ??
document.querySelector<HTMLElement>(selector);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
}
@@ -68,6 +98,45 @@ function candidateTextFromHref(href: string): string {
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, "");
}
/**
* Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a
* fragment navigation blanks the frame; intercepting the click avoids that.
*/
export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){
function decode(v){try{return decodeURIComponent(v);}catch(e){return v;}}
function bySelector(id){
if(!document.querySelector)return null;
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
}
document.addEventListener("click",function(e){
var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;}
if(!el)return;
var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault();
var id=decode(href.slice(1));
var raw=href.slice(1);
var target=
document.getElementById(id)||
document.getElementById(raw)||
document.getElementsByName(id)[0]||
document.getElementsByName(raw)[0]||
bySelector(id)||
bySelector(raw);
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
},true);
})();</script>`;
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${script}</body>`);
}
return html + script;
}
export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>();
@@ -25,7 +25,7 @@ import {
Users,
type LucideIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
@@ -49,19 +50,22 @@ import type {
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: {
soft: "bg-[#EEF2FF] dark:bg-indigo-400/15",
text: "text-[#4F46E5] dark:text-indigo-200",
bar: "bg-[#4F46E5]",
border: "border-[#C7D2FE] dark:border-indigo-300/25",
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#C1121F]",
border: "border-[#F1D6D6] dark:border-[#462123]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -83,7 +87,7 @@ const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar
},
};
const RANK_COLORS = ["#4F46E5", "#0F766E", "#F59E0B", "#E11D48", "#7C3AED"] as const;
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
function toDateInput(date: Date): string {
const year = date.getFullYear();
@@ -241,7 +245,7 @@ function StatCard({
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#06213F]", color.border)}>
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
@@ -260,21 +264,47 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
const activeUsers = Math.max(1, overview.active_users);
const totalRuns = Math.max(1, overview.total_runs);
const items = [
["人均使用", overview.total_runs / activeUsers, "次/活跃用户"],
["问答密度", overview.total_questions / totalRuns, "问答/运行"],
["平均 Token", overview.total_tokens / totalRuns, "Token/运行"],
["技能覆盖", overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0, "%"],
["失败率", (overview.error_runs / totalRuns) * 100, "%"],
{
label: "人均使用",
value: overview.total_runs / activeUsers,
unit: "次/活跃用户",
description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
},
{
label: "问答密度",
value: overview.total_questions / totalRuns,
unit: "问答/运行",
description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
},
{
label: "平均 Token",
value: overview.total_tokens / totalRuns,
unit: "Token/运行",
description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
},
{
label: "技能覆盖",
value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
unit: "%",
description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
},
{
label: "失败率",
value: (overview.error_runs / totalRuns) * 100,
unit: "%",
description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
},
];
return (
<section className="grid gap-3 md:grid-cols-5">
{items.map(([label, value, unit]) => (
<div key={label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(value).toFixed(unit === "%" || String(value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{unit}</span>
{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>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
</div>
))}
</section>
@@ -363,13 +393,13 @@ function PodiumCard({
}) {
const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
2: "border-[#D2E3FC] bg-[#E8F0FE] text-[#4285F4] dark:border-[#4285F4]/40 dark:bg-[#4285F4]/10 dark:text-[#8AB4F8]",
2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
}[rank];
if (!user) {
return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#06213F]/70">
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#462123] dark:bg-[#230D0F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
@@ -381,8 +411,8 @@ function PodiumCard({
type="button"
onClick={() => onSelect(user)}
className={cn(
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#06213F]",
active ? "border-[#4285F4] ring-2 ring-[#D2E3FC] dark:border-[#8AB4F8] dark:ring-[#4285F4]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#230D0F]",
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]",
)}
>
<div className="flex items-start justify-between gap-3">
@@ -393,11 +423,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#4285F4] text-base font-semibold text-white shadow-sm">
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm">
{initials(user)}
</div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#4285F4] dark:text-[#8AB4F8]">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{scoreLabel(user, tab)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span>
@@ -408,7 +438,7 @@ function PodiumCard({
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendPoint[]; compact?: boolean }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, "");
@@ -418,10 +448,10 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const visible = points.slice(-32);
const series = [
{ key: "runs", label: "运行", color: "#4F46E5", soft: "bg-[#EEF2FF] text-[#4F46E5]" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]" },
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" },
] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
@@ -452,9 +482,11 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
visible[0],
);
const displayedSeries = metric === "all" ? series : [activeSeries];
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
const activeDays = visible.filter((point) => point.runs > 0 || point.questions > 0 || point.tokens > 0).length;
const chart = (large = false) => (
<div className={cn("overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : "h-72")} role="img" aria-label="使用趋势折线图">
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : compact ? "h-56" : "h-72")} role="img" aria-label="使用趋势折线图">
<defs>
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
@@ -471,7 +503,7 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#17395F]"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
@@ -526,8 +558,61 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</div>
);
if (compact) {
return (
<div className="min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-4">
{series.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setMetric(item.key)}
className={cn(
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
title={item.description}
>
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
<span className="mt-2 block truncate text-base font-semibold tabular-nums text-[#231815] dark:text-white">
{fmtNumber(totalFor(item.key))}
</span>
<span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Maximize2 className="mr-1 size-4" />
放大
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(1180px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>个人使用趋势</DialogTitle>
<DialogDescription>
每个折线点代表一天。运行=发起次数,问答=有效提问,活跃=有行为的用户数,Token=模型输入输出消耗。
</DialogDescription>
</DialogHeader>
{chart(true)}
</DialogContent>
</Dialog>
</div>
{chart(false)}
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button
@@ -583,13 +668,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</Dialog>
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#17395F] dark:bg-[#071F3B]">
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="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-[#0F766E]" : "text-[#E11D48]")}>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -616,7 +701,7 @@ function UserTable({
}
return (
<div className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
{users.map((user, index) => {
const score = userScore(user, tab);
return (
@@ -625,11 +710,11 @@ function UserTable({
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#EEF0F1] px-4 py-3 text-left last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]",
selectedUserId === user.user_id && "bg-[#E8F0FE] dark:bg-[#4285F4]/10",
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10",
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#F1F3F4] text-sm font-semibold text-[#5F6368] dark:bg-[#11345E] dark:text-slate-200">
<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]">
{index + 1}
</div>
<div className="min-w-0">
@@ -642,9 +727,9 @@ function UserTable({
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full bg-[#4285F4]"
className="h-2 rounded-full bg-[#C1121F]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
@@ -678,7 +763,7 @@ function SkillRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -698,7 +783,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -736,7 +821,7 @@ function DimensionRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -756,7 +841,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -790,8 +875,8 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -799,7 +884,7 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
@@ -881,16 +966,13 @@ function UserDetailPanel({
</div>
</section>
<Tabs defaultValue="skills" className="space-y-3">
<Tabs defaultValue="questions" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
@@ -899,7 +981,10 @@ function UserDetailPanel({
<TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势
</div>
<TrendBars points={data.trends} />
<TrendBars points={data.trends} compact />
</TabsContent>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3">
@@ -920,14 +1005,23 @@ export default function AdminUserLeaderboardPage() {
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false);
const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen });
const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL);
const [reportText, setReportText] = useState("");
const [reportError, setReportError] = useState<string | null>(null);
const [reportStatus, setReportStatus] = useState<"idle" | "streaming" | "done" | "cached" | "error">("idle");
const [reportGeneratedAt, setReportGeneratedAt] = useState<string | null>(null);
const [reportResolvedModel, setReportResolvedModel] = useState<string | null>(null);
const reportAbortRef = useRef<AbortController | null>(null);
const reportGenerating = reportStatus === "streaming";
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -964,6 +1058,92 @@ export default function AdminUserLeaderboardPage() {
setRange(defaultRange(days));
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
reportAbortRef.current?.abort();
reportAbortRef.current = null;
}
};
const startReportGeneration = async () => {
reportAbortRef.current?.abort();
const controller = new AbortController();
reportAbortRef.current = controller;
const selectedModelName = reportModelName === DEFAULT_REPORT_MODEL ? undefined : reportModelName;
let terminalEventSeen = false;
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(selectedModelName ?? null);
setReportStatus("streaming");
try {
await streamUserLeaderboardReport({
range: apiRange,
limit: 40,
modelName: selectedModelName,
signal: controller.signal,
onEvent: (event) => {
if (event.event === "meta") {
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
return;
}
if (event.event === "cached") {
terminalEventSeen = true;
setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus("cached");
return;
}
if (event.event === "text") {
setReportText((value) => value + event.data.chunk);
return;
}
if (event.event === "done") {
terminalEventSeen = true;
if (event.data.report) setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus(event.data.cached ? "cached" : "done");
return;
}
if (event.event === "error") {
terminalEventSeen = true;
setReportError(event.data.message);
setReportStatus("error");
}
},
});
if (!terminalEventSeen && !controller.signal.aborted) {
setReportStatus("done");
}
} catch (err) {
if (!controller.signal.aborted) {
setReportError(err instanceof Error ? err.message : String(err));
setReportStatus("error");
}
} finally {
if (reportAbortRef.current === controller) {
reportAbortRef.current = null;
}
}
};
useEffect(() => {
return () => reportAbortRef.current?.abort();
}, []);
useEffect(() => {
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(null);
setReportStatus("idle");
}, [apiRange.since, apiRange.until, reportModelName]);
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
@@ -1010,21 +1190,11 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!data || reportMutation.isPending}
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
className="h-9"
>
{reportMutation.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Sparkles className="mr-1 size-4" />
)}
生成报告
<Button variant="outline" size="sm" disabled={!data} className="h-9">
<Sparkles className="mr-1 size-4" />
AI 报告
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
@@ -1034,32 +1204,61 @@ export default function AdminUserLeaderboardPage() {
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<div className="flex 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="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
<SelectValue placeholder="选择报告模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_REPORT_MODEL}>默认模型</SelectItem>
{reportModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
{reportGenerating ? "生成中" : "生成报告"}
</Button>
</div>
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportMutation.isPending && (
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div>
)}
{reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告
正在调用大模型生成报告,内容会流式显示
</div>
)}
{reportMutation.error && (
{reportError && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-3 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
生成失败:{reportMutation.error.message}
生成失败:{reportError}
</div>
)}
{reportMutation.data?.report && (
{reportText && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report}
{reportText}
</pre>
)}
</ScrollArea>
{reportMutation.data?.report && (
{reportText && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report,
reportText,
"text/markdown;charset=utf-8",
)
}
@@ -1079,7 +1278,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<div className="flex flex-col gap-1">
@@ -1090,7 +1289,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1124,21 +1323,29 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="indigo" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<InsightStrip overview={overview} />
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setShowGroupStats((value) => !value)}>
<Users className="mr-1 size-4" />
{showGroupStats ? "隐藏分组统计" : "显示分组统计"}
</Button>
</div>
{showGroupStats && (
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
)}
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1177,7 +1384,7 @@ export default function AdminUserLeaderboardPage() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]"
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
/>
</div>
</div>
@@ -1189,8 +1396,8 @@ export default function AdminUserLeaderboardPage() {
/>
</section>
<section className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center gap-2 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
@@ -1206,7 +1413,7 @@ export default function AdminUserLeaderboardPage() {
</section>
</div>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -39,6 +39,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
@@ -953,10 +954,8 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
)}
{activeTabMeta?.kind === "html" ? (
htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
<HtmlPreviewFrame
html={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
@@ -18,6 +18,7 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
@@ -100,22 +101,6 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
/**
* Sandboxed preview of a generated HTML document. The iframe has no
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
* of the server-side sanitization that already stripped script vectors.
*/
function HtmlPreviewFrame({ html }: { html: string }) {
return (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={html}
className="h-[70vh] w-full rounded-md border bg-white"
/>
);
}
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
@@ -5,7 +5,11 @@ import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
@@ -74,13 +78,20 @@ function createMarkdownComponents(content: string) {
</span>
);
}
const finalHref = resolvedHref ?? href;
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return (
<a
href={resolvedHref ?? href}
href={finalHref}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="text-primary underline hover:opacity-80"
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
}}
>
{children}
</a>
@@ -203,7 +214,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
<div
data-markdown-anchor-root
className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700"
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />