feat(admin): 管理统计 - 合并 PR #545 财务统计与分组选择

Merge pull request #545 from Rou325. Preserve the original author and current main behavior.
This commit is contained in:
Rou
2026-09-20 04:56:42 +08:00
committed by ddCat
13 changed files with 522 additions and 427 deletions

View File

@@ -0,0 +1,22 @@
import type { ColumnsType } from "antd/es/table";
import type { AdminAnalytics, AnalyticsFinance } from "@/services/api/auth";
export function formatCredits(value: number | null | undefined) {
return value == null ? "--" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 6 }).format(value / 1_000_000);
}
export function formatFinanceCost(finance: AnalyticsFinance | null | undefined) {
if (!finance?.costedOrders) return "--";
return `${formatCredits(finance.costMicrocredits)}${finance.costedOrders < finance.settledOrders ? "(部分)" : ""}`;
}
export function formatFinanceMargin(value: number | null | undefined) {
return value == null ? "--" : `${value.toFixed(1)}%`;
}
export const analyticsFinanceColumns: ColumnsType<AdminAnalytics["models"][number]> = [
{ title: "收入(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.revenueMicrocredits) },
{ title: "成本(积分)", width: 150, render: (_, row) => formatFinanceCost(row.finance) },
{ title: "利润(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.profitMicrocredits) },
{ title: "利润率", width: 100, render: (_, row) => formatFinanceMargin(row.finance?.profitMargin) },
];

View File

@@ -1,47 +1,30 @@
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd";
import { Alert, App, Button, DatePicker, Select, Tabs, Tag } from "antd";
import { Tooltip } from "@/pages/admin/ui/controls";
import { useCountUp } from "@/hooks/use-count-up";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
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 { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, RefreshCw, 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, AdminDataTable, AdminExportButton, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
import {
createAdminModelPricing,
deleteAdminModelPricing,
exportAdminAnalytics,
getAdminAnalytics,
listAdminUsers,
listAdminModelPricings,
updateAdminModelPricing,
type AdminReferenceData,
type AdminAnalytics,
type AnalyticsFilters,
type ModelPricing,
} from "@/services/api/auth";
import { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance";
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";
@@ -63,23 +46,14 @@ export default function AnalyticsPanel({ users, channels }: Props) {
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>(
@@ -97,9 +71,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
const reload = useCallback(async () => {
setLoading(true);
try {
const [analytics, pricingData] = await Promise.all([getAdminAnalytics(filters), listAdminModelPricings()]);
const analytics = await getAdminAnalytics(filters);
setData(analytics);
setPricings(pricingData.pricings);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取统计数据失败");
} finally {
@@ -123,7 +96,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
setModelPage(1);
setUserPage(1);
setFailurePage(1);
setPricingPage(1);
}, [filters]);
useEffect(() => {
@@ -149,105 +121,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
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: "模型",
@@ -270,7 +143,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{ 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) },
...analyticsFinanceColumns,
];
const userColumns: ColumnsType<AdminAnalytics["users"][number]> = [
@@ -301,60 +174,16 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{ 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 finance = data?.kpi.finance;
const financeUnavailable = data !== null && (!finance || modelRows.some((row) => !row.finance));
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);
@@ -443,6 +272,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
</div>
</ListToolbar>
{financeUnavailable && <Alert type="warning" showIcon title="后端未返回完整财务统计,缺失金额显示为 --。请确认后端已更新并重启后刷新。" />}
<section className="admin-analytics-health-grid" aria-label="运营健康指标">
<AnalyticsHealthCard
icon={<UsersRound className="size-4" />}
@@ -468,10 +299,14 @@ export default function AnalyticsPanel({ users, channels }: Props) {
/>
<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"}
label="费用统计(积分)"
value={finance ? <dl className="admin-analytics-finance-values">
<div><dt>收入</dt><dd>{formatCredits(finance.revenueMicrocredits)}</dd></div>
<div><dt>成本</dt><dd>{formatFinanceCost(finance)}</dd></div>
<div><dt>利润</dt><dd>{formatCredits(finance.profitMicrocredits)}</dd></div>
</dl> : "--"}
detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined}
tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"}
/>
</section>
@@ -553,11 +388,11 @@ export default function AnalyticsPanel({ users, channels }: Props) {
/>
<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)}
label="成本覆盖订单"
value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"}
description={!finance ? "财务统计尚未返回" : finance.settledOrders ? (finance.costedOrders === finance.settledOrders ? "已结算订单成本完整" : `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`) : "当前范围暂无已结算订单"}
tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"}
onClick={() => openAnalysis("models")}
/>
</div>
</aside>
@@ -569,9 +404,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<h2>深度分析</h2>
<p>按模型、用户或异常类型继续核对当前统计范围。</p>
</div>
<Button icon={<Settings2 className="size-4" />} onClick={() => setPricingWorkspaceOpen(true)}>
模型价格配置
</Button>
</div>
<Tabs
activeKey={analysisTab}
@@ -583,9 +415,9 @@ export default function AnalyticsPanel({ users, channels }: Props) {
label: "模型分析",
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }}
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1600 } }}
empty={<AdminTableEmpty />}
skeletonColumns={9}
skeletonColumns={12}
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
@@ -618,76 +450,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
/>
</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>
);
}
@@ -765,20 +527,6 @@ function FilterSelect({
);
}
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 || "未分类";
}
@@ -811,32 +559,6 @@ function formatRateDelta(current?: number, previous?: number) {
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);

