feat: enhance leaderboard AI report

This commit is contained in:
吉鑫升 committed 2026-06-05 11:00:08 +08:00
1 parent 22be6325a8
commit d59f9e218f
5 files changed
+129 -48

No files matched your search

@@ -269,14 +269,6 @@ export function PageSidebar({
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
>
<Settings className="size-4" />
排行榜设置
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
@@ -198,16 +198,6 @@ export function WorkspaceNavMenu() {
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard/settings")
}
>
<Settings2Icon />
排行榜设置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
@@ -253,13 +253,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard-settings",
label: "排行榜设置",
icon: Settings,
path: "/page/strategy/admin/user-leaderboard/settings",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
@@ -42,10 +42,12 @@ 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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
@@ -53,7 +55,9 @@ import type {
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
@@ -193,6 +197,39 @@ function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
);
}
function markdownList(items: string[]): string {
return items.length ? items.map((item) => `- ${item}`).join("\n") : "- 暂无数据";
}
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
if (!data) return "";
const recentQuestions = data.recent_questions
.slice(0, 8)
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
const frequentQuestions = data.frequent_questions
.slice(0, 8)
.map((item) => clampText(item.text, 120));
const frequentUsage = [
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
];
return [
"# AI 榜单分析报告",
"",
`统计周期:${range.since} 至 ${range.until}`,
"",
"## 最近关注",
markdownList(recentQuestions),
"",
"## 高频问题",
markdownList(frequentQuestions),
"",
"## 使用频率重点",
markdownList(frequentUsage),
].join("\n");
}
function parseGroupRules(value: string): string[] {
return value
.split(/[\n,,;;\s]+/)
@@ -1020,8 +1057,14 @@ export default function AdminUserLeaderboardPage() {
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 [reportExporting, setReportExporting] = useState(false);
const reportAbortRef = useRef<AbortController | null>(null);
const reportGenerating = reportStatus === "streaming";
const reportFocusMarkdown = useMemo(() => buildReportFocusMarkdown(data, range), [data, range]);
const reportWordMarkdown = useMemo(
() => [reportFocusMarkdown, reportText.trim()].filter(Boolean).join("\n\n---\n\n"),
[reportFocusMarkdown, reportText],
);
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -1066,6 +1109,26 @@ export default function AdminUserLeaderboardPage() {
}
};
const exportReportAsWord = async () => {
if (!reportWordMarkdown.trim()) {
toast.error("暂无可导出的报告内容");
return;
}
setReportExporting(true);
try {
await downloadMarkdownAsDocx(
reportWordMarkdown,
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}`,
"AI 榜单分析报告",
);
toast.success("Word 文件已生成");
} catch (err) {
toast.error(err instanceof Error ? err.message : "导出 Word 失败");
} finally {
setReportExporting(false);
}
};
const startReportGeneration = async () => {
reportAbortRef.current?.abort();
const controller = new AbortController();
@@ -1228,6 +1291,54 @@ export default function AdminUserLeaderboardPage() {
{reportGenerating ? "生成中" : "生成报告"}
</Button>
</div>
{data && (
<div className="grid gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F]" />
最近关注
</div>
<div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span>
</div>
))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
高频问题
</div>
<div className="space-y-2">
{data.frequent_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">
{index + 1}. {clampText(item.text, 72)}
</div>
))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F]" />
使用频率
</div>
<div className="space-y-2">
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="truncate">{item.name}</span>
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span>
</div>
))}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
</div>
</section>
</div>
)}
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
@@ -1246,25 +1357,20 @@ export default function AdminUserLeaderboardPage() {
</div>
)}
{reportText && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportText}
</pre>
<div className="rounded-lg bg-white px-5 py-4 text-sm leading-7 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-3 [&_blockquote]:py-2 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1 [&_h1]:mb-4 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-3 [&_h2]:mt-5 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-2 [&_h3]:mt-4 [&_h3]:font-semibold [&_li]:my-1 [&_ol]:pl-5 [&_p]:my-2 [&_strong]:text-[#C1121F] [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-2 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-2 [&_ul]:pl-5 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]">
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
</div>
)}
</ScrollArea>
{reportText && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportText,
"text/markdown;charset=utf-8",
)
}
onClick={exportReportAsWord}
disabled={reportExporting}
>
<FileDown className="mr-1 size-4" />
下载报告
{reportExporting ? <Loader2 className="mr-1 size-4 animate-spin" /> : <FileDown className="mr-1 size-4" />}
导出 Word
</Button>
</div>
)}