refactor(admin): 模型定价 - 重排规格价格编辑并精简视频匹配条件

This commit is contained in:
ddCat
2026-09-19 20:28:46 +08:00
parent 6f2f0eb490
commit 0d4d6870b5
9 changed files with 538 additions and 216 deletions

View File

@@ -90,9 +90,7 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
if (tier.matchMode === "advanced") {
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
const specific = (value: string | undefined) => Boolean(value && value !== "*");
if (capability === "video" && tier.videoGenerateAudio && !["*", "true", "false"].includes(tier.videoGenerateAudio)) fail("请选择任意音频、有声或无声");
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0 || specific(tier.videoGenerateAudio)))))
fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
}
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");

View File

@@ -372,27 +372,31 @@ export function ChannelModelEditor({
]}
>
{(fields, { add, remove }, { errors }) => (
<div className="space-y-3">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}
onRemove={() => remove(field.name)}
/>
))}
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
</Button>
<Form.ErrorList errors={errors} />
<div className="admin-price-tier-list-shell">
<div className="admin-price-tier-list" aria-label="积分价格规则列表">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}
onRemove={() => remove(field.name)}
/>
))}
</div>
<div className="admin-price-tier-list-footer">
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
</Button>
<Form.ErrorList errors={errors} />
</div>
</div>
)}
</Form.List>

View File

