feat: 缓存计费细分、能力匹配优化、用户模型调用计数

1. 缓存创建 tokens 区分 5min/1h TTL,支持按缓存时长差异化计费
   - Usage 表新增 cache_creation_input_tokens_5m/1h 字段
   - Claude handler 解析新格式 (ephemeral_5m/1h, claude_cache_creation_5/1h)
   - 计费规则支持 cache_ttl_pricing 覆盖 cache_creation 价格

2. 能力匹配机制优化
   - COMPATIBLE 能力不再硬过滤,改为排序阶段通过 capability_miss_count 优先级处理
   - cache_1h 改为 COMPATIBLE + REQUEST_PARAM(自动检测请求体中的 ttl=1h)
   - gemini_files 改为 EXCLUSIVE + REQUEST_PARAM(自动检测 fileData.fileUri)
   - 移除前端模型偏好/能力配置 UI(不再需要用户手动配置)

3. 新增用户-模型维度调用次数计数器 (UserModelUsageCount)
   - 原子递增,避免从 Usage 表聚合查询
   - 前端模型目录和用户可用模型列表展示调用次数

4. 其他改进
   - global_model_id 改为必填(NOT NULL),清理孤立模型
   - 模型映射对话框支持从上游获取模型列表并分组折叠
   - 端点测试不再依赖端点启用状态
   - 异步任务页面对普通用户隐藏用户信息列
   - Dashboard 响应式布局断点调整 (sm -> lg)
   - 号池管理仅展示已启用号池的提供商
This commit is contained in:
fawney19
2026-02-28 11:44:08 +08:00
parent 82bbed2720
commit ecb16d345a
59 changed files with 1053 additions and 608 deletions

View File

