mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 12:08:30 +08:00
Initial commit
This commit is contained in:
4
frontend/src/features/usage/composables/index.ts
Normal file
4
frontend/src/features/usage/composables/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useUsageData } from './useUsageData'
|
||||
export { useUsageFilters } from './useUsageFilters'
|
||||
export { useUsagePagination } from './useUsagePagination'
|
||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
68
frontend/src/features/usage/composables/useDateRange.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { PeriodValue, DateRangeParams } from '../types'
|
||||
|
||||
/**
|
||||
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
|
||||
*/
|
||||
function formatDateForApi(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间段值计算日期范围
|
||||
*/
|
||||
export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
let endDate = new Date(now)
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
break
|
||||
case 'last7days':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last30days':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'last90days':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
return {} // 返回空对象表示不过滤时间
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: formatDateForApi(startDate),
|
||||
end_date: formatDateForApi(endDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为时分秒
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
// 后端返回的是 UTC 时间但没有时区标识,需要手动添加 'Z'
|
||||
const utcDateStr = dateStr.includes('Z') || dateStr.includes('+') ? dateStr : dateStr + 'Z'
|
||||
const date = new Date(utcDateStr)
|
||||
|
||||
// 只显示时分秒
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成功率颜色类名
|
||||
*/
|
||||
export function getSuccessRateColor(rate: number): string {
|
||||
if (rate >= 95) return 'text-green-600 dark:text-green-400'
|
||||
if (rate >= 90) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
314
frontend/src/features/usage/composables/useUsageData.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { meApi } from '@/api/me'
|
||||
import type {
|
||||
UsageStatsState,
|
||||
ModelStatsItem,
|
||||
ProviderStatsItem,
|
||||
ApiFormatStatsItem,
|
||||
UsageRecord,
|
||||
DateRangeParams,
|
||||
EnhancedModelStatsItem
|
||||
} from '../types'
|
||||
import { createDefaultStats } from '../types'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export function useUsageData(options: UseUsageDataOptions) {
|
||||
const { isAdminPage } = options
|
||||
|
||||
// 加载状态
|
||||
const isLoadingStats = ref(true)
|
||||
const isLoadingRecords = ref(false)
|
||||
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<UsageStatsState>(createDefaultStats())
|
||||
const modelStats = ref<ModelStatsItem[]>([])
|
||||
const providerStats = ref<ProviderStatsItem[]>([])
|
||||
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
|
||||
|
||||
// 记录数据 - 只存储当前页
|
||||
const currentRecords = ref<UsageRecord[]>([])
|
||||
const totalRecords = ref(0)
|
||||
|
||||
// 当前的日期范围(用于分页请求)
|
||||
const currentDateRange = ref<DateRangeParams | undefined>(undefined)
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
return modelStats.value.map(model => ({
|
||||
...model,
|
||||
costPerToken: model.total_tokens > 0
|
||||
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
|
||||
: '-'
|
||||
}))
|
||||
})
|
||||
|
||||
// 活跃度热图数据
|
||||
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
try {
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面,并行加载统计数据
|
||||
const [statsData, modelData, providerData, apiFormatData] = await Promise.all([
|
||||
usageApi.getUsageStats(dateRange),
|
||||
usageApi.getUsageByModel(dateRange),
|
||||
usageApi.getUsageByProvider(dateRange),
|
||||
usageApi.getUsageByApiFormat(dateRange)
|
||||
])
|
||||
|
||||
stats.value = {
|
||||
total_requests: statsData.total_requests || 0,
|
||||
total_tokens: statsData.total_tokens || 0,
|
||||
total_cost: statsData.total_cost || 0,
|
||||
total_actual_cost: (statsData as any).total_actual_cost,
|
||||
avg_response_time: statsData.avg_response_time || 0,
|
||||
error_count: (statsData as any).error_count,
|
||||
error_rate: (statsData as any).error_rate,
|
||||
cache_stats: (statsData as any).cache_stats,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: statsData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = modelData.map(item => ({
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: (item as any).actual_cost
|
||||
}))
|
||||
|
||||
providerStats.value = providerData.map(item => ({
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
totalCost: item.total_cost,
|
||||
actualCost: item.actual_cost,
|
||||
successRate: item.success_rate,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
apiFormatStats.value = apiFormatData.map(item => ({
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost || 0,
|
||||
actual_cost: item.actual_cost,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 从统计数据中提取可用的筛选选项
|
||||
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
|
||||
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
|
||||
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
total_tokens: userData.total_tokens || 0,
|
||||
total_cost: userData.total_cost || 0,
|
||||
total_actual_cost: userData.total_actual_cost,
|
||||
avg_response_time: userData.avg_response_time || 0,
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
activity_heatmap: userData.activity_heatmap || null
|
||||
}
|
||||
|
||||
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
total_cost: item.total_cost_usd || 0,
|
||||
actual_cost: item.actual_total_cost_usd
|
||||
}))
|
||||
|
||||
providerStats.value = (userData.summary_by_provider || []).map((item: any) => ({
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalCost: item.total_cost_usd || 0,
|
||||
successRate: item.success_rate || 0,
|
||||
avgResponseTime: item.avg_response_time_ms > 0
|
||||
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
|
||||
: '-'
|
||||
}))
|
||||
|
||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
totalRecords.value = currentRecords.value.length
|
||||
|
||||
// 从记录中提取筛选选项和 API 格式统计
|
||||
const models = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
const apiFormatMap = new Map<string, {
|
||||
count: number
|
||||
tokens: number
|
||||
cost: number
|
||||
totalResponseTime: number
|
||||
responseTimeCount: number
|
||||
}>()
|
||||
|
||||
currentRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
if (record.provider) providers.add(record.provider)
|
||||
if (record.api_format) {
|
||||
const existing = apiFormatMap.get(record.api_format) || {
|
||||
count: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
totalResponseTime: 0,
|
||||
responseTimeCount: 0
|
||||
}
|
||||
existing.count++
|
||||
existing.tokens += record.total_tokens || 0
|
||||
existing.cost += record.cost || 0
|
||||
if (record.response_time_ms) {
|
||||
existing.totalResponseTime += record.response_time_ms
|
||||
existing.responseTimeCount++
|
||||
}
|
||||
apiFormatMap.set(record.api_format, existing)
|
||||
}
|
||||
})
|
||||
|
||||
availableModels.value = Array.from(models).sort()
|
||||
availableProviders.value = Array.from(providers).sort()
|
||||
|
||||
// 构建 API 格式统计数据
|
||||
apiFormatStats.value = Array.from(apiFormatMap.entries())
|
||||
.map(([format, data]) => {
|
||||
const avgMs = data.responseTimeCount > 0
|
||||
? data.totalResponseTime / data.responseTimeCount
|
||||
: 0
|
||||
return {
|
||||
api_format: format,
|
||||
request_count: data.count,
|
||||
total_tokens: data.tokens,
|
||||
total_cost: data.cost,
|
||||
avgResponseTime: avgMs > 0 ? `${(avgMs / 1000).toFixed(2)}s` : '-'
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status !== 403) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
stats.value = createDefaultStats()
|
||||
modelStats.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载记录(真正的后端分页)
|
||||
async function loadRecords(
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
const offset = (pagination.page - 1) * pagination.pageSize
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
limit: pagination.pageSize,
|
||||
offset,
|
||||
...currentDateRange.value
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData(dateRange?: DateRangeParams) {
|
||||
await loadStats(dateRange)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
isLoadingStats,
|
||||
isLoadingRecords,
|
||||
stats,
|
||||
modelStats,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
|
||||
// 筛选选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
136
frontend/src/features/usage/composables/useUsageFilters.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ref, computed, type Ref, watch } from 'vue'
|
||||
import type { UsageRecord, FilterStatusValue } from '../types'
|
||||
|
||||
export interface UseUsageFiltersOptions {
|
||||
/** 所有记录的响应式引用 */
|
||||
allRecords: Ref<UsageRecord[]>
|
||||
/** 当筛选变化时的回调 */
|
||||
onFilterChange?: () => void
|
||||
}
|
||||
|
||||
export function useUsageFilters(options: UseUsageFiltersOptions) {
|
||||
const { allRecords, onFilterChange } = options
|
||||
|
||||
// 筛选状态
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
|
||||
// Select 打开状态
|
||||
const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// 可用模型和提供商选项
|
||||
const availableModels = computed(() => {
|
||||
const models = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.model) models.add(record.model)
|
||||
})
|
||||
return Array.from(models).sort()
|
||||
})
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
const providers = new Set<string>()
|
||||
allRecords.value.forEach(record => {
|
||||
if (record.provider) providers.add(record.provider)
|
||||
})
|
||||
return Array.from(providers).sort()
|
||||
})
|
||||
|
||||
// 是否有活跃的筛选条件
|
||||
const hasActiveFilters = computed(() => {
|
||||
return filterModel.value !== '__all__' ||
|
||||
filterProvider.value !== '__all__' ||
|
||||
filterStatus.value !== '__all__'
|
||||
})
|
||||
|
||||
// 筛选后的记录
|
||||
const filteredRecords = computed(() => {
|
||||
if (!hasActiveFilters.value) {
|
||||
return allRecords.value
|
||||
}
|
||||
|
||||
let records = [...allRecords.value]
|
||||
|
||||
if (filterModel.value !== '__all__') {
|
||||
records = records.filter(record => record.model === filterModel.value)
|
||||
}
|
||||
|
||||
if (filterProvider.value !== '__all__') {
|
||||
records = records.filter(record => record.provider === filterProvider.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== '__all__') {
|
||||
if (filterStatus.value === 'stream') {
|
||||
records = records.filter(record =>
|
||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'standard') {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
})
|
||||
|
||||
// 筛选后的总记录数
|
||||
const filteredTotalRecords = computed(() => filteredRecords.value.length)
|
||||
|
||||
// 处理筛选变化
|
||||
function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterProviderChange(value: string) {
|
||||
filterProvider.value = value
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
function handleFilterStatusChange(value: string) {
|
||||
filterStatus.value = value as FilterStatusValue
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
// 重置所有筛选
|
||||
function resetFilters() {
|
||||
filterModel.value = '__all__'
|
||||
filterProvider.value = '__all__'
|
||||
filterStatus.value = '__all__'
|
||||
onFilterChange?.()
|
||||
}
|
||||
|
||||
return {
|
||||
// 筛选状态
|
||||
filterModel,
|
||||
filterProvider,
|
||||
filterStatus,
|
||||
|
||||
// Select 打开状态
|
||||
filterModelSelectOpen,
|
||||
filterProviderSelectOpen,
|
||||
filterStatusSelectOpen,
|
||||
|
||||
// 可用选项
|
||||
availableModels,
|
||||
availableProviders,
|
||||
|
||||
// 计算属性
|
||||
hasActiveFilters,
|
||||
filteredRecords,
|
||||
filteredTotalRecords,
|
||||
|
||||
// 方法
|
||||
handleFilterModelChange,
|
||||
handleFilterProviderChange,
|
||||
handleFilterStatusChange,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export interface UseUsagePaginationOptions {
|
||||
/** 数据源记录 */
|
||||
records: Ref<UsageRecord[]>
|
||||
/** 初始页码 */
|
||||
initialPage?: number
|
||||
/** 初始每页大小 */
|
||||
initialPageSize?: number
|
||||
/** 每页大小选项 */
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
export function useUsagePagination(options: UseUsagePaginationOptions) {
|
||||
const {
|
||||
records,
|
||||
initialPage = 1,
|
||||
initialPageSize = 20,
|
||||
pageSizeOptions = [10, 20, 50, 100]
|
||||
} = options
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(initialPage)
|
||||
const pageSize = ref(initialPageSize)
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(records.value.length / pageSize.value))
|
||||
)
|
||||
|
||||
// 计算总记录数
|
||||
const totalRecords = computed(() => records.value.length)
|
||||
|
||||
// 分页后的记录
|
||||
const paginatedRecords = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return records.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 处理页码变化
|
||||
function changePage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
function changePageSize(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
}
|
||||
|
||||
// 重置到第一页
|
||||
function resetPage() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 跳转到最后一页
|
||||
function goToLastPage() {
|
||||
currentPage.value = totalPages.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentPage,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
|
||||
// 计算属性
|
||||
totalPages,
|
||||
totalRecords,
|
||||
paginatedRecords,
|
||||
|
||||
// 方法
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetPage,
|
||||
goToLastPage
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user