feat: 视频计费增强与影子计费系统

This commit is contained in:
fawney19
2026-02-03 18:48:39 +08:00
parent ed68aebfb0
commit 00442c41fa
84 changed files with 6507 additions and 1678 deletions

View File

@@ -111,26 +111,26 @@
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="model-name"
for="model-display-name"
class="text-xs"
>模型名称 *</Label>
>名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
<div class="space-y-1.5">
<Label
for="model-display-name"
for="model-name"
class="text-xs"
>显示名称 *</Label>
>模型ID *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
</div>
@@ -147,105 +147,6 @@
@update:model-value="(v) => setConfigField('description', v || undefined)"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label
for="model-family"
class="text-xs"
>模型系列</Label>
<Input
id="model-family"
:model-value="form.config?.family || ''"
placeholder=" GPT-4Claude 3"
@update:model-value="(v) => setConfigField('family', v || undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-context-limit"
class="text-xs"
>上下文限制</Label>
<Input
id="model-context-limit"
type="number"
:model-value="form.config?.context_limit ?? ''"
placeholder=" 128000"
@update:model-value="(v) => setConfigField('context_limit', v ? Number(v) : undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-output-limit"
class="text-xs"
>输出限制</Label>
<Input
id="model-output-limit"
type="number"
:model-value="form.config?.output_limit ?? ''"
placeholder=" 8192"
@update:model-value="(v) => setConfigField('output_limit', v ? Number(v) : undefined)"
/>
</div>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.streaming !== false"
class="rounded"
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.vision === true"
class="rounded"
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.function_calling === true"
class="rounded"
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.extended_thinking === true"
class="rounded"
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>思考</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.image_generation === true"
class="rounded"
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>生图</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
@@ -254,7 +155,7 @@
class="space-y-2"
>
<h4 class="font-medium text-sm">
Key 能力支持
模型偏好
</h4>
<div class="flex flex-wrap gap-2">
<label
@@ -296,6 +197,94 @@
/>
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
</div>
<!-- 视频计费(分辨率 × 时长) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">视频计费(分辨率 × 时长)</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="fillVideoResolutionPricePreset('common')"
>
通用
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="fillVideoResolutionPricePreset('sora')"
>
Sora
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="fillVideoResolutionPricePreset('veo')"
>
Veo
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="addVideoResolutionPriceRow"
>
<Plus class="w-3.5 h-3.5 mr-0.5" />
自定义
</Button>
</div>
<div
v-if="videoResolutionPrices.length > 0"
class="rounded-lg border border-border overflow-hidden"
>
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价($/秒)</span>
<span></span>
</div>
<div class="divide-y divide-border">
<div
v-for="(row, idx) in videoResolutionPrices"
:key="idx"
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
>
<Input
v-model="row.resolution"
class="h-7 text-sm"
placeholder=" 720p"
/>
<Input
:model-value="row.price_per_second ?? ''"
type="number"
step="0.0001"
min="0"
class="h-7 text-sm"
placeholder="0"
@update:model-value="(v) => row.price_per_second = parseNumberInput(v, { allowFloat: true })"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="removeVideoResolutionPriceRow(idx)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</div>
</section>
</form>
</div>
@@ -332,15 +321,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import {
Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen,
Search, ChevronRight
Loader2, Layers, SquarePen,
Search, ChevronRight, Plus, Trash2
} from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { log } from '@/utils/logger'
import TieredPricingEditor from './TieredPricingEditor.vue'
import {
@@ -455,6 +444,31 @@ function toggleProvider(providerId: string) {
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
'common' | 'sora' | 'veo',
VideoResolutionPriceRow[]
> = {
common: [
{ resolution: '480p', price_per_second: 0 },
{ resolution: '720p', price_per_second: 0 },
{ resolution: '1080p', price_per_second: 0 },
{ resolution: '4k', price_per_second: 0 },
],
sora: [
{ resolution: '720x1080', price_per_second: 0 },
{ resolution: '1024x1792', price_per_second: 0 },
],
veo: [
{ resolution: '720p', price_per_second: 0 },
{ resolution: '1080p', price_per_second: 0 },
{ resolution: '4k', price_per_second: 0 },
],
}
interface FormData {
name: string
display_name: string
@@ -489,29 +503,137 @@ function setConfigField(key: string, value: any) {
}
}
// Key 能力选项
const availableCapabilities = ref<CapabilityDefinition[]>([])
function getNested(obj: any, path: string): any {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
let cur: any = obj
for (const p of parts) {
if (!cur || typeof cur !== 'object') return undefined
cur = cur[p]
}
return cur
}
// 加载模型列表
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
} finally {
loading.value = false
function setNested(obj: any, path: string, value: any) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: any = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') {
cur[p] = {}
}
cur = cur[p]
}
cur[parts[parts.length - 1]] = value
}
function deleteNested(obj: any, path: string) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: any = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') return
cur = cur[p]
}
delete cur[parts[parts.length - 1]]
}
function pruneEmptyBillingConfig() {
const cfg = form.value.config
if (!cfg || typeof cfg !== 'object') return
const billing = cfg.billing
if (!billing || typeof billing !== 'object') return
const video = billing.video
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
delete billing.video
}
if (Object.keys(billing).length === 0) {
delete cfg.billing
}
}
// 打开对话框时加载数据
watch(() => props.open, (isOpen) => {
if (isOpen && !props.model) {
loadModels()
/**
* Normalize resolution key:
* - lowercase, remove spaces, × → x
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
*/
function normalizeResolutionKey(raw: string): string {
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
// Check if it's WxH format (e.g., 1080x720)
const match = k.match(/^(\d+)x(\d+)$/)
if (match) {
const a = parseInt(match[1], 10)
const b = parseInt(match[2], 10)
// Sort: smaller dimension first
k = a <= b ? `${a}x${b}` : `${b}x${a}`
}
})
return k
}
function loadVideoPricingFromConfig() {
const cfg = form.value.config || {}
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
// 按分辨率从低到高排序
const sortedEntries = sortResolutionEntries(Object.entries(raw))
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
resolution: String(k),
price_per_second: typeof v === 'number' ? v : undefined,
}))
} else {
videoResolutionPrices.value = []
}
}
function applyVideoPricingToConfig() {
if (!form.value.config) {
form.value.config = {}
}
const cfg = form.value.config
// Clean legacy keys
deleteNested(cfg, 'billing.video.price_per_second')
deleteNested(cfg, 'billing.video.resolution_multipliers')
// resolution/size prices (normalized: 1080x720 → 720x1080)
const map: Record<string, number> = {}
for (const row of videoResolutionPrices.value) {
const k = normalizeResolutionKey(row.resolution || '')
const v = row.price_per_second
if (!k) continue
if (typeof v !== 'number' || Number.isNaN(v)) continue
map[k] = v
}
if (Object.keys(map).length > 0) {
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
} else {
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
}
pruneEmptyBillingConfig()
}
function addVideoResolutionPriceRow() {
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
}
function removeVideoResolutionPriceRow(idx: number) {
videoResolutionPrices.value.splice(idx, 1)
}
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
resolution: r.resolution,
price_per_second: r.price_per_second,
}))
}
// Key 能力选项
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 加载可用能力列表
async function loadCapabilities() {
@@ -539,6 +661,27 @@ onMounted(() => {
loadCapabilities()
})
// 加载模型列表
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
} finally {
loading.value = false
}
}
// 打开对话框时加载数据
watch(() => props.open, (isOpen) => {
if (isOpen && !props.model) {
loadModels()
}
})
// 选择模型并填充表单
function selectModel(model: ModelsDevModelItem) {
selectedModel.value = model
@@ -565,6 +708,7 @@ function selectModel(model: ModelsDevModelItem) {
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
loadVideoPricingFromConfig()
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
tieredPricing.value = {
@@ -596,6 +740,7 @@ function handleLogoError(event: Event) {
function resetForm() {
form.value = defaultForm()
tieredPricing.value = null
videoResolutionPrices.value = []
searchQuery.value = ''
selectedModel.value = null
expandedProvider.value = null
@@ -621,6 +766,7 @@ function loadModelData() {
tieredPricing.value = props.model.default_tiered_pricing
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
: null
loadVideoPricingFromConfig()
}
// 使用 useFormDialog 统一处理对话框逻辑
@@ -635,7 +781,7 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
async function handleSubmit() {
if (!form.value.name || !form.value.display_name) {
showError('请填写模型名称和显示名称')
showError('请填写模型ID和名称')
return
}
@@ -647,6 +793,9 @@ async function handleSubmit() {
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
// Apply billing (video) pricing into config before cleaning/submitting.
applyVideoPricingToConfig()
// 清理空的 config
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
? form.value.config

View File

@@ -139,100 +139,6 @@
</div>
</div>
<!-- 模型能力 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">
模型能力
</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Zap class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Streaming
</p>
<p class="text-xs text-muted-foreground">
流式输出
</p>
</div>
<Badge
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Image class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Image Generation
</p>
<p class="text-xs text-muted-foreground">
图像生成
</p>
</div>
<Badge
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Eye class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Vision
</p>
<p class="text-xs text-muted-foreground">
视觉理解
</p>
</div>
<Badge
:variant="model.config?.vision === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.config?.vision === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Wrench class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Tool Use
</p>
<p class="text-xs text-muted-foreground">
工具调用
</p>
</div>
<Badge
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Brain class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Extended Thinking
</p>
<p class="text-xs text-muted-foreground">
深度思考
</p>
</div>
<Badge
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
</Badge>
</div>
</div>
</div>
<!-- 模型偏好 -->
<div
v-if="model.supported_capabilities && model.supported_capabilities.length > 0"
@@ -307,6 +213,44 @@
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div>
<!-- 视频分辨率计费 -->
<div
v-if="hasVideoPricing"
class="space-y-2"
>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Video class="w-4 h-4" />
<span>视频分辨率计费 ({{ videoPricingEntries.length }} )</span>
</div>
<div class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">
分辨率
</TableHead>
<TableHead class="text-xs h-9 text-right">
单价 ($/)
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="[res, price] in videoPricingEntries"
:key="res"
class="text-xs"
>
<TableCell class="py-2">
{{ res }}
</TableCell>
<TableCell class="py-2 text-right font-mono">
${{ (price as number).toFixed(4) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
<!-- 多阶梯计费展示 -->
@@ -389,6 +333,44 @@
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div>
<!-- 视频分辨率计费多阶梯时也显示 -->
<div
v-if="hasVideoPricing"
class="space-y-2"
>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Video class="w-4 h-4" />
<span>视频分辨率计费 ({{ videoPricingEntries.length }} )</span>
</div>
<div class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">
分辨率
</TableHead>
<TableHead class="text-xs h-9 text-right">
单价 ($/)
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="[res, price] in videoPricingEntries"
:key="res"
class="text-xs"
>
<TableCell class="py-2">
{{ res }}
</TableCell>
<TableCell class="py-2 text-right font-mono">
${{ (price as number).toFixed(4) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
</div>
@@ -454,20 +436,16 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import {
X,
Eye,
Wrench,
Brain,
Zap,
Image,
Building2,
Edit,
Power,
Copy,
Layers,
BarChart3
BarChart3,
Video
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
@@ -483,6 +461,7 @@ import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import RoutingTab from './RoutingTab.vue'
import ModelMappingsTab from './ModelMappingsTab.vue'
import { sortResolutionEntries } from '@/utils/form'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
@@ -570,6 +549,19 @@ function getCapabilityDisplayName(capName: string): string {
return cap?.display_name || capName
}
// 检测是否有视频分辨率计费配置
const hasVideoPricing = computed(() => {
const priceByResolution = props.model?.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
})
// 获取视频分辨率计费条目(按分辨率从低到高排序)
const videoPricingEntries = computed(() => {
const priceByResolution = props.model?.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return []
return sortResolutionEntries(Object.entries(priceByResolution))
})
const detailTab = ref('basic')
// 处理背景点击

View File

@@ -2,7 +2,7 @@
<Dialog
:model-value="open"
:title="isEditing ? '编辑模型配置' : '添加模型'"
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
:description="isEditing ? '修改模型价格配置' : '为此 Provider 添加模型实现'"
:icon="isEditing ? SquarePen : Layers"
size="xl"
@update:model-value="handleClose"
@@ -86,67 +86,97 @@
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
</div>
</div>
<!-- 能力配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
能力配置
</h4>
<!-- 视频计费(可选覆盖) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">视频计费(可选覆盖)</div>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
v-model="form.supports_streaming"
type="checkbox"
:indeterminate="form.supports_streaming === undefined"
class="rounded"
<div class="flex items-center gap-1.5 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
>
<Zap class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">流式输出</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
v-model="form.supports_image_generation"
type="checkbox"
:indeterminate="form.supports_image_generation === undefined"
class="rounded"
通用
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
>
<Image class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">图像生成</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
v-model="form.supports_vision"
type="checkbox"
:indeterminate="form.supports_vision === undefined"
class="rounded"
Sora
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
>
<Eye class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">视觉理解</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
v-model="form.supports_function_calling"
type="checkbox"
:indeterminate="form.supports_function_calling === undefined"
class="rounded"
Veo
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
>
<Wrench class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">工具调用</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
v-model="form.supports_extended_thinking"
type="checkbox"
:indeterminate="form.supports_extended_thinking === undefined"
class="rounded"
>
<Brain class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">深度思考</span>
</label>
<Plus class="w-3.5 h-3.5 mr-0.5" />
自定义
</Button>
</div>
<div
v-if="videoResolutionPrices.length > 0"
class="rounded-lg border border-border overflow-hidden"
>
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价($/秒)</span>
<span></span>
</div>
<div class="divide-y divide-border">
<div
v-for="(row, idx) in videoResolutionPrices"
:key="idx"
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
>
<Input
v-model="row.resolution"
class="h-7 text-sm"
placeholder=" 720p"
@update:model-value="() => { configTouched = true }"
/>
<Input
:model-value="row.price_per_second ?? ''"
type="number"
step="0.0001"
min="0"
class="h-7 text-sm"
placeholder="0"
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</div>
</div>
</form>
<template #footer>
@@ -172,7 +202,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next'
import { Loader2, Layers, SquarePen, Plus, Trash2 } from 'lucide-vue-next'
import {
Dialog,
Button,
@@ -185,7 +215,7 @@ import {
SelectItem,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput } from '@/utils/form'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
@@ -240,9 +270,36 @@ const tieredPricingModified = ref(false)
// 保存原始配置用于比较
const originalTieredPricing = ref<string>('')
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
const configTouched = ref(false)
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
'common' | 'sora' | 'veo',
VideoResolutionPriceRow[]
> = {
common: [
{ resolution: '480p', price_per_second: 0 },
{ resolution: '720p', price_per_second: 0 },
{ resolution: '1080p', price_per_second: 0 },
{ resolution: '4k', price_per_second: 0 },
],
sora: [
{ resolution: '720x1080', price_per_second: 0 },
{ resolution: '1024x1792', price_per_second: 0 },
],
veo: [
{ resolution: '720p', price_per_second: 0 },
{ resolution: '1080p', price_per_second: 0 },
{ resolution: '4k', price_per_second: 0 },
],
}
const form = ref({
global_model_id: '',
price_per_request: undefined as number | undefined,
config: {} as Record<string, any>,
// 能力配置
supports_vision: undefined as boolean | undefined,
supports_function_calling: undefined as boolean | undefined,
@@ -258,9 +315,13 @@ watch(() => props.open, async (newOpen) => {
resetForm()
if (props.editingModel) {
// 编辑模式:填充表单
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
form.value = {
global_model_id: props.editingModel.global_model_id || '',
price_per_request: props.editingModel.price_per_request ?? undefined,
// 显示有效的按次计费价格(继承自全局模型)
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
supports_vision: props.editingModel.supports_vision ?? undefined,
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
@@ -268,6 +329,8 @@ watch(() => props.open, async (newOpen) => {
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 从有效配置中加载视频费用
loadVideoPricingFromConfig(effectiveConfig)
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
if (pricing) {
@@ -314,6 +377,7 @@ function resetForm() {
form.value = {
global_model_id: '',
price_per_request: undefined,
config: {},
supports_vision: undefined,
supports_function_calling: undefined,
supports_streaming: undefined,
@@ -321,12 +385,149 @@ function resetForm() {
supports_image_generation: undefined,
is_active: true
}
configTouched.value = false
videoResolutionPrices.value = []
tieredPricing.value = null
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
}
function getNested(obj: any, path: string): any {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
let cur: any = obj
for (const p of parts) {
if (!cur || typeof cur !== 'object') return undefined
cur = cur[p]
}
return cur
}
function setNested(obj: any, path: string, value: any) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: any = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') {
cur[p] = {}
}
cur = cur[p]
}
cur[parts[parts.length - 1]] = value
}
function deleteNested(obj: any, path: string) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: any = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') return
cur = cur[p]
}
delete cur[parts[parts.length - 1]]
}
function pruneEmptyBillingConfig(cfg: Record<string, any>) {
const billing = cfg.billing
if (!billing || typeof billing !== 'object') return
const video = billing.video
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
delete billing.video
}
if (Object.keys(billing).length === 0) {
delete cfg.billing
}
}
/**
* Normalize resolution key:
* - lowercase, remove spaces, × → x
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
*/
function normalizeResolutionKey(raw: string): string {
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
// Check if it's WxH format (e.g., 1080x720)
const match = k.match(/^(\d+)x(\d+)$/)
if (match) {
const a = parseInt(match[1], 10)
const b = parseInt(match[2], 10)
// Sort: smaller dimension first
k = a <= b ? `${a}x${b}` : `${b}x${a}`
}
return k
}
function loadVideoPricingFromConfig(cfg: Record<string, any>) {
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
// 按分辨率从低到高排序
const sortedEntries = sortResolutionEntries(Object.entries(raw))
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
resolution: String(k),
price_per_second: typeof v === 'number' ? v : undefined,
}))
} else {
videoResolutionPrices.value = []
}
}
function applyVideoPricingToConfig(cfg: Record<string, any>) {
// Clean legacy keys
deleteNested(cfg, 'billing.video.price_per_second')
deleteNested(cfg, 'billing.video.resolution_multipliers')
// resolution/size prices (normalized: 1080x720 → 720x1080)
const map: Record<string, number> = {}
for (const row of videoResolutionPrices.value) {
const k = normalizeResolutionKey(row.resolution || '')
const v = row.price_per_second
if (!k) continue
if (typeof v !== 'number' || Number.isNaN(v)) continue
map[k] = v
}
if (Object.keys(map).length > 0) {
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
} else {
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
}
pruneEmptyBillingConfig(cfg)
}
function addVideoResolutionPriceRow() {
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
}
function removeVideoResolutionPriceRow(idx: number) {
videoResolutionPrices.value.splice(idx, 1)
}
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
resolution: r.resolution,
price_per_second: r.price_per_second,
}))
}
function copyVideoPricingFromSelectedGlobal() {
const gm = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const cfg = gm?.config || {}
if (cfg && typeof cfg === 'object') {
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
videoResolutionPrices.value = Object.entries(raw).map(([k, v]) => ({
resolution: String(k),
price_per_second: typeof v === 'number' ? v : undefined,
}))
}
}
configTouched.value = true
}
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
@@ -370,12 +571,19 @@ async function handleSubmit() {
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
// Apply billing (video) pricing into config.
applyVideoPricingToConfig(form.value.config)
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
? form.value.config
: undefined
if (isEditing.value && props.editingModel) {
// 编辑模式
// 注意:使用 null 而不是 undefined 来显式清空字段undefined 会被 JSON 序列化忽略)
await updateModel(props.providerId, props.editingModel.id, {
tiered_pricing: finalTieredPricing,
price_per_request: form.value.price_per_request ?? null,
config: cleanConfig || null,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,
@@ -393,6 +601,7 @@ async function handleSubmit() {
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
tiered_pricing: tieredPricingModified.value ? finalTieredPricing : undefined,
price_per_request: form.value.price_per_request,
config: configTouched.value ? cleanConfig : undefined,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,

View File

@@ -103,8 +103,18 @@
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/
</span>
</template>
<!-- 视频费用计费 -->
<template v-if="hasVideoPricing(model)">
<span class="text-muted-foreground text-right">视频:</span>
<span
class="font-mono font-semibold"
:title="getVideoPricingTooltip(model)"
>
{{ getVideoPricingDisplay(model) }}
</span>
</template>
<!-- 无计费配置 -->
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model) && !hasVideoPricing(model)">
<span class="text-muted-foreground"></span>
</template>
</div>
@@ -217,6 +227,7 @@ import {
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { sortResolutionEntries } from '@/utils/form'
import {
getProviderModels,
getProviderMappingPreview,
@@ -345,6 +356,38 @@ function hasRequestPricing(model: Model): boolean {
return requestPrice != null && requestPrice > 0
}
// 检查是否有视频分辨率计费配置
function hasVideoPricing(model: Model): boolean {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频计费的显示文本
function getVideoPricingDisplay(model: Model): string {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
const entries = sortResolutionEntries(Object.entries(priceByResolution))
if (entries.length === 0) return ''
// 获取最低分辨率和价格
const [firstRes, firstPrice] = entries[0]
const priceStr = `${firstRes} $${(firstPrice as number).toFixed(2)}/s`
if (entries.length > 1) {
return `${priceStr} [${entries.length}种]`
}
return priceStr
}
// 获取视频计费详情的 tooltip
function getVideoPricingTooltip(model: Model): string {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
const entries = sortResolutionEntries(Object.entries(priceByResolution))
return entries.map(([res, price]) => `${res}: $${(price as number).toFixed(4)}/s`).join('\n')
}
// 获取状态指示灯样式
function getStatusIndicatorClass(model: Model): string {
if (!model.is_active) {

View File

@@ -160,24 +160,40 @@
<!-- 分隔线 -->
<Separator class="mb-4" />
<!-- 统一使用阶梯计费展示方式 -->
<!-- 单价信息行 -->
<!-- ========== 1. 费用聚合计算 ========== -->
<div class="text-xs text-muted-foreground mb-3 flex items-center gap-2 flex-wrap">
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="text-foreground">|</span>
<span>总输入上下文: <span class="font-mono font-medium text-foreground">{{ formatNumber(totalInputContext) }}</span></span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<Badge
v-if="displayTiers.length > 1"
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
命中第 {{ currentTierIndex + 1 }}
</Badge>
<span class="font-mono text-foreground">
总费用 = Token费用 <span class="font-medium">${{ tokenCostTotal.toFixed(6) }}</span>
<template v-if="perRequestCost > 0">
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
</template>
<template v-if="videoCostTotal > 0">
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
</template>
</span>
</div>
<!-- 统一使用阶梯展示格式 -->
<div class="space-y-2">
<!-- ========== 2. Token分阶段成本 ========== -->
<div
v-if="hasTokenCost"
class="space-y-2 mb-3"
>
<!-- 阶梯标题 -->
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground">Token 计费</span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<Badge
v-if="displayTiers.length > 1"
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
命中第 {{ currentTierIndex + 1 }}
</Badge>
</div>
<!-- 阶梯展示 -->
<div
v-for="(tier, index) in displayTiers"
:key="index"
@@ -238,7 +254,7 @@
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).toFixed(6) }}</span>
</div>
</div>
<!-- 缓存创建 缓存读取始终显示 -->
<!-- 缓存创建 缓存读取 -->
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">缓存创建</span>
@@ -255,29 +271,90 @@
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
</div>
</div>
<!-- 按次计费 -->
<div
v-if="detail.request_cost"
class="flex items-center"
>
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">按次计费</span>
<span class="text-sm font-semibold font-mono flex-1 text-center" />
<span class="text-xs font-mono">${{ detail.request_cost.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
class="h-4 mx-4 invisible"
/>
<div class="flex items-center flex-1 invisible">
<span class="text-xs text-muted-foreground w-[56px]">占位</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
<span class="text-xs font-mono">$0.000000</span>
</div>
</div>
</template>
</div>
</div>
<!-- ========== 3. 按次计费独立隔离 ========== -->
<div
v-if="perRequestCost > 0 && !detail.video_billing"
class="rounded-lg p-3 bg-amber-500/5 border border-amber-500/30 mb-3"
>
<div class="flex items-center justify-between text-xs mb-2">
<span class="font-medium text-amber-600 dark:text-amber-400">按次计费</span>
<span
v-if="detail.price_per_request"
class="text-muted-foreground"
>
单价 ${{ detail.price_per_request.toFixed(6) }}/
</span>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
</div>
</div>
</div>
<!-- ========== 4. 视频/图像/音频计费独立隔离与Token计费风格一致 ========== -->
<div
v-if="detail.video_billing"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
>
<!-- 标题行与阶梯标题行风格一致 -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
<div class="flex items-center gap-2">
<span class="font-medium text-primary">
{{ getTaskTypeLabel(detail.video_billing.task_type) }}
</span>
<span
v-if="detail.video_billing.resolution"
class="text-muted-foreground"
>
{{ detail.video_billing.resolution }}
</span>
</div>
<!-- 费用计算公式 -->
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span
v-if="detail.video_billing.duration_seconds && detail.video_billing.video_price_per_second"
class="font-mono"
>
{{ detail.video_billing.duration_seconds.toFixed(1) }}s × ${{ detail.video_billing.video_price_per_second.toFixed(4) }}/s = ${{ videoCostTotal.toFixed(6) }}
</span>
<span
v-else-if="detail.video_billing.video_price_per_second"
class="font-mono"
>
${{ detail.video_billing.video_price_per_second.toFixed(4) }}/
</span>
</div>
</div>
<!-- 费用详情与Token详情行风格一致 -->
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">
{{ detail.video_billing.task_type === 'video' ? '时长' : detail.video_billing.task_type === 'audio' ? '时长' : '数量' }}
</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">
{{ detail.video_billing.duration_seconds ? formatDuration(detail.video_billing.duration_seconds) : '1' }}
</span>
<span class="text-xs font-mono">${{ videoCostTotal.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
class="h-4 mx-4 invisible"
/>
<div class="flex items-center flex-1 invisible">
<span class="text-xs text-muted-foreground w-[56px]">占位</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
<span class="text-xs font-mono">$0.000000</span>
</div>
</div>
</div>
</div>
</Card>
@@ -698,6 +775,43 @@ const totalInputContext = computed(() => {
return input + cacheCreation + cacheRead
})
// Token 费用总计
const tokenCostTotal = computed(() => {
if (!detail.value) return 0
const inputCost = detail.value.cost?.input || detail.value.input_cost || 0
const outputCost = detail.value.cost?.output || detail.value.output_cost || 0
const cacheCreationCost = detail.value.cache_creation_cost || 0
const cacheReadCost = detail.value.cache_read_cost || 0
return inputCost + outputCost + cacheCreationCost + cacheReadCost
})
// 按次计费费用(非视频任务时)
const perRequestCost = computed(() => {
if (!detail.value) return 0
// 视频任务的 request_cost 实际上是视频费用,不算按次
if (detail.value.video_billing) return 0
return detail.value.request_cost || 0
})
// 视频/图像/音频费用
const videoCostTotal = computed(() => {
if (!detail.value?.video_billing) return 0
return detail.value.video_billing.video_cost
|| detail.value.video_billing.cost
|| detail.value.request_cost
|| 0
})
// 是否有 Token 费用(用于决定是否显示 Token 计费区块)
const hasTokenCost = computed(() => {
if (!detail.value) return false
const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0
const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0
const cacheCreation = detail.value.cache_creation_input_tokens || 0
const cacheRead = detail.value.cache_read_input_tokens || 0
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
})
const tabs = [
{ name: 'request-headers', label: '请求头' },
{ name: 'request-body', label: '请求体' },
@@ -817,6 +931,35 @@ function formatDateTime(dateStr: string | null | undefined): string {
})
}
// 格式化视频/音频时长
function formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
}
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins < 60) {
return `${mins}m ${secs.toFixed(0)}s`
}
const hours = Math.floor(mins / 60)
const remainMins = mins % 60
return `${hours}h ${remainMins}m`
}
// 获取任务类型标签
function getTaskTypeLabel(taskType: string): string {
switch (taskType) {
case 'video':
return '视频生成'
case 'image':
return '图像生成'
case 'audio':
return '音频生成'
default:
return taskType
}
}
function formatApiFormat(format: string | null | undefined): string {
if (!format) return '-'
const raw = (format || '').trim()