mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-05 09:12:27 +08:00
Initial commit
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Plus v-if="!isEditMode" class="h-5 w-5 text-primary" />
|
||||
<SquarePen v-else class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ isEditMode ? '编辑独立余额 API Key' : '创建独立余额 API Key' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,不关联用户配额,必须设置余额限制' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-2 gap-0">
|
||||
<!-- 左侧:基础设置 -->
|
||||
<div class="pr-6 space-y-4">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Key class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">基础设置</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-name" class="text-sm font-medium">密钥名称</Label>
|
||||
<Input
|
||||
id="form-name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="例如: 用户A专用"
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 初始余额 - 仅创建模式显示 -->
|
||||
<div v-if="!isEditMode" class="space-y-2">
|
||||
<Label for="form-balance" class="text-sm font-medium">初始余额 (USD) <span class="text-rose-500">*</span></Label>
|
||||
<Input
|
||||
id="form-balance"
|
||||
:model-value="form.initial_balance_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
placeholder="10.00"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">独立Key必须设置余额限制,最小值 $0.01</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-expire-days" class="text-sm font-medium">有效期设置</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="form-expire-days"
|
||||
:model-value="form.expire_days ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
placeholder="天数"
|
||||
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
|
||||
:disabled="form.never_expire"
|
||||
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
|
||||
<input
|
||||
v-model="form.never_expire"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
@change="onNeverExpireChange"
|
||||
/>
|
||||
永不过期
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap" :class="form.never_expire ? 'opacity-50' : ''">
|
||||
<input
|
||||
v-model="form.auto_delete_on_expiry"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
|
||||
:disabled="form.never_expire"
|
||||
/>
|
||||
到期删除
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">不勾选"到期删除"则仅禁用</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-rate-limit" class="text-sm font-medium">速率限制 (请求/分钟)</Label>
|
||||
<Input
|
||||
id="form-rate-limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="100"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
<div class="pl-6 space-y-4 border-l border-border">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<Shield class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">访问限制</span>
|
||||
<span class="text-xs text-muted-foreground">(留空不限)</span>
|
||||
</div>
|
||||
|
||||
<!-- Provider 多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的 Provider</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="providerDropdownOpen = !providerDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="providerDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="providerDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="providerDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="providerDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_providers', provider.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_providers.includes(provider.id)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_providers', provider.id)"
|
||||
/>
|
||||
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
|
||||
</div>
|
||||
<div v-if="providers.length === 0" class="px-3 py-2 text-sm text-muted-foreground">
|
||||
暂无可用 Provider
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 格式多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的 API 格式</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="apiFormatDropdownOpen = !apiFormatDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="apiFormatDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="apiFormatDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="apiFormatDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="apiFormatDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="format in allApiFormats"
|
||||
:key="format"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_api_formats', format)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_api_formats.includes(format)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_api_formats', format)"
|
||||
/>
|
||||
<span class="text-sm">{{ format }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="modelDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="modelDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="modelDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
/>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div v-if="globalModels.length === 0" class="px-3 py-2 text-sm text-muted-foreground">
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="handleCancel" type="button" class="h-10 px-5">取消</Button>
|
||||
<Button @click="handleSubmit" :disabled="saving" class="h-10 px-5">
|
||||
{{ saving ? (isEditMode ? '更新中...' : '创建中...') : (isEditMode ? '更新' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
} from '@/components/ui'
|
||||
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface StandaloneKeyFormData {
|
||||
id?: string
|
||||
name: string
|
||||
initial_balance_usd?: number
|
||||
expire_days?: number
|
||||
never_expire: boolean
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
allowed_providers: string[]
|
||||
allowed_api_formats: string[]
|
||||
allowed_models: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
apiKey: StandaloneKeyFormData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [data: StandaloneKeyFormData]
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const apiFormatDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const allApiFormats = ref<string[]>([])
|
||||
|
||||
// 表单数据
|
||||
const form = ref<StandaloneKeyFormData>({
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: []
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: []
|
||||
}
|
||||
providerDropdownOpen.value = false
|
||||
apiFormatDropdownOpen.value = false
|
||||
modelDropdownOpen.value = false
|
||||
}
|
||||
|
||||
function loadKeyData() {
|
||||
if (!props.apiKey) return
|
||||
form.value = {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
expire_days: props.apiKey.expire_days,
|
||||
never_expire: props.apiKey.never_expire,
|
||||
rate_limit: props.apiKey.rate_limit || 100,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
allowed_providers: props.apiKey.allowed_providers || [],
|
||||
allowed_api_formats: props.apiKey.allowed_api_formats || [],
|
||||
allowed_models: props.apiKey.allowed_models || []
|
||||
}
|
||||
}
|
||||
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.apiKey,
|
||||
isLoading: saving,
|
||||
onClose: () => emit('close'),
|
||||
loadData: loadKeyData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 加载选项数据
|
||||
async function loadAccessRestrictionOptions() {
|
||||
try {
|
||||
const [providersData, modelsData, formatsData] = await Promise.all([
|
||||
getProvidersSummary(),
|
||||
getGlobalModels({ limit: 1000, is_active: true }),
|
||||
adminApi.getApiFormats()
|
||||
])
|
||||
providers.value = providersData
|
||||
globalModels.value = modelsData.models || []
|
||||
allApiFormats.value = formatsData.formats?.map((f: any) => f.value) || []
|
||||
} catch (err) {
|
||||
console.error('加载访问限制选项失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'allowed_models', value: string) {
|
||||
const arr = form.value[field]
|
||||
const index = arr.indexOf(value)
|
||||
if (index === -1) {
|
||||
arr.push(value)
|
||||
} else {
|
||||
arr.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 永不过期切换
|
||||
function onNeverExpireChange() {
|
||||
if (form.value.never_expire) {
|
||||
form.value.expire_days = undefined
|
||||
form.value.auto_delete_on_expiry = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
function handleSubmit() {
|
||||
emit('submit', { ...form.value })
|
||||
}
|
||||
|
||||
// 设置保存状态
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
|
||||
// 监听打开状态,加载选项数据
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
loadAccessRestrictionOptions()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
</script>
|
||||
2
frontend/src/features/api-keys/components/index.ts
Normal file
2
frontend/src/features/api-keys/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as StandaloneKeyFormDialog } from './StandaloneKeyFormDialog.vue'
|
||||
export type { StandaloneKeyFormData } from './StandaloneKeyFormDialog.vue'
|
||||
1
frontend/src/features/api-keys/index.ts
Normal file
1
frontend/src/features/api-keys/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
135
frontend/src/features/auth/components/LoginDialog.vue
Normal file
135
frontend/src/features/auth/components/LoginDialog.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<Dialog v-model="isOpen" size="lg">
|
||||
<div class="space-y-6">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
|
||||
<img src="/aether_adaptive.svg" alt="Logo" class="h-16 w-16" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
欢迎回来
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="login-email">邮箱</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="hello@example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="login-password">密码</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
autocomplete="off"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<p class="text-xs text-slate-400 dark:text-muted-foreground/80">
|
||||
如需开通账户,请联系管理员配置访问权限
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
@click="isOpen = false"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleLogin"
|
||||
:disabled="authStore.loading"
|
||||
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
||||
>
|
||||
{{ authStore.loading ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
// 打开对话框时重置表单
|
||||
if (val) {
|
||||
form.value = {
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.value.email || !form.value.password) {
|
||||
showWarning('请输入邮箱和密码')
|
||||
return
|
||||
}
|
||||
|
||||
const success = await authStore.login(form.value.email, form.value.password)
|
||||
if (success) {
|
||||
showSuccess('登录成功,正在跳转...')
|
||||
|
||||
// 关闭对话框
|
||||
isOpen.value = false
|
||||
|
||||
// 延迟一下让用户看到成功消息
|
||||
setTimeout(() => {
|
||||
// 根据用户角色跳转到不同的仪表盘
|
||||
const targetPath = authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
||||
router.push(targetPath)
|
||||
}, 1000)
|
||||
} else {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1
frontend/src/features/auth/components/index.ts
Normal file
1
frontend/src/features/auth/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LoginDialog } from './LoginDialog.vue'
|
||||
1
frontend/src/features/auth/index.ts
Normal file
1
frontend/src/features/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
337
frontend/src/features/models/components/AliasDialog.vue
Normal file
337
frontend/src/features/models/components/AliasDialog.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:icon="dialogIcon"
|
||||
size="md"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- 模式选择(仅创建时显示) -->
|
||||
<div v-if="!isEditMode" class="space-y-2">
|
||||
<Label>创建类型 *</Label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.mapping_type = 'alias'"
|
||||
:class="[
|
||||
'p-3 rounded-lg border-2 text-left transition-all',
|
||||
form.mapping_type === 'alias'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
]"
|
||||
>
|
||||
<div class="font-medium text-sm">别名</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">名称简写,按目标模型计费</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.mapping_type = 'mapping'"
|
||||
:class="[
|
||||
'p-3 rounded-lg border-2 text-left transition-all',
|
||||
form.mapping_type === 'mapping'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
]"
|
||||
>
|
||||
<div class="font-medium text-sm">映射</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">模型降级,按源模型计费</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式说明 -->
|
||||
<div class="rounded-lg border border-border bg-muted/50 p-3 text-sm">
|
||||
<p class="text-foreground font-medium mb-1">
|
||||
{{ form.mapping_type === 'alias' ? '别名模式' : '映射模式' }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ form.mapping_type === 'alias'
|
||||
? '用户请求此别名时,会路由到目标模型,并按目标模型价格计费。'
|
||||
: '将源模型的请求转发到目标模型处理,按源模型价格计费。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Provider 选择/作用范围 -->
|
||||
<div v-if="showProviderSelect" class="space-y-2">
|
||||
<Label>作用范围</Label>
|
||||
<!-- 固定 Provider 时显示只读 -->
|
||||
<div v-if="fixedProvider" class="px-3 py-2 border rounded-md bg-muted/50 text-sm">
|
||||
仅 {{ fixedProvider.display_name || fixedProvider.name }}
|
||||
</div>
|
||||
<!-- 否则显示可选择的下拉 -->
|
||||
<Select v-else v-model:open="providerSelectOpen" :model-value="form.provider_id || 'global'" @update:model-value="handleProviderChange">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="选择作用范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">全局(所有 Provider)</SelectItem>
|
||||
<SelectItem v-for="p in providers" :key="p.id" :value="p.id">
|
||||
仅 {{ p.display_name || p.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 别名模式:别名名称 -->
|
||||
<div v-if="form.mapping_type === 'alias'" class="space-y-2">
|
||||
<Label for="alias-name">别名名称 *</Label>
|
||||
<Input
|
||||
id="alias-name"
|
||||
v-model="form.alias"
|
||||
placeholder="如:sonnet, opus"
|
||||
:disabled="isEditMode"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '创建后不可修改' : '用户将使用此名称请求模型' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 映射模式:选择源模型 -->
|
||||
<div v-else class="space-y-2">
|
||||
<Label>源模型 (用户请求的模型) *</Label>
|
||||
<Select v-model:open="sourceModelSelectOpen" :model-value="form.alias" @update:model-value="form.alias = $event" :disabled="isEditMode">
|
||||
<SelectTrigger class="w-full" :class="{ 'opacity-50': isEditMode }">
|
||||
<SelectValue placeholder="请选择源模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in availableSourceModels"
|
||||
:key="model.id"
|
||||
:value="model.name"
|
||||
>
|
||||
{{ model.display_name }} ({{ model.name }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '创建后不可修改' : '选择要被映射的源模型,计费将按此模型价格' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 目标模型选择 -->
|
||||
<div class="space-y-2">
|
||||
<Label>
|
||||
{{ form.mapping_type === 'alias' ? '目标模型 *' : '目标模型 (实际处理请求) *' }}
|
||||
</Label>
|
||||
<!-- 固定目标模型时显示只读信息 -->
|
||||
<div v-if="fixedTargetModel" class="px-3 py-2 border rounded-md bg-muted/50">
|
||||
<span class="font-medium">{{ fixedTargetModel.display_name }}</span>
|
||||
<span class="text-muted-foreground ml-1">({{ fixedTargetModel.name }})</span>
|
||||
</div>
|
||||
<!-- 否则显示下拉选择 -->
|
||||
<Select v-else v-model:open="targetModelSelectOpen" :model-value="form.global_model_id" @update:model-value="form.global_model_id = $event">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in availableTargetModels"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.display_name }} ({{ model.name }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="button" variant="outline" @click="handleCancel">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="handleSubmit" :disabled="submitting">
|
||||
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{ isEditMode ? '保存' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Loader2, Tag, SquarePen } from 'lucide-vue-next'
|
||||
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import type { ModelAlias, CreateModelAliasRequest, UpdateModelAliasRequest } from '@/api/endpoints/aliases'
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
|
||||
export interface ProviderOption {
|
||||
id: string
|
||||
name: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
interface AliasFormData {
|
||||
alias: string
|
||||
global_model_id: string
|
||||
provider_id: string | null
|
||||
mapping_type: 'alias' | 'mapping'
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
editingAlias?: ModelAlias | null
|
||||
globalModels: GlobalModelResponse[]
|
||||
providers?: ProviderOption[]
|
||||
fixedTargetModel?: GlobalModelResponse | null // 用于从模型详情抽屉打开时固定目标模型
|
||||
fixedProvider?: ProviderOption | null // 用于 Provider 特定别名固定 Provider
|
||||
showProviderSelect?: boolean // 是否显示 Provider 选择(默认 true)
|
||||
}>(), {
|
||||
editingAlias: null,
|
||||
providers: () => [],
|
||||
fixedTargetModel: null,
|
||||
fixedProvider: null,
|
||||
showProviderSelect: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'submit': [data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean]
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const providerSelectOpen = ref(false)
|
||||
const sourceModelSelectOpen = ref(false)
|
||||
const targetModelSelectOpen = ref(false)
|
||||
const form = ref<AliasFormData>({
|
||||
alias: '',
|
||||
global_model_id: '',
|
||||
provider_id: null,
|
||||
mapping_type: 'alias',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
// 处理 Provider 选择变化
|
||||
function handleProviderChange(value: string) {
|
||||
form.value.provider_id = value === 'global' ? null : value
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
alias: '',
|
||||
global_model_id: props.fixedTargetModel?.id || '',
|
||||
provider_id: props.fixedProvider?.id || null,
|
||||
mapping_type: 'alias',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
// 加载别名数据(编辑模式)
|
||||
function loadAliasData() {
|
||||
if (!props.editingAlias) return
|
||||
form.value = {
|
||||
alias: props.editingAlias.alias,
|
||||
global_model_id: props.editingAlias.global_model_id,
|
||||
provider_id: props.editingAlias.provider_id,
|
||||
mapping_type: props.editingAlias.mapping_type || 'alias',
|
||||
is_active: props.editingAlias.is_active,
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.editingAlias,
|
||||
isLoading: submitting,
|
||||
onClose: () => emit('update:open', false),
|
||||
loadData: loadAliasData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
if (isEditMode.value) {
|
||||
return form.value.mapping_type === 'mapping' ? '编辑映射' : '编辑别名'
|
||||
}
|
||||
if (props.fixedProvider) {
|
||||
return `创建 ${props.fixedProvider.display_name || props.fixedProvider.name} 特定别名/映射`
|
||||
}
|
||||
return '创建别名/映射'
|
||||
})
|
||||
|
||||
// 对话框描述
|
||||
const dialogDescription = computed(() => {
|
||||
if (isEditMode.value) {
|
||||
return form.value.mapping_type === 'mapping' ? '修改模型映射配置' : '修改别名设置'
|
||||
}
|
||||
return '为模型创建别名或映射规则'
|
||||
})
|
||||
|
||||
// 对话框图标
|
||||
const dialogIcon = computed(() => isEditMode.value ? SquarePen : Tag)
|
||||
|
||||
// 作用范围描述
|
||||
const scopeDescription = computed(() => {
|
||||
if (props.fixedProvider) {
|
||||
return `仅对 ${props.fixedProvider.display_name || props.fixedProvider.name} 生效。`
|
||||
}
|
||||
if (form.value.provider_id) {
|
||||
const provider = props.providers.find(p => p.id === form.value.provider_id)
|
||||
if (provider) {
|
||||
return `仅对 ${provider.display_name || provider.name} 生效。`
|
||||
}
|
||||
}
|
||||
return '全局生效。'
|
||||
})
|
||||
|
||||
// 映射模式下可选的源模型(排除已选择的目标模型)
|
||||
const availableSourceModels = computed(() => {
|
||||
return props.globalModels.filter(m => m.id !== form.value.global_model_id)
|
||||
})
|
||||
|
||||
// 可选的目标模型(映射模式下排除已选择的源模型)
|
||||
const availableTargetModels = computed(() => {
|
||||
if (form.value.mapping_type === 'mapping' && form.value.alias) {
|
||||
// 找到源模型对应的 GlobalModel
|
||||
const sourceModel = props.globalModels.find(m => m.name === form.value.alias)
|
||||
if (sourceModel) {
|
||||
return props.globalModels.filter(m => m.id !== sourceModel.id)
|
||||
}
|
||||
}
|
||||
return props.globalModels
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!form.value.alias) {
|
||||
showError(form.value.mapping_type === 'alias' ? '请输入别名名称' : '请选择源模型', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
const targetModelId = props.fixedTargetModel?.id || form.value.global_model_id
|
||||
if (!targetModelId) {
|
||||
showError('请选择目标模型', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: CreateModelAliasRequest | UpdateModelAliasRequest = {
|
||||
alias: form.value.alias,
|
||||
global_model_id: targetModelId,
|
||||
provider_id: props.fixedProvider?.id || form.value.provider_id,
|
||||
mapping_type: form.value.mapping_type,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
|
||||
emit('submit', data, !!props.editingAlias)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="isEditMode ? '编辑模型' : '创建统一模型'"
|
||||
:description="isEditMode ? '修改模型配置和价格信息' : '添加一个新的全局模型定义'"
|
||||
:icon="isEditMode ? SquarePen : Layers"
|
||||
size="xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5 max-h-[70vh] overflow-y-auto pr-1">
|
||||
<!-- 基本信息 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">基本信息</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="model-name" class="text-xs">模型名称 *</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
v-model="form.name"
|
||||
placeholder="claude-3-5-sonnet-20241022"
|
||||
:disabled="isEditMode"
|
||||
required
|
||||
/>
|
||||
<p v-if="!isEditMode" class="text-xs text-muted-foreground">创建后不可修改</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="model-display-name" class="text-xs">显示名称 *</Label>
|
||||
<Input
|
||||
id="model-display-name"
|
||||
v-model="form.display_name"
|
||||
placeholder="Claude 3.5 Sonnet"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="model-description" class="text-xs">描述</Label>
|
||||
<Input
|
||||
id="model-description"
|
||||
v-model="form.description"
|
||||
placeholder="简短描述此模型的特点"
|
||||
/>
|
||||
</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-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="form.default_supports_streaming" class="rounded" />
|
||||
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>流式输出</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="form.default_supports_vision" class="rounded" />
|
||||
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>视觉理解</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="form.default_supports_function_calling" class="rounded" />
|
||||
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>工具调用</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="form.default_supports_extended_thinking" class="rounded" />
|
||||
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>深度思考</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
|
||||
<input type="checkbox" v-model="form.default_supports_image_generation" class="rounded" />
|
||||
<Image class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>图像生成</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key 能力配置 -->
|
||||
<section v-if="availableCapabilities.length > 0" class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Key 能力支持</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.supported_capabilities?.includes(cap.name)"
|
||||
@change="toggleCapability(cap.name)"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">价格配置</h4>
|
||||
<TieredPricingEditor v-model="tieredPricing" :show-cache1h="form.supported_capabilities?.includes('cache_1h')" />
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
||||
<Input
|
||||
:model-value="form.default_price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-32"
|
||||
placeholder="留空不启用"
|
||||
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="button" variant="outline" @click="handleCancel">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="handleSubmit" :disabled="submitting">
|
||||
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{ isEditMode ? '保存' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen } 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 TieredPricingEditor from './TieredPricingEditor.vue'
|
||||
import {
|
||||
createGlobalModel,
|
||||
updateGlobalModel,
|
||||
type GlobalModelResponse,
|
||||
type GlobalModelCreate,
|
||||
type GlobalModelUpdate,
|
||||
} from '@/api/global-models'
|
||||
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
model?: GlobalModelResponse | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'success': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const submitting = ref(false)
|
||||
|
||||
// 阶梯计费配置(统一使用,固定价格就是单阶梯)
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
default_price_per_request?: number
|
||||
default_supports_streaming?: boolean
|
||||
default_supports_image_generation?: boolean
|
||||
default_supports_vision?: boolean
|
||||
default_supports_function_calling?: boolean
|
||||
default_supports_extended_thinking?: boolean
|
||||
supported_capabilities?: string[]
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
const defaultForm = (): FormData => ({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
default_price_per_request: undefined,
|
||||
default_supports_streaming: true,
|
||||
default_supports_image_generation: false,
|
||||
default_supports_vision: false,
|
||||
default_supports_function_calling: false,
|
||||
default_supports_extended_thinking: false,
|
||||
supported_capabilities: [],
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const form = ref<FormData>(defaultForm())
|
||||
|
||||
// Key 能力选项
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
// 加载可用能力列表
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (err) {
|
||||
console.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换能力
|
||||
function toggleCapability(capName: string) {
|
||||
if (!form.value.supported_capabilities) {
|
||||
form.value.supported_capabilities = []
|
||||
}
|
||||
const index = form.value.supported_capabilities.indexOf(capName)
|
||||
if (index >= 0) {
|
||||
form.value.supported_capabilities.splice(index, 1)
|
||||
} else {
|
||||
form.value.supported_capabilities.push(capName)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载能力列表
|
||||
onMounted(() => {
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
function loadModelData() {
|
||||
if (!props.model) return
|
||||
form.value = {
|
||||
name: props.model.name,
|
||||
display_name: props.model.display_name,
|
||||
description: props.model.description,
|
||||
default_price_per_request: props.model.default_price_per_request,
|
||||
default_supports_streaming: props.model.default_supports_streaming,
|
||||
default_supports_image_generation: props.model.default_supports_image_generation,
|
||||
default_supports_vision: props.model.default_supports_vision,
|
||||
default_supports_function_calling: props.model.default_supports_function_calling,
|
||||
default_supports_extended_thinking: props.model.default_supports_extended_thinking,
|
||||
supported_capabilities: [...(props.model.supported_capabilities || [])],
|
||||
is_active: props.model.is_active,
|
||||
}
|
||||
|
||||
// 加载阶梯计费配置(深拷贝)
|
||||
if (props.model.default_tiered_pricing) {
|
||||
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.model,
|
||||
isLoading: submitting,
|
||||
onClose: () => emit('update:open', false),
|
||||
loadData: loadModelData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name || !form.value.display_name) {
|
||||
showError('请填写模型名称和显示名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!tieredPricing.value?.tiers?.length) {
|
||||
showError('请配置至少一个价格阶梯')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditMode.value && props.model) {
|
||||
const updateData: GlobalModelUpdate = {
|
||||
display_name: form.value.display_name,
|
||||
description: form.value.description,
|
||||
// 使用 null 而不是 undefined 来显式清空字段
|
||||
default_price_per_request: form.value.default_price_per_request ?? null,
|
||||
default_tiered_pricing: tieredPricing.value,
|
||||
default_supports_streaming: form.value.default_supports_streaming,
|
||||
default_supports_image_generation: form.value.default_supports_image_generation,
|
||||
default_supports_vision: form.value.default_supports_vision,
|
||||
default_supports_function_calling: form.value.default_supports_function_calling,
|
||||
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
|
||||
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
await updateGlobalModel(props.model.id, updateData)
|
||||
success('模型更新成功')
|
||||
} else {
|
||||
const createData: GlobalModelCreate = {
|
||||
name: form.value.name!,
|
||||
display_name: form.value.display_name!,
|
||||
description: form.value.description,
|
||||
default_price_per_request: form.value.default_price_per_request || undefined,
|
||||
default_tiered_pricing: tieredPricing.value,
|
||||
default_supports_streaming: form.value.default_supports_streaming,
|
||||
default_supports_image_generation: form.value.default_supports_image_generation,
|
||||
default_supports_vision: form.value.default_supports_vision,
|
||||
default_supports_function_calling: form.value.default_supports_function_calling,
|
||||
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
|
||||
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
await createGlobalModel(createData)
|
||||
success('模型创建成功')
|
||||
}
|
||||
emit('update:open', false)
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, isEditMode.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
784
frontend/src/features/models/components/ModelDetailDrawer.vue
Normal file
784
frontend/src/features/models/components/ModelDetailDrawer.vue
Normal file
@@ -0,0 +1,784 @@
|
||||
<template>
|
||||
<!-- 模型详情抽屉 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="open && model"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleBackdropClick"></div>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
|
||||
<div class="sticky top-0 z-10 bg-background border-b p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xl font-bold truncate">{{ model.display_name }}</h3>
|
||||
<Badge :variant="model.is_active ? 'default' : 'secondary'" class="text-xs shrink-0">
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground font-mono">{{ model.name }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="复制模型 ID"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="model.description" class="text-xs text-muted-foreground">
|
||||
{{ model.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" @click="$emit('edit-model', model)" title="编辑模型">
|
||||
<Edit class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="$emit('toggle-model-status', model)"
|
||||
:title="model.is_active ? '点击停用' : '点击启用'"
|
||||
>
|
||||
<Power class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- 自定义 Tab 切换 -->
|
||||
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg mb-4">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
detailTab === 'basic'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="detailTab = 'basic'"
|
||||
>
|
||||
基本信息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
detailTab === 'providers'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="detailTab = 'providers'"
|
||||
>
|
||||
关联提供商
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
detailTab === 'aliases'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="detailTab = 'aliases'"
|
||||
>
|
||||
别名/映射
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<div v-show="detailTab === 'basic'" class="space-y-6">
|
||||
<!-- 基础属性 -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-sm">基础属性</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">创建时间</Label>
|
||||
<p class="text-sm mt-1">{{ formatDate(model.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型能力 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型能力</h4>
|
||||
<div class="grid 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.default_supports_streaming ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_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.default_supports_image_generation ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
|
||||
</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.default_supports_vision ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
|
||||
</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.default_supports_function_calling ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
|
||||
</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.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型偏好 -->
|
||||
<div v-if="model.supported_capabilities && model.supported_capabilities.length > 0" class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型偏好</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="cap in model.supported_capabilities"
|
||||
:key="cap"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ getCapabilityDisplayName(cap) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认定价 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">默认定价</h4>
|
||||
|
||||
<!-- 单阶梯(固定价格)展示 -->
|
||||
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 按 Token 计费 -->
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输入价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'input_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输出价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'output_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存创建 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存读取 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_read_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 1h 缓存 -->
|
||||
<div v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
|
||||
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<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>
|
||||
|
||||
<!-- 多阶梯计费展示 -->
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Layers class="w-4 h-4" />
|
||||
<span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} 档)</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">输入 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">输出 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存创建</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存读取</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">1h 缓存</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(tier, index) in model.default_tiered_pricing?.tiers || []"
|
||||
:key="index"
|
||||
class="text-xs"
|
||||
>
|
||||
<TableCell class="py-2">
|
||||
<span v-if="tier.up_to === null" class="text-muted-foreground">
|
||||
{{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ index === 0 ? '0' : formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to) }} - {{ formatTierLimit(tier.up_to) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.input_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.output_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_creation_price_per_1m != null ? `$${tier.cache_creation_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_read_price_per_1m != null ? `$${tier.cache_read_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ get1hCachePrice(tier) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 按次计费(多阶梯时也显示) -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">统计信息</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3 rounded-lg border bg-muted/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-xs text-muted-foreground">关联提供商</Label>
|
||||
<Building2 class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-1">{{ model.provider_count || 0 }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border bg-muted/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-xs text-muted-foreground">别名数量</Label>
|
||||
<Tag class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-1">{{ model.alias_count || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: 关联提供商 -->
|
||||
<div v-show="detailTab === 'providers'">
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold">关联提供商列表</h4>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="$emit('add-provider')"
|
||||
title="添加关联"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="$emit('refresh-providers')"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="loadingProviders ? 'animate-spin' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格内容 -->
|
||||
<div v-if="loadingProviders" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<Table v-else-if="providers.length > 0">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-10 font-semibold">Provider</TableHead>
|
||||
<TableHead class="w-[120px] h-10 font-semibold">能力</TableHead>
|
||||
<TableHead class="w-[180px] h-10 font-semibold">价格 ($/M)</TableHead>
|
||||
<TableHead class="w-[80px] h-10 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="provider.is_active ? '活跃' : '停用'"
|
||||
></span>
|
||||
<span class="font-medium truncate">{{ provider.display_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex gap-0.5">
|
||||
<Zap v-if="provider.supports_streaming" class="w-3.5 h-3.5 text-muted-foreground" title="流式输出" />
|
||||
<Eye v-if="provider.supports_vision" class="w-3.5 h-3.5 text-muted-foreground" title="视觉理解" />
|
||||
<Wrench v-if="provider.supports_function_calling" class="w-3.5 h-3.5 text-muted-foreground" title="工具调用" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="text-xs font-mono space-y-0.5">
|
||||
<!-- Token 计费:输入/输出 -->
|
||||
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
|
||||
<span class="text-muted-foreground">输入/输出:</span>
|
||||
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
|
||||
<!-- 阶梯标记 -->
|
||||
<span v-if="(provider.tier_count || 1) > 1" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span>
|
||||
</div>
|
||||
<!-- 缓存价格 -->
|
||||
<div v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0" class="text-muted-foreground">
|
||||
<span>缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 1h 缓存价格 -->
|
||||
<div v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0" class="text-muted-foreground">
|
||||
<span>1h 缓存:</span>
|
||||
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="(provider.price_per_request || 0) > 0">
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 无定价 -->
|
||||
<span v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)" class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('edit-provider', provider)"
|
||||
title="编辑此关联"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggle-provider-status', provider)"
|
||||
:title="provider.is_active ? '停用此关联' : '启用此关联'"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('delete-provider', provider)"
|
||||
title="删除此关联"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="text-center py-12">
|
||||
<Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground">暂无关联提供商</p>
|
||||
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-provider')">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加第一个关联
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: 别名 -->
|
||||
<div v-show="detailTab === 'aliases'">
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold">别名与映射</h4>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="$emit('add-alias')"
|
||||
title="添加别名/映射"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="$emit('refresh-aliases')"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="loadingAliases ? 'animate-spin' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格内容 -->
|
||||
<div v-if="loadingAliases" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<Table v-else-if="aliases.length > 0">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-10 font-semibold">别名</TableHead>
|
||||
<TableHead class="w-[80px] h-10 font-semibold">类型</TableHead>
|
||||
<TableHead class="w-[100px] h-10 font-semibold">作用域</TableHead>
|
||||
<TableHead class="w-[100px] h-10 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="alias in aliases"
|
||||
:key="alias.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="alias.is_active ? '活跃' : '停用'"
|
||||
></span>
|
||||
<code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded">{{ alias.alias }}</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs truncate max-w-[90px]"
|
||||
:title="alias.provider_name || 'Provider'"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider' }}
|
||||
</Badge>
|
||||
<Badge v-else variant="default" class="text-xs">全局</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('edit-alias', alias)"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggle-alias-status', alias)"
|
||||
:title="alias.is_active ? '停用' : '启用'"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('delete-alias', alias)"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="text-center py-12">
|
||||
<Tag class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground">暂无别名或映射</p>
|
||||
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-alias')">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加别名/映射
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
X,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Zap,
|
||||
Image,
|
||||
Building2,
|
||||
Tag,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Power,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Layers
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
|
||||
// 使用外部类型定义
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
import type { ModelAlias } from '@/api/endpoints/aliases'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
interface Props {
|
||||
model: GlobalModelResponse | null
|
||||
open: boolean
|
||||
providers: any[]
|
||||
aliases: ModelAlias[]
|
||||
loadingProviders?: boolean
|
||||
loadingAliases?: boolean
|
||||
hasBlockingDialogOpen?: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loadingProviders: false,
|
||||
loadingAliases: false,
|
||||
hasBlockingDialogOpen: false
|
||||
})
|
||||
|
||||
// 根据能力名称获取显示名称
|
||||
function getCapabilityDisplayName(capName: string): string {
|
||||
const cap = props.capabilities?.find(c => c.name === capName)
|
||||
return cap?.display_name || capName
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'edit-model': [model: GlobalModelResponse]
|
||||
'toggle-model-status': [model: GlobalModelResponse]
|
||||
'add-provider': []
|
||||
'edit-provider': [provider: any]
|
||||
'delete-provider': [provider: any]
|
||||
'toggle-provider-status': [provider: any]
|
||||
'refresh-providers': []
|
||||
'add-alias': []
|
||||
'edit-alias': [alias: ModelAlias]
|
||||
'toggle-alias-status': [alias: ModelAlias]
|
||||
'delete-alias': [alias: ModelAlias]
|
||||
'refresh-aliases': []
|
||||
}>()
|
||||
|
||||
const detailTab = ref('basic')
|
||||
|
||||
// 处理背景点击
|
||||
function handleBackdropClick() {
|
||||
if (!props.hasBlockingDialogOpen) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function handleClose() {
|
||||
if (!props.hasBlockingDialogOpen) {
|
||||
emit('update:open', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 从 tiered_pricing 获取第一阶梯的价格
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||
): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
const firstTier = tieredPricing.tiers[0]
|
||||
const value = firstTier[priceKey]
|
||||
if (value == null || value === 0) return '-'
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 获取阶梯数量
|
||||
function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): number {
|
||||
return tieredPricing?.tiers?.length || 0
|
||||
}
|
||||
|
||||
// 格式化阶梯上限(tokens 数量简化显示)
|
||||
function formatTierLimit(limit: number | null | undefined): string {
|
||||
if (limit == null) return ''
|
||||
if (limit >= 1000000) {
|
||||
return `${(limit / 1000000).toFixed(1)}M`
|
||||
} else if (limit >= 1000) {
|
||||
return `${(limit / 1000).toFixed(0)}K`
|
||||
}
|
||||
return limit.toString()
|
||||
}
|
||||
|
||||
// 获取 1h 缓存价格
|
||||
function get1hCachePrice(tier: PricingTier): string {
|
||||
const ttl1h = tier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
||||
if (ttl1h) {
|
||||
return `$${ttl1h.cache_creation_price_per_1m.toFixed(2)}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 获取第一阶梯的 1h 缓存价格
|
||||
function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | null): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
|
||||
// 监听 open 变化,重置 tab
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
// 直接设置为 basic,不需要先重置为空
|
||||
detailTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 抽屉过渡动画 */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active .relative,
|
||||
.drawer-leave-active .relative {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-leave-to .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-enter-to .relative,
|
||||
.drawer-leave-from .relative {
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal file
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- 阶梯列表 -->
|
||||
<div
|
||||
v-for="(tier, index) in localTiers"
|
||||
:key="index"
|
||||
class="p-3 border rounded-lg bg-muted/20 space-y-3"
|
||||
>
|
||||
<!-- 阶梯头部 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<template v-if="index < localTiers.length - 1">
|
||||
<template v-if="customInputMode[index]">
|
||||
<Input
|
||||
v-model="customInputValue[index]"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-7 w-20 text-sm"
|
||||
placeholder="K"
|
||||
@keyup.enter="confirmCustomInput(index)"
|
||||
@blur="confirmCustomInput(index)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">K</span>
|
||||
</template>
|
||||
<select
|
||||
v-else
|
||||
:value="getSelectValue(index)"
|
||||
class="h-7 px-2 text-sm border rounded bg-background"
|
||||
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="opt in getAvailableThresholds(index)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<span v-else class="font-medium">无上限</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="localTiers.length > 1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 价格输入 -->
|
||||
<div :class="['grid gap-3', showCache1h ? 'grid-cols-5' : 'grid-cols-4']">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输入 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.input_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输出 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.output_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">缓存创建</Label>
|
||||
<Input
|
||||
:model-value="getCacheCreationDisplay(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:placeholder="getCacheCreationPlaceholder(index)"
|
||||
@update:model-value="(v) => updateCacheCreation(index, v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">缓存读取</Label>
|
||||
<Input
|
||||
:model-value="getCacheReadDisplay(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:placeholder="getCacheReadPlaceholder(index)"
|
||||
@update:model-value="(v) => updateCacheRead(index, v)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showCache1h" class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">1h 缓存创建</Label>
|
||||
<Input
|
||||
:model-value="getCache1hDisplay(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:placeholder="getCache1hPlaceholder(index)"
|
||||
@update:model-value="(v) => updateCache1h(index, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加阶梯按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addTier"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
|
||||
<!-- 验证提示 -->
|
||||
<p v-if="validationError" class="text-xs text-destructive">
|
||||
{{ validationError }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Button, Input, Label } from '@/components/ui'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: TieredPricingConfig | null
|
||||
showCache1h?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TieredPricingConfig | null]
|
||||
}>()
|
||||
|
||||
// 本地状态
|
||||
const localTiers = ref<PricingTier[]>([])
|
||||
|
||||
// 跟踪每个阶梯的缓存价格是否被手动设置
|
||||
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
||||
|
||||
// 预设的阶梯上限选项
|
||||
const THRESHOLD_OPTIONS = [
|
||||
{ value: 64000, label: '64K' },
|
||||
{ value: 128000, label: '128K' },
|
||||
{ value: 200000, label: '200K' },
|
||||
{ value: 500000, label: '500K' },
|
||||
{ value: 1000000, label: '1M' },
|
||||
{ value: -1, label: '自定义...' }, // 特殊值表示自定义输入
|
||||
]
|
||||
|
||||
// 跟踪哪些阶梯正在使用自定义输入
|
||||
const customInputMode = reactive<Record<number, boolean>>({})
|
||||
const customInputValue = reactive<Record<number, string>>({})
|
||||
|
||||
// 初始化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue?.tiers) {
|
||||
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
||||
// 如果已有缓存价格,标记为手动设置
|
||||
newValue.tiers.forEach((t, i) => {
|
||||
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
|
||||
cacheManuallySet[i] = {
|
||||
creation: t.cache_creation_price_per_1m != null,
|
||||
read: t.cache_read_price_per_1m != null,
|
||||
cache1h: has1hCache,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
localTiers.value = [{
|
||||
up_to: null,
|
||||
input_price_per_1m: 0,
|
||||
output_price_per_1m: 0,
|
||||
}]
|
||||
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听 showCache1h 变化
|
||||
watch(
|
||||
() => props.showCache1h,
|
||||
(newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
// 取消勾选时,清除本地的 1h 缓存数据和手动设置标记
|
||||
localTiers.value.forEach((tier, i) => {
|
||||
tier.cache_ttl_pricing = undefined
|
||||
if (cacheManuallySet[i]) {
|
||||
cacheManuallySet[i].cache1h = false
|
||||
}
|
||||
})
|
||||
syncToParent()
|
||||
} else if (oldValue === false && newValue === true) {
|
||||
// 勾选时,同步自动计算的价格到父组件
|
||||
syncToParent()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 验证错误
|
||||
const validationError = computed(() => {
|
||||
if (localTiers.value.length === 0) {
|
||||
return '至少需要一个价格阶梯'
|
||||
}
|
||||
|
||||
if (localTiers.value[localTiers.value.length - 1].up_to !== null) {
|
||||
return '最后一个阶梯必须是无上限的'
|
||||
}
|
||||
|
||||
let prevUpTo = 0
|
||||
for (let i = 0; i < localTiers.value.length - 1; i++) {
|
||||
const tier = localTiers.value[i]
|
||||
if (tier.up_to === null || tier.up_to <= prevUpTo) {
|
||||
return `阶梯 ${i + 1} 的上限必须大于前一个阶梯`
|
||||
}
|
||||
prevUpTo = tier.up_to
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// 获取阶梯起始标签
|
||||
function getTierStartLabel(index: number): string {
|
||||
if (index === 0) return '0'
|
||||
const prevTier = localTiers.value[index - 1]
|
||||
if (prevTier.up_to === null) return '0'
|
||||
return formatTokens(prevTier.up_to)
|
||||
}
|
||||
|
||||
// 获取可用的阈值选项
|
||||
function getAvailableThresholds(index: number) {
|
||||
const usedThresholds = new Set<number>()
|
||||
localTiers.value.forEach((t, i) => {
|
||||
if (i !== index && t.up_to !== null) {
|
||||
usedThresholds.add(t.up_to)
|
||||
}
|
||||
})
|
||||
|
||||
const minValue = index > 0 ? (localTiers.value[index - 1].up_to || 0) : 0
|
||||
const currentValue = localTiers.value[index].up_to
|
||||
|
||||
// 过滤可用的预设选项
|
||||
const options = THRESHOLD_OPTIONS.filter(opt =>
|
||||
(opt.value === -1) || // "自定义..."始终显示
|
||||
(!usedThresholds.has(opt.value) && opt.value > minValue)
|
||||
)
|
||||
|
||||
// 如果当前值是自定义的(不在预设中),添加到选项列表
|
||||
if (currentValue !== null && !THRESHOLD_OPTIONS.some(opt => opt.value === currentValue)) {
|
||||
options.unshift({ value: currentValue, label: formatTokens(currentValue) })
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// 格式化 token 数量
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(0)}K`
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
// 缓存价格自动计算
|
||||
function getAutoCacheCreation(index: number): number {
|
||||
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
|
||||
return parseFloat((inputPrice * 1.25).toFixed(2))
|
||||
}
|
||||
|
||||
function getAutoCacheRead(index: number): number {
|
||||
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
|
||||
return parseFloat((inputPrice * 0.1).toFixed(2))
|
||||
}
|
||||
|
||||
function getAutoCache1h(index: number): number {
|
||||
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
|
||||
return parseFloat((inputPrice * 2).toFixed(2))
|
||||
}
|
||||
|
||||
function getCacheCreationPlaceholder(index: number): string {
|
||||
const auto = getAutoCacheCreation(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
function getCacheReadPlaceholder(index: number): string {
|
||||
const auto = getAutoCacheRead(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
function getCache1hPlaceholder(index: number): string {
|
||||
const auto = getAutoCache1h(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
function getCacheCreationDisplay(index: number): string | number {
|
||||
const tier = localTiers.value[index]
|
||||
if (cacheManuallySet[index]?.creation && tier?.cache_creation_price_per_1m != null) {
|
||||
// 修复浮点数精度问题
|
||||
return parseFloat(tier.cache_creation_price_per_1m.toFixed(4))
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getCacheReadDisplay(index: number): string | number {
|
||||
const tier = localTiers.value[index]
|
||||
if (cacheManuallySet[index]?.read && tier?.cache_read_price_per_1m != null) {
|
||||
// 修复浮点数精度问题
|
||||
return parseFloat(tier.cache_read_price_per_1m.toFixed(4))
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getCache1hDisplay(index: number): string | number {
|
||||
const tier = localTiers.value[index]
|
||||
// 只有手动设置过才显示值
|
||||
if (cacheManuallySet[index]?.cache1h) {
|
||||
const ttl1h = tier?.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
||||
if (ttl1h) {
|
||||
// 修复浮点数精度问题
|
||||
return parseFloat(ttl1h.cache_creation_price_per_1m.toFixed(4))
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 同步到父组件(包含自动计算的缓存价格)
|
||||
function syncToParent() {
|
||||
if (validationError.value) return
|
||||
|
||||
const tiers: PricingTier[] = localTiers.value.map((t, i) => {
|
||||
const tier: PricingTier = {
|
||||
up_to: t.up_to,
|
||||
input_price_per_1m: t.input_price_per_1m,
|
||||
output_price_per_1m: t.output_price_per_1m,
|
||||
}
|
||||
|
||||
// 缓存创建价格:手动设置则用设置值,否则自动计算
|
||||
if (cacheManuallySet[i]?.creation && t.cache_creation_price_per_1m != null) {
|
||||
tier.cache_creation_price_per_1m = t.cache_creation_price_per_1m
|
||||
} else if (t.input_price_per_1m > 0) {
|
||||
tier.cache_creation_price_per_1m = getAutoCacheCreation(i)
|
||||
}
|
||||
|
||||
// 缓存读取价格:手动设置则用设置值,否则自动计算
|
||||
if (cacheManuallySet[i]?.read && t.cache_read_price_per_1m != null) {
|
||||
tier.cache_read_price_per_1m = t.cache_read_price_per_1m
|
||||
} else if (t.input_price_per_1m > 0) {
|
||||
tier.cache_read_price_per_1m = getAutoCacheRead(i)
|
||||
}
|
||||
|
||||
// 缓存 TTL 价格(1h 缓存)- 只有启用 1h 缓存能力时才处理
|
||||
if (props.showCache1h) {
|
||||
if (cacheManuallySet[i]?.cache1h && t.cache_ttl_pricing?.length) {
|
||||
// 手动设置则用设置值
|
||||
tier.cache_ttl_pricing = t.cache_ttl_pricing
|
||||
} else if (t.input_price_per_1m > 0) {
|
||||
// 否则自动计算
|
||||
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: getAutoCache1h(i) }]
|
||||
}
|
||||
}
|
||||
|
||||
return tier
|
||||
})
|
||||
|
||||
emit('update:modelValue', { tiers })
|
||||
}
|
||||
|
||||
function parseFloatInput(value: string | number): number {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||
return isNaN(num) ? 0 : num
|
||||
}
|
||||
|
||||
// 更新输入价格(会触发缓存价格自动更新)
|
||||
function updateInputPrice(index: number, value: number) {
|
||||
localTiers.value[index].input_price_per_1m = value
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateOutputPrice(index: number, value: number) {
|
||||
localTiers.value[index].output_price_per_1m = value
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
// 获取下拉框当前选中值
|
||||
function getSelectValue(index: number): number {
|
||||
const upTo = localTiers.value[index].up_to
|
||||
if (upTo === null) return -1
|
||||
return upTo // 直接返回当前值,让下拉框显示对应选项
|
||||
}
|
||||
|
||||
// 处理下拉框选择变化
|
||||
function handleThresholdChange(index: number, value: number) {
|
||||
if (value === -1) {
|
||||
// 选择了"自定义...",进入自定义输入模式
|
||||
customInputMode[index] = true
|
||||
customInputValue[index] = ''
|
||||
} else {
|
||||
localTiers.value[index].up_to = value
|
||||
syncToParent()
|
||||
}
|
||||
}
|
||||
|
||||
// 确认自定义输入
|
||||
function confirmCustomInput(index: number) {
|
||||
const inputK = parseInt(customInputValue[index])
|
||||
if (inputK > 0) {
|
||||
localTiers.value[index].up_to = inputK * 1000
|
||||
syncToParent()
|
||||
}
|
||||
customInputMode[index] = false
|
||||
}
|
||||
|
||||
function updateCacheCreation(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
if (numValue > 0) {
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: true }
|
||||
localTiers.value[index].cache_creation_price_per_1m = numValue
|
||||
} else {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: false }
|
||||
localTiers.value[index].cache_creation_price_per_1m = undefined
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateCacheRead(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
if (numValue > 0) {
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], read: true }
|
||||
localTiers.value[index].cache_read_price_per_1m = numValue
|
||||
} else {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], read: false }
|
||||
localTiers.value[index].cache_read_price_per_1m = undefined
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateCache1h(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
const tier = localTiers.value[index]
|
||||
if (numValue > 0) {
|
||||
// 手动设置 1 小时缓存创建价格
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: true }
|
||||
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: numValue }]
|
||||
} else {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: false }
|
||||
tier.cache_ttl_pricing = undefined
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
// 阶梯操作
|
||||
function addTier() {
|
||||
if (localTiers.value.length === 0) {
|
||||
localTiers.value = [{
|
||||
up_to: null,
|
||||
input_price_per_1m: 0,
|
||||
output_price_per_1m: 0,
|
||||
}]
|
||||
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
||||
} else {
|
||||
// 把当前最后一个阶梯(无上限)改为有上限
|
||||
const lastTier = localTiers.value[localTiers.value.length - 1]
|
||||
const secondLastTier = localTiers.value[localTiers.value.length - 2]
|
||||
const minValue = secondLastTier?.up_to || 0
|
||||
const availableThresholds = THRESHOLD_OPTIONS.filter(opt => opt.value > minValue)
|
||||
const newUpTo = availableThresholds[0]?.value || minValue + 200000
|
||||
|
||||
// 给当前最后一个阶梯设置上限
|
||||
lastTier.up_to = newUpTo
|
||||
|
||||
// 添加新的无上限阶梯
|
||||
const newIndex = localTiers.value.length
|
||||
const newTier: PricingTier = {
|
||||
up_to: null,
|
||||
input_price_per_1m: 0,
|
||||
output_price_per_1m: 0,
|
||||
}
|
||||
|
||||
localTiers.value.push(newTier)
|
||||
cacheManuallySet[newIndex] = { creation: false, read: false, cache1h: false }
|
||||
}
|
||||
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
if (localTiers.value.length <= 1) return
|
||||
localTiers.value.splice(index, 1)
|
||||
delete cacheManuallySet[index]
|
||||
|
||||
// 重新整理 cacheManuallySet 的索引
|
||||
const newManuallySet: Record<number, { creation: boolean; read: boolean }> = {}
|
||||
localTiers.value.forEach((_, i) => {
|
||||
newManuallySet[i] = cacheManuallySet[i] || { creation: false, read: false }
|
||||
})
|
||||
Object.keys(cacheManuallySet).forEach(k => delete cacheManuallySet[Number(k)])
|
||||
Object.assign(cacheManuallySet, newManuallySet)
|
||||
|
||||
if (localTiers.value.length > 0) {
|
||||
localTiers.value[localTiers.value.length - 1].up_to = null
|
||||
}
|
||||
|
||||
syncToParent()
|
||||
}
|
||||
</script>
|
||||
4
frontend/src/features/models/components/index.ts
Normal file
4
frontend/src/features/models/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
||||
export { default as AliasDialog } from './AliasDialog.vue'
|
||||
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
||||
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
||||
1
frontend/src/features/models/index.ts
Normal file
1
frontend/src/features/models/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
@update:model-value="$emit('update:open', $event)"
|
||||
title="批量添加关联模型"
|
||||
description="为提供商批量添加模型实现,提供商将自动继承模型的价格和能力,可在添加后单独修改"
|
||||
:icon="Layers"
|
||||
size="4xl"
|
||||
>
|
||||
<template #default>
|
||||
<div class="space-y-4">
|
||||
<!-- 提供商信息头部 -->
|
||||
<div class="rounded-lg border bg-muted/30 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ providerName }}</p>
|
||||
<p class="text-sm text-muted-foreground font-mono">{{ providerIdentifier }}</p>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
当前 {{ existingModels.length }} 个模型
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左右对比布局 -->
|
||||
<div class="flex gap-2 items-stretch">
|
||||
<!-- 左侧:可添加的模型 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">可添加</p>
|
||||
<Button
|
||||
v-if="availableModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllLeft"
|
||||
>
|
||||
{{ isAllLeftSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ availableModels.length }} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div v-if="loadingGlobalModels" class="flex items-center justify-center h-full">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
<div v-else-if="availableModels.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">所有模型均已关联</p>
|
||||
</div>
|
||||
<div v-else class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
|
||||
:class="selectedLeftIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50 cursor-pointer'"
|
||||
@click="toggleLeftSelection(model.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedLeftIds.includes(model.id)"
|
||||
@update:checked="toggleLeftSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">{{ model.display_name }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:操作按钮 -->
|
||||
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-9 h-8"
|
||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||
:disabled="selectedLeftIds.length === 0 || submittingAdd"
|
||||
@click="batchAddSelected"
|
||||
title="添加选中"
|
||||
>
|
||||
<Loader2 v-if="submittingAdd" class="w-4 h-4 animate-spin" />
|
||||
<ChevronRight v-else class="w-6 h-6 stroke-[3]" :class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-9 h-8"
|
||||
:class="selectedRightIds.length > 0 && !submittingRemove ? 'border-primary' : ''"
|
||||
:disabled="selectedRightIds.length === 0 || submittingRemove"
|
||||
@click="batchRemoveSelected"
|
||||
title="移除选中"
|
||||
>
|
||||
<Loader2 v-if="submittingRemove" class="w-4 h-4 animate-spin" />
|
||||
<ChevronLeft v-else class="w-6 h-6 stroke-[3]" :class="selectedRightIds.length > 0 && !submittingRemove ? 'text-primary' : ''" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已添加的模型 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">已添加</p>
|
||||
<Button
|
||||
v-if="existingModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllRight"
|
||||
>
|
||||
{{ isAllRightSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ existingModels.length }} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div v-if="existingModels.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">暂无关联模型</p>
|
||||
</div>
|
||||
<div v-else class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="model in existingModels"
|
||||
:key="'existing-' + model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedRightIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50'"
|
||||
@click="toggleRightSelection(model.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedRightIds.includes(model.id)"
|
||||
@update:checked="toggleRightSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">{{ model.global_model_display_name || model.provider_model_name }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.provider_model_name }}</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
getGlobalModels,
|
||||
type GlobalModelResponse
|
||||
} from '@/api/endpoints/global-models'
|
||||
import {
|
||||
getProviderModels,
|
||||
batchAssignModelsToProvider,
|
||||
deleteModel,
|
||||
type Model
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerIdentifier: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
|
||||
// 状态
|
||||
const loadingGlobalModels = ref(false)
|
||||
const submittingAdd = ref(false)
|
||||
const submittingRemove = ref(false)
|
||||
|
||||
// 数据
|
||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const existingModels = ref<Model[]>([])
|
||||
|
||||
// 选择状态
|
||||
const selectedLeftIds = ref<string[]>([])
|
||||
const selectedRightIds = ref<string[]>([])
|
||||
|
||||
// 计算可添加的模型(排除已关联的)
|
||||
const availableModels = computed(() => {
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.value
|
||||
.filter(m => m.global_model_id)
|
||||
.map(m => m.global_model_id)
|
||||
)
|
||||
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
||||
})
|
||||
|
||||
// 全选状态
|
||||
const isAllLeftSelected = computed(() =>
|
||||
availableModels.value.length > 0 &&
|
||||
selectedLeftIds.value.length === availableModels.value.length
|
||||
)
|
||||
|
||||
const isAllRightSelected = computed(() =>
|
||||
existingModels.value.length > 0 &&
|
||||
selectedRightIds.value.length === existingModels.value.length
|
||||
)
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
// 重置状态
|
||||
selectedLeftIds.value = []
|
||||
selectedRightIds.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||
}
|
||||
|
||||
// 加载全局模型列表
|
||||
async function loadGlobalModels() {
|
||||
try {
|
||||
loadingGlobalModels.value = true
|
||||
const response = await getGlobalModels({ limit: 1000 })
|
||||
allGlobalModels.value = response.models
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '加载全局模型失败'), '错误')
|
||||
} finally {
|
||||
loadingGlobalModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载已关联的模型
|
||||
async function loadExistingModels() {
|
||||
try {
|
||||
existingModels.value = await getProviderModels(props.providerId)
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '加载已关联模型失败'), '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换左侧选择
|
||||
function toggleLeftSelection(id: string) {
|
||||
const index = selectedLeftIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedLeftIds.value.push(id)
|
||||
} else {
|
||||
selectedLeftIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换右侧选择
|
||||
function toggleRightSelection(id: string) {
|
||||
const index = selectedRightIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedRightIds.value.push(id)
|
||||
} else {
|
||||
selectedRightIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选左侧
|
||||
function toggleSelectAllLeft() {
|
||||
if (isAllLeftSelected.value) {
|
||||
selectedLeftIds.value = []
|
||||
} else {
|
||||
selectedLeftIds.value = availableModels.value.map(m => m.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选右侧
|
||||
function toggleSelectAllRight() {
|
||||
if (isAllRightSelected.value) {
|
||||
selectedRightIds.value = []
|
||||
} else {
|
||||
selectedRightIds.value = existingModels.value.map(m => m.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量添加选中的模型
|
||||
async function batchAddSelected() {
|
||||
if (selectedLeftIds.value.length === 0) return
|
||||
|
||||
try {
|
||||
submittingAdd.value = true
|
||||
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
|
||||
|
||||
if (result.success.length > 0) {
|
||||
success(`成功添加 ${result.success.length} 个模型`)
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const errorMessages = result.errors.map(e => e.error).join(', ')
|
||||
showError(`部分模型添加失败: ${errorMessages}`, '警告')
|
||||
}
|
||||
|
||||
selectedLeftIds.value = []
|
||||
await loadExistingModels()
|
||||
emit('changed')
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '批量添加失败'), '错误')
|
||||
} finally {
|
||||
submittingAdd.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量移除选中的模型
|
||||
async function batchRemoveSelected() {
|
||||
if (selectedRightIds.value.length === 0) return
|
||||
|
||||
try {
|
||||
submittingRemove.value = true
|
||||
let successCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const modelId of selectedRightIds.value) {
|
||||
try {
|
||||
await deleteModel(props.providerId, modelId)
|
||||
successCount++
|
||||
} catch (err: any) {
|
||||
errors.push(parseApiError(err, '删除失败'))
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
success(`成功移除 ${successCount} 个模型`)
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
showError(`部分模型移除失败: ${errors.join(', ')}`, '警告')
|
||||
}
|
||||
|
||||
selectedRightIds.value = []
|
||||
await loadExistingModels()
|
||||
emit('changed')
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '批量移除失败'), '错误')
|
||||
} finally {
|
||||
submittingRemove.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
:title="isEditMode ? '编辑 API 端点' : '添加 API 端点'"
|
||||
:description="isEditMode ? `修改 ${provider?.display_name} 的端点配置` : '为提供商添加新的 API 端点'"
|
||||
:icon="isEditMode ? SquarePen : Link"
|
||||
size="xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- API 配置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 v-if="isEditMode" class="text-sm font-medium">API 配置</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- API 格式 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="api_format">API 格式 *</Label>
|
||||
<template v-if="isEditMode">
|
||||
<Input
|
||||
id="api_format"
|
||||
v-model="form.api_format"
|
||||
disabled
|
||||
class="bg-muted"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">API 格式创建后不可修改</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Select v-model="form.api_format" v-model:open="selectOpen" required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择 API 格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="format in apiFormats"
|
||||
:key="format.value"
|
||||
:value="format.value"
|
||||
>
|
||||
{{ format.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- API URL -->
|
||||
<div class="space-y-2">
|
||||
<Label for="base_url">API URL *</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
v-model="form.base_url"
|
||||
placeholder="https://api.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义路径 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="custom_path">自定义请求路径(可选)</Label>
|
||||
<Input
|
||||
id="custom_path"
|
||||
v-model="form.custom_path"
|
||||
:placeholder="defaultPathPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求配置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">请求配置</h3>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="timeout">超时(秒)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
v-model.number="form.timeout"
|
||||
type="number"
|
||||
placeholder="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="max_retries">最大重试</Label>
|
||||
<Input
|
||||
id="max_retries"
|
||||
v-model.number="form.max_retries"
|
||||
type="number"
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="max_concurrent">最大并发</Label>
|
||||
<Input
|
||||
id="max_concurrent"
|
||||
:model-value="form.max_concurrent ?? ''"
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="rate_limit">速率限制(请求/分钟)</Label>
|
||||
<Input
|
||||
id="rate_limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
@click="handleCancel"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleSubmit"
|
||||
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Link, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
createEndpoint,
|
||||
updateEndpoint,
|
||||
type ProviderEndpoint,
|
||||
type ProviderWithEndpointsSummary
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider: ProviderWithEndpointsSummary | null
|
||||
endpoint?: ProviderEndpoint | null // 编辑模式时传入
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'endpoint-created': []
|
||||
'endpoint-updated': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const selectOpen = ref(false)
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
api_format: '',
|
||||
base_url: '',
|
||||
custom_path: '',
|
||||
timeout: 300,
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
// API 格式列表
|
||||
const apiFormats = ref<Array<{ value: string; label: string; default_path: string; aliases: string[] }>>([])
|
||||
|
||||
// 加载API格式列表
|
||||
const loadApiFormats = async () => {
|
||||
try {
|
||||
const response = await adminApi.getApiFormats()
|
||||
apiFormats.value = response.formats
|
||||
} catch (error) {
|
||||
console.error('加载API格式失败:', error)
|
||||
if (!isEditMode.value) {
|
||||
showError('加载API格式失败', '错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选择的 API 格式计算默认路径
|
||||
const defaultPath = computed(() => {
|
||||
const format = apiFormats.value.find(f => f.value === form.value.api_format)
|
||||
return format?.default_path || '/'
|
||||
})
|
||||
|
||||
// 动态 placeholder
|
||||
const defaultPathPlaceholder = computed(() => {
|
||||
return `留空使用默认路径:${defaultPath.value}`
|
||||
})
|
||||
|
||||
// 组件挂载时加载API格式
|
||||
onMounted(() => {
|
||||
loadApiFormats()
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
api_format: '',
|
||||
base_url: '',
|
||||
custom_path: '',
|
||||
timeout: 300,
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载端点数据(编辑模式)
|
||||
function loadEndpointData() {
|
||||
if (!props.endpoint) return
|
||||
|
||||
form.value = {
|
||||
api_format: props.endpoint.api_format,
|
||||
base_url: props.endpoint.base_url,
|
||||
custom_path: props.endpoint.custom_path || '',
|
||||
timeout: props.endpoint.timeout,
|
||||
max_retries: props.endpoint.max_retries,
|
||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
||||
rate_limit: props.endpoint.rate_limit || undefined,
|
||||
is_active: props.endpoint.is_active
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.modelValue,
|
||||
entity: () => props.endpoint,
|
||||
isLoading: loading,
|
||||
onClose: () => emit('update:modelValue', false),
|
||||
loadData: loadEndpointData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!props.provider && !props.endpoint) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEditMode.value && props.endpoint) {
|
||||
// 更新端点
|
||||
await updateEndpoint(props.endpoint.id, {
|
||||
base_url: form.value.base_url,
|
||||
custom_path: form.value.custom_path || undefined,
|
||||
timeout: form.value.timeout,
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
})
|
||||
|
||||
success('端点已更新', '保存成功')
|
||||
emit('endpoint-updated')
|
||||
} else if (props.provider) {
|
||||
// 创建端点
|
||||
await createEndpoint(props.provider.id, {
|
||||
provider_id: props.provider.id,
|
||||
api_format: form.value.api_format,
|
||||
base_url: form.value.base_url,
|
||||
custom_path: form.value.custom_path || undefined,
|
||||
timeout: form.value.timeout,
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
})
|
||||
|
||||
success('端点创建成功', '成功')
|
||||
emit('endpoint-created')
|
||||
resetForm()
|
||||
}
|
||||
|
||||
emit('update:modelValue', false)
|
||||
} catch (error: any) {
|
||||
const action = isEditMode.value ? '更新' : '创建'
|
||||
showError(error.response?.data?.detail || `${action}端点失败`, '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="w-full space-y-1">
|
||||
<!-- 时间线 -->
|
||||
<div class="flex items-center gap-px h-6 w-full">
|
||||
<TooltipProvider v-for="(segment, index) in segments" :key="index" :delay-duration="100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="flex-1 h-full rounded-sm transition-all duration-150 cursor-pointer hover:scale-y-110 hover:brightness-110"
|
||||
:class="segment.color"
|
||||
></div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" :side-offset="8" class="max-w-xs">
|
||||
<div class="text-xs whitespace-pre-line">{{ segment.tooltip }}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<!-- 时间标签 -->
|
||||
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{{ earliestTime }}</span>
|
||||
<span>{{ latestTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { EndpointStatusMonitor, EndpointHealthEvent, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
// 组件同时支持管理员端和用户端的监控数据类型
|
||||
// - EndpointStatusMonitor: 管理员端,包含 provider_count, key_count 等敏感信息
|
||||
// - PublicEndpointStatusMonitor: 用户端,不含敏感信息
|
||||
const props = defineProps<{
|
||||
monitor?: EndpointStatusMonitor | PublicEndpointStatusMonitor | null
|
||||
segmentCount?: number
|
||||
lookbackHours?: number
|
||||
}>()
|
||||
|
||||
// 固定格子数量,将实际事件按时间均匀分布到格子中
|
||||
const GRID_COUNT = 100
|
||||
|
||||
const segments = computed(() => {
|
||||
const gridCount = props.segmentCount ?? GRID_COUNT
|
||||
const lookbackHours = props.lookbackHours ?? 6
|
||||
const usageTimeline = Array.isArray(props.monitor?.timeline)
|
||||
? props.monitor?.timeline ?? []
|
||||
: []
|
||||
|
||||
if (usageTimeline.length > 0) {
|
||||
return buildUsageTimelineSegments(
|
||||
usageTimeline,
|
||||
props.monitor?.time_range_start ?? null,
|
||||
props.monitor?.time_range_end ?? null,
|
||||
lookbackHours
|
||||
)
|
||||
}
|
||||
|
||||
const events = props.monitor?.events ?? []
|
||||
|
||||
// 无数据时显示空白格子
|
||||
if (events.length === 0) {
|
||||
return Array.from({ length: gridCount }, () => ({
|
||||
color: 'bg-gray-300 dark:bg-gray-600',
|
||||
tooltip: '暂无请求记录'
|
||||
}))
|
||||
}
|
||||
|
||||
// 计算时间范围:使用 UTC 时间戳避免时区问题
|
||||
const nowUtc = Date.now()
|
||||
const startTimeUtc = nowUtc - lookbackHours * 60 * 60 * 1000
|
||||
const timeRange = lookbackHours * 60 * 60 * 1000
|
||||
const timePerGrid = timeRange / gridCount
|
||||
|
||||
const gridEvents: Array<Array<EndpointHealthEvent | PublicHealthEvent>> = Array.from({ length: gridCount }, () => [])
|
||||
|
||||
for (const event of events) {
|
||||
const eventTime = new Date(event.timestamp).getTime()
|
||||
const gridIndex = Math.floor((eventTime - startTimeUtc) / timePerGrid)
|
||||
if (gridIndex >= 0 && gridIndex < gridCount) {
|
||||
gridEvents[gridIndex].push(event)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Array<{ color: string; tooltip: string }> = []
|
||||
|
||||
for (let i = 0; i < gridCount; i++) {
|
||||
const cellEvents = gridEvents[i]
|
||||
const cellStartTime = new Date(startTimeUtc + i * timePerGrid)
|
||||
const cellEndTime = new Date(startTimeUtc + (i + 1) * timePerGrid)
|
||||
|
||||
if (cellEvents.length === 0) {
|
||||
result.push({
|
||||
color: 'bg-gray-300 dark:bg-gray-600',
|
||||
tooltip: `${formatTimestamp(cellStartTime.toISOString())} - ${formatTimestamp(cellEndTime.toISOString())}\n暂无请求记录`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (cellEvents.length === 1) {
|
||||
result.push({
|
||||
color: getStatusColor(cellEvents[0].status),
|
||||
tooltip: buildTooltip(cellEvents[0])
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const successCount = cellEvents.filter(e => e.status === 'success').length
|
||||
const failedCount = cellEvents.filter(e => e.status === 'failed').length
|
||||
const skippedCount = cellEvents.filter(e => e.status === 'skipped').length
|
||||
const total = cellEvents.length
|
||||
|
||||
let color: string
|
||||
if (failedCount > 0) {
|
||||
const failRate = failedCount / total
|
||||
color = failRate > 0.5 ? 'bg-red-500' : 'bg-red-400/80'
|
||||
} else if (successCount > 0) {
|
||||
const successRate = successCount / total
|
||||
color = successRate > 0.7 ? 'bg-green-500/80' : 'bg-green-400/80'
|
||||
} else if (skippedCount > 0) {
|
||||
color = 'bg-amber-400/80'
|
||||
} else {
|
||||
color = 'bg-gray-300 dark:bg-gray-600'
|
||||
}
|
||||
|
||||
const firstTime = formatTimestamp(cellEvents[0]?.timestamp)
|
||||
const lastTime = formatTimestamp(cellEvents[cellEvents.length - 1]?.timestamp)
|
||||
const tooltip = `${firstTime} - ${lastTime}\n共 ${total} 次请求\n成功: ${successCount}, 失败: ${failedCount}, 跳过: ${skippedCount}`
|
||||
|
||||
result.push({ color, tooltip })
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'bg-green-500/80 dark:bg-green-400/90'
|
||||
case 'failed':
|
||||
return 'bg-red-500/80 dark:bg-red-400/90'
|
||||
case 'skipped':
|
||||
return 'bg-amber-400/80 dark:bg-amber-300/80'
|
||||
case 'started':
|
||||
return 'bg-blue-400/80 dark:bg-blue-300/80'
|
||||
default:
|
||||
return 'bg-muted/50 dark:bg-muted/20'
|
||||
}
|
||||
}
|
||||
|
||||
function buildTooltip(event: EndpointHealthEvent | PublicHealthEvent) {
|
||||
const time = formatTimestamp(event.timestamp)
|
||||
const statusText = getStatusText(event.status)
|
||||
const latency = event.latency_ms ? ` • ${event.latency_ms}ms` : ''
|
||||
const code = event.status_code ? ` • ${event.status_code}` : ''
|
||||
const error = event.error_type ? ` • ${event.error_type}` : ''
|
||||
return `${time} ${statusText}${latency}${code}${error}`
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
case 'skipped':
|
||||
return '跳过'
|
||||
case 'started':
|
||||
return '执行中'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp?: string | null) {
|
||||
if (!timestamp) return '未知时间'
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 计算时间范围显示
|
||||
const earliestTime = computed(() => {
|
||||
const explicitStart =
|
||||
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_start
|
||||
if (explicitStart) return formatTimestamp(explicitStart)
|
||||
const lookbackHours = props.lookbackHours ?? 6
|
||||
const startTime = new Date(Date.now() - lookbackHours * 60 * 60 * 1000)
|
||||
return formatTimestamp(startTime.toISOString())
|
||||
})
|
||||
|
||||
const latestTime = computed(() => {
|
||||
const explicitEnd =
|
||||
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_end
|
||||
if (explicitEnd) return formatTimestamp(explicitEnd)
|
||||
return formatTimestamp(new Date().toISOString())
|
||||
})
|
||||
|
||||
function buildUsageTimelineSegments(
|
||||
statuses: string[],
|
||||
timeRangeStart: string | null,
|
||||
timeRangeEnd: string | null,
|
||||
lookbackHours: number
|
||||
) {
|
||||
const gridCount = statuses.length
|
||||
const endTime = timeRangeEnd ? new Date(timeRangeEnd).getTime() : Date.now()
|
||||
const startTime = timeRangeStart
|
||||
? new Date(timeRangeStart).getTime()
|
||||
: endTime - lookbackHours * 60 * 60 * 1000
|
||||
const safeRange = Math.max(endTime - startTime, 1)
|
||||
const interval = safeRange / gridCount
|
||||
|
||||
return statuses.map((status, index) => {
|
||||
const cellStart = new Date(startTime + index * interval)
|
||||
const cellEnd = new Date(startTime + (index + 1) * interval)
|
||||
return {
|
||||
color: getHealthTimelineColor(status),
|
||||
tooltip: `${formatTimestamp(cellStart.toISOString())} - ${formatTimestamp(
|
||||
cellEnd.toISOString()
|
||||
)}\n状态:${getHealthTimelineLabel(status)}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getHealthTimelineColor(status: string) {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500/80 dark:bg-green-400/90'
|
||||
case 'warning':
|
||||
return 'bg-amber-400/80 dark:bg-amber-300/80'
|
||||
case 'unhealthy':
|
||||
return 'bg-red-500/80 dark:bg-red-400/90'
|
||||
default:
|
||||
return 'bg-gray-300 dark:bg-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
function getHealthTimelineLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return '健康'
|
||||
case 'warning':
|
||||
return '警告'
|
||||
case 'unhealthy':
|
||||
return '异常'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
164
frontend/src/features/providers/components/HealthMonitorCard.vue
Normal file
164
frontend/src/features/providers/components/HealthMonitorCard.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">{{ title }}</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
|
||||
<Select v-model="lookbackHours" v-model:open="selectOpen">
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 小时</SelectItem>
|
||||
<SelectItem value="6">6 小时</SelectItem>
|
||||
<SelectItem value="12">12 小时</SelectItem>
|
||||
<SelectItem value="24">24 小时</SelectItem>
|
||||
<SelectItem value="48">48 小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<RefreshButton :loading="loading" @click="refreshData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="p-6">
|
||||
<div v-if="loadingMonitors" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span class="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="monitors.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Activity class="w-12 h-12 mb-3 opacity-30" />
|
||||
<p>暂无健康监控数据</p>
|
||||
<p class="text-xs mt-1">端点尚未产生请求记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="monitor in monitors"
|
||||
:key="monitor.api_format"
|
||||
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<!-- 左右结构布局 -->
|
||||
<div class="flex gap-6 items-center">
|
||||
<!-- 左侧:信息区域 -->
|
||||
<div class="w-44 flex-shrink-0 space-y-1.5">
|
||||
<!-- API 格式标签和成功率 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono text-xs">
|
||||
{{ monitor.api_format }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="monitor.total_attempts > 0"
|
||||
:variant="getSuccessRateVariant(monitor.success_rate)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ (monitor.success_rate * 100).toFixed(0) }}%
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 提供商信息(仅管理员可见) -->
|
||||
<div v-if="showProviderInfo && 'provider_count' in monitor" class="text-xs text-muted-foreground">
|
||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:时间线区域 -->
|
||||
<div class="flex-1 min-w-0 flex justify-end">
|
||||
<div class="w-full max-w-5xl">
|
||||
<EndpointHealthTimeline
|
||||
:monitor="monitor"
|
||||
:lookback-hours="parseInt(lookbackHours)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Activity, Loader2 } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
|
||||
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
|
||||
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
isAdmin?: boolean
|
||||
showProviderInfo?: boolean
|
||||
}>(), {
|
||||
title: '健康监控',
|
||||
isAdmin: false,
|
||||
showProviderInfo: false
|
||||
})
|
||||
|
||||
const { error: showError } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMonitors = ref(false)
|
||||
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
|
||||
const lookbackHours = ref('6')
|
||||
const selectOpen = ref(false)
|
||||
|
||||
async function loadMonitors() {
|
||||
loadingMonitors.value = true
|
||||
try {
|
||||
const params = {
|
||||
lookback_hours: parseInt(lookbackHours.value),
|
||||
per_format_limit: 100
|
||||
}
|
||||
|
||||
if (props.isAdmin) {
|
||||
const data = await getEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
} else {
|
||||
const data = await getPublicEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载健康监控数据失败', '错误')
|
||||
} finally {
|
||||
loadingMonitors.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadMonitors()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (rate >= 0.95) return 'default'
|
||||
if (rate >= 0.8) return 'secondary'
|
||||
return 'destructive'
|
||||
}
|
||||
|
||||
watch(lookbackHours, () => {
|
||||
loadMonitors()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="配置允许的模型"
|
||||
description="选择该 API Key 允许访问的模型,留空则允许访问所有模型"
|
||||
:icon="Settings2"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
<!-- 已选模型展示 -->
|
||||
<div v-if="selectedModels.length > 0" class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">已选模型 ({{ selectedModels.length }})</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs hover:text-destructive"
|
||||
@click="clearModels"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 p-2 bg-muted/20 rounded-lg border border-border/40 min-h-[40px]">
|
||||
<Badge
|
||||
v-for="modelName in selectedModels"
|
||||
:key="modelName"
|
||||
variant="secondary"
|
||||
class="text-[11px] px-2 py-0.5 bg-background border-border/60 shadow-sm"
|
||||
>
|
||||
{{ getModelLabel(modelName) }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-destructive focus:outline-none"
|
||||
@click.stop="toggleModel(modelName, false)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表区域 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">可选模型列表</div>
|
||||
<div v-if="!loadingModels && availableModels.length > 0" class="text-[10px] text-muted-foreground/60">
|
||||
共 {{ availableModels.length }} 个模型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingModels" class="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary"></div>
|
||||
<span class="text-xs text-muted-foreground">正在加载模型列表...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无模型 -->
|
||||
<div v-else-if="availableModels.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10">
|
||||
<Box class="w-10 h-10 mb-2 opacity-20" />
|
||||
<span class="text-sm">暂无可选模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div v-else class="max-h-[320px] overflow-y-auto pr-1 space-y-1.5 custom-scrollbar">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.global_model_name"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200 cursor-pointer select-none"
|
||||
:class="[
|
||||
selectedModels.includes(model.global_model_name)
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/40 bg-background hover:border-primary/20 hover:bg-muted/30'
|
||||
]"
|
||||
@click="toggleModel(model.global_model_name, !selectedModels.includes(model.global_model_name))"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<Checkbox
|
||||
:checked="selectedModels.includes(model.global_model_name)"
|
||||
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
@click.stop
|
||||
@update:checked="checked => toggleModel(model.global_model_name, checked)"
|
||||
/>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate text-foreground/90">{{ model.display_name }}</span>
|
||||
<span v-if="hasPricing(model)" class="text-[10px] font-mono text-muted-foreground/80 bg-muted/30 px-1.5 py-0.5 rounded border border-border/30 shrink-0">
|
||||
{{ formatPricingShort(model) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
||||
{{ model.global_model_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 w-full pt-2">
|
||||
<Button @click="handleCancel" variant="outline" class="h-9">取消</Button>
|
||||
<Button @click="handleSave" :disabled="saving" class="h-9 min-w-[80px]">
|
||||
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
{{ saving ? '保存中' : '保存配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
apiKey: EndpointAPIKey | null
|
||||
providerId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
// 初始化已选模型
|
||||
if (props.apiKey?.allowed_models) {
|
||||
selectedModels.value = [...props.apiKey.allowed_models]
|
||||
initialModels.value = [...props.apiKey.allowed_models]
|
||||
} else {
|
||||
selectedModels.value = []
|
||||
initialModels.value = []
|
||||
}
|
||||
|
||||
// 加载可选模型
|
||||
if (props.providerId) {
|
||||
await loadAvailableModels()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
const response = await getProviderAvailableSourceModels(props.providerId)
|
||||
availableModels.value = response.models
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '加载模型列表失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const modelLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
availableModels.value.forEach(model => {
|
||||
map.set(model.global_model_name, model.display_name || model.global_model_name)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function getModelLabel(modelName: string): string {
|
||||
return modelLabelMap.value.get(modelName) ?? modelName
|
||||
}
|
||||
|
||||
function hasPricing(model: ProviderAvailableSourceModel): boolean {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
return input > 0 || output > 0
|
||||
}
|
||||
|
||||
function formatPricingShort(model: ProviderAvailableSourceModel): string {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
if (input > 0 || output > 0) {
|
||||
return `$${formatPrice(input)}/$${formatPrice(output)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | null): string {
|
||||
if (value === undefined || value === null || value === 0) return '0'
|
||||
if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function toggleModel(modelName: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedModels.value.includes(modelName)) {
|
||||
selectedModels.value = [...selectedModels.value, modelName]
|
||||
}
|
||||
} else {
|
||||
selectedModels.value = selectedModels.value.filter(name => name !== modelName)
|
||||
}
|
||||
}
|
||||
|
||||
function clearModels() {
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
const sortedB = [...b].sort()
|
||||
return sortedA.every((value, index) => value === sortedB[index])
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
if (!value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.apiKey) return
|
||||
|
||||
// 检查是否有变化
|
||||
const hasChanged = !areArraysEqual(selectedModels.value, initialModels.value)
|
||||
if (!hasChanged) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await updateEndpointKey(props.apiKey.id, {
|
||||
// 空数组时发送 null,表示允许所有模型
|
||||
allowed_models: selectedModels.value.length > 0 ? [...selectedModels.value] : null
|
||||
})
|
||||
success('允许的模型已更新', '成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '保存失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
</style>
|
||||
459
frontend/src/features/providers/components/KeyFormDialog.vue
Normal file
459
frontend/src/features/providers/components/KeyFormDialog.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
||||
:description="isEditMode ? '修改 API 密钥配置' : '为端点添加新的 API 密钥'"
|
||||
:icon="isEditMode ? SquarePen : Key"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form @submit.prevent="handleSave" class="space-y-5" autocomplete="off">
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">基本信息</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label :for="keyNameInputId">密钥名称 *</Label>
|
||||
<Input
|
||||
:id="keyNameInputId"
|
||||
:name="keyNameFieldName"
|
||||
v-model="form.name"
|
||||
required
|
||||
placeholder="例如:主 Key、备用 Key 1"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="rate_multiplier">成本倍率 *</Label>
|
||||
<Input
|
||||
id="rate_multiplier"
|
||||
v-model.number="form.rate_multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
placeholder="1.0"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
真实成本 = 表面成本 × 倍率
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
|
||||
<Input
|
||||
:id="apiKeyInputId"
|
||||
:name="apiKeyFieldName"
|
||||
v-model="form.api_key"
|
||||
:type="apiKeyInputType"
|
||||
:required="!editingKey"
|
||||
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
|
||||
:class="getApiKeyInputClass()"
|
||||
autocomplete="new-password"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
@focus="apiKeyFocused = true"
|
||||
@blur="apiKeyFocused = form.api_key.trim().length > 0"
|
||||
/>
|
||||
<p v-if="apiKeyError" class="text-xs text-destructive mt-1">
|
||||
{{ apiKeyError }}
|
||||
</p>
|
||||
<p v-else-if="editingKey" class="text-xs text-muted-foreground mt-1">
|
||||
留空表示不修改,输入新值则覆盖
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="note">备注</Label>
|
||||
<Input
|
||||
id="note"
|
||||
v-model="form.note"
|
||||
placeholder="可选的备注信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度与限流 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">调度与限流</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="internal_priority">内部优先级</Label>
|
||||
<Input
|
||||
id="internal_priority"
|
||||
v-model.number="form.internal_priority"
|
||||
type="number"
|
||||
min="0"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">数字越小越优先</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="max_concurrent">最大并发</Label>
|
||||
<Input
|
||||
id="max_concurrent"
|
||||
:model-value="form.max_concurrent ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空启用自适应"
|
||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">留空 = 自适应模式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label for="rate_limit">速率限制(/分钟)</Label>
|
||||
<Input
|
||||
id="rate_limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="daily_limit">每日限制</Label>
|
||||
<Input
|
||||
id="daily_limit"
|
||||
:model-value="form.daily_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.daily_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="monthly_limit">每月限制</Label>
|
||||
<Input
|
||||
id="monthly_limit"
|
||||
:model-value="form.monthly_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.monthly_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存与熔断 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">缓存与熔断</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label>
|
||||
<Input
|
||||
id="cache_ttl_minutes"
|
||||
:model-value="form.cache_ttl_minutes ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">0 = 禁用缓存亲和性</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label>
|
||||
<Input
|
||||
id="max_probe_interval_minutes"
|
||||
:model-value="form.max_probe_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="2"
|
||||
max="32"
|
||||
placeholder="32"
|
||||
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">范围 2-32 分钟</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 能力标签配置 -->
|
||||
<div v-if="availableCapabilities.length > 0" class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">能力标签</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.capabilities[cap.name] || false"
|
||||
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="handleCancel" variant="outline">取消</Button>
|
||||
<Button @click="handleSave" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
addEndpointKey,
|
||||
updateEndpointKey,
|
||||
getAllCapabilities,
|
||||
type EndpointAPIKey,
|
||||
type ProviderEndpoint,
|
||||
type CapabilityDefinition
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
endpoint: ProviderEndpoint | null
|
||||
editingKey: EndpointAPIKey | null
|
||||
providerId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const formNonce = ref(createFieldNonce())
|
||||
const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
|
||||
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
|
||||
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
|
||||
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
|
||||
const apiKeyFocused = ref(false)
|
||||
const apiKeyInputType = computed(() =>
|
||||
apiKeyFocused.value || form.value.api_key.trim().length > 0 ? 'password' : 'text'
|
||||
)
|
||||
|
||||
// 可用的能力列表
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
api_key: '',
|
||||
rate_multiplier: 1.0,
|
||||
internal_priority: 50,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
daily_limit: undefined as number | undefined,
|
||||
monthly_limit: undefined as number | undefined,
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {} as Record<string, boolean>
|
||||
})
|
||||
|
||||
// 加载能力列表
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (err) {
|
||||
console.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
// API 密钥输入框样式计算
|
||||
function getApiKeyInputClass(): string {
|
||||
const classes = []
|
||||
if (apiKeyError.value) {
|
||||
classes.push('border-destructive')
|
||||
}
|
||||
if (!apiKeyFocused.value && !form.value.api_key) {
|
||||
classes.push('text-transparent caret-transparent selection:bg-transparent selection:text-transparent')
|
||||
}
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
|
||||
// API 密钥验证错误信息
|
||||
const apiKeyError = computed(() => {
|
||||
const apiKey = form.value.api_key.trim()
|
||||
if (!apiKey) {
|
||||
// 新增模式下必填
|
||||
if (!props.editingKey) {
|
||||
return '' // 空值由 required 属性处理
|
||||
}
|
||||
// 编辑模式下可以为空(表示不修改)
|
||||
return ''
|
||||
}
|
||||
|
||||
// 如果输入了值,检查长度
|
||||
if (apiKey.length < 10) {
|
||||
return 'API 密钥至少需要 10 个字符'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: '',
|
||||
api_key: '',
|
||||
rate_multiplier: 1.0,
|
||||
internal_priority: 50,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
daily_limit: undefined,
|
||||
monthly_limit: undefined,
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载密钥数据(编辑模式)
|
||||
function loadKeyData() {
|
||||
if (!props.editingKey) return
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: props.editingKey.name,
|
||||
api_key: '',
|
||||
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
||||
internal_priority: props.editingKey.internal_priority ?? 50,
|
||||
max_concurrent: props.editingKey.max_concurrent || undefined,
|
||||
rate_limit: props.editingKey.rate_limit || undefined,
|
||||
daily_limit: props.editingKey.daily_limit || undefined,
|
||||
monthly_limit: props.editingKey.monthly_limit || undefined,
|
||||
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
|
||||
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||
note: props.editingKey.note || '',
|
||||
is_active: props.editingKey.is_active,
|
||||
capabilities: { ...(props.editingKey.capabilities || {}) }
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.editingKey,
|
||||
isLoading: saving,
|
||||
onClose: () => emit('close'),
|
||||
loadData: loadKeyData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
function createFieldNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.endpoint) return
|
||||
|
||||
// 提交前验证
|
||||
if (apiKeyError.value) {
|
||||
showError(apiKeyError.value, '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 新增模式下,API 密钥必填
|
||||
if (!props.editingKey && !form.value.api_key.trim()) {
|
||||
showError('请输入 API 密钥', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出有效的能力配置(只包含值为 true 的)
|
||||
const activeCapabilities: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(form.value.capabilities)) {
|
||||
if (value) {
|
||||
activeCapabilities[key] = true
|
||||
}
|
||||
}
|
||||
const capabilitiesData = Object.keys(activeCapabilities).length > 0 ? activeCapabilities : null
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editingKey) {
|
||||
// 更新
|
||||
const updateData: any = {
|
||||
name: form.value.name,
|
||||
rate_multiplier: form.value.rate_multiplier,
|
||||
internal_priority: form.value.internal_priority,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
daily_limit: form.value.daily_limit,
|
||||
monthly_limit: form.value.monthly_limit,
|
||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
is_active: form.value.is_active,
|
||||
capabilities: capabilitiesData
|
||||
}
|
||||
|
||||
if (form.value.api_key.trim()) {
|
||||
updateData.api_key = form.value.api_key
|
||||
}
|
||||
|
||||
await updateEndpointKey(props.editingKey.id, updateData)
|
||||
success('密钥已更新', '成功')
|
||||
} else {
|
||||
// 新增
|
||||
await addEndpointKey(props.endpoint.id, {
|
||||
endpoint_id: props.endpoint.id,
|
||||
api_key: form.value.api_key,
|
||||
name: form.value.name,
|
||||
rate_multiplier: form.value.rate_multiplier,
|
||||
internal_priority: form.value.internal_priority,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
daily_limit: form.value.daily_limit,
|
||||
monthly_limit: form.value.monthly_limit,
|
||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
capabilities: capabilitiesData || undefined
|
||||
})
|
||||
success('密钥已添加', '成功')
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '保存密钥失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
title="优先级管理"
|
||||
description="调整提供商和 API Key 的优先级顺序,保存后自动切换对应的调度策略"
|
||||
:icon="ListOrdered"
|
||||
size="3xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 主 Tab 切换 -->
|
||||
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
activeMainTab === 'provider'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="activeMainTab = 'provider'"
|
||||
>
|
||||
<Layers class="w-4 h-4" />
|
||||
<span>提供商优先</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
|
||||
activeMainTab === 'key'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="activeMainTab = 'key'"
|
||||
>
|
||||
<Key class="w-4 h-4" />
|
||||
<span>Key 优先</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="min-h-[420px]">
|
||||
<!-- 提供商优先级 -->
|
||||
<div v-show="activeMainTab === 'provider'" class="space-y-4">
|
||||
<!-- 提示信息 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md">
|
||||
<Info class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>拖拽调整顺序,位置越靠前优先级越高</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="sortedProviders.length === 0" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Layers class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无提供商</span>
|
||||
</div>
|
||||
|
||||
<!-- 提供商列表 -->
|
||||
<div v-else class="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(provider, index) in sortedProviders"
|
||||
:key="provider.id"
|
||||
:class="[
|
||||
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
|
||||
draggedProvider === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
: dragOverProvider === index
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleProviderDragStart(index, $event)"
|
||||
@dragend="handleProviderDragEnd"
|
||||
@dragover.prevent="handleProviderDragOver(index)"
|
||||
@dragleave="handleProviderDragLeave"
|
||||
@drop="handleProviderDrop(index)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors">
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- 序号 -->
|
||||
<div class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground shrink-0">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
|
||||
<!-- 提供商信息 -->
|
||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span class="font-medium text-sm truncate">{{ provider.display_name }}</span>
|
||||
<Badge
|
||||
v-if="!provider.is_active"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 h-5 shrink-0"
|
||||
>
|
||||
停用
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key 优先级 -->
|
||||
<div v-show="activeMainTab === 'key'" class="space-y-3">
|
||||
<!-- 提示信息 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md">
|
||||
<Info class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>拖拽调整顺序,点击序号可编辑(相同数字为同级,负载均衡)</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingKeys" class="flex items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-2 border-muted border-t-primary"></div>
|
||||
<span class="text-xs text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="availableFormats.length === 0" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Key class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无 API Key</span>
|
||||
</div>
|
||||
|
||||
<!-- 左右布局:格式列表 + Key 列表 -->
|
||||
<div v-else class="flex gap-4">
|
||||
<!-- 左侧:API 格式列表 -->
|
||||
<div class="w-32 shrink-0 space-y-1">
|
||||
<button
|
||||
v-for="format in availableFormats"
|
||||
:key="format"
|
||||
type="button"
|
||||
:class="[
|
||||
'w-full px-3 py-2 text-xs font-medium rounded-md text-left transition-all duration-200',
|
||||
activeFormatTab === format
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
@click="activeFormatTab = format"
|
||||
>
|
||||
{{ format }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:Key 列表 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-for="format in availableFormats" :key="format" v-show="activeFormatTab === format">
|
||||
<div v-if="keysByFormat[format]?.length > 0" class="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(key, index) in keysByFormat[format]"
|
||||
:key="key.id"
|
||||
:class="[
|
||||
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
|
||||
draggedKey[format] === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
: dragOverKey[format] === index
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleKeyDragStart(format, index, $event)"
|
||||
@dragend="handleKeyDragEnd(format)"
|
||||
@dragover.prevent="handleKeyDragOver(format, index)"
|
||||
@dragleave="handleKeyDragLeave(format)"
|
||||
@drop="handleKeyDrop(format, index)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors shrink-0">
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- 可编辑序号 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="editingKeyPriority[format] === key.id"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="key.priority"
|
||||
class="w-8 h-6 rounded-md bg-background border border-primary text-xs font-medium text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@blur="finishEditKeyPriority(format, key, $event)"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.escape="cancelEditKeyPriority(format)"
|
||||
autofocus
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
:title="'点击编辑优先级,相同数字为同级(负载均衡)'"
|
||||
@click.stop="startEditKeyPriority(format, key)"
|
||||
>
|
||||
{{ key.priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key 信息 -->
|
||||
<div class="flex-1 min-w-0 flex items-center gap-3">
|
||||
<!-- 左侧:名称和来源 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{{ key.name }}</span>
|
||||
<Badge
|
||||
v-if="key.circuit_breaker_open"
|
||||
variant="destructive"
|
||||
class="text-[10px] h-5 px-1.5 shrink-0"
|
||||
>
|
||||
熔断
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="!key.is_active"
|
||||
variant="secondary"
|
||||
class="text-[10px] h-5 px-1.5 shrink-0"
|
||||
>
|
||||
停用
|
||||
</Badge>
|
||||
<!-- 能力标签紧跟名称 -->
|
||||
<template v-if="key.capabilities?.length">
|
||||
<span v-for="cap in key.capabilities.slice(0, 2)" :key="cap" class="px-1 py-0.5 bg-muted text-muted-foreground rounded text-[10px]">{{ cap }}</span>
|
||||
<span v-if="key.capabilities.length > 2" class="text-[10px] text-muted-foreground">+{{ key.capabilities.length - 2 }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-1">
|
||||
<span class="text-[10px] font-medium shrink-0">{{ key.provider_name }}</span>
|
||||
<span class="font-mono text-[10px] opacity-60 truncate">{{ key.api_key_masked }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:健康度 + 速率 -->
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<!-- 健康度 -->
|
||||
<div v-if="key.success_rate !== null" class="text-xs text-right">
|
||||
<div :class="[
|
||||
'font-medium tabular-nums',
|
||||
key.success_rate >= 0.95 ? 'text-green-600' :
|
||||
key.success_rate >= 0.8 ? 'text-yellow-600' : 'text-red-500'
|
||||
]">
|
||||
{{ (key.success_rate * 100).toFixed(0) }}%
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground opacity-70">{{ key.request_count }} reqs</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground/50 text-right">
|
||||
<div>--</div>
|
||||
<div class="text-[10px]">无数据</div>
|
||||
</div>
|
||||
<!-- 速率倍数 -->
|
||||
<div class="text-sm font-medium tabular-nums text-primary min-w-[40px] text-right">{{ key.rate_multiplier }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Key class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无 {{ format }} 格式的 Key</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="save" :disabled="saving" class="min-w-[72px]">
|
||||
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
{{ saving ? '保存中' : '保存' }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="close" class="min-w-[72px]">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { GripVertical, Layers, Key, Info, Loader2, ListOrdered } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { updateProvider, updateEndpointKey } from '@/api/endpoints'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
interface KeyWithMeta {
|
||||
id: string
|
||||
name: string
|
||||
api_key_masked: string
|
||||
internal_priority: number
|
||||
global_priority: number | null
|
||||
priority: number // 用于编辑的优先级
|
||||
rate_multiplier: number
|
||||
is_active: boolean
|
||||
circuit_breaker_open: boolean
|
||||
provider_name: string
|
||||
endpoint_base_url: string
|
||||
api_format: string
|
||||
capabilities: string[]
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
request_count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providers: ProviderWithEndpointsSummary[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 主 Tab 状态
|
||||
const activeMainTab = ref<'provider' | 'key'>('provider')
|
||||
const activeFormatTab = ref<string>('CLAUDE')
|
||||
|
||||
// 提供商排序状态
|
||||
const sortedProviders = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const draggedProvider = ref<number | null>(null)
|
||||
const dragOverProvider = ref<number | null>(null)
|
||||
|
||||
// Key 排序状态
|
||||
const keysByFormat = ref<Record<string, KeyWithMeta[]>>({})
|
||||
const draggedKey = ref<Record<string, number | null>>({})
|
||||
const dragOverKey = ref<Record<string, number | null>>({})
|
||||
const loadingKeys = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// Key 优先级编辑状态
|
||||
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
||||
|
||||
// 可用的 API 格式
|
||||
const availableFormats = computed(() => {
|
||||
return Object.keys(keysByFormat.value).sort()
|
||||
})
|
||||
|
||||
// 监听 props.providers 变化
|
||||
watch(() => props.providers, (newProviders) => {
|
||||
if (newProviders) {
|
||||
sortedProviders.value = [...newProviders].sort((a, b) => a.provider_priority - b.provider_priority)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听对话框打开
|
||||
watch(internalOpen, async (open) => {
|
||||
if (open) {
|
||||
await loadCurrentPriorityMode()
|
||||
await loadKeysByFormat()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载当前的优先级模式配置
|
||||
async function loadCurrentPriorityMode() {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('provider_priority_mode')
|
||||
const currentMode = response.value || 'provider'
|
||||
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
|
||||
} catch {
|
||||
activeMainTab.value = 'provider'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载按格式分组的 Keys
|
||||
async function loadKeysByFormat() {
|
||||
try {
|
||||
loadingKeys.value = true
|
||||
const { default: client } = await import('@/api/client')
|
||||
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||
|
||||
// 为每个 key 添加 priority 字段,基于 global_priority 计算显示优先级
|
||||
const data: Record<string, KeyWithMeta[]> = {}
|
||||
for (const [format, keys] of Object.entries(response.data as Record<string, any[]>)) {
|
||||
data[format] = keys.map((key, index) => ({
|
||||
...key,
|
||||
priority: key.global_priority ?? index + 1
|
||||
}))
|
||||
}
|
||||
keysByFormat.value = data
|
||||
|
||||
const formats = Object.keys(data)
|
||||
if (formats.length > 0 && !formats.includes(activeFormatTab.value)) {
|
||||
activeFormatTab.value = formats[0]
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载 Key 列表失败', '错误')
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Key 优先级编辑
|
||||
function startEditKeyPriority(format: string, key: KeyWithMeta) {
|
||||
editingKeyPriority.value[format] = key.id
|
||||
}
|
||||
|
||||
function cancelEditKeyPriority(format: string) {
|
||||
editingKeyPriority.value[format] = null
|
||||
}
|
||||
|
||||
function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEvent) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const newPriority = parseInt(input.value, 10)
|
||||
|
||||
if (!isNaN(newPriority) && newPriority >= 1) {
|
||||
key.priority = newPriority
|
||||
// 按 priority 重新排序
|
||||
keysByFormat.value[format] = [...keysByFormat.value[format]].sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
editingKeyPriority.value[format] = null
|
||||
}
|
||||
|
||||
// Provider 拖拽处理
|
||||
function handleProviderDragStart(index: number, event: DragEvent) {
|
||||
draggedProvider.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/html', '')
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderDragEnd() {
|
||||
draggedProvider.value = null
|
||||
dragOverProvider.value = null
|
||||
}
|
||||
|
||||
function handleProviderDragOver(index: number) {
|
||||
dragOverProvider.value = index
|
||||
}
|
||||
|
||||
function handleProviderDragLeave() {
|
||||
dragOverProvider.value = null
|
||||
}
|
||||
|
||||
function handleProviderDrop(dropIndex: number) {
|
||||
if (draggedProvider.value === null || draggedProvider.value === dropIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
const providers = [...sortedProviders.value]
|
||||
const draggedItem = providers[draggedProvider.value]
|
||||
|
||||
providers.splice(draggedProvider.value, 1)
|
||||
providers.splice(dropIndex, 0, draggedItem)
|
||||
|
||||
sortedProviders.value = providers.map((provider, index) => ({
|
||||
...provider,
|
||||
provider_priority: index + 1
|
||||
}))
|
||||
|
||||
draggedProvider.value = null
|
||||
}
|
||||
|
||||
// Key 拖拽处理
|
||||
function handleKeyDragStart(format: string, index: number, event: DragEvent) {
|
||||
draggedKey.value[format] = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/html', '')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDragEnd(format: string) {
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
|
||||
function handleKeyDragOver(format: string, index: number) {
|
||||
dragOverKey.value[format] = index
|
||||
}
|
||||
|
||||
function handleKeyDragLeave(format: string) {
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
|
||||
function handleKeyDrop(format: string, dropIndex: number) {
|
||||
const dragIndex = draggedKey.value[format]
|
||||
if (dragIndex === null || dragIndex === dropIndex) {
|
||||
draggedKey.value[format] = null
|
||||
return
|
||||
}
|
||||
|
||||
const keys = [...keysByFormat.value[format]]
|
||||
const draggedItem = keys[dragIndex]
|
||||
|
||||
// 记录每个 key 的原始优先级(在修改前)
|
||||
const originalPriorityMap = new Map<string, number>()
|
||||
for (const key of keys) {
|
||||
originalPriorityMap.set(key.id, key.priority)
|
||||
}
|
||||
|
||||
// 重排数组
|
||||
keys.splice(dragIndex, 1)
|
||||
keys.splice(dropIndex, 0, draggedItem)
|
||||
|
||||
// 按新顺序为每个组分配新的优先级
|
||||
// 同组的 Key 保持相同的优先级
|
||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||
let currentPriority = 1
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.id === draggedItem.id) {
|
||||
// 被拖动的 Key 是独立的新组,获得当前优先级
|
||||
key.priority = currentPriority
|
||||
currentPriority++
|
||||
} else {
|
||||
// 使用记录的原始优先级,而不是可能已被修改的值
|
||||
const originalPriority = originalPriorityMap.get(key.id)!
|
||||
|
||||
if (groupNewPriority.has(originalPriority)) {
|
||||
// 这个组已经分配过优先级,使用相同的值
|
||||
key.priority = groupNewPriority.get(originalPriority)!
|
||||
} else {
|
||||
// 这个组第一次出现,分配新优先级
|
||||
groupNewPriority.set(originalPriority, currentPriority)
|
||||
key.priority = currentPriority
|
||||
currentPriority++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysByFormat.value[format] = keys
|
||||
draggedKey.value[format] = null
|
||||
}
|
||||
|
||||
// 保存
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
|
||||
|
||||
await adminApi.updateSystemConfig(
|
||||
'provider_priority_mode',
|
||||
newMode,
|
||||
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)'
|
||||
)
|
||||
|
||||
const providerUpdates = sortedProviders.value.map((provider, index) =>
|
||||
updateProvider(provider.id, { provider_priority: index + 1 })
|
||||
)
|
||||
|
||||
const keyUpdates: Promise<any>[] = []
|
||||
|
||||
for (const format of Object.keys(keysByFormat.value)) {
|
||||
const keys = keysByFormat.value[format]
|
||||
keys.forEach((key) => {
|
||||
// 使用用户设置的 priority 值,相同 priority 会做负载均衡
|
||||
keyUpdates.push(updateEndpointKey(key.id, { global_priority: key.priority }))
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all([...providerUpdates, ...keyUpdates])
|
||||
|
||||
await loadKeysByFormat()
|
||||
|
||||
success('优先级已保存')
|
||||
emit('saved')
|
||||
|
||||
// 提供商优先模式保存后关闭,Key 优先模式保存后保持打开方便继续调整
|
||||
if (activeMainTab.value === 'provider') {
|
||||
close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '保存失败', '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
1238
frontend/src/features/providers/components/ProviderDetailDrawer.vue
Normal file
1238
frontend/src/features/providers/components/ProviderDetailDrawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
:title="isEditMode ? '编辑提供商' : '添加提供商'"
|
||||
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
|
||||
:icon="isEditMode ? SquarePen : Server"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium border-b pb-2">基本信息</h3>
|
||||
|
||||
<!-- 添加模式显示提供商标识 -->
|
||||
<div v-if="!isEditMode" class="space-y-2">
|
||||
<Label for="name">提供商标识 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="例如: openai-primary"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">唯一ID,创建后不可修改</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="display_name">显示名称 *</Label>
|
||||
<Input
|
||||
id="display_name"
|
||||
v-model="form.display_name"
|
||||
placeholder="例如: OpenAI 主账号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="website">主站链接</Label>
|
||||
<Input
|
||||
id="website"
|
||||
v-model="form.website"
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="提供商描述(可选)"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计费与限流 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium border-b pb-2">计费与限流</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>计费类型</Label>
|
||||
<Select v-model="form.billing_type" v-model:open="billingTypeSelectOpen">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly_quota">月卡额度</SelectItem>
|
||||
<SelectItem value="pay_as_you_go">按量付费</SelectItem>
|
||||
<SelectItem value="free_tier">免费套餐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>RPM 限制</Label>
|
||||
<Input
|
||||
:model-value="form.rpm_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="不限制请留空"
|
||||
@update:model-value="(v) => form.rpm_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 月卡配置 -->
|
||||
<div v-if="form.billing_type === 'monthly_quota'" class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">周期额度 (USD)</Label>
|
||||
<Input
|
||||
:model-value="form.monthly_quota_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-9"
|
||||
@update:model-value="(v) => form.monthly_quota_usd = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">重置周期 (天)</Label>
|
||||
<Input
|
||||
:model-value="form.quota_reset_day ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="h-9"
|
||||
@update:model-value="(v) => form.quota_reset_day = parseNumberInput(v) ?? 30"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">
|
||||
周期开始时间
|
||||
<span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input v-model="form.quota_last_reset_at" type="datetime-local" class="h-9" />
|
||||
<p class="text-xs text-muted-foreground">系统会自动统计从该时间点开始的使用量</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">过期时间</Label>
|
||||
<Input v-model="form.quota_expires_at" type="datetime-local" class="h-9" />
|
||||
<p class="text-xs text-muted-foreground">留空表示永久有效</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
:disabled="loading"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleSubmit"
|
||||
:disabled="loading || !form.display_name || (!isEditMode && !form.name)"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Server, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider?: ProviderWithEndpointsSummary | null // 编辑模式时传入
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'provider-created': []
|
||||
'provider-updated': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const billingTypeSelectOpen = ref(false)
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
// 计费配置
|
||||
billing_type: 'pay_as_you_go' as 'monthly_quota' | 'pay_as_you_go' | 'free_tier',
|
||||
monthly_quota_usd: undefined as number | undefined,
|
||||
quota_reset_day: 30,
|
||||
quota_last_reset_at: '', // 周期开始时间
|
||||
quota_expires_at: '',
|
||||
rpm_limit: undefined as string | number | undefined,
|
||||
provider_priority: 999,
|
||||
// 状态配置
|
||||
is_active: true,
|
||||
rate_limit: undefined as number | undefined,
|
||||
concurrent_limit: undefined as number | undefined,
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
billing_type: 'pay_as_you_go',
|
||||
monthly_quota_usd: undefined,
|
||||
quota_reset_day: 30,
|
||||
quota_last_reset_at: '',
|
||||
quota_expires_at: '',
|
||||
rpm_limit: undefined,
|
||||
provider_priority: 999,
|
||||
is_active: true,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 加载提供商数据(编辑模式)
|
||||
function loadProviderData() {
|
||||
if (!props.provider) return
|
||||
|
||||
form.value = {
|
||||
name: props.provider.name,
|
||||
display_name: props.provider.display_name,
|
||||
description: props.provider.description || '',
|
||||
website: props.provider.website || '',
|
||||
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go',
|
||||
monthly_quota_usd: props.provider.monthly_quota_usd || undefined,
|
||||
quota_reset_day: props.provider.quota_reset_day || 30,
|
||||
quota_last_reset_at: props.provider.quota_last_reset_at ?
|
||||
new Date(props.provider.quota_last_reset_at).toISOString().slice(0, 16) : '',
|
||||
quota_expires_at: props.provider.quota_expires_at ?
|
||||
new Date(props.provider.quota_expires_at).toISOString().slice(0, 16) : '',
|
||||
rpm_limit: props.provider.rpm_limit ?? undefined,
|
||||
provider_priority: props.provider.provider_priority || 999,
|
||||
is_active: props.provider.is_active,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.modelValue,
|
||||
entity: () => props.provider,
|
||||
isLoading: loading,
|
||||
onClose: () => emit('update:modelValue', false),
|
||||
loadData: loadProviderData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError('月卡类型必须设置周期开始时间', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form.value,
|
||||
rpm_limit:
|
||||
form.value.rpm_limit === undefined || form.value.rpm_limit === ''
|
||||
? null
|
||||
: Number(form.value.rpm_limit),
|
||||
// 空字符串时不发送
|
||||
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
||||
quota_expires_at: form.value.quota_expires_at || undefined,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
// 更新提供商
|
||||
await updateProvider(props.provider.id, payload)
|
||||
success('提供商更新成功')
|
||||
emit('provider-updated')
|
||||
} else {
|
||||
// 创建提供商
|
||||
await createProvider(payload)
|
||||
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
|
||||
emit('provider-created')
|
||||
}
|
||||
|
||||
emit('update:modelValue', false)
|
||||
} catch (error: any) {
|
||||
const action = isEditMode.value ? '更新' : '创建'
|
||||
showError(parseApiError(error, `${action}提供商失败`), `${action}失败`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
@update:model-value="handleClose"
|
||||
:title="isEditing ? '编辑模型配置' : '添加模型'"
|
||||
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
|
||||
:icon="isEditing ? SquarePen : Layers"
|
||||
size="xl"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- 添加模式:选择全局模型 -->
|
||||
<div v-if="!isEditing" class="space-y-2">
|
||||
<Label for="global-model">选择模型 *</Label>
|
||||
<Select
|
||||
v-model:open="globalModelSelectOpen"
|
||||
:model-value="form.global_model_id"
|
||||
:disabled="loadingGlobalModels"
|
||||
@update:model-value="form.global_model_id = $event"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="loadingGlobalModels ? '加载中...' : '请选择模型'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in availableGlobalModels"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.display_name }} ({{ model.name }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="availableGlobalModels.length === 0 && !loadingGlobalModels" class="text-xs text-muted-foreground">
|
||||
所有全局模型已添加到此 Provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模式:显示模型信息 -->
|
||||
<div v-else class="rounded-lg border bg-muted/30 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ editingModel?.global_model_display_name || editingModel?.provider_model_name }}</p>
|
||||
<p class="text-sm text-muted-foreground font-mono">{{ editingModel?.provider_model_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-sm border-b pb-2">价格配置</h4>
|
||||
<TieredPricingEditor v-model="tieredPricing" :show-cache1h="showCache1h" />
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
||||
<Input
|
||||
:model-value="form.price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-32"
|
||||
placeholder="留空使用默认值"
|
||||
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<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="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
|
||||
type="checkbox"
|
||||
v-model="form.supports_streaming"
|
||||
:indeterminate="form.supports_streaming === undefined"
|
||||
class="rounded"
|
||||
/>
|
||||
<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
|
||||
type="checkbox"
|
||||
v-model="form.supports_image_generation"
|
||||
:indeterminate="form.supports_image_generation === undefined"
|
||||
class="rounded"
|
||||
/>
|
||||
<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
|
||||
type="checkbox"
|
||||
v-model="form.supports_vision"
|
||||
:indeterminate="form.supports_vision === undefined"
|
||||
class="rounded"
|
||||
/>
|
||||
<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
|
||||
type="checkbox"
|
||||
v-model="form.supports_function_calling"
|
||||
:indeterminate="form.supports_function_calling === undefined"
|
||||
class="rounded"
|
||||
/>
|
||||
<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
|
||||
type="checkbox"
|
||||
v-model="form.supports_extended_thinking"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="handleClose(false)">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="handleSubmit" :disabled="submitting || (!isEditing && !form.global_model_id)">
|
||||
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{ isEditing ? '保存' : '添加' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next'
|
||||
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseNumberInput } 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'
|
||||
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName?: string
|
||||
editingModel?: Model | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
providerName: '',
|
||||
editingModel: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
|
||||
const isEditing = computed(() => !!props.editingModel)
|
||||
|
||||
// 计算是否显示 1h 缓存输入框
|
||||
const showCache1h = computed(() => {
|
||||
if (isEditing.value) {
|
||||
// 编辑模式:检查当前配置是否有 1h 缓存配置(从 tiered_pricing 或 effective_tiered_pricing 中检测)
|
||||
const pricing = props.editingModel?.tiered_pricing || props.editingModel?.effective_tiered_pricing
|
||||
return pricing?.tiers?.some(t => t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60)) ?? false
|
||||
} else {
|
||||
// 添加模式:从选中的全局模型中读取 supported_capabilities
|
||||
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||
return selectedModel?.supported_capabilities?.includes('cache_1h') ?? false
|
||||
}
|
||||
})
|
||||
|
||||
// 表单状态
|
||||
const submitting = ref(false)
|
||||
const loadingGlobalModels = ref(false)
|
||||
const availableGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const globalModelSelectOpen = ref(false)
|
||||
|
||||
// 阶梯计费配置
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
// 跟踪用户是否修改了阶梯配置(用于判断是否提交)
|
||||
const tieredPricingModified = ref(false)
|
||||
// 保存原始配置用于比较
|
||||
const originalTieredPricing = ref<string>('')
|
||||
|
||||
const form = ref({
|
||||
global_model_id: '',
|
||||
price_per_request: undefined as number | undefined,
|
||||
// 能力配置
|
||||
supports_vision: undefined as boolean | undefined,
|
||||
supports_function_calling: undefined as boolean | undefined,
|
||||
supports_streaming: undefined as boolean | undefined,
|
||||
supports_extended_thinking: undefined as boolean | undefined,
|
||||
supports_image_generation: undefined as boolean | undefined,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
// 监听 open 变化
|
||||
watch(() => props.open, async (newOpen) => {
|
||||
if (newOpen) {
|
||||
resetForm()
|
||||
if (props.editingModel) {
|
||||
// 编辑模式:填充表单
|
||||
form.value = {
|
||||
global_model_id: props.editingModel.global_model_id || '',
|
||||
price_per_request: props.editingModel.price_per_request ?? undefined,
|
||||
supports_vision: props.editingModel.supports_vision ?? undefined,
|
||||
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
||||
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
||||
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
|
||||
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
|
||||
is_active: props.editingModel.is_active
|
||||
}
|
||||
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
||||
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
||||
if (pricing) {
|
||||
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
|
||||
}
|
||||
} else {
|
||||
// 添加模式:加载可用全局模型
|
||||
await loadAvailableGlobalModels()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模式:选择全局模型时显示其阶梯计费配置(仅供预览)
|
||||
// 注意:为保持继承关系,添加时只有用户修改了配置才提交 tiered_pricing
|
||||
watch(() => form.value.global_model_id, (newId) => {
|
||||
if (!isEditing.value && newId) {
|
||||
const selectedModel = availableGlobalModels.value.find(m => m.id === newId)
|
||||
if (selectedModel?.default_tiered_pricing) {
|
||||
// 深拷贝阶梯计费配置用于预览
|
||||
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
|
||||
tieredPricing.value = pricingCopy
|
||||
// 保存原始配置用于比较
|
||||
originalTieredPricing.value = JSON.stringify(pricingCopy)
|
||||
} else {
|
||||
tieredPricing.value = null
|
||||
originalTieredPricing.value = ''
|
||||
}
|
||||
tieredPricingModified.value = false
|
||||
// 同时继承按次计费(仅供预览)
|
||||
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 监听阶梯配置变化,标记为已修改
|
||||
watch(tieredPricing, (newValue) => {
|
||||
if (!isEditing.value && originalTieredPricing.value) {
|
||||
const newJson = JSON.stringify(newValue)
|
||||
tieredPricingModified.value = newJson !== originalTieredPricing.value
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
global_model_id: '',
|
||||
price_per_request: undefined,
|
||||
supports_vision: undefined,
|
||||
supports_function_calling: undefined,
|
||||
supports_streaming: undefined,
|
||||
supports_extended_thinking: undefined,
|
||||
supports_image_generation: undefined,
|
||||
is_active: true
|
||||
}
|
||||
tieredPricing.value = null
|
||||
tieredPricingModified.value = false
|
||||
originalTieredPricing.value = ''
|
||||
availableGlobalModels.value = []
|
||||
}
|
||||
|
||||
// 加载可用的全局模型(排除已添加的)
|
||||
async function loadAvailableGlobalModels() {
|
||||
loadingGlobalModels.value = true
|
||||
try {
|
||||
const [globalModelsResponse, existingModels] = await Promise.all([
|
||||
listGlobalModels({ limit: 1000, is_active: true }),
|
||||
getProviderModels(props.providerId)
|
||||
])
|
||||
const allGlobalModels = globalModelsResponse.models || []
|
||||
|
||||
// 获取当前 provider 已添加的模型的 global_model_id 列表
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.map((m: Model) => m.global_model_id).filter(Boolean)
|
||||
)
|
||||
|
||||
// 过滤掉已添加的模型
|
||||
availableGlobalModels.value = allGlobalModels.filter(
|
||||
(gm: GlobalModelResponse) => !existingGlobalModelIds.has(gm.id)
|
||||
)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载模型列表失败', '错误')
|
||||
} finally {
|
||||
loadingGlobalModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose(value: boolean) {
|
||||
if (!submitting.value) {
|
||||
emit('update:open', value)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditing.value && props.editingModel) {
|
||||
// 编辑模式
|
||||
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
||||
await updateModel(props.providerId, props.editingModel.id, {
|
||||
tiered_pricing: tieredPricing.value,
|
||||
price_per_request: form.value.price_per_request ?? null,
|
||||
supports_vision: form.value.supports_vision,
|
||||
supports_function_calling: form.value.supports_function_calling,
|
||||
supports_streaming: form.value.supports_streaming,
|
||||
supports_extended_thinking: form.value.supports_extended_thinking,
|
||||
supports_image_generation: form.value.supports_image_generation,
|
||||
is_active: form.value.is_active
|
||||
})
|
||||
showSuccess('模型配置已更新')
|
||||
} else {
|
||||
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
|
||||
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||
await createModel(props.providerId, {
|
||||
global_model_id: form.value.global_model_id,
|
||||
provider_model_name: selectedModel?.name || '',
|
||||
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
|
||||
tiered_pricing: tieredPricingModified.value ? tieredPricing.value : undefined,
|
||||
price_per_request: form.value.price_per_request,
|
||||
supports_vision: form.value.supports_vision,
|
||||
supports_function_calling: form.value.supports_function_calling,
|
||||
supports_streaming: form.value.supports_streaming,
|
||||
supports_extended_thinking: form.value.supports_extended_thinking,
|
||||
supports_image_generation: form.value.supports_image_generation,
|
||||
is_active: form.value.is_active
|
||||
})
|
||||
showSuccess('模型已添加')
|
||||
}
|
||||
emit('update:open', false)
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || (isEditing.value ? '更新失败' : '添加失败'), '错误')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
12
frontend/src/features/providers/components/index.ts
Normal file
12
frontend/src/features/providers/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
|
||||
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
||||
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
||||
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
||||
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
|
||||
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
|
||||
export { default as MappingsTab } from './provider-tabs/MappingsTab.vue'
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题头部 -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold leading-none">
|
||||
别名与映射管理
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!hideAddButton"
|
||||
@click="openCreateDialog"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
创建别名/映射
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- 别名列表 -->
|
||||
<div v-else-if="mappings.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-semibold">名称</th>
|
||||
<th class="text-left px-4 py-3 font-semibold w-24">类型</th>
|
||||
<th class="text-left px-4 py-3 font-semibold">指向模型</th>
|
||||
<th v-if="!hideAddButton" class="px-4 py-3 font-semibold w-28 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="mapping in mappings"
|
||||
:key="mapping.id"
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 状态指示灯 -->
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="mapping.is_active ? 'bg-green-500' : 'bg-gray-300'"
|
||||
:title="mapping.is_active ? '活跃' : '停用'"
|
||||
></span>
|
||||
<span class="font-mono">{{ mapping.alias }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ mapping.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ mapping.global_model_display_name || mapping.global_model_name }}
|
||||
</td>
|
||||
<td v-if="!hideAddButton" class="px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="openEditDialog(mapping)"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="togglingId === mapping.id"
|
||||
@click="toggleActive(mapping)"
|
||||
:title="mapping.is_active ? '点击停用' : '点击启用'"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
@click="confirmDelete(mapping)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="p-8 text-center text-muted-foreground">
|
||||
<ArrowLeftRight class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">暂无特定别名/映射</p>
|
||||
<p class="text-xs mt-1">点击上方按钮添加</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 使用共享的 AliasDialog 组件 -->
|
||||
<AliasDialog
|
||||
:open="dialogOpen"
|
||||
:editing-alias="editingAlias"
|
||||
:global-models="availableModels"
|
||||
:fixed-provider="fixedProviderOption"
|
||||
:show-provider-select="true"
|
||||
@update:open="handleDialogVisibility"
|
||||
@submit="handleAliasSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowLeftRight, Plus, Edit, Trash2, Power } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import AliasDialog from '@/features/models/components/AliasDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getAliases,
|
||||
createAlias,
|
||||
updateAlias,
|
||||
deleteAlias,
|
||||
type ModelAlias,
|
||||
type CreateModelAliasRequest,
|
||||
type UpdateModelAliasRequest,
|
||||
} from '@/api/endpoints/aliases'
|
||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
provider: any
|
||||
hideAddButton?: boolean
|
||||
}>(), {
|
||||
hideAddButton: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const togglingId = ref<string | null>(null)
|
||||
const mappings = ref<ModelAlias[]>([])
|
||||
const availableModels = ref<GlobalModelResponse[]>([])
|
||||
const dialogOpen = ref(false)
|
||||
const editingAlias = ref<ModelAlias | null>(null)
|
||||
|
||||
// 固定的 Provider 选项(传递给 AliasDialog)
|
||||
const fixedProviderOption = computed(() => ({
|
||||
id: props.provider.id,
|
||||
name: props.provider.name,
|
||||
display_name: props.provider.display_name
|
||||
}))
|
||||
|
||||
// 加载映射 (实际返回的是该 Provider 的别名列表)
|
||||
async function loadMappings() {
|
||||
try {
|
||||
loading.value = true
|
||||
mappings.value = await getAliases({ provider_id: props.provider.id })
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载可用的 GlobalModel 列表
|
||||
async function loadAvailableModels() {
|
||||
try {
|
||||
const response = await listGlobalModels({ limit: 1000, is_active: true })
|
||||
availableModels.value = response.models || []
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载模型列表失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开创建对话框
|
||||
function openCreateDialog() {
|
||||
editingAlias.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(alias: ModelAlias) {
|
||||
editingAlias.value = alias
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理对话框可见性变化
|
||||
function handleDialogVisibility(value: boolean) {
|
||||
dialogOpen.value = value
|
||||
if (!value) {
|
||||
editingAlias.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理别名提交(来自 AliasDialog 组件)
|
||||
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit && editingAlias.value) {
|
||||
// 更新
|
||||
await updateAlias(editingAlias.value.id, data as UpdateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
|
||||
} else {
|
||||
// 创建 - 确保 provider_id 设置为当前 Provider
|
||||
const createData = data as CreateModelAliasRequest
|
||||
createData.provider_id = props.provider.id
|
||||
await createAlias(createData)
|
||||
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
editingAlias.value = null
|
||||
await loadMappings()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
let errorMessage = detail
|
||||
if (detail === '映射已存在') {
|
||||
errorMessage = '该名称已存在,请使用其他名称'
|
||||
}
|
||||
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换启用状态
|
||||
async function toggleActive(alias: ModelAlias) {
|
||||
if (togglingId.value) return
|
||||
|
||||
togglingId.value = alias.id
|
||||
try {
|
||||
const newStatus = !alias.is_active
|
||||
await updateAlias(alias.id, { is_active: newStatus })
|
||||
alias.is_active = newStatus
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
async function confirmDelete(alias: ModelAlias) {
|
||||
const typeName = alias.mapping_type === 'mapping' ? '映射' : '别名'
|
||||
if (!confirm(`确定要删除${typeName} "${alias.alias}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAlias(alias.id)
|
||||
success(`${typeName}已删除`)
|
||||
await loadMappings()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMappings()
|
||||
loadAvailableModels()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题头部 -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
模型列表
|
||||
</h3>
|
||||
<Button @click="openBatchAssignDialog" variant="outline" size="sm" class="h-8">
|
||||
<Layers class="w-3.5 h-3.5 mr-1.5" />
|
||||
关联模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div v-else-if="models.length > 0" class="overflow-hidden">
|
||||
<table class="w-full text-sm table-fixed">
|
||||
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-semibold w-[40%]">模型</th>
|
||||
<th class="text-left px-4 py-3 font-semibold w-[15%]">能力</th>
|
||||
<th class="text-left px-4 py-3 font-semibold w-[25%]">价格 ($/M)</th>
|
||||
<th class="text-center px-4 py-3 font-semibold w-[20%]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="model in sortedModels"
|
||||
:key="model.id"
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- 状态指示灯 -->
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="getStatusIndicatorClass(model)"
|
||||
:title="getStatusTitle(model)"
|
||||
></div>
|
||||
<!-- 模型信息 -->
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<span class="font-semibold text-sm">
|
||||
{{ model.global_model_display_name || model.provider_model_name }}
|
||||
</span>
|
||||
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
|
||||
<button
|
||||
@click.stop="copyModelId(model.provider_model_name)"
|
||||
class="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
|
||||
title="复制模型 ID"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div v-if="hasAnyCapability(model)" class="grid grid-cols-3 gap-1 w-fit">
|
||||
<Zap v-if="model.effective_supports_streaming ?? model.supports_streaming" class="w-4 h-4 text-muted-foreground" title="流式输出" />
|
||||
<Image v-if="model.effective_supports_image_generation ?? model.supports_image_generation" class="w-4 h-4 text-muted-foreground" title="图像生成" />
|
||||
<Eye v-if="model.effective_supports_vision ?? model.supports_vision" class="w-4 h-4 text-muted-foreground" title="视觉理解" />
|
||||
<Wrench v-if="model.effective_supports_function_calling ?? model.supports_function_calling" class="w-4 h-4 text-muted-foreground" title="工具调用" />
|
||||
<Brain v-if="model.effective_supports_extended_thinking ?? model.supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="深度思考" />
|
||||
</div>
|
||||
<span v-else class="text-xs text-muted-foreground">—</span>
|
||||
</td>
|
||||
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
|
||||
<div class="grid gap-1" style="grid-template-columns: auto 1fr;">
|
||||
<!-- 按 Token 计费 -->
|
||||
<template v-if="hasTokenPricing(model)">
|
||||
<span class="text-muted-foreground text-right">输入/输出:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(model.effective_input_price) }}/${{ formatPrice(model.effective_output_price) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
|
||||
<span class="text-muted-foreground text-right">缓存:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 1h 缓存价格 -->
|
||||
<template v-if="get1hCachePrice(model) > 0">
|
||||
<span class="text-muted-foreground text-right">1h 缓存:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(get1hCachePrice(model)) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 按次计费 -->
|
||||
<template v-if="hasRequestPricing(model)">
|
||||
<span class="text-muted-foreground text-right">按次:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
||||
</span>
|
||||
</template>
|
||||
<!-- 无计费配置 -->
|
||||
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
||||
<span class="text-muted-foreground">—</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="editModel(model)"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="togglingModelId === model.id"
|
||||
@click="toggleModelActive(model)"
|
||||
:title="model.is_active ? '点击停用' : '点击启用'"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="deleteModel(model)"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="p-8 text-center text-muted-foreground">
|
||||
<Box class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">暂无模型</p>
|
||||
<p class="text-xs mt-1">请前往"模型目录"页面添加模型</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'edit-model': [model: Model]
|
||||
'delete-model': [model: Model]
|
||||
'batch-assign': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
return [...models.value].sort((a, b) => {
|
||||
const nameA = (a.global_model_display_name || a.provider_model_name || '').toLowerCase()
|
||||
const nameB = (b.global_model_display_name || b.provider_model_name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
// 复制模型 ID 到剪贴板
|
||||
async function copyModelId(modelId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(modelId)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型
|
||||
async function loadModels() {
|
||||
try {
|
||||
loading.value = true
|
||||
models.value = await getProviderModels(props.provider.id)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化价格显示
|
||||
function formatPrice(price: number | null | undefined): string {
|
||||
if (price === null || price === undefined) return '-'
|
||||
// 如果是整数或小数点后只有1-2位,直接显示
|
||||
if (price >= 0.01 || price === 0) {
|
||||
return price.toFixed(2)
|
||||
}
|
||||
// 对于非常小的数字,使用科学计数法
|
||||
if (price < 0.0001) {
|
||||
return price.toExponential(2)
|
||||
}
|
||||
// 其他情况保留4位小数
|
||||
return price.toFixed(4)
|
||||
}
|
||||
|
||||
// 检查模型是否有任何能力
|
||||
function hasAnyCapability(model: Model): boolean {
|
||||
return !!(
|
||||
(model.effective_supports_vision ?? model.supports_vision) ||
|
||||
(model.effective_supports_function_calling ?? model.supports_function_calling) ||
|
||||
(model.effective_supports_streaming ?? model.supports_streaming) ||
|
||||
(model.effective_supports_extended_thinking ?? model.supports_extended_thinking) ||
|
||||
(model.effective_supports_image_generation ?? model.supports_image_generation)
|
||||
)
|
||||
}
|
||||
|
||||
// 检查是否有按 Token 计费
|
||||
function hasTokenPricing(model: Model): boolean {
|
||||
const inputPrice = model.effective_input_price
|
||||
const outputPrice = model.effective_output_price
|
||||
return (inputPrice != null && inputPrice > 0) || (outputPrice != null && outputPrice > 0)
|
||||
}
|
||||
|
||||
// 获取有效的缓存价格(从 effective_tiered_pricing 或 tiered_pricing 中提取)
|
||||
function getEffectiveCachePrice(model: Model, type: 'creation' | 'read'): number {
|
||||
const tiered = model.effective_tiered_pricing || model.tiered_pricing
|
||||
if (!tiered?.tiers?.length) return 0
|
||||
const firstTier = tiered.tiers[0]
|
||||
if (type === 'creation') {
|
||||
return firstTier.cache_creation_price_per_1m || 0
|
||||
}
|
||||
return firstTier.cache_read_price_per_1m || 0
|
||||
}
|
||||
|
||||
// 获取 1h 缓存价格
|
||||
function get1hCachePrice(model: Model): number {
|
||||
const tiered = model.effective_tiered_pricing || model.tiered_pricing
|
||||
if (!tiered?.tiers?.length) return 0
|
||||
const firstTier = tiered.tiers[0]
|
||||
const ttl1h = firstTier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
||||
return ttl1h?.cache_creation_price_per_1m || 0
|
||||
}
|
||||
|
||||
// 检查是否有按次计费
|
||||
function hasRequestPricing(model: Model): boolean {
|
||||
const requestPrice = model.effective_price_per_request ?? model.price_per_request
|
||||
return requestPrice != null && requestPrice > 0
|
||||
}
|
||||
|
||||
// 获取状态指示灯样式
|
||||
function getStatusIndicatorClass(model: Model): string {
|
||||
if (!model.is_active) {
|
||||
// 已停用 - 灰色
|
||||
return 'bg-gray-400 dark:bg-gray-600'
|
||||
}
|
||||
if (model.is_available) {
|
||||
// 活跃且可用 - 绿色
|
||||
return 'bg-green-500 dark:bg-green-400'
|
||||
}
|
||||
// 活跃但不可用 - 红色
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 获取状态提示文本
|
||||
function getStatusTitle(model: Model): string {
|
||||
if (!model.is_active) {
|
||||
return '已停用'
|
||||
}
|
||||
if (model.is_available) {
|
||||
return '活跃且可用'
|
||||
}
|
||||
return '活跃但不可用'
|
||||
}
|
||||
|
||||
// 编辑模型
|
||||
function editModel(model: Model) {
|
||||
emit('edit-model', model)
|
||||
}
|
||||
|
||||
// 删除模型
|
||||
function deleteModel(model: Model) {
|
||||
emit('delete-model', model)
|
||||
}
|
||||
|
||||
// 打开批量关联对话框
|
||||
function openBatchAssignDialog() {
|
||||
emit('batch-assign')
|
||||
}
|
||||
|
||||
// 切换模型启用状态
|
||||
async function toggleModelActive(model: Model) {
|
||||
if (togglingModelId.value) return
|
||||
|
||||
togglingModelId.value = model.id
|
||||
try {
|
||||
const newStatus = !model.is_active
|
||||
await updateModel(props.provider.id, model.id, { is_active: newStatus })
|
||||
model.is_active = newStatus
|
||||
showSuccess(newStatus ? '模型已启用' : '模型已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
togglingModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
})
|
||||
</script>
|
||||
1
frontend/src/features/providers/index.ts
Normal file
1
frontend/src/features/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<Card class="p-4 !overflow-visible">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-semibold">{{ title }}</p>
|
||||
<div v-if="hasData" class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0">
|
||||
<span class="flex-shrink-0">少</span>
|
||||
<div
|
||||
v-for="(level, index) in legendLevels"
|
||||
:key="index"
|
||||
class="w-3 h-3 rounded-[3px] flex-shrink-0"
|
||||
:style="{ backgroundColor: `rgba(var(--color-primary-rgb), ${level})` }"
|
||||
/>
|
||||
<span class="flex-shrink-0">多</span>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
/>
|
||||
<div v-else class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
暂无活跃数据
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
|
||||
const hasData = computed(() =>
|
||||
props.data && props.data.days && props.data.days.length > 0
|
||||
)
|
||||
</script>
|
||||
1322
frontend/src/features/usage/components/HorizontalRequestTimeline.vue
Normal file
1322
frontend/src/features/usage/components/HorizontalRequestTimeline.vue
Normal file
File diff suppressed because it is too large
Load Diff
851
frontend/src/features/usage/components/RequestDetailDrawer.vue
Normal file
851
frontend/src/features/usage/components/RequestDetailDrawer.vue
Normal file
@@ -0,0 +1,851 @@
|
||||
<template>
|
||||
<!-- 请求详情抽屉 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<Card class="relative h-full w-[800px] max-w-[90vw] rounded-none shadow-2xl flex flex-col">
|
||||
<!-- 固定头部 - 整合基本信息 -->
|
||||
<div class="sticky top-0 z-10 bg-background border-b px-6 py-4 flex-shrink-0">
|
||||
<!-- 第一行:标题、模型、状态、操作按钮 -->
|
||||
<div class="flex items-center justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h3 class="text-lg font-semibold">请求详情</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
<span>{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 flex-shrink-0">
|
||||
<path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>{{ detail.target_model }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<Badge v-if="detail?.status_code === 200" variant="success">{{ detail.status_code }}</Badge>
|
||||
<Badge v-else-if="detail" variant="destructive">{{ detail.status_code }}</Badge>
|
||||
<Badge variant="outline" class="text-xs" v-if="detail">{{ detail.is_stream ? '流式' : '标准' }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="refreshDetail"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleClose" title="关闭">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:关键元信息 -->
|
||||
<div v-if="detail" class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-medium text-foreground">ID:</span>
|
||||
<span class="font-mono">{{ detail.request_id || detail.id }}</span>
|
||||
</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ formatDateTime(detail.created_at) }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ formatApiFormat(detail.api_format) }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>用户: {{ detail.user?.username || 'Unknown' }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span class="font-mono">{{ detail.api_key?.display || 'N/A' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-stable">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-8 space-y-4">
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-64 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Card v-else-if="error" class="border-red-200 dark:border-red-800">
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Detail Content -->
|
||||
<div v-else-if="detail" class="space-y-4">
|
||||
<!-- 费用与性能概览 -->
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<!-- 总费用和响应时间(独立显示) -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">总费用</span>
|
||||
<span class="text-lg font-bold text-green-600 dark:text-green-400">
|
||||
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
|
||||
</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" class="h-6 mx-6" />
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">响应时间</span>
|
||||
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
|
||||
<span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Separator class="mb-4" />
|
||||
|
||||
<!-- 统一使用阶梯计费展示方式 -->
|
||||
<!-- 单价信息行 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 统一使用阶梯展示格式 -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(tier, index) in displayTiers"
|
||||
:key="index"
|
||||
class="rounded-lg p-3 space-y-2"
|
||||
:class="index === currentTierIndex
|
||||
? 'bg-primary/5 border border-primary/30'
|
||||
: 'bg-muted/20 border border-border/50 opacity-60'"
|
||||
>
|
||||
<!-- 阶梯标题行 -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" :class="index === currentTierIndex ? 'text-primary' : 'text-muted-foreground'">
|
||||
第 {{ index + 1 }} 阶
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ getTierRangeText(tier, index, displayTiers) }}
|
||||
</span>
|
||||
<Badge v-if="index === currentTierIndex" variant="default" class="text-[10px] px-1.5 py-0 h-4">
|
||||
当前
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 单价信息 -->
|
||||
<div class="text-muted-foreground flex items-center gap-2">
|
||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span v-if="tier.cache_creation_price_per_1m">
|
||||
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
|
||||
</span>
|
||||
<span v-if="tier.cache_read_price_per_1m">
|
||||
缓存读取 ${{ formatPrice(tier.cache_read_price_per_1m) }}/M
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前阶梯的详细计算 -->
|
||||
<template v-if="index === currentTierIndex">
|
||||
<!-- 输入 输出 -->
|
||||
<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">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" class="h-4 mx-4" />
|
||||
<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">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
|
||||
<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>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" class="h-4 mx-4" />
|
||||
<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">{{ detail.cache_read_input_tokens || 0 }}</span>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 请求链路追踪卡片 -->
|
||||
<div v-if="detail.request_id || detail.id">
|
||||
<HorizontalRequestTimeline
|
||||
:request-id="detail.request_id || detail.id"
|
||||
:override-status-code="detail.status_code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息卡片 -->
|
||||
<Card v-if="detail.error_message" class="border-red-200 dark:border-red-800">
|
||||
<div class="p-4">
|
||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">错误信息</h4>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<p class="text-sm text-red-800 dark:text-red-300">{{ detail.error_message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Tabs 区域 -->
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<Tabs v-model="activeTab" :default-value="activeTab">
|
||||
<!-- Tab + 图标工具栏同行 -->
|
||||
<div class="flex items-center justify-between border-b pb-2 mb-3">
|
||||
<!-- 左侧 Tab -->
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.name"
|
||||
@click="activeTab = tab.name"
|
||||
class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-[9px]"
|
||||
:class="activeTab === tab.name
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 右侧图标工具栏 -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<!-- 请求头专用:对比/客户端/提供商 切换组 -->
|
||||
<template v-if="activeTab === 'request-headers' && hasProviderHeaders">
|
||||
<button
|
||||
:title="'对比'"
|
||||
@click="viewMode = 'compare'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Columns2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
:title="'客户端'"
|
||||
@click="viewMode = 'formatted'; dataSource = 'client'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'formatted' && dataSource === 'client' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Monitor class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
:title="'提供商'"
|
||||
@click="viewMode = 'formatted'; dataSource = 'provider'"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'formatted' && dataSource === 'provider' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
</button>
|
||||
<Separator orientation="vertical" class="h-4 mx-1" />
|
||||
</template>
|
||||
<!-- 展开/收缩 -->
|
||||
<button
|
||||
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
|
||||
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare'"
|
||||
>
|
||||
<Maximize2 v-if="currentExpandDepth === 0" class="w-4 h-4" />
|
||||
<Minimize2 v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<!-- 复制 -->
|
||||
<button
|
||||
:title="copiedStates[activeTab] ? '已复制' : '复制'"
|
||||
@click="copyJsonToClipboard(activeTab)"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="viewMode === 'compare'
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: 'text-muted-foreground hover:bg-muted'"
|
||||
:disabled="viewMode === 'compare'"
|
||||
>
|
||||
<Check v-if="copiedStates[activeTab]" class="w-4 h-4 text-green-500" />
|
||||
<Copy v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<TabsContent value="request-headers">
|
||||
<RequestHeadersContent
|
||||
:detail="detail"
|
||||
:view-mode="viewMode"
|
||||
:data-source="dataSource"
|
||||
:current-header-data="currentHeaderData"
|
||||
:current-expand-depth="currentExpandDepth"
|
||||
:has-provider-headers="hasProviderHeaders"
|
||||
:client-headers-with-diff="clientHeadersWithDiff"
|
||||
:provider-headers-with-diff="providerHeadersWithDiff"
|
||||
:header-stats="headerStats"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="request-body">
|
||||
<JsonContent
|
||||
:data="detail.request_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-headers">
|
||||
<JsonContent
|
||||
:data="detail.response_headers"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应头信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-body">
|
||||
<JsonContent
|
||||
:data="detail.response_body"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无响应体信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata">
|
||||
<JsonContent
|
||||
:data="detail.metadata"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无元数据信息"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const detail = ref<RequestDetail | null>(null)
|
||||
const activeTab = ref('request-body')
|
||||
const copiedStates = ref<Record<string, boolean>>({})
|
||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||
const currentExpandDepth = ref(1)
|
||||
const dataSource = ref<'client' | 'provider'>('client')
|
||||
const historicalPricing = ref<{
|
||||
input_price: string
|
||||
output_price: string
|
||||
cache_creation_price: string
|
||||
cache_read_price: string
|
||||
request_price: string
|
||||
} | null>(null)
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab !== 'request-headers' && viewMode.value === 'compare') {
|
||||
viewMode.value = 'formatted'
|
||||
}
|
||||
})
|
||||
|
||||
// 检测暗色模式
|
||||
const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 检测是否有提供商请求头
|
||||
const hasProviderHeaders = computed(() => {
|
||||
return !!(detail.value?.provider_request_headers &&
|
||||
Object.keys(detail.value.provider_request_headers).length > 0)
|
||||
})
|
||||
|
||||
// 获取当前数据源的请求头数据
|
||||
const currentHeaderData = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return dataSource.value === 'client'
|
||||
? detail.value.request_headers
|
||||
: detail.value.provider_request_headers
|
||||
})
|
||||
|
||||
// 价格来源标签
|
||||
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
|
||||
const priceSourceLabel = computed(() => {
|
||||
if (!detail.value) return '历史定价'
|
||||
|
||||
const source = detail.value.tiered_pricing?.source
|
||||
if (source === 'provider') {
|
||||
return '提供商定价'
|
||||
} else if (source === 'global') {
|
||||
return '全局定价'
|
||||
}
|
||||
|
||||
// 没有 tiered_pricing 时,使用历史价格
|
||||
return '历史定价'
|
||||
})
|
||||
|
||||
// 统一的阶梯显示数据
|
||||
// 如果有 tiered_pricing,使用它;否则用历史价格构建单阶梯
|
||||
const displayTiers = computed(() => {
|
||||
if (!detail.value) return []
|
||||
|
||||
// 如果有阶梯定价数据,直接使用
|
||||
if (detail.value.tiered_pricing?.tiers && detail.value.tiered_pricing.tiers.length > 0) {
|
||||
return detail.value.tiered_pricing.tiers
|
||||
}
|
||||
|
||||
// 否则用历史价格构建单阶梯(无上限)
|
||||
return [{
|
||||
up_to: null,
|
||||
input_price_per_1m: detail.value.input_price_per_1m || 0,
|
||||
output_price_per_1m: detail.value.output_price_per_1m || 0,
|
||||
cache_creation_price_per_1m: detail.value.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: detail.value.cache_read_price_per_1m
|
||||
}]
|
||||
})
|
||||
|
||||
// 当前命中的阶梯索引
|
||||
const currentTierIndex = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
|
||||
// 如果有阶梯定价,使用它的 tier_index
|
||||
if (detail.value.tiered_pricing?.tier_index !== undefined) {
|
||||
return detail.value.tiered_pricing.tier_index
|
||||
}
|
||||
|
||||
// 单阶梯时默认是第0阶
|
||||
return 0
|
||||
})
|
||||
|
||||
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
|
||||
const totalInputContext = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
|
||||
// 优先使用 tiered_pricing 中的值
|
||||
if (detail.value.tiered_pricing?.total_input_context !== undefined) {
|
||||
return detail.value.tiered_pricing.total_input_context
|
||||
}
|
||||
|
||||
// 否则手动计算
|
||||
const input = detail.value.tokens?.input || detail.value.input_tokens || 0
|
||||
const cacheCreation = detail.value.cache_creation_input_tokens || 0
|
||||
const cacheRead = detail.value.cache_read_input_tokens || 0
|
||||
return input + cacheCreation + cacheRead
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ name: 'request-headers', label: '请求头' },
|
||||
{ name: 'request-body', label: '请求体' },
|
||||
{ name: 'response-headers', label: '响应头' },
|
||||
{ name: 'response-body', label: '响应体' },
|
||||
{ name: 'metadata', label: '元数据' },
|
||||
]
|
||||
|
||||
// 根据实际数据决定显示哪些 Tab
|
||||
const visibleTabs = computed(() => {
|
||||
if (!detail.value) return []
|
||||
|
||||
return tabs.filter(tab => {
|
||||
switch (tab.name) {
|
||||
case 'request-headers':
|
||||
return detail.value!.request_headers && Object.keys(detail.value!.request_headers).length > 0
|
||||
case 'request-body':
|
||||
return detail.value!.request_body !== null && detail.value!.request_body !== undefined
|
||||
case 'response-headers':
|
||||
return detail.value!.response_headers && Object.keys(detail.value!.response_headers).length > 0
|
||||
case 'response-body':
|
||||
return detail.value!.response_body !== null && detail.value!.response_body !== undefined
|
||||
case 'metadata':
|
||||
return detail.value!.metadata && Object.keys(detail.value!.metadata).length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.requestId, async (newId) => {
|
||||
if (newId && props.isOpen) {
|
||||
await loadDetail(newId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen && props.requestId) {
|
||||
await loadDetail(props.requestId)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
historicalPricing.value = null
|
||||
try {
|
||||
detail.value = await dashboardApi.getRequestDetail(id)
|
||||
|
||||
// 默认显示有内容的第一个可见 tab
|
||||
const visibleTabNames = visibleTabs.value.map(t => t.name)
|
||||
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
|
||||
activeTab.value = 'request-body'
|
||||
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
|
||||
activeTab.value = 'response-body'
|
||||
} else if (visibleTabNames.length > 0) {
|
||||
activeTab.value = visibleTabNames[0]
|
||||
}
|
||||
|
||||
// 使用请求记录中保存的历史价格
|
||||
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
|
||||
historicalPricing.value = {
|
||||
input_price: detail.value.input_price_per_1m ? detail.value.input_price_per_1m.toFixed(4) : 'N/A',
|
||||
output_price: detail.value.output_price_per_1m ? detail.value.output_price_per_1m.toFixed(4) : 'N/A',
|
||||
cache_creation_price: detail.value.cache_creation_price_per_1m ? detail.value.cache_creation_price_per_1m.toFixed(4) : 'N/A',
|
||||
cache_read_price: detail.value.cache_read_price_per_1m ? detail.value.cache_read_price_per_1m.toFixed(4) : 'N/A',
|
||||
request_price: detail.value.price_per_request ? detail.value.price_per_request.toFixed(4) : 'N/A'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load request detail:', err)
|
||||
error.value = '加载请求详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
if (props.requestId) {
|
||||
await loadDetail(props.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return 'N/A'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatApiFormat(format: string | null | undefined): string {
|
||||
if (!format) return '-'
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) {
|
||||
return (num / 1_000_000).toFixed(1) + 'M'
|
||||
} else if (num >= 1_000) {
|
||||
return (num / 1_000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 格式化响应时间,自动选择合适的单位
|
||||
function formatResponseTime(ms: number): { value: string; unit: string } {
|
||||
if (ms >= 1_000) {
|
||||
return { value: (ms / 1_000).toFixed(2), unit: 's' }
|
||||
}
|
||||
return { value: ms.toString(), unit: 'ms' }
|
||||
}
|
||||
|
||||
// 格式化价格,修复浮点数精度问题
|
||||
function formatPrice(price: number): string {
|
||||
// 处理浮点数精度问题,最多保留4位小数,去掉尾部的0
|
||||
const fixed = price.toFixed(4)
|
||||
return parseFloat(fixed).toString()
|
||||
}
|
||||
|
||||
// 获取阶梯范围文本
|
||||
function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers: Array<{ up_to?: number | null }>): string {
|
||||
const prevTier = index > 0 ? tiers[index - 1] : null
|
||||
const start = prevTier?.up_to ? prevTier.up_to + 1 : 0
|
||||
|
||||
if (tier.up_to) {
|
||||
if (start === 0) {
|
||||
return `0 ~ ${formatNumber(tier.up_to)} tokens`
|
||||
}
|
||||
return `${formatNumber(start)} ~ ${formatNumber(tier.up_to)} tokens`
|
||||
}
|
||||
// 无上限的情况
|
||||
return `> ${formatNumber(start)} tokens`
|
||||
}
|
||||
|
||||
function copyJsonToClipboard(tabName: string) {
|
||||
if (!detail.value) return
|
||||
// 对比模式下不允许复制
|
||||
if (viewMode.value === 'compare') return
|
||||
|
||||
let data: any = null
|
||||
switch (tabName) {
|
||||
case 'request-headers':
|
||||
// 根据当前数据源选择要复制的数据
|
||||
data = dataSource.value === 'provider'
|
||||
? detail.value.provider_request_headers
|
||||
: detail.value.request_headers
|
||||
break
|
||||
case 'request-body':
|
||||
data = detail.value.request_body
|
||||
break
|
||||
case 'response-headers':
|
||||
data = detail.value.response_headers
|
||||
break
|
||||
case 'response-body':
|
||||
data = detail.value.response_body
|
||||
break
|
||||
case 'metadata':
|
||||
data = detail.value.metadata
|
||||
break
|
||||
}
|
||||
|
||||
if (data) {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
||||
copiedStates.value[tabName] = true
|
||||
setTimeout(() => {
|
||||
copiedStates.value[tabName] = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
currentExpandDepth.value = 999
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
currentExpandDepth.value = 0
|
||||
}
|
||||
|
||||
// 请求头合并对比逻辑
|
||||
interface HeaderEntry {
|
||||
key: string
|
||||
status: 'added' | 'modified' | 'removed' | 'unchanged'
|
||||
originalValue?: any
|
||||
newValue?: any
|
||||
}
|
||||
|
||||
const mergedHeaderEntries = computed(() => {
|
||||
if (!detail.value?.request_headers && !detail.value?.provider_request_headers) {
|
||||
return []
|
||||
}
|
||||
|
||||
const clientHeaders = detail.value?.request_headers || {}
|
||||
const providerHeaders = detail.value?.provider_request_headers || {}
|
||||
|
||||
const clientKeys = new Set(Object.keys(clientHeaders))
|
||||
const providerKeys = new Set(Object.keys(providerHeaders))
|
||||
const allKeys = new Set([...clientKeys, ...providerKeys])
|
||||
|
||||
const entries: HeaderEntry[] = []
|
||||
|
||||
for (const key of Array.from(allKeys).sort()) {
|
||||
const entry: HeaderEntry = { key, status: 'unchanged' }
|
||||
|
||||
if (clientKeys.has(key) && providerKeys.has(key)) {
|
||||
if (clientHeaders[key] !== providerHeaders[key]) {
|
||||
entry.status = 'modified'
|
||||
entry.originalValue = clientHeaders[key]
|
||||
entry.newValue = providerHeaders[key]
|
||||
} else {
|
||||
entry.status = 'unchanged'
|
||||
entry.originalValue = clientHeaders[key]
|
||||
}
|
||||
} else if (clientKeys.has(key)) {
|
||||
entry.status = 'removed'
|
||||
entry.originalValue = clientHeaders[key]
|
||||
} else {
|
||||
entry.status = 'added'
|
||||
entry.newValue = providerHeaders[key]
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
return entries
|
||||
})
|
||||
|
||||
const headerStats = computed(() => {
|
||||
const counts = {
|
||||
added: 0,
|
||||
modified: 0,
|
||||
removed: 0,
|
||||
unchanged: 0
|
||||
}
|
||||
|
||||
for (const entry of mergedHeaderEntries.value) {
|
||||
counts[entry.status]++
|
||||
}
|
||||
|
||||
return counts
|
||||
})
|
||||
|
||||
const clientHeadersWithDiff = computed(() => {
|
||||
if (!detail.value?.request_headers) return []
|
||||
|
||||
const headers = detail.value.request_headers
|
||||
const result = []
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const diffEntry = mergedHeaderEntries.value.find(e => e.key === key)
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
status: diffEntry?.status || 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const providerHeadersWithDiff = computed(() => {
|
||||
if (!detail.value?.provider_request_headers) return []
|
||||
|
||||
const headers = detail.value.provider_request_headers
|
||||
const result = []
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const diffEntry = mergedHeaderEntries.value.find(e => e.key === key)
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
status: diffEntry?.status || 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 抽屉过渡动画 */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active .relative,
|
||||
.drawer-leave-active .relative {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-leave-to .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-enter-to .relative,
|
||||
.drawer-leave-from .relative {
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 滚动条始终预留空间,保持宽度稳定 */
|
||||
.scrollbar-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Webkit 浏览器滚动条样式 */
|
||||
.scrollbar-stable::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-stable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-stable::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(128, 128, 128, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-stable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(128, 128, 128, 0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)" class="text-sm text-muted-foreground">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
<!-- 纯字符串数据(非 JSON 对象) -->
|
||||
<Card v-else-if="typeof data === 'string'" class="bg-muted/30 overflow-hidden">
|
||||
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- 非 JSON 响应(如 HTML 错误页面) -->
|
||||
<Card v-else-if="data.raw_response && data.metadata?.parse_error" class="bg-muted/30 overflow-hidden">
|
||||
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
|
||||
<span class="text-xs text-amber-700 dark:text-amber-300">{{ data.metadata.parse_error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ data.raw_response }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-else class="bg-muted/30 overflow-hidden">
|
||||
<!-- JSON 查看器 -->
|
||||
<div class="json-viewer" :class="{ 'theme-dark': isDark }">
|
||||
<div class="json-lines">
|
||||
<template v-for="line in visibleLines" :key="line.displayId">
|
||||
<div class="json-line" :class="{ 'has-fold': line.canFold }">
|
||||
<!-- 行号区域(包含折叠按钮) -->
|
||||
<div class="line-number-area">
|
||||
<span
|
||||
v-if="line.canFold"
|
||||
class="fold-button"
|
||||
@click="toggleFold(line.blockId)"
|
||||
>
|
||||
<ChevronRight v-if="collapsedBlocks.has(line.blockId)" class="fold-icon" />
|
||||
<ChevronDown v-else class="fold-icon" />
|
||||
</span>
|
||||
<span class="line-number">{{ line.displayLineNumber }}</span>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="line-content-area">
|
||||
<!-- 缩进 -->
|
||||
<span class="indent" :style="{ width: `${line.indent * 16}px` }"></span>
|
||||
<!-- 内容 -->
|
||||
<span
|
||||
class="line-content"
|
||||
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
|
||||
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
|
||||
v-html="getDisplayHtml(line)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
|
||||
interface JsonLine {
|
||||
id: number
|
||||
lineNumber: number
|
||||
indent: number
|
||||
html: string
|
||||
canFold: boolean
|
||||
blockId: string
|
||||
blockEnd?: number
|
||||
collapsedInfo?: string
|
||||
closingBracket?: string
|
||||
trailingComma?: string
|
||||
}
|
||||
|
||||
interface DisplayLine extends JsonLine {
|
||||
displayId: string
|
||||
displayLineNumber: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: any
|
||||
viewMode: 'formatted' | 'raw' | 'compare'
|
||||
expandDepth: number
|
||||
isDark: boolean
|
||||
emptyMessage: string
|
||||
}>()
|
||||
|
||||
const collapsedBlocks = ref<Set<string>>(new Set())
|
||||
const lines = ref<JsonLine[]>([])
|
||||
|
||||
const getTokenHtml = (value: string, type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'bracket' | 'punctuation' | 'ellipsis'): string => {
|
||||
const classMap = {
|
||||
key: 'token-key',
|
||||
string: 'token-string',
|
||||
number: 'token-number',
|
||||
boolean: 'token-boolean',
|
||||
null: 'token-null',
|
||||
bracket: 'token-bracket',
|
||||
punctuation: 'token-punctuation',
|
||||
ellipsis: 'token-ellipsis',
|
||||
}
|
||||
return `<span class="${classMap[type]}">${escapeHtml(value)}</span>`
|
||||
}
|
||||
|
||||
const escapeHtml = (str: string): string => {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
const parseJsonToLines = (data: any): JsonLine[] => {
|
||||
const result: JsonLine[] = []
|
||||
let lineNumber = 1
|
||||
let blockIdCounter = 0
|
||||
|
||||
const getBlockId = () => `block-${blockIdCounter++}`
|
||||
|
||||
const processValue = (value: any, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
|
||||
const comma = isLast ? '' : ','
|
||||
|
||||
if (value === null) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('null', 'null') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'boolean') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'boolean') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'number') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'number') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (typeof value === 'string') {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(`"${escapeHtml(value)}"`, 'string') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('[]', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else {
|
||||
const blockId = getBlockId()
|
||||
const startLine = result.length
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('[', 'bracket'),
|
||||
canFold: true,
|
||||
blockId,
|
||||
collapsedInfo: `${value.length} items`,
|
||||
closingBracket: ']',
|
||||
trailingComma: comma,
|
||||
})
|
||||
|
||||
value.forEach((item, i) => {
|
||||
processValue(item, indent + 1, i === value.length - 1)
|
||||
})
|
||||
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: getTokenHtml(']', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
|
||||
result[startLine].blockEnd = result.length - 1
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
const keys = Object.keys(value)
|
||||
if (keys.length === 0) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('{}', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
} else {
|
||||
const blockId = getBlockId()
|
||||
const startLine = result.length
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml('{', 'bracket'),
|
||||
canFold: true,
|
||||
blockId,
|
||||
collapsedInfo: `${keys.length} keys`,
|
||||
closingBracket: '}',
|
||||
trailingComma: comma,
|
||||
})
|
||||
|
||||
keys.forEach((key, i) => {
|
||||
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
|
||||
processValue(value[key], indent + 1, i === keys.length - 1, keyHtml)
|
||||
})
|
||||
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: getTokenHtml('}', 'bracket') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
|
||||
result[startLine].blockEnd = result.length - 1
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
id: result.length,
|
||||
lineNumber: lineNumber++,
|
||||
indent,
|
||||
html: keyPrefix + getTokenHtml(String(value), 'string') + comma,
|
||||
canFold: false,
|
||||
blockId: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
processValue(data, 0, true)
|
||||
return result
|
||||
}
|
||||
|
||||
const visibleLines = computed((): DisplayLine[] => {
|
||||
const result: DisplayLine[] = []
|
||||
const hiddenRanges: Array<{ start: number; end: number }> = []
|
||||
|
||||
for (const line of lines.value) {
|
||||
if (line.canFold && collapsedBlocks.value.has(line.blockId) && line.blockEnd !== undefined) {
|
||||
hiddenRanges.push({ start: line.id + 1, end: line.blockEnd })
|
||||
}
|
||||
}
|
||||
|
||||
const isHidden = (id: number): boolean => {
|
||||
return hiddenRanges.some(range => id >= range.start && id <= range.end)
|
||||
}
|
||||
|
||||
let displayLineNumber = 1
|
||||
for (const line of lines.value) {
|
||||
if (!isHidden(line.id)) {
|
||||
result.push({
|
||||
...line,
|
||||
displayId: `display-${line.id}`,
|
||||
displayLineNumber: displayLineNumber++,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const getDisplayHtml = (line: DisplayLine): string => {
|
||||
if (line.canFold && collapsedBlocks.value.has(line.blockId)) {
|
||||
const closingBracket = getTokenHtml(line.closingBracket || '}', 'bracket')
|
||||
const ellipsis = getTokenHtml('...', 'ellipsis')
|
||||
const comma = line.trailingComma || ''
|
||||
return `${line.html}${ellipsis}${closingBracket}${comma}<span class="collapsed-info">${line.collapsedInfo}</span>`
|
||||
}
|
||||
return line.html
|
||||
}
|
||||
|
||||
const toggleFold = (blockId: string) => {
|
||||
const newSet = new Set(collapsedBlocks.value)
|
||||
if (newSet.has(blockId)) {
|
||||
newSet.delete(blockId)
|
||||
} else {
|
||||
newSet.add(blockId)
|
||||
}
|
||||
collapsedBlocks.value = newSet
|
||||
}
|
||||
|
||||
const initCollapsedState = () => {
|
||||
const newSet = new Set<string>()
|
||||
|
||||
// 默认展开第一层(indent = 0),折叠更深层(indent >= 1)
|
||||
// expandDepth = 999 表示全部展开
|
||||
const depth = props.expandDepth === 0 ? 1 : props.expandDepth
|
||||
if (depth < 999) {
|
||||
for (const line of lines.value) {
|
||||
if (line.canFold && line.indent >= depth) {
|
||||
newSet.add(line.blockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapsedBlocks.value = newSet
|
||||
}
|
||||
|
||||
watch(() => props.data, () => {
|
||||
if (props.data) {
|
||||
lines.value = parseJsonToLines(props.data)
|
||||
initCollapsedState()
|
||||
} else {
|
||||
lines.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.expandDepth, () => {
|
||||
initCollapsedState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer {
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.json-lines {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.json-line {
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.json-line:hover {
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
}
|
||||
|
||||
.line-number-area {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fold-button {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
margin-right: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.fold-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.fold-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: hsl(var(--muted-foreground) / 0.5);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.line-content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.line-content.clickable-collapsed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.line-content.clickable-collapsed:hover :deep(.token-ellipsis) {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Token 颜色 - 亮色主题 */
|
||||
:deep(.token-key) {
|
||||
color: #0451a5;
|
||||
}
|
||||
|
||||
:deep(.token-string) {
|
||||
color: #a31515;
|
||||
}
|
||||
|
||||
:deep(.token-number) {
|
||||
color: #098658;
|
||||
}
|
||||
|
||||
:deep(.token-boolean) {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
:deep(.token-null) {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
:deep(.token-bracket) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:deep(.token-punctuation) {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:deep(.token-ellipsis) {
|
||||
color: #0451a5;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
:deep(.collapsed-info) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Token 颜色 - 暗色主题 */
|
||||
.theme-dark :deep(.token-key) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-string) {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-number) {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-boolean) {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-null) {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-bracket) {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-punctuation) {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.theme-dark :deep(.token-ellipsis) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
.theme-dark .line-number-area {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 对比模式 - 并排 Diff -->
|
||||
<div v-show="viewMode === 'compare'">
|
||||
<div v-if="!detail.request_headers && !detail.provider_request_headers" class="text-sm text-muted-foreground">
|
||||
无请求头信息
|
||||
</div>
|
||||
<Card v-else class="bg-muted/30 overflow-hidden">
|
||||
<!-- Diff 头部 -->
|
||||
<div class="flex border-b bg-muted/50">
|
||||
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground border-r flex items-center justify-between">
|
||||
<span class="font-medium">客户端请求头</span>
|
||||
<span class="text-destructive">-{{ headerStats.removed + headerStats.modified }}</span>
|
||||
</div>
|
||||
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span class="font-medium">提供商请求头</span>
|
||||
<span class="text-green-600 dark:text-green-400">+{{ headerStats.added + headerStats.modified }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并排 Diff 内容 -->
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<div class="flex font-mono text-xs">
|
||||
<!-- 左侧:客户端 -->
|
||||
<div class="flex-1 border-r">
|
||||
<template v-for="entry in sortedEntries" :key="'left-' + entry.key">
|
||||
<!-- 删除的行 -->
|
||||
<div v-if="entry.status === 'removed'" class="flex items-start bg-destructive/10 px-3 py-0.5">
|
||||
<span class="text-destructive">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 修改的行 - 旧值 -->
|
||||
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 新增的行 - 左侧空白占位 -->
|
||||
<div v-else-if="entry.status === 'added'" class="flex items-start bg-muted/30 px-3 py-0.5">
|
||||
<span class="text-muted-foreground/30 italic">(无)</span>
|
||||
</div>
|
||||
<!-- 未变化的行 -->
|
||||
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
|
||||
<span class="text-muted-foreground">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧:提供商 -->
|
||||
<div class="flex-1">
|
||||
<template v-for="entry in sortedEntries" :key="'right-' + entry.key">
|
||||
<!-- 删除的行 - 右侧空白占位 -->
|
||||
<div v-if="entry.status === 'removed'" class="flex items-start bg-muted/30 px-3 py-0.5">
|
||||
<span class="text-muted-foreground/50 line-through">
|
||||
"{{ entry.key }}": "{{ entry.clientValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 修改的行 - 新值 -->
|
||||
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 新增的行 -->
|
||||
<div v-else-if="entry.status === 'added'" class="flex items-start bg-green-500/10 px-3 py-0.5">
|
||||
<span class="text-green-600 dark:text-green-400">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
<!-- 未变化的行 -->
|
||||
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
|
||||
<span class="text-muted-foreground">
|
||||
"{{ entry.key }}": "{{ entry.providerValue }}"
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 格式化模式 - 直接使用 JsonContent -->
|
||||
<div v-show="viewMode === 'formatted'">
|
||||
<JsonContent
|
||||
:data="currentHeaderData"
|
||||
:view-mode="viewMode"
|
||||
:expand-depth="currentExpandDepth"
|
||||
:is-dark="isDark"
|
||||
empty-message="无请求头信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 原始模式 -->
|
||||
<div v-show="viewMode === 'raw'">
|
||||
<div v-if="!currentHeaderData || Object.keys(currentHeaderData).length === 0" class="text-sm text-muted-foreground">
|
||||
无请求头信息
|
||||
</div>
|
||||
<Card v-else class="bg-muted/30">
|
||||
<div class="p-4 overflow-x-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(currentHeaderData, null, 2) }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import JsonContent from './JsonContent.vue'
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
|
||||
const props = defineProps<{
|
||||
detail: RequestDetail
|
||||
viewMode: 'compare' | 'formatted' | 'raw'
|
||||
dataSource: 'client' | 'provider'
|
||||
currentHeaderData: any
|
||||
currentExpandDepth: number
|
||||
hasProviderHeaders: boolean
|
||||
clientHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
providerHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
headerStats: { added: number; modified: number; removed: number; unchanged: number }
|
||||
isDark: boolean
|
||||
}>()
|
||||
|
||||
// 合并并排序的条目(用于并排显示)
|
||||
const sortedEntries = computed(() => {
|
||||
const clientHeaders = props.detail.request_headers || {}
|
||||
const providerHeaders = props.detail.provider_request_headers || {}
|
||||
|
||||
const clientKeys = new Set(Object.keys(clientHeaders))
|
||||
const providerKeys = new Set(Object.keys(providerHeaders))
|
||||
const allKeys = Array.from(new Set([...clientKeys, ...providerKeys])).sort()
|
||||
|
||||
return allKeys.map(key => {
|
||||
const inClient = clientKeys.has(key)
|
||||
const inProvider = providerKeys.has(key)
|
||||
const clientValue = clientHeaders[key]
|
||||
const providerValue = providerHeaders[key]
|
||||
|
||||
let status: 'added' | 'removed' | 'modified' | 'unchanged'
|
||||
if (inClient && inProvider) {
|
||||
status = clientValue === providerValue ? 'unchanged' : 'modified'
|
||||
} else if (inClient) {
|
||||
status = 'removed'
|
||||
} else {
|
||||
status = 'added'
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
clientValue,
|
||||
providerValue,
|
||||
status
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按API格式分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">API格式</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">平均响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无API格式统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="item in data" :key="item.api_format">
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ formatApiFormat(item.api_format) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ item.request_count }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(item.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
||||
<span v-if="isAdmin && item.actual_cost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(item.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ item.avgResponseTime }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { ApiFormatStatsItem } from '../types'
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
function formatApiFormat(format: string): string {
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: ApiFormatStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
61
frontend/src/features/usage/components/UsageModelTable.vue
Normal file
61
frontend/src/features/usage/components/UsageModelTable.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按模型分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">模型</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">效率</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无模型统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="model in data" :key="model.model">
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ model.model.replace('claude-', '') }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ model.request_count }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(model.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
||||
<span v-if="isAdmin && model.actual_cost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(model.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ model.costPerToken }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { EnhancedModelStatsItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
data: EnhancedModelStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">按提供商分析</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">提供商</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">请求数</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">Tokens</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">费用</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">成功率</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">平均响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell :colspan="6" class="text-center py-6 text-muted-foreground px-2">
|
||||
暂无提供商统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="provider in data" :key="provider.provider">
|
||||
<TableCell class="font-medium py-2 px-2">{{ provider.provider }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">{{ provider.requests }}</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(provider.totalTokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
||||
<span v-if="isAdmin && provider.actualCost !== undefined" class="text-muted-foreground text-[10px]">
|
||||
{{ formatCurrency(provider.actualCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ provider.avgResponseTime }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import type { ProviderStatsItem } from '../types'
|
||||
|
||||
// 成功率样式 - 简化为两种状态
|
||||
function getSuccessRateClass(rate: number): string {
|
||||
if (rate < 90) return 'text-destructive'
|
||||
return '' // 默认颜色
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
data: ProviderStatsItem[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
</script>
|
||||
424
frontend/src/features/usage/components/UsageRecordsTable.vue
Normal file
424
frontend/src/features/usage/components/UsageRecordsTable.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<TableCard title="使用记录">
|
||||
<template #actions>
|
||||
<!-- 时间段筛选 -->
|
||||
<Select
|
||||
:model-value="selectedPeriod"
|
||||
v-model:open="periodSelectOpen"
|
||||
@update:model-value="$emit('update:selectedPeriod', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">今天</SelectItem>
|
||||
<SelectItem value="yesterday">昨天</SelectItem>
|
||||
<SelectItem value="last7days">最近7天</SelectItem>
|
||||
<SelectItem value="last30days">最近30天</SelectItem>
|
||||
<SelectItem value="last90days">最近90天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 用户筛选(仅管理员可见) -->
|
||||
<Select
|
||||
v-if="isAdmin && availableUsers.length > 0"
|
||||
:model-value="filterUser"
|
||||
v-model:open="filterUserSelectOpen"
|
||||
@update:model-value="$emit('update:filterUser', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部用户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部用户</SelectItem>
|
||||
<SelectItem v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||
{{ user.username || user.email }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select
|
||||
:model-value="filterModel"
|
||||
v-model:open="filterModelSelectOpen"
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部模型</SelectItem>
|
||||
<SelectItem v-for="model in availableModels" :key="model" :value="model">
|
||||
{{ model.replace('claude-', '') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 提供商筛选 -->
|
||||
<Select
|
||||
:model-value="filterProvider"
|
||||
v-model:open="filterProviderSelectOpen"
|
||||
@update:model-value="$emit('update:filterProvider', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部提供商</SelectItem>
|
||||
<SelectItem v-for="provider in availableProviders" :key="provider" :value="provider">
|
||||
{{ provider }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
v-model:open="filterStatusSelectOpen"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部状态</SelectItem>
|
||||
<SelectItem value="active">进行中</SelectItem>
|
||||
<SelectItem value="pending">等待中</SelectItem>
|
||||
<SelectItem value="streaming">流式传输</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">已失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="$emit('refresh')" />
|
||||
</template>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-12 font-semibold w-[70px]">时间</TableHead>
|
||||
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">用户</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px]">模型</TableHead>
|
||||
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">提供商</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[80px]">API格式</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[50px] text-center">类型</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px] text-right">Tokens</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[100px] text-right">费用</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[70px] text-right">
|
||||
<div class="inline-block max-w-[2rem] leading-tight">响应时间</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="records.length === 0">
|
||||
<TableCell :colspan="isAdmin ? 9 : 7" class="text-center py-12 text-muted-foreground">
|
||||
暂无请求记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-else
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
@mousedown="handleMouseDown"
|
||||
@click="handleRowClick($event, record.id)"
|
||||
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
|
||||
>
|
||||
<TableCell class="text-xs py-4 w-[70px]">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4 w-[100px] truncate" :title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')">
|
||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||
</TableCell>
|
||||
<TableCell class="font-medium py-4 w-[140px]" :title="getModelTooltip(record)">
|
||||
<div v-if="getActualModel(record)" class="flex flex-col text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1 truncate">
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 text-muted-foreground flex-shrink-0">
|
||||
<path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
|
||||
</div>
|
||||
<span v-else class="truncate block">{{ record.model }}</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4 w-[60px]">
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ record.provider }}</span>
|
||||
<span
|
||||
v-if="record.has_fallback"
|
||||
class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400"
|
||||
title="此请求发生了 Provider 切换"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="record.api_key_name" class="text-muted-foreground truncate" :title="record.api_key_name">
|
||||
{{ record.api_key_name }}
|
||||
<span v-if="record.rate_multiplier && record.rate_multiplier !== 1.0" class="text-foreground/60">({{ record.rate_multiplier }}x)</span>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 w-[80px]">
|
||||
<span
|
||||
v-if="record.api_format"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full border border-border/60 text-[10px] font-medium whitespace-nowrap text-muted-foreground"
|
||||
:title="record.api_format"
|
||||
>
|
||||
{{ formatApiFormat(record.api_format) }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center py-4 w-[50px]">
|
||||
<!-- 优先显示请求状态 -->
|
||||
<Badge v-if="record.status === 'pending'" variant="outline" class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground">
|
||||
等待中
|
||||
</Badge>
|
||||
<Badge v-else-if="record.status === 'streaming'" variant="outline" class="whitespace-nowrap animate-pulse border-primary/50 text-primary">
|
||||
传输中
|
||||
</Badge>
|
||||
<Badge v-else-if="record.status === 'failed' || (record.status_code && record.status_code >= 400) || record.error_message" variant="destructive" class="whitespace-nowrap">
|
||||
失败
|
||||
</Badge>
|
||||
<Badge v-else-if="record.is_stream" variant="secondary" class="whitespace-nowrap">
|
||||
流式
|
||||
</Badge>
|
||||
<Badge v-else variant="outline" class="whitespace-nowrap border-border/60 text-muted-foreground">
|
||||
标准
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[140px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<span :class="record.cache_creation_input_tokens ? 'text-foreground/70' : ''">{{ record.cache_creation_input_tokens ? formatTokens(record.cache_creation_input_tokens) : '-' }}</span>
|
||||
<span>/</span>
|
||||
<span :class="record.cache_read_input_tokens ? 'text-foreground/70' : ''">{{ record.cache_read_input_tokens ? formatTokens(record.cache_read_input_tokens) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[100px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||
<span v-if="showActualCost && record.actual_cost !== undefined" class="text-muted-foreground">
|
||||
{{ formatCurrency(record.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-4 w-[70px]">
|
||||
<span
|
||||
v-if="record.status === 'pending' || record.status === 'streaming'"
|
||||
class="text-primary tabular-nums"
|
||||
>
|
||||
{{ getElapsedTime(record) }}
|
||||
</span>
|
||||
<span v-else-if="record.response_time_ms">
|
||||
{{ (record.response_time_ms / 1000).toFixed(2) }}s
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="totalRecords > 0"
|
||||
:current="currentPage"
|
||||
:total="totalRecords"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:current="$emit('update:currentPage', $event)"
|
||||
@update:page-size="$emit('update:pageSize', $event)"
|
||||
/>
|
||||
</template>
|
||||
</TableCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import TableCard from '@/components/ui/table-card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import { Pagination, RefreshButton } from '@/components/ui'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export interface UserOption {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
records: UsageRecord[]
|
||||
isAdmin: boolean
|
||||
showActualCost: boolean
|
||||
loading: boolean
|
||||
// 时间段
|
||||
selectedPeriod: string
|
||||
// 筛选
|
||||
filterUser: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
filterStatus: string
|
||||
availableUsers: UserOption[]
|
||||
availableModels: string[]
|
||||
availableProviders: string[]
|
||||
// 分页
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
totalRecords: number
|
||||
pageSizeOptions: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedPeriod': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
'update:filterStatus': [value: string]
|
||||
'update:currentPage': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
'refresh': []
|
||||
'showDetail': [id: string]
|
||||
}>()
|
||||
|
||||
// Select 打开状态
|
||||
const periodSelectOpen = ref(false)
|
||||
const filterUserSelectOpen = ref(false)
|
||||
const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 动态计时器相关
|
||||
const now = ref(Date.now())
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 检查是否有活跃请求
|
||||
const hasActiveRecords = computed(() => {
|
||||
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
|
||||
})
|
||||
|
||||
// 启动计时器
|
||||
function startTimer() {
|
||||
if (timerInterval) return
|
||||
timerInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 100) // 每 100ms 更新一次
|
||||
}
|
||||
|
||||
// 停止计时器
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 计算活跃请求的实时耗时
|
||||
function getElapsedTime(record: UsageRecord): string {
|
||||
if (record.status !== 'pending' && record.status !== 'streaming') {
|
||||
// 非活跃状态,显示实际响应时间
|
||||
if (record.response_time_ms) {
|
||||
return `${(record.response_time_ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 活跃状态,计算实时耗时
|
||||
if (!record.created_at) return '-'
|
||||
|
||||
const createdAt = new Date(record.created_at).getTime()
|
||||
const elapsed = now.value - createdAt
|
||||
|
||||
if (elapsed < 0) return '0.00s'
|
||||
return `${(elapsed / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// 监听活跃记录状态,自动启动/停止计时器
|
||||
watch(hasActiveRecords, (hasActive) => {
|
||||
if (hasActive) {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
// 处理行点击,排除文本选择操作
|
||||
function handleRowClick(event: MouseEvent, id: string) {
|
||||
if (!props.isAdmin) return
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
emit('showDetail', id)
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
// 格式化 API 格式显示名称
|
||||
function formatApiFormat(format: string): string {
|
||||
const formatMap: Record<string, string> = {
|
||||
'CLAUDE': 'Claude',
|
||||
'CLAUDE_CLI': 'Claude CLI',
|
||||
'OPENAI': 'OpenAI',
|
||||
'OPENAI_CLI': 'OpenAI CLI',
|
||||
'GEMINI': 'Gemini',
|
||||
'GEMINI_CLI': 'Gemini CLI',
|
||||
}
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
// 获取实际使用的模型(优先 target_model,其次 model_version)
|
||||
function getActualModel(record: UsageRecord): string | null {
|
||||
// 优先显示模型映射
|
||||
if (record.target_model) {
|
||||
return record.target_model
|
||||
}
|
||||
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
|
||||
if (record.request_metadata?.model_version) {
|
||||
return record.request_metadata.model_version
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取模型列的 tooltip
|
||||
function getModelTooltip(record: UsageRecord): string {
|
||||
const actualModel = getActualModel(record)
|
||||
if (actualModel) {
|
||||
return `${record.model} -> ${actualModel}`
|
||||
}
|
||||
return record.model
|
||||
}
|
||||
</script>
|
||||
7
frontend/src/features/usage/components/index.ts
Normal file
7
frontend/src/features/usage/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as UsageModelTable } from './UsageModelTable.vue'
|
||||
export { default as UsageProviderTable } from './UsageProviderTable.vue'
|
||||
export { default as UsageApiFormatTable } from './UsageApiFormatTable.vue'
|
||||
export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
||||
4
frontend/src/features/usage/composables/index.ts
Normal file
4
frontend/src/features/usage/composables/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useUsageData } from './useUsageData'
|
||||
export { useUsageFilters } from './useUsageFilters'
|
||||
export { useUsagePagination } from './useUsagePagination'
|
||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { PeriodValue, DateRangeParams } from '../types'
|
||||
|
||||
/**
|
||||
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
|
||||
*/
|
||||
function formatDateForApi(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间段值计算日期范围
|
||||
*/
|
||||
export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
let endDate = new Date(now)
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'last7days':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last30days':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last90days':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
return {} // 返回空对象表示不过滤时间
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: formatDateForApi(startDate),
|
||||
end_date: formatDateForApi(endDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为时分秒
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
// 后端返回的是 UTC 时间但没有时区标识,需要手动添加 'Z'
|
||||
const utcDateStr = dateStr.includes('Z') || dateStr.includes('+') ? dateStr : dateStr + 'Z'
|
||||
const date = new Date(utcDateStr)
|
||||
|
||||
// 只显示时分秒
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成功率颜色类名
|
||||
*/
|
||||
export function getSuccessRateColor(rate: number): string {
|
||||
if (rate >= 95) return 'text-green-600 dark:text-green-400'
|
||||
if (rate >= 90) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { meApi } from '@/api/me'
|
||||
import type {
|
||||
UsageStatsState,
|
||||
ModelStatsItem,
|
||||
ProviderStatsItem,
|
||||
ApiFormatStatsItem,
|
||||
UsageRecord,
|
||||
DateRangeParams,
|
||||
EnhancedModelStatsItem
|
||||
} from '../types'
|
||||
import { createDefaultStats } from '../types'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export function useUsageData(options: UseUsageDataOptions) {
|
||||
const { isAdminPage } = options
|
||||
|
||||
// 加载状态
|
||||
const isLoadingStats = ref(true)
|
||||
const isLoadingRecords = ref(false)
|
||||
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<UsageStatsState>(createDefaultStats())
|
||||
const modelStats = ref<ModelStatsItem[]>([])
|
||||
const providerStats = ref<ProviderStatsItem[]>([])
|
||||
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
|
||||
|
||||
// 记录数据 - 只存储当前页
|
||||
const currentRecords = ref<UsageRecord[]>([])
|
||||
const totalRecords = ref(0)
|
||||
|
||||
// 当前的日期范围(用于分页请求)
|
||||
const currentDateRange = ref<DateRangeParams | undefined>(undefined)
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
return modelStats.value.map(model => ({
|
||||
...model,
|
||||
costPerToken: model.total_tokens > 0
|
||||
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
|
||||
: '-'
|
||||
}))
|
||||
})
|
||||
|
||||
// 活跃度热图数据
|
||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面,并行加载统计数据
|
||||
const [statsData, modelData, providerData, apiFormatData] = await Promise.all([
|
||||
usageApi.getUsageStats(dateRange),
|
||||
usageApi.getUsageByModel(dateRange),
|
||||
usageApi.getUsageByProvider(dateRange),
|
||||
usageApi.getUsageByApiFormat(dateRange)
|
||||
])
|
||||
|
||||
stats.value = {
|
||||
total_requests: statsData.total_requests || 0,
|
||||
total_tokens: statsData.total_tokens || 0,
|
||||
total_cost: statsData.total_cost || 0,
|
||||
total_actual_cost: (statsData as any).total_actual_cost,
|
||||
avg_response_time: statsData.avg_response_time || 0,
|
||||
error_count: (statsData as any).error_count,
|
||||
error_rate: (statsData as any).error_rate,
|
||||
cache_stats: (statsData as any).cache_stats,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: statsData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => ({
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: (item as any).actual_cost
|
||||
}))
|
||||
|
||||
providerStats.value = providerData.map(item => ({
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
totalCost: item.total_cost,
|
||||
actualCost: item.actual_cost,
|
||||
successRate: item.success_rate,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
apiFormatStats.value = apiFormatData.map(item => ({
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: item.actual_cost,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 从统计数据中提取可用的筛选选项
|
||||
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
|
||||
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
|
||||
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
total_tokens: userData.total_tokens || 0,
|
||||
total_cost: userData.total_cost || 0,
|
||||
total_actual_cost: userData.total_actual_cost,
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: userData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost_usd || 0,
|
||||
actual_cost: item.actual_total_cost_usd
|
||||
}))
|
||||
|
||||
providerStats.value = (userData.summary_by_provider || []).map((item: any) => ({
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalCost: item.total_cost_usd || 0,
|
||||
successRate: item.success_rate || 0,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
totalRecords.value = currentRecords.value.length
|
||||
|
||||
// 从记录中提取筛选选项和 API 格式统计
|
||||
const models = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
const apiFormatMap = new Map<string, {
|
||||
count: number
|
||||
tokens: number
|
||||
cost: number
|
||||
totalResponseTime: number
|
||||
responseTimeCount: number
|
||||
}>()
|
||||
|
||||
currentRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
if (record.provider) providers.add(record.provider)
|
||||
if (record.api_format) {
|
||||
const existing = apiFormatMap.get(record.api_format) || {
|
||||
count: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
totalResponseTime: 0,
|
||||
responseTimeCount: 0
|
||||
}
|
||||
existing.count++
|
||||
existing.tokens += record.total_tokens || 0
|
||||
existing.cost += record.cost || 0
|
||||
if (record.response_time_ms) {
|
||||
existing.totalResponseTime += record.response_time_ms
|
||||
existing.responseTimeCount++
|
||||
}
|
||||
apiFormatMap.set(record.api_format, existing)
|
||||
}
|
||||
})
|
||||
|
||||
availableModels.value = Array.from(models).sort()
|
||||
availableProviders.value = Array.from(providers).sort()
|
||||
|
||||
// 构建 API 格式统计数据
|
||||
apiFormatStats.value = Array.from(apiFormatMap.entries())
|
||||
.map(([format, data]) => {
|
||||
const avgMs = data.responseTimeCount > 0
|
||||
? data.totalResponseTime / data.responseTimeCount
|
||||
: 0
|
||||
return {
|
||||
api_format: format,
|
||||
request_count: data.count,
|
||||
total_tokens: data.tokens,
|
||||
total_cost: data.cost,
|
||||
avgResponseTime: avgMs > 0 ? `${(avgMs / 1000).toFixed(2)}s` : '-'
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status !== 403) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
stats.value = createDefaultStats()
|
||||
modelStats.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载记录(真正的后端分页)
|
||||
async function loadRecords(
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
const offset = (pagination.page - 1) * pagination.pageSize
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
limit: pagination.pageSize,
|
||||
offset,
|
||||
...currentDateRange.value
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData(dateRange?: DateRangeParams) {
|
||||
await loadStats(dateRange)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
isLoadingStats,
|
||||
isLoadingRecords,
|
||||
stats,
|
||||
modelStats,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
|
||||
// 筛选选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ref, computed, type Ref, watch } from 'vue'
|
||||
import type { UsageRecord, FilterStatusValue } from '../types'
|
||||
|
||||
export interface UseUsageFiltersOptions {
|
||||
/** 所有记录的响应式引用 */
|
||||
allRecords: Ref<UsageRecord[]>
|
||||
/** 当筛选变化时的回调 */
|
||||
onFilterChange?: () => void
|
||||
}
|
||||
|
||||
export function useUsageFilters(options: UseUsageFiltersOptions) {
|
||||
const { allRecords, onFilterChange } = options
|
||||
|
||||
// 筛选状态
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
|
||||
// Select 打开状态
|
||||
const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 可用模型和提供商选项
|
||||
const availableModels = computed(() => {
|
||||
const models = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
})
|
||||
return Array.from(models).sort()
|
||||
})
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
const providers = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.provider) providers.add(record.provider)
|
||||
})
|
||||
return Array.from(providers).sort()
|
||||
})
|
||||
|
||||
// 是否有活跃的筛选条件
|
||||
const hasActiveFilters = computed(() => {
|
||||
return filterModel.value !== '__all__' ||
|
||||
filterProvider.value !== '__all__' ||
|
||||
filterStatus.value !== '__all__'
|
||||
})
|
||||
|
||||
// 筛选后的记录
|
||||
const filteredRecords = computed(() => {
|
||||
if (!hasActiveFilters.value) {
|
||||
return allRecords.value
|
||||
}
|
||||
|
||||
let records = [...allRecords.value]
|
||||
|
||||
if (filterModel.value !== '__all__') {
|
||||
records = records.filter(record => record.model === filterModel.value)
|
||||
}
|
||||
|
||||
if (filterProvider.value !== '__all__') {
|
||||
records = records.filter(record => record.provider === filterProvider.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== '__all__') {
|
||||
if (filterStatus.value === 'stream') {
|
||||
records = records.filter(record =>
|
||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'standard') {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
})
|
||||
|
||||
// 筛选后的总记录数
|
||||
const filteredTotalRecords = computed(() => filteredRecords.value.length)
|
||||
|
||||
// 处理筛选变化
|
||||
function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterProviderChange(value: string) {
|
||||
filterProvider.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterStatusChange(value: string) {
|
||||
filterStatus.value = value as FilterStatusValue
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
// 重置所有筛选
|
||||
function resetFilters() {
|
||||
filterModel.value = '__all__'
|
||||
filterProvider.value = '__all__'
|
||||
filterStatus.value = '__all__'
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
return {
|
||||
// 筛选状态
|
||||
filterModel,
|
||||
filterProvider,
|
||||
filterStatus,
|
||||
|
||||
// Select 打开状态
|
||||
filterModelSelectOpen,
|
||||
filterProviderSelectOpen,
|
||||
filterStatusSelectOpen,
|
||||
|
||||
// 可用选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
hasActiveFilters,
|
||||
filteredRecords,
|
||||
filteredTotalRecords,
|
||||
|
||||
// 方法
|
||||
handleFilterModelChange,
|
||||
handleFilterProviderChange,
|
||||
handleFilterStatusChange,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export interface UseUsagePaginationOptions {
|
||||
/** 数据源记录 */
|
||||
records: Ref<UsageRecord[]>
|
||||
/** 初始页码 */
|
||||
initialPage?: number
|
||||
/** 初始每页大小 */
|
||||
initialPageSize?: number
|
||||
/** 每页大小选项 */
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
export function useUsagePagination(options: UseUsagePaginationOptions) {
|
||||
const {
|
||||
records,
|
||||
initialPage = 1,
|
||||
initialPageSize = 20,
|
||||
pageSizeOptions = [10, 20, 50, 100]
|
||||
} = options
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(initialPage)
|
||||
const pageSize = ref(initialPageSize)
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(records.value.length / pageSize.value))
|
||||
)
|
||||
|
||||
// 计算总记录数
|
||||
const totalRecords = computed(() => records.value.length)
|
||||
|
||||
// 分页后的记录
|
||||
const paginatedRecords = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return records.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 处理页码变化
|
||||
function changePage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
function changePageSize(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
}
|
||||
|
||||
// 重置到第一页
|
||||
function resetPage() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 跳转到最后一页
|
||||
function goToLastPage() {
|
||||
currentPage.value = totalPages.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentPage,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
|
||||
// 计算属性
|
||||
totalPages,
|
||||
totalRecords,
|
||||
paginatedRecords,
|
||||
|
||||
// 方法
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetPage,
|
||||
goToLastPage
|
||||
}
|
||||
}
|
||||
1
frontend/src/features/usage/index.ts
Normal file
1
frontend/src/features/usage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
120
frontend/src/features/usage/types.ts
Normal file
120
frontend/src/features/usage/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
||||
avg_response_time: number
|
||||
error_count?: number
|
||||
error_rate?: number
|
||||
cache_stats?: {
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost: number
|
||||
cache_read_cost: number
|
||||
}
|
||||
period_start: string
|
||||
period_end: string
|
||||
activity_heatmap: ActivityHeatmap | null
|
||||
}
|
||||
|
||||
// 模型统计
|
||||
export interface ModelStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost?: number // 倍率消耗
|
||||
}
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
export interface EnhancedModelStatsItem extends ModelStatsItem {
|
||||
costPerToken: string
|
||||
}
|
||||
|
||||
// 提供商统计
|
||||
export interface ProviderStatsItem {
|
||||
provider: string
|
||||
requests: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
actualCost?: number
|
||||
successRate: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// API格式统计
|
||||
export interface ApiFormatStatsItem {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_cost?: number
|
||||
avgResponseTime: string
|
||||
}
|
||||
|
||||
// 请求记录
|
||||
// 请求状态类型
|
||||
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
user_email?: string
|
||||
provider: string
|
||||
api_key_name?: string
|
||||
rate_multiplier?: number
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
api_format?: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
total_tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
response_time_ms?: number
|
||||
is_stream: boolean
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
|
||||
created_at: string
|
||||
has_fallback?: boolean
|
||||
request_metadata?: {
|
||||
model_version?: string // Provider 返回的实际模型版本(如 Gemini 的 modelVersion)
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// 日期范围参数
|
||||
export interface DateRangeParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
// 时间段选项
|
||||
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
|
||||
|
||||
// 筛选状态(包含新的请求状态值)
|
||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'error' | 'active' | 'pending' | 'streaming' | 'completed' | 'failed'
|
||||
|
||||
// 默认统计状态
|
||||
export function createDefaultStats(): UsageStatsState {
|
||||
return {
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_actual_cost: undefined,
|
||||
avg_response_time: 0,
|
||||
error_count: undefined,
|
||||
error_rate: undefined,
|
||||
cache_stats: undefined,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: null
|
||||
}
|
||||
}
|
||||
483
frontend/src/features/users/components/UserFormDialog.vue
Normal file
483
frontend/src/features/users/components/UserFormDialog.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<UserPlus v-if="!isEditMode" class="h-5 w-5 text-primary" />
|
||||
<SquarePen v-else class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ isEditMode ? '编辑用户' : '新增用户' }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '修改用户账户信息' : '创建新的系统用户账户' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit" autocomplete="off">
|
||||
<div class="grid grid-cols-2 gap-0">
|
||||
<!-- 左侧:基础设置 -->
|
||||
<div class="pr-6 space-y-4">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<span class="text-sm font-medium">基础设置</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-username" class="text-sm font-medium">用户名 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="form-username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
required
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">
|
||||
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }} <span v-if="!isEditMode" class="text-muted-foreground">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
:id="`pwd-${formNonce}`"
|
||||
v-model="form.password"
|
||||
:type="passwordFocused ? 'password' : 'text'"
|
||||
@focus="passwordFocused = true"
|
||||
@blur="passwordFocused = form.password.length > 0"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
:name="`field-${formNonce}`"
|
||||
:required="!isEditMode"
|
||||
minlength="6"
|
||||
:placeholder="isEditMode ? '留空保持原密码' : '至少6个字符'"
|
||||
:class="!passwordFocused && form.password.length === 0 ? 'h-10 text-transparent' : 'h-10'"
|
||||
/>
|
||||
<p v-if="!isEditMode" class="text-xs text-muted-foreground">密码至少需要6个字符</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-email" class="text-sm font-medium">邮箱 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="form-email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
required
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-quota" class="text-sm font-medium">配额(美元)</Label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Input
|
||||
id="form-quota"
|
||||
v-model.number="form.quota"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="10000"
|
||||
placeholder="10"
|
||||
:class="form.unlimited ? 'flex-1 h-10 opacity-50' : 'flex-1 h-10'"
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
|
||||
<input
|
||||
id="form-unlimited"
|
||||
v-model="form.unlimited"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="form-unlimited" class="whitespace-nowrap cursor-pointer text-sm">无限制</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="form-role" class="text-sm font-medium">用户角色</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select v-model="form.role" v-model:open="roleSelectOpen" class="flex-1">
|
||||
<SelectTrigger id="form-role" class="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="!isEditMode" class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
|
||||
<input
|
||||
id="form-active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="form-active" class="whitespace-nowrap cursor-pointer text-sm">启用用户</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
<div class="pl-6 space-y-4 border-l border-border">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
|
||||
<span class="text-sm font-medium">访问限制</span>
|
||||
<span class="text-xs text-muted-foreground">(留空不限)</span>
|
||||
</div>
|
||||
|
||||
<!-- Provider 多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的 Provider</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="providerDropdownOpen = !providerDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="providerDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="providerDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="providerDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="providerDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_providers', provider.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_providers.includes(provider.id)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_providers', provider.id)"
|
||||
/>
|
||||
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
|
||||
</div>
|
||||
<div v-if="providers.length === 0" class="px-3 py-2 text-sm text-muted-foreground">
|
||||
暂无可用 Provider
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 格式多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的 API 格式</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="endpointDropdownOpen = !endpointDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="endpointDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="endpointDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="endpointDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="endpointDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="format in apiFormats"
|
||||
:key="format.value"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_endpoints', format.value)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_endpoints.includes(format.value)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_endpoints', format.value)"
|
||||
/>
|
||||
<span class="text-sm">{{ format.label }}</span>
|
||||
</div>
|
||||
<div v-if="apiFormats.length === 0" class="px-3 py-2 text-sm text-muted-foreground">
|
||||
暂无可用 API 格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型多选下拉框 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
||||
>
|
||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="modelDropdownOpen ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div v-if="modelDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="modelDropdownOpen = false"></div>
|
||||
<div
|
||||
v-if="modelDropdownOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="model in globalModels"
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||
@click="toggleSelection('allowed_models', model.name)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.allowed_models.includes(model.name)"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggleSelection('allowed_models', model.name)"
|
||||
/>
|
||||
<span class="text-sm">{{ model.name }}</span>
|
||||
</div>
|
||||
<div v-if="globalModels.length === 0" class="px-3 py-2 text-sm text-muted-foreground">
|
||||
暂无可用模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="handleCancel" type="button" class="h-10 px-5">取消</Button>
|
||||
<Button @click="handleSubmit" class="h-10 px-5" :disabled="saving || !isFormValid">
|
||||
{{ saving ? '处理中...' : (isEditMode ? '更新' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
username: string
|
||||
email: string
|
||||
quota_usd?: number | null
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
user: UserFormData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [data: UserFormData & { password?: string }]
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const formNonce = ref(createFieldNonce())
|
||||
const passwordFocused = ref(false)
|
||||
const roleSelectOpen = ref(false)
|
||||
|
||||
// 下拉框状态
|
||||
const providerDropdownOpen = ref(false)
|
||||
const endpointDropdownOpen = ref(false)
|
||||
const modelDropdownOpen = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<any[]>([])
|
||||
const globalModels = ref<any[]>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_endpoints: [] as string[],
|
||||
allowed_models: [] as string[]
|
||||
})
|
||||
|
||||
function createFieldNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formNonce.value = createFieldNonce()
|
||||
passwordFocused.value = false
|
||||
form.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user',
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
allowed_providers: [],
|
||||
allowed_endpoints: [],
|
||||
allowed_models: []
|
||||
}
|
||||
}
|
||||
|
||||
function loadUserData() {
|
||||
if (!props.user) return
|
||||
formNonce.value = createFieldNonce()
|
||||
passwordFocused.value = false
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
password: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
role: props.user.role,
|
||||
unlimited: props.user.quota_usd == null,
|
||||
is_active: props.user.is_active ?? true,
|
||||
allowed_providers: props.user.allowed_providers || [],
|
||||
allowed_endpoints: props.user.allowed_endpoints || [],
|
||||
allowed_models: props.user.allowed_models || []
|
||||
}
|
||||
}
|
||||
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
entity: () => props.user,
|
||||
isLoading: saving,
|
||||
onClose: () => emit('close'),
|
||||
loadData: loadUserData,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const hasEmail = form.value.email.trim().length > 0
|
||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||
return hasUsername && hasEmail && hasPassword
|
||||
})
|
||||
|
||||
// 加载访问控制选项
|
||||
async function loadAccessControlOptions() {
|
||||
try {
|
||||
const [providersData, modelsData, formatsData] = await Promise.all([
|
||||
getProvidersSummary(),
|
||||
getGlobalModels({ limit: 1000, is_active: true }),
|
||||
adminApi.getApiFormats()
|
||||
])
|
||||
providers.value = providersData
|
||||
globalModels.value = modelsData.models || []
|
||||
apiFormats.value = formatsData.formats || []
|
||||
} catch (err) {
|
||||
console.error('加载访问限制选项失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
function toggleSelection(field: 'allowed_providers' | 'allowed_endpoints' | 'allowed_models', value: string) {
|
||||
const arr = form.value[field]
|
||||
const index = arr.indexOf(value)
|
||||
if (index === -1) {
|
||||
arr.push(value)
|
||||
} else {
|
||||
arr.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
// 验证邮箱必填
|
||||
if (!form.value.email || !form.value.email.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: UserFormData & { password?: string } = {
|
||||
username: form.value.username,
|
||||
email: form.value.email.trim(),
|
||||
quota_usd: form.value.unlimited ? null : form.value.quota,
|
||||
role: form.value.role,
|
||||
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
|
||||
allowed_endpoints: form.value.allowed_endpoints.length > 0 ? form.value.allowed_endpoints : null,
|
||||
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
data.id = props.user.id
|
||||
}
|
||||
|
||||
if (!isEditMode.value) {
|
||||
data.is_active = form.value.is_active
|
||||
}
|
||||
|
||||
if (form.value.password) {
|
||||
data.password = form.value.password
|
||||
} else if (!isEditMode.value) {
|
||||
// 创建模式必须有密码
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', data)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置保存状态(供父组件调用)
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
|
||||
// 监听打开状态,加载选项数据
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
loadAccessControlOptions()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setSaving
|
||||
})
|
||||
</script>
|
||||
2
frontend/src/features/users/components/index.ts
Normal file
2
frontend/src/features/users/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as UserFormDialog } from './UserFormDialog.vue'
|
||||
export type { UserFormData } from './UserFormDialog.vue'
|
||||
1
frontend/src/features/users/index.ts
Normal file
1
frontend/src/features/users/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
Reference in New Issue
Block a user