feat(admin): add read-only pricing simulator
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user