feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
120
web/src/pages/admin/components/channel-model-pricing.ts
Normal file
120
web/src/pages/admin/components/channel-model-pricing.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
224
web/src/pages/admin/components/channel-model-reprice-dialog.tsx
Normal file
224
web/src/pages/admin/components/channel-model-reprice-dialog.tsx
Normal 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 }) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user