feat(ui): add model mapping cache monitoring panel in admin

This commit is contained in:
fawney19
2025-12-15 20:39:57 +08:00
parent f2cd96c34c
commit f849a54027

View File

@@ -18,10 +18,10 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue' import SelectItem from '@/components/ui/select-item.vue'
import SelectValue from '@/components/ui/select-value.vue' import SelectValue from '@/components/ui/select-value.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue' import ScatterChart from '@/components/charts/ScatterChart.vue'
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight } from 'lucide-vue-next' import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight, Database, ArrowRight } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache' import { cacheApi, modelMappingCacheApi, type CacheStats, type CacheConfig, type UserAffinity, type ModelMappingCacheStats } from '@/api/cache'
import type { TTLAnalysisUser } from '@/api/cache' import type { TTLAnalysisUser } from '@/api/cache'
import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format' import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format'
import { import {
@@ -47,6 +47,13 @@ const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000)) const currentTime = ref(Math.floor(Date.now() / 1000))
// ==================== 模型映射缓存 ====================
const modelMappingStats = ref<ModelMappingCacheStats | null>(null)
const modelMappingLoading = ref(false)
const clearingModelMapping = ref(false)
const clearingModelName = ref<string | null>(null)
const { success: showSuccess, error: showError, info: showInfo } = useToast() const { success: showSuccess, error: showError, info: showInfo } = useToast()
const { confirm: showConfirm } = useConfirm() const { confirm: showConfirm } = useConfirm()
@@ -241,13 +248,87 @@ function stopCountdown() {
} }
} }
// ==================== 模型映射缓存方法 ====================
async function fetchModelMappingStats() {
modelMappingLoading.value = true
try {
modelMappingStats.value = await modelMappingCacheApi.getStats()
} catch (error) {
showError('获取模型映射缓存统计失败')
log.error('获取模型映射缓存统计失败', error)
} finally {
modelMappingLoading.value = false
}
}
async function clearAllModelMappingCache() {
const confirmed = await showConfirm({
title: '确认清除',
message: '确定要清除所有模型映射缓存吗?这会影响所有模型的名称解析。',
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) return
clearingModelMapping.value = true
try {
const result = await modelMappingCacheApi.clearAll()
showSuccess(`已清除 ${result.deleted_count} 个缓存键`)
await fetchModelMappingStats()
} catch (error) {
showError('清除模型映射缓存失败')
log.error('清除模型映射缓存失败', error)
} finally {
clearingModelMapping.value = false
}
}
async function clearModelMappingByName(modelName: string) {
clearingModelName.value = modelName
try {
await modelMappingCacheApi.clearByName(modelName)
showSuccess(`已清除 ${modelName} 的映射缓存`)
await fetchModelMappingStats()
} catch (error) {
showError('清除缓存失败')
log.error('清除模型映射缓存失败', error)
} finally {
clearingModelName.value = null
}
}
function formatTTL(ttl: number | null): string {
if (ttl === null || ttl < 0) return '-'
if (ttl < 60) return `${ttl}s`
const minutes = Math.floor(ttl / 60)
const seconds = ttl % 60
if (seconds === 0) return `${minutes}m`
return `${minutes}m${seconds}s`
}
function getUnmappedStatusBadge(status: string): { variant: 'default' | 'secondary' | 'destructive' | 'outline', text: string } {
switch (status) {
case 'not_found':
return { variant: 'secondary', text: '未找到' }
case 'invalid':
return { variant: 'destructive', text: '无效' }
case 'error':
return { variant: 'destructive', text: '错误' }
default:
return { variant: 'outline', text: status }
}
}
// ==================== 刷新所有数据 ==================== // ==================== 刷新所有数据 ====================
async function refreshData() { async function refreshData() {
await Promise.all([ await Promise.all([
fetchCacheStats(), fetchCacheStats(),
fetchCacheConfig(), fetchCacheConfig(),
fetchAffinityList() fetchAffinityList(),
fetchModelMappingStats()
]) ])
} }
@@ -272,6 +353,7 @@ onMounted(() => {
fetchCacheStats() fetchCacheStats()
fetchCacheConfig() fetchCacheConfig()
fetchAffinityList() fetchAffinityList()
fetchModelMappingStats()
startCountdown() startCountdown()
refreshAnalysis() refreshAnalysis()
}) })
@@ -599,6 +681,222 @@ onBeforeUnmount(() => {
/> />
</Card> </Card>
<!-- 模型映射缓存管理 -->
<Card class="overflow-hidden">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="flex items-center gap-3 shrink-0">
<Database class="h-5 w-5 text-muted-foreground hidden sm:block" />
<h3 class="text-sm sm:text-base font-semibold">
模型映射缓存
</h3>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
title="清除全部映射缓存"
:disabled="clearingModelMapping || !modelMappingStats?.available"
@click="clearAllModelMappingCache"
>
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton
:loading="modelMappingLoading"
@click="fetchModelMappingStats"
/>
</div>
</div>
</div>
<!-- 映射缓存表格 -->
<Table
v-if="modelMappingStats?.available && modelMappingStats.mappings && modelMappingStats.mappings.length > 0"
class="hidden md:table"
>
<TableHeader>
<TableRow>
<TableHead class="w-[25%]">
全局模型
</TableHead>
<TableHead class="w-8 text-center" />
<TableHead class="w-[30%]">
映射模型
</TableHead>
<TableHead class="w-[25%]">
提供商
</TableHead>
<TableHead class="w-[10%] text-center">
剩余
</TableHead>
<TableHead class="w-[5%] text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="mapping in modelMappingStats.mappings"
:key="mapping.mapping_name"
>
<TableCell>
<div v-if="mapping.global_model_name">
<div class="text-sm font-medium">
{{ mapping.global_model_display_name || mapping.global_model_name }}
</div>
<div
v-if="mapping.global_model_display_name && mapping.global_model_display_name !== mapping.global_model_name"
class="text-xs text-muted-foreground font-mono"
>
{{ mapping.global_model_name }}
</div>
</div>
<span
v-else
class="text-sm text-muted-foreground"
>-</span>
</TableCell>
<TableCell class="text-center">
<ArrowRight class="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<span class="text-sm font-mono">{{ mapping.mapping_name }}</span>
</TableCell>
<TableCell>
<div
v-if="mapping.providers && mapping.providers.length > 0"
class="flex flex-wrap gap-1"
>
<Badge
v-for="provider in mapping.providers.slice(0, 3)"
:key="provider"
variant="outline"
class="text-xs"
>
{{ provider }}
</Badge>
<Badge
v-if="mapping.providers.length > 3"
variant="outline"
class="text-xs"
>
+{{ mapping.providers.length - 3 }}
</Badge>
</div>
<span
v-else
class="text-sm text-muted-foreground"
>-</span>
</TableCell>
<TableCell class="text-center">
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
</TableCell>
<TableCell class="text-right">
<Button
size="icon"
variant="ghost"
class="h-6 w-6 text-muted-foreground/50 hover:text-destructive"
:disabled="clearingModelName === mapping.mapping_name"
title="清除缓存"
@click="clearModelMappingByName(mapping.mapping_name)"
>
<X class="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="modelMappingStats?.available && modelMappingStats.mappings && modelMappingStats.mappings.length > 0"
class="md:hidden divide-y divide-border/40"
>
<div
v-for="mapping in modelMappingStats.mappings"
:key="`m-${mapping.mapping_name}`"
class="p-4 space-y-2"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate">{{ mapping.global_model_display_name || mapping.global_model_name || '-' }}</span>
<Button
size="icon"
variant="ghost"
class="h-6 w-6 text-muted-foreground/50 hover:text-destructive shrink-0"
:disabled="clearingModelName === mapping.mapping_name"
@click="clearModelMappingByName(mapping.mapping_name)"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<ArrowRight class="h-3.5 w-3.5 shrink-0" />
<span class="font-mono">{{ mapping.mapping_name }}</span>
</div>
<div
v-if="mapping.providers && mapping.providers.length > 0"
class="flex flex-wrap gap-1"
>
<Badge
v-for="provider in mapping.providers"
:key="provider"
variant="outline"
class="text-xs"
>
{{ provider }}
</Badge>
</div>
</div>
</div>
<!-- 未映射条目NOT_FOUND -->
<div
v-if="modelMappingStats?.available && modelMappingStats.unmapped && modelMappingStats.unmapped.length > 0"
class="px-6 py-4 border-t border-border/40"
>
<div class="text-xs text-muted-foreground mb-2">
未映射的缓存条目
</div>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="entry in modelMappingStats.unmapped"
:key="entry.mapping_name"
:variant="getUnmappedStatusBadge(entry.status).variant"
class="text-xs font-mono cursor-pointer"
:title="`${getUnmappedStatusBadge(entry.status).text} - 点击清除`"
@click="clearModelMappingByName(entry.mapping_name)"
>
{{ entry.mapping_name }}
</Badge>
</div>
</div>
<!-- 无缓存状态 -->
<div
v-else-if="modelMappingStats?.available && (!modelMappingStats.mappings || modelMappingStats.mappings.length === 0) && (!modelMappingStats.unmapped || modelMappingStats.unmapped.length === 0)"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
暂无模型解析缓存
</div>
<!-- Redis 未启用 -->
<div
v-else-if="modelMappingStats && !modelMappingStats.available"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
{{ modelMappingStats.message || 'Redis 未启用' }}
</div>
<!-- 加载中 -->
<div
v-else-if="modelMappingLoading"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
加载中...
</div>
</Card>
<!-- TTL 分析区域 --> <!-- TTL 分析区域 -->
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">