Files
team-api/web/admin/src/components/ModelPricingModal.vue
T

1908 lines
66 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Component } from 'vue'
import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
import { displayCurrency, currencySymbol, formatBilling, cnyToUsd } from '@/composables/useCurrency'
import PricingTimeSegmentsEditor, {
type TimeSegmentRow,
type SegmentPreviewBase,
timeSegmentRowsFromAPI,
timeSegmentPayloadFrom,
} from './PricingTimeSegmentsEditor.vue'
import PricingParamRulesEditor, { type ParamRuleRow, paramRuleRowsFromAPI, paramRulePayloadFrom } from './PricingParamRulesEditor.vue'
import { resolvePricingSchemeEditor } from './pricingSchemeEditors'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
const props = defineProps<{
visible: boolean
modelId: number | null
modelIdStr: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
saved: []
}>()
// ============================================================
// Pricing Editor State
// ============================================================
const editorLoading = ref(false)
const editorSaving = ref(false)
const editorBillingMode = ref('token')
const editorItems = reactive<any[]>([])
// 特殊计费方案分发:命中注册编辑器时内容区整体交给方案组件(加载/校验/保存自治),
// 内置通用表单不参与;契约见 pricingSchemeEditors.ts
const schemeEditorComp = ref<Component | null>(null)
const schemeConfigData = ref<any>(null)
const schemeEditorRef = ref<any>()
// 展示字段(挂锚点行:内部备注 + 对外营销文案)
const editorPriceNote = ref('')
const editorDiscountLabel = ref('')
const editorPriceChangeNote = ref('')
const billingModeOptions = [
{ label: '按量计费 (Token)', value: 'token' },
{ label: '按次计费', value: 'per_request' },
{ label: '阶梯计费', value: 'tiered' },
{ label: '按秒计费 (视频)', value: 'per_second' },
]
function createEmptyItem(mode: string) {
return {
billing_mode: mode,
min_tokens: 0,
max_tokens: null,
input_price: 0,
output_price: 0,
per_request_price: null,
cache_read_price: 0,
cache_creation_price: 0,
per_second_prices: null as Record<string, number> | null,
}
}
// ============================================================
// Per-Second Mode(按秒计费:分辨率规格 × 每秒单价矩阵)
// ============================================================
interface PerSecondRow {
spec: string // 分辨率/规格键,如 480p / 720p / 1080p / *(兜底)
price: number // 每秒单价(本位币)
}
const perSecondRows = reactive<PerSecondRow[]>([])
// 常用规格快捷键(点击追加行)
const perSecondSpecPresets = ['480p', '720p', '1080p', '*']
function addPerSecondRow(spec = '') {
perSecondRows.push({ spec, price: 0 })
}
function removePerSecondRow(index: number) {
perSecondRows.splice(index, 1)
}
// 回显:后端 map → 行编辑器
function loadPerSecondPrices(prices: Record<string, number> | null | undefined) {
perSecondRows.length = 0
if (!prices) return
for (const [spec, price] of Object.entries(prices)) {
perSecondRows.push({ spec, price: Number(price) || 0 })
}
}
// 行 → 提交 map;requirePositive=true 要求至少一档正价(计费定价必需,官方参考价不强制)。
// map 为 null 表示校验失败(error 为中文提示文案)
function buildPerSecondMap(rows: PerSecondRow[], requirePositive: boolean): { map: Record<string, number> | null; error?: string } {
const map: Record<string, number> = {}
let hasPositive = false
for (const row of rows) {
const spec = row.spec.trim()
if (!spec) continue
if (map[spec] !== undefined) {
return { map: null, error: `按秒计费规格「${spec}」重复` }
}
if (row.price < 0) {
return { map: null, error: `按秒计费规格「${spec}」单价不能为负` }
}
map[spec] = row.price
if (row.price > 0) hasPositive = true
}
if (requirePositive && !hasPositive) {
return { map: null, error: '按秒计费至少配置一档正价(建议额外配置 * 兜底价)' }
}
return { map }
}
// 主定价按秒矩阵:校验失败 Message 提示并返回 null
function buildPerSecondPrices(): Record<string, number> | null {
const result = buildPerSecondMap(perSecondRows, true)
if (result.map === null) {
if (result.error) Message.warning(result.error)
return null
}
return result.map
}
// 时段预览基准价:优先 * 兜底价,否则矩阵最低正价
function perSecondBasePrice(): number {
const prices = perSecondRows.map((r) => Number(r.price) || 0).filter((p) => p > 0)
if (prices.length === 0) return 0
const wildcard = perSecondRows.find((r) => r.spec.trim() === '*')
return wildcard && wildcard.price > 0 ? wildcard.price : Math.min(...prices)
}
function resetEditorDefaults() {
editorBillingMode.value = 'token'
editorItems.length = 0
editorItems.push(createEmptyItem('token'))
perSecondRows.length = 0
paramRules.length = 0
editorTimeSegments.length = 0
timePricingEnabled.value = false
paramEditorRef.value?.resetParamRules()
editorPriceNote.value = ''
editorDiscountLabel.value = ''
editorPriceChangeNote.value = ''
resetOfficialPricing()
officialDiscountPercent.value = 100
}
// ============================================================
// Official Pricing Fetch(数据源参考价:官方价二层弹窗内懒加载)
// ============================================================
const officialLoading = ref(false)
const officialData = ref<any>(null)
const officialError = ref('')
const officialModalVisible = ref(false)
const officialSourceMeta: Record<string, { label: string; color: string }> = {
litellm: { label: 'LiteLLM', color: 'arcoblue' },
'models.dev': { label: 'models.dev', color: 'green' },
basellm: { label: 'BaseLLM', color: 'orange' },
openrouter: { label: 'OpenRouter', color: 'purple' },
}
function getOfficialSourceMeta(source: string) {
return officialSourceMeta[source] || { label: source, color: 'gray' }
}
// 官方数据源价格恒为美元;本位币=CNY 时按汇率倒数折算展示与填入(汇率恒为 CNY→USD 单向配置,USD→CNY 取倒数)
const usdToBaseRatio = computed(() => {
if (displayCurrency.value !== 'CNY') return 1
return 1 / cnyToUsd.value
})
// USD 原值 → 本位币值(6 位小数,与资金显示精度及定价输入控件精度对齐)
function toBasePrice(v: number | null | undefined): number {
const usd = Number(v ?? 0)
if (usd <= 0) return 0
return Math.round(usd * usdToBaseRatio.value * 1e6) / 1e6
}
// 金额按 6 位小数取整(折扣计算结果落编辑器前的唯一取整点)
function round6(v: number): number {
return Math.round(v * 1e6) / 1e6
}
// 官方参考价展示:折算为本位币直显;本位币=CNY 时括注美元原值便于核对
function formatOfficialPrice(v: number | null | undefined): string {
const converted = formatBilling(toBasePrice(v), 2)
if (displayCurrency.value !== 'CNY') return converted
const usd = Number(Number(v ?? 0).toFixed(2))
return usd > 0 ? `${converted} ($${usd})` : converted
}
function resetOfficialData() {
officialData.value = null
officialError.value = ''
officialModalVisible.value = false
}
// 打开官方价二层弹窗:数据源不自动拉取,用户在弹窗内按需展开(后端串行请求 4 个源,冷缓存较慢)
function openOfficialModal() {
officialModalVisible.value = true
}
// 数据源参考价浮窗(APopover 挂在标题行按钮上):trigger=click 非受控,
// 首次弹出自动拉取;已拉取过则直接展示
function onSourcesVisibleChange(visible: boolean) {
if (visible && !officialData.value && !officialLoading.value) {
fetchOfficialPricing()
}
}
async function fetchOfficialPricing() {
if (!props.modelId) return
officialLoading.value = true
officialError.value = ''
officialData.value = null
try {
const res: any = await request.get(`/admin/models/${props.modelId}/official-pricing`)
officialData.value = res.data?.data
} catch {
officialError.value = '获取官方定价失败'
} finally {
officialLoading.value = false
}
}
// ============================================================
// 官方定价(official_pricing 落库参照:非计费依据,是折扣计算与几折展示的基准)。
// 存储结构与计费定价完全同构(official_items + 时段 + 参数倍率,支持全部四种计费模式);
// 数据源拉取仅作平面价初值参考,人工核对修正后随保存落库,调折扣不再依赖远程数据源
// ============================================================
const officialBillingMode = ref('token')
const officialItems = reactive<any[]>([])
const officialPerSecondRows = reactive<PerSecondRow[]>([])
const officialTimeSegments = reactive<TimeSegmentRow[]>([])
const officialTimeEnabled = ref(false)
const officialParamRules = reactive<ParamRuleRow[]>([])
// 官方时段/倍率的回显与载荷一律走纯函数填充/构建行数组,不依赖编辑器组件挂载
// (编辑器在二层弹窗内,Arco Modal 关闭即卸载,ref 不可靠)
const officialBillingModeOptions = [
{ label: '按量 (Token)', value: 'token' },
{ label: '按次', value: 'per_request' },
{ label: '阶梯计费', value: 'tiered' },
{ label: '按秒', value: 'per_second' },
]
// 官方定价首行(单行模式取价用;tiered 的档位价在 officialItems 各行)
function officialFirst(): any {
return officialItems[0] || {}
}
// 官方定价是否已配置(价格维度或时段/倍率任一非空;阶梯看任一档——首档可能是 0 价免费档)
const hasOfficialPricing = computed(() => {
if (officialTimeSegments.length > 0 || officialParamRules.length > 0) return true
if (officialBillingMode.value === 'tiered') {
return officialItems.some(
(t) =>
(Number(t.input_price) || 0) > 0 ||
(Number(t.output_price) || 0) > 0 ||
(Number(t.cache_read_price) || 0) > 0 ||
(Number(t.cache_creation_price) || 0) > 0,
)
}
const first = officialFirst()
return (
Number(first.input_price) > 0 ||
Number(first.output_price) > 0 ||
Number(first.cache_read_price) > 0 ||
Number(first.cache_creation_price) > 0 ||
(Number(first.per_request_price) || 0) > 0 ||
officialPerSecondRows.some((r) => (Number(r.price) || 0) > 0)
)
})
function addOfficialPerSecondRow(spec = '') {
officialPerSecondRows.push({ spec, price: 0 })
}
function removeOfficialPerSecondRow(index: number) {
officialPerSecondRows.splice(index, 1)
}
// 保证官方定价首行存在:模板直接绑定 officialItems[0],空数组会渲染报错;
// 空行(价格全 0)不视为已配置,提交时后端按全空清除,无害
function ensureOfficialFirstRow(mode = officialBillingMode.value) {
if (officialItems.length === 0) {
officialItems.push({ billing_mode: mode, min_tokens: 0, max_tokens: null, input_price: 0, output_price: 0, per_request_price: null, cache_read_price: 0, cache_creation_price: 0, per_second_prices: null })
}
}
// 切换官方计费模式时保证首行存在且行数合法(保留原首行价格,tiered 之外不保留多余档位行)
watch(officialBillingMode, (mode) => {
ensureOfficialFirstRow(mode)
if (mode !== 'tiered' && officialItems.length > 1) {
officialItems.splice(1)
}
officialItems[0].billing_mode = mode
// 切到按秒模式时给常见起始模板(与主编辑器一致,已手动填写则不覆盖)
if (mode === 'per_second' && officialPerSecondRows.length === 0) {
addOfficialPerSecondRow('720p')
addOfficialPerSecondRow('*')
}
})
// 组件创建即补首行(定价弹窗在模型列表页常驻渲染,此时 loadPricing 尚未执行)
ensureOfficialFirstRow()
function addOfficialTier() {
// 阶梯档位行:上一档 max_tokens 收口为新增档的 min_tokens(与主弹窗阶梯语义一致)
const last = officialItems[officialItems.length - 1]
if (last && last.max_tokens != null) {
const nextMin = Number(last.max_tokens) + 1
last.max_tokens = nextMin - 1
officialItems.push({ billing_mode: 'tiered', min_tokens: nextMin, max_tokens: null, input_price: 0, output_price: 0, per_request_price: null, cache_read_price: 0, cache_creation_price: 0, per_second_prices: null })
} else {
officialItems.push({ billing_mode: 'tiered', min_tokens: 0, max_tokens: null, input_price: 0, output_price: 0, per_request_price: null, cache_read_price: 0, cache_creation_price: 0, per_second_prices: null })
}
}
function removeOfficialTier(index: number) {
if (officialItems.length <= 1) return
officialItems.splice(index, 1)
// 末档保持无上限
if (officialItems.length > 0) officialItems[officialItems.length - 1].max_tokens = null
}
// 回显:GET pricing 响应 → 官方定价编辑器(official_items + 时段 + 倍率,同构结构)。
// 时段/倍率用纯函数直接填充行数组——二层弹窗未打开时编辑器组件未挂载,ref 加载不生效
function loadOfficialPricing(data: any) {
officialBillingMode.value = 'token'
officialItems.length = 0
officialPerSecondRows.length = 0
const list: any[] = data?.official_items || []
if (list.length > 0) {
officialBillingMode.value = list[0].billing_mode || 'token'
for (const item of list) {
officialItems.push({
billing_mode: item.billing_mode,
min_tokens: item.min_tokens || 0,
max_tokens: item.max_tokens ?? null,
input_price: item.input_price ?? 0,
output_price: item.output_price ?? 0,
per_request_price: item.per_request_price ?? null,
cache_read_price: item.cache_read_price ?? 0,
cache_creation_price: item.cache_creation_price ?? 0,
per_second_prices: item.per_second_prices ?? null,
})
}
const perSecond: Record<string, number> = list[0].per_second_prices || {}
for (const [spec, price] of Object.entries(perSecond)) {
officialPerSecondRows.push({ spec, price: Number(price) || 0 })
}
} else {
// 无官方价:补一行空 token 行,保证官方编辑器模板绑定 officialItems[0] 安全
ensureOfficialFirstRow()
}
const segConv = timeSegmentRowsFromAPI(data?.official_time_segments)
officialTimeSegments.length = 0
officialTimeSegments.push(...segConv.rows)
officialTimeEnabled.value = segConv.enabled
const ruleRows = paramRuleRowsFromAPI(data?.official_param_multipliers)
officialParamRules.length = 0
officialParamRules.push(...ruleRows)
}
function resetOfficialPricing() {
officialBillingMode.value = 'token'
officialItems.length = 0
officialPerSecondRows.length = 0
officialTimeSegments.length = 0
officialTimeEnabled.value = false
officialParamRules.length = 0
ensureOfficialFirstRow()
}
// 保存载荷:官方定价 → official_items / official_time_segments / official_param_multipliers
// 三个数组恒传(后端 OfficialItems nil 才是「不动」),与主定价同为全量替换语义
function officialItemsPayload(): any[] {
return officialItems.map((item, index) => ({
billing_mode: officialBillingMode.value,
min_tokens: officialBillingMode.value === 'tiered' ? item.min_tokens : 0,
max_tokens: officialBillingMode.value === 'tiered' ? (index < officialItems.length - 1 ? item.max_tokens : null) : null,
input_price: item.input_price,
output_price: item.output_price,
per_request_price: officialBillingMode.value === 'per_request' ? (item.per_request_price ?? null) : null,
cache_read_price: item.cache_read_price,
cache_creation_price: item.cache_creation_price,
per_second_prices: officialBillingMode.value === 'per_second' ? officialPerSecondPayload() : null,
}))
}
function officialPerSecondPayload(): Record<string, number> {
const prices: Record<string, number> = {}
for (const row of officialPerSecondRows) {
const spec = row.spec.trim()
if (spec) prices[spec] = Number(row.price) || 0
}
return prices
}
// 官方时段载荷:从行数组纯函数构建(不依赖编辑器挂载);null=校验失败,调用方中止保存
function officialTimeSegmentsPayload(): any[] | null {
const result = timeSegmentPayloadFrom(officialTimeSegments, officialTimeEnabled.value)
if (result.error) {
Message.warning(result.error)
return null
}
return result.payload ?? []
}
// 官方参数倍率载荷:同官方时段,null=校验失败
function officialParamRulesPayload(): any[] | null {
const result = paramRulePayloadFrom(officialParamRules)
if (result.error) {
Message.warning(result.error)
return null
}
return result.payload ?? []
}
// 数据源源价 → 官方定价编辑器(100% 填入,USD 折算本位币 6 位小数;数据源只有平面价,
// 填入后仅改价格初值,人工核对修正并保存才落库)
function applySourceToOfficial(pricing: any) {
if (!pricing) return
if (officialBillingMode.value === 'per_second') {
Message.warning('官方定价当前为按秒模式,数据源无按秒价格,请手动填写')
return
}
if (officialBillingMode.value === 'tiered') {
Message.warning('官方定价当前为阶梯模式,数据源为平面价,请手动调整各档位')
return
}
if (officialItems.length === 0) {
officialItems.push({ billing_mode: officialBillingMode.value, min_tokens: 0, max_tokens: null, input_price: 0, output_price: 0, per_request_price: null, cache_read_price: 0, cache_creation_price: 0, per_second_prices: null })
}
const first = officialItems[0]
if (pricing.billing_mode === 'per_request') {
officialBillingMode.value = 'per_request'
first.per_request_price = toBasePrice(pricing.output_price)
} else {
if (officialBillingMode.value === 'per_request') {
officialBillingMode.value = 'token'
}
first.input_price = toBasePrice(pricing.input_price)
first.output_price = toBasePrice(pricing.output_price)
first.cache_read_price = toBasePrice(pricing.cache_read_price)
first.cache_creation_price = toBasePrice(pricing.cache_creation_price)
}
Message.success('已采用该数据源价格作为官方定价,请人工核对修正后保存')
}
// 派生比较:数据源价(USD)折本位币后与当前基准完全一致 → 该源即当前基准的来源
// (official_pricing 落库不存来源字段,只能前端比对推导;仅平面价模式可比)
function isCurrentBaseline(src: any): boolean {
const p = src?.pricing
if (!src?.found || !p) return false
const first = officialFirst()
if (p.billing_mode === 'per_request') {
return (
officialBillingMode.value === 'per_request' &&
(Number(first.per_request_price) || 0) > 0 &&
round6(toBasePrice(p.output_price)) === round6(Number(first.per_request_price) || 0)
)
}
if (officialBillingMode.value !== 'token') return false
return (
round6(toBasePrice(p.input_price)) === round6(Number(first.input_price) || 0) &&
round6(toBasePrice(p.output_price)) === round6(Number(first.output_price) || 0) &&
round6(toBasePrice(p.cache_read_price)) === round6(Number(first.cache_read_price) || 0) &&
round6(toBasePrice(p.cache_creation_price)) === round6(Number(first.cache_creation_price) || 0)
)
}
// 按折扣换算(百分比):官方定价 × 折扣 → 整体替换我方定价(价格维度 ×折扣,
// 时段与参数倍率原样复制——乘数是相对结构,不随折扣缩放)。纯前端计算,保留 6 位小数
const officialDiscountPercent = ref(100)
// 折扣显示文案:88.5% → "8.85 折"、50% → "5 折"
function foldLabel(percent: number | null | undefined): string {
const p = Number(percent) || 0
return String(+(p / 10).toFixed(2))
}
interface OfficialApplyRow {
label: string
official: number
applied: number
current: number
}
interface OfficialApplyPlan {
ok: boolean
reason: string // 不满足时的中文原因(预览区展示 + 按钮 disabled)
hint: string // 可换算时的作用范围提示
rows: OfficialApplyRow[]
timeSegmentCount: number // 将原样复制的时段条数
paramRuleCount: number // 将原样复制的参数倍率条数
target: 'token' | 'per_request' | 'tiered' | 'per_second' // 将写入的我方计费模式
tiers: Array<{ min_tokens: number; max_tokens: number | null; input_price: number; output_price: number; cache_read_price: number; cache_creation_price: number }> // tiered 换算结果
}
// 换算计划:预览表与执行动作共用同一套判断与计算,避免两份逻辑漂移
function officialApplyPlan(): OfficialApplyPlan {
const plan: OfficialApplyPlan = { ok: false, reason: '', hint: '', rows: [], timeSegmentCount: 0, paramRuleCount: 0, target: 'token', tiers: [] }
if (!hasOfficialPricing.value) {
plan.reason = '官方定价尚未配置,请先从数据源采用或手动填写'
return plan
}
const percent = Number(officialDiscountPercent.value)
if (!percent || percent <= 0) {
plan.reason = '请填写有效的折扣'
return plan
}
const d = percent / 100
const mode = officialBillingMode.value
plan.target = mode as OfficialApplyPlan['target']
if (editorBillingMode.value === 'per_second' && mode !== 'per_second') {
plan.reason = '当前计费为按秒模式,官方定价需同为按秒才能换算填入'
return plan
}
const first: any = editorItems[0] || {}
const offFirst: any = officialFirst()
if (mode === 'per_second') {
// 官方按秒矩阵 × 折扣 → 整体替换我方矩阵(规格随官方定价)
for (const r of officialPerSecondRows) {
const spec = r.spec.trim()
if (!spec) continue
const official = Number(r.price) || 0
plan.rows.push({
label: spec,
official,
applied: round6(official * d),
current: perSecondRows.find((c) => c.spec.trim() === spec)?.price || 0,
})
}
if (!plan.rows.some((r) => r.applied > 0)) {
plan.reason = '官方按秒定价矩阵中没有正价规格'
return plan
}
plan.hint = '将整体替换我方按秒规格矩阵(规格随官方定价)'
} else if (mode === 'per_request') {
const official = Number(offFirst.per_request_price) || 0
plan.rows.push({
label: '按次单价',
official,
applied: round6(official * d),
current: Number(first.per_request_price) || 0,
})
if (plan.rows[0].applied <= 0) {
plan.reason = '官方按次定价尚未配置单价'
return plan
}
plan.hint = '将整体替换我方按次单价'
} else if (mode === 'tiered') {
// 阶梯:逐档 input/output/缓存价 ×折扣,整体重建我方阶梯
for (const [i, t] of officialItems.entries()) {
const officialIn = Number(t.input_price) || 0
const officialOut = Number(t.output_price) || 0
const officialCacheRead = Number(t.cache_read_price) || 0
const officialCacheCreation = Number(t.cache_creation_price) || 0
plan.rows.push({
label: `档位 ${i + 1}(≥${Number(t.min_tokens) || 0} tokens)`,
official: officialIn,
applied: round6(officialIn * d),
current: Number(editorItems[i]?.input_price) || 0,
})
plan.rows.push({
label: `档位 ${i + 1} 输出`,
official: officialOut,
applied: round6(officialOut * d),
current: Number(editorItems[i]?.output_price) || 0,
})
if (officialCacheRead > 0) {
plan.rows.push({
label: `档位 ${i + 1} 缓存读取`,
official: officialCacheRead,
applied: round6(officialCacheRead * d),
current: Number(editorItems[i]?.cache_read_price) || 0,
})
}
if (officialCacheCreation > 0) {
plan.rows.push({
label: `档位 ${i + 1} 缓存创建`,
official: officialCacheCreation,
applied: round6(officialCacheCreation * d),
current: Number(editorItems[i]?.cache_creation_price) || 0,
})
}
plan.tiers.push({
min_tokens: Number(t.min_tokens) || 0,
max_tokens: i < officialItems.length - 1 ? (t.max_tokens ?? null) : null,
input_price: round6(officialIn * d),
output_price: round6(officialOut * d),
cache_read_price: round6(officialCacheRead * d),
cache_creation_price: round6(officialCacheCreation * d),
})
}
if (!plan.tiers.some((t) => t.input_price > 0 || t.output_price > 0)) {
plan.reason = '官方阶梯定价中没有任何正价档位'
return plan
}
plan.hint = '将按官方档位整体重建我方阶梯定价(逐档缓存价 ×折扣)'
} else {
plan.target = 'token'
const dims: Array<[string, number, number]> = [
['输入价格', Number(offFirst.input_price) || 0, Number(first.input_price) || 0],
['输出价格', Number(offFirst.output_price) || 0, Number(first.output_price) || 0],
['缓存读取', Number(offFirst.cache_read_price) || 0, Number(first.cache_read_price) || 0],
['缓存创建', Number(offFirst.cache_creation_price) || 0, Number(first.cache_creation_price) || 0],
]
for (const [label, official, current] of dims) {
plan.rows.push({ label, official, applied: round6(official * d), current })
}
if (!plan.rows.some((r) => r.applied > 0)) {
plan.reason = '官方定价尚未配置价格'
return plan
}
plan.hint = '将覆盖我方按量四项价格,保留 6 位小数'
}
// 时段与参数倍率原样复制(乘数是相对结构,不随折扣缩放)
plan.timeSegmentCount = officialTimeEnabled.value ? officialTimeSegments.length : 0
plan.paramRuleCount = officialParamRules.length
if (plan.timeSegmentCount > 0 || plan.paramRuleCount > 0) {
plan.hint += `;另将原样复制官方的 ${plan.timeSegmentCount} 条时段定价与 ${plan.paramRuleCount} 条参数倍率规则`
}
plan.ok = true
return plan
}
function applyOfficialToPricing() {
const plan = officialApplyPlan()
if (!plan.ok) {
Message.warning(plan.reason)
return
}
if (plan.target === 'per_second') {
editorBillingMode.value = 'per_second'
perSecondRows.length = 0
perSecondRows.push(...plan.rows.map((r) => ({ spec: r.label, price: r.applied })))
} else if (plan.target === 'per_request') {
editorBillingMode.value = 'per_request'
if (editorItems.length === 0) {
editorItems.push(createEmptyItem('per_request'))
}
editorItems.splice(1)
editorItems[0].per_request_price = plan.rows[0].applied
} else if (plan.target === 'tiered') {
editorBillingMode.value = 'tiered'
editorItems.length = 0
for (const t of plan.tiers) {
editorItems.push({
billing_mode: 'tiered',
min_tokens: t.min_tokens,
max_tokens: t.max_tokens,
input_price: t.input_price,
output_price: t.output_price,
per_request_price: null,
cache_read_price: t.cache_read_price,
cache_creation_price: t.cache_creation_price,
per_second_prices: null,
})
}
} else {
if (editorBillingMode.value === 'per_request') {
editorBillingMode.value = 'token'
}
if (editorItems.length === 0) {
editorItems.push(createEmptyItem('token'))
}
editorItems.splice(1)
editorItems[0].input_price = plan.rows[0].applied
editorItems[0].output_price = plan.rows[1].applied
editorItems[0].cache_read_price = plan.rows[2].applied
editorItems[0].cache_creation_price = plan.rows[3].applied
}
// 时段与参数倍率整体复制(直接克隆行数据,不经载荷往返;乘数是相对结构,不随折扣缩放)
if (plan.timeSegmentCount > 0) {
editorTimeSegments.length = 0
editorTimeSegments.push(
...officialTimeSegments.map((s) => ({
...s,
days: [...s.days],
validRange: s.validRange ? [...s.validRange] : undefined,
})),
)
timePricingEnabled.value = true
}
if (plan.paramRuleCount > 0) {
paramRules.length = 0
paramRules.push(...officialParamRules.map((r) => ({ ...r, conditions: r.conditions.map((c) => ({ ...c })) })))
}
Message.success(`已按官方价 ${foldLabel(Number(officialDiscountPercent.value))} 折填入(整体替换我方定价)`)
}
// 现价相对库内官方价的折扣摘要(营销口径自动计算,替代手算几折)。
// 官方与计费模式需同维可比(token↔token/tiered 取输入输出、按次↔按次、按秒↔按秒取基准价)
function foldText(officialVal: number, currentVal: number): string | null {
if (officialVal <= 0 || currentVal <= 0) return null
return String(+((currentVal / officialVal) * 10).toFixed(2))
}
function officialPerSecondBasePrice(): number {
const prices = officialPerSecondRows.map((r) => Number(r.price) || 0).filter((p) => p > 0)
if (prices.length === 0) return 0
const wildcard = officialPerSecondRows.find((r) => r.spec.trim() === '*')
return wildcard && wildcard.price > 0 ? wildcard.price : Math.min(...prices)
}
function officialFoldSummary(): string | null {
const first: any = editorItems[0] || {}
const offFirst: any = officialFirst()
const parts: string[] = []
if (officialBillingMode.value === 'per_second' && editorBillingMode.value === 'per_second') {
const fold = foldText(officialPerSecondBasePrice(), perSecondBasePrice())
if (fold) parts.push(`单价 ${fold} 折`)
} else if (officialBillingMode.value === 'per_request' && editorBillingMode.value === 'per_request') {
const fold = foldText(Number(offFirst.per_request_price) || 0, Number(first.per_request_price) || 0)
if (fold) parts.push(`单价 ${fold} 折`)
} else if (officialBillingMode.value === 'tiered' && editorBillingMode.value === 'tiered') {
// 阶梯对阶梯:取首档(min_tokens=0)输入价比较
const offEntry = officialItems.find((t: any) => (Number(t.min_tokens) || 0) === 0) || officialItems[0] || {}
const fold = foldText(Number(offEntry.input_price) || 0, Number(first.input_price) || 0)
if (fold) parts.push(`首档 ${fold} 折`)
} else if (officialBillingMode.value === 'token' && (editorBillingMode.value === 'token' || editorBillingMode.value === 'tiered')) {
const fi = foldText(Number(offFirst.input_price) || 0, Number(first.input_price) || 0)
const fo = foldText(Number(offFirst.output_price) || 0, Number(first.output_price) || 0)
if (fi) parts.push(`输入 ${fi} 折`)
if (fo) parts.push(`输出 ${fo} 折`)
}
return parts.length ? parts.join(' · ') : null
}
// ============================================================
// Param Multipliers(参数倍率:行数据父级持有,编辑/校验/载荷在 PricingParamRulesEditor 组件)
// ============================================================
const paramRules = reactive<ParamRuleRow[]>([])
const paramEditorRef = ref<InstanceType<typeof PricingParamRulesEditor>>()
// ============================================================
// Load Pricing Data
// ============================================================
async function loadPricing() {
if (!props.modelId) return
// 模型切换时先清除上一模型的方案分发状态
schemeEditorComp.value = null
schemeConfigData.value = null
editorItems.length = 0
editorBillingMode.value = 'token'
paramRules.length = 0
resetOfficialData()
editorLoading.value = true
try {
const res: any = await request.get(`/admin/models/${props.modelId}/pricing`)
// 特殊计费方案:命中注册编辑器则整体交给方案组件,通用表单不初始化
// (未登记的方案名同样回落通用表单——后端查询已透出 scheme,前端映射缺失时兜底)
schemeEditorComp.value = resolvePricingSchemeEditor(res.data?.data?.scheme)
if (schemeEditorComp.value) {
schemeConfigData.value = res.data?.data?.scheme_config ?? null
return
}
const list: any[] = res.data?.data?.list || []
editorPriceNote.value = res.data?.data?.price_note || ''
editorDiscountLabel.value = res.data?.data?.discount_label || ''
editorPriceChangeNote.value = res.data?.data?.price_change_note || ''
if (list.length > 0) {
editorBillingMode.value = list[0].billing_mode || 'token'
editorItems.length = 0
for (const item of list) {
editorItems.push({
billing_mode: item.billing_mode,
min_tokens: item.min_tokens || 0,
max_tokens: item.max_tokens ?? null,
input_price: item.input_price ?? 0,
output_price: item.output_price ?? 0,
per_request_price: item.per_request_price ?? null,
cache_read_price: item.cache_read_price ?? 0,
cache_creation_price: item.cache_creation_price ?? 0,
per_second_prices: item.per_second_prices ?? null,
})
}
loadPerSecondPrices(list[0].per_second_prices)
} else {
resetEditorDefaults()
}
timeSegEditorRef.value?.loadTimeSegments(res.data?.data?.time_segments)
paramEditorRef.value?.loadParamMultipliers(res.data?.data?.param_multipliers)
loadOfficialPricing(res.data?.data)
} catch {
resetEditorDefaults()
} finally {
editorLoading.value = false
}
}
// ============================================================
// Time Segment Pricing Editor(时段定价:行数据父级持有,编辑/校验/载荷在 PricingTimeSegmentsEditor 组件)
// ============================================================
const timePricingEnabled = ref(false)
const editorTimeSegments = reactive<TimeSegmentRow[]>([])
const timeSegEditorRef = ref<InstanceType<typeof PricingTimeSegmentsEditor>>()
// 组合预览基础价:随计费模式取当前编辑中的价格(tiered 用首档价)
const previewBase = computed<SegmentPreviewBase>(() => {
const first: any = editorItems[0] || {}
if (editorBillingMode.value === 'per_second') {
return { kind: 'per_second', perSecond: perSecondBasePrice() }
}
if (editorBillingMode.value === 'per_request') {
return { kind: 'per_request', perRequest: Number(first.per_request_price) || 0 }
}
const kind = editorBillingMode.value === 'tiered' ? 'tiered' : 'token'
return { kind, input: Number(first.input_price) || 0, output: Number(first.output_price) || 0 }
})
// 官方时段组合预览基准价:随官方计费模式取官方编辑中的价格(与 previewBase 同构)
const officialPreviewBase = computed<SegmentPreviewBase>(() => {
const first: any = officialFirst()
if (officialBillingMode.value === 'per_second') {
return { kind: 'per_second', perSecond: officialPerSecondBasePrice() }
}
if (officialBillingMode.value === 'per_request') {
return { kind: 'per_request', perRequest: Number(first.per_request_price) || 0 }
}
const kind = officialBillingMode.value === 'tiered' ? 'tiered' : 'token'
return { kind, input: Number(first.input_price) || 0, output: Number(first.output_price) || 0 }
})
// ============================================================
// Tiered Mode Helpers
// ============================================================
watch(editorBillingMode, (mode) => {
if (mode === 'tiered') {
if (editorItems.length === 0) {
editorItems.push(createEmptyItem('tiered'))
}
} else {
if (editorItems.length === 0) {
editorItems.push(createEmptyItem(mode))
} else {
editorItems[0].billing_mode = mode
}
}
// 切到按秒模式时给一个常见起始模板(已手动编辑过则不覆盖)
if (mode === 'per_second' && perSecondRows.length === 0) {
addPerSecondRow('720p')
addPerSecondRow('*')
}
})
function addTier() {
const last = editorItems[editorItems.length - 1]
const newMin = last?.max_tokens ?? 0
editorItems.push(createEmptyItem('tiered'))
const newItem = editorItems[editorItems.length - 1]
newItem.min_tokens = newMin
if (last && last.max_tokens === null) {
last.max_tokens = newMin
}
}
function removeTier(index: number) {
editorItems.splice(index, 1)
if (editorItems.length > 0) {
editorItems[editorItems.length - 1].max_tokens = null
}
}
// ============================================================
// Save
// ============================================================
async function savePricing() {
if (!props.modelId) return
editorSaving.value = true
try {
// 按秒模式:行编辑器 → 矩阵(校验失败中止保存)
let perSecondPrices: Record<string, number> | null = null
if (editorBillingMode.value === 'per_second') {
perSecondPrices = buildPerSecondPrices()
if (!perSecondPrices) {
editorSaving.value = false
return
}
}
// 时段定价:编辑器组件校验并生成载荷(校验失败中止保存)
const timeSegments = timeSegEditorRef.value?.buildTimeSegmentsPayload()
if (timeSegments === null || timeSegments === undefined) {
editorSaving.value = false
return
}
// 参数倍率:编辑器组件校验并生成载荷(校验失败中止保存)
const paramMultipliers = paramEditorRef.value?.buildParamMultipliersPayload()
if (paramMultipliers === null || paramMultipliers === undefined) {
editorSaving.value = false
return
}
// 官方时段/参数倍率:行数组纯函数构建(编辑器未挂载也可用);校验失败中止保存,与主定价一致
const officialSegsPayload = officialTimeSegmentsPayload()
if (officialSegsPayload === null) {
editorSaving.value = false
return
}
const officialRulesPayload = officialParamRulesPayload()
if (officialRulesPayload === null) {
editorSaving.value = false
return
}
// 官方按秒矩阵:重复规格/负价校验(官方价为可选参照,空矩阵=未配置交后端清除,不强制正价)
if (officialBillingMode.value === 'per_second') {
const officialPerSecond = buildPerSecondMap(officialPerSecondRows, false)
if (officialPerSecond.map === null) {
if (officialPerSecond.error) Message.warning(officialPerSecond.error)
editorSaving.value = false
return
}
}
const items = editorItems.map((item, index) => ({
billing_mode: editorBillingMode.value,
min_tokens: editorBillingMode.value === 'tiered' ? item.min_tokens : 0,
max_tokens: editorBillingMode.value === 'tiered' ? (index < editorItems.length - 1 ? item.max_tokens : null) : null,
input_price: item.input_price,
output_price: item.output_price,
per_request_price: editorBillingMode.value === 'per_request' ? (item.per_request_price ?? null) : null,
cache_read_price: item.cache_read_price,
cache_creation_price: item.cache_creation_price,
per_second_prices: editorBillingMode.value === 'per_second' ? perSecondPrices : null,
}))
await request.put(`/admin/models/${props.modelId}/pricing`, {
items,
time_segments: timeSegments,
param_multipliers: paramMultipliers,
// 官方定价:三数组恒传(后端 OfficialItems nil 才是「不动」),全量替换语义;上方已校验
official_items: officialItemsPayload(),
official_time_segments: officialSegsPayload,
official_param_multipliers: officialRulesPayload,
price_note: editorPriceNote.value.trim(),
discount_label: editorDiscountLabel.value.trim(),
price_change_note: editorPriceChangeNote.value.trim(),
})
Message.success('定价已保存')
emit('update:visible', false)
emit('saved')
} catch {
// error handled by interceptor
} finally {
editorSaving.value = false
}
}
// 保存统一入口:特殊计费方案交给方案编辑器(自治校验与保存,失败自行提示),
// 成功提示与关弹窗由本外壳统一处理;否则走通用表单保存
async function onSave() {
if (schemeEditorComp.value) {
editorSaving.value = true
try {
const ok = await schemeEditorRef.value?.save()
if (ok) {
Message.success('定价已保存')
emit('update:visible', false)
emit('saved')
}
} catch {
// 方案编辑器内部已提示,此处不重复弹错
} finally {
editorSaving.value = false
}
return
}
await savePricing()
}
// ============================================================
// Watchers
// ============================================================
// 打开时自动加载定价数据
watch(() => props.visible, (val) => {
if (val && props.modelId) {
loadPricing()
}
})
// 关闭时重置状态
watch(() => props.visible, (val) => {
if (!val) {
schemeEditorComp.value = null
schemeConfigData.value = null
resetEditorDefaults()
resetOfficialData()
}
})
</script>
<template>
<AModal
:visible="visible"
:title="modelId ? `定价设置 - ${modelIdStr}` : '定价设置'"
:width="'min(880px, 96vw)'"
:mask-closable="false"
:esc-to-close="false"
:footer="true"
:body-style="{ maxHeight: '72vh', overflowY: 'auto' }"
@cancel="emit('update:visible', false)"
>
<template #footer>
<AButton @click="emit('update:visible', false)">取消</AButton>
<AButton type="primary" :loading="editorSaving" @click="onSave">保存定价</AButton>
</template>
<ASpin :loading="editorLoading" style="width: 100%">
<!-- 特殊计费方案:内容区整体交给方案专属编辑器(契约见 pricingSchemeEditors.ts) -->
<component
:is="schemeEditorComp"
v-if="schemeEditorComp"
ref="schemeEditorRef"
:model-id="props.modelId"
:scheme-config="schemeConfigData"
/>
<AForm v-else :model="{}" layout="vertical">
<div class="pricing-editor">
<!-- Billing Mode -->
<div class="editor-section">
<div class="editor-section-header">
<h3>计费模式</h3>
</div>
<div class="billing-mode-row">
<ARadioGroup v-model="editorBillingMode" type="button">
<ARadio v-for="opt in billingModeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
</ARadioGroup>
<div class="billing-mode-tools">
<!-- 官方价状态:颜色区分是否配置;点击标签进二层弹窗设置。已配置时右侧直接给快速折扣录入 -->
<div
class="official-quick"
:title="hasOfficialPricing ? `官方价基准已设置(非计费依据)${officialFoldSummary() ? ',' + officialFoldSummary() : ''};点击标签可修改` : '官方价基准未设置;点击标签设置后可按折扣一键生成我方定价'"
>
<ATag :color="hasOfficialPricing ? 'green' : 'gray'" class="official-quick-tag" @click="openOfficialModal">
{{ hasOfficialPricing ? '官方价已设置' : '官方价未设置' }}
</ATag>
<template v-if="hasOfficialPricing">
<AInputNumber
v-model="officialDiscountPercent"
:min="1"
:max="1000"
:step="5"
:precision="1"
size="small"
class="official-quick-input"
title="官方价 × 折扣 → 填入我方定价"
>
<template #suffix>%</template>
</AInputNumber>
<AButton size="small" type="primary" @click="applyOfficialToPricing">按官方价填入</AButton>
</template>
</div>
</div>
</div>
</div>
<!-- Per-request Mode -->
<template v-if="editorBillingMode === 'per_request' && editorItems.length > 0">
<div class="editor-section">
<div class="editor-section-header">
<h3>按次定价</h3>
</div>
<div class="grid grid-cols-2 gap-x-4">
<AFormItem label="按次单价">
<AInputNumber
v-model="editorItems[0].per_request_price"
:min="0"
:precision="6"
placeholder="每次调用价格"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 次</template>
</AInputNumber>
</AFormItem>
</div>
</div>
</template>
<!-- Per-second Mode(视频按秒计费:分辨率 × 每秒单价矩阵,单卡片行式紧凑布局) -->
<template v-else-if="editorBillingMode === 'per_second'">
<div class="editor-section">
<div class="editor-section-header">
<h3>按秒定价</h3>
<span class="section-hint">按视频时长(秒)计费,费用 = 命中规格单价 × 秒数;<code>*</code> 为兜底价(请求规格未命中时使用)</span>
</div>
<div class="per-second-card">
<div v-if="perSecondRows.length" class="per-second-col-head">
<span>规格</span>
<span>每秒单价</span>
<span class="per-second-op-col"></span>
</div>
<div v-for="(row, index) in perSecondRows" :key="index" class="per-second-row">
<AInput
v-model="row.spec"
placeholder="如 720p / 1080p / *(兜底)"
:max-length="32"
class="per-second-spec"
/>
<AInputNumber
v-model="row.price"
:min="0"
:precision="6"
placeholder="0"
class="per-second-price"
>
<template #suffix>{{ currencySymbol }} / 秒</template>
</AInputNumber>
<AButton
size="mini"
status="danger"
title="删除该规格"
@click="removePerSecondRow(index)"
>✕</AButton>
</div>
<div v-if="!perSecondRows.length" class="per-second-empty">
暂无规格,点击下方按钮或预设快捷添加
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<AButton size="small" type="outline" @click="addPerSecondRow()">+ 添加规格</AButton>
<AButton
v-for="preset in perSecondSpecPresets"
:key="preset"
size="mini"
@click="addPerSecondRow(preset)"
>{{ preset }}</AButton>
</div>
</div>
</template>
<!-- Token Mode -->
<template v-else-if="editorBillingMode === 'token' && editorItems.length > 0">
<div class="editor-section">
<div class="editor-section-header">
<h3>按量定价</h3>
<span class="section-hint">价格单位为 {{ currencySymbol }} / 1M Token</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="输入价格">
<AInputNumber
v-model="editorItems[0].input_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber
v-model="editorItems[0].output_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存读取">
<AInputNumber
v-model="editorItems[0].cache_read_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建">
<AInputNumber
v-model="editorItems[0].cache_creation_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
</template>
<!-- Tiered Mode -->
<template v-else>
<div class="editor-section">
<div class="editor-section-header">
<h3>阶梯定价</h3>
<span class="section-hint">按 Token 用量分段设置不同价格</span>
</div>
<div v-for="(tier, index) in editorItems" :key="index" class="tier-card">
<div class="tier-header">
<span class="tier-label">第 {{ index + 1 }} 梯</span>
<AButton
v-if="editorItems.length > 1"
size="mini"
status="danger"
@click="removeTier(index)"
>删除</AButton>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="起始 Token">
<AInputNumber
v-model="tier.min_tokens"
:min="0"
:step="1000"
placeholder="0"
class="w-full"
/>
</AFormItem>
<AFormItem label="结束 Token">
<AInputNumber
v-if="index < editorItems.length - 1"
v-model="tier.max_tokens"
:min="0"
:step="1000"
placeholder="上限"
class="w-full"
/>
<AInput v-else model-value="无上限" disabled class="w-full" />
</AFormItem>
<AFormItem label="输入价格">
<AInputNumber
v-model="tier.input_price"
:min="0"
:precision="6"
class="w-full"
>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber
v-model="tier.output_price"
:min="0"
:precision="6"
class="w-full"
>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="grid grid-cols-2 gap-x-4 mt-1">
<AFormItem label="缓存读取价格">
<AInputNumber
v-model="tier.cache_read_price"
:min="0"
:precision="6"
class="w-full"
>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber
v-model="tier.cache_creation_price"
:min="0"
:precision="6"
class="w-full"
>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
<AButton type="dashed" long @click="addTier" class="mt-3">+ 添加梯度</AButton>
</div>
</template>
<!-- Time Segment Pricing(时段定价:峰谷/工作时段/促销)——编辑器组件与官方弹窗共用 -->
<PricingTimeSegmentsEditor
ref="timeSegEditorRef"
v-model:enabled="timePricingEnabled"
:segments="editorTimeSegments"
:preview-base="previewBase"
/>
<!-- Param Multipliers(参数倍率:按请求参数匹配加/折扣)——编辑器组件与官方弹窗共用 -->
<PricingParamRulesEditor ref="paramEditorRef" :rules="paramRules" />
<!-- 展示信息(折扣标签 / 价格调整说明对外展示,价格说明仅内部可见) -->
<div class="editor-section">
<div class="editor-section-header">
<h3>展示信息</h3>
<span class="section-hint">折扣标签与调整说明会展示给租户;价格说明仅管理后台可见</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4">
<AFormItem label="折扣标签(对外展示)">
<AInput v-model="editorDiscountLabel" :maxlength="50" placeholder="如:7折起、限时5折" allow-clear />
</AFormItem>
<AFormItem label="价格调整说明(对外展示)">
<AInput v-model="editorPriceChangeNote" :maxlength="200" placeholder="如:9月1日起输入价下调 20%" allow-clear />
</AFormItem>
</div>
<AFormItem label="价格说明(仅内部可见)">
<ATextarea
v-model="editorPriceNote"
:max-length="500"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder="调价背景、渠道成本等内部备注,不对外展示"
/>
</AFormItem>
</div>
</div>
</AForm>
</ASpin>
</AModal>
<!-- 官方定价基准:二层弹窗(数据源参考价 → 官方价编辑 → 折扣换算填入),与我方定价解耦。
误关无损失:官方价改动留在组件 reactive 里,随主弹窗「保存定价」落库才生效 -->
<AModal
:visible="officialModalVisible"
:title="`官方定价基准 - ${modelIdStr}`"
:width="'min(880px, 96vw)'"
:body-style="{ maxHeight: '72vh', overflowY: 'auto' }"
@cancel="officialModalVisible = false"
@close="officialModalVisible = false"
>
<!-- 官方定价(基准,可人工核对修正;结构与主定价同构,支持全部四种计费模式)。
数据源参考价为浮窗(APopover 挂在标题行按钮上),不混入页面内容 -->
<div class="official-modal-section">
<div class="official-modal-section-head">
<h4>官方定价(基准)</h4>
<span class="official-modal-section-hint">单位随本位币({{ currencySymbol }})</span>
<APopover
trigger="click"
position="bottom"
:content-style="{ padding: '12px', width: 'min(680px, 90vw)' }"
@popup-visible-change="onSourcesVisibleChange"
>
<AButton size="mini" class="official-sources-trigger">
<template #icon><IconCloudDownload /></template>
数据源参考价
</AButton>
<template #content>
<div class="official-sources-panel">
<div class="official-sources-panel-head">
<span class="official-sources-panel-title">数据源参考价</span>
<AButton size="mini" :loading="officialLoading" @click="fetchOfficialPricing">
<template #icon><IconRefresh /></template>
重新拉取
</AButton>
</div>
<div v-if="officialLoading && !officialData" class="official-reference-state">
<ASpin />
<span class="official-loading-text">正在拉取 4 个数据源…</span>
</div>
<AAlert v-else-if="officialError" type="error" :title="officialError" />
<div v-else-if="officialData" class="official-source-grid">
<div v-for="src in officialData.sources" :key="src.source" class="official-source-card">
<div class="official-source-header">
<ATag size="small" :color="getOfficialSourceMeta(src.source).color">
{{ getOfficialSourceMeta(src.source).label }}
</ATag>
<template v-if="src.found && src.pricing">
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
<ATag v-if="isCurrentBaseline(src)" size="small" color="green">已是当前基准</ATag>
</template>
</div>
<template v-if="src.found && src.pricing">
<div v-if="src.max_context_tokens || src.max_output_tokens" class="official-source-meta">
<span v-if="src.max_context_tokens">上下文 {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
<span v-if="src.max_output_tokens">输出 {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
</div>
<div class="official-prices">
<div class="official-price-row">
<span class="official-price-label">输入价格</span>
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.input_price) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">输出价格</span>
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.output_price) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存读取</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatOfficialPrice(src.pricing.cache_read_price) + ' / 1M' : '—' }}</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存创建</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatOfficialPrice(src.pricing.cache_creation_price) + ' / 1M' : '—' }}</span>
</div>
</div>
<AButton
type="primary"
size="small"
long
:disabled="officialBillingMode === 'per_second'"
:title="officialBillingMode === 'per_second' ? '官方定价当前为按秒模式,数据源无按秒价格' : ''"
@click="applySourceToOfficial(src.pricing)"
>采用为官方价</AButton>
</template>
<div v-else class="official-not-found" :class="{ 'official-not-found--error': src.error }">
{{ src.error ? '数据源暂时不可用' : '未找到该模型' }}
</div>
</div>
</div>
</div>
</template>
</APopover>
</div>
<div class="official-mode-row">
<ARadioGroup v-model="officialBillingMode" type="button" size="small">
<ARadio v-for="opt in officialBillingModeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
</ARadioGroup>
</div>
<!-- 四种模式分支:officialItems 保证至少一行(首行由 ensureOfficialFirstRow 兜底),
v-if 再防御一次空数组渲染 -->
<template v-if="officialItems.length > 0">
<!-- 官方按次定价 -->
<template v-if="officialBillingMode === 'per_request'">
<div class="grid grid-cols-2 gap-x-4">
<AFormItem label="官方按次单价">
<AInputNumber
v-model="officialItems[0].per_request_price"
:min="0"
:precision="6"
placeholder="官方每次调用价格"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 次</template>
</AInputNumber>
</AFormItem>
</div>
</template>
<!-- 官方按秒定价(数据源无按秒价,按官方公布价格手动填写) -->
<template v-else-if="officialBillingMode === 'per_second'">
<div class="per-second-card">
<div v-if="officialPerSecondRows.length" class="per-second-col-head">
<span>规格</span>
<span>每秒单价</span>
<span class="per-second-op-col"></span>
</div>
<div v-for="(row, index) in officialPerSecondRows" :key="index" class="per-second-row">
<AInput
v-model="row.spec"
placeholder="如 720p / 1080p / *(兜底)"
:max-length="32"
class="per-second-spec"
/>
<AInputNumber
v-model="row.price"
:min="0"
:precision="6"
placeholder="0"
class="per-second-price"
>
<template #suffix>{{ currencySymbol }} / 秒</template>
</AInputNumber>
<AButton
size="mini"
status="danger"
title="删除该规格"
@click="removeOfficialPerSecondRow(index)"
>−</AButton>
</div>
<div v-if="!officialPerSecondRows.length" class="per-second-empty">
数据源无按秒价格,请按官方公布价格手动填写
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<AButton size="small" type="outline" @click="addOfficialPerSecondRow()">+ 添加规格</AButton>
<AButton
v-for="preset in perSecondSpecPresets"
:key="preset"
size="mini"
@click="addOfficialPerSecondRow(preset)"
>{{ preset }}</AButton>
</div>
</template>
<!-- 官方阶梯定价(与我方阶梯同构:逐档 min/max/输入/输出/缓存价) -->
<template v-else-if="officialBillingMode === 'tiered'">
<div v-for="(tier, index) in officialItems" :key="index" class="tier-card">
<div class="tier-header">
<span class="tier-label">第 {{ index + 1 }} 档</span>
<AButton
size="mini"
status="danger"
:disabled="officialItems.length <= 1"
@click="removeOfficialTier(index)"
>删除</AButton>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="起始 Token">
<AInputNumber v-model="tier.min_tokens" :min="0" :precision="0" class="w-full" />
</AFormItem>
<AFormItem :label="index === officialItems.length - 1 ? '结束(无上限)' : '结束 Token'">
<AInputNumber v-if="index < officialItems.length - 1" v-model="tier.max_tokens" :min="0" :precision="0" class="w-full" />
<AInput v-else value="∞" disabled />
</AFormItem>
<AFormItem label="输入价格">
<AInputNumber v-model="tier.input_price" :min="0" :precision="6" placeholder="0" class="w-full">
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber v-model="tier.output_price" :min="0" :precision="6" placeholder="0" class="w-full">
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="grid grid-cols-2 gap-x-4 mt-1">
<AFormItem label="缓存读取价格">
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="6" class="w-full">
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="6" class="w-full">
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
<AButton type="dashed" long class="mt-1" @click="addOfficialTier">+ 添加档位</AButton>
</template>
<!-- 官方按量定价 -->
<template v-else>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="输入价格">
<AInputNumber
v-model="officialItems[0].input_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber
v-model="officialItems[0].output_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存读取">
<AInputNumber
v-model="officialItems[0].cache_read_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建">
<AInputNumber
v-model="officialItems[0].cache_creation_price"
:min="0"
:precision="6"
placeholder="0"
class="w-full"
>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
</div>
</template>
</template>
<!-- 官方的时段定价与参数倍率(与主定价一致:始终展示,不做折叠开关) -->
<PricingTimeSegmentsEditor
v-model:enabled="officialTimeEnabled"
:segments="officialTimeSegments"
:preview-base="officialPreviewBase"
title="时段定价(基准)"
hint="随折扣填入时原样复制到主定价(乘数不缩放)"
/>
<PricingParamRulesEditor
:rules="officialParamRules"
title="参数倍率(基准)"
hint="随折扣填入时原样复制到主定价(乘数不缩放)"
/>
</div>
<template #footer>
<!-- 底部提示占用现有 footer 插槽:左下角说明文字 + 右侧完成按钮 -->
<div class="official-modal-footer">
<span class="official-modal-footer-hint">
官方价不是计费依据,仅作折扣换算基准与「现价几折」展示;人工核对修正后随主弹窗「保存定价」一并落库。
</span>
<AButton type="primary" @click="officialModalVisible = false">完成</AButton>
</div>
</template>
</AModal>
</template>
<style 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);
}
.billing-mode-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.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);
}
/* 按秒定价:单卡片行式紧凑布局(规格 + 单价 + 删除一行排布) */
.per-second-card {
padding: 10px 12px;
background: var(--color-fill-1);
border: 1px solid var(--ta-border-light);
border-radius: 8px;
}
.per-second-col-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.per-second-row {
display: flex;
align-items: center;
gap: 8px;
}
.per-second-row + .per-second-row {
margin-top: 6px;
}
.per-second-spec {
flex: 1 1 40%;
min-width: 0;
}
.per-second-price {
flex: 1;
min-width: 0;
}
.per-second-op-col {
flex: 0 0 28px;
}
.per-second-empty {
font-size: 12px;
color: var(--ta-text-tertiary);
text-align: center;
padding: 6px 0;
}
/* 计费模式行右上角工具组:官方价状态 + 快速折扣录入 + 折扣重算 */
.billing-mode-tools {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
/* 官方价状态标签:颜色区分是否配置;可点击进二层弹窗设置 */
.official-quick {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.official-quick-tag {
cursor: pointer;
}
.official-quick-input {
width: 88px;
}
/* 官方价二层弹窗 footer:左下角提示说明 + 右侧完成按钮(复用 footer 插槽,非新增插槽) */
.official-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.official-modal-footer-hint {
min-width: 0;
font-size: 12px;
color: var(--ta-text-tertiary);
text-align: left;
}
@media (max-width: 639px) {
.official-modal-footer {
flex-direction: column-reverse;
align-items: stretch;
}
.official-modal-footer-hint {
text-align: center;
}
}
.official-modal-section {
margin-bottom: 20px;
}
.official-modal-section-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.official-modal-section-head h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--ta-text-primary);
}
.official-modal-section-hint {
font-size: 12px;
color: var(--ta-text-tertiary);
}
/* 官方价数据源参考价:浮窗触发按钮推到标题行右侧 */
.official-sources-trigger {
margin-left: auto;
}
/* 数据源参考价浮窗面板(宽度经 APopover content-style 设定,内容随触发元素渲染) */
.official-sources-panel {
max-height: 56vh;
overflow-y: auto;
}
.official-sources-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.official-sources-panel-title {
font-size: 13px;
font-weight: 600;
color: var(--ta-text-primary);
}
.official-reference-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 120px;
}
.official-loading-text {
font-size: 12px;
color: var(--ta-text-tertiary);
}
/* 官方价二层弹窗:模式切换行 */
.official-mode-row {
margin-bottom: 12px;
}
.official-source-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.official-source-card {
display: flex;
flex-direction: column;
min-width: 0;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-1);
box-shadow: var(--ta-shadow-card);
transition: border-color var(--ta-duration-fast), box-shadow var(--ta-duration-fast);
}
.official-source-card:hover {
border-color: var(--color-primary-4);
box-shadow: var(--ta-shadow-hover);
}
.official-source-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-height: 24px;
}
.official-source-meta {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.official-prices {
margin: 8px 0 10px;
}
.official-price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.official-price-label {
font-size: 13px;
color: var(--ta-text-secondary);
}
.official-price-value {
font-size: 13px;
font-weight: 500;
color: var(--ta-text-primary);
font-family: monospace;
}
.official-not-found {
display: flex;
align-items: center;
min-height: 76px;
font-size: 13px;
color: var(--ta-text-tertiary);
}
.official-not-found--error {
color: rgb(var(--danger-6));
}
@media (max-width: 639px) {
.official-source-grid {
grid-template-columns: 1fr;
}
}
</style>