Files
ttstd-infinite-canvas/web/src/pages/admin/components/analytics-panel.tsx

874 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
import { useSearchParams } from "react-router";
import { ListToolbar, PaginationBar } from "@/components/layout/workspace-page";
import {
createAdminModelPricing,
deleteAdminModelPricing,
exportAdminAnalytics,
getAdminAnalytics,
listAdminUsers,
listAdminModelPricings,
updateAdminModelPricing,
type AdminReferenceData,
type AdminAnalytics,
type AnalyticsFilters,
type ModelPricing,
} from "@/services/api/auth";
import { AdminDataTable, AdminExportButton, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
type Props = {
users: AdminReferenceData["users"];
channels: AdminReferenceData["channels"];
};
type PricingFormValues = {
channelId?: string;
model: string;
capability: ModelPricing["capability"];
currency: string;
inputPerMillion?: string;
outputPerMillion?: string;
cachedPerMillion?: string;
perRequest?: string;
perMedia?: string;
perVideoSecond?: string;
};
type TrendMetric = "volume" | "quality" | "activity";
type AnalysisTab = "models" | "users" | "failures";
type RangePreset = "7d" | "30d" | "60d";
const capabilityOptions = [
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
];
export default function AnalyticsPanel({ users, channels }: Props) {
const { message } = App.useApp();
const [searchParams, setSearchParams] = useSearchParams();
const [rangePreset, setRangePreset] = useState<RangePreset | undefined>(() => initialRangePreset(searchParams));
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => initialAnalyticsRange(searchParams, rangePreset));
const [userId, setUserId] = useState(searchParams.get("userId") || undefined);
const [model, setModel] = useState(searchParams.get("model") || undefined);
const [channelId, setChannelId] = useState(searchParams.get("channelId") || undefined);
const [capability, setCapability] = useState(searchParams.get("capability") || undefined);
const [data, setData] = useState<AdminAnalytics | null>(null);
const [pricings, setPricings] = useState<ModelPricing[]>([]);
const [loading, setLoading] = useState(false);
const [pricingModalOpen, setPricingModalOpen] = useState(false);
const [editingPricing, setEditingPricing] = useState<ModelPricing | null>(null);
const [savingPricing, setSavingPricing] = useState(false);
const [userOptions, setUserOptions] = useState(users);
const [searchingUsers, setSearchingUsers] = useState(false);
const [modelPage, setModelPage] = useState(1);
const [userPage, setUserPage] = useState(1);
const [failurePage, setFailurePage] = useState(1);
const [pricingPage, setPricingPage] = useState(1);
const [trendMetric, setTrendMetric] = useState<TrendMetric>("volume");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("models");
const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false);
const [pendingPricing, setPendingPricing] = useState<ModelPricing | null | undefined>(undefined);
const [form] = Form.useForm<PricingFormValues>();
const pricingChannelId = Form.useWatch("channelId", form);
const analyticsPageSize = 20;
const filters = useMemo<AnalyticsFilters>(
() => ({
from: range[0].format("YYYY-MM-DD"),
to: range[1].format("YYYY-MM-DD"),
userId,
model,
channelId,
capability,
}),
[capability, channelId, model, range, userId],
);
const reload = useCallback(async () => {
setLoading(true);
try {
const [analytics, pricingData] = await Promise.all([getAdminAnalytics(filters), listAdminModelPricings()]);
setData(analytics);
setPricings(pricingData.pricings);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取统计数据失败");
} finally {
setLoading(false);
}
}, [filters, message]);
useEffect(() => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(filters)) {
if (value) next.set(key, value);
else next.delete(key);
}
if (rangePreset) next.set("rangePreset", rangePreset);
else next.delete("rangePreset");
setSearchParams(next, { replace: true });
void reload();
}, [filters, rangePreset]);
useEffect(() => {
setModelPage(1);
setUserPage(1);
setFailurePage(1);
setPricingPage(1);
}, [filters]);
useEffect(() => {
setUserOptions(users);
}, [users]);
const searchUsers = async (keyword: string) => {
setSearchingUsers(true);
try {
const result = await listAdminUsers({ keyword: keyword.trim() || undefined, page: 1, limit: 50 });
setUserOptions(result.users);
} catch (error) {
message.error(error instanceof Error ? error.message : "搜索用户失败");
} finally {
setSearchingUsers(false);
}
};
const modelOptions = useMemo(() => {
const names = new Set<string>();
channels.forEach((channel) => channel.models?.forEach((name) => names.add(name)));
data?.models.forEach((item) => item.model !== "未识别" && names.add(item.model));
return [...names].sort().map((name) => ({ label: name, value: name }));
}, [channels, data?.models]);
const pricingModelOptions = useMemo(() => {
const names = new Set<string>();
const sourceChannels = channels.filter((channel) => channel.enabled !== false && (!pricingChannelId || channel.id === pricingChannelId));
sourceChannels.forEach((channel) => channel.models?.forEach((name) => names.add(name)));
if (editingPricing?.model && (!pricingChannelId || editingPricing.channelId === pricingChannelId)) {
names.add(editingPricing.model);
}
return [...names].sort().map((name) => ({ label: name, value: name }));
}, [channels, editingPricing?.channelId, editingPricing?.model, pricingChannelId]);
const preparePricingForm = (pricing: ModelPricing | null) => {
setEditingPricing(pricing);
form.resetFields();
form.setFieldsValue(
pricing
? {
channelId: pricing.channelId || undefined,
model: pricing.model,
capability: pricing.capability,
currency: pricing.currency,
inputPerMillion: formatPriceInput(pricing.inputPerMillionMicros),
outputPerMillion: formatPriceInput(pricing.outputPerMillionMicros),
cachedPerMillion: formatPriceInput(pricing.cachedPerMillionMicros),
perRequest: formatPriceInput(pricing.perRequestMicros),
perMedia: formatPriceInput(pricing.perMediaMicros),
perVideoSecond: formatPriceInput(pricing.perVideoSecondMicros),
}
: { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: "", outputPerMillion: "", cachedPerMillion: "", perRequest: "", perMedia: "", perVideoSecond: "" },
);
setPricingModalOpen(true);
};
const openPricing = (pricing?: ModelPricing) => {
const nextPricing = pricing || null;
if (pricingWorkspaceOpen) {
setPendingPricing(nextPricing);
setPricingWorkspaceOpen(false);
} else {
preparePricingForm(nextPricing);
}
};
const handlePricingValuesChange = (changedValues: Partial<PricingFormValues>, values: PricingFormValues) => {
if (Object.prototype.hasOwnProperty.call(changedValues, "channelId")) {
const nextModels = channels.filter((channel) => channel.enabled !== false && (!values.channelId || channel.id === values.channelId)).flatMap((channel) => channel.models || []);
if (values.model && !nextModels.includes(values.model)) form.setFieldValue("model", undefined);
return;
}
if (!Object.prototype.hasOwnProperty.call(changedValues, "model") || !values.model) return;
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(values.model));
if (values.channelId && matchingChannels.some((channel) => channel.id === values.channelId)) return;
if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id);
};
const handlePricingModelChange = (modelName: string) => {
const currentChannelId = form.getFieldValue("channelId") as string | undefined;
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(modelName));
const matchingChannel = matchingChannels.find((channel) => channel.id === currentChannelId) || matchingChannels[0];
form.setFieldsValue({ model: modelName, channelId: matchingChannel?.id || currentChannelId });
};
const savePricing = async () => {
const values = await form.validateFields();
const payload = {
channelId: values.channelId || "",
model: values.model.trim(),
capability: values.capability,
currency: values.currency.trim().toUpperCase(),
inputPerMillionMicros: toMicros(values.inputPerMillion),
outputPerMillionMicros: toMicros(values.outputPerMillion),
cachedPerMillionMicros: toMicros(values.cachedPerMillion),
perRequestMicros: toMicros(values.perRequest),
perMediaMicros: toMicros(values.perMedia),
perVideoSecondMicros: toMicros(values.perVideoSecond),
};
setSavingPricing(true);
try {
const result = editingPricing ? await updateAdminModelPricing(editingPricing.id, payload) : await createAdminModelPricing(payload);
setPricings((items) => (editingPricing ? items.map((item) => (item.id === result.pricing.id ? result.pricing : item)) : [...items, result.pricing]));
setPricingModalOpen(false);
message.success("模型价格已保存,后续调用将按新价格记录费用快照");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存价格失败");
} finally {
setSavingPricing(false);
}
};
const removePricing = async (id: string) => {
try {
await deleteAdminModelPricing(id);
setPricings((items) => items.filter((item) => item.id !== id));
message.success("价格配置已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除价格失败");
}
};
const modelColumns: ColumnsType<AdminAnalytics["models"][number]> = [
{
title: "模型",
dataIndex: "model",
fixed: "left",
width: 210,
render: (value, row) => (
<div>
<div className="font-medium">{value}</div>
<div className="mt-1">
<Tag className="admin-analytics-capability-tag">{capabilityLabel(row.capability)}</Tag>
</div>
</div>
),
},
{ title: "任务 / 请求", width: 120, render: (_, row) => `${row.tasks} / ${row.requests}` },
{ title: "用户", dataIndex: "uniqueUsers", width: 80 },
{ title: "任务成功率", dataIndex: "taskSuccessRate", width: 110, render: percent },
{ title: "请求成功率", dataIndex: "requestSuccessRate", width: 110, render: percent },
{ title: "P50 / P95", width: 145, render: (_, row) => `${formatDuration(row.p50DurationMs)} / ${formatDuration(row.p95DurationMs)}` },
{ title: "Token(入 / 出 / 缓存)", width: 190, render: (_, row) => (row.usageAvailable ? `${formatNumber(row.inputTokens)} / ${formatNumber(row.outputTokens)} / ${formatNumber(row.cachedTokens)}` : "--") },
{ title: "媒体 / 视频秒", width: 125, render: (_, row) => `${row.mediaCount} / ${row.videoSeconds}` },
{ title: "估算费用", width: 120, render: (_, row) => formatCost(row.estimatedCostMicros, row.currency, row.costAvailable) },
];
const userColumns: ColumnsType<AdminAnalytics["users"][number]> = [
{
title: "用户",
dataIndex: "name",
width: 180,
render: (name, row) => (
<div>
<div className="font-medium">{name}</div>
<div className="text-xs text-foreground/45">{row.userId}</div>
</div>
),
},
{ title: "活跃天数", dataIndex: "activeDays", width: 95 },
{ title: "任务", dataIndex: "tasks", width: 80 },
{ title: "Agent 消息", dataIndex: "agentMessages", width: 105 },
{ title: "画布活跃天数", dataIndex: "canvasDays", width: 120 },
{ title: "素材 / 资源", width: 110, render: (_, row) => `${row.assets} / ${row.resources}` },
{ title: "常用模型", dataIndex: "commonModel", ellipsis: true, render: (value) => value || "--" },
];
const failureColumns: ColumnsType<AdminAnalytics["failures"][number]> = [
{ title: "错误类型", dataIndex: "type", width: 120, render: (value) => <AdminStatusBadge label={value} tone={value === "超时" ? "warning" : "error"} /> },
{ title: "模型", dataIndex: "model", width: 220 },
{ title: "次数", dataIndex: "count", width: 90 },
{ title: "最近错误", dataIndex: "lastError", ellipsis: true, render: (value) => <Tooltip title={value}>{value || "--"}</Tooltip> },
{ title: "最近发生", dataIndex: "lastSeenAt", width: 170, render: (value) => dayjs(value).format("YYYY-MM-DD HH:mm") },
];
const pricingColumns: ColumnsType<ModelPricing> = [
{
title: "模型",
dataIndex: "model",
width: 210,
render: (value, row) => (
<div>
<div className="font-medium">{value}</div>
<div className="text-xs text-foreground/45">{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}</div>
</div>
),
},
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
{
title: "输入 / 输出 / 缓存(每百万 Token)",
width: 250,
render: (_, row) => `${formatMoney(fromMicros(row.inputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.outputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.cachedPerMillionMicros), row.currency)}`,
},
{
title: "每请求 / 每媒体 / 每视频秒",
width: 220,
render: (_, row) => `${formatMoney(fromMicros(row.perRequestMicros), row.currency)} / ${formatMoney(fromMicros(row.perMediaMicros), row.currency)} / ${formatMoney(fromMicros(row.perVideoSecondMicros), row.currency)}`,
},
{
title: "操作",
width: 170,
render: (_, row) => (
<AdminRowActions
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }}
actions={[
{
key: "delete",
label: "删除",
icon: <Trash2 className="size-3.5" />,
danger: true,
confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" },
onClick: () => removePricing(row.id),
},
]}
/>
),
},
];
const trend = data?.trend || [];
const currentTrend = trend[trend.length - 1];
const previousTrend = trend[trend.length - 2];
const modelRows = data?.models || [];
const userRows = data?.users || [];
const failureRows = data?.failures || [];
const pricingRows = pricings;
const failureTotal = failureRows.reduce((sum, item) => sum + item.count, 0);
const topFailure = failureRows.reduce<AdminAnalytics["failures"][number] | undefined>((current, item) => (!current || item.count > current.count ? item : current), undefined);
const pricedModelCount = modelRows.filter((item) => item.costAvailable).length;
const trendHasData = trendMetric === "volume" ? trend.some((item) => item.tasks > 0 || item.requests > 0) : trendMetric === "quality" ? trend.some((item) => item.requests > 0) : trend.some((item) => item.activeUsers > 0);
const trendTitle = trendMetric === "volume" ? "任务与请求趋势" : trendMetric === "quality" ? "请求质量趋势" : "用户活跃趋势";
const pageRows = <T,>(rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize);
const applyRangePreset = (preset: RangePreset) => {
const end = dayjs();
const start = preset === "7d" ? end.subtract(6, "day") : preset === "30d" ? end.subtract(29, "day") : end.subtract(59, "day");
setRangePreset(preset);
setRange([start, end]);
};
const openAnalysis = (tab: AnalysisTab) => {
setAnalysisTab(tab);
window.requestAnimationFrame(() => document.getElementById("admin-analytics-analysis")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" }));
};
return (
<div className="admin-analytics-panel space-y-5">
<ListToolbar
className="admin-analytics-toolbar"
active={Boolean(userId || model || channelId || capability)}
activeFilters={
<>
{userId ? <AdminFilterChip label={`用户:${userOptions.find((user) => user.id === userId)?.displayName || userId}`} onRemove={() => setUserId(undefined)} /> : null}
{model ? <AdminFilterChip label={`模型:${model}`} onRemove={() => setModel(undefined)} /> : null}
{channelId ? <AdminFilterChip label={`渠道:${channels.find((channel) => channel.id === channelId)?.name || channelId}`} onRemove={() => setChannelId(undefined)} /> : null}
{capability ? <AdminFilterChip label={`能力:${capabilityLabel(capability)}`} onRemove={() => setCapability(undefined)} /> : null}
</>
}
onReset={() => {
setUserId(undefined);
setModel(undefined);
setChannelId(undefined);
setCapability(undefined);
}}
trailing={
<>
<div className="admin-analytics-range-picker" role="group" aria-label="时间范围">
<span className="sr-only">时间范围</span>
<DatePicker.RangePicker
allowClear={false}
value={range}
onChange={(value) => {
if (value?.[0] && value?.[1]) {
setRangePreset(undefined);
setRange([value[0], value[1]]);
}
}}
/>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
<AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" />
</>
}
filters={
<>
<FilterSelect
label="用户"
value={userId}
onChange={setUserId}
options={userOptions.map((user) => ({ label: user.displayName || user.username, value: user.id }))}
filterOption={false}
loading={searchingUsers}
onSearch={(value) => void searchUsers(value)}
/>
<FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} />
<FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} />
<FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} />
</>
}
>
<div className="admin-analytics-range-presets" role="group" aria-label="快捷时间范围">
{(
[
["7d", "7 天"],
["30d", "30 天"],
["60d", "60 天"],
] as const
).map(([value, label]) => (
<button key={value} type="button" className={rangePreset === value ? "is-active" : undefined} aria-pressed={rangePreset === value} onClick={() => applyRangePreset(value)}>
{label}
</button>
))}
</div>
</ListToolbar>
<section className="admin-analytics-health-grid" aria-label="运营健康指标">
<AnalyticsHealthCard
icon={<UsersRound className="size-4" />}
label="活跃用户"
value={data ? formatNumber(data.kpi.activeUsers) : "--"}
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined}
/>
<AnalyticsHealthCard
icon={<Workflow className="size-4" />}
label="生成任务"
value={data ? formatNumber(data.kpi.generationTasks) : "--"}
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
detail={data ? `上游请求 ${formatNumber(data.kpi.upstreamRequests)} · 队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
/>
<AnalyticsHealthCard
icon={<Gauge className="size-4" />}
label="服务质量"
value={data ? percent(data.kpi.successRate) : "--"}
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
tone={data && data.kpi.successRate < 90 ? "warning" : "success"}
/>
<AnalyticsHealthCard
icon={<CircleDollarSign className="size-4" />}
label="估算费用"
value={data ? (data.kpi.costAvailable ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, true) : "待配置") : "--"}
detail={data ? (data.kpi.costAvailable ? `${pricedModelCount}/${modelRows.length} 个模型可估算` : "价格未完整配置,暂不能汇总") : undefined}
tone={data?.kpi.costAvailable ? "neutral" : "warning"}
/>
</section>
<div className="admin-analytics-overview-grid">
<section className="admin-analytics-trend-section" aria-labelledby="admin-analytics-trend-title">
<div className="admin-analytics-section-heading">
<div>
<h2 id="admin-analytics-trend-title">{trendTitle}</h2>
<p>{trendMetric === "volume" ? "生成任务与真实上游请求分开统计。" : trendMetric === "quality" ? "成功率按真实上游请求计算。" : "活跃用户按自然日去重统计。"}</p>
</div>
<div className="admin-analytics-trend-switch" role="group" aria-label="趋势指标">
{(
[
["volume", "用量"],
["quality", "质量"],
["activity", "活跃"],
] as const
).map(([value, label]) => (
<button key={value} type="button" className={trendMetric === value ? "is-active" : undefined} aria-pressed={trendMetric === value} onClick={() => setTrendMetric(value)}>
{label}
</button>
))}
</div>
</div>
{trendHasData ? (
<div className="admin-analytics-chart" role="img" aria-label={`${trendTitle}图`}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data?.trend || []} margin={{ top: 8, right: 12, bottom: 0, left: -16 }}>
<CartesianGrid stroke="currentColor" className="text-foreground/10" vertical={false} />
<XAxis dataKey="day" tickFormatter={(value) => value.slice(5)} tick={{ fontSize: 11 }} />
{trendMetric === "quality" ? <YAxis yAxisId="primary" domain={[0, 100]} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11 }} /> : <YAxis yAxisId="primary" allowDecimals={false} tick={{ fontSize: 11 }} />}
<ChartTooltip labelFormatter={(value) => `日期 ${value}`} />
{trendMetric === "volume" ? <Legend wrapperStyle={{ fontSize: 12 }} /> : null}
{trendMetric === "volume" ? (
<>
<Area yAxisId="primary" type="monotone" dataKey="tasks" name="生成任务" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} />
<Area yAxisId="primary" type="monotone" dataKey="requests" name="上游请求" stroke="var(--admin-chart-secondary)" fill="var(--admin-chart-secondary)" fillOpacity={0.08} />
</>
) : null}
{trendMetric === "quality" ? <Line yAxisId="primary" type="monotone" dataKey="requestSuccessRate" name="成功率" stroke="var(--admin-chart-warning)" dot={false} strokeWidth={2} /> : null}
{trendMetric === "activity" ? <Area yAxisId="primary" type="monotone" dataKey="activeUsers" name="活跃用户" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} /> : null}
</ComposedChart>
</ResponsiveContainer>
</div>
) : (
<div className="admin-analytics-empty-chart">
<span>
<BarChart3 className="size-5" />
</span>
<div className="font-medium">当前范围暂无{trendMetric === "quality" ? "质量" : trendMetric === "activity" ? "活跃" : "使用"}数据</div>
<p>可以调整时间范围或筛选条件后重新查看。</p>
</div>
)}
</section>
<aside className="admin-analytics-attention" aria-labelledby="admin-analytics-attention-title">
<div className="admin-analytics-attention-heading">
<div>
<h2 id="admin-analytics-attention-title">需要关注</h2>
<p>优先展示可能需要处理的运行状态。</p>
</div>
<AdminStatusBadge label={failureTotal > 0 ? `${formatNumber(failureTotal)} 次异常` : "运行平稳"} tone={failureTotal > 0 ? "warning" : "success"} />
</div>
<div className="admin-analytics-attention-list">
<AnalyticsAttentionItem
icon={<AlertTriangle className="size-4" />}
label="异常请求"
value={data ? formatNumber(failureTotal) : "--"}
description={topFailure ? `${topFailure.type} · ${topFailure.model}` : "当前范围未记录异常"}
tone={failureTotal > 0 ? "warning" : "success"}
onClick={failureTotal > 0 ? () => openAnalysis("failures") : undefined}
/>
<AnalyticsAttentionItem
icon={<Clock3 className="size-4" />}
label="当前队列"
value={data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}
description={data?.kpi.currentQueuedTasks ? "存在等待执行的生成任务" : "没有排队中的生成任务"}
tone={data?.kpi.currentQueuedTasks ? "warning" : "success"}
/>
<AnalyticsAttentionItem
icon={<CircleDollarSign className="size-4" />}
label="费用可估算模型"
value={data ? `${pricedModelCount}/${modelRows.length}` : "--"}
description={data?.kpi.costAvailable ? "当前范围费用可以汇总" : "仍有调用无法完成费用估算"}
tone={data?.kpi.costAvailable ? "success" : "warning"}
onClick={() => setPricingWorkspaceOpen(true)}
/>
</div>
</aside>
</div>
<section id="admin-analytics-analysis" className="admin-analytics-analysis-section">
<div className="admin-analytics-analysis-heading">
<div>
<h2>深度分析</h2>
<p>按模型、用户或异常类型继续核对当前统计范围。</p>
</div>
<Button icon={<Settings2 className="size-4" />} onClick={() => setPricingWorkspaceOpen(true)}>
模型价格配置
</Button>
</div>
<Tabs
activeKey={analysisTab}
onChange={(key) => setAnalysisTab(key as AnalysisTab)}
className="admin-analytics-tabs"
items={[
{
key: "models",
label: "模型分析",
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }}
empty={<AdminTableEmpty />}
skeletonColumns={9}
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
{
key: "users",
label: "用户活动",
children: (
<AdminDataTable
table={{ rowKey: "userId", size: "small", loading, columns: userColumns, dataSource: pageRows(userRows, userPage), pagination: false, scroll: { x: 900 } }}
empty={<AdminTableEmpty />}
skeletonColumns={7}
footer={<PaginationBar alwaysShow current={userPage} pageSize={analyticsPageSize} total={userRows.length} onChange={(page) => setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
{
key: "failures",
label: `异常定位${data?.failures.length ? ` (${data.failures.reduce((sum, item) => sum + item.count, 0)})` : ""}`,
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }}
empty={<AdminTableEmpty />}
skeletonColumns={5}
footer={<PaginationBar alwaysShow current={failurePage} pageSize={analyticsPageSize} total={failureRows.length} onChange={(page) => setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
]}
/>
</section>
<Drawer
rootClassName="admin-secondary-drawer admin-analytics-pricing-drawer"
title="模型价格配置"
open={pricingWorkspaceOpen}
width="min(1120px, calc(100vw - 48px))"
onClose={() => setPricingWorkspaceOpen(false)}
afterOpenChange={(open) => {
if (open || pendingPricing === undefined) return;
const nextPricing = pendingPricing;
setPendingPricing(undefined);
preparePricingForm(nextPricing);
}}
extra={
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openPricing()}>
新增价格
</Button>
}
>
<p className="admin-analytics-pricing-description">价格只影响后续调用的费用快照,历史统计不会被重新计算。</p>
<AdminDataTable
table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }}
empty={<AdminTableEmpty title="暂无模型价格" description="新增价格后,后续调用才能形成可汇总的费用快照。" />}
skeletonColumns={5}
footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
</Drawer>
<Modal
rootClassName="admin-modal-root admin-analytics-pricing-modal"
title={editingPricing ? "编辑模型价格" : "新增模型价格"}
open={pricingModalOpen}
onCancel={() => setPricingModalOpen(false)}
onOk={() => void savePricing()}
confirmLoading={savingPricing}
okText="保存"
cancelText="取消"
width={760}
zIndex={1200}
destroyOnHidden
>
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handlePricingValuesChange}>
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
<Form.Item name="model" label="模型" rules={[{ required: true, message: "请选择模型" }]}>
<Select
showSearch
optionFilterProp="label"
placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}
options={pricingModelOptions}
disabled={!pricingModelOptions.length}
onChange={handlePricingModelChange}
/>
</Form.Item>
<Form.Item name="channelId" label="渠道范围">
<Select allowClear placeholder="全部渠道" options={channels.filter((channel) => channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} />
</Form.Item>
<Form.Item name="capability" label="能力类型" rules={[{ required: true }]}>
<Select options={capabilityOptions} />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Input maxLength={12} />
</Form.Item>
<PriceInput name="inputPerMillion" label="每百万输入 Token" />
<PriceInput name="outputPerMillion" label="每百万输出 Token" />
<PriceInput name="cachedPerMillion" label="每百万缓存 Token" />
<PriceInput name="perRequest" label="每次请求" />
<PriceInput name="perMedia" label="每个输出媒体" />
<PriceInput name="perVideoSecond" label="每视频秒" />
</div>
</Form>
</Modal>
</div>
);
}
function AnalyticsHealthCard({ icon, label, value, trend, detail, tone = "neutral" }: { icon: ReactNode; label: string; value: ReactNode; trend?: { value: string; tone?: AdminStatusTone }; detail?: string; tone?: AdminStatusTone }) {
return (
<article className="admin-analytics-health-card" data-tone={tone}>
<div className="admin-analytics-health-card-heading">
<span aria-hidden="true">{icon}</span>
<span>{label}</span>
</div>
<div className="admin-analytics-health-card-value">{value}</div>
<div className="admin-analytics-health-card-meta">
{trend ? <AdminStatusBadge label={trend.value} tone={trend.tone || "neutral"} /> : null}
{detail ? <span>{detail}</span> : null}
</div>
</article>
);
}
function AnalyticsAttentionItem({ icon, label, value, description, tone = "neutral", onClick }: { icon: ReactNode; label: string; value: ReactNode; description: string; tone?: AdminStatusTone; onClick?: () => void }) {
const content = (
<>
<span className="admin-analytics-attention-icon" aria-hidden="true">
{icon}
</span>
<span className="admin-analytics-attention-copy">
<span className="admin-analytics-attention-label">{label}</span>
<span className="admin-analytics-attention-description">{description}</span>
</span>
<strong>{value}</strong>
</>
);
return onClick ? (
<button type="button" className="admin-analytics-attention-item is-action" data-tone={tone} onClick={onClick}>
{content}
</button>
) : (
<div className="admin-analytics-attention-item" data-tone={tone}>
{content}
</div>
);
}
function FilterSelect({
label,
value,
onChange,
options,
width = 150,
filterOption = true,
loading,
onSearch,
}: {
label: string;
value?: string;
onChange: (value?: string) => void;
options: Array<{ label: string; value: string }>;
width?: number;
filterOption?: boolean;
loading?: boolean;
onSearch?: (value: string) => void;
}) {
return (
<div>
<div className="mb-1 text-xs text-foreground/55">{label}</div>
<Select aria-label={`${label}筛选`} allowClear showSearch optionFilterProp="label" filterOption={filterOption} loading={loading} placeholder="全部" value={value} onChange={onChange} onSearch={onSearch} options={options} style={{ width }} />
</div>
);
}
function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) {
return (
<Form.Item className="admin-analytics-price-field" name={name} label={`${label}(币种单位)`} rules={[{ validator: validatePriceInput }]}>
<Input aria-label={`${label}价格`} disabled={false} readOnly={false} inputMode="decimal" maxLength={30} className="admin-analytics-price-input" style={{ width: "100%" }} />
</Form.Item>
);
}
function validatePriceInput(_: unknown, value?: string) {
const normalized = value?.trim() || "";
if (!normalized || /^(?:\d+(?:\.\d{0,6})?|\.\d{1,6})$/.test(normalized)) return Promise.resolve();
return Promise.reject(new Error("请输入非负价格,最多 6 位小数"));
}
function capabilityLabel(value: string) {
return capabilityOptions.find((item) => item.value === value)?.label || "未分类";
}
function percent(value: number) {
return `${Number(value || 0).toFixed(1)}%`;
}
function formatDuration(value: number) {
if (!value) return "--";
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
}
function formatNumber(value: number) {
return new Intl.NumberFormat("zh-CN", { notation: value >= 100000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value);
}
function formatCountDelta(current?: number, previous?: number) {
if (current === undefined || previous === undefined) return undefined;
const delta = current - previous;
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
const value = previous === 0 ? formatNumber(Math.abs(delta)) : `${Math.abs((delta / previous) * 100).toFixed(1)}%`;
return { value: `较前一日 ${direction} ${value}`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
}
function formatRateDelta(current?: number, previous?: number) {
if (current === undefined || previous === undefined) return undefined;
const delta = current - previous;
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
return { value: `较前一日 ${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
}
function formatCost(micros: number, currency?: string, available?: boolean) {
return available ? formatMoney(fromMicros(micros), currency || "USD") : "--";
}
function formatMoney(value: number, currency = "USD") {
if (currency === "MIXED") return `${value.toFixed(6)}(混合币种)`;
try {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value);
} catch {
return `${currency} ${value.toFixed(6)}`;
}
}
function fromMicros(value: number) {
return value / 1_000_000;
}
function formatPriceInput(micros: number) {
return fromMicros(micros).toFixed(6);
}
function toMicros(value?: string | number) {
const parsed = typeof value === "number" ? value : Number(value?.trim() || 0);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1_000_000) : 0;
}
function filterDate(value: string | null, fallback: Dayjs) {
if (!value) return fallback;
const parsed = dayjs(value);
return parsed.isValid() ? parsed : fallback;
}
function resolveRangePreset(range: [Dayjs, Dayjs]): RangePreset | undefined {
const end = dayjs();
if (!range[1].isSame(end, "day")) return undefined;
if (range[0].isSame(end.subtract(6, "day"), "day")) return "7d";
if (range[0].isSame(end.subtract(29, "day"), "day")) return "30d";
if (range[0].isSame(end.subtract(59, "day"), "day")) return "60d";
return undefined;
}
function parseRangePreset(value: string | null): RangePreset | undefined {
return value === "7d" || value === "30d" || value === "60d" ? value : undefined;
}
function initialRangePreset(searchParams: URLSearchParams): RangePreset | undefined {
const explicit = parseRangePreset(searchParams.get("rangePreset"));
if (explicit) return explicit;
if (searchParams.has("from") || searchParams.has("to")) return resolveRangePresetFromQuery(searchParams.get("from"), searchParams.get("to"));
return "30d";
}
function initialAnalyticsRange(searchParams: URLSearchParams, preset: RangePreset | undefined): [Dayjs, Dayjs] {
const end = dayjs();
if (preset) {
const days = preset === "7d" ? 6 : preset === "30d" ? 29 : 59;
return [end.subtract(days, "day"), end];
}
return [filterDate(searchParams.get("from"), end.subtract(29, "day")), filterDate(searchParams.get("to"), end)];
}
function resolveRangePresetFromQuery(from: string | null, to: string | null): RangePreset | undefined {
const start = from ? dayjs(from) : null;
const end = to ? dayjs(to) : null;
if (!start?.isValid() || !end?.isValid()) return undefined;
return resolveRangePreset([start, end]);
}