Files
Aether/frontend/src/views/admin/CacheMonitoring.vue
fawney19 702f9bc5f1 fix: 修复缓存监控页面TTL分析时间段选择器点击无响应
为 Select 组件添加 v-model:open 绑定,解决 radix-vue Select 组件
在某些情况下点击无响应的问题。

Fixes #55
2025-12-28 19:14:49 +08:00

1282 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Table from '@/components/ui/table.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableCell from '@/components/ui/table-cell.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableRow from '@/components/ui/table-row.vue'
import Input from '@/components/ui/input.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import SelectValue from '@/components/ui/select-value.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight, Database, ArrowRight } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { cacheApi, modelMappingCacheApi, type CacheStats, type CacheConfig, type UserAffinity, type ModelMappingCacheStats } from '@/api/cache'
import type { TTLAnalysisUser } from '@/api/cache'
import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format'
import {
useTTLAnalysis,
ANALYSIS_HOURS_OPTIONS,
getTTLBadgeVariant,
getFrequencyLabel,
getFrequencyClass
} from '@/composables/useTTLAnalysis'
import { log } from '@/utils/logger'
// ==================== 缓存统计与亲和性列表 ====================
const stats = ref<CacheStats | null>(null)
const config = ref<CacheConfig | null>(null)
const loading = ref(false)
const affinityList = ref<UserAffinity[]>([])
const listLoading = ref(false)
const tableKeyword = ref('')
const matchedUserId = ref<string | null>(null)
const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000))
const analysisHoursSelectOpen = ref(false)
// ==================== 模型映射缓存 ====================
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 { confirm: showConfirm } = useConfirm()
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let skipNextKeywordWatch = false
let countdownTimer: ReturnType<typeof setInterval> | null = null
// ==================== TTL 分析 (使用 composable) ====================
const {
ttlAnalysis,
hitAnalysis,
ttlAnalysisLoading,
analysisHours,
expandedUserId,
userTimelineData,
userTimelineLoading,
userTimelineChartData,
toggleUserExpand,
refreshAnalysis
} = useTTLAnalysis()
// ==================== 计算属性 ====================
const paginatedAffinityList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return affinityList.value.slice(start, end)
})
// ==================== 缓存统计方法 ====================
async function fetchCacheStats() {
loading.value = true
try {
stats.value = await cacheApi.getStats()
} catch (error) {
showError('获取缓存统计失败')
log.error('获取缓存统计失败', error)
} finally {
loading.value = false
}
}
async function fetchCacheConfig() {
try {
config.value = await cacheApi.getConfig()
} catch (error) {
log.error('获取缓存配置失败', error)
}
}
async function fetchAffinityList(keyword?: string) {
listLoading.value = true
try {
const response = await cacheApi.listAffinities(keyword)
affinityList.value = response.items
matchedUserId.value = response.matched_user_id ?? null
if (keyword && response.total === 0) {
showInfo('未找到匹配的缓存记录')
}
} catch (error) {
showError('获取缓存列表失败')
log.error('获取缓存列表失败', error)
} finally {
listLoading.value = false
}
}
async function resetAffinitySearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!tableKeyword.value) {
currentPage.value = 1
await fetchAffinityList()
return
}
skipNextKeywordWatch = true
tableKeyword.value = ''
currentPage.value = 1
await fetchAffinityList()
}
async function clearSingleAffinity(item: UserAffinity) {
const affinityKey = item.affinity_key?.trim()
const endpointId = item.endpoint_id?.trim()
const modelId = item.global_model_id?.trim()
const apiFormat = item.api_format?.trim()
if (!affinityKey || !endpointId || !modelId || !apiFormat) {
showError('缓存记录信息不完整,无法删除')
return
}
const label = item.user_api_key_name || affinityKey
const modelLabel = item.model_display_name || item.model_name || modelId
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) return
clearingRowAffinityKey.value = affinityKey
try {
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
showSuccess('清除成功')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
log.error('清除单条缓存失败', error)
} finally {
clearingRowAffinityKey.value = null
}
}
async function clearAllCache() {
const firstConfirm = await showConfirm({
title: '危险操作',
message: '警告:此操作会清除所有用户的缓存亲和性,确定继续吗?',
confirmText: '继续',
variant: 'destructive'
})
if (!firstConfirm) return
const secondConfirm = await showConfirm({
title: '再次确认',
message: '这将影响所有用户,请再次确认!',
confirmText: '确认清除',
variant: 'destructive'
})
if (!secondConfirm) return
try {
await cacheApi.clearAllCache()
showSuccess('已清除所有缓存')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
log.error('清除所有缓存失败', error)
}
}
// ==================== 工具方法 ====================
function getRemainingTime(expireAt?: number): string {
return formatRemainingTime(expireAt, currentTime.value)
}
function formatIntervalDescription(user: TTLAnalysisUser): string {
const p90 = user.percentiles.p90
if (p90 === null || p90 === undefined) return '-'
if (p90 < 1) {
const seconds = Math.round(p90 * 60)
return `90% 请求间隔 < ${seconds}`
}
return `90% 请求间隔 < ${p90.toFixed(1)} 分钟`
}
function handlePageChange() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// ==================== 定时器管理 ====================
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
currentTime.value = Math.floor(Date.now() / 1000)
const beforeCount = affinityList.value.length
affinityList.value = affinityList.value.filter(
item => item.expire_at && item.expire_at > currentTime.value
)
if (beforeCount > affinityList.value.length) {
const removedCount = beforeCount - affinityList.value.length
showInfo(`${removedCount} 个缓存已自动过期移除`)
}
}, 1000)
}
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
// ==================== 模型映射缓存方法 ====================
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
}
}
async function clearProviderModelMapping(providerId: string, globalModelId: string, displayName?: string) {
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${displayName || 'Provider 模型映射'} 的缓存吗?`,
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) return
try {
await modelMappingCacheApi.clearProviderModel(providerId, globalModelId)
showSuccess('已清除 Provider 模型映射缓存')
await fetchModelMappingStats()
} catch (error) {
showError('清除缓存失败')
log.error('清除 Provider 模型映射缓存失败', error)
}
}
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() {
await Promise.all([
fetchCacheStats(),
fetchCacheConfig(),
fetchAffinityList(),
fetchModelMappingStats()
])
}
// ==================== 生命周期 ====================
watch(tableKeyword, (value) => {
if (skipNextKeywordWatch) {
skipNextKeywordWatch = false
return
}
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
const keyword = value.trim()
searchDebounceTimer = setTimeout(() => {
fetchAffinityList(keyword || undefined)
searchDebounceTimer = null
}, 600)
})
onMounted(() => {
fetchCacheStats()
fetchCacheConfig()
fetchAffinityList()
fetchModelMappingStats()
startCountdown()
refreshAnalysis()
})
onBeforeUnmount(() => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
stopCountdown()
})
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<div>
<h2 class="text-2xl font-bold">
缓存监控
</h2>
<p class="text-sm text-muted-foreground mt-1">
管理缓存亲和性提高 Prompt Caching 命中率
</p>
</div>
<!-- 亲和性系统状态 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card class="p-4">
<div class="text-xs text-muted-foreground">
活跃亲和性
</div>
<div class="text-2xl font-bold mt-1">
{{ stats?.affinity_stats?.active_affinities || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
TTL {{ config?.cache_ttl_seconds || 300 }}s
</div>
</Card>
<Card class="p-4">
<div class="text-xs text-muted-foreground">
Provider 切换
</div>
<div
class="text-2xl font-bold mt-1"
:class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''"
>
{{ stats?.affinity_stats?.provider_switches || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
Key 切换 {{ stats?.affinity_stats?.key_switches || 0 }}
</div>
</Card>
<Card class="p-4">
<div class="text-xs text-muted-foreground">
缓存失效
</div>
<div
class="text-2xl font-bold mt-1"
:class="(stats?.affinity_stats?.cache_invalidations || 0) > 0 ? 'text-warning' : ''"
>
{{ stats?.affinity_stats?.cache_invalidations || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
Provider 不可用
</div>
</Card>
<Card class="p-4">
<div class="text-xs text-muted-foreground flex items-center gap-1">
预留比例
<Badge
v-if="config?.dynamic_reservation?.enabled"
variant="outline"
class="text-[10px] px-1"
>
动态
</Badge>
</div>
<div class="text-2xl font-bold mt-1">
<template v-if="config?.dynamic_reservation?.enabled">
{{ (config.dynamic_reservation.config.stable_min_reservation * 100).toFixed(0) }}-{{ (config.dynamic_reservation.config.stable_max_reservation * 100).toFixed(0) }}%
</template>
<template v-else>
{{ config ? (config.cache_reservation_ratio * 100).toFixed(0) : '30' }}%
</template>
</div>
<div class="text-xs text-muted-foreground mt-1">
当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
</div>
</Card>
</div>
<!-- 缓存亲和性列表 -->
<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">
<h3 class="text-sm sm:text-base font-semibold shrink-0">
亲和性列表
</h3>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="cache-affinity-search"
v-model="tableKeyword"
placeholder="搜索用户或 Key"
class="w-32 sm:w-48 h-8 text-sm pl-8 pr-8"
/>
<button
v-if="tableKeyword"
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10"
@click="resetAffinitySearch"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="hidden sm:block h-4 w-px bg-border" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
title="清除全部缓存"
@click="clearAllCache"
>
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton
:loading="loading || listLoading"
@click="refreshData"
/>
</div>
</div>
</div>
<Table class="hidden xl:table">
<TableHeader>
<TableRow>
<TableHead class="w-36">
用户
</TableHead>
<TableHead class="w-28">
Key
</TableHead>
<TableHead class="w-28">
Provider
</TableHead>
<TableHead class="w-40">
模型
</TableHead>
<TableHead class="w-36">
API 格式 / Key
</TableHead>
<TableHead class="w-20 text-center">
剩余
</TableHead>
<TableHead class="w-14 text-center">
次数
</TableHead>
<TableHead class="w-12 text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="!listLoading && affinityList.length">
<TableRow
v-for="item in paginatedAffinityList"
:key="`${item.affinity_key}-${item.endpoint_id}-${item.key_id}`"
>
<TableCell>
<div class="flex items-center gap-1.5">
<Badge
v-if="item.is_standalone"
variant="outline"
class="text-warning border-warning/30 text-[10px] px-1"
>
独立
</Badge>
<span
class="text-sm font-medium truncate max-w-[120px]"
:title="item.username ?? undefined"
>{{ item.username || '未知' }}</span>
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-1.5">
<span
class="text-sm truncate max-w-[80px]"
:title="item.user_api_key_name || undefined"
>{{ item.user_api_key_name || '未命名' }}</span>
<Badge
v-if="item.rate_multiplier !== 1.0"
variant="outline"
class="text-warning border-warning/30 text-[10px] px-2"
>
{{ item.rate_multiplier }}x
</Badge>
</div>
<div class="text-xs text-muted-foreground font-mono">
{{ item.user_api_key_prefix || '---' }}
</div>
</TableCell>
<TableCell>
<div
class="text-sm truncate max-w-[100px]"
:title="item.provider_name || undefined"
>
{{ item.provider_name || '未知' }}
</div>
</TableCell>
<TableCell>
<div
class="text-sm truncate max-w-[150px]"
:title="item.model_display_name || undefined"
>
{{ item.model_display_name || '---' }}
</div>
<div
class="text-xs text-muted-foreground"
:title="item.model_name || undefined"
>
{{ item.model_name || '---' }}
</div>
</TableCell>
<TableCell>
<div class="text-sm">
{{ item.endpoint_api_format || '---' }}
</div>
<div class="text-xs text-muted-foreground font-mono">
{{ item.key_prefix || '---' }}
</div>
</TableCell>
<TableCell class="text-center">
<span class="text-xs">{{ getRemainingTime(item.expire_at) }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-sm">{{ item.request_count }}</span>
</TableCell>
<TableCell class="text-right">
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
:disabled="clearingRowAffinityKey === item.affinity_key"
title="清除缓存"
@click="clearSingleAffinity(item)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell
colspan="8"
class="text-center py-6 text-sm text-muted-foreground"
>
{{ listLoading ? '加载中...' : '暂无缓存记录' }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="!listLoading && affinityList.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="item in paginatedAffinityList"
:key="`m-${item.affinity_key}-${item.endpoint_id}-${item.key_id}`"
class="p-4 space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<Badge
v-if="item.is_standalone"
variant="outline"
class="text-warning border-warning/30 text-[10px] px-1"
>
独立
</Badge>
<span class="text-sm font-medium truncate">{{ item.username || '未知' }}</span>
</div>
<div class="text-xs text-muted-foreground mt-0.5">
{{ item.user_api_key_name || '未命名' }} · {{ item.user_api_key_prefix || '---' }}
</div>
</div>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
:disabled="clearingRowAffinityKey === item.affinity_key"
@click="clearSingleAffinity(item)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{{ item.provider_name || '未知' }}</span>
<span>·</span>
<span class="truncate max-w-[100px]">{{ item.model_display_name || '---' }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{{ item.endpoint_api_format || '---' }}</span>
<span>{{ getRemainingTime(item.expire_at) }} · {{ item.request_count }}</span>
</div>
</div>
</div>
<div
v-else-if="!listLoading && affinityList.length === 0"
class="xl:hidden text-center py-6 text-sm text-muted-foreground"
>
暂无缓存记录
</div>
<Pagination
v-if="affinityList.length > 0"
:current="currentPage"
:total="affinityList.length"
:page-size="pageSize"
@update:current="currentPage = $event; handlePageChange()"
@update:page-size="pageSize = $event"
/>
</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>
<!-- Provider 模型映射缓存 -->
<div
v-if="modelMappingStats?.available && modelMappingStats.provider_model_mappings && modelMappingStats.provider_model_mappings.length > 0"
class="border-t border-border/40"
>
<div class="px-6 py-3 text-xs text-muted-foreground border-b border-border/30 bg-muted/20">
Provider 模型映射缓存
</div>
<!-- 桌面端表格 -->
<Table class="hidden md:table">
<TableHeader>
<TableRow>
<TableHead class="w-[15%]">
提供商
</TableHead>
<TableHead class="w-[25%]">
请求名称
</TableHead>
<TableHead class="w-8 text-center" />
<TableHead class="w-[25%]">
映射模型
</TableHead>
<TableHead class="w-[10%] text-center">
剩余
</TableHead>
<TableHead class="w-[10%] text-center">
次数
</TableHead>
<TableHead class="w-[7%] text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template
v-for="(mapping, index) in modelMappingStats.provider_model_mappings"
:key="index"
>
<TableRow
v-for="(alias, aliasIndex) in (mapping.aliases || [])"
:key="`${index}-${aliasIndex}`"
>
<TableCell>
<Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }}
</Badge>
</TableCell>
<TableCell>
<span class="text-sm font-mono">{{ alias }}</span>
</TableCell>
<TableCell class="text-center">
<ArrowRight class="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<span class="text-sm font-mono font-medium">{{ mapping.provider_model_name }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-sm">{{ mapping.hit_count || 0 }}</span>
</TableCell>
<TableCell class="text-right">
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
title="清除缓存"
@click="clearProviderModelMapping(mapping.provider_id, mapping.global_model_id, `${mapping.provider_name} - ${alias}`)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<!-- 移动端卡片 -->
<div class="md:hidden divide-y divide-border/40">
<template
v-for="(mapping, index) in modelMappingStats.provider_model_mappings"
:key="`m-pm-${index}`"
>
<div
v-for="(alias, aliasIndex) in (mapping.aliases || [])"
:key="`m-pm-${index}-${aliasIndex}`"
class="p-4 space-y-2"
>
<div class="flex items-center justify-between">
<Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }}
</Badge>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ formatTTL(mapping.ttl) }}</span>
<span class="text-xs">{{ mapping.hit_count || 0 }}</span>
<Button
size="icon"
variant="ghost"
class="h-6 w-6 text-muted-foreground/70 hover:text-destructive"
title="清除缓存"
@click="clearProviderModelMapping(mapping.provider_id, mapping.global_model_id, `${mapping.provider_name} - ${alias}`)"
>
<Trash2 class="h-3 w-3" />
</Button>
</div>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="font-mono">{{ alias }}</span>
<ArrowRight class="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<span class="font-mono font-medium">{{ mapping.provider_model_name }}</span>
</div>
</div>
</template>
</div>
</div>
<!-- 无缓存状态 -->
<div
v-else-if="modelMappingStats?.available && (!modelMappingStats.mappings || modelMappingStats.mappings.length === 0) && (!modelMappingStats.unmapped || modelMappingStats.unmapped.length === 0) && (!modelMappingStats.provider_model_mappings || modelMappingStats.provider_model_mappings.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 分析区域 -->
<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">
<BarChart3 class="h-5 w-5 text-muted-foreground hidden sm:block" />
<h3 class="text-sm sm:text-base font-semibold">
TTL 分析
</h3>
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in ANALYSIS_HOURS_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<!-- 缓存命中概览 -->
<div
v-if="hitAnalysis"
class="px-6 py-4 border-b border-border/40 bg-muted/30"
>
<div class="grid grid-cols-2 md:grid-cols-5 gap-6">
<div>
<div class="text-xs text-muted-foreground">
请求命中率
</div>
<div class="text-2xl font-bold text-success">
{{ hitAnalysis.request_cache_hit_rate }}%
</div>
<div class="text-xs text-muted-foreground">
{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
Token 命中率
</div>
<div class="text-2xl font-bold">
{{ hitAnalysis.token_cache_hit_rate }}%
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
缓存创建费用
</div>
<div class="text-2xl font-bold">
{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
缓存读取费用
</div>
<div class="text-2xl font-bold">
{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
预估节省
</div>
<div class="text-2xl font-bold text-success">
{{ formatCost(hitAnalysis.estimated_savings_usd) }}
</div>
</div>
</div>
</div>
<!-- 用户 TTL 分析表格 -->
<Table v-if="ttlAnalysis && ttlAnalysis.users.length > 0">
<TableHeader>
<TableRow>
<TableHead class="w-10" />
<TableHead class="w-[20%]">
用户
</TableHead>
<TableHead class="w-[15%] text-center">
请求数
</TableHead>
<TableHead class="w-[15%] text-center">
使用频率
</TableHead>
<TableHead class="w-[15%] text-center">
推荐 TTL
</TableHead>
<TableHead>说明</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template
v-for="user in ttlAnalysis.users"
:key="user.group_id"
>
<TableRow
class="cursor-pointer hover:bg-muted/50"
@click="toggleUserExpand(user.group_id)"
>
<TableCell class="p-2">
<button class="p-1 hover:bg-muted rounded">
<ChevronDown
v-if="expandedUserId === user.group_id"
class="h-4 w-4 text-muted-foreground"
/>
<ChevronRight
v-else
class="h-4 w-4 text-muted-foreground"
/>
</button>
</TableCell>
<TableCell>
<span class="text-sm font-medium">{{ user.username || '未知用户' }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-sm font-medium">{{ user.request_count }}</span>
</TableCell>
<TableCell class="text-center">
<span
class="text-sm"
:class="getFrequencyClass(user.recommended_ttl_minutes)"
>
{{ getFrequencyLabel(user.recommended_ttl_minutes) }}
</span>
</TableCell>
<TableCell class="text-center">
<Badge :variant="getTTLBadgeVariant(user.recommended_ttl_minutes)">
{{ user.recommended_ttl_minutes }} 分钟
</Badge>
</TableCell>
<TableCell>
<span class="text-xs text-muted-foreground">
{{ formatIntervalDescription(user) }}
</span>
</TableCell>
</TableRow>
<!-- 展开行显示用户散点图 -->
<TableRow
v-if="expandedUserId === user.group_id"
class="bg-muted/30"
>
<TableCell
colspan="6"
class="p-0"
>
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium">
请求间隔时间线
</h4>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500" /> 0-5分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500" /> 5-15分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-purple-500" /> 15-30分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-orange-500" /> 30-60分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-500" /> >60分钟</span>
<span
v-if="userTimelineData"
class="ml-2"
> {{ userTimelineData.total_points }} 个数据点</span>
</div>
</div>
<div
v-if="userTimelineLoading"
class="h-64 flex items-center justify-center"
>
<span class="text-sm text-muted-foreground">加载中...</span>
</div>
<div
v-else-if="userTimelineData && userTimelineData.points.length > 0"
class="h-64"
>
<ScatterChart :data="userTimelineChartData" />
</div>
<div
v-else
class="h-64 flex items-center justify-center"
>
<span class="text-sm text-muted-foreground">暂无数据</span>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<!-- 分析完成但无数据 -->
<div
v-else-if="ttlAnalysis && ttlAnalysis.users.length === 0"
class="px-6 py-12 text-center"
>
<BarChart3 class="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p class="text-sm text-muted-foreground">
未找到符合条件的用户数据
</p>
<p class="text-xs text-muted-foreground mt-1">
尝试增加分析天数或降低最小请求数阈值
</p>
</div>
<!-- 加载中 -->
<div
v-else-if="ttlAnalysisLoading"
class="px-6 py-12 text-center"
>
<p class="text-sm text-muted-foreground">
正在分析用户请求数据...
</p>
</div>
</Card>
</div>
</template>