fix(model):修复minimax-h3模型计费问题

This commit is contained in:
qian committed 2026-09-14 22:58:47 +08:00
1 parent 417c97f94d
commit 2de068633c
3 files changed
+132 -95

No files matched your search

@@ -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 输入视频预扣估算时长(秒):
@@ -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")
}
}
@@ -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<PriceRow[]>([])
// 输入图片素材
const imageFreeCount = ref<number>(5)
const imagePrice = ref<number>(0)
// 输入视频按秒单一单价(官方 usage 无输入视频分辨率信息,不做规格区分)
const inputVideoPrice = ref<number>(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<boolean> {
// 输出生成矩阵:至少一档正价
// 输出生成矩阵:至少一档正价(该矩阵同时用于输出生成与输入视频按秒计费)
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<boolean> {
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<boolean> {
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 })
<div class="editor-section">
<div class="section-header">
<h3>输入素材计费</h3>
<span class="section-hint">音频免费;图片按张(含免费额度);输入视频按其时长 × 生成分辨率单价(结算以官方 usage 为准)</span>
<span class="section-hint">音频免费;图片按张(含免费额度);输入视频自动按输出生成矩阵单价 × 输入时长计费(结算以官方 usage 为准,无需单独配置)</span>
</div>
<div class="image-config">
<div class="field-item">
@@ -190,11 +189,32 @@ defineExpose({ save })
<template #suffix>{{ currencySymbol }} / 张</template>
</AInputNumber>
</div>
</div>
</div>
<div class="editor-section">
<div class="section-header">
<h3>展示信息</h3>
<span class="section-hint">折扣标签与调整说明会展示给租户;价格说明仅管理后台可见</span>
</div>
<div class="display-config">
<div class="field-item">
<span class="field-label">输入视频单价(按秒)</span>
<AInputNumber v-model="inputVideoPrice" :min="0" :step="0.01" size="small" class="field-input">
<template #suffix>{{ currencySymbol }} / 秒</template>
</AInputNumber>
<span class="field-label">折扣标签(对外展示)</span>
<AInput v-model="discountLabel" :maxlength="50" placeholder="如:7折起、限时5折" allow-clear size="small" class="field-input-flex" />
</div>
<div class="field-item">
<span class="field-label">价格调整说明(对外展示)</span>
<AInput v-model="priceChangeNote" :maxlength="200" placeholder="如:9月1日起价格下调 20%" allow-clear size="small" class="field-input-flex" />
</div>
<div class="field-item field-item-top">
<span class="field-label">价格说明(仅内部可见)</span>
<ATextarea
v-model="priceNote"
:max-length="500"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder="调价背景、渠道成本等内部备注,不对外展示"
class="field-input-flex"
/>
</div>
</div>
</div>
@@ -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;