feat(admin): add read-only pricing simulator

This commit is contained in:
Jesse_Chen
2026-08-31 04:29:53 +08:00
parent 6e13949d3b
commit a499c3444e
5 changed files with 351 additions and 0 deletions
@@ -0,0 +1,162 @@
"use client";
import { Alert, Button, Card, Col, Divider, InputNumber, Row, Space, Statistic, Table, Tag, Typography } from "antd";
import { useEffect, useMemo, useState } from "react";
import { adminRequestJson } from "@/lib/admin/providers";
import {
CONSULTATION_DOMAIN_DURATION_MS,
estimateFeature,
memberCapacity,
membershipCost,
type DistributionBand,
type Metric,
type ModelPrice,
type UsageAggregate,
} from "@/lib/pricing-simulation";
const FEATURE_KEYS = ["chat.standard", "chat.premium", "rectification", "report.full"] as const;
const BANDS: DistributionBand[] = [
{ weight: 0, monthlyMessages: 0 },
{ weight: 0, monthlyMessages: 0 },
{ weight: 0, monthlyMessages: 0 },
{ weight: 0, monthlyMessages: 0 },
];
type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean };
type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean };
type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> };
type UsagePayload = { data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }> };
type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] };
function metric(value: unknown): Metric {
const data = value as Partial<Metric> | null;
return { runs: Number(data?.runs ?? 0), avg: data?.avg ?? null, p50: data?.p50 ?? null, p95: data?.p95 ?? null, max: data?.max ?? null };
}
function formatNumber(value: number | null, suffix = "") {
return value === null || !Number.isFinite(value) ? "—" : `${value.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}${suffix}`;
}
function formatCny(value: number | null) {
return value === null ? "—" : `¥${value.toFixed(2)}`;
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function PricingSimulator() {
const [state, setState] = useState<LoadState>({ errors: [] });
const [usdToCny, setUsdToCny] = useState<number | null>(null);
const [fixedCostCny, setFixedCostCny] = useState<number | null>(null);
const [distribution, setDistribution] = useState<DistributionBand[]>(BANDS);
useEffect(() => {
let active = true;
void Promise.allSettled([
adminRequestJson<{ data: ModelRow[] }>("/api/admin/models?page=1&pageSize=100&status=published"),
adminRequestJson<{ data: PricingRow[] }>("/api/admin/feature-pricing"),
adminRequestJson<{ data: Product[] }>("/api/admin/products?page=1&pageSize=100&status=published"),
adminRequestJson<UsagePayload>("/api/admin/usage/aggregate"),
]).then((results) => {
if (!active) return;
const [models, pricing, products, usage] = results;
setState({
models: models.status === "fulfilled" ? models.value.data : undefined,
pricing: pricing.status === "fulfilled" ? pricing.value.data : undefined,
products: products.status === "fulfilled" ? products.value.data : undefined,
usage: usage.status === "fulfilled" ? usage.value : undefined,
errors: results.flatMap((result, index) => result.status === "rejected" ? [errorText(result.reason, `数据块 ${index + 1} 不可用`)] : []),
});
});
return () => { active = false; };
}, []);
const usageByFeature = useMemo(() => new Map((state.usage?.data ?? []).map((row) => [row.featureKey, {
hasData: row.hasData,
metrics: {
costMicrousd: metric(row.metrics.costMicrousd), inputTokens: metric(row.metrics.inputTokens),
outputTokens: metric(row.metrics.outputTokens), durationMs: metric(row.metrics.durationMs),
},
} satisfies UsageAggregate])), [state.usage]);
const rows = useMemo(() => FEATURE_KEYS.map((featureKey) => {
const pricing = (state.pricing ?? []).find((row) => row.featureKey === featureKey && row.status === "published" && row.enabled);
const model = (state.models ?? []).find((row) => row.modelTier === pricing?.modelTier && row.status === "published" && row.enabled) ?? null;
const estimate = estimateFeature({
featureKey,
creditCost: pricing?.creditCost ?? null,
model,
usage: usageByFeature.get(featureKey) ?? null,
usdToCny,
});
return { key: featureKey, featureKey, pricing, model, usage: usageByFeature.get(featureKey) ?? null, estimate };
}), [state.models, state.pricing, usageByFeature, usdToCny]);
const standardRow = rows.find((row) => row.featureKey === "chat.standard");
const monthly = (state.products ?? []).find((product) => product.code === "standard_monthly" && product.status === "published");
const monthlyEntitlement = monthly?.entitlements.find((entry) => entry.featureKey === "chat.standard");
const averageCost = membershipCost(distribution, standardRow?.estimate.costCny ?? null, monthlyEntitlement?.metadata?.billingLimit ?? null);
const monthlyPrice = monthly ? monthly.priceCents / 100 : null;
const averageMargin = monthlyPrice === null || averageCost.averageCostCny === null ? null : ((monthlyPrice - averageCost.averageCostCny) / monthlyPrice) * 100;
const worstMargin = monthlyPrice === null || averageCost.worstCostCny === null ? null : ((monthlyPrice - averageCost.worstCostCny) / monthlyPrice) * 100;
const breakEven = fixedCostCny === null || monthlyPrice === null || averageMargin === null || averageMargin <= 0 ? null : Math.ceil(fixedCostCny / (monthlyPrice * averageMargin / 100));
const capacity = memberCapacity(distribution, CONSULTATION_DOMAIN_DURATION_MS);
return (
<Space direction="vertical" size={18} style={{ display: "flex" }}>
<Card title="定价测算" extra={<Button href="/admin/feature-pricing"></Button>}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
{state.errors.map((error) => <Alert key={error} type="warning" showIcon message="部分数据不可用" description={error} />)}
</Card>
<Card title="可调假设">
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Typography.Text>USD/CNY </Typography.Text><InputNumber min={0} value={usdToCny ?? undefined} onChange={setUsdToCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
<Col xs={24} md={8}><Typography.Text> ¥/</Typography.Text><InputNumber min={0} value={fixedCostCny ?? undefined} onChange={setFixedCostCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
<Col xs={24} md={8}><Typography.Text></Typography.Text><Space.Compact style={{ width: "100%" }}><InputNumber min={0} value={distribution[0]?.weight} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, weight: value ?? 0 } : item))} placeholder="占比" /><InputNumber min={0} value={distribution[0]?.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, monthlyMessages: value ?? 0 } : item))} placeholder="条/月" /></Space.Compact></Col>
</Row>
<Typography.Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
1
</Typography.Paragraph>
<Table<DistributionBand> size="small" pagination={false} rowKey={(_, index) => String(index)} dataSource={distribution} columns={[
{ title: "档位", render: (_, __, index) => `${index + 1}` },
{ title: "占比", render: (_, item, index) => <InputNumber min={0} value={item.weight} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, weight: value ?? 0 } : entry))} /> },
{ title: "条数/月", render: (_, item, index) => <InputNumber min={0} value={item.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, monthlyMessages: value ?? 0 } : entry))} /> },
]} />
</Card>
<Card title="单功能表(p50">
<Table dataSource={rows} rowKey="key" pagination={false} scroll={{ x: 920 }} columns={[
{ title: "功能", dataIndex: "featureKey" },
{ title: "模型档位", render: (_, row) => row.pricing?.modelTier ?? <Tag></Tag> },
{ title: "售价", render: (_, row) => row.estimate.saleCny === null ? "无发布价格" : `${row.pricing?.creditCost} 积分 / ${formatCny(row.estimate.saleCny)}` },
{ title: "单位成本", render: (_, row) => row.estimate.source === "unavailable" ? <Tag></Tag> : <>{formatCny(row.estimate.costCny)}<Typography.Text type="secondary" style={{ display: "block" }}>{formatNumber(row.estimate.costMicrousd, " µUSD")} · {row.estimate.source === "ledger" ? "账本实测" : "模型估算"}</Typography.Text></> },
{ title: "毛利率", render: (_, row) => row.estimate.marginPercent === null ? "需汇率或价格" : `${row.estimate.marginPercent.toFixed(1)}%` },
]} />
</Card>
<Card title="标准月卡风险视图">
<Row gutter={[16, 16]}>
<Col xs={24} md={6}><Statistic title="月卡售价" value={monthlyPrice === null ? "—" : monthlyPrice} prefix={monthlyPrice === null ? undefined : "¥"} /></Col>
<Col xs={24} md={6}><Statistic title="加权平均成本" value={averageCost.averageCostCny === null ? "—" : averageCost.averageCostCny} prefix={averageCost.averageCostCny === null ? undefined : "¥"} /></Col>
<Col xs={24} md={6}><Statistic title="平均毛利率" value={averageMargin === null ? "—" : averageMargin.toFixed(1)} suffix={averageMargin === null ? undefined : "%"} /></Col>
<Col xs={24} md={6}><Statistic title="最坏毛利率" value={averageCost.unlimitedWorstCase ? "不限" : worstMargin === null ? "—" : worstMargin.toFixed(1)} suffix={averageCost.unlimitedWorstCase || worstMargin === null ? undefined : "%"} /></Col>
</Row>
<Divider />
<Typography.Paragraph type="secondary">
billingLimit 便minuteLimit {formatNumber(monthlyEntitlement?.metadata?.minuteLimit ?? null)}dayLimit {formatNumber(monthlyEntitlement?.metadata?.dayLimit ?? null)}billingLimit {monthlyEntitlement?.metadata?.billingLimit === null || monthlyEntitlement?.metadata?.billingLimit === undefined ? "不限" : formatNumber(monthlyEntitlement.metadata.billingLimit)}
</Typography.Paragraph>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Statistic title="盈亏平衡月卡数" value={breakEven ?? "—"} /></Col>
<Col xs={24} md={8}><Statistic title="21 秒串行日吞吐" value={formatNumber(Math.floor(86_400_000 / CONSULTATION_DOMAIN_DURATION_MS))} suffix="次" /></Col>
<Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col>
</Row>
</Card>
</Space>
);
}