feat(model):优化模型价格设置方式
This commit is contained in:
1 parent
e6293c4c7c
commit
8ec110a885
9 files changed
+2304
-984
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user