Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
<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 { Trash2, Eraser, Search, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
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 { success: showSuccess, error: showError, info: showInfo } = useToast()
const { confirm: showConfirm } = useConfirm()
const currentTime = ref(Math.floor(Date.now() / 1000))
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let skipNextKeywordWatch = false
let countdownTimer: ReturnType<typeof setInterval> | null = null
// 计算分页后的数据
const paginatedAffinityList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return affinityList.value.slice(start, end)
})
// 页码变化处理
function handlePageChange() {
// 分页变化时滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 获取缓存统计
async function fetchCacheStats() {
loading.value = true
try {
stats.value = await cacheApi.getStats()
} catch (error) {
showError('获取缓存统计失败')
console.error(error)
} finally {
loading.value = false
}
}
// 获取缓存配置
async function fetchCacheConfig() {
try {
config.value = await cacheApi.getConfig()
} catch (error) {
console.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('获取缓存列表失败')
console.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()
}
// 清除缓存(按 affinity_key 或用户标识符)
async function clearUserCache(identifier: string, displayName?: string) {
const target = identifier?.trim()
if (!target) {
showError('无法识别标识符')
return
}
const label = displayName || target
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${label} 的缓存吗?`,
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) {
return
}
clearingRowAffinityKey.value = target
try {
await cacheApi.clearUserCache(target)
showSuccess('清除成功')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
console.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('清除失败')
console.error(error)
}
}
// 计算剩余时间(使用实时更新的 currentTime
function getRemainingTime(expireAt?: number) {
if (!expireAt) return '未知'
const remaining = expireAt - currentTime.value
if (remaining <= 0) return '已过期'
const minutes = Math.floor(remaining / 60)
const seconds = Math.floor(remaining % 60)
return `${minutes}${seconds}`
}
// 启动倒计时定时器
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 => {
return 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
}
}
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()
startCountdown()
})
// 刷新所有数据
async function refreshData() {
await Promise.all([
fetchCacheStats(),
fetchCacheConfig(),
fetchAffinityList()
])
}
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 text-success mt-1">
{{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}%
</div>
<div class="text-xs text-muted-foreground mt-1">
{{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }}
</div>
</Card>
<!-- 活跃缓存数 -->
<Card class="p-4">
<div class="text-xs text-muted-foreground">活跃缓存</div>
<div class="text-2xl font-bold mt-1">
{{ stats?.affinity_stats?.total_affinities || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
TTL {{ config?.cache_ttl_seconds || 300 }}s
</div>
</Card>
<!-- Provider切换 -->
<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 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?.affinity_stats?.cache_invalidations || 0 }}
</div>
</Card>
</div>
<!-- 缓存亲和性列表 -->
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold">亲和性列表</h3>
</div>
<div class="flex 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-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="h-4 w-px bg-border" />
<Button @click="clearAllCache" variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground/70 hover:text-destructive" title="清除全部缓存">
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton :loading="loading || listLoading" @click="refreshData" />
</div>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-28">用户</TableHead>
<TableHead class="w-36">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-[90px]" :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-[100px]" :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"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
:disabled="clearingRowAffinityKey === item.affinity_key"
title="清除缓存"
>
<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>
<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>
</div>
</template>