@@ -38,23 +38,26 @@ export function PriceTierFields({
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isImage = capability === "image";
return (
<div className="admin-price-tier-card">
<div className="admin-price-tier-card-header">
<div>
<div className="text-sm font-medium">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
<article className="admin-price-tier-card">
<header className="admin-price-tier-card-header">
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<div className="admin-price-tier-card-heading">
<span className="admin-price-tier-card-index">{String(ordinal).padStart(2, "0")}</span>
<div className="admin-price-tier-card-heading-copy">
<div className="admin-price-tier-card-title">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
<div className="admin-price-tier-card-summary">{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、质量或尺寸匹配"}</div>
</div>
</div>
<div className="admin-price-tier-controls">
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<div className="admin-price-tier-card-actions">
<div className="admin-price-tier-toggle">
<span title="关闭后保留配置,但不参与用户请求及计费匹配">可供用户使用</span>
<Switch
@@ -67,116 +70,125 @@ export function PriceTierFields({
}}
/>
</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
删除
</Button>
</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
删除
</Button>
</div>
</header>
<div className="admin-price-tier-card-body">
<div className="admin-price-tier-block admin-price-tier-match-block">
<Form.Item className="mb-0" name={[index, "matchMode"]} label="定价方式" tooltip="默认价格适用于所有请求;按规格配置时,精确规则优先。" rules={[{ required: true }]}>
<section className="admin-price-tier-mode-row">
<div className="admin-price-tier-panel-heading">
<div>
<h3>适用范围</h3>
<p>选择全部请求,或按生成规格精确匹配。</p>
</div>
</div>
<Form.Item className="admin-price-tier-mode-control mb-0" name={[index, "matchMode"]} rules={[{ required: true }]}>
<Segmented
block
aria-label="价格规则适用范围"
options={[
{ label: "统一价格", value: "default" },
{ label: "按规格定价", value: "advanced" },
]}
/>
</Form.Item>
</div>
<div className="admin-price-tier-block admin-price-tier-billing-block">
<div className="mb-2 text-sm font-medium">销售价格</div>
<div className="admin-price-tier-billing-grid">
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
className="w-full"
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: isVideo ? "视频 Token" : "Token", value: "token", disabled: !tokenEnabled },
]}
/>
</Form.Item>
{billingMode === "token" ? (
isVideo ? (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="积分 / 百万视频 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="admin-price-tier-token-grid">
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</section>
<div className={`admin-price-tier-workspace ${matchMode === "default" ? "is-default" : "is-advanced"}`}>
{matchMode !== "default" && (
<section className="admin-price-tier-panel admin-price-tier-conditions-panel">
<div className="admin-price-tier-panel-heading">
<div>
<h3>匹配条件</h3>
<p>符合以下条件时使用这条价格,精确规则优先。</p>
</div>
)
) : (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
</div>
{isVideo && billingMode === "token" ? (
<p className="mb-0 mt-2 text-xs text-foreground/60">
所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。
</p>
) : null}
</div>
<CreditCostFields index={index} form={form} billingMode={billingMode} isVideo={isVideo} />
{matchMode !== "default" && (
<div className="admin-price-tier-match-grid">
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
<Select options={operationOptions(capability)} />
</Form.Item>
{isVideo ? (
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0" name={[index, "videoGenerateAudio"]} label="生成音频" rules={[{ required: true, message: "请选择音频条件" }]}>
<Select options={[{ label: "任意音频", value: "*" }, { label: "有声", value: "true" }, { label: "无声", value: "false" }]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select
</div>
<div className="admin-price-tier-match-grid">
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
<Select options={operationOptions(capability)} />
</Form.Item>
{isVideo ? (
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select
options={[
{ label: "任意质量", value: "*" },
{ label: "1K", value: "1k" },
{ label: "2K", value: "2k" },
{ label: "4K", value: "4k" },
]}
/>
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
</Form.Item>
) : null}
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
<Input placeholder="留空则使用模型默认上游 ID" />
</Form.Item>
</div>
</section>
)}
<section className="admin-price-tier-panel admin-price-tier-selling-panel">
<div className="admin-price-tier-panel-heading">
<div>
<h3>销售价格</h3>
<p>设置用户完成一次生成实际消耗的积分。</p>
</div>
</div>
<div className="admin-price-tier-billing-grid">
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
className="w-full"
options={[
{ label: "任意质量", value: "*" },
{ label: "1K", value: "1k" },
{ label: "2K", value: "2k" },
{ label: "4K", value: "4k" },
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: isVideo ? "视频 Token" : "Token", value: "token", disabled: !tokenEnabled },
]}
/>
</Form.Item>
{billingMode === "token" ? (
isVideo ? (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="积分 / 百万视频 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="admin-price-tier-token-grid">
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
)
) : (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
</div>
{isVideo && billingMode === "token" ? (
<div className="admin-price-tier-helper" role="note">
<strong>视频 Token 计费</strong>
<span>估算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024;优先按上游有效用量结算,无用量时使用公式,授权预留 10% 会在结算后补扣或退回。</span>
</div>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
</Form.Item>
) : null}
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
<Input placeholder="留空则使用模型默认上游 ID" />
</Form.Item>
</div>
)}
<CreditCostFields index={index} form={form} billingMode={billingMode} isVideo={isVideo} />
</section>
</div>
{staleTierUpstream ? (
<Alert
className="admin-price-tier-alert"
type="warning"
showIcon
title="该统一价格档仍指向独立上游模型 ID"
@@ -195,7 +207,7 @@ export function PriceTierFields({
/>
) : null}
</div>
</div>
</article>
);
}

View File

@@ -5,7 +5,9 @@ export type PriceTierMatchMode = "default" | "advanced";
// 与后端保存口径一致:上游键比较前剥掉一次 models/ 前缀,避免极旧存量行误报差异。
export function normalizeUpstreamModelKey(value: unknown): string {
return String(value || "").trim().replace(/^models\//, "");
return String(value || "")
.trim()
.replace(/^models\//, "");
}
export type PriceTierFormValues = {
@@ -60,7 +62,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
const selector = tier.selector || {};
const hasSpecificMatch = Object.values(selector).some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*") || tier.videoSeconds > 0;
const hasSpecificMatch = [selector.operation, selector.quality, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
return {
matchMode: hasSpecificMatch ? "advanced" : "default",
operation: selector.operation || "*",
@@ -107,9 +109,6 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
if (capability === "video") {
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
if (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio;
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
@@ -123,7 +122,9 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
}
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0;
void capability;
void tier;
return 0;
}
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {

View File

@@ -14,12 +14,21 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index:
]
: [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]];
return (
<div className="admin-price-tier-block">
<Form.Item name={[index, "costConfigured"]} label="积分成本价(仅管理员可见)" valuePropName="checked" extra="按当前价格档的计费方式核算成本,不影响用户售价;开启并填 0 表示零成本。">
<Switch aria-label="配置积分成本价" />
</Form.Item>
<section className="admin-price-tier-cost-panel" aria-label="积分成本设置">
<header className="admin-price-tier-cost-header">
<div className="admin-price-tier-cost-heading">
<div className="admin-price-tier-cost-title">
<span>积分成本</span>
<span className="admin-price-tier-cost-badge">仅管理员可见</span>
</div>
<p>按当前计费方式填写成本,不影响用户售价。</p>
</div>
<Form.Item className="admin-price-tier-cost-switch mb-0" name={[index, "costConfigured"]} valuePropName="checked">
<Switch aria-label="配置积分成本价" />
</Form.Item>
</header>
{configured ? (
<div className={billingMode === "token" && !isVideo ? "admin-price-tier-token-grid" : "admin-price-tier-billing-grid"}>
<div className={billingMode === "token" && !isVideo ? "admin-price-tier-cost-grid admin-price-tier-cost-token-grid" : "admin-price-tier-cost-grid"}>
{fields.map(([field, label]) => (
<Form.Item key={field} className="mb-0" name={[index, field]} label={label} rules={[{ required: true, message: "请输入积分成本价" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
@@ -27,6 +36,6 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index:
))}
</div>
) : null}
</div>
</section>
);
}

View File

@@ -148,7 +148,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
capability: nextCapability,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
capabilityConfig: nextCapability === "text" || nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
});
}}
/>
@@ -165,7 +165,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
protocol: nextProtocol,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
capabilityConfig: activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
})}
/>
</section>
@@ -175,7 +175,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
key: "capabilities",
label: "能力与参数",
children: <div inert={Boolean(testingModel)}>
{activeCapability === "image" || activeCapability === "video" ? (
{activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? (
<ModelCapabilityEditor
capability={activeCapability}
model={activeModel}

View File

@@ -9263,11 +9263,31 @@
}
}
.admin-price-tier-list-shell {
display: grid;
min-width: 0;
min-height: 0;
gap: 10px;
}
.admin-price-tier-list {
display: grid;
min-width: 0;
gap: 10px;
}
.admin-price-tier-list-footer {
display: grid;
gap: 8px;
}
.admin-price-tier-card {
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 8%, var(--admin-card-border));
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 2%, var(--admin-layer-1));
background: var(--admin-layer-1);
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent);
}
.admin-price-tier-card-header {
@@ -9275,86 +9295,274 @@
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
gap: 10px;
background: color-mix(in srgb, var(--foreground) 1.5%, var(--admin-layer-1));
padding: 8px 12px;
}
.admin-price-tier-card-heading,
.admin-price-tier-card-actions,
.admin-price-tier-cost-header,
.admin-price-tier-cost-title {
display: flex;
min-width: 0;
align-items: center;
}
.admin-price-tier-card-heading {
gap: 8px;
}
.admin-price-tier-card-index {
display: grid;
width: 26px;
height: 26px;
flex: 0 0 auto;
place-items: center;
border: 0;
border-radius: 6px;
background: color-mix(in srgb, var(--workspace-accent) 10%, var(--admin-layer-2));
color: color-mix(in srgb, var(--workspace-accent) 76%, var(--admin-text-primary));
font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.admin-price-tier-card-heading-copy {
min-width: 0;
}
.admin-price-tier-card-title {
color: var(--admin-text-primary);
font-size: 13px;
font-weight: 650;
line-height: 1.35;
}
.admin-price-tier-card-summary {
overflow: hidden;
color: var(--admin-text-muted);
font-size: 10px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-price-tier-card-actions {
flex: 0 0 auto;
gap: 6px;
}
.admin-price-tier-card-actions > .ant-btn {
padding-inline: 5px;
font-size: 12px;
}
.admin-price-tier-card-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 12px 16px;
padding: 12px;
}
.admin-price-tier-block {
min-width: 0;
}
.admin-price-tier-block-title {
margin-bottom: 10px;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.08em;
}
.admin-price-tier-block-title > span {
margin-right: 5px;
color: var(--admin-text-muted);
}
.admin-price-tier-match-grid {
grid-column: 1 / -1;
.admin-price-tier-mode-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 0;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
align-items: center;
gap: 16px;
background: color-mix(in srgb, var(--foreground) 0.8%, transparent);
padding: 9px 12px;
}
.admin-price-tier-upstream {
grid-column: span 2;
.admin-price-tier-mode-row .admin-price-tier-panel-heading {
margin-bottom: 0;
}
.admin-price-tier-billing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 12px;
.admin-price-tier-mode-row .admin-price-tier-panel-heading > div {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 8px;
}
.admin-price-tier-token-grid {
grid-column: 1 / -1;
.admin-price-tier-mode-row .admin-price-tier-panel-heading p {
margin-top: 0;
}
.admin-price-tier-token-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
.admin-price-tier-mode-control {
min-width: 0;
}
.admin-price-tier-billing-mode .ant-segmented {
border: 1px solid var(--admin-card-border);
.admin-price-tier-mode-control .ant-segmented {
width: 100%;
border: 0;
background: var(--admin-layer-2);
padding: 2px;
}
.admin-price-tier-billing-mode .ant-segmented-item {
min-height: 28px;
border-radius: 5px;
.admin-price-tier-workspace {
display: grid;
min-width: 0;
align-items: start;
gap: 12px;
padding: 10px 12px 12px;
}
.admin-price-tier-billing-mode .ant-segmented-item-label {
min-height: 28px;
padding-inline: 12px;
line-height: 28px;
.admin-price-tier-workspace.is-advanced {
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.6fr);
}
.admin-price-tier-billing-mode .ant-segmented-item-selected {
background: var(--admin-layer-1);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 14%, transparent);
.admin-price-tier-workspace.is-default {
grid-template-columns: minmax(0, 1fr);
}
.admin-price-tier-unit-price .ant-input-number {
.admin-price-tier-panel {
min-width: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 1.8%, var(--admin-layer-2));
padding: 11px 12px 12px;
}
.admin-price-tier-panel-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.admin-price-tier-panel-heading > div,
.admin-price-tier-cost-heading {
display: flex;
min-width: 0;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 9px;
}
.admin-price-tier-panel-heading h3,
.admin-price-tier-cost-title {
margin: 0;
color: var(--admin-text-primary);
font-size: 12px;
font-weight: 650;
line-height: 1.4;
}
.admin-price-tier-panel-heading p,
.admin-price-tier-cost-heading p {
margin: 0;
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.4;
}
.admin-price-tier-match-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px 10px;
}
.admin-price-tier-upstream {
grid-column: 1 / -1;
}
.admin-price-tier-billing-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(190px, 0.85fr);
align-items: start;
gap: 10px;
}
.admin-price-tier-token-grid {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.admin-price-tier-cost-panel {
min-width: 0;
margin-top: 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 2.2%, transparent);
padding: 9px 10px 10px;
}
.admin-price-tier-cost-header {
justify-content: space-between;
gap: 10px;
}
.admin-price-tier-cost-heading {
min-width: 0;
}
.admin-price-tier-cost-title {
gap: 8px;
}
.admin-price-tier-cost-badge {
border: 1px solid color-mix(in srgb, var(--workspace-accent) 24%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--workspace-accent) 8%, transparent);
padding: 2px 7px;
color: color-mix(in srgb, var(--workspace-accent) 72%, var(--admin-text-primary));
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.admin-price-tier-cost-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
align-items: start;
gap: 10px;
margin-top: 8px;
}
.admin-price-tier-cost-token-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-price-tier-cost-switch {
flex: 0 0 auto;
}
.admin-price-tier-alert {
margin: 0 16px 16px;
}
.admin-price-tier-helper {
display: flex;
max-width: none;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 8px;
margin-top: 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--workspace-accent) 3.5%, transparent);
padding: 6px 8px;
color: var(--admin-text-muted);
font-size: 10px;
line-height: 1.4;
}
.admin-price-tier-helper strong {
color: var(--admin-text-secondary);
flex: 0 0 auto;
font-size: 10px;
font-weight: 650;
}
.admin-price-tier-helper span {
min-width: 240px;
flex: 1 1 520px;
}
.admin-price-tier-unit-price .ant-input-number,
.admin-price-tier-cost-grid .ant-input-number {
width: 100% !important;
}
@@ -9366,20 +9574,50 @@
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 7px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 3px 6px 3px 8px;
}
.admin-price-tier-toggle span {
overflow-wrap: anywhere;
color: var(--admin-text-muted);
font-size: 12px;
font-size: 11px;
line-height: 1.4;
white-space: normal;
}
.admin-model-editor-add-tier.ant-btn {
min-height: 38px;
border-color: color-mix(in srgb, var(--workspace-accent) 28%, var(--admin-card-border));
background: color-mix(in srgb, var(--workspace-accent) 4%, transparent);
}
.admin-price-tier-billing-mode .ant-segmented {
border: 0;
background: var(--admin-layer-2);
padding: 2px;
}
.admin-price-tier-mode-control .ant-segmented-item,
.admin-price-tier-billing-mode .ant-segmented-item {
min-height: 28px;
border-radius: 5px;
}
.admin-price-tier-mode-control .ant-segmented-item-label,
.admin-price-tier-billing-mode .ant-segmented-item-label {
min-height: 28px;
padding-inline: 12px;
line-height: 28px;
}
.admin-price-tier-mode-control .ant-segmented-item-selected,
.admin-price-tier-billing-mode .ant-segmented-item-selected {
background: var(--admin-layer-1);
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 7%, transparent);
}
.admin-model-editor-footer-actions {
@@ -9441,14 +9679,19 @@
:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
:root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card,
:root:not(.dark) .admin-price-tier-toggle,
:root:not(.dark) .admin-price-tier-mode-control .ant-segmented,
:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented {
border-color: rgba(17, 17, 17, 0.18);
background: #ffffff;
}
:root:not(.dark) .admin-price-tier-card {
border-color: rgba(17, 17, 17, 0.18);
background: #f7f7f7;
border-color: rgba(17, 17, 17, 0.1);
background: #ffffff;
}
:root:not(.dark) .admin-price-tier-panel {
background: #f8f8f8;
}
:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
@@ -9492,10 +9735,12 @@
box-shadow: 0 6px 20px rgb(17 17 17 / 0.08);
}
:root:not(.dark) .admin-price-tier-mode-control .ant-segmented-item-selected,
:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented-item-selected {
background: #ececec;
box-shadow: inset 0 0 0 1px rgb(17 17 17 / 0.12);
box-shadow: 0 1px 2px rgb(17 17 17 / 0.08);
}
@media (max-width: 600px) {
.admin-model-editor-modal .ant-modal-container {
height: calc(100dvh - 24px);
@@ -9513,6 +9758,23 @@
.admin-model-editor-modal .admin-capability-protocol-grid {
grid-template-columns: minmax(0, 1fr);
}
.admin-price-tier-card-header {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.admin-price-tier-card-actions {
width: 100%;
justify-content: space-between;
}
.admin-price-tier-mode-row {
grid-template-columns: minmax(0, 1fr);
gap: 7px;
padding: 9px 10px;
}
.admin-price-tier-workspace {
padding: 10px;
}
.admin-price-tier-upstream {
grid-column: 1 / -1;
}
@@ -9520,14 +9782,15 @@
.admin-model-protocol-grid,
.admin-price-tier-billing-grid,
.admin-price-tier-match-grid,
.admin-price-tier-token-grid {
.admin-price-tier-token-grid,
.admin-price-tier-cost-token-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 899px) {
.admin-model-editor-section,
.admin-price-tier-card-body {
.admin-price-tier-workspace.is-advanced {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -10840,7 +11103,6 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
line-height: 1;
}
.admin-banner-icon-picker-option.is-active {
border-color: color-mix(in srgb, var(--foreground) 22%, transparent);
background: var(--control-selected-bg);
@@ -10943,3 +11205,37 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
.admin-banner-type-card[data-banner-type="warning"] .admin-banner-type-card-preview {
background: var(--notice-banner-surface-warning);
}
/* 模型定价编辑器:弹窗内只保留一个滚动区域。 */
.admin-model-editor-modal .ant-modal-body {
display: flex;
min-height: 0;
overflow: hidden;
}
.admin-model-editor-modal .admin-model-editor-form {
display: flex;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.admin-model-editor-modal .admin-model-editor-form > .ant-tabs {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder > .ant-tabs-content,
.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder > .ant-tabs-content > .ant-tabs-tabpane {
min-height: 100%;
}

View File

@@ -83,11 +83,12 @@ describe("pricing write validation", () => {
video.priceTiers[0].outputTokenPrice = 0.000001;
expect(() => validateChannelModelPrices(video)).not.toThrow();
});
test("accepts audio-only video price conditions and rejects invalid audio selectors", () => {
for (const videoGenerateAudio of ["true", "false"]) {
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow();
test("rejects removed video conditions and accepts visible operation or resolution matches", () => {
for (const hiddenCondition of [{ videoGenerateAudio: "false" }, { videoSeconds: 5 }, { imageCount: 2 }]) {
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", ...hiddenCondition }] })).toThrow("匹配条件");
}
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声");
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), operation: "image_to_video" }] })).not.toThrow();
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), resolution: "1080p" }] })).not.toThrow();
});
});

View File

@@ -70,21 +70,22 @@ describe("channel model price tier defaults", () => {
expect(priceTierToForm(base).matchMode).toBe("default");
expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced");
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" });
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "default", videoGenerateAudio: "false" });
});
test("preserves explicit silent and audible selectors but omits the wildcard", () => {
test("keeps video matching limited to operation and resolution", () => {
for (const videoGenerateAudio of ["true", "false"]) {
const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio };
expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio });
expect(skuSelectorFromForm("image", tier)).toEqual({});
const tier = { ...defaultPriceTier("advanced"), operation: "image_to_video", resolution: "1080p", videoSeconds: 10, videoGenerateAudio, imageCount: 2 };
expect(skuSelectorFromForm("video", tier)).toEqual({ operation: "image_to_video", vquality: "1080p" });
expect(priceTierVideoSecondsFromForm("video", tier)).toBe(0);
expect(skuSelectorFromForm("image", { ...defaultPriceTier("advanced"), videoSeconds: 10, videoGenerateAudio, imageCount: 2 })).toEqual({});
}
expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({});
});
test("video Token writes discard hidden text prices without changing the configured video rate", () => {
const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 };
expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 });
expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: {}, videoSeconds: 0, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 });
expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 });
expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0);
});