feat(model):优化模型价格设置方式

This commit is contained in:
qian committed 2026-09-13 00:47:43 +08:00
1 parent e6293c4c7c
commit 8ec110a885
9 files changed
+2304 -984

No files matched your search

+10
View File
@@ -147,6 +147,11 @@ type PricingGetRes struct {
List []PricingItem `json:"list"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(pricing JSONB 顶层)"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(pricing JSONB 顶层,横切所有计费模式)"`
// 官方参考定价(official_pricing JSONB 还原,结构与计费定价完全一致;OfficialItems nil=未配置)。
// 非计费依据,管理端按官方价 × 折扣快速定价 / 计算现价几折以此为准
OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(tiered 展开为多行;nil=未配置)"`
OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价"`
OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则"`
// 以下为定价行展示字段
PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"`
@@ -160,6 +165,11 @@ type PricingSetReq struct {
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选,全量替换;空数组清除)"`
// 官方参考定价(可选,结构与计费定价完全一致,支持全部四种计费模式 + 时段/倍率):
// OfficialItems nil=本次不动库内官方定价;非 nil=全量替换(三个数组全空=清除)。金额本位币,非计费依据
OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(nil=不修改;非 nil=全量替换,与另两个官方数组全空=清除)"`
OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价(随 official_items 一并全量替换)"`
OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则(随 official_items 一并全量替换)"`
// 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"`
+216 -60
View File
@@ -463,6 +463,7 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
var row *struct {
BillingMode string `json:"billing_mode" orm:"billing_mode"`
Pricing string `json:"pricing" orm:"pricing"`
OfficialPricing string `json:"official_pricing" orm:"official_pricing"`
PriceNote *string `json:"price_note" orm:"price_note"`
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
@@ -475,68 +476,99 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
return nil, err
}
result := make([]v1.PricingItem, 0, 1)
var timeSegments []billing.TimeSegment
var paramRules []billing.ParamRule
var result []v1.PricingItem
var timeSegments []v1.TimeSegmentItem
var paramRules []v1.ParamMultiplierItem
var officialItems []v1.PricingItem
var officialTimeSegments []v1.TimeSegmentItem
var officialParamRules []v1.ParamMultiplierItem
var priceNote, discountLabel, priceChangeNote string
if row != nil {
mode := row.BillingMode
blob := billing.ParsePricingBlob(row.Pricing)
if blob != nil {
paramRules = blob.ParamMultipliers
}
switch {
case blob != nil && len(blob.Tiers) > 0:
// tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)
for _, tier := range blob.Tiers {
result = append(result, v1.PricingItem{
BillingMode: mode,
MinTokens: tier.MinTokens,
MaxTokens: tier.MaxTokens,
InputPrice: tier.InputPrice,
OutputPrice: tier.OutputPrice,
})
}
default:
item := v1.PricingItem{BillingMode: mode}
if blob != nil {
if blob.InputPrice != nil {
item.InputPrice = *blob.InputPrice
}
if blob.OutputPrice != nil {
item.OutputPrice = *blob.OutputPrice
}
if blob.CacheReadPrice != nil {
item.CacheReadPrice = *blob.CacheReadPrice
}
if blob.CacheCreationPrice != nil {
item.CacheCreationPrice = *blob.CacheCreationPrice
}
if blob.Price != nil {
p := *blob.Price
item.PerRequestPrice = &p
}
if len(blob.Prices) > 0 {
item.PerSecondPrices = blob.Prices
}
timeSegments = blob.TimeSegments
}
result = append(result, item)
// 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑)
result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, billing.ParsePricingBlob(row.Pricing))
// 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil
offMode, offBlob := officialBlobFromJSON(row.OfficialPricing)
if offBlob != nil {
officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob)
}
priceNote = derefStr(row.PriceNote)
discountLabel = derefStr(row.DiscountLabel)
priceChangeNote = derefStr(row.PriceChangeNote)
}
return &v1.PricingGetRes{
List: result,
TimeSegments: timeSegmentsToAPI(timeSegments),
ParamMultipliers: paramMultipliersToAPI(paramRules),
PriceNote: priceNote,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
List: result,
TimeSegments: timeSegments,
ParamMultipliers: paramRules,
OfficialItems: officialItems,
OfficialTimeSegments: officialTimeSegments,
OfficialParamMultipliers: officialParamRules,
PriceNote: priceNote,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
}, nil
}
// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。
// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时
// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。
func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) {
if blob == nil {
blob = &billing.PricingBlob{}
}
items := make([]v1.PricingItem, 0, 1)
switch {
case len(blob.Tiers) > 0:
// tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。
// 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容)
for i, tier := range blob.Tiers {
item := v1.PricingItem{
BillingMode: mode,
MinTokens: tier.MinTokens,
MaxTokens: tier.MaxTokens,
InputPrice: tier.InputPrice,
OutputPrice: tier.OutputPrice,
}
if tier.CacheReadPrice != nil {
item.CacheReadPrice = *tier.CacheReadPrice
} else if i == 0 && blob.CacheReadPrice != nil {
item.CacheReadPrice = *blob.CacheReadPrice
}
if tier.CacheCreationPrice != nil {
item.CacheCreationPrice = *tier.CacheCreationPrice
} else if i == 0 && blob.CacheCreationPrice != nil {
item.CacheCreationPrice = *blob.CacheCreationPrice
}
items = append(items, item)
}
default:
item := v1.PricingItem{BillingMode: mode}
if blob.InputPrice != nil {
item.InputPrice = *blob.InputPrice
}
if blob.OutputPrice != nil {
item.OutputPrice = *blob.OutputPrice
}
if blob.CacheReadPrice != nil {
item.CacheReadPrice = *blob.CacheReadPrice
}
if blob.CacheCreationPrice != nil {
item.CacheCreationPrice = *blob.CacheCreationPrice
}
if blob.Price != nil {
p := *blob.Price
item.PerRequestPrice = &p
}
if len(blob.Prices) > 0 {
item.PerSecondPrices = blob.Prices
}
items = append(items, item)
}
// 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原
return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers)
}
// derefStr 字符串指针安全解引用(NULL → 空串)
func derefStr(s *string) string {
if s == nil {
@@ -671,20 +703,125 @@ func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.Prici
return err
}
// 全量替换语义:删旧行插新行(保留展示字段列随 insert 重置为默认,随后由调用方回写)
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", modelDBID).Delete(); err != nil {
return err
}
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: modelDBID,
BillingMode: items[0].BillingMode,
Pricing: string(blobJSON),
}); err != nil {
// 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing,
// official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工
// 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。
// 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。
if _, err := dao.MdlPricing.Ctx(ctx).
Data(g.Map{
"model_id": modelDBID,
"billing_mode": items[0].BillingMode,
"pricing": string(blobJSON),
}).
OnConflict("model_id").
OnDuplicate(g.Map{
"billing_mode": gdb.Raw("excluded.billing_mode"),
"pricing": gdb.Raw("excluded.pricing"),
}).
Save(); err != nil {
return err
}
return nil
}
// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺
// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构,
// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。
type officialPricingBlob struct {
BillingMode string `json:"billing_mode"`
billing.PricingBlob
}
// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。
// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。
// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」
// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存),
// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。
func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) {
segs, err := timeSegmentsFromAPI(segments)
if err != nil {
return nil, err
}
paramRules, err := paramMultipliersFromAPI(rules)
if err != nil {
return nil, err
}
input := pricingItemsToInput(items)
var blob *billing.PricingBlob
if len(input) > 0 && input[0].BillingMode == "per_second" && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) {
blob = &billing.PricingBlob{}
} else {
blob, err = billing.BuildPricingBlob(input)
if err != nil {
return nil, err
}
}
blob.TimeSegments = segs
blob.ParamMultipliers = paramRules
empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil &&
blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil &&
len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0
if empty {
return nil, nil
}
return blob, nil
}
// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态)
func perSecondMatrixHasPositivePrice(prices map[string]float64) bool {
for _, p := range prices {
if p > 0 {
return true
}
}
return false
}
// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。
// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。
func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) {
if raw == "" || raw == "null" {
return "", nil
}
wrapped := officialPricingBlob{}
if err := json.Unmarshal([]byte(raw), &wrapped); err != nil {
return "", nil
}
mode := wrapped.BillingMode
if mode == "" {
mode = "token"
}
return mode, &wrapped.PricingBlob
}
// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换,
// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层,
// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦
// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。
func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error {
blob, err := buildOfficialBlob(items, segments, rules)
if err != nil {
return err
}
data := any(gdb.Raw("NULL"))
if blob != nil {
mode := "token"
if len(items) > 0 {
mode = items[0].BillingMode
}
raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob})
if mErr != nil {
return mErr
}
data = string(raw)
}
_, err = dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Data(g.Map{"official_pricing": data}).
Update()
return err
}
// writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键),
// 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。
// 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。
@@ -748,6 +885,16 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
return nil, err
}
// 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价)
if req.OfficialItems != nil {
if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil {
return nil, gerror.Wrap(err, "官方参考定价")
}
if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil {
return nil, gerror.Wrap(err, "官方参考定价")
}
}
// 先查模型编码,供事务提交后按模型清除所有租户的价格缓存
var model struct {
ModelId string `json:"model_id"`
@@ -765,6 +912,15 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
return err
}
// 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。
// 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据,
// 不触发价格缓存失效
if req.OfficialItems != nil {
if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil {
return gerror.Wrap(err, "官方参考定价")
}
}
// 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行)
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
return err
+15 -9
View File
@@ -702,10 +702,12 @@ func BuildPricingBlob(items []PricingItemInput) (*PricingBlob, error) {
}
for _, item := range items {
blob.Tiers = append(blob.Tiers, pricingTierRow{
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: item.InputPrice,
OutputPrice: item.OutputPrice,
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: item.InputPrice,
OutputPrice: item.OutputPrice,
CacheReadPrice: positivePtr(item.CacheReadPrice),
CacheCreationPrice: positivePtr(item.CacheCreationPrice),
})
}
return blob, nil
@@ -776,12 +778,16 @@ func tiersHavePrice(tiers []pricingTierRow) bool {
return false
}
// pricingTierRow 定价阶梯行(绝对价格)
// pricingTierRow 定价阶梯行(绝对价格)。
// 逐档缓存价为可选键(omitempty,旧数据缺键 unmarshal 为 nil,无需迁移):
// 编辑器逐档缓存价落库于此;计费引擎的缓存 token 计费仍取 blob 顶层锚点缓存价,不读档内字段
type pricingTierRow struct {
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price,omitempty"`
CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"`
}
// effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。
@@ -101,3 +101,50 @@ func TestBuildPricingBlob_ZeroPricesOmitted(t *testing.T) {
t.Fatalf("expected empty blob json, got %s", raw)
}
}
// TestBuildPricingBlob_TieredPerTierCachePrices 阶梯逐档缓存价:档内键随行落库,
// 顶层锚点缓存价照旧写入(计费引擎的缓存 token 计费读顶层,不读档内字段)。
// JSONB 往返后两处都完整保留;旧格式(档内无缓存键)unmarshal 为 nil 不报错。
func TestBuildPricingBlob_TieredPerTierCachePrices(t *testing.T) {
max := int64(100000)
blob, err := BuildPricingBlob([]PricingItemInput{
{BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5, CacheCreationPrice: 18.75},
{BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60, CacheReadPrice: 1.2, CacheCreationPrice: 15},
})
if err != nil {
t.Fatalf("build: %v", err)
}
// 顶层锚点缓存价保留(计费引擎读取口径)
if blob.CacheReadPrice == nil || *blob.CacheReadPrice != 1.5 || blob.CacheCreationPrice == nil || *blob.CacheCreationPrice != 18.75 {
t.Fatalf("anchor cache prices lost: %+v", blob)
}
// 逐档缓存价落行
if blob.Tiers[0].CacheReadPrice == nil || *blob.Tiers[0].CacheReadPrice != 1.5 {
t.Fatalf("tier0 cache_read_price lost: %+v", blob.Tiers[0])
}
if blob.Tiers[1].CacheReadPrice == nil || *blob.Tiers[1].CacheReadPrice != 1.2 {
t.Fatalf("tier1 cache_read_price lost: %+v", blob.Tiers[1])
}
// JSONB 往返(mdl_pricing.pricing 的真实存取路径)
raw, err := json.Marshal(blob)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed := ParsePricingBlob(string(raw))
if parsed == nil {
t.Fatal("parse failed")
}
if parsed.CacheReadPrice == nil || *parsed.CacheReadPrice != 1.5 {
t.Fatalf("roundtrip anchor cache lost: %+v", parsed)
}
if parsed.Tiers[1].CacheCreationPrice == nil || *parsed.Tiers[1].CacheCreationPrice != 15 {
t.Fatalf("roundtrip tier1 cache_creation lost: %+v", parsed.Tiers[1])
}
// 旧格式(档内无缓存键、顶层有锚点):unmarshal 不报错,档内为 nil
legacy := ParsePricingBlob(`{"tiers":[{"min_tokens":0,"input_price":15,"output_price":75}],"cache_read_price":1.5}`)
if legacy == nil || legacy.Tiers[0].CacheReadPrice != nil || legacy.CacheReadPrice == nil || *legacy.CacheReadPrice != 1.5 {
t.Fatalf("legacy blob parse broken: %+v", legacy)
}
}
+11 -4
View File
@@ -87,8 +87,12 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Dec
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
case pricing.BillingMode == "per_request":
// 按次计费:直接用单价
costD = NewFromFloat(pricing.PerRequestPrice)
// 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的
// 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价,
// 预扣漏乘租户/时段折扣会让折扣租户按原价多扣
costD = NewFromFloat(pricing.PerRequestPrice).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
case pricing.OutputPrice > 0 && hasDurationSignal(ratios):
duration := 5.0 // 默认 5 秒
if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 {
@@ -112,11 +116,14 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Dec
Mul(NewFromFloat(pricing.OutputPrice)).
Mul(NewFromFloat(pricing.TenantMultiplier))
default:
// 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣,
// 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数,
// 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣,
// 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补
// (见 sync_image_worker.settleSyncImageSuccess)。
if pricing.PerRequestPrice > 0 {
costD = NewFromFloat(pricing.PerRequestPrice)
costD = NewFromFloat(pricing.PerRequestPrice).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
} else {
costD = NewFromFloat(imagePlaceholderPreDeduct)
}
@@ -168,6 +168,38 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) {
assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost")
}
// TestEstimateTaskCost_PerRequestTenantTimeDiscount 按次任务预扣即终价(结算无 token 重算
// 信号),租户乘数与时段乘数必须在预扣阶段就生效,与 chat 路径 computeCost 的按次口径一致。
func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) {
pricing := &PricingResult{
BillingMode: "per_request",
PerRequestPrice: 0.10,
TenantMultiplier: 0.5,
TimeMultiplier: 0.8,
}
// 0.10 × 0.5 × 0.8 = 0.04
assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request tenant × time discount")
// token 模式但仅配按次价的图片任务(default 分支回退)同样生效
p2 := &PricingResult{
BillingMode: "token",
PerRequestPrice: 0.10,
OutputPrice: 30.0,
TenantMultiplier: 0.5,
TimeMultiplier: 0.8,
}
assertDecimal(t, estimateTaskCost(p2, nil), 0.04, "image per-request fallback discount")
// 时段乘数零值(旧缓存条目缺字段)兜底 1.0,不得把预扣清零
p3 := &PricingResult{
BillingMode: "per_request",
PerRequestPrice: 0.10,
TenantMultiplier: 0.5,
TimeMultiplier: 0,
}
assertDecimal(t, estimateTaskCost(p3, nil), 0.05, "zero time multiplier fallback")
}
// TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution)
// 才走 10000×duration×resolution 的 token 估算:默认 5s×720p(2.25)×$30/1M = $3.375。
func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,563 @@
<script lang="ts">
// 行数据结构导出(<script setup> 不支持 export,类型声明放前置块)
export interface ParamConditionRow {
path: string
match: string // has_value | equals | gt | gte | lt | lte | between
value: string // 输入态字符串;between 逗号分隔;提交时按匹配方式转换
}
export interface ParamRuleRow {
conditions: ParamConditionRow[]
multiplier: number
note: string
}
// 无需比对值的匹配方式(has_value 只判字段存在)
function matchNeedsValue(match: string): boolean {
return match !== 'has_value'
}
// 条件输入态 → 提交值(数字类强转、between 转双元素数组、equals 保原样)
function conditionValuePayloadFrom(cond: ParamConditionRow): any {
const raw = cond.value.trim()
if (cond.match === 'between') {
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
return parts.length === 2 ? parts.map(Number) : null
}
if (['gt', 'gte', 'lt', 'lte'].includes(cond.match)) {
const n = Number(raw)
return raw !== '' && !Number.isNaN(n) ? n : null
}
return raw // equals:字符串原样(后端数字强转比较)
}
// API 规则列表 → 行编辑态。父级在编辑器组件未挂载时(如二层弹窗未打开/折叠区未展开)
// 也用本函数直接填充行数组,与组件内 loadParamMultipliers 共用同一换算
export function paramRuleRowsFromAPI(list: any[] | null | undefined): ParamRuleRow[] {
const rows: ParamRuleRow[] = []
if (!Array.isArray(list)) return rows
for (const rule of list) {
const row: ParamRuleRow = {
conditions: (rule.conditions || []).map((c: any) => ({
path: c.path || '',
match: c.match || 'has_value',
value: Array.isArray(c.value) ? c.value.join(',') : c.value != null ? String(c.value) : '',
})),
multiplier: Number(rule.multiplier) || 1,
note: rule.note || '',
}
if (row.conditions.length > 0) rows.push(row)
}
return rows
}
// 行编辑态 → 提交载荷(含校验)。error 非空=校验失败文案(提示方式由调用方决定)
export function paramRulePayloadFrom(rows: ParamRuleRow[]): { payload?: any[]; error?: string } {
const payload: any[] = []
for (const [i, rule] of rows.entries()) {
if (rule.multiplier <= 0 || rule.multiplier > 100) {
return { error: `参数倍率规则 ${i + 1} 倍率需在 (0, 100] 区间` }
}
if (rule.conditions.length === 0) {
return { error: `参数倍率规则 ${i + 1} 至少需要一个条件` }
}
const conditions: any[] = []
for (const [j, cond] of rule.conditions.entries()) {
const path = cond.path.trim()
if (!path) {
return { error: `参数倍率规则 ${i + 1} 条件 ${j + 1} 路径为空` }
}
const value = conditionValuePayloadFrom(cond)
if (matchNeedsValue(cond.match) && (value === null || value === '')) {
return { error: `参数倍率规则 ${i + 1} 条件 ${j + 1} 需要填写比对值` }
}
conditions.push(matchNeedsValue(cond.match) ? { path, match: cond.match, value } : { path, match: cond.match })
}
// 命中说明:空 = 默认「规则N」(随序号,删除上方规则后自动顺延)
payload.push({ conditions, multiplier: rule.multiplier, note: rule.note.trim() || `规则 ${i + 1}` })
}
return { payload }
}
</script>
<script setup lang="ts">
// 参数倍率编辑器(主弹窗与官方定价弹窗共用):
// 按归一化任务体参数匹配,命中规则连乘;规则数组由父级持有(reactive),
// 编辑/校验/载荷/试匹配逻辑内聚在本组件。
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDelete } from '@arco-design/web-vue/es/icon'
const props = withDefaults(
defineProps<{
rules: ParamRuleRow[]
title?: string
hint?: string
}>(),
{
title: '参数倍率',
hint: '按归一化任务体参数匹配(如含参考图 / 时长档位 / 视频输入),命中规则连乘;所有计费模式共享',
},
)
const paramMatchOptions = [
{ label: '有值', value: 'has_value' },
{ label: '等于', value: 'equals' },
{ label: '大于', value: 'gt' },
{ label: '大于等于', value: 'gte' },
{ label: '小于', value: 'lt' },
{ label: '小于等于', value: 'lte' },
{ label: '区间', value: 'between' },
]
// 常用路径预设(归一化任务体,规则跟语义走不跟入口协议走)
const paramPathPresets = [
'metadata.image',
'metadata.duration',
'metadata.resolution',
'metadata.aspect_ratio',
'seconds',
'metadata.content[*].video_url',
'metadata.content[*].role',
]
function addParamRule() {
props.rules.push({ conditions: [{ path: '', match: 'has_value', value: '' }], multiplier: 1, note: '' })
}
function removeParamRule(index: number) {
props.rules.splice(index, 1)
}
function addParamCondition(ruleIndex: number) {
props.rules[ruleIndex].conditions.push({ path: '', match: 'has_value', value: '' })
}
function removeParamCondition(ruleIndex: number, condIndex: number) {
props.rules[ruleIndex].conditions.splice(condIndex, 1)
}
// 模板绑定:模板只认 setup 绑定,前置块的 matchNeedsValue 经此别名暴露
const conditionNeedsValue = matchNeedsValue
// 行编辑态 → 提交载荷;校验失败 Message 提示并返回 null(委托前置块纯函数)
function buildParamMultipliersPayload(): any[] | null {
const result = paramRulePayloadFrom(props.rules)
if (result.error) {
Message.warning(result.error)
return null
}
return result.payload ?? []
}
// 回显:后端规则 → 行编辑态(委托前置块纯函数,与父级直接填充行数组共用换算)
function loadParamMultipliers(list: any[] | null | undefined) {
const rows = paramRuleRowsFromAPI(list)
props.rules.length = 0
props.rules.push(...rows)
}
function resetParamRules() {
props.rules.length = 0
tryMatchInput.value = ''
}
// ── 试匹配预览(前端复刻后端求值语义:路径提取 + [*] 扇出 + 数字强转 + 隐式与 + 连乘) ──
const tryMatchInput = ref('')
function lookupPath(root: any, path: string): { values: any[]; found: boolean } {
const segments = path.split('.').filter(Boolean)
let current = [root]
for (const seg of segments) {
const wildcard = seg.endsWith('[*]')
const key = wildcard ? seg.slice(0, -3) : seg
const next: any[] = []
for (const cur of current) {
if (cur == null || typeof cur !== 'object' || Array.isArray(cur)) continue
const v = (cur as Record<string, any>)[key]
if (v === undefined) continue
if (wildcard) {
if (Array.isArray(v)) next.push(...v)
} else {
next.push(v)
}
}
if (next.length === 0) return { values: [], found: false }
current = next
}
return { values: current, found: true }
}
function toNum(v: any): number | null {
if (typeof v === 'number') return v
if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v)
return null
}
function conditionHit(cond: { path: string; match: string; value?: any }, root: any): boolean {
const { values, found } = lookupPath(root, cond.path)
if (!found) return false
if (cond.match === 'has_value') return values.some((v) => v !== null && v !== undefined)
const target = Array.isArray(cond.value) ? cond.value.map(Number) : cond.value
if (cond.match === 'equals') {
return values.some((v) => {
const vn = toNum(v)
const tn = toNum(target)
if (vn != null && tn != null) return vn === tn
return v === target
})
}
if (cond.match === 'between') {
if (!Array.isArray(target) || target.length !== 2) return false
return values.some((v) => {
const n = toNum(v)
return n != null && n >= target[0] && n <= target[1]
})
}
const tn = toNum(target)
if (tn == null) return false
return values.some((v) => {
const n = toNum(v)
if (n == null) return false
if (cond.match === 'gt') return n > tn
if (cond.match === 'gte') return n >= tn
if (cond.match === 'lt') return n < tn
return n <= tn // lte
})
}
// 试匹配结果:命中规则列表 + 总倍率
function tryMatchResult(): { hits: Array<{ label: string; multiplier: number }>; total: number } | null {
const raw = tryMatchInput.value.trim()
if (!raw) return null
let root: any
try {
root = JSON.parse(raw)
} catch {
return null
}
const hits: Array<{ label: string; multiplier: number }> = []
let total = 1
for (const rule of props.rules) {
const allHit = rule.conditions.every((c) =>
conditionHit({ path: c.path, match: c.match, value: conditionValuePayloadFrom(c) }, root),
)
if (allHit) {
total *= rule.multiplier || 1
hits.push({ label: rule.note || rule.conditions.map((c) => c.path).join(' & '), multiplier: rule.multiplier })
}
}
return { hits, total }
}
defineExpose({ loadParamMultipliers, resetParamRules, buildParamMultipliersPayload })
</script>
<template>
<div class="editor-section">
<div class="editor-section-header">
<h3>{{ title }}</h3>
<span class="section-hint">{{ hint }}</span>
</div>
<div v-for="(rule, ruleIndex) in rules" :key="ruleIndex" class="tier-card">
<!-- 头行:命中说明(默认占位"规则N")跨前两列 · 命中倍率对齐「值」列 · 删除列 -->
<div class="param-rule-head">
<div class="param-field param-field--note">
<span class="field-label field-label--required">说明</span>
<AInput
v-model="rule.note"
:placeholder="`规则 ${ruleIndex + 1}`"
:max-length="64"
allow-clear
class="param-note-input"
/>
</div>
<div class="param-field param-field--mult">
<span class="field-label field-label--required">倍率</span>
<AInputNumber
v-model="rule.multiplier"
:min="0.0001"
:max="100"
:precision="4"
title="命中倍率"
class="param-multiplier-input"
>
<template #suffix>×</template>
</AInputNumber>
</div>
<!-- 删除整条规则:垃圾桶图标,与下方条件行的「−」同宽同列 -->
<AButton
class="param-head-remove"
size="mini"
status="danger"
title="删除该规则"
@click="removeParamRule(ruleIndex)"
>
<IconDelete />
</AButton>
</div>
<!-- 条件行:路径 / 匹配 / 值 / 删除 四列固定网格;「值」列恒占位(无需比对值时留空),
各条件行纵向对齐,不随匹配方式增减列而跳动 -->
<div
v-for="(cond, condIndex) in rule.conditions"
:key="condIndex"
class="param-condition-grid"
>
<div class="param-field param-field--path">
<span class="field-label field-label--required">路径</span>
<ASelect
v-model="cond.path"
:options="paramPathPresets.map((p) => ({ label: p, value: p }))"
allow-create
allow-search
placeholder="如 metadata.image"
class="param-path-select"
/>
</div>
<div class="param-field param-field--match">
<span class="field-label field-label--required">匹配</span>
<ASelect v-model="cond.match" :options="paramMatchOptions" class="param-match-select" />
</div>
<div
class="param-field param-field--value"
:class="{ 'is-empty': !conditionNeedsValue(cond.match) }"
>
<span class="field-label field-label--required">值</span>
<AInput
v-model="cond.value"
:placeholder="cond.match === 'between' ? 'min,max(如 8,12)' : '比对值'"
class="param-value-input"
/>
</div>
<!-- 行尾「−」= 移除该条件(仅剩一条时禁用);不用 ✕ 避免被当成关闭弹窗 -->
<AButton
class="param-cond-remove"
size="mini"
status="danger"
title="移除该条件"
:disabled="rule.conditions.length <= 1"
@click="removeParamCondition(ruleIndex, condIndex)"
>−</AButton>
</div>
<AButton size="mini" type="text" @click="addParamCondition(ruleIndex)">+ 添加条件(全部满足才命中)</AButton>
</div>
<AButton type="dashed" long class="mt-3" @click="addParamRule">+ 添加规则</AButton>
<!-- 试匹配:粘贴任务体 JSON 即时验证规则命中 -->
<div v-if="rules.length" class="param-try-match">
<div class="combo-preview-title">试匹配(粘贴归一化任务体 JSON 验证命中)</div>
<ATextarea
v-model="tryMatchInput"
:auto-size="{ minRows: 3, maxRows: 8 }"
placeholder='如 {"model":"...","prompt":"...","seconds":"8","metadata":{"image":"https://...","duration":8}}'
/>
<template v-if="tryMatchResult()">
<div class="combo-row">
<span class="combo-name">命中规则</span>
<span class="combo-value">
<template v-if="tryMatchResult()!.hits.length">
<ATag v-for="(h, i) in tryMatchResult()!.hits" :key="i" size="small" color="arcoblue">
{{ h.label }} ×{{ h.multiplier }}
</ATag>
</template>
<template v-else>无(按原价)</template>
</span>
</div>
<div class="combo-row">
<span class="combo-name">总倍率</span>
<span class="combo-value">×{{ tryMatchResult()!.total }}</span>
</div>
</template>
<div v-else-if="tryMatchInput.trim()" class="combo-preview-hint">JSON 解析失败或为空</div>
</div>
</div>
</template>
<style scoped>
/* 与主弹窗一致的 section / 卡片基础样式(scoped 隔离,组件内自持) */
.editor-section {
margin-bottom: 24px;
}
.editor-section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.editor-section-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--ta-text-primary);
margin: 0;
}
.section-hint {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.tier-card {
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 8px;
margin-bottom: 8px;
}
/* 行内字段标签:灰色小字前置,紧凑布局下的字段语义说明 */
.field-label {
flex: none;
font-size: 12px;
color: var(--ta-text-tertiary);
white-space: nowrap;
}
/* 必填标记:星号决定位在标签左侧(不占文本宽度),标签整体多留 8px,
各行控件左边缘仍对齐;星号色对齐 Arco 表单必填符号 */
.field-label--required {
position: relative;
padding-left: 8px;
}
.field-label--required::before {
content: '*';
position: absolute;
left: 0;
color: rgb(var(--danger-6));
}
/* 参数倍率:规则头行与条件行共用同一套列轨道(路径 · 匹配 · 值 · 删除)。
头行「说明」占「路径」列、「倍率」落在「值」列,中间隔开「匹配」列,
两个输入框既与下方条件行纵向对齐,又不会挤在一起 */
.param-rule-head,
.param-condition-grid {
/* 匹配列 152px = 32px 必填标签 + 8px 间隙 + 112px 下拉框:
下拉框内部固定吃掉 50px(边框 2 + 左右内边距 24 + 箭头区 24),剩 62px 文本区,
刚好容纳最长的「大于等于」四字(14px 字号 × 4 = 56px)不被省略号截断 */
--param-cols: minmax(0, 1.2fr) 152px minmax(0, 1fr) auto;
display: grid;
grid-template-columns: var(--param-cols);
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.param-rule-head {
grid-template-areas: 'note . mult remove';
}
.param-condition-grid {
grid-template-areas: 'path match value remove';
}
/* 行内字段:标签 + 控件(标签定宽,各行控件左边缘对齐) */
.param-field {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.param-field .field-label {
min-width: 32px; /* 8px 必填星号 + 24px 两字标签,保证控件左边缘对齐 */
}
.param-field--note {
grid-area: note;
}
.param-field--mult {
grid-area: mult;
}
.param-field--path {
grid-area: path;
}
.param-field--match {
grid-area: match;
}
.param-field--value {
grid-area: value;
}
/* 控件撑满所属列 */
.param-field .param-note-input,
.param-field .param-multiplier-input,
.param-field .param-path-select,
.param-field .param-match-select,
.param-field .param-value-input {
flex: 1;
min-width: 0;
}
/* 匹配方式无需比对值(如「有值」):保留列宽占位,仅隐藏内容 */
.param-field--value.is-empty {
visibility: hidden;
}
.param-head-remove,
.param-cond-remove {
grid-area: remove;
width: 32px;
padding: 0;
}
/* 窄屏:头行说明独占首行、倍率下移;条件行路径独占首行,匹配 / 值下移,避免列被压扁 */
@media (max-width: 640px) {
.param-rule-head,
.param-condition-grid {
--param-cols: 152px minmax(0, 1fr) auto;
row-gap: 6px;
}
.param-rule-head {
grid-template-areas:
'note note remove'
'mult mult mult';
}
.param-condition-grid {
grid-template-areas:
'path path remove'
'match value value';
}
}
.param-try-match {
margin-top: 12px;
padding: 10px 14px;
background: var(--color-fill-1);
border: 1px solid var(--ta-border-light);
border-radius: 8px;
}
.combo-preview-title {
font-size: 12px;
font-weight: 600;
color: var(--ta-text-secondary);
margin-bottom: 6px;
}
.combo-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 3px 0;
font-size: 12px;
}
.combo-value {
color: var(--ta-text-primary);
font-family: monospace;
text-align: right;
}
.combo-preview-hint {
font-size: 12px;
color: var(--ta-text-tertiary);
}
</style>
@@ -0,0 +1,412 @@
<script lang="ts">
// 行数据结构导出(<script setup> 不支持 export,类型声明放前置块)
export interface TimeSegmentRow {
name: string
dayPreset: string // every / weekday / weekend / custom
days: number[] // 仅 custom 使用,1=周一..7=周日
start_time: string // HH:mm,与 end_time 均空=全天
end_time: string
validRange: string[] | undefined // 有效期 [from, to],可选(促销/定时调价)
percent: number // 乘数百分比,50 = 半价(提交时换算为 multiplier)
}
// 组合预览基准价(父级按当前计费模式计算;不传则预览只显示倍率百分比)
export interface SegmentPreviewBase {
kind: 'token' | 'tiered' | 'per_request' | 'per_second'
input?: number
output?: number
perRequest?: number
perSecond?: number
}
// 预设 → days 数组(提交时换算;every=空数组表示每天)
const presetToDays: Record<string, number[]> = {
every: [],
weekday: [1, 2, 3, 4, 5],
weekend: [6, 7],
}
// days 数组 → 预设(回显时换算)
function daysToPreset(days: number[] | null | undefined): string {
if (!days || days.length === 0) return 'every'
const sorted = [...days].sort((a, b) => a - b)
if (sorted.join(',') === '1,2,3,4,5') return 'weekday'
if (sorted.join(',') === '6,7') return 'weekend'
return 'custom'
}
// API 时段列表 → 行编辑态。父级在编辑器组件未挂载时(如二层弹窗未打开/折叠区未展开)
// 也用本函数直接填充行数组,与组件内 loadTimeSegments 共用同一换算
export function timeSegmentRowsFromAPI(list: any[] | null | undefined): { rows: TimeSegmentRow[]; enabled: boolean } {
const rows: TimeSegmentRow[] = []
if (Array.isArray(list)) {
for (const seg of list) {
rows.push({
name: seg.name || '',
dayPreset: daysToPreset(seg.days),
days: Array.isArray(seg.days) ? [...seg.days] : [],
start_time: seg.start_time || '',
end_time: seg.end_time || '',
validRange: seg.valid_from || seg.valid_to ? [seg.valid_from || '', seg.valid_to || ''] : undefined,
percent: Math.round((Number(seg.multiplier) || 1) * 10000) / 100,
})
}
}
return { rows, enabled: rows.length > 0 }
}
// 行编辑态 → 提交载荷(percent → multiplier、预设 → days,含前置校验)。
// error 非空=校验失败文案(提示方式由调用方决定,组件内走 Message.warning)
export function timeSegmentPayloadFrom(rows: TimeSegmentRow[], enabled: boolean): { payload?: any[]; error?: string } {
if (!enabled) return { payload: [] }
if (rows.length === 0) {
return { error: '已开启时段定价,请至少添加一条时段' }
}
for (const [i, seg] of rows.entries()) {
if (!seg.name.trim()) {
return { error: `第 ${i + 1} 条时段名称不能为空` }
}
if (!seg.percent || seg.percent <= 0) {
return { error: `时段「${seg.name}」乘数必须大于 0` }
}
if ((seg.start_time ? 1 : 0) !== (seg.end_time ? 1 : 0)) {
return { error: `时段「${seg.name}」开始与结束时间需同时填写或同时留空` }
}
if (seg.dayPreset === 'custom' && seg.days.length === 0) {
return { error: `时段「${seg.name}」自定义适用日至少选择一天` }
}
}
return {
payload: rows.map((seg) => ({
name: seg.name.trim(),
days: seg.dayPreset === 'custom' ? [...seg.days].sort((a, b) => a - b) : presetToDays[seg.dayPreset] || [],
start_time: seg.start_time || '',
end_time: seg.end_time || '',
valid_from: seg.validRange?.[0] || '',
valid_to: seg.validRange?.[1] || '',
multiplier: (seg.percent || 0) / 100,
})),
}
}
</script>
<script setup lang="ts">
// 时段定价编辑器(主弹窗与官方定价弹窗共用):
// 按命名时段对基准价等比缩放,时段从上到下先命中先生效。
// 数据数组由父级持有(reactive),编辑/校验/载荷逻辑内聚在本组件。
import { computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { formatBilling } from '@/composables/useCurrency'
const props = withDefaults(
defineProps<{
segments: TimeSegmentRow[]
previewBase?: SegmentPreviewBase | null
title?: string
hint?: string
}>(),
{
previewBase: null,
title: '时段定价',
hint: '全部价格(含各档位/缓存/按次价)按乘数等比缩放:100% = 默认价,未命中时段按默认价',
},
)
// 开关:父级可 v-model:enabled;关闭时段不参与提交
const enabled = defineModel<boolean>('enabled', { default: false })
const dayPresetOptions = [
{ label: '每天', value: 'every' },
{ label: '工作日', value: 'weekday' },
{ label: '周末', value: 'weekend' },
{ label: '自定义', value: 'custom' },
]
const weekdayOptions = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 },
]
// 预设/days 换算在前置块(timeSegmentRowsFromAPI / timeSegmentPayloadFrom 共用)
function createEmptySegment(): TimeSegmentRow {
return { name: '', dayPreset: 'every', days: [], start_time: '', end_time: '', validRange: undefined, percent: 100 }
}
function addTimeSegment() {
props.segments.push(createEmptySegment())
if (!enabled.value) enabled.value = true
}
function removeTimeSegment(index: number) {
props.segments.splice(index, 1)
}
// 上移/下移:时段从上到下先命中先生效,促销时段应排在常驻时段之前
function moveTimeSegment(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= props.segments.length) return
const [seg] = props.segments.splice(index, 1)
props.segments.splice(target, 0, seg)
}
function segmentDesc(seg: TimeSegmentRow): string {
const daysText =
seg.dayPreset === 'custom'
? seg.days.length
? [...seg.days]
.sort((a, b) => a - b)
.map((d) => '周' + '一二三四五六日'[d - 1])
.join('、')
: '每天'
: dayPresetOptions.find((o) => o.value === seg.dayPreset)?.label || '每天'
const timeText = seg.start_time && seg.end_time ? `${seg.start_time}~${seg.end_time}` : '全天'
let rangeText = ''
if (seg.validRange?.[0]) {
rangeText = seg.validRange[1] ? `,${seg.validRange[0]}~${seg.validRange[1]}` : `,${seg.validRange[0]} 起`
}
return `${daysText} ${timeText}${rangeText}`
}
function segmentPreview(seg: TimeSegmentRow): string {
const m = (seg.percent || 0) / 100
const base = props.previewBase
if (!base) return `×${m}`
if (base.kind === 'per_second') {
return `${formatBilling((base.perSecond || 0) * m, 4)} /秒`
}
if (base.kind === 'per_request') {
return `${formatBilling((base.perRequest || 0) * m, 4)} /次`
}
const prefix = base.kind === 'tiered' ? '首档 ' : ''
return `${prefix}输入 ${formatBilling((base.input || 0) * m, 4)} · 输出 ${formatBilling((base.output || 0) * m, 4)}`
}
function defaultPreviewText(): string {
const base = props.previewBase
if (!base) return '—'
if (base.kind === 'per_second') {
return `${formatBilling(base.perSecond || 0, 4)} /秒`
}
if (base.kind === 'per_request') {
return `${formatBilling(base.perRequest || 0, 4)} /次`
}
const prefix = base.kind === 'tiered' ? '首档 ' : ''
return `${prefix}输入 ${formatBilling(base.input || 0, 4)} · 输出 ${formatBilling(base.output || 0, 4)}`
}
const hasSegments = computed(() => props.segments.length > 0)
// 回显:multiplier → percent,days → 预设(委托前置块纯函数,与父级直接填充行数组共用换算)
function loadTimeSegments(list: any[] | null | undefined) {
const conv = timeSegmentRowsFromAPI(list)
props.segments.length = 0
props.segments.push(...conv.rows)
enabled.value = conv.enabled
}
function resetTimeSegments() {
enabled.value = false
props.segments.length = 0
}
// 提交载荷:percent → multiplier,预设 → days(含前置校验,校验失败 Message 提示并返回 null)
function buildTimeSegmentsPayload(): any[] | null {
const result = timeSegmentPayloadFrom(props.segments, enabled.value)
if (result.error) {
Message.warning(result.error)
return null
}
return result.payload ?? []
}
defineExpose({ loadTimeSegments, resetTimeSegments, buildTimeSegmentsPayload })
</script>
<template>
<div class="editor-section">
<div class="editor-section-header">
<h3>{{ title }}</h3>
<ASwitch v-model="enabled" size="small" />
<span class="section-hint">{{ hint }}</span>
</div>
<template v-if="enabled">
<div v-for="(seg, index) in segments" :key="index" class="tier-card">
<div class="tier-header">
<div class="segment-tools">
<AButton size="mini" :disabled="index === 0" @click="moveTimeSegment(index, -1)">↑</AButton>
<AButton size="mini" :disabled="index === segments.length - 1" @click="moveTimeSegment(index, 1)">↓</AButton>
<span class="tier-label">第 {{ index + 1 }} 条</span>
</div>
<AButton size="mini" status="danger" @click="removeTimeSegment(index)">删除</AButton>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="时段名称">
<AInput v-model="seg.name" placeholder="如:闲时 / 工作忙时" :maxlength="32" allow-clear />
</AFormItem>
<AFormItem label="价格乘数">
<AInputNumber v-model="seg.percent" :min="1" :max="1000" :step="5" :precision="0" class="w-full">
<template #suffix>%</template>
</AInputNumber>
</AFormItem>
<AFormItem label="每日时段(留空 = 全天)" class="md:col-span-2">
<div class="time-range">
<ATimePicker v-model="seg.start_time" format="HH:mm" allow-clear placeholder="开始" class="w-full" />
<span class="time-sep">~</span>
<ATimePicker v-model="seg.end_time" format="HH:mm" allow-clear placeholder="结束" class="w-full" />
</div>
</AFormItem>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 mt-1">
<AFormItem label="适用日">
<ARadioGroup v-model="seg.dayPreset" type="button" size="small">
<ARadio v-for="opt in dayPresetOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem label="有效期(促销/定时调价,可选)">
<ARangePicker v-model="seg.validRange" class="w-full" allow-clear />
</AFormItem>
<AFormItem v-if="seg.dayPreset === 'custom'" label="自定义星期" class="custom-days-item md:col-span-2">
<div class="custom-days">
<ACheckboxGroup v-model="seg.days" :options="weekdayOptions" />
</div>
</AFormItem>
</div>
<div class="segment-preview">{{ segmentDesc(seg) }} → {{ segmentPreview(seg) }}</div>
</div>
<AButton type="dashed" long class="mt-3" @click="addTimeSegment">+ 添加时段</AButton>
<div class="combo-preview-hint">时段从上到下先命中先生效:限时促销时段应排在常驻时段上方</div>
<!-- 组合预览:换算后的实际价格(父级未传基准价时不显示金额,仅倍率) -->
<div v-if="hasSegments" class="combo-preview">
<div class="combo-preview-title">价格预览(换算后实际价格)</div>
<div class="combo-row">
<span class="combo-name">默认价(未命中时段)</span>
<span class="combo-value">{{ defaultPreviewText() }}</span>
</div>
<div v-for="(seg, index) in segments" :key="index" class="combo-row">
<span class="combo-name">{{ seg.name || `时段 ${index + 1}` }}({{ segmentDesc(seg) }})</span>
<span class="combo-value">{{ segmentPreview(seg) }}</span>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* 与主弹窗一致的 section / 卡片基础样式(scoped 隔离,组件内自持) */
.editor-section {
margin-bottom: 24px;
}
.editor-section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.editor-section-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--ta-text-primary);
margin: 0;
}
.section-hint {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.tier-card {
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 8px;
margin-bottom: 8px;
}
.tier-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.tier-label {
font-size: 13px;
font-weight: 600;
color: var(--ta-text-secondary);
}
.combo-value {
color: var(--ta-text-primary);
font-family: monospace;
text-align: right;
}
.segment-tools {
display: flex;
align-items: center;
gap: 4px;
}
.time-range {
display: flex;
align-items: center;
gap: 8px;
}
.time-sep {
color: var(--color-text-3);
}
.custom-days {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
}
.segment-preview {
margin-top: 4px;
padding: 6px 10px;
font-size: 12px;
color: var(--ta-text-tertiary);
background: var(--color-fill-2);
border-radius: 4px;
}
.combo-preview {
margin-top: 10px;
padding: 10px 14px;
background: var(--color-fill-1);
border: 1px solid var(--ta-border-light);
border-radius: 8px;
}
.combo-preview-title {
font-size: 12px;
font-weight: 600;
color: var(--ta-text-secondary);
margin-bottom: 6px;
}
.combo-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 3px 0;
font-size: 12px;
}
.combo-preview-hint {
margin-top: 6px;
font-size: 12px;
color: var(--ta-text-tertiary);
}
</style>