@@ -176,7 +176,7 @@
任务
</TableHead>
<TableHead class="w-[15%]">
用户/Provider
{{ isAdmin ? '用户/Provider' : 'Provider' }}
</TableHead>
<TableHead class="w-[12%]">
状态
@@ -220,7 +220,10 @@
<!-- 用户/Provider -->
<TableCell>
<div class="space-y-0.5 text-sm">
<div class="flex items-center gap-1.5">
<div
v-if="isAdmin"
class="flex items-center gap-1.5"
>
<User class="w-3 h-3 text-muted-foreground" />
<span class="truncate max-w-[100px]">{{ task.username }}</span>
</div>
@@ -367,7 +370,10 @@
<!-- 信息网格 -->
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex items-center gap-1.5 text-muted-foreground">
<div
v-if="isAdmin"
class="flex items-center gap-1.5 text-muted-foreground"
>
<User class="w-3 h-3" />
<span class="truncate">{{ task.username }}</span>
</div>
@@ -492,8 +498,10 @@
</span>
<span class="opacity-40">|</span>
<span>{{ formatDateFull(selectedTask.created_at) }}</span>
<span class="opacity-40">|</span>
<span>用户: {{ selectedTask.username }}</span>
<template v-if="isAdmin">
<span class="opacity-40">|</span>
<span>用户: {{ selectedTask.username }}</span>
</template>
<span class="opacity-40">|</span>
<span>Provider: {{ selectedTask.provider_name }}</span>
</div>
@@ -882,8 +890,11 @@ import {
ExternalLink,
Copy,
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { log } from '@/utils/logger'
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const { toast } = useToast()
const { copyToClipboard } = useClipboard()

View File

@@ -59,9 +59,6 @@
<TableHead class="w-[240px]">
模型名称
</TableHead>
<TableHead class="w-[140px]">
模型偏好
</TableHead>
<TableHead class="w-[160px] text-center">
价格 ($/M)
</TableHead>
@@ -82,7 +79,7 @@
<TableBody>
<TableRow v-if="loading">
<TableCell
colspan="7"
colspan="6"
class="text-center py-8"
>
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
@@ -90,7 +87,7 @@
</TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell
colspan="7"
colspan="6"
class="text-center py-8 text-muted-foreground"
>
没有找到匹配的模型
@@ -121,22 +118,6 @@
</div>
</div>
</TableCell>
<TableCell>
<div class="flex flex-wrap gap-0.5">
<template v-if="model.supported_capabilities?.length">
<span
v-for="capName in model.supported_capabilities"
:key="capName"
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
:title="getCapabilityDisplayName(capName)"
>{{ getCapabilityShortName(capName) }}</span>
</template>
<span
v-else
class="text-muted-foreground text-xs"
>-</span>
</div>
</TableCell>
<TableCell class="text-center">
<div class="text-xs space-y-0.5">
<!-- Token 计费 -->
@@ -297,19 +278,7 @@
</div>
</div>
<!-- 第二行模型偏好 -->
<div
v-if="model.supported_capabilities?.length"
class="flex flex-wrap gap-0.5"
>
<span
v-for="capName in model.supported_capabilities"
:key="capName"
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
>{{ getCapabilityShortName(capName) }}</span>
</div>
<!-- 第三行统计信息 -->
<!-- 第二行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
@@ -350,7 +319,6 @@
:model="selectedModel"
:open="!!selectedModel"
:has-blocking-dialog-open="hasBlockingDialogOpen"
:capabilities="capabilities"
@update:open="handleDrawerOpenChange"
@edit-model="editModel"
@toggle-model-status="toggleModelStatus"
@@ -693,8 +661,8 @@ import {
type GlobalModelResponse,
} from '@/api/global-models'
import { log } from '@/utils/logger'
import { formatUsageCount } from '@/utils/format'
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints/providers'
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
interface ModelProviderDisplay {
@@ -733,7 +701,6 @@ const editingModel = ref<GlobalModelResponse | null>(null)
// 数据
const globalModels = ref<GlobalModelResponse[]>([])
const providers = ref<ProviderWithEndpointsSummary[]>([])
const capabilities = ref<CapabilityDefinition[]>([])
// 模型目录分页
const catalogCurrentPage = ref(1)
@@ -790,16 +757,6 @@ const editingProviderModel = computed<Model | null>(() => {
// 使用全局确认对话框
const { confirmDanger } = useConfirm()
// 格式化调用次数(大数字简化显示)
function formatUsageCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1) }M`
} else if (count >= 1000) {
return `${(count / 1000).toFixed(1) }K`
}
return count.toString()
}
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
function getFirstTierPrice(model: GlobalModelResponse, type: 'input' | 'output'): number | null {
const tiered = model.default_tiered_pricing
@@ -1489,31 +1446,10 @@ async function loadProviders() {
}
}
async function loadCapabilities() {
try {
capabilities.value = await getAllCapabilities()
} catch (err) {
log.error('Failed to load capabilities:', err)
}
}
// 获取 capability 的显示名称
function getCapabilityDisplayName(capName: string): string {
const cap = capabilities.value.find(c => c.name === capName)
return cap?.display_name || capName
}
// 获取 capability 的短名称(用于表格展示)
function getCapabilityShortName(capName: string): string {
const cap = capabilities.value.find(c => c.name === capName)
return cap?.short_name || cap?.display_name || capName
}
onMounted(async () => {
await Promise.all([
refreshData(),
loadProviders(),
loadCapabilities(),
])
})
</script>

View File

@@ -652,7 +652,7 @@ async function loadOverview() {
overviewLoading.value = true
try {
const res = await getPoolOverview()
poolProviders.value = res.items
poolProviders.value = res.items.filter(item => item.pool_enabled)
// Auto-select first provider if none selected
if (!selectedProviderId.value && res.items.length > 0) {
await selectProvider(res.items[0].provider_id)

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<!-- 页面头部统计卡片 + 公告 -->
<div class="flex flex-col sm:flex-row gap-6 sm:items-start">
<div class="flex flex-col lg:flex-row gap-6 lg:items-start">
<!-- 左侧统计区域 -->
<div
ref="statsPanelRef"
@@ -215,12 +215,7 @@
Monthly
</Badge>
</div>
<div
class="grid gap-2 sm:gap-3"
:class="[
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]"
>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/30"
@@ -284,7 +279,7 @@
<!-- 右侧系统公告 -->
<div
id="announcements-section"
class="w-full sm:w-[260px] md:w-[300px] lg:w-[320px] flex-shrink-0 flex flex-col min-h-0"
class="w-full lg:w-[300px] xl:w-[320px] flex-shrink-0 flex flex-col min-h-0"
:style="announcementsContainerStyle"
>
<div class="mb-3 flex items-center justify-between flex-shrink-0">
@@ -299,7 +294,7 @@
</Badge>
</div>
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] lg:max-h-none">
<div
v-if="loadingAnnouncements"
class="flex-1 flex items-center justify-center"
@@ -856,7 +851,7 @@ const announcementsContainerStyle = computed(() => {
function checkScreenSize() {
if (typeof window !== 'undefined') {
isLargeScreen.value = window.innerWidth >= 640 // sm breakpoint
isLargeScreen.value = window.innerWidth >= 1024 // lg breakpoint
}
}

View File

@@ -40,12 +40,12 @@
<TableHead class="w-[140px] h-12 font-semibold">
模型名称
</TableHead>
<TableHead class="w-[120px] h-12 font-semibold">
模型偏好
</TableHead>
<TableHead class="w-[140px] h-12 font-semibold text-center">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] h-12 font-semibold text-center">
调用次数
</TableHead>
<TableHead class="w-[70px] h-12 font-semibold text-center">
状态
</TableHead>
@@ -93,38 +93,6 @@
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="flex gap-1.5 flex-wrap items-center">
<template v-if="getModelSupportedCapabilities(model).length > 0">
<button
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
:key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[
isCapabilityEnabled(model.name, cap.name)
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
]"
:title="cap.description"
@click.stop="toggleCapability(model.name, cap.name)"
>
<Check
v-if="isCapabilityEnabled(model.name, cap.name)"
class="w-3 h-3"
/>
<Plus
v-else
class="w-3 h-3"
/>
{{ cap.short_name || cap.display_name }}
</button>
</template>
<span
v-else
class="text-muted-foreground text-xs"
>-</span>
</div>
</TableCell>
<TableCell class="py-4 text-center">
<div class="text-xs space-y-0.5">
<!-- Token 计费 -->
@@ -154,6 +122,9 @@
</div>
</div>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<Badge :variant="model.is_active ? 'success' : 'secondary'">
{{ model.is_active ? '可用' : '停用' }}
@@ -194,41 +165,15 @@
</Badge>
</div>
<!-- 第二行价格 -->
<div
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="text-xs text-muted-foreground font-mono"
>
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</div>
<!-- 第四行模型偏好按钮 -->
<div
v-if="getModelSupportedCapabilities(model).length > 0"
class="flex gap-1.5 flex-wrap"
@click.stop
>
<button
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
:key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[
isCapabilityEnabled(model.name, cap.name)
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50'
]"
@click="toggleCapability(model.name, cap.name)"
<!-- 第二行价格 + 调用次数 -->
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="font-mono"
>
<Check
v-if="isCapabilityEnabled(model.name, cap.name)"
class="w-3 h-3"
/>
<Plus
v-else
class="w-3 h-3"
/>
{{ cap.short_name || cap.display_name }}
</button>
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span>
<span class="font-mono">{{ formatUsageCount(model.usage_count || 0) }} </span>
</div>
</div>
</div>
@@ -250,10 +195,6 @@
<UserModelDetailDrawer
v-model:open="drawerOpen"
:model="selectedModel"
:capabilities="allCapabilities"
:user-configurable-capabilities="userConfigurableCapabilities"
:model-capability-settings="modelCapabilitySettings"
@toggle-capability="toggleCapability"
/>
</div>
</template>
@@ -264,8 +205,6 @@ import {
Loader2,
Search,
Copy,
Check,
Plus,
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
@@ -283,12 +222,8 @@ import {
RefreshButton,
} from '@/components/ui'
import { type PublicGlobalModel } from '@/api/public-models'
import { formatUsageCount } from '@/utils/format'
import { meApi } from '@/api/me'
import {
getUserConfigurableCapabilities,
getAllCapabilities,
type CapabilityDefinition
} from '@/api/endpoints'
import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
import { useRowClick } from '@/composables/useRowClick'
import { log } from '@/utils/logger'
@@ -319,83 +254,6 @@ function openModelDetail(model: PublicGlobalModel, event: MouseEvent) {
const currentPage = ref(1)
const pageSize = ref(20)
// 能力筛选
const capabilityFilters = ref({
vision: false,
toolUse: false,
extendedThinking: false,
})
// 能力配置相关
const availableCapabilities = ref<CapabilityDefinition[]>([])
const allCapabilities = ref<CapabilityDefinition[]>([])
const userConfigurableCapabilities = computed(() =>
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
)
const modelCapabilitySettings = ref<Record<string, Record<string, boolean>>>({})
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "modelName:capName"
// 获取模型支持的可配置能力名称列表(从 supported_capabilities 字段读取)
function getModelSupportedCapabilities(model: PublicGlobalModel): string[] {
if (!model.supported_capabilities) return []
// 只返回用户可配置的能力
return model.supported_capabilities.filter(capName =>
userConfigurableCapabilities.value.some(cap => cap.name === capName)
)
}
// 获取模型支持的可配置能力详情列表
function getModelSupportedCapabilitiesDetails(model: PublicGlobalModel): CapabilityDefinition[] {
const supportedNames = getModelSupportedCapabilities(model)
return userConfigurableCapabilities.value.filter(cap => supportedNames.includes(cap.name))
}
// 检查某个能力是否已启用
function isCapabilityEnabled(modelName: string, capName: string): boolean {
return modelCapabilitySettings.value[modelName]?.[capName] || false
}
// 切换能力配置
async function toggleCapability(modelName: string, capName: string) {
const capKey = `${modelName}:${capName}`
if (savingCapability.value === capKey) return // 防止重复点击
savingCapability.value = capKey
try {
const currentEnabled = isCapabilityEnabled(modelName, capName)
const newEnabled = !currentEnabled
// 更新本地状态
const newSettings = { ...modelCapabilitySettings.value }
if (!newSettings[modelName]) {
newSettings[modelName] = {}
}
if (newEnabled) {
newSettings[modelName][capName] = true
} else {
delete newSettings[modelName][capName]
// 如果该模型没有任何能力配置了,删除整个模型条目
if (Object.keys(newSettings[modelName]).length === 0) {
delete newSettings[modelName]
}
}
// 调用 API 保存
await meApi.updateModelCapabilitySettings({
model_capability_settings: Object.keys(newSettings).length > 0 ? newSettings : null
})
// 更新本地状态
modelCapabilitySettings.value = newSettings
} catch (err) {
log.error('保存能力配置失败:', err)
showError('保存失败,请重试')
} finally {
savingCapability.value = null
}
}
// 筛选后的模型列表
const filteredModels = computed(() => {
let result = models.value
@@ -409,17 +267,6 @@ const filteredModels = computed(() => {
})
}
// 能力筛选
if (capabilityFilters.value.vision) {
result = result.filter(m => m.config?.vision === true)
}
if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.config?.function_calling === true)
}
if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.config?.extended_thinking === true)
}
return result
})
@@ -430,10 +277,10 @@ const paginatedModels = computed(() => {
return filteredModels.value.slice(start, end)
})
// 搜索或筛选变化时重置到第一页
watch([searchQuery, capabilityFilters], () => {
// 搜索变化时重置到第一页
watch(searchQuery, () => {
currentPage.value = 1
}, { deep: true })
})
async function loadModels() {
loading.value = true
@@ -449,30 +296,8 @@ async function loadModels() {
}
}
async function loadCapabilities() {
try {
const [userCaps, allCaps] = await Promise.all([
getUserConfigurableCapabilities(),
getAllCapabilities()
])
availableCapabilities.value = userCaps
allCapabilities.value = allCaps
} catch (err) {
log.error('Failed to load capabilities:', err)
}
}
async function loadModelCapabilitySettings() {
try {
const response = await meApi.getModelCapabilitySettings()
modelCapabilitySettings.value = response.model_capability_settings || {}
} catch (err) {
log.error('Failed to load model capability settings:', err)
}
}
async function refreshData() {
await Promise.all([loadModels(), loadCapabilities(), loadModelCapabilitySettings()])
await loadModels()
}
// 从 PublicGlobalModel 的 default_tiered_pricing 获取第一阶梯价格

View File

@@ -76,9 +76,6 @@
<TableHead class="min-w-[200px] h-12 font-semibold">
密钥名称
</TableHead>
<TableHead class="min-w-[80px] h-12 font-semibold">
能力
</TableHead>
<TableHead class="min-w-[160px] h-12 font-semibold">
密钥
</TableHead>
@@ -120,42 +117,6 @@
</div>
</TableCell>
<!-- 能力 -->
<TableCell class="py-4">
<div class="flex gap-1.5 flex-wrap items-center">
<template v-if="userConfigurableCapabilities.length > 0">
<button
v-for="cap in userConfigurableCapabilities"
:key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[
apiKey.is_locked ? 'opacity-50 cursor-not-allowed' : '',
isCapabilityEnabled(apiKey, cap.name)
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
]"
:title="apiKey.is_locked ? '已锁定' : getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
:disabled="apiKey.is_locked"
@click.stop="!apiKey.is_locked && toggleCapability(apiKey, cap.name)"
>
<Check
v-if="isCapabilityEnabled(apiKey, cap.name)"
class="w-3 h-3"
/>
<Plus
v-else
class="w-3 h-3"
/>
{{ cap.short_name || cap.display_name }}
</button>
</template>
<span
v-else
class="text-muted-foreground text-xs"
>-</span>
</div>
</TableCell>
<!-- 密钥显示 -->
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
@@ -478,7 +439,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { meApi, type ApiKey } from '@/api/me'
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -495,7 +455,7 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, Check } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
@@ -526,26 +486,10 @@ const newKeyName = ref('')
const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null)
// 能力配置相关
const availableCapabilities = ref<CapabilityDefinition[]>([])
const userConfigurableCapabilities = computed(() =>
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
)
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "keyId:capName"
onMounted(() => {
loadApiKeys()
loadCapabilities()
})
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (error) {
log.error('Failed to load capabilities:', error)
}
}
async function loadApiKeys() {
loading.value = true
try {
@@ -625,50 +569,6 @@ async function toggleApiKey(apiKey: ApiKey) {
}
}
// 检查某个能力是否已启用
function isCapabilityEnabled(apiKey: ApiKey, capName: string): boolean {
return apiKey.force_capabilities?.[capName] || false
}
// 切换能力配置
async function toggleCapability(apiKey: ApiKey, capName: string) {
const capKey = `${apiKey.id}:${capName}`
if (savingCapability.value === capKey) return // 防止重复点击
savingCapability.value = capKey
try {
const currentEnabled = isCapabilityEnabled(apiKey, capName)
const newEnabled = !currentEnabled
// 构建新的能力配置
const newCapabilities: Record<string, boolean> = { ...(apiKey.force_capabilities || {}) }
if (newEnabled) {
newCapabilities[capName] = true
} else {
delete newCapabilities[capName]
}
const capabilitiesData = Object.keys(newCapabilities).length > 0 ? newCapabilities : null
// 调用 API 保存
await meApi.updateApiKeyCapabilities(apiKey.id, {
force_capabilities: capabilitiesData
})
// 更新本地数据
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
apiKeys.value[index].force_capabilities = capabilitiesData
}
} catch (err) {
log.error('保存能力配置失败:', err)
showError('保存失败,请重试')
} finally {
savingCapability.value = null
}
}
async function copyApiKey(apiKey: ApiKey) {
try {
// 调用后端 API 获取完整密钥
@@ -745,12 +645,4 @@ function formatRelativeTime(dateString: string): string {
return formatDate(dateString)
}
// 获取能力按钮的提示文字
function getCapabilityTooltip(cap: CapabilityDefinition, isEnabled: boolean): string {
if (isEnabled) {
return `[已启用] 此密钥只能访问支持${cap.display_name}的模型`
}
return `${cap.description}`
}
</script>

View File

@@ -150,51 +150,6 @@
</div>
</div>
<!-- 模型偏好 -->
<div
v-if="getModelUserConfigurableCapabilities().length > 0"
class="space-y-3"
>
<h4 class="font-semibold text-sm">
模型偏好
</h4>
<div class="space-y-2">
<div
v-for="cap in getModelUserConfigurableCapabilities()"
:key="cap.name"
class="flex items-center justify-between p-3 rounded-lg border"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">
{{ cap.display_name }}
</p>
<p
v-if="cap.description"
class="text-xs text-muted-foreground truncate"
>
{{ cap.description }}
</p>
</div>
<button
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
:class="[
isCapabilityEnabled(cap.name) ? 'bg-primary' : 'bg-muted'
]"
role="switch"
:aria-checked="isCapabilityEnabled(cap.name)"
@click="handleToggleCapability(cap.name)"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out"
:class="[
isCapabilityEnabled(cap.name) ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
</div>
<!-- 定价信息 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">
@@ -365,13 +320,11 @@ import TableCell from '@/components/ui/table-cell.vue'
import type { PublicGlobalModel } from '@/api/public-models'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { CapabilityDefinition } from '@/api/endpoints'
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'toggleCapability': [modelName: string, capName: string]
}>()
const { copyToClipboard } = useClipboard()
@@ -379,29 +332,6 @@ const { copyToClipboard } = useClipboard()
interface Props {
model: PublicGlobalModel | null
open: boolean
capabilities?: CapabilityDefinition[]
userConfigurableCapabilities?: CapabilityDefinition[]
modelCapabilitySettings?: Record<string, Record<string, boolean>>
}
// 获取模型支持的用户可配置能力
function getModelUserConfigurableCapabilities(): CapabilityDefinition[] {
if (!props.model?.supported_capabilities || !props.userConfigurableCapabilities) return []
return props.userConfigurableCapabilities.filter(cap =>
props.model?.supported_capabilities?.includes(cap.name)
)
}
// 检查能力是否已启用
function isCapabilityEnabled(capName: string): boolean {
if (!props.model) return false
return props.modelCapabilitySettings?.[props.model.name]?.[capName] || false
}
// 切换能力
function handleToggleCapability(capName: string) {
if (!props.model) return
emit('toggleCapability', props.model.name, capName)
}
function handleClose() {