mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
feat: 实现缓存监控仪表板和散点图组件
This commit is contained in:
@@ -156,3 +156,116 @@ export const {
|
|||||||
clearProviderCache,
|
clearProviderCache,
|
||||||
listAffinities
|
listAffinities
|
||||||
} = cacheApi
|
} = 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<string, string> // user_id -> username 映射(仅 include_user_info=true 时存在)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheAnalysisApi = {
|
||||||
|
/**
|
||||||
|
* 分析缓存亲和性 TTL 推荐
|
||||||
|
*/
|
||||||
|
async analyzeTTL(params?: {
|
||||||
|
user_id?: string
|
||||||
|
api_key_id?: string
|
||||||
|
hours?: number
|
||||||
|
}): Promise<TTLAnalysisResponse> {
|
||||||
|
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<CacheHitAnalysisResponse> {
|
||||||
|
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<IntervalTimelineResponse> {
|
||||||
|
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
372
frontend/src/components/charts/ScatterChart.vue
Normal file
372
frontend/src/components/charts/ScatterChart.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
<div
|
||||||
|
v-if="crosshairStats"
|
||||||
|
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-yellow-400">Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="text-green-400">{{ crosshairStats.belowCount }}</span> / {{ crosshairStats.totalCount }} 点在横线以下
|
||||||
|
<span class="ml-2 text-blue-400">({{ crosshairStats.belowPercent.toFixed(1) }}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
ScatterController,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
type Plugin,
|
||||||
|
type Scale
|
||||||
|
} from 'chart.js'
|
||||||
|
import 'chartjs-adapter-date-fns'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
ScatterController,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: ChartData<'scatter'>
|
||||||
|
options?: ChartOptions<'scatter'>
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrosshairStats {
|
||||||
|
yValue: number
|
||||||
|
belowCount: number
|
||||||
|
totalCount: number
|
||||||
|
belowPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
height: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
|
let chart: ChartJS<'scatter'> | null = null
|
||||||
|
|
||||||
|
const crosshairY = ref<number | null>(null)
|
||||||
|
|
||||||
|
const crosshairStats = computed<CrosshairStats | null>(() => {
|
||||||
|
if (crosshairY.value === null || !props.data.datasets) return null
|
||||||
|
|
||||||
|
let totalCount = 0
|
||||||
|
let belowCount = 0
|
||||||
|
|
||||||
|
for (const dataset of props.data.datasets) {
|
||||||
|
if (!dataset.data) continue
|
||||||
|
for (const point of dataset.data) {
|
||||||
|
const p = point as { x: string; y: number }
|
||||||
|
if (typeof p.y === 'number') {
|
||||||
|
totalCount++
|
||||||
|
if (p.y <= crosshairY.value) {
|
||||||
|
belowCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
yValue: crosshairY.value,
|
||||||
|
belowCount,
|
||||||
|
totalCount,
|
||||||
|
belowPercent: (belowCount / totalCount) * 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const crosshairPlugin: Plugin<'scatter'> = {
|
||||||
|
id: 'crosshairLine',
|
||||||
|
afterDraw: (chartInstance) => {
|
||||||
|
if (crosshairY.value === null) return
|
||||||
|
|
||||||
|
const { ctx, chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
if (!yScale || !chartArea) return
|
||||||
|
|
||||||
|
const yPixel = yScale.getPixelForValue(crosshairY.value)
|
||||||
|
|
||||||
|
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(chartArea.left, yPixel)
|
||||||
|
ctx.lineTo(chartArea.right, yPixel)
|
||||||
|
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义非线性 Y 轴转换函数
|
||||||
|
// 0-10 分钟占据 70% 的空间,10-120 分钟占据 30% 的空间
|
||||||
|
const BREAKPOINT = 10 // 分界点:10 分钟
|
||||||
|
const LOWER_RATIO = 0.7 // 0-10 分钟占 70% 空间
|
||||||
|
|
||||||
|
// 将实际值转换为显示值(用于绘图)
|
||||||
|
function toDisplayValue(realValue: number): number {
|
||||||
|
if (realValue <= BREAKPOINT) {
|
||||||
|
// 0-10 分钟线性映射到 0-70
|
||||||
|
return realValue * (LOWER_RATIO * 100 / BREAKPOINT)
|
||||||
|
} else {
|
||||||
|
// 10-120 分钟映射到 70-100
|
||||||
|
const upperRange = 120 - BREAKPOINT
|
||||||
|
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||||
|
return LOWER_RATIO * 100 + ((realValue - BREAKPOINT) / upperRange) * displayUpperRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将显示值转换回实际值(用于读取鼠标位置)
|
||||||
|
function toRealValue(displayValue: number): number {
|
||||||
|
const breakpointDisplay = LOWER_RATIO * 100
|
||||||
|
if (displayValue <= breakpointDisplay) {
|
||||||
|
return displayValue / (LOWER_RATIO * 100 / BREAKPOINT)
|
||||||
|
} else {
|
||||||
|
const upperRange = 120 - BREAKPOINT
|
||||||
|
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||||
|
return BREAKPOINT + ((displayValue - breakpointDisplay) / displayUpperRange) * upperRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换数据点的 Y 值
|
||||||
|
function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
datasets: data.datasets.map(dataset => ({
|
||||||
|
...dataset,
|
||||||
|
data: (dataset.data as Array<{ x: string; y: number }>).map(point => ({
|
||||||
|
...point,
|
||||||
|
y: toDisplayValue(point.y),
|
||||||
|
_originalY: point.y // 保存原始值用于 tooltip
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: ChartOptions<'scatter'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
intersect: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'hour',
|
||||||
|
displayFormats: {
|
||||||
|
hour: 'MM-dd HH:mm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(107, 114, 128)',
|
||||||
|
maxRotation: 45
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '时间',
|
||||||
|
color: 'rgb(107, 114, 128)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
min: 0,
|
||||||
|
max: 100, // 显示值范围 0-100
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(107, 114, 128)',
|
||||||
|
// 自定义刻度值:在实际值 0, 2, 5, 10, 30, 60, 120 处显示
|
||||||
|
callback: function(this: Scale, tickValue: string | number) {
|
||||||
|
const displayVal = Number(tickValue)
|
||||||
|
const realVal = toRealValue(displayVal)
|
||||||
|
// 只在特定的显示位置显示刻度
|
||||||
|
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||||
|
for (const target of targetTicks) {
|
||||||
|
const targetDisplay = toDisplayValue(target)
|
||||||
|
if (Math.abs(displayVal - targetDisplay) < 1) {
|
||||||
|
return `${target}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
stepSize: 5, // 显示值的步长
|
||||||
|
autoSkip: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '间隔 (分钟)',
|
||||||
|
color: 'rgb(107, 114, 128)'
|
||||||
|
},
|
||||||
|
afterBuildTicks: function(scale: Scale) {
|
||||||
|
// 在特定实际值处设置刻度
|
||||||
|
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||||
|
scale.ticks = targetTicks.map(val => ({
|
||||||
|
value: toDisplayValue(val),
|
||||||
|
label: `${val}`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgb(31, 41, 55)',
|
||||||
|
titleColor: 'rgb(243, 244, 246)',
|
||||||
|
bodyColor: 'rgb(243, 244, 246)',
|
||||||
|
borderColor: 'rgb(75, 85, 99)',
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||||
|
const realY = point._originalY ?? toRealValue(point.y)
|
||||||
|
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHover: (event, _elements, chartInstance) => {
|
||||||
|
const canvas = chartInstance.canvas
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const mouseY = (event.native as MouseEvent)?.clientY
|
||||||
|
|
||||||
|
if (mouseY === undefined) {
|
||||||
|
crosshairY.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
|
||||||
|
if (!chartArea || !yScale) return
|
||||||
|
|
||||||
|
const relativeY = mouseY - rect.top
|
||||||
|
|
||||||
|
if (relativeY < chartArea.top || relativeY > chartArea.bottom) {
|
||||||
|
crosshairY.value = null
|
||||||
|
} else {
|
||||||
|
const displayValue = yScale.getValueForPixel(relativeY)
|
||||||
|
// 转换回实际值
|
||||||
|
crosshairY.value = displayValue !== undefined ? toRealValue(displayValue) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 crosshairPlugin 使用显示值
|
||||||
|
const crosshairPluginWithTransform: Plugin<'scatter'> = {
|
||||||
|
id: 'crosshairLine',
|
||||||
|
afterDraw: (chartInstance) => {
|
||||||
|
if (crosshairY.value === null) return
|
||||||
|
|
||||||
|
const { ctx, chartArea, scales } = chartInstance
|
||||||
|
const yScale = scales.y
|
||||||
|
if (!yScale || !chartArea) return
|
||||||
|
|
||||||
|
// 将实际值转换为显示值再获取像素位置
|
||||||
|
const displayValue = toDisplayValue(crosshairY.value)
|
||||||
|
const yPixel = yScale.getPixelForValue(displayValue)
|
||||||
|
|
||||||
|
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(chartArea.left, yPixel)
|
||||||
|
ctx.lineTo(chartArea.right, yPixel)
|
||||||
|
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([6, 4])
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
crosshairY.value = null
|
||||||
|
if (chart) {
|
||||||
|
chart.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
// 转换数据
|
||||||
|
const transformedData = transformData(props.data)
|
||||||
|
|
||||||
|
chart = new ChartJS(chartRef.value, {
|
||||||
|
type: 'scatter',
|
||||||
|
data: transformedData,
|
||||||
|
options: {
|
||||||
|
...defaultOptions,
|
||||||
|
...props.options
|
||||||
|
},
|
||||||
|
plugins: [crosshairPluginWithTransform]
|
||||||
|
})
|
||||||
|
|
||||||
|
chartRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart() {
|
||||||
|
if (chart) {
|
||||||
|
chart.data = transformData(props.data)
|
||||||
|
chart.update('none')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
createChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (chartRef.value) {
|
||||||
|
chartRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||||
|
}
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy()
|
||||||
|
chart = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
|
watch(() => props.options, () => {
|
||||||
|
if (chart) {
|
||||||
|
chart.options = {
|
||||||
|
...defaultOptions,
|
||||||
|
...props.options
|
||||||
|
}
|
||||||
|
chart.update()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
@@ -12,10 +12,27 @@ import TableRow from '@/components/ui/table-row.vue'
|
|||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Pagination from '@/components/ui/pagination.vue'
|
import Pagination from '@/components/ui/pagination.vue'
|
||||||
import RefreshButton from '@/components/ui/refresh-button.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 { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
|
import { cacheApi, 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<CacheStats | null>(null)
|
const stats = ref<CacheStats | null>(null)
|
||||||
const config = ref<CacheConfig | null>(null)
|
const config = ref<CacheConfig | null>(null)
|
||||||
@@ -27,28 +44,40 @@ const matchedUserId = ref<string | null>(null)
|
|||||||
const clearingRowAffinityKey = ref<string | null>(null)
|
const clearingRowAffinityKey = ref<string | null>(null)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||||
|
|
||||||
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
||||||
const { confirm: showConfirm } = useConfirm()
|
const { confirm: showConfirm } = useConfirm()
|
||||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
|
||||||
|
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let skipNextKeywordWatch = false
|
let skipNextKeywordWatch = false
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
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 paginatedAffinityList = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
const end = start + pageSize.value
|
const end = start + pageSize.value
|
||||||
return affinityList.value.slice(start, end)
|
return affinityList.value.slice(start, end)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 页码变化处理
|
// ==================== 缓存统计方法 ====================
|
||||||
function handlePageChange() {
|
|
||||||
// 分页变化时滚动到顶部
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取缓存统计
|
|
||||||
async function fetchCacheStats() {
|
async function fetchCacheStats() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +90,6 @@ async function fetchCacheStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取缓存配置
|
|
||||||
async function fetchCacheConfig() {
|
async function fetchCacheConfig() {
|
||||||
try {
|
try {
|
||||||
config.value = await cacheApi.getConfig()
|
config.value = await cacheApi.getConfig()
|
||||||
@@ -70,7 +98,6 @@ async function fetchCacheConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取缓存亲和性列表
|
|
||||||
async function fetchAffinityList(keyword?: string) {
|
async function fetchAffinityList(keyword?: string) {
|
||||||
listLoading.value = true
|
listLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -107,17 +134,14 @@ async function resetAffinitySearch() {
|
|||||||
await fetchAffinityList()
|
await fetchAffinityList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除缓存(按 affinity_key 或用户标识符)
|
|
||||||
async function clearUserCache(identifier: string, displayName?: string) {
|
async function clearUserCache(identifier: string, displayName?: string) {
|
||||||
const target = identifier?.trim()
|
const target = identifier?.trim()
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
showError('无法识别标识符')
|
showError('无法识别标识符')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = displayName || target
|
const label = displayName || target
|
||||||
|
|
||||||
const confirmed = await showConfirm({
|
const confirmed = await showConfirm({
|
||||||
title: '确认清除',
|
title: '确认清除',
|
||||||
message: `确定要清除 ${label} 的缓存吗?`,
|
message: `确定要清除 ${label} 的缓存吗?`,
|
||||||
@@ -125,12 +149,9 @@ async function clearUserCache(identifier: string, displayName?: string) {
|
|||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearingRowAffinityKey.value = target
|
clearingRowAffinityKey.value = target
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cacheApi.clearUserCache(target)
|
await cacheApi.clearUserCache(target)
|
||||||
showSuccess('清除成功')
|
showSuccess('清除成功')
|
||||||
@@ -144,7 +165,6 @@ async function clearUserCache(identifier: string, displayName?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除所有缓存
|
|
||||||
async function clearAllCache() {
|
async function clearAllCache() {
|
||||||
const firstConfirm = await showConfirm({
|
const firstConfirm = await showConfirm({
|
||||||
title: '危险操作',
|
title: '危险操作',
|
||||||
@@ -152,10 +172,7 @@ async function clearAllCache() {
|
|||||||
confirmText: '继续',
|
confirmText: '继续',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
if (!firstConfirm) return
|
||||||
if (!firstConfirm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondConfirm = await showConfirm({
|
const secondConfirm = await showConfirm({
|
||||||
title: '再次确认',
|
title: '再次确认',
|
||||||
@@ -163,10 +180,7 @@ async function clearAllCache() {
|
|||||||
confirmText: '确认清除',
|
confirmText: '确认清除',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
if (!secondConfirm) return
|
||||||
if (!secondConfirm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cacheApi.clearAllCache()
|
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)
|
function getRemainingTime(expireAt?: number): string {
|
||||||
const seconds = Math.floor(remaining % 60)
|
return formatRemainingTime(expireAt, currentTime.value)
|
||||||
return `${minutes}分${seconds}秒`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动倒计时定时器
|
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() {
|
function startCountdown() {
|
||||||
if (countdownTimer) {
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
clearInterval(countdownTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
currentTime.value = Math.floor(Date.now() / 1000)
|
currentTime.value = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
// 过滤掉已过期的项目
|
|
||||||
const beforeCount = affinityList.value.length
|
const beforeCount = affinityList.value.length
|
||||||
affinityList.value = affinityList.value.filter(item => {
|
affinityList.value = affinityList.value.filter(
|
||||||
return item.expire_at && item.expire_at > currentTime.value
|
item => item.expire_at && item.expire_at > currentTime.value
|
||||||
})
|
)
|
||||||
|
|
||||||
// 如果有项目被移除,显示提示
|
|
||||||
if (beforeCount > affinityList.value.length) {
|
if (beforeCount > affinityList.value.length) {
|
||||||
const removedCount = beforeCount - affinityList.value.length
|
const removedCount = beforeCount - affinityList.value.length
|
||||||
showInfo(`${removedCount} 个缓存已自动过期移除`)
|
showInfo(`${removedCount} 个缓存已自动过期移除`)
|
||||||
@@ -213,7 +233,6 @@ function startCountdown() {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止倒计时定时器
|
|
||||||
function stopCountdown() {
|
function stopCountdown() {
|
||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
@@ -221,15 +240,25 @@ function stopCountdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 刷新所有数据 ====================
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
await Promise.all([
|
||||||
|
fetchCacheStats(),
|
||||||
|
fetchCacheConfig(),
|
||||||
|
fetchAffinityList()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
watch(tableKeyword, (value) => {
|
watch(tableKeyword, (value) => {
|
||||||
if (skipNextKeywordWatch) {
|
if (skipNextKeywordWatch) {
|
||||||
skipNextKeywordWatch = false
|
skipNextKeywordWatch = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchDebounceTimer) {
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = value.trim()
|
const keyword = value.trim()
|
||||||
searchDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
@@ -243,21 +272,11 @@ onMounted(() => {
|
|||||||
fetchCacheConfig()
|
fetchCacheConfig()
|
||||||
fetchAffinityList()
|
fetchAffinityList()
|
||||||
startCountdown()
|
startCountdown()
|
||||||
|
refreshAnalysis()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 刷新所有数据
|
|
||||||
async function refreshData() {
|
|
||||||
await Promise.all([
|
|
||||||
fetchCacheStats(),
|
|
||||||
fetchCacheConfig(),
|
|
||||||
fetchAffinityList()
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (searchDebounceTimer) {
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
}
|
|
||||||
stopCountdown()
|
stopCountdown()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -272,31 +291,18 @@ onBeforeUnmount(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 核心指标 -->
|
<!-- 亲和性系统状态 -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<!-- 缓存命中率 -->
|
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground">命中率</div>
|
<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">
|
<div class="text-2xl font-bold mt-1">
|
||||||
{{ stats?.affinity_stats?.total_affinities || 0 }}
|
{{ stats?.affinity_stats?.active_affinities || 0 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
TTL {{ config?.cache_ttl_seconds || 300 }}s
|
TTL {{ config?.cache_ttl_seconds || 300 }}s
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Provider切换 -->
|
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground">Provider 切换</div>
|
<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' : ''">
|
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''">
|
||||||
@@ -307,7 +313,16 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<Card class="p-4">
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
预留比例
|
预留比例
|
||||||
@@ -322,14 +337,13 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }}
|
当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 缓存亲和性列表 -->
|
<!-- 缓存亲和性列表 -->
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden">
|
||||||
<!-- 标题和操作栏 -->
|
|
||||||
<div class="px-6 py-3 border-b border-border/60">
|
<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 justify-between gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -365,8 +379,8 @@ onBeforeUnmount(() => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-28">用户</TableHead>
|
<TableHead class="w-36">用户</TableHead>
|
||||||
<TableHead class="w-36">Key</TableHead>
|
<TableHead class="w-28">Key</TableHead>
|
||||||
<TableHead class="w-28">Provider</TableHead>
|
<TableHead class="w-28">Provider</TableHead>
|
||||||
<TableHead class="w-40">模型</TableHead>
|
<TableHead class="w-40">模型</TableHead>
|
||||||
<TableHead class="w-36">API 格式 / Key</TableHead>
|
<TableHead class="w-36">API 格式 / Key</TableHead>
|
||||||
@@ -380,12 +394,12 @@ onBeforeUnmount(() => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-1.5">
|
<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>
|
<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>
|
<span class="text-sm font-medium truncate max-w-[120px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-1.5">
|
<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>
|
<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>
|
<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>
|
||||||
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
|
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
|
||||||
@@ -439,5 +453,157 @@ onBeforeUnmount(() => {
|
|||||||
@update:page-size="pageSize = $event"
|
@update:page-size="pageSize = $event"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- TTL 分析区域 -->
|
||||||
|
<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">
|
||||||
|
<BarChart3 class="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h3 class="text-base font-semibold">TTL 分析</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Select v-model="analysisHours">
|
||||||
|
<SelectTrigger class="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>
|
||||||
|
<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"></span> 0-5分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> 5-15分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-purple-500"></span> 15-30分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-orange-500"></span> 30-60分钟</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-500"></span> >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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user