From 2de068633c868b7155399f182ecf7547af2273cb Mon Sep 17 00:00:00 2001 From: qian Date: Mon, 14 Sep 2026 22:58:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(model):=E4=BF=AE=E5=A4=8Dminimax-h3?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=AE=A1=E8=B4=B9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logic/billing/scheme_minimax_material.go | 82 +++++++++--------- .../billing/scheme_minimax_material_test.go | 59 ++++++------- .../pricingSchemes/MinimaxMaterialEditor.vue | 86 ++++++++++++++----- 3 files changed, 132 insertions(+), 95 deletions(-) diff --git a/internal/logic/billing/scheme_minimax_material.go b/internal/logic/billing/scheme_minimax_material.go index dcbd3c5..0ce2d2a 100644 --- a/internal/logic/billing/scheme_minimax_material.go +++ b/internal/logic/billing/scheme_minimax_material.go @@ -18,7 +18,11 @@ import ( // 总费用 = 输出生成费 + 输入图片费 + 输入视频费(输入音频免费,不配置即不计费) // 输出生成费 = 输出秒数 × pricing JSONB per_second 矩阵[生成分辨率] // 输入图片费 = max(0, 图片张数 - free_count) × price_per_unit -// 输入视频费 = 输入视频秒数 × price_per_second(官方 usage 无输入视频分辨率,按秒单一单价) +// 输入视频费 = 输入视频秒数 × per_second 矩阵[生成分辨率] +// +// 输入视频不单独定价:官方规则为「输入视频按时长、以输出分辨率对应单价计费」 +// (与生成视频同价),单价复用输出生成矩阵按生成分辨率查取,scheme_config 无 +// input_video 节(存量配置残留该节在保存时被校验拒绝,提示移除)。 // // 预扣 vs 结算: // - 图片张数提交时可知(adaptor 上报 ratios 的 spec.input_image_count)→ 预扣即精确; @@ -28,11 +32,11 @@ import ( // 结算时 usage 某字段缺失(上游未返回)则回退 ratios 中提交时刻的对应 spec.* 事实值, // 官方完全不返回素材计量时整体回退预扣口径(多退少补兜底)。 // -// scheme_config(pricing JSONB 不透明容器,金额本位币): +// scheme_config(pricing JSONB 不透明容器,金额本位币;可不配置 = 图片免费, +// 输出与输入视频均按 per_second 矩阵计费): // // { -// "image": {"free_count": 5, "price_per_unit": 0.20}, -// "input_video": {"price_per_second": 0.50} +// "image": {"free_count": 5, "price_per_unit": 0.20} // } // SchemeMiniMaxMaterial MiniMax 素材计费方案名(模型 pricing JSONB scheme 键引用) @@ -47,10 +51,9 @@ const inputVideoMaxSeconds = 600.0 // 避免 URL 素材时长未知导致预扣少冻结。 const inputVideoEstimateSeconds = 15.0 -// minimaxMaterialConfig 方案私有配置 +// minimaxMaterialConfig 方案私有配置(输入视频按生成分辨率单价计费,无独立配置) type minimaxMaterialConfig struct { - Image *minimaxImagePrice `json:"image,omitempty"` - InputVideo *minimaxInputVideoPrice `json:"input_video,omitempty"` + Image *minimaxImagePrice `json:"image,omitempty"` } // minimaxImagePrice 输入图片单价:请求级免费额度 + 超出部分单价 @@ -59,12 +62,6 @@ type minimaxImagePrice struct { PricePerUnit float64 `json:"price_per_unit"` // 超出部分单价(本位币/张) } -// minimaxInputVideoPrice 输入视频按秒单一单价:官方 usage 只有 input_seconds -// (无输入视频分辨率信息),不做规格区分 -type minimaxInputVideoPrice struct { - PricePerSecond float64 `json:"price_per_second"` -} - // minimaxMaterialScheme MiniMax 素材计费方案(无状态单例,配置从 pricing.SchemeConfig 读取) type minimaxMaterialScheme struct{} @@ -87,16 +84,24 @@ func (minimaxMaterialScheme) parseConfig(pricing *PricingResult) *minimaxMateria } // ValidateSchemeConfig 校验 scheme_config(管理端保存 fail-fast): -// 单价非负、免费额度非负、输入视频矩阵至少一档正价且规格键非空、至少配置一个计费节。 +// 配置可空(图片免费、仅按秒矩阵计费输出与输入视频的合法形态);配置时校验图片 +// 单价与免费额度非负;input_video 节已废弃(输入视频改按生成分辨率单价计费), +// 残留即拒绝,提示管理员移除后重存,避免旧单价静默失效造成计费口径突变。 func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error { if len(cfg) == 0 || string(cfg) == "null" { - return gerror.New("素材计费方案必须配置 scheme_config(图片单价 / 输入视频单价至少一项)") + return nil + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(cfg, &raw); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if _, ok := raw["input_video"]; ok { + return gerror.New("输入视频不再单独定价(按生成分辨率单价计费),请移除 scheme_config 中的 input_video 配置") } var c minimaxMaterialConfig if err := json.Unmarshal(cfg, &c); err != nil { return gerror.Wrap(err, "scheme_config 解析失败") } - hasPricedSection := false if c.Image != nil { if c.Image.PricePerUnit < 0 { return gerror.New("输入图片单价不能为负") @@ -104,32 +109,18 @@ func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error { if c.Image.FreeCount < 0 { return gerror.New("输入图片免费张数不能为负") } - if c.Image.PricePerUnit > 0 { - hasPricedSection = true - } - } - if c.InputVideo != nil { - if c.InputVideo.PricePerSecond < 0 { - return gerror.New("输入视频每秒单价不能为负") - } - if c.InputVideo.PricePerSecond == 0 { - return gerror.New("输入视频计费必须配置正的每秒单价(音频免费无需配置,视频不免费请填正数)") - } - hasPricedSection = true - } - if !hasPricedSection { - return gerror.New("素材计费方案至少配置图片单价或输入视频单价之一") } return nil } -// EstimateTaskCost 预扣估算:输出组件 + 提交可知的素材组件(图片张数精确、输入视频按 0 估)。 +// EstimateTaskCost 预扣估算:按秒组件(输出生成 + 输入视频,共用生成分辨率单价)+ +// 提交可知的图片组件(张数精确)。 func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { cfg := s.parseConfig(pricing) spec, _ := ratioString(ratios, "spec.resolution") total := decimal.Zero - // 输出生成组件:per_second 矩阵(与通用按秒引擎共用查价与兜底链),矩阵未配置时为 0 + // 按秒组件(与通用按秒引擎共用查价与兜底链),矩阵未配置时输出与输入视频均不计费 if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { duration := defaultTaskDurationSeconds if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { @@ -138,15 +129,16 @@ func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios m if duration > maxTaskDurationSeconds { duration = maxTaskDurationSeconds } + // 输出生成:时长 × 单价 total = total.Add(NewFromFloat(price).Mul(NewFromFloat(duration))) + // 输入视频:估算时长(确知时长 > 含视频按生成长度上限 15s 估 > 0)× 同一单价, + // 结算以官方 usage 补差 + total = total.Add(inputVideoCost(price, estimateInputVideoSeconds(ratios))) } // 输入图片:请求级免费额度扣减 total = total.Add(s.imageCost(cfg, ratioFloatOr(ratios, "spec.input_image_count", 0))) - // 输入视频:时长 × 按秒单价(提交含输入视频按生成长度上限 15s 冻结,结算以官方 usage 补扣) - total = total.Add(s.inputVideoCost(cfg, estimateInputVideoSeconds(ratios))) - return total.Mul(NewFromFloat(pricing.TenantMultiplier)). Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) } @@ -162,8 +154,11 @@ func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map spec, _ := ratioString(ratios, "spec.resolution") total := decimal.Zero + // 按秒组件单价:生成分辨率矩阵查价(输出与输入视频同价),矩阵未配置时为 0 + price := LookupPerSecondPrice(pricing.PerSecondPrices, spec) + // 输出生成组件:优先官方 output_seconds,缺失回退请求时长信号(与预扣同源) - if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { + if price > 0 { outputSecs := usage.OutputSeconds if outputSecs <= 0 { outputSecs = ratioFloatOr(ratios, "spec.duration", defaultTaskDurationSeconds) @@ -186,7 +181,7 @@ func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map if inputSecs <= 0 { inputSecs = estimateInputVideoSeconds(ratios) } - total = total.Add(s.inputVideoCost(cfg, inputSecs)) + total = total.Add(inputVideoCost(price, inputSecs)) return total.Mul(NewFromFloat(pricing.TenantMultiplier)). Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) @@ -201,16 +196,17 @@ func (minimaxMaterialScheme) imageCost(cfg *minimaxMaterialConfig, count float64 return NewFromFloat(charged).Mul(NewFromFloat(cfg.Image.PricePerUnit)) } -// inputVideoCost 输入视频费用:时长(钳制上限)× 按秒单一单价 -// (官方 usage 无输入视频分辨率信息,不做规格区分) -func (minimaxMaterialScheme) inputVideoCost(cfg *minimaxMaterialConfig, seconds float64) decimal.Decimal { - if cfg == nil || cfg.InputVideo == nil || cfg.InputVideo.PricePerSecond <= 0 || seconds <= 0 { +// inputVideoCost 输入视频费用:时长(钳制上限)× 生成分辨率单价。 +// 官方规则:输入视频按时长、以输出分辨率对应单价计费(与生成视频同价), +// 不单独定价、不区分输入视频自身分辨率(官方 usage 无该信息)。 +func inputVideoCost(price, seconds float64) decimal.Decimal { + if price <= 0 || seconds <= 0 { return decimal.Zero } if seconds > inputVideoMaxSeconds { seconds = inputVideoMaxSeconds } - return NewFromFloat(seconds).Mul(NewFromFloat(cfg.InputVideo.PricePerSecond)) + return NewFromFloat(seconds).Mul(NewFromFloat(price)) } // estimateInputVideoSeconds 输入视频预扣估算时长(秒): diff --git a/internal/logic/billing/scheme_minimax_material_test.go b/internal/logic/billing/scheme_minimax_material_test.go index 4c4c927..12b683e 100644 --- a/internal/logic/billing/scheme_minimax_material_test.go +++ b/internal/logic/billing/scheme_minimax_material_test.go @@ -8,7 +8,7 @@ import ( ) // mmTestPricing 构造 MiniMax 素材方案测试定价: -// 输出矩阵 768P=0.35 / 2K=0.6;图片 5 张免费、超出 0.20/张;输入视频按秒 0.50。 +// 按秒矩阵 768P=0.35 / 2K=0.6(输出生成与输入视频共用);图片 5 张免费、超出 0.20/张。 // 乘数恒 1(租户/时段折扣单测用例另行覆盖)。 // billing_mode 用 special(方案模型的标准配对形态);分发只看 Scheme, // per_second+scheme 的存量组合由 scheme_test 覆盖。 @@ -20,13 +20,12 @@ func mmTestPricing(t *testing.T, tenantMul float64) *PricingResult { PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6}, Scheme: SchemeMiniMaxMaterial, SchemeConfig: json.RawMessage(`{ - "image": {"free_count": 5, "price_per_unit": 0.20}, - "input_video": {"price_per_second": 0.50} + "image": {"free_count": 5, "price_per_unit": 0.20} }`), } } -// TestMinimaxMaterial_Estimate 预扣口径:输出组件 + 提交可知的素材组件 +// TestMinimaxMaterial_Estimate 预扣口径:按秒组件(输出 + 输入视频同价)+ 提交可知的图片组件 // (图片精确、含输入视频按生成长度上限 15s 冻结)。 func TestMinimaxMaterial_Estimate(t *testing.T) { pricing := mmTestPricing(t, 1.0) @@ -51,20 +50,28 @@ func TestMinimaxMaterial_Estimate(t *testing.T) { "spec.duration": 6.0, "spec.resolution": "2K", "spec.input_image_count": 8.0, }, nil), 6*0.6+3*0.20, "no input video at estimate") - // 提交含输入视频(时长未知):按生成长度上限 15s 冻结 → 2.1 + 15×0.50 + // 提交含输入视频(时长未知):按生成长度上限 15s × 生成分辨率单价冻结 → 2.1 + 15×0.35 assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, - }, nil), 2.1+15*0.50, "input video frozen at generation cap") + }, nil), 2.1+15*0.35, "input video frozen at generation cap") // has_input_video=false 显式无视频:不含输入视频费 assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": false, }, nil), 2.1, "explicit no input video") - // adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限 + // adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限, + // 单价同生成分辨率(2K) assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "2K", "spec.has_input_video": true, "spec.input_video_seconds": 8.0, - }, nil), 6*0.6+8*0.50, "input video estimated via ratios") + }, nil), 6*0.6+8*0.6, "input video estimated via ratios") + + // 矩阵未配置(查价为 0):输出与输入视频均不计费,仅图片组件 + noMatrix := mmTestPricing(t, 1.0) + noMatrix.PerSecondPrices = nil + assertDecimal(t, estimateTaskCost(noMatrix, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true, + }, nil), 3*0.20, "empty matrix: no per-second components") } // TestMinimaxMaterial_Settle 结算口径:官方 usage 为最终计费依据。 @@ -72,17 +79,17 @@ func TestMinimaxMaterial_Settle(t *testing.T) { pricing := mmTestPricing(t, 1.0) scheme := minimaxMaterialScheme{} - // usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.50 = 6.7 + // usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.35(与输出同价) cost := scheme.SettleTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 8, InputImageCount: 8, OutputSeconds: 6}) - assertDecimal(t, cost, 6.7, "settle full usage 768P") + assertDecimal(t, cost, 6*0.35+3*0.20+8*0.35, "settle full usage 768P") - // 2K 档:输出 6×0.6 + 输入视频 10×0.50 = 8.6(输入视频按秒单一单价,与生成分辨率无关) + // 2K 档:输出与输入视频均按生成分辨率单价 → (6+10)×0.6 cost = scheme.SettleTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "2K", }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 10, OutputSeconds: 6}) - assertDecimal(t, cost, 8.6, "settle 2K with input video") + assertDecimal(t, cost, 16*0.6, "settle 2K with input video") // usage 字段部分缺失:图片回退 ratios 提交时事实值,输出回退 spec.duration; // 输入视频缺失且提交不含视频 → 0 @@ -91,11 +98,11 @@ func TestMinimaxMaterial_Settle(t *testing.T) { }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) assertDecimal(t, cost, 5*0.35+3*0.20, "settle partial usage falls back to ratios") - // 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限) + // 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限 × 生成分辨率单价) cost = scheme.SettleTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true, }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) - assertDecimal(t, cost, 5*0.35+3*0.20+15*0.50, "settle missing input seconds falls back to cap estimate") + assertDecimal(t, cost, 5*0.35+3*0.20+15*0.35, "settle missing input seconds falls back to cap estimate") // usage 为 nil:整体回退预扣口径(含输入视频时同样含 15s 冻结) cost = scheme.SettleTaskCost(pricing, map[string]any{ @@ -106,13 +113,13 @@ func TestMinimaxMaterial_Settle(t *testing.T) { cost = scheme.SettleTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, }, nil) - assertDecimal(t, cost, 6*0.35+15*0.50, "settle nil usage with input video falls back to cap estimate") + assertDecimal(t, cost, (6+15)*0.35, "settle nil usage with input video falls back to cap estimate") // 输入视频异常大值钳制到 600s(防 usage 异常刷扣) cost = scheme.SettleTaskCost(pricing, map[string]any{ "spec.resolution": "768P", }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 99999, OutputSeconds: 1}) - assertDecimal(t, cost, 1*0.35+600*0.50, "settle clamps input video seconds") + assertDecimal(t, cost, 1*0.35+600*0.35, "settle clamps input video seconds") } // TestMinimaxMaterial_Multipliers 租户折扣作用于全部组件(输出 + 素材)。 @@ -121,7 +128,7 @@ func TestMinimaxMaterial_Multipliers(t *testing.T) { cost := estimateTaskCost(pricing, map[string]any{ "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.input_video_seconds": 4.0, }, nil) - assertDecimal(t, cost, (6*0.35+3*0.20+4*0.50)*0.5, "tenant multiplier applies to all components") + assertDecimal(t, cost, (6*0.35+3*0.20+4*0.35)*0.5, "tenant multiplier applies to all components") } // TestMinimaxMaterial_ValidateSchemeConfig 方案私有配置校验。 @@ -132,10 +139,10 @@ func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) { if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":5,"price_per_unit":0.2}}`)); err != nil { t.Errorf("valid image config rejected: %v", err) } - // 空 / null / 全空 → 拒绝 + // 空 / null / 空对象 → 合法(图片免费,输出与输入视频均按 per_second 矩阵计费) for _, cfg := range []json.RawMessage{nil, json.RawMessage("null"), json.RawMessage(`{}`)} { - if err := scheme.ValidateSchemeConfig(cfg); err == nil { - t.Errorf("empty config should be rejected: %s", cfg) + if err := scheme.ValidateSchemeConfig(cfg); err != nil { + t.Errorf("empty config should be accepted: %s (%v)", cfg, err) } } // 负单价 / 负免费额度 @@ -145,15 +152,9 @@ func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) { if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":-1,"price_per_unit":0.2}}`)); err == nil { t.Error("negative free count should be rejected") } - // 输入视频按秒单价:零价(配置了节但不收费)与负价拒绝 - if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0}}`)); err == nil { - t.Error("zero input video price should be rejected") - } - if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":-0.5}}`)); err == nil { - t.Error("negative input video price should be rejected") - } - if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err != nil { - t.Errorf("valid input video price rejected: %v", err) + // input_video 节已废弃(改按生成分辨率单价计费):残留即拒绝,防旧配置静默失效 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err == nil { + t.Error("deprecated input_video section should be rejected") } } diff --git a/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue index 3af627f..15d2ac1 100644 --- a/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue +++ b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue @@ -6,9 +6,11 @@ import { currencySymbol } from '@/composables/useCurrency' // MiniMax 素材计费方案编辑器(scheme = custom:minimax-material,契约见 pricingSchemeEditors.ts): // 费用 = 输出生成(per_second 矩阵,存 pricing JSONB prices) +// + 输入视频(按输入时长 × 生成分辨率单价,复用输出生成矩阵,无需单独配置) // + 输入图片(免费额度 + 单价,存 scheme_config.image) -// + 输入视频(按秒单一单价,存 scheme_config.input_video.price_per_second) // 输入音频免费(无需配置)。时段定价 / 参数倍率不适用于素材计费,保存时恒清除。 +// 展示字段(折扣标签 / 价格调整说明 / 价格说明)与通用表单同源,随保存全量替换提交 +// (后端空串=清除,缺省不传同样清空,故必须显式回传)。 // 金额单位均为本位币(bil 层存储原值,不折算)。 const props = defineProps<{ @@ -26,8 +28,10 @@ const outputRows = reactive([]) // 输入图片素材 const imageFreeCount = ref(5) const imagePrice = ref(0) -// 输入视频按秒单一单价(官方 usage 无输入视频分辨率信息,不做规格区分) -const inputVideoPrice = ref(0) +// 展示字段(挂锚点行:对外营销文案 + 内部备注,与通用定价表单同源) +const discountLabel = ref('') +const priceChangeNote = ref('') +const priceNote = ref('') const outputSpecPresets = ['480P', '768P', '2K', '*'] @@ -78,21 +82,19 @@ onMounted(async () => { const cfg = data?.scheme_config || props.schemeConfig || {} imageFreeCount.value = cfg.image?.free_count ?? 5 imagePrice.value = Number(cfg.image?.price_per_unit) || 0 - inputVideoPrice.value = Number(cfg.input_video?.price_per_second) || 0 + // 展示字段(锚点行直存列,与通用表单同源) + discountLabel.value = data?.discount_label || '' + priceChangeNote.value = data?.price_change_note || '' + priceNote.value = data?.price_note || '' } catch { // error handled by interceptor;回显留空可手工填写 } }) async function save(): Promise { - // 输出生成矩阵:至少一档正价 + // 输出生成矩阵:至少一档正价(该矩阵同时用于输出生成与输入视频按秒计费) const outputMap = buildMatrix(outputRows, true) if (!outputMap) return false - // 输入视频按秒单价:可选配置(0 = 不对输入视频计费) - if (inputVideoPrice.value < 0) { - Message.warning('输入视频单价不能为负') - return false - } // 图片参数 if (imageFreeCount.value < 0) { Message.warning('输入图片免费张数不能为负') @@ -102,20 +104,13 @@ async function save(): Promise { Message.warning('输入图片单价不能为负') return false } - // 素材配置至少一项(与后端 scheme_config 校验一致) - if (imagePrice.value <= 0 && inputVideoPrice.value <= 0) { - Message.warning('至少配置输入图片单价或输入视频单价之一(输入音频免费无需配置)') - return false - } // 时段 / 参数倍率不适用于素材计费,恒传空数组清除(全量替换语义) + // scheme_config 仅含图片配置(0 = 图片免费;输入视频按输出生成矩阵计费,无需配置) const schemeConfig: any = {} if (imagePrice.value > 0) { schemeConfig.image = { free_count: imageFreeCount.value, price_per_unit: imagePrice.value } } - if (inputVideoPrice.value > 0) { - schemeConfig.input_video = { price_per_second: inputVideoPrice.value } - } await request.put(`/admin/models/${props.modelId}/pricing`, { items: [ @@ -137,6 +132,10 @@ async function save(): Promise { scheme_config: schemeConfig, time_segments: [], param_multipliers: [], + // 展示字段全量替换提交(后端空串=清除;缺省不传同样清空,必须显式回传) + price_note: priceNote.value.trim(), + discount_label: discountLabel.value.trim(), + price_change_note: priceChangeNote.value.trim(), }) return true } @@ -175,7 +174,7 @@ defineExpose({ save })

输入素材计费

- 音频免费;图片按张(含免费额度);输入视频按其时长 × 生成分辨率单价(结算以官方 usage 为准) + 音频免费;图片按张(含免费额度);输入视频自动按输出生成矩阵单价 × 输入时长计费(结算以官方 usage 为准,无需单独配置)
@@ -190,11 +189,32 @@ defineExpose({ save })
+
+
+ +
+
+

展示信息

+ 折扣标签与调整说明会展示给租户;价格说明仅管理后台可见 +
+
- 输入视频单价(按秒) - - - + 折扣标签(对外展示) + +
+
+ 价格调整说明(对外展示) + +
+
+ 价格说明(仅内部可见) +
@@ -271,6 +291,26 @@ defineExpose({ save }) gap: 8px; } +.display-config { + display: flex; + flex-direction: column; + gap: 8px; +} + +.field-input-flex { + flex: 1; + min-width: 0; +} + +/* 多行文本行:标签顶对齐,避免 textarea 垂直居中错位 */ +.field-item-top { + align-items: flex-start; +} + +.field-item-top .field-label { + margin-top: 4px; +} + .field-item { display: flex; align-items: center;