import { useCallback, useEffect, useMemo, useState } from "react"; import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Tabs, Tag, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; import dayjs, { type Dayjs } from "dayjs"; import { Pencil, Plus, RefreshCw, Trash2 } 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, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; type Props = { users: AdminReferenceData["users"]; channels: AdminReferenceData["channels"]; }; type PricingFormValues = { channelId?: string; model: string; capability: ModelPricing["capability"]; currency: string; inputPerMillion?: number; outputPerMillion?: number; cachedPerMillion?: number; perRequest?: number; perMedia?: number; perVideoSecond?: number; }; 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 [range, setRange] = useState<[Dayjs, Dayjs]>(() => [filterDate(searchParams.get("from"), dayjs().subtract(29, "day")), filterDate(searchParams.get("to"), dayjs())]); 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(null); const [pricings, setPricings] = useState([]); const [loading, setLoading] = useState(false); const [pricingModalOpen, setPricingModalOpen] = useState(false); const [editingPricing, setEditingPricing] = useState(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 [form] = Form.useForm(); const analyticsPageSize = 20; const filters = useMemo( () => ({ 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); } setSearchParams(next, { replace: true }); void reload(); }, [filters]); 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(); 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 openPricing = (pricing?: ModelPricing) => { setEditingPricing(pricing || null); form.setFieldsValue( pricing ? { channelId: pricing.channelId || undefined, model: pricing.model, capability: pricing.capability, currency: pricing.currency, inputPerMillion: fromMicros(pricing.inputPerMillionMicros), outputPerMillion: fromMicros(pricing.outputPerMillionMicros), cachedPerMillion: fromMicros(pricing.cachedPerMillionMicros), perRequest: fromMicros(pricing.perRequestMicros), perMedia: fromMicros(pricing.perMediaMicros), perVideoSecond: fromMicros(pricing.perVideoSecondMicros), } : { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: 0, outputPerMillion: 0, cachedPerMillion: 0, perRequest: 0, perMedia: 0, perVideoSecond: 0 }, ); setPricingModalOpen(true); }; 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 = [ { title: "模型", dataIndex: "model", fixed: "left", width: 210, render: (value, row) => (
{value}
{capabilityLabel(row.capability)}
), }, { 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 = [ { title: "用户", dataIndex: "name", width: 180, render: (name, row) => (
{name}
{row.userId}
), }, { 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 = [ { title: "错误类型", dataIndex: "type", width: 120, render: (value) => }, { title: "模型", dataIndex: "model", width: 220 }, { title: "次数", dataIndex: "count", width: 90 }, { title: "最近错误", dataIndex: "lastError", ellipsis: true, render: (value) => {value || "--"} }, { title: "最近发生", dataIndex: "lastSeenAt", width: 170, render: (value) => dayjs(value).format("YYYY-MM-DD HH:mm") }, ]; const pricingColumns: ColumnsType = [ { title: "模型", dataIndex: "model", width: 210, render: (value, row) => (
{value}
{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}
), }, { 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) => , onClick: () => openPricing(row) }} actions={[{ key: "delete", label: "删除", icon: , 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 pageRows = (rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize); return (
{userId ? user.id === userId)?.displayName || userId}`} onRemove={() => setUserId(undefined)} /> : null} {model ? setModel(undefined)} /> : null} {channelId ? channel.id === channelId)?.name || channelId}`} onRemove={() => setChannelId(undefined)} /> : null} {capability ? setCapability(undefined)} /> : null} )} onReset={() => { setUserId(undefined); setModel(undefined); setChannelId(undefined); setCapability(undefined); }} trailing={<> exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" />} filters={<> ({ label: user.displayName || user.username, value: user.id }))} filterOption={false} loading={searchingUsers} onSearch={(value) => void searchUsers(value)} /> ({ label: channel.name, value: channel.id }))} />} >
时间范围
value?.[0] && value?.[1] && setRange([value[0], value[1]])} />
当前队列 {data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}
P95 耗时 {data ? formatDuration(data.kpi.p95DurationMs) : "--"}
估算费用 {data ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, data.kpi.costAvailable) : "--"}

使用趋势

生成任务与真实上游请求分开统计,成功率按上游请求计算。

value.slice(5)} tick={{ fontSize: 11 }} /> `${value}%`} tick={{ fontSize: 11 }} /> `日期 ${value}`} />
`${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }} empty={} skeletonColumns={9} footer={ setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />} />, }, { key: "users", label: "用户活动", children: } skeletonColumns={7} footer={ setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />} /> }, { key: "failures", label: `异常定位${data?.failures.length ? ` (${data.failures.reduce((sum, item) => sum + item.count, 0)})` : ""}`, children: `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }} empty={} skeletonColumns={5} footer={ setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />} />, }, { key: "pricing", label: "模型价格", children: (
} skeletonColumns={5} footer={ setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />} />
), }, ]} /> setPricingModalOpen(false)} onOk={() => void savePricing()} confirmLoading={savingPricing} okText="保存" cancelText="取消" width={760}>
); } 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 (
{label}