feat(channel): 通配符定价匹配 + OpenAI BillingModelSource + 按次价格校验 + 用户端计费模式展示

- 定价查找支持通配符(suffix *),最长前缀优先匹配
- 模型限制(restrict_models)同样支持通配符匹配
- OpenAI 网关接入渠道映射/BillingModelSource/模型限制
- 按次/图片计费模式创建时强制要求价格或层级(前后端)
- 用户使用记录列表增加计费模式 badge 列
This commit is contained in:
erio
2026-03-31 00:23:45 +08:00
parent 0fbc9a44d3
commit 8d03c52e15
11 changed files with 255 additions and 22 deletions

View File

@@ -1789,6 +1789,7 @@ export default {
noTiersYet: 'No tiers yet. Click add to configure per-request pricing.',
noPricingRules: 'No pricing rules yet. Click "Add" to create one.',
perRequestPrice: 'Price per Request',
perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode',
tierLabel: 'Tier',
resolution: 'Resolution',
modelMapping: 'Model Mapping',

View File

@@ -1869,6 +1869,7 @@ export default {
noTiersYet: '暂无层级,点击添加配置按次计费价格',
noPricingRules: '暂无定价规则,点击"添加"创建',
perRequestPrice: '单次价格',
perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级',
tierLabel: '层级',
resolution: '分辨率',
modelMapping: '模型映射',

View File

@@ -876,6 +876,19 @@ async function handleSubmit() {
return
}
// 校验 per_request/image 模式必须有价格
for (const section of form.platforms) {
for (const entry of section.model_pricing) {
if (entry.models.length === 0) continue
if ((entry.billing_mode === 'per_request' || entry.billing_mode === 'image') &&
(entry.per_request_price == null || entry.per_request_price === '') &&
(!entry.intervals || entry.intervals.length === 0)) {
appStore.showError(t('admin.channels.perRequestPriceRequired', '按次/图片计费模式必须设置默认价格或至少一个计费层级'))
return
}
}
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
console.log('[handleSubmit] model_pricing to send:', JSON.stringify(model_pricing))

View File

@@ -181,6 +181,13 @@
</span>
</template>
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
@@ -525,6 +532,7 @@ const columns = computed<Column[]>(() => [
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
@@ -615,6 +623,18 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
const getBillingModeLabel = (mode: string | null | undefined): string => {
if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
if (mode === 'image') return t('admin.usage.billingModeImage')
return t('admin.usage.billingModeToken')
}
const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200'
if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
const getRequestTypeExportText = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log)
if (requestType === 'ws_v2') return 'WS'
@@ -804,6 +824,7 @@ const exportToCSV = async () => {
'Reasoning Effort',
'Inbound Endpoint',
'Type',
'Billing Mode',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
@@ -822,6 +843,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '',
getRequestTypeExportText(log),
getBillingModeLabel(log.billing_mode),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,