mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 视频计费增强与影子计费系统
This commit is contained in:
@@ -616,5 +616,5 @@ export const adminApi = {
|
||||
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
||||
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||
return response.data
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AsyncTaskDetail extends AsyncTaskItem {
|
||||
video_urls: string[] | null
|
||||
thumbnail_url: string | null
|
||||
video_size_bytes: number | null
|
||||
video_duration_seconds: number | null // 实际视频时长(秒)
|
||||
video_expires_at: string | null
|
||||
stored_video_path: string | null
|
||||
storage_provider: string | null
|
||||
|
||||
@@ -99,6 +99,19 @@ export interface ProviderStatusResponse {
|
||||
providers: ProviderStatus[]
|
||||
}
|
||||
|
||||
// 视频/图像/音频计费信息
|
||||
export interface VideoBilling {
|
||||
task_type: 'video' | 'image' | 'audio'
|
||||
duration_seconds?: number // 视频时长(秒)
|
||||
resolution?: string // 分辨率
|
||||
video_price_per_second?: number // 每秒单价
|
||||
video_cost?: number // 视频费用
|
||||
cost?: number // 总费用
|
||||
rule_name?: string // 计费规则名称
|
||||
expression?: string // 计费公式
|
||||
status?: string // 计费状态
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
@@ -187,6 +200,8 @@ export interface RequestDetail {
|
||||
}>
|
||||
}>
|
||||
} | null
|
||||
// 视频/图像/音频计费信息
|
||||
video_billing?: VideoBilling | null
|
||||
}
|
||||
|
||||
export interface ModelBreakdown {
|
||||
|
||||
@@ -13,23 +13,23 @@ export const API_FORMATS = {
|
||||
|
||||
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
|
||||
|
||||
// API 格式显示名称映射(按品牌分组:API 在前,CLI 在后)
|
||||
// API 格式显示名称映射(按品牌分组:Chat 在前,CLI/Video 在后)
|
||||
export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
[API_FORMATS.CLAUDE]: 'Claude',
|
||||
[API_FORMATS.CLAUDE]: 'Claude Chat',
|
||||
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
||||
[API_FORMATS.OPENAI]: 'OpenAI',
|
||||
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
||||
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
||||
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
||||
[API_FORMATS.GEMINI]: 'Gemini',
|
||||
[API_FORMATS.GEMINI]: 'Gemini Chat',
|
||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
|
||||
// legacy 兼容(仅用于展示历史数据)
|
||||
CLAUDE: 'Claude',
|
||||
CLAUDE: 'Claude Chat',
|
||||
CLAUDE_CLI: 'Claude CLI',
|
||||
OPENAI: 'OpenAI',
|
||||
OPENAI: 'OpenAI Chat',
|
||||
OPENAI_CLI: 'OpenAI CLI',
|
||||
OPENAI_VIDEO: 'OpenAI Video',
|
||||
GEMINI: 'Gemini',
|
||||
GEMINI: 'Gemini Chat',
|
||||
GEMINI_CLI: 'Gemini CLI',
|
||||
GEMINI_VIDEO: 'Gemini Video',
|
||||
}
|
||||
@@ -418,6 +418,7 @@ export interface Model {
|
||||
global_model_id?: string // 关联的 GlobalModel ID
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
config?: Record<string, any> | null // 额外配置(如 billing/video 等)
|
||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
@@ -443,6 +444,8 @@ export interface Model {
|
||||
// GlobalModel 信息(从后端 join 获取)
|
||||
global_model_name?: string
|
||||
global_model_display_name?: string
|
||||
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||
effective_config?: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface ModelCreate {
|
||||
@@ -475,6 +478,7 @@ export interface ModelUpdate {
|
||||
supports_image_generation?: boolean
|
||||
is_active?: boolean
|
||||
is_available?: boolean
|
||||
config?: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
|
||||
27
frontend/src/components/ui/popover/Popover.vue
Normal file
27
frontend/src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverRoot } from 'radix-vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
modal?: boolean
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
modal: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
:default-open="props.defaultOpen"
|
||||
:open="props.open"
|
||||
:modal="props.modal"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<slot />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
39
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
39
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverContent, PopoverPortal } from 'radix-vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
}>(), {
|
||||
align: 'center',
|
||||
side: 'bottom',
|
||||
sideOffset: 4,
|
||||
alignOffset: 0,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
:class="cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class
|
||||
)"
|
||||
:align="props.align"
|
||||
:side="props.side"
|
||||
:side-offset="props.sideOffset"
|
||||
:align-offset="props.alignOffset"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
13
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
13
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger } from 'radix-vue'
|
||||
|
||||
defineProps<{
|
||||
asChild?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger :as-child="asChild">
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
3
frontend/src/components/ui/popover/index.ts
Normal file
3
frontend/src/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Popover } from './Popover.vue'
|
||||
export { default as PopoverTrigger } from './PopoverTrigger.vue'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
@@ -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-4、Claude 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
|
||||
|
||||
@@ -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')
|
||||
|
||||
// 处理背景点击
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -865,12 +865,12 @@ export const MOCK_SYSTEM_CONFIGS = [
|
||||
|
||||
export const MOCK_API_FORMATS = {
|
||||
formats: [
|
||||
{ value: 'claude:chat', label: 'Claude', default_path: '/v1/messages', aliases: [] },
|
||||
{ value: 'claude:chat', label: 'Claude Chat', default_path: '/v1/messages', aliases: [] },
|
||||
{ value: 'claude:cli', label: 'Claude CLI', default_path: '/v1/messages', aliases: [] },
|
||||
{ value: 'openai:chat', label: 'OpenAI', default_path: '/v1/chat/completions', aliases: [] },
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat', default_path: '/v1/chat/completions', aliases: [] },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI', default_path: '/responses', aliases: [] },
|
||||
{ value: 'openai:video', label: 'OpenAI Video', default_path: '/v1/videos', aliases: [] },
|
||||
{ value: 'gemini:chat', label: 'Gemini', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
||||
{ value: 'gemini:chat', label: 'Gemini Chat', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
||||
{ value: 'gemini:cli', label: 'Gemini CLI', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
||||
{ value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] }
|
||||
]
|
||||
|
||||
@@ -130,3 +130,50 @@ export function createNumberInputHandler<T extends Record<string, any>>(
|
||||
(obj as any)[field] = parseNumberInput(value, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分辨率的排序权重(用于从低到高排序)
|
||||
* 支持的格式:
|
||||
* - NNNp 格式:480p, 720p, 1080p, 2160p
|
||||
* - 4k/8k 格式:4k -> 2160, 8k -> 4320
|
||||
* - WxH 格式:720x1080 -> 按像素总数排序
|
||||
*
|
||||
* @param resolution - 分辨率字符串
|
||||
* @returns 排序权重(数字越大分辨率越高)
|
||||
*/
|
||||
export function getResolutionSortWeight(resolution: string): number {
|
||||
const normalized = (resolution || '').trim().toLowerCase()
|
||||
|
||||
// 4k/8k 格式
|
||||
if (normalized === '4k') return 2160 * 2160
|
||||
if (normalized === '8k') return 4320 * 4320
|
||||
|
||||
// NNNp 格式(如 480p, 720p, 1080p)
|
||||
const pMatch = normalized.match(/^(\d+)p$/)
|
||||
if (pMatch) {
|
||||
const height = parseInt(pMatch[1], 10)
|
||||
// 假设 16:9 宽高比计算像素数
|
||||
return height * height * (16 / 9)
|
||||
}
|
||||
|
||||
// WxH 格式(如 720x1080, 1024x1792)
|
||||
const wxhMatch = normalized.replace(/×/g, 'x').match(/^(\d+)x(\d+)$/)
|
||||
if (wxhMatch) {
|
||||
const w = parseInt(wxhMatch[1], 10)
|
||||
const h = parseInt(wxhMatch[2], 10)
|
||||
return w * h
|
||||
}
|
||||
|
||||
// 无法识别的格式,放到最后
|
||||
return Infinity
|
||||
}
|
||||
|
||||
/**
|
||||
* 对分辨率价格条目进行排序(从低分辨率到高分辨率)
|
||||
*
|
||||
* @param entries - 分辨率价格条目数组 [[resolution, price], ...]
|
||||
* @returns 排序后的数组
|
||||
*/
|
||||
export function sortResolutionEntries<T>(entries: [string, T][]): [string, T][] {
|
||||
return [...entries].sort((a, b) => getResolutionSortWeight(a[0]) - getResolutionSortWeight(b[0]))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,58 +26,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 能力筛选 -->
|
||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.streaming ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="流式输出"
|
||||
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.imageGeneration ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="图像生成"
|
||||
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
|
||||
>
|
||||
<Image class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="视觉理解"
|
||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="工具调用"
|
||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="深度思考"
|
||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -103,7 +51,7 @@
|
||||
模型名称
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px]">
|
||||
能力/偏好
|
||||
模型偏好
|
||||
</TableHead>
|
||||
<TableHead class="w-[160px] text-center">
|
||||
价格 ($/M)
|
||||
@@ -165,45 +113,19 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1 w-fit">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Zap
|
||||
v-if="model.config?.streaming !== false"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="流式输出"
|
||||
/>
|
||||
<Image
|
||||
v-if="model.config?.image_generation === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="图像生成"
|
||||
/>
|
||||
<Eye
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="视觉理解"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="工具调用"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="深度思考"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
<template v-if="model.supported_capabilities?.length">
|
||||
<div class="border-t border-border/50" />
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
<span
|
||||
v-for="capName in model.supported_capabilities"
|
||||
:key="capName"
|
||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||
:title="getCapabilityDisplayName(capName)"
|
||||
>{{ getCapabilityShortName(capName) }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-for="capName in model.supported_capabilities"
|
||||
:key="capName"
|
||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||
:title="getCapabilityDisplayName(capName)"
|
||||
>{{ getCapabilityShortName(capName) }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
@@ -227,9 +149,17 @@
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 视频费用计费 -->
|
||||
<div v-if="hasVideoPricing(model)">
|
||||
<span class="text-muted-foreground">视频:</span>
|
||||
<span
|
||||
class="font-mono ml-1"
|
||||
:title="getVideoPricingTooltip(model)"
|
||||
>{{ getVideoPricingDisplay(model) }}</span>
|
||||
</div>
|
||||
<!-- 无计费配置 -->
|
||||
<div
|
||||
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request"
|
||||
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request && !hasVideoPricing(model)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
-
|
||||
@@ -358,28 +288,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:能力图标 -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Zap
|
||||
v-if="model.config?.streaming !== false"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Image
|
||||
v-if="model.config?.image_generation === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Eye
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<!-- 第二行:模型偏好 -->
|
||||
<div
|
||||
v-if="model.supported_capabilities?.length"
|
||||
class="flex flex-wrap gap-0.5"
|
||||
>
|
||||
<span
|
||||
v-for="capName in model.supported_capabilities"
|
||||
:key="capName"
|
||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||
>{{ getCapabilityShortName(capName) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:统计信息 -->
|
||||
@@ -601,6 +519,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { sortResolutionEntries } from '@/utils/form'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -722,6 +641,43 @@ function hasTieredPricing(model: GlobalModelResponse): boolean {
|
||||
return (tiered?.tiers?.length || 0) > 1
|
||||
}
|
||||
|
||||
// 检测是否有视频分辨率计费配置
|
||||
function hasVideoPricing(model: GlobalModelResponse): boolean {
|
||||
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
||||
}
|
||||
|
||||
// 获取视频分辨率计费的数量
|
||||
function getVideoPricingCount(model: GlobalModelResponse): number {
|
||||
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||
if (!priceByResolution || typeof priceByResolution !== 'object') return 0
|
||||
return Object.keys(priceByResolution).length
|
||||
}
|
||||
|
||||
// 获取视频计费的显示文本(如:720p $0.1/s [多分辨率])
|
||||
function getVideoPricingDisplay(model: GlobalModelResponse): string {
|
||||
const priceByResolution = 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: GlobalModelResponse): string {
|
||||
const priceByResolution = 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')
|
||||
}
|
||||
|
||||
// 检测是否有对话框打开(防止误关闭抽屉)
|
||||
const hasBlockingDialogOpen = computed(() =>
|
||||
createModelDialogOpen.value ||
|
||||
|
||||
@@ -97,23 +97,23 @@
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/40 hover:bg-transparent">
|
||||
<TableHead class="w-[18%] min-w-[140px] h-11 font-medium text-foreground/80">
|
||||
<TableRow>
|
||||
<TableHead class="w-[18%] min-w-[140px]">
|
||||
提供商信息
|
||||
</TableHead>
|
||||
<TableHead class="w-[20%] min-w-[180px] h-11 font-medium text-foreground/80">
|
||||
<TableHead class="w-[20%] min-w-[180px]">
|
||||
余额监控
|
||||
</TableHead>
|
||||
<TableHead class="w-[12%] min-w-[100px] h-11 font-medium text-foreground/80 text-center">
|
||||
<TableHead class="w-[12%] min-w-[100px] text-center">
|
||||
资源统计
|
||||
</TableHead>
|
||||
<TableHead class="w-[24%] min-w-[260px] h-11 font-medium text-foreground/80">
|
||||
<TableHead class="w-[24%] min-w-[260px]">
|
||||
端点健康
|
||||
</TableHead>
|
||||
<TableHead class="w-[8%] min-w-[60px] h-11 font-medium text-foreground/80 text-center">
|
||||
<TableHead class="w-[8%] min-w-[60px] text-center">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-[18%] min-w-[120px] h-11 font-medium text-foreground/80 text-center">
|
||||
<TableHead class="w-[18%] min-w-[120px] text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -210,10 +210,10 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 日志记录配置 -->
|
||||
<!-- 请求记录配置 -->
|
||||
<CardSection
|
||||
title="日志记录"
|
||||
description="控制请求日志的记录方式和内容"
|
||||
title="请求记录"
|
||||
description="控制请求/响应详情的入库方式和内容"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@@ -233,7 +233,7 @@
|
||||
记录详细程度
|
||||
</Label>
|
||||
<Select
|
||||
v-model="systemConfig.request_log_level"
|
||||
v-model="systemConfig.request_record_level"
|
||||
v-model:open="logLevelSelectOpen"
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -317,10 +317,10 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 日志清理策略 -->
|
||||
<!-- 请求记录清理策略 -->
|
||||
<CardSection
|
||||
title="日志清理策略"
|
||||
description="配置日志的分级保留和自动清理"
|
||||
title="请求记录清理策略"
|
||||
description="配置请求记录的分级保留和自动清理"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -357,7 +357,7 @@
|
||||
for="detail-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
详细日志保留天数
|
||||
详细记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="detail-log-retention-days"
|
||||
@@ -376,7 +376,7 @@
|
||||
for="compressed-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
压缩日志保留天数
|
||||
压缩记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="compressed-log-retention-days"
|
||||
@@ -414,7 +414,7 @@
|
||||
for="log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
完整日志保留天数
|
||||
完整记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
@@ -909,12 +909,12 @@ interface SystemConfig {
|
||||
auto_delete_expired_keys: boolean
|
||||
// 格式转换
|
||||
enable_format_conversion: boolean
|
||||
// 日志记录
|
||||
request_log_level: string
|
||||
// 请求记录
|
||||
request_record_level: string
|
||||
max_request_body_size: number
|
||||
max_response_body_size: number
|
||||
sensitive_headers: string[]
|
||||
// 日志清理
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: boolean
|
||||
detail_log_retention_days: number
|
||||
compressed_log_retention_days: number
|
||||
@@ -965,12 +965,12 @@ const systemConfig = ref<SystemConfig>({
|
||||
auto_delete_expired_keys: false,
|
||||
// 格式转换
|
||||
enable_format_conversion: false,
|
||||
// 日志记录
|
||||
request_log_level: 'basic',
|
||||
// 请求记录
|
||||
request_record_level: 'basic',
|
||||
max_request_body_size: 1048576,
|
||||
max_response_body_size: 1048576,
|
||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||
// 日志清理
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: true,
|
||||
detail_log_retention_days: 7,
|
||||
compressed_log_retention_days: 90,
|
||||
@@ -1000,7 +1000,7 @@ const hasBasicConfigChanges = computed(() => {
|
||||
const hasLogConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.request_log_level !== originalConfig.value.request_log_level ||
|
||||
systemConfig.value.request_record_level !== originalConfig.value.request_record_level ||
|
||||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
||||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
||||
JSON.stringify(systemConfig.value.sensitive_headers) !== JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
@@ -1072,12 +1072,12 @@ async function loadSystemConfig() {
|
||||
'auto_delete_expired_keys',
|
||||
// 格式转换
|
||||
'enable_format_conversion',
|
||||
// 日志记录
|
||||
'request_log_level',
|
||||
// 请求记录
|
||||
'request_record_level',
|
||||
'max_request_body_size',
|
||||
'max_response_body_size',
|
||||
'sensitive_headers',
|
||||
// 日志清理
|
||||
// 请求记录清理
|
||||
'enable_auto_cleanup',
|
||||
'detail_log_retention_days',
|
||||
'compressed_log_retention_days',
|
||||
@@ -1165,8 +1165,8 @@ async function saveLogConfig() {
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'request_log_level',
|
||||
value: systemConfig.value.request_log_level,
|
||||
key: 'request_record_level',
|
||||
value: systemConfig.value.request_record_level,
|
||||
description: '请求记录级别'
|
||||
},
|
||||
{
|
||||
@@ -1193,15 +1193,15 @@ async function saveLogConfig() {
|
||||
)
|
||||
// 更新原始值
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.request_log_level = systemConfig.value.request_log_level
|
||||
originalConfig.value.request_record_level = systemConfig.value.request_record_level
|
||||
originalConfig.value.max_request_body_size = systemConfig.value.max_request_body_size
|
||||
originalConfig.value.max_response_body_size = systemConfig.value.max_response_body_size
|
||||
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
|
||||
}
|
||||
success('日志配置已保存')
|
||||
success('请求记录配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存日志配置失败:', err)
|
||||
log.error('保存请求记录配置失败:', err)
|
||||
} finally {
|
||||
logConfigLoading.value = false
|
||||
}
|
||||
@@ -1250,12 +1250,12 @@ async function saveCleanupConfig() {
|
||||
{
|
||||
key: 'detail_log_retention_days',
|
||||
value: systemConfig.value.detail_log_retention_days,
|
||||
description: '详细日志保留天数'
|
||||
description: '详细记录保留天数'
|
||||
},
|
||||
{
|
||||
key: 'compressed_log_retention_days',
|
||||
value: systemConfig.value.compressed_log_retention_days,
|
||||
description: '压缩日志保留天数'
|
||||
description: '压缩记录保留天数'
|
||||
},
|
||||
{
|
||||
key: 'header_retention_days',
|
||||
@@ -1265,7 +1265,7 @@ async function saveCleanupConfig() {
|
||||
{
|
||||
key: 'log_retention_days',
|
||||
value: systemConfig.value.log_retention_days,
|
||||
description: '完整日志保留天数'
|
||||
description: '完整记录保留天数'
|
||||
},
|
||||
{
|
||||
key: 'cleanup_batch_size',
|
||||
@@ -1293,10 +1293,10 @@ async function saveCleanupConfig() {
|
||||
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
|
||||
originalConfig.value.audit_log_retention_days = systemConfig.value.audit_log_retention_days
|
||||
}
|
||||
success('日志清理配置已保存')
|
||||
success('请求记录清理配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存日志清理配置失败:', err)
|
||||
log.error('保存请求记录清理配置失败:', err)
|
||||
} finally {
|
||||
cleanupConfigLoading.value = false
|
||||
}
|
||||
|
||||
@@ -29,38 +29,38 @@ const providerExamples = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
url: 'https://api.openai.com',
|
||||
format: 'OpenAI',
|
||||
format: 'OpenAI Chat',
|
||||
note: '官方 API,需要国际信用卡或通过代理访问'
|
||||
},
|
||||
{
|
||||
name: 'Anthropic',
|
||||
url: 'https://api.anthropic.com',
|
||||
format: 'Claude',
|
||||
format: 'Claude Chat',
|
||||
note: '官方 Claude API'
|
||||
},
|
||||
{
|
||||
name: 'Google AI',
|
||||
url: 'https://generativelanguage.googleapis.com',
|
||||
format: 'Gemini',
|
||||
format: 'Gemini Chat',
|
||||
note: '官方 Gemini API'
|
||||
},
|
||||
{
|
||||
name: 'Azure OpenAI',
|
||||
url: 'https://{resource}.openai.azure.com',
|
||||
format: 'OpenAI',
|
||||
format: 'OpenAI Chat',
|
||||
note: '需要替换 {resource} 为你的资源名'
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
url: 'https://openrouter.ai/api',
|
||||
format: 'OpenAI',
|
||||
format: 'OpenAI Chat',
|
||||
note: '聚合多家供应商的 API 代理'
|
||||
},
|
||||
{
|
||||
name: '自托管 / 其他',
|
||||
url: 'https://your-api.com',
|
||||
format: 'OpenAI',
|
||||
note: '大多数 OpenAI 兼容服务选择 OpenAI 格式'
|
||||
format: 'OpenAI Chat',
|
||||
note: '大多数 OpenAI 兼容服务选择 OpenAI Chat 格式'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -111,7 +111,7 @@ export const coreConcepts = [
|
||||
// API 格式说明
|
||||
export const apiFormats = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
name: 'OpenAI Chat',
|
||||
endpoint: '/v1/chat/completions',
|
||||
auth: 'Authorization: Bearer xxx',
|
||||
clients: ['OpenAI SDK', 'Cursor', 'LangChain', '大部分开源工具']
|
||||
@@ -123,7 +123,7 @@ export const apiFormats = [
|
||||
clients: ['Codex CLI']
|
||||
},
|
||||
{
|
||||
name: 'Claude',
|
||||
name: 'Claude Chat',
|
||||
endpoint: '/v1/messages',
|
||||
auth: 'x-api-key: xxx',
|
||||
clients: ['Anthropic SDK']
|
||||
@@ -135,7 +135,7 @@ export const apiFormats = [
|
||||
clients: ['Claude Code']
|
||||
},
|
||||
{
|
||||
name: 'Gemini',
|
||||
name: 'Gemini Chat',
|
||||
endpoint: '/v1beta/models/{model}:generateContent',
|
||||
auth: 'x-goog-api-key: xxx',
|
||||
clients: ['Gemini SDK', 'Gemini CLI']
|
||||
|
||||
@@ -24,40 +24,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 能力筛选 -->
|
||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="Vision"
|
||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="Tool Use"
|
||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
title="Extended Thinking"
|
||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@@ -77,9 +43,6 @@
|
||||
<TableHead class="w-[120px] h-12 font-semibold">
|
||||
模型偏好
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] h-12 font-semibold">
|
||||
能力
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
||||
价格 ($/M)
|
||||
</TableHead>
|
||||
@@ -91,7 +54,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
colspan="4"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||
@@ -99,7 +62,7 @@
|
||||
</TableRow>
|
||||
<TableRow v-else-if="filteredModels.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
colspan="4"
|
||||
class="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
没有找到匹配的模型
|
||||
@@ -162,25 +125,6 @@
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5">
|
||||
<Eye
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Vision"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Tool Use"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
title="Extended Thinking"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<!-- 按 Token 计费 -->
|
||||
@@ -250,23 +194,7 @@
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:能力图标 -->
|
||||
<div class="flex gap-1.5">
|
||||
<Eye
|
||||
v-if="model.config?.vision === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Wrench
|
||||
v-if="model.config?.function_calling === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<Brain
|
||||
v-if="model.config?.extended_thinking === true"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:价格 -->
|
||||
<!-- 第二行:价格 -->
|
||||
<div
|
||||
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
||||
class="text-xs text-muted-foreground font-mono"
|
||||
|
||||
Reference in New Issue
Block a user