Files
Aether/frontend/src/views/admin/CacheMonitoring.vue

1282 lines
45 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<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'
2025-12-10 20:52:44 +08:00
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'
// ==================== 缓存统计与亲和性列表 ====================
2025-12-10 20:52:44 +08:00
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)
2025-12-10 20:52:44 +08:00
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()
// ==================== 计算属性 ====================
2025-12-10 20:52:44 +08:00
const paginatedAffinityList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return affinityList.value.slice(start, end)
})
// ==================== 缓存统计方法 ====================
2025-12-10 20:52:44 +08:00
async function fetchCacheStats() {
loading.value = true
try {
stats.value = await cacheApi.getStats()
} catch (error) {
showError('获取缓存统计失败')
log.error('获取缓存统计失败', error)
2025-12-10 20:52:44 +08:00
} finally {
loading.value = false
}
}
async function fetchCacheConfig() {
try {
config.value = await cacheApi.getConfig()
} catch (error) {
log.error('获取缓存配置失败', error)
2025-12-10 20:52:44 +08:00
}
}
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)
2025-12-10 20:52:44 +08:00
} 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('缓存记录信息不完整,无法删除')
2025-12-10 20:52:44 +08:00
return
}
const label = item.user_api_key_name || affinityKey
const modelLabel = item.model_display_name || item.model_name || modelId
2025-12-10 20:52:44 +08:00
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
2025-12-10 20:52:44 +08:00
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) return
2025-12-10 20:52:44 +08:00
clearingRowAffinityKey.value = affinityKey
2025-12-10 20:52:44 +08:00
try {
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
2025-12-10 20:52:44 +08:00
showSuccess('清除成功')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
log.error('清除单条缓存失败', error)
2025-12-10 20:52:44 +08:00
} finally {
clearingRowAffinityKey.value = null
}
}
async function clearAllCache() {
const firstConfirm = await showConfirm({
title: '危险操作',
message: '警告:此操作会清除所有用户的缓存亲和性,确定继续吗?',
confirmText: '继续',
variant: 'destructive'
})
if (!firstConfirm) return
2025-12-10 20:52:44 +08:00
const secondConfirm = await showConfirm({
title: '再次确认',
message: '这将影响所有用户,请再次确认!',
confirmText: '确认清除',
variant: 'destructive'
})
if (!secondConfirm) return
2025-12-10 20:52:44 +08:00
try {
await cacheApi.clearAllCache()
showSuccess('已清除所有缓存')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
log.error('清除所有缓存失败', error)
2025-12-10 20:52:44 +08:00
}
}
// ==================== 工具方法 ====================
2025-12-10 20:52:44 +08:00
function getRemainingTime(expireAt?: number): string {
return formatRemainingTime(expireAt, currentTime.value)
2025-12-10 20:52:44 +08:00
}
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}`
2025-12-10 20:52:44 +08:00
}
return `90% 请求间隔 < ${p90.toFixed(1)} 分钟`
}
function handlePageChange() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// ==================== 定时器管理 ====================
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
2025-12-10 20:52:44 +08:00
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
)
2025-12-10 20:52:44 +08:00
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()
])
}
// ==================== 生命周期 ====================
2025-12-10 20:52:44 +08:00
watch(tableKeyword, (value) => {
if (skipNextKeywordWatch) {
skipNextKeywordWatch = false
return
}
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
2025-12-10 20:52:44 +08:00
const keyword = value.trim()
searchDebounceTimer = setTimeout(() => {
fetchAffinityList(keyword || undefined)
searchDebounceTimer = null
}, 600)
})
onMounted(() => {
fetchCacheStats()
fetchCacheConfig()
fetchAffinityList()
fetchModelMappingStats()
2025-12-10 20:52:44 +08:00
startCountdown()
refreshAnalysis()
2025-12-10 20:52:44 +08:00
})
onBeforeUnmount(() => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
2025-12-10 20:52:44 +08:00
stopCountdown()
})
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<div>
<h2 class="text-2xl font-bold">
缓存监控
</h2>
2025-12-10 20:52:44 +08:00
<p class="text-sm text-muted-foreground mt-1">
管理缓存亲和性提高 Prompt Caching 命中率
</p>
</div>
<!-- 亲和性系统状态 -->
2025-12-10 20:52:44 +08:00
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card class="p-4">
<div class="text-xs text-muted-foreground">
活跃亲和性
</div>
2025-12-10 20:52:44 +08:00
<div class="text-2xl font-bold mt-1">
{{ stats?.affinity_stats?.active_affinities || 0 }}
2025-12-10 20:52:44 +08:00
</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' : ''"
>
2025-12-10 20:52:44 +08:00
{{ 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>
2025-12-10 20:52:44 +08:00
<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>
2025-12-10 20:52:44 +08:00
</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) : '-' }}%
2025-12-10 20:52:44 +08:00
</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">
2025-12-10 20:52:44 +08:00
<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"
2025-12-10 20:52:44 +08:00
/>
<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"
>
2025-12-10 20:52:44 +08:00
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton
:loading="loading || listLoading"
@click="refreshData"
/>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
<Table class="hidden xl:table">
2025-12-10 20:52:44 +08:00
<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>
2025-12-10 20:52:44 +08:00
</TableRow>
</TableHeader>
<TableBody v-if="!listLoading && affinityList.length">
<TableRow
v-for="item in paginatedAffinityList"
:key="`${item.affinity_key}-${item.endpoint_id}-${item.key_id}`"
>
2025-12-10 20:52:44 +08:00
<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>
2025-12-10 20:52:44 +08:00
</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 || '---' }}
2025-12-10 20:52:44 +08:00
</div>
</TableCell>
<TableCell>
<div
class="text-sm truncate max-w-[100px]"
:title="item.provider_name || undefined"
>
{{ item.provider_name || '未知' }}
</div>
2025-12-10 20:52:44 +08:00
</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>
2025-12-10 20:52:44 +08:00
</TableCell>
<TableCell>
<div class="text-sm">
{{ item.endpoint_api_format || '---' }}
</div>
<div class="text-xs text-muted-foreground font-mono">
{{ item.key_prefix || '---' }}
</div>
2025-12-10 20:52:44 +08:00
</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)"
2025-12-10 20:52:44 +08:00
>
<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"
>
2025-12-10 20:52:44 +08:00
{{ 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>
2025-12-10 20:52:44 +08:00
<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>
2025-12-10 20:52:44 +08:00
</div>
</template>