enhance admin leaderboard and previews
This commit is contained in:
1 parent
a5897caec4
commit
92f5cb8fe3
11 files changed
+937
-282
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user