feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动

This commit is contained in:
ddCat
2026-09-20 11:18:19 +08:00
parent 8e64dc6d82
commit 46c498ff87
26 changed files with 1761 additions and 44 deletions

View File

@@ -1,14 +1,14 @@
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
import { AdminStatusBadge } from "./admin-ui";
import { formatModelMargin, formatModelPrice, modelPriceFields } from "./channel-model-pricing";
export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
// Cost configuration is independent of the user-facing sale price.
const tiers = item.priceTiers?.filter((tier) => tier.enabled) || [];
if (!tiers.length) return <AdminStatusBadge label="未配置成本" tone="warning" />;
if (!tiers.length) return <AdminStatusBadge label="无已启用规格" tone="warning" />;
const renderTier = (tier: ChannelModelPriceTier) => (
<div className="admin-model-cost-tier" key={tier.id}>
<div className="admin-model-cost-spec">{specificationLabel(tier)}</div>
<div className="admin-model-cost-value">{costLabel(tier, item.capability)}</div>
<div className="admin-model-cost-value">{priceLabel(tier, item.capability)}</div>
</div>
);
return (
@@ -16,7 +16,7 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
{tiers.slice(0, 3).map(renderTier)}
{tiers.length > 3 ? (
<details className="admin-model-cost-more">
<summary>其余 {tiers.length - 3} 个规格成本</summary>
<summary>其余 {tiers.length - 3} 个规格价格</summary>
{tiers.slice(3).map(renderTier)}
</details>
) : null}
@@ -24,25 +24,20 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
);
}
function costLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
const cost = tier.costPricing;
if (!cost?.configured) return <span className="admin-model-cost-missing">未配置成本</span>;
const format = (value: number) => (Number.isFinite(value) && value >= 0 ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—");
if (tier.billingMode === "token") {
if (capability === "video") return `${format(cost.outputTokenPriceMicrocredits)} 积分 / 百万视频 Token`;
function priceLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
return modelPriceFields(tier, capability).map(({ key, label, unit }) => {
const cost = tier.costPricing?.configured ? tier.costPricing[key] : undefined;
const sale = tier.priceConfigured ? tier[key] : undefined;
return (
<>
<span>
输入 {format(cost.inputTokenPriceMicrocredits)} · 输出 {format(cost.outputTokenPriceMicrocredits)} · 缓存 {format(cost.cachedTokenPriceMicrocredits)}
</span>
<span className="admin-model-cost-unit">积分 / 百万 Token</span>
</>
<div key={key}>
<span>{label ? `${label} ` : ""}{tier.costPricing?.configured ? formatModelPrice(cost) : "未配置成本"} / {tier.priceConfigured ? formatModelPrice(sale) : "未配置售价"}</span>
<span className="admin-model-cost-unit">积分 / {unit} · 利润率 {formatModelMargin(cost, sale)}</span>
</div>
);
}
return `${format(cost.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
});
}
function specificationLabel(tier: ChannelModelPriceTier) {
export function specificationLabel(tier: ChannelModelPriceTier) {
const selector = tier.selector || {};
const specific = (value?: string) => (value && value !== "*" ? value : "");
const operation = specific(selector.operation);

View File

@@ -13,12 +13,14 @@ import { ChannelModelEditor } from "./channel-model-editor";
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
import { ChannelOrderDialog } from "./channel-order-dialog";
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise<void> }) {
const { message, modal } = App.useApp();
const [items, setItems] = useState<ChannelModel[]>([]);
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const [deletingSelected, setDeletingSelected] = useState(false);
const [repriceItems, setRepriceItems] = useState<ChannelModel[]>([]);
const [editing, setEditing] = useState<ChannelModel | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
@@ -70,6 +72,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
setEditing(null);
setEditorOpen(false);
setSelectedModelIds([]);
setRepriceItems([]);
resetFetchPreview();
setKeyword("");
setCapability("all");
@@ -209,7 +212,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<AdminStatusBadge label="待配置" tone="warning" />
),
},
{ title: "规格成本价", width: 290, render: (_, item) => <ChannelModelCostSummary item={item} /> },
{ title: "规格成本价 / 销售价 / 利润率", width: 310, render: (_, item) => <ChannelModelCostSummary item={item} /> },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
{
@@ -264,7 +267,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<h3 className="admin-channel-model-heading">
模型管理 <span className="admin-channel-count">{items.length}</span>
</h3>
<p className="admin-channel-model-hint">规格展示上游成本,不影响用户售价</p>
<p className="admin-channel-model-hint">规格展示成本价 / 销售价及利润率,勾选模型可统一调价</p>
</div>
<Space wrap>
<ChannelOrderDialog
@@ -382,6 +385,9 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
}}
batchActions={
<AdminBatchBar count={selectedModelIds.length} onClear={() => setSelectedModelIds([])}>
<Button size="small" disabled={deletingSelected || loading || Boolean(loadError)} onClick={() => setRepriceItems(items.filter((item) => selectedModelIds.includes(item.id)))}>
统一调价
</Button>
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deletingSelected} onClick={confirmBatchRemove}>
批量删除
</Button>
@@ -419,6 +425,11 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
/>
}
/>
{repriceItems.length > 0 && <ChannelModelRepriceDialog channelId={channel.id} channelName={channel.name} items={repriceItems} onClose={() => setRepriceItems([])} onSaved={async () => {
setSelectedModelIds([]);
await reload();
await onChanged();
}} />}
<Modal
title="选择要导入的模型"
open={fetchPreviewOpen}

View File

@@ -0,0 +1,120 @@
import type { ChannelModel, ChannelModelPriceTier, ChannelModelRepriceInput } from "@/services/api/wallet";
const validPrice = (value: number | undefined): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
export function formatModelPrice(value: number | undefined) {
return validPrice(value) ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—";
}
export function formatModelMargin(cost: number | undefined, sale: number | undefined) {
if (!validPrice(cost) || !validPrice(sale) || sale === 0) return "—";
return `${(((sale - cost) * 100) / sale).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`;
}
export function modelPriceFields(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
if (tier.billingMode !== "token") return [{ key: "unitPriceMicrocredits" as const, label: "", unit: tier.billingMode === "per_second" ? "秒" : "次" }];
if (capability === "video") return [{ key: "outputTokenPriceMicrocredits" as const, label: "视频", unit: "百万视频 Token" }];
return [
{ key: "inputTokenPriceMicrocredits" as const, label: "输入", unit: "百万 Token" },
{ key: "outputTokenPriceMicrocredits" as const, label: "输出", unit: "百万 Token" },
{ key: "cachedTokenPriceMicrocredits" as const, label: "缓存", unit: "百万 Token" },
];
}
export function previewModelRepricing(items: ChannelModel[], marginPercent: number | null, decimalPlaces = 2): ChannelModel[] {
if (marginPercent === null || !Number.isFinite(marginPercent) || marginPercent < 0 || marginPercent > 99.99 || Math.abs(marginPercent * 100 - Math.round(marginPercent * 100)) > 1e-8) {
throw new Error("请输入 0% 到 99.99% 的目标利润率,最多两位小数");
}
if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型");
if (!Number.isInteger(decimalPlaces) || decimalPlaces < 0 || decimalPlaces > 6) throw new Error("请选择保留 0 到 6 位小数");
const denominator = BigInt(10_000 - Math.round(marginPercent * 100));
const quantum = BigInt(10 ** (6 - decimalPlaces));
return items.map((item) => {
if (!item.priceTiers.some((tier) => tier.enabled)) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`);
return {
...item,
priceTiers: item.priceTiers.map((tier) => {
if (!tier.enabled) return tier;
const name = `${item.displayName || item.modelKey}(${tier.selectorKey})`;
if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${name}:请先保存完整的规格成本价和销售价配置`);
const next = { ...tier };
for (const { key } of modelPriceFields(tier, item.capability)) {
const cost = tier.costPricing[key];
if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${name}:成本价无效`);
// Round once at the selected credit precision, with exact half-up ties.
const numerator = BigInt(cost) * BigInt(10_000);
const divisor = denominator * quantum;
const sale = Number(((numerator * BigInt(2) + divisor) / (divisor * BigInt(2))) * quantum);
if (!validPrice(sale) || (tier.billingMode === "token" && sale > 1_000_000_000_000)) throw new Error(`${name}:调整后售价超过上限`);
next[key] = sale;
}
return next;
}),
};
});
}
export function saleInputValue(microcredits: number) {
if (!validPrice(microcredits)) return null;
const value = BigInt(microcredits);
return `${value / BigInt(1_000_000)}.${String(value % BigInt(1_000_000)).padStart(6, "0")}`.replace(/\.?0+$/, "");
}
export function parseSaleInput(value: string | null) {
if (value === null || !/^\d+(?:\.\d{0,6})?$/.test(value)) throw new Error("销售价须为非负数,最多保留 6 位小数");
const [integer, fraction = ""] = value.split(".");
const amount = Number(BigInt(integer) * BigInt(1_000_000) + BigInt(fraction.padEnd(6, "0")));
if (!validPrice(amount)) throw new Error("销售价超出有效范围");
return amount;
}
export type ModelRepriceRow = {
key: string;
model: ChannelModel;
tier: ChannelModelPriceTier;
field: ReturnType<typeof modelPriceFields>[number];
sale: string | null;
manual: boolean;
};
export function modelRepriceRows(items: ChannelModel[]): ModelRepriceRow[] {
return items.flatMap((model) =>
model.priceTiers
.filter((tier) => tier.enabled)
.flatMap((tier) =>
modelPriceFields(tier, model.capability).map((field) => ({
key: `${tier.id}:${field.key}`,
model,
tier,
field,
sale: tier.priceConfigured ? saleInputValue(tier[field.key]) : null,
manual: false,
})),
),
);
}
export function modelRepricePayload(items: ChannelModel[], rows: ModelRepriceRow[]): ChannelModelRepriceInput[] {
if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型");
const byKey = new Map(rows.map((row) => [row.key, row]));
return items.map((item) => {
const tiers = item.priceTiers.filter((tier) => tier.enabled);
if (!tiers.length) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`);
return {
modelId: item.id,
priceVersion: item.priceVersion,
priceTiers: tiers.map((tier) => {
if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${item.displayName || item.modelKey}:请先配置规格成本价和销售价`);
const prices: ChannelModelRepriceInput["priceTiers"][number]["prices"] = {};
for (const { key } of modelPriceFields(tier, item.capability)) {
const cost = tier.costPricing[key];
if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${item.displayName || item.modelKey}:成本价无效`);
const sale = parseSaleInput(byKey.get(`${tier.id}:${key}`)?.sale ?? null);
if (tier.billingMode === "token" && sale > 1_000_000_000_000) throw new Error("Token 售价不能超过 1,000,000 积分");
prices[key] = sale;
}
return { id: tier.id, priceVersion: tier.priceVersion, prices };
}),
};
});
}

View File

@@ -0,0 +1,224 @@
import { useState } from "react";
import { Alert, App, Button, InputNumber, Select, Table } from "antd";
import { AdminModal } from "@/pages/admin/ui/overlays";
import { repriceAdminChannelModels, type ChannelModel } from "@/services/api/wallet";
import { specificationLabel } from "./channel-model-cost-summary";
import { formatModelMargin, formatModelPrice, modelRepricePayload, modelRepriceRows, parseSaleInput, previewModelRepricing, type ModelRepriceRow } from "./channel-model-pricing";
export function ChannelModelRepriceDialog({ channelId, channelName, items, onClose, onSaved }: { channelId: string; channelName: string; items: ChannelModel[]; onClose: () => void; onSaved: () => Promise<void> }) {
const { message } = App.useApp();
const [margin, setMargin] = useState<number | null>(null);
const [decimalPlaces, setDecimalPlaces] = useState(2);
const [rows, setRows] = useState(() => modelRepriceRows(items));
const [calculationError, setCalculationError] = useState("");
const [saving, setSaving] = useState(false);
let validationError = "";
try {
modelRepricePayload(items, rows);
} catch (cause) {
validationError = cause instanceof Error ? cause.message : "销售价无效";
}
const original = new Map(modelRepriceRows(items).map((row) => [row.key, row.sale]));
const changed = rows.filter((row) => {
try {
return parseSaleInput(row.sale) !== parseSaleInput(original.get(row.key) ?? null);
} catch {
return false;
}
}).length;
const recalculate = (nextMargin: number | null, places: number) => {
setMargin(nextMargin);
setDecimalPlaces(places);
if (nextMargin === null) {
setCalculationError("");
return;
}
try {
setRows(modelRepriceRows(previewModelRepricing(items, nextMargin, places)));
setCalculationError("");
} catch (cause) {
setCalculationError(cause instanceof Error ? cause.message : "无法计算销售价");
}
};
const save = async () => {
if (validationError || calculationError || !changed || saving) return;
setSaving(true);
try {
const result = await repriceAdminChannelModels(channelId, modelRepricePayload(items, rows));
message.success(`已保存 ${result.updated} 个模型的销售价`);
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "统一调价失败");
setSaving(false);
return;
}
try {
await onSaved();
} catch {
message.warning("价格已保存,但列表刷新失败,请手动刷新");
} finally {
setSaving(false);
onClose();
}
};
return (
<AdminModal
title={`统一调价 · ${items.length} 个模型`}
open
centered
width={1180}
rootClassName="admin-model-reprice-modal"
onCancel={onClose}
closable={!saving}
mask={{ closable: !saving }}
keyboard={!saving}
confirmLoading={saving}
cancelButtonProps={{ disabled: saving }}
okButtonProps={{ disabled: Boolean(validationError || calculationError) || !changed }}
okText={`保存销售价${changed ? `(${changed} 项)` : ""}`}
onOk={() => void save()}
>
<div className="admin-model-reprice">
<div className="admin-model-reprice-controls">
<div className="admin-model-reprice-field">
<label htmlFor="reprice-margin">目标利润率</label>
<InputNumber id="reprice-margin" aria-label="目标利润率" size="large" value={margin} onChange={(value) => recalculate(value, decimalPlaces)} min={0} max={99.99} step={1} suffix="%" placeholder="输入百分比" disabled={saving} />
</div>
<div className="admin-model-reprice-field">
<label htmlFor="reprice-decimals">保留小数位</label>
<Select
id="reprice-decimals"
aria-label="保留小数位"
size="large"
value={decimalPlaces}
disabled={saving}
onChange={(value) => recalculate(margin, value)}
options={Array.from({ length: 7 }, (_, value) => ({ value, label: `${value} 位小数` }))}
/>
</div>
<Button size="large" disabled={saving || margin === null} onClick={() => recalculate(margin, decimalPlaces)}>
重新计算
</Button>
<div className="admin-model-reprice-formula">
<strong>按成本计算,四舍五入</strong>
<span>新售价 = 成本价 ÷(1 − 利润率)</span>
</div>
</div>
<p className="admin-model-reprice-note">修改利润率、小数位或点击「重新计算」会按成本重算全部规格售价,覆盖手动修改。计算后可继续单独编辑,保存以输入框的最终值为准。</p>
<div className="admin-model-reprice-list-heading">
<strong>模型价格</strong>
<span>
{rows.length} 个计费项 · 已修改 {changed} 项
</span>
</div>
<ChannelModelRepriceTable
items={items}
rows={rows}
channelName={channelName}
decimalPlaces={decimalPlaces}
disabled={saving}
onSaleChange={(key, value) => setRows((current) => current.map((entry) => (entry.key === key ? { ...entry, sale: value, manual: true } : entry)))}
/>
{calculationError || validationError ? <Alert type="warning" showIcon title={calculationError || validationError} /> : null}
<p className="admin-model-reprice-note">仅修改已启用规格的售价,不改变成本或启停状态。四舍五入及手动修改可能使实际利润率偏离目标;价格配置发生变化时整批拒绝保存,请刷新后重试。</p>
</div>
</AdminModal>
);
}
export function ChannelModelRepriceTable({
items,
rows,
channelName,
decimalPlaces,
disabled,
onSaleChange,
}: {
items: ChannelModel[];
rows: ModelRepriceRow[];
channelName: string;
decimalPlaces: number;
disabled: boolean;
onSaleChange: (key: string, value: string | null) => void;
}) {
const byModel = new Map<string, ModelRepriceRow[]>();
for (const row of rows) {
const group = byModel.get(row.model.id) ?? [];
group.push(row);
byModel.set(row.model.id, group);
}
return (
<Table<ChannelModel>
className="admin-model-reprice-table"
rowKey="id"
dataSource={items}
pagination={false}
tableLayout="fixed"
scroll={{ x: 1040 }}
locale={{ emptyText: "暂无所选模型" }}
columns={[
{ title: "模型展示名", width: 180, render: (_, model) => <strong>{model.displayName || model.modelKey}</strong> },
{
title: "渠道展示名(上游模型ID)",
width: 220,
render: (_, model) => (
<div className="admin-model-reprice-identity">
<span>{model.channelLabel || channelName}</span>
<small className="admin-monospace">{model.providerModelKey || model.modelKey}</small>
</div>
),
},
// One model per table row; the shared grid keeps each specification and its prices aligned even when labels wrap.
{
title: "规格",
width: 140,
onCell: () => ({ colSpan: 3, className: "admin-model-reprice-spec-cell" }),
render: (_, model) => (
<div className="admin-model-reprice-specs">
{(byModel.get(model.id) ?? []).map((row) => {
let sale: number | undefined;
try {
sale = parseSaleInput(row.sale);
} catch {
/* Incomplete input remains visible and blocks saving. */
}
const cost = row.tier.costPricing?.configured ? row.tier.costPricing[row.field.key] : undefined;
return (
<div key={row.key} className="admin-model-reprice-spec-row">
<div className="admin-model-reprice-identity">
<span>{specificationLabel(row.tier)}</span>
{row.field.label && <small>{row.field.label}</small>}
{row.tier.providerModelKey && row.tier.providerModelKey !== (model.providerModelKey || model.modelKey) && <small className="admin-monospace">上游:{row.tier.providerModelKey}</small>}
</div>
<div className="admin-model-reprice-identity">
<span>{cost === undefined ? "未配置成本" : formatModelPrice(cost)}</span>
<small>积分 / {row.field.unit}</small>
</div>
<div className="admin-model-reprice-sale">
<InputNumber<string>
stringMode
aria-label={`${model.displayName || model.modelKey} ${specificationLabel(row.tier)} ${row.field.label} 销售价`}
size="middle"
value={row.sale}
min="0"
step={String(10 ** -decimalPlaces)}
disabled={disabled}
status={sale === undefined ? "error" : undefined}
onChange={(value) => onSaleChange(row.key, value)}
/>
<small>
{row.manual ? "手动修改 · " : ""}利润率 {formatModelMargin(cost, sale)}
</small>
</div>
</div>
);
})}
{!byModel.get(model.id)?.length && <div className="admin-model-reprice-no-spec">暂无已启用规格</div>}
</div>
),
},
{ title: "成本价", width: 160, onCell: () => ({ colSpan: 0 }) },
{ title: "销售价(积分)", width: 340, onCell: () => ({ colSpan: 0 }) },
]}
/>
);
}