mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
feat: 添加使用量统计和数据分析功能
This commit is contained in:
@@ -253,5 +253,18 @@ export const meApi = {
|
|||||||
}> {
|
}> {
|
||||||
const response = await apiClient.put('/api/users/me/model-capabilities', data)
|
const response = await apiClient.put('/api/users/me/model-capabilities', data)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取请求间隔时间线(用于散点图)
|
||||||
|
async getIntervalTimeline(params?: {
|
||||||
|
hours?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<{
|
||||||
|
analysis_period_hours: number
|
||||||
|
total_points: number
|
||||||
|
points: Array<{ x: string; y: number }>
|
||||||
|
}> {
|
||||||
|
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const monthMarkers = computed(() => {
|
|||||||
if (month === lastMonth) {
|
if (month === lastMonth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markers[index] = String(month + 1)
|
markers[index] = `${month + 1}月`
|
||||||
lastMonth = month
|
lastMonth = month
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
208
frontend/src/composables/useTTLAnalysis.ts
Normal file
208
frontend/src/composables/useTTLAnalysis.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* TTL 分析 composable
|
||||||
|
* 封装缓存亲和性 TTL 分析相关的状态和逻辑
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import {
|
||||||
|
cacheAnalysisApi,
|
||||||
|
type TTLAnalysisResponse,
|
||||||
|
type CacheHitAnalysisResponse,
|
||||||
|
type IntervalTimelineResponse
|
||||||
|
} from '@/api/cache'
|
||||||
|
import type { ChartData } from 'chart.js'
|
||||||
|
|
||||||
|
// 时间范围选项
|
||||||
|
export const ANALYSIS_HOURS_OPTIONS = [
|
||||||
|
{ value: '12', label: '12 小时' },
|
||||||
|
{ value: '24', label: '24 小时' },
|
||||||
|
{ value: '72', label: '3 天' },
|
||||||
|
{ value: '168', label: '7 天' },
|
||||||
|
{ value: '336', label: '14 天' },
|
||||||
|
{ value: '720', label: '30 天' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// 间隔颜色配置
|
||||||
|
export const INTERVAL_COLORS = {
|
||||||
|
short: 'rgba(34, 197, 94, 0.6)', // green: 0-5 分钟
|
||||||
|
medium: 'rgba(59, 130, 246, 0.6)', // blue: 5-15 分钟
|
||||||
|
normal: 'rgba(168, 85, 247, 0.6)', // purple: 15-30 分钟
|
||||||
|
long: 'rgba(249, 115, 22, 0.6)', // orange: 30-60 分钟
|
||||||
|
veryLong: 'rgba(239, 68, 68, 0.6)' // red: >60 分钟
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据间隔时间获取对应的颜色
|
||||||
|
*/
|
||||||
|
export function getIntervalColor(interval: number): string {
|
||||||
|
if (interval <= 5) return INTERVAL_COLORS.short
|
||||||
|
if (interval <= 15) return INTERVAL_COLORS.medium
|
||||||
|
if (interval <= 30) return INTERVAL_COLORS.normal
|
||||||
|
if (interval <= 60) return INTERVAL_COLORS.long
|
||||||
|
return INTERVAL_COLORS.veryLong
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 TTL 推荐的 Badge 样式
|
||||||
|
*/
|
||||||
|
export function getTTLBadgeVariant(ttl: number): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
if (ttl <= 5) return 'default'
|
||||||
|
if (ttl <= 15) return 'secondary'
|
||||||
|
if (ttl <= 30) return 'outline'
|
||||||
|
return 'destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取使用频率标签
|
||||||
|
*/
|
||||||
|
export function getFrequencyLabel(ttl: number): string {
|
||||||
|
if (ttl <= 5) return '高频'
|
||||||
|
if (ttl <= 15) return '中高频'
|
||||||
|
if (ttl <= 30) return '中频'
|
||||||
|
return '低频'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取使用频率样式类名
|
||||||
|
*/
|
||||||
|
export function getFrequencyClass(ttl: number): string {
|
||||||
|
if (ttl <= 5) return 'text-success font-medium'
|
||||||
|
if (ttl <= 15) return 'text-blue-500 font-medium'
|
||||||
|
if (ttl <= 30) return 'text-muted-foreground'
|
||||||
|
return 'text-destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTTLAnalysis() {
|
||||||
|
const { error: showError, info: showInfo } = useToast()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const ttlAnalysis = ref<TTLAnalysisResponse | null>(null)
|
||||||
|
const hitAnalysis = ref<CacheHitAnalysisResponse | null>(null)
|
||||||
|
const ttlAnalysisLoading = ref(false)
|
||||||
|
const hitAnalysisLoading = ref(false)
|
||||||
|
const analysisHours = ref('24')
|
||||||
|
|
||||||
|
// 用户散点图展开状态
|
||||||
|
const expandedUserId = ref<string | null>(null)
|
||||||
|
const userTimelineData = ref<IntervalTimelineResponse | null>(null)
|
||||||
|
const userTimelineLoading = ref(false)
|
||||||
|
|
||||||
|
// 计算属性:是否正在加载
|
||||||
|
const isLoading = computed(() => ttlAnalysisLoading.value || hitAnalysisLoading.value)
|
||||||
|
|
||||||
|
// 获取 TTL 分析数据
|
||||||
|
async function fetchTTLAnalysis() {
|
||||||
|
ttlAnalysisLoading.value = true
|
||||||
|
try {
|
||||||
|
const hours = parseInt(analysisHours.value)
|
||||||
|
const result = await cacheAnalysisApi.analyzeTTL({ hours })
|
||||||
|
ttlAnalysis.value = result
|
||||||
|
|
||||||
|
if (result.total_users_analyzed === 0) {
|
||||||
|
const periodText = hours >= 24 ? `${hours / 24} 天` : `${hours} 小时`
|
||||||
|
showInfo(`未找到符合条件的数据(最近 ${periodText})`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取 TTL 分析失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
ttlAnalysisLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存命中分析数据
|
||||||
|
async function fetchHitAnalysis() {
|
||||||
|
hitAnalysisLoading.value = true
|
||||||
|
try {
|
||||||
|
hitAnalysis.value = await cacheAnalysisApi.analyzeHit({
|
||||||
|
hours: parseInt(analysisHours.value)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取缓存命中分析失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
hitAnalysisLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定用户的时间线数据
|
||||||
|
async function fetchUserTimeline(userId: string) {
|
||||||
|
userTimelineLoading.value = true
|
||||||
|
try {
|
||||||
|
userTimelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||||
|
hours: parseInt(analysisHours.value),
|
||||||
|
limit: 2000,
|
||||||
|
user_id: userId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取用户时间线数据失败')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
userTimelineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换用户行展开状态
|
||||||
|
async function toggleUserExpand(userId: string) {
|
||||||
|
if (expandedUserId.value === userId) {
|
||||||
|
expandedUserId.value = null
|
||||||
|
userTimelineData.value = null
|
||||||
|
} else {
|
||||||
|
expandedUserId.value = userId
|
||||||
|
await fetchUserTimeline(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新所有分析数据
|
||||||
|
async function refreshAnalysis() {
|
||||||
|
expandedUserId.value = null
|
||||||
|
userTimelineData.value = null
|
||||||
|
await Promise.all([fetchTTLAnalysis(), fetchHitAnalysis()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户时间线散点图数据
|
||||||
|
const userTimelineChartData = computed<ChartData<'scatter'>>(() => {
|
||||||
|
if (!userTimelineData.value || userTimelineData.value.points.length === 0) {
|
||||||
|
return { datasets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = userTimelineData.value.points
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: [{
|
||||||
|
label: '请求间隔',
|
||||||
|
data: points.map(p => ({ x: p.x, y: p.y })),
|
||||||
|
backgroundColor: points.map(p => getIntervalColor(p.y)),
|
||||||
|
borderColor: points.map(p => getIntervalColor(p.y).replace('0.6', '1')),
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听时间范围变化
|
||||||
|
watch(analysisHours, () => {
|
||||||
|
refreshAnalysis()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
ttlAnalysis,
|
||||||
|
hitAnalysis,
|
||||||
|
ttlAnalysisLoading,
|
||||||
|
hitAnalysisLoading,
|
||||||
|
analysisHours,
|
||||||
|
expandedUserId,
|
||||||
|
userTimelineData,
|
||||||
|
userTimelineLoading,
|
||||||
|
isLoading,
|
||||||
|
userTimelineChartData,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
fetchTTLAnalysis,
|
||||||
|
fetchHitAnalysis,
|
||||||
|
fetchUserTimeline,
|
||||||
|
toggleUserExpand,
|
||||||
|
refreshAnalysis
|
||||||
|
}
|
||||||
|
}
|
||||||
205
frontend/src/features/usage/components/IntervalTimelineCard.vue
Normal file
205
frontend/src/features/usage/components/IntervalTimelineCard.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-sm font-semibold">{{ title }}</p>
|
||||||
|
<div v-if="hasMultipleUsers && userLegend.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]">
|
||||||
|
<div
|
||||||
|
v-for="user in userLegend"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: user.color }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground">{{ user.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="h-[160px] flex items-center justify-center">
|
||||||
|
<div class="text-sm text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasData" class="h-[160px]">
|
||||||
|
<ScatterChart :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-[160px] flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
暂无请求间隔数据
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
|
import Card from '@/components/ui/card.vue'
|
||||||
|
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||||
|
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
||||||
|
import { meApi } from '@/api/me'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
title: string
|
||||||
|
isAdmin: boolean
|
||||||
|
hours?: number
|
||||||
|
}>(), {
|
||||||
|
hours: 168 // 默认7天
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||||
|
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||||
|
|
||||||
|
// 获取主题色
|
||||||
|
function getPrimaryColor(): string {
|
||||||
|
if (typeof window === 'undefined') return '201, 100, 66'
|
||||||
|
// CSS 变量定义在 body 上,不是 documentElement
|
||||||
|
const body = document.body
|
||||||
|
const style = getComputedStyle(body)
|
||||||
|
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
|
||||||
|
return rgb || '201, 100, 66'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
primaryColor.value = getPrimaryColor()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预定义的颜色列表(用于区分不同用户)
|
||||||
|
const USER_COLORS = [
|
||||||
|
'rgba(59, 130, 246, 0.7)', // blue
|
||||||
|
'rgba(236, 72, 153, 0.7)', // pink
|
||||||
|
'rgba(34, 197, 94, 0.7)', // green
|
||||||
|
'rgba(249, 115, 22, 0.7)', // orange
|
||||||
|
'rgba(168, 85, 247, 0.7)', // purple
|
||||||
|
'rgba(234, 179, 8, 0.7)', // yellow
|
||||||
|
'rgba(14, 165, 233, 0.7)', // sky
|
||||||
|
'rgba(239, 68, 68, 0.7)', // red
|
||||||
|
'rgba(20, 184, 166, 0.7)', // teal
|
||||||
|
'rgba(99, 102, 241, 0.7)', // indigo
|
||||||
|
]
|
||||||
|
|
||||||
|
const hasData = computed(() =>
|
||||||
|
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasMultipleUsers = computed(() =>
|
||||||
|
props.isAdmin && timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用户图例
|
||||||
|
const userLegend = computed(() => {
|
||||||
|
if (!props.isAdmin || !timelineData.value?.users) return []
|
||||||
|
|
||||||
|
const users = Object.entries(timelineData.value.users)
|
||||||
|
return users.map(([userId, username], index) => ({
|
||||||
|
id: userId,
|
||||||
|
name: username || userId.slice(0, 8),
|
||||||
|
color: USER_COLORS[index % USER_COLORS.length]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建图表数据
|
||||||
|
const chartData = computed<ChartData<'scatter'>>(() => {
|
||||||
|
if (!timelineData.value?.points) {
|
||||||
|
return { datasets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = timelineData.value.points
|
||||||
|
|
||||||
|
// 如果是管理员且有多个用户,按用户分组
|
||||||
|
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
||||||
|
const userIds = Object.keys(timelineData.value.users)
|
||||||
|
const userColorMap: Record<string, string> = {}
|
||||||
|
userIds.forEach((userId, index) => {
|
||||||
|
userColorMap[userId] = USER_COLORS[index % USER_COLORS.length]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按用户分组数据
|
||||||
|
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||||
|
for (const point of points) {
|
||||||
|
const userId = point.user_id || 'unknown'
|
||||||
|
if (!groupedData[userId]) {
|
||||||
|
groupedData[userId] = []
|
||||||
|
}
|
||||||
|
groupedData[userId].push({ x: point.x, y: point.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建每个用户的 dataset
|
||||||
|
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
|
||||||
|
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
|
||||||
|
data,
|
||||||
|
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
|
||||||
|
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { datasets }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单用户或用户视图:使用主题色
|
||||||
|
return {
|
||||||
|
datasets: [{
|
||||||
|
label: '请求间隔',
|
||||||
|
data: points.map(p => ({ x: p.x, y: p.y })),
|
||||||
|
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
|
||||||
|
borderColor: `rgba(${primaryColor.value}, 0.8)`,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false // 使用自定义图例
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||||
|
const realY = point._originalY ?? point.y
|
||||||
|
const datasetLabel = context.dataset.label || ''
|
||||||
|
if (props.isAdmin && hasMultipleUsers.value) {
|
||||||
|
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (props.isAdmin) {
|
||||||
|
// 管理员:获取所有用户数据
|
||||||
|
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||||
|
hours: props.hours,
|
||||||
|
include_user_info: true,
|
||||||
|
limit: 2000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 普通用户:获取自己的数据
|
||||||
|
timelineData.value = await meApi.getIntervalTimeline({
|
||||||
|
hours: props.hours,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载请求间隔时间线失败:', error)
|
||||||
|
timelineData.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.hours, () => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.isAdmin, () => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,3 +5,4 @@ export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
|
|||||||
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
|
||||||
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
|
||||||
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
|
||||||
|
export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue'
|
||||||
|
|||||||
@@ -125,4 +125,21 @@ export function formatBillingType(type: string | undefined | null): string {
|
|||||||
'free_tier': '免费套餐'
|
'free_tier': '免费套餐'
|
||||||
}
|
}
|
||||||
return typeMap[type || ''] || type || '按量付费'
|
return typeMap[type || ''] || type || '按量付费'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format cost with 4 decimal places (for cache analysis)
|
||||||
|
export function formatCost(cost: number | null | undefined): string {
|
||||||
|
if (cost === null || cost === undefined) return '-'
|
||||||
|
return `$${cost.toFixed(4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format remaining time from unix timestamp
|
||||||
|
export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string {
|
||||||
|
if (!expireAt) return '未知'
|
||||||
|
const remaining = expireAt - currentTime
|
||||||
|
if (remaining <= 0) return '已过期'
|
||||||
|
|
||||||
|
const minutes = Math.floor(remaining / 60)
|
||||||
|
const seconds = Math.floor(remaining % 60)
|
||||||
|
return `${minutes}分${seconds}秒`
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 pb-8">
|
<div class="space-y-6 pb-8">
|
||||||
<!-- 活跃度热图 -->
|
<!-- 活跃度热图 + 请求间隔时间线 -->
|
||||||
<ActivityHeatmapCard
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
:data="activityHeatmapData"
|
<ActivityHeatmapCard
|
||||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
:data="activityHeatmapData"
|
||||||
/>
|
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||||
|
/>
|
||||||
|
<IntervalTimelineCard
|
||||||
|
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
||||||
|
:is-admin="isAdminPage"
|
||||||
|
:hours="168"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分析统计 -->
|
<!-- 分析统计 -->
|
||||||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
||||||
@@ -87,7 +94,8 @@ import {
|
|||||||
UsageApiFormatTable,
|
UsageApiFormatTable,
|
||||||
UsageRecordsTable,
|
UsageRecordsTable,
|
||||||
ActivityHeatmapCard,
|
ActivityHeatmapCard,
|
||||||
RequestDetailDrawer
|
RequestDetailDrawer,
|
||||||
|
IntervalTimelineCard
|
||||||
} from '@/features/usage/components'
|
} from '@/features/usage/components'
|
||||||
import {
|
import {
|
||||||
useUsageData,
|
useUsageData,
|
||||||
|
|||||||
Reference in New Issue
Block a user