feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示

This commit is contained in:
ddCat
2026-09-18 23:26:54 +08:00
parent 90add8c466
commit 1a79005d25
57 changed files with 2904 additions and 341 deletions

View File

@@ -8,6 +8,8 @@ export type ChannelModelFormValues = {
modelKey: string;
providerModelKey?: string;
displayName?: string;
channelLabel?: string;
description?: string;
icon?: string;
capability: ModelCapabilityChoice;
protocol?: string;
@@ -30,6 +32,8 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
modelKey: item?.modelKey || "",
providerModelKey: upstreamModel,
displayName: item?.displayName || "",
channelLabel: item?.channelLabel || "",
description: item?.description || "",
icon: item?.icon || "",
capability,
protocol,
@@ -54,6 +58,7 @@ export function changeChannelModelCapability(values: ChannelModelFormValues, pro
size: "*",
resolution: "*",
videoSeconds: 0,
videoGenerateAudio: "*",
imageCount: 0,
})),
};
@@ -81,15 +86,15 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
};
if (!["fixed_request", "per_second", "token"].includes(tier.billingMode)) fail("请选择计费方式");
if (tier.billingMode === "per_second" && capability !== "video") fail("按秒计费仅支持视频,请重新选择计费方式并核对价格");
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前协议不支持 Token 计费,请重新选择计费方式并核对价格");
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前模型能力不支持 Token 计费,请重新选择计费方式并核对价格");
if (tier.matchMode === "advanced") {
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
const specific = (value: string | undefined) => Boolean(value && value !== "*");
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0))))
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("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
}
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 之间的有效数值");
if (tier.billingMode === "token" && capability === "video" && tier.outputTokenPrice < 0.000001) fail("视频 Token 价格必须至少为 0.000001");
});
}

View File

@@ -10,7 +10,7 @@ import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type Mode
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
@@ -106,22 +106,12 @@ export function ChannelModelEditor({
modelKey: values.modelKey.trim(),
providerModelKey: upstreamModel,
displayName: values.displayName?.trim() || values.modelKey.trim(),
channelLabel: values.channelLabel?.trim() || "",
description: values.description?.trim() || "",
icon: values.icon?.trim() || "",
capability: values.capability,
protocol: values.protocol,
priceTiers: values.priceTiers.map((tier) => ({
selector: skuSelectorFromForm(values.capability, tier),
resolution: priceTierResolutionFromForm(values.capability, tier),
videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier),
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
billingMode: tier.billingMode,
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
})),
priceTiers: values.priceTiers.map((tier) => priceTierPayloadFromForm(values.capability, tier, upstreamModel)),
enabled: values.enabled !== false,
capabilityConfig,
};
@@ -244,7 +234,7 @@ export function ChannelModelEditor({
<section className="admin-model-editor-section">
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, whitespace: true, message: "请输入产品模型标识" }]}>
<Form.Item name="modelKey" label="产品模型标识" tooltip="不同渠道使用相同标识时,创作端归为同一个产品模型。不同版本(例如 Fast)应使用不同标识;请勿为分组随意修改已有标识。" rules={[{ required: true, whitespace: true, message: "请输入产品模型标识" }]}>
<Input
prefix={
<span className="grid size-6 place-items-center">
@@ -257,9 +247,12 @@ export function ChannelModelEditor({
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="后台显示名称" tooltip="仅用于后台识别,不改变调用 ID。">
<Form.Item name="displayName" label="模型展示名" tooltip="创作端一级菜单名称。同一产品模型标识请使用相同名称和 Logo,不改变调用 ID。">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名" tooltip="创作端二级菜单名称,仅作用于此渠道中的当前模型。留空使用渠道公开名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
@@ -272,6 +265,9 @@ export function ChannelModelEditor({
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
/>
) : null}
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项悬浮或聚焦时显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写此渠道模型的使用说明" />
</Form.Item>
</section>
<section className="admin-model-editor-section">
<SectionHeading title="能力与协议" description="先选任务类型,再选择对应的调用协议;更换后请核对参数与价格。" />

View File

@@ -183,6 +183,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<div className="min-w-0">
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
<div className="admin-monospace truncate text-xs text-foreground/45">{item.modelKey}</div>
{item.channelLabel ? <div className="truncate text-xs text-foreground/60">渠道展示名:{item.channelLabel}</div> : null}
{item.providerModelKey && item.providerModelKey !== item.modelKey ? <div className="admin-monospace truncate text-xs text-foreground/35">上游:{item.providerModelKey}</div> : null}
</div>
</div>
@@ -224,7 +225,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const filteredItems = items.filter((item) => {
const query = keyword.trim().toLowerCase();
if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName}`.toLowerCase().includes(query)) return false;
if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName} ${item.channelLabel || ""}`.toLowerCase().includes(query)) return false;
if (capability !== "all" && item.capability !== capability) return false;
if (status === "enabled" && !item.enabled) return false;
if (status === "disabled" && item.enabled) return false;
@@ -473,14 +474,14 @@ function billingSummary(item: ChannelModel) {
return (
<div className="space-y-1 text-xs leading-5">
{tiers.slice(0, 3).map((tier) => (
<div key={tier.id}>{priceTierLabel(tier)}</div>
<div key={tier.id}>{priceTierLabel(tier, item.capability)}</div>
))}
{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}
</div>
);
}
function priceTierLabel(tier: ChannelModelPriceTier) {
function priceTierLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
const selector = tier.selector || {};
const specParts = [
selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式",
@@ -489,9 +490,10 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
].filter(Boolean);
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} 积分 / 百万${capability === "video" ? "视频 " : " "}Token`;
return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
}

View File

@@ -38,7 +38,7 @@ export function PriceTierFields({
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isImage = capability === "image";
return (
@@ -91,14 +91,14 @@ export function PriceTierFields({
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: "Token", value: "token", disabled: !tokenEnabled },
{ 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.000001} max={1_000_000} precision={6} step={0.1} />
<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">
@@ -119,6 +119,11 @@ export function PriceTierFields({
</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>
{matchMode !== "default" && (
<div className="admin-price-tier-match-grid">
@@ -135,6 +140,11 @@ export function PriceTierFields({
{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 表示任意数量" />

View File

@@ -15,6 +15,7 @@ export type PriceTierFormValues = {
size: string;
resolution: string;
videoSeconds: number;
videoGenerateAudio: string;
imageCount: number;
providerModelKey?: string;
billingMode: ChannelModel["billingMode"];
@@ -34,6 +35,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
size: "*",
resolution: "*",
videoSeconds: 0,
videoGenerateAudio: "*",
imageCount: 0,
providerModelKey: "",
billingMode: "fixed_request",
@@ -56,6 +58,7 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
size: selector.size || "*",
resolution: tier.resolution || "*",
videoSeconds: tier.videoSeconds || 0,
videoGenerateAudio: selector.videoGenerateAudio || "*",
imageCount: Number(selector.imageCount || 0),
providerModelKey: tier.providerModelKey || "",
billingMode: tier.billingMode,
@@ -91,6 +94,7 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
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;
@@ -106,3 +110,20 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0;
}
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {
const videoTokens = capability === "video" && tier.billingMode === "token";
return {
selector: skuSelectorFromForm(capability, tier),
resolution: priceTierResolutionFromForm(capability, tier),
videoSeconds: priceTierVideoSecondsFromForm(capability, tier),
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
billingMode: tier.billingMode,
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
};
}