diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts
index e0bf159..c8d3b59 100644
--- a/frontend/src/api/cache.ts
+++ b/frontend/src/api/cache.ts
@@ -156,3 +156,116 @@ export const {
clearProviderCache,
listAffinities
} = cacheApi
+
+// ==================== 缓存亲和性分析 API ====================
+
+export interface TTLAnalysisUser {
+ group_id: string
+ username: string | null
+ email: string | null
+ request_count: number
+ interval_distribution: {
+ within_5min: number
+ within_15min: number
+ within_30min: number
+ within_60min: number
+ over_60min: number
+ }
+ interval_percentages: {
+ within_5min: number
+ within_15min: number
+ within_30min: number
+ within_60min: number
+ over_60min: number
+ }
+ percentiles: {
+ p50: number | null
+ p75: number | null
+ p90: number | null
+ }
+ avg_interval_minutes: number | null
+ min_interval_minutes: number | null
+ max_interval_minutes: number | null
+ recommended_ttl_minutes: number
+ recommendation_reason: string
+}
+
+export interface TTLAnalysisResponse {
+ analysis_period_hours: number
+ total_users_analyzed: number
+ ttl_distribution: {
+ '5min': number
+ '15min': number
+ '30min': number
+ '60min': number
+ }
+ users: TTLAnalysisUser[]
+}
+
+export interface CacheHitAnalysisResponse {
+ analysis_period_hours: number
+ total_requests: number
+ requests_with_cache_hit: number
+ request_cache_hit_rate: number
+ total_input_tokens: number
+ total_cache_read_tokens: number
+ total_cache_creation_tokens: number
+ token_cache_hit_rate: number
+ total_cache_read_cost_usd: number
+ total_cache_creation_cost_usd: number
+ estimated_savings_usd: number
+}
+
+export interface IntervalTimelinePoint {
+ x: string // ISO 时间字符串
+ y: number // 间隔分钟数
+ user_id?: string // 用户 ID(仅 include_user_info=true 时存在)
+}
+
+export interface IntervalTimelineResponse {
+ analysis_period_hours: number
+ total_points: number
+ points: IntervalTimelinePoint[]
+ users?: Record // user_id -> username 映射(仅 include_user_info=true 时存在)
+}
+
+export const cacheAnalysisApi = {
+ /**
+ * 分析缓存亲和性 TTL 推荐
+ */
+ async analyzeTTL(params?: {
+ user_id?: string
+ api_key_id?: string
+ hours?: number
+ }): Promise {
+ const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
+ return response.data
+ },
+
+ /**
+ * 分析缓存命中情况
+ */
+ async analyzeHit(params?: {
+ user_id?: string
+ api_key_id?: string
+ hours?: number
+ }): Promise {
+ const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
+ return response.data
+ },
+
+ /**
+ * 获取请求间隔时间线数据
+ *
+ * @param params.include_user_info 是否包含用户信息(用于管理员多用户视图)
+ */
+ async getIntervalTimeline(params?: {
+ hours?: number
+ limit?: number
+ user_id?: string
+ include_user_info?: boolean
+ }): Promise {
+ const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
+ return response.data
+ }
+}
diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue
new file mode 100644
index 0000000..24e43e4
--- /dev/null
+++ b/frontend/src/components/charts/ScatterChart.vue
@@ -0,0 +1,372 @@
+
+
+
+
+
Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟
+
+ {{ crosshairStats.belowCount }} / {{ crosshairStats.totalCount }} 点在横线以下
+ ({{ crosshairStats.belowPercent.toFixed(1) }}%)
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/CacheMonitoring.vue b/frontend/src/views/admin/CacheMonitoring.vue
index 3d9714e..bc0d9aa 100644
--- a/frontend/src/views/admin/CacheMonitoring.vue
+++ b/frontend/src/views/admin/CacheMonitoring.vue
@@ -12,10 +12,27 @@ 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 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 } 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'
+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'
+
+// ==================== 缓存统计与亲和性列表 ====================
const stats = ref(null)
const config = ref(null)
@@ -27,28 +44,40 @@ const matchedUserId = ref(null)
const clearingRowAffinityKey = ref(null)
const currentPage = ref(1)
const pageSize = ref(20)
+const currentTime = ref(Math.floor(Date.now() / 1000))
+
const { success: showSuccess, error: showError, info: showInfo } = useToast()
const { confirm: showConfirm } = useConfirm()
-const currentTime = ref(Math.floor(Date.now() / 1000))
let searchDebounceTimer: ReturnType | null = null
let skipNextKeywordWatch = false
let countdownTimer: ReturnType | 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)
})
-// 页码变化处理
-function handlePageChange() {
- // 分页变化时滚动到顶部
- window.scrollTo({ top: 0, behavior: 'smooth' })
-}
+// ==================== 缓存统计方法 ====================
-// 获取缓存统计
async function fetchCacheStats() {
loading.value = true
try {
@@ -61,7 +90,6 @@ async function fetchCacheStats() {
}
}
-// 获取缓存配置
async function fetchCacheConfig() {
try {
config.value = await cacheApi.getConfig()
@@ -70,7 +98,6 @@ async function fetchCacheConfig() {
}
}
-// 获取缓存亲和性列表
async function fetchAffinityList(keyword?: string) {
listLoading.value = true
try {
@@ -107,17 +134,14 @@ async function resetAffinitySearch() {
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} 的缓存吗?`,
@@ -125,12 +149,9 @@ async function clearUserCache(identifier: string, displayName?: string) {
variant: 'destructive'
})
- if (!confirmed) {
- return
- }
+ if (!confirmed) return
clearingRowAffinityKey.value = target
-
try {
await cacheApi.clearUserCache(target)
showSuccess('清除成功')
@@ -144,7 +165,6 @@ async function clearUserCache(identifier: string, displayName?: string) {
}
}
-// 清除所有缓存
async function clearAllCache() {
const firstConfirm = await showConfirm({
title: '危险操作',
@@ -152,10 +172,7 @@ async function clearAllCache() {
confirmText: '继续',
variant: 'destructive'
})
-
- if (!firstConfirm) {
- return
- }
+ if (!firstConfirm) return
const secondConfirm = await showConfirm({
title: '再次确认',
@@ -163,10 +180,7 @@ async function clearAllCache() {
confirmText: '确认清除',
variant: 'destructive'
})
-
- if (!secondConfirm) {
- return
- }
+ if (!secondConfirm) return
try {
await cacheApi.clearAllCache()
@@ -179,33 +193,39 @@ async function clearAllCache() {
}
}
-// 计算剩余时间(使用实时更新的 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 getRemainingTime(expireAt?: number): string {
+ return formatRemainingTime(expireAt, currentTime.value)
}
-// 启动倒计时定时器
-function startCountdown() {
- if (countdownTimer) {
- clearInterval(countdownTimer)
+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 => {
- return item.expire_at && item.expire_at > currentTime.value
- })
+ 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} 个缓存已自动过期移除`)
@@ -213,7 +233,6 @@ function startCountdown() {
}, 1000)
}
-// 停止倒计时定时器
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
@@ -221,15 +240,25 @@ function stopCountdown() {
}
}
+// ==================== 刷新所有数据 ====================
+
+async function refreshData() {
+ await Promise.all([
+ fetchCacheStats(),
+ fetchCacheConfig(),
+ fetchAffinityList()
+ ])
+}
+
+// ==================== 生命周期 ====================
+
watch(tableKeyword, (value) => {
if (skipNextKeywordWatch) {
skipNextKeywordWatch = false
return
}
- if (searchDebounceTimer) {
- clearTimeout(searchDebounceTimer)
- }
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
const keyword = value.trim()
searchDebounceTimer = setTimeout(() => {
@@ -243,21 +272,11 @@ onMounted(() => {
fetchCacheConfig()
fetchAffinityList()
startCountdown()
+ refreshAnalysis()
})
-// 刷新所有数据
-async function refreshData() {
- await Promise.all([
- fetchCacheStats(),
- fetchCacheConfig(),
- fetchAffinityList()
- ])
-}
-
onBeforeUnmount(() => {
- if (searchDebounceTimer) {
- clearTimeout(searchDebounceTimer)
- }
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
stopCountdown()
})
@@ -272,31 +291,18 @@ onBeforeUnmount(() => {
-
+
-
- 命中率
-
- {{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}%
-
-
- {{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }}
-
-
-
-
-
- 活跃缓存
+ 活跃亲和性
- {{ stats?.affinity_stats?.total_affinities || 0 }}
+ {{ stats?.affinity_stats?.active_affinities || 0 }}
TTL {{ config?.cache_ttl_seconds || 300 }}s
-
Provider 切换
@@ -307,7 +313,16 @@ onBeforeUnmount(() => {
-
+
+ 缓存失效
+
+ {{ stats?.affinity_stats?.cache_invalidations || 0 }}
+
+
+ 因 Provider 不可用
+
+
+
预留比例
@@ -322,14 +337,13 @@ onBeforeUnmount(() => {
- 失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }}
+ 当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
-
@@ -365,8 +379,8 @@ onBeforeUnmount(() => {
- 用户
- Key
+ 用户
+ Key
Provider
模型
API 格式 / Key
@@ -380,12 +394,12 @@ onBeforeUnmount(() => {
独立
- {{ item.username || '未知' }}
+ {{ item.username || '未知' }}
- {{ item.user_api_key_name || '未命名' }}
+ {{ item.user_api_key_name || '未命名' }}
{{ item.rate_multiplier }}x
{{ item.user_api_key_prefix || '---' }}
@@ -439,5 +453,157 @@ onBeforeUnmount(() => {
@update:page-size="pageSize = $event"
/>
+
+
+
+
+
+
+
+
TTL 分析
+ 分析用户请求间隔,推荐合适的缓存 TTL
+
+
+
+
+
+
+
+
+
+
+
+
请求命中率
+
{{ hitAnalysis.request_cache_hit_rate }}%
+
{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求
+
+
+
Token 命中率
+
{{ hitAnalysis.token_cache_hit_rate }}%
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中
+
+
+
缓存创建费用
+
{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens
+
+
+
缓存读取费用
+
{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens
+
+
+
预估节省
+
{{ formatCost(hitAnalysis.estimated_savings_usd) }}
+
+
+
+
+
+
+
+
+
+ 用户
+ 请求数
+ 使用频率
+ 推荐 TTL
+ 说明
+
+
+
+
+
+
+
+
+
+ {{ user.username || '未知用户' }}
+
+
+ {{ user.request_count }}
+
+
+
+ {{ getFrequencyLabel(user.recommended_ttl_minutes) }}
+
+
+
+
+ {{ user.recommended_ttl_minutes }} 分钟
+
+
+
+
+ {{ formatIntervalDescription(user) }}
+
+
+
+
+
+
+
+
+
请求间隔时间线
+
+ 0-5分钟
+ 5-15分钟
+ 15-30分钟
+ 30-60分钟
+ >60分钟
+ 共 {{ userTimelineData.total_points }} 个数据点
+
+
+
+ 加载中...
+
+
+
+
+
+ 暂无数据
+
+
+
+
+
+
+
+
+
+
+
+
+ 未找到符合条件的用户数据
+
+
+ 尝试增加分析天数或降低最小请求数阈值
+
+
+
+
+
+