View File

@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
import { Alert, App, AutoComplete, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
import { useAdminContext } from "../admin-context";
import { AdminModal } from "@/pages/admin/ui/overlays";
import { FlaskConical, Plus } from "lucide-react";
import { ModelIconPicker } from "@/components/model-logo";
@@ -34,6 +35,10 @@ export function ChannelModelEditor({
onSaved: () => Promise<void>;
}) {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const displayNameOptions = [...new Set(references.channels.flatMap((item) => item.modelDisplayNames ?? []))]
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((value) => ({ value }));
const [form] = Form.useForm<FormValues>();
const [initialValues] = useState(() => initialChannelModelValues(editing, protocols));
const [activeSection, setActiveSection] = useState<EditorSection>("identity");
@@ -248,7 +253,12 @@ export function ChannelModelEditor({
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
<Input placeholder="不填则使用模型标识" />
<AutoComplete
options={displayNameOptions}
filterOption={(input, option) => Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))}
placeholder="选择已有分组或输入新名称"
allowClear
/>
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />

View File

@@ -160,7 +160,16 @@ export type AnalyticsFilters = {
export type AdminReferenceData = {
users: Array<{ id: string; username: string; displayName: string }>;
channels: Array<{ id: string; name: string; enabled: boolean; models: string[] }>;
channels: Array<{ id: string; name: string; enabled: boolean; models: string[]; modelDisplayNames?: string[] }>;
};
export type AnalyticsFinance = {
settledOrders: number;
costedOrders: number;
revenueMicrocredits: number;
costMicrocredits: number;
profitMicrocredits: number | null;
profitMargin: number | null;
};
export type AdminAnalytics = {
@@ -176,9 +185,7 @@ export type AdminAnalytics = {
successRate: number;
p95DurationMs: number;
currentQueuedTasks: number;
estimatedCostMicros: number;
costAvailable: boolean;
currency?: string;
finance?: AnalyticsFinance | null;
};
trend: Array<{ day: string; tasks: number; requests: number; activeUsers: number; requestSuccessRate: number }>;
models: Array<{
@@ -197,9 +204,7 @@ export type AdminAnalytics = {
usageAvailable: boolean;
mediaCount: number;
videoSeconds: number;
estimatedCostMicros: number;
costAvailable: boolean;
currency?: string;
finance?: AnalyticsFinance | null;
}>;
users: Array<{ userId: string; name: string; activeDays: number; tasks: number; agentMessages: number; canvasDays: number; assets: number; resources: number; commonModel?: string }>;
failures: Array<{ type: string; model: string; count: number; lastError?: string; lastSeenAt: string }>;

View File

@@ -8485,21 +8485,6 @@
width: 246px;
}
.admin-analytics-pricing-modal .admin-analytics-price-field,
.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control,
.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control-input,
.admin-analytics-pricing-modal .admin-analytics-price-input,
.admin-analytics-pricing-modal .admin-analytics-price-input input {
width: 100%;
min-width: 0;
pointer-events: auto;
user-select: text;
}
.admin-analytics-pricing-modal .admin-analytics-price-input {
display: block;
}
.admin-analytics-toolbar .admin-list-toolbar-actions {
align-items: center;
}
@@ -8590,6 +8575,33 @@
line-height: 16px;
}
.admin-analytics-finance-values {
display: grid;
gap: 6px;
margin: 0;
font-size: 14px;
letter-spacing: 0;
line-height: 1.4;
white-space: normal;
}
.admin-analytics-finance-values > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
}
.admin-analytics-finance-values dt {
color: var(--admin-text-muted);
font-weight: 400;
}
.admin-analytics-finance-values dd {
margin: 0;
text-align: right;
overflow-wrap: anywhere;
}
.admin-analytics-overview-grid {
display: grid;
min-width: 0;
@@ -8824,21 +8836,6 @@
padding-block: 10px;
}
.admin-analytics-pricing-description {
margin-bottom: 14px;
border: 1px solid var(--admin-divider);
border-radius: 8px;
background: var(--admin-layer-2);
padding: 10px 12px;
color: var(--admin-text-secondary);
font-size: 11px;
line-height: 17px;
}
.admin-analytics-pricing-drawer .admin-table-frame {
min-height: min(620px, calc(100vh - 190px));
}
@media (min-width: 1280px) {
.admin-analytics-overview-grid {
grid-template-columns: minmax(0, 2fr) minmax(300px, 0.82fr);