feat: 统计数据优化 - 支持细粒度时间范围和多维度分析

- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
This commit is contained in:
fawney19
2026-02-04 02:04:54 +08:00
parent 6f363a7703
commit f3e2f84b38
59 changed files with 6392 additions and 500 deletions

View File

@@ -20,13 +20,6 @@
<h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
活跃 {{ activeKeyCount }} · 禁用 {{ inactiveKeyCount }} · 无限 Key {{ unlimitedKeyCount }}
<span
v-if="expiringSoonCount > 0"
class="text-amber-600"
> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 -->

View File

@@ -56,7 +56,7 @@
<h3 class="text-base font-semibold">异步任务</h3>
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select v-model="filterStatus">
<Select v-model:open="statusSelectOpen" v-model="filterStatus">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
@@ -690,6 +690,7 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filterStatus = ref('all')
const statusSelectOpen = ref(false)
const filterModel = ref('')
const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null)

View File

@@ -0,0 +1,129 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">成本分析</h1>
<p class="text-xs text-muted-foreground">成本趋势预测与节省统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存节省</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_savings ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存读取 Tokens</div>
<div class="text-lg font-semibold">{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存创建成本</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}</div>
<div class="text-xs text-muted-foreground">基于当前时间范围</div>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<CostForecastChart
title="成本趋势预测"
:history="forecastHistory"
:forecast="forecastFuture"
:loading="forecastLoading"
/>
</Card>
<QuotaProgressCard
title="月卡消耗进度"
:providers="quotaProviders"
:loading="quotaLoading"
/>
</div>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { ProviderStatsItem } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadForecast() {
forecastLoading.value = true
try {
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams())
} finally {
forecastLoading.value = false
}
}
async function loadSavings() {
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams())
}
async function loadQuotaUsage() {
quotaLoading.value = true
try {
const response = await adminApi.getQuotaUsage()
quotaProviders.value = response.providers
} finally {
quotaLoading.value = false
}
}
async function loadProviderStats() {
providerStats.value = await usageApi.getUsageByProvider({
...buildTimeRangeParams(),
limit: 8
})
}
async function loadAll() {
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">性能分析</h1>
<p class="text-xs text-muted-foreground">延迟分布与错误统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<ErrorDistributionChart
title="错误分布"
:distribution="errorDistribution"
:loading="errorLoading"
/>
</Card>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">错误趋势</h3>
<div v-if="errorLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="errorTrendChartData" />
</div>
</Card>
</div>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">提供商健康度</h3>
<div v-if="providerLoading" class="p-4">
<LoadingState />
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div
v-for="provider in providerStatus"
:key="provider.name"
class="p-3 border rounded-lg"
>
<div class="flex items-center justify-between">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
</div>
<div class="text-xs text-muted-foreground mt-1">
状态: {{ provider.status }}
</div>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const percentiles = ref<PercentileItem[]>([])
const percentileLoading = ref(false)
const errorDistribution = ref<ErrorDistributionResponse['distribution']>([])
const errorTrend = ref<ErrorDistributionResponse['trend']>([])
const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadPercentiles() {
percentileLoading.value = true
try {
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams())
} finally {
percentileLoading.value = false
}
}
async function loadErrors() {
errorLoading.value = true
try {
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
errorDistribution.value = response.distribution
errorTrend.value = response.trend
} finally {
errorLoading.value = false
}
}
async function loadProviders() {
providerLoading.value = true
try {
providerStatus.value = await dashboardApi.getProviderStatus()
} finally {
providerLoading.value = false
}
}
const errorTrendChartData = computed(() => ({
labels: errorTrend.value.map(item => item.date),
datasets: [
{
label: '错误数',
data: errorTrend.value.map(item => item.total),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}))
async function loadAll() {
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>

View File

@@ -0,0 +1,239 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">用户统计</h1>
<p class="text-xs text-muted-foreground">查看用户排行榜与使用趋势</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TimeRangePicker v-model="timeRange" :allow-hourly="true" />
<Select v-model:open="userSelectOpen" v-model="selectedUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model:open="compareUserSelectOpen" v-model="compareUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不对比</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeaderboardTable
title="用户排行榜"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
@update:metric="metric = $event"
/>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">用户摘要</h3>
<div v-if="summaryLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-muted-foreground">请求数</div>
<div class="font-semibold">{{ userSummary?.total_requests ?? 0 }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Tokens</div>
<div class="font-semibold">{{ formatTokens(userSummary?.total_tokens ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">成本</div>
<div class="font-semibold">{{ formatCurrency(userSummary?.total_cost ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">错误率</div>
<div class="font-semibold">{{ userSummary?.error_rate ?? 0 }}%</div>
</div>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户使用趋势</h3>
<div v-if="seriesLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card v-if="comparisonSeries.length > 0" class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户对比趋势</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const userSelectOpen = ref(false)
const compareUserSelectOpen = ref(false)
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
const userSummary = ref<any | null>(null)
const summaryLoading = ref(false)
const series = ref<any[]>([])
const comparisonSeries = ref<any[]>([])
const seriesLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
}
}
async function loadUsers() {
users.value = await usersApi.getAllUsers()
if (!selectedUserId.value && users.value.length > 0) {
selectedUserId.value = users.value[0].id
}
}
async function loadLeaderboard() {
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
leaderboard.value = response.items
} finally {
leaderboardLoading.value = false
}
}
async function loadSummary() {
if (!selectedUserId.value) return
summaryLoading.value = true
try {
userSummary.value = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
} finally {
summaryLoading.value = false
}
}
async function loadSeries() {
if (!selectedUserId.value) return
seriesLoading.value = true
try {
series.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
comparisonSeries.value = []
if (compareUserId.value && compareUserId.value !== '__none__') {
comparisonSeries.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
})
}
} finally {
seriesLoading.value = false
}
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '成本',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}
]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '当前用户',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: '对比用户',
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
}
]
}))
watch([timeRange, metric], loadLeaderboard, { deep: true })
watch([timeRange, selectedUserId, compareUserId], () => {
loadSummary()
loadSeries()
}, { deep: true })
onMounted(async () => {
await loadUsers()
await loadLeaderboard()
await loadSummary()
await loadSeries()
})
</script>

View File

@@ -393,6 +393,14 @@
</div>
</div>
<!-- 趋势图表筛选 -->
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
统计周期
</h3>
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
</div>
<!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 每日使用趋势折线图- 普通用户可见 -->
@@ -784,6 +792,8 @@
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import { announcementApi, type Announcement } from '@/api/announcements'
import {
Card,
@@ -798,6 +808,7 @@ import {
TableHead,
TableCell,
} from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import BarChart from '@/components/charts/BarChart.vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
@@ -972,7 +983,8 @@ const tokenBreakdown = ref<{
const activeUsers = ref(0)
const dailyStats = ref<DailyStat[]>([])
const providerSummary = ref<ProviderSummary[]>([])
const selectedDays = ref(7)
const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
// 统计周期
const loadingDaily = ref(false)
const loading = ref(false)
@@ -1317,7 +1329,7 @@ async function loadDashboardData() {
async function loadDailyStats() {
loadingDaily.value = true
try {
const response = await dashboardApi.getDailyStats(selectedDays.value)
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch {
@@ -1328,6 +1340,10 @@ async function loadDailyStats() {
}
}
watch(dailyTimeRange, async () => {
await loadDailyStats()
}, { deep: true })
function formatDate(dateString: string): string {
const date = new Date(dateString)
const today = new Date()

View File

@@ -55,11 +55,12 @@
:is-admin="isAdminPage"
:show-actual-cost="authStore.isAdmin"
:loading="isLoadingRecords"
:selected-period="selectedPeriod"
:time-range="timeRange"
:filter-search="filterSearch"
:filter-user="filterUser"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:available-users="availableUsers"
:available-models="availableModels"
@@ -69,17 +70,17 @@
:total-records="totalRecords"
:page-size-options="pageSizeOptions"
:auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange"
@update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@update:filter-status="handleFilterStatusChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData"
@export="exportData"
@show-detail="showRequestDetail"
/>
@@ -113,7 +114,7 @@ import {
useUsageData,
getDateRangeFromPeriod
} from '@/features/usage/composables'
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
@@ -126,8 +127,8 @@ const authStore = useAuthStore()
// 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 时间选择
const selectedPeriod = ref<PeriodValue>('today')
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
// 分页状态
const currentPage = ref(1)
@@ -139,6 +140,7 @@ const filterSearch = ref('')
const filterUser = ref('__all__')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
@@ -194,6 +196,12 @@ const filteredRecords = computed(() => {
records = records.filter(record => record.provider === filterProvider.value)
}
if (filterApiFormat.value !== '__all__') {
records = records.filter(record =>
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
)
}
if (filterStatus.value !== '__all__') {
if (filterStatus.value === 'stream') {
records = records.filter(record =>
@@ -386,11 +394,9 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载
onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
const [statsResult, heatmapResult] = await Promise.allSettled([
loadStats(dateRange),
loadStats(timeRange.value),
loadHeatmapData()
])
@@ -418,13 +424,11 @@ onMounted(async () => {
}
})
// 处理时间变化
async function handlePeriodChange(value: string) {
selectedPeriod.value = value as PeriodValue
currentPage.value = 1 // 重置到第一页
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
// 处理时间范围变化
async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value
currentPage.value = 1 // 重置到第一页
await loadStats(timeRange.value)
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -448,6 +452,7 @@ function getCurrentFilters() {
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
}
}
@@ -487,6 +492,15 @@ async function handleFilterProviderChange(value: string) {
}
}
async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
}
async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue
currentPage.value = 1
@@ -498,8 +512,7 @@ async function handleFilterStatusChange(value: string) {
// 刷新数据
async function refreshData() {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
await loadStats(timeRange.value)
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -510,20 +523,6 @@ function showRequestDetail(id: string) {
detailModalOpen.value = true
}
// 导出数据
async function exportData(format: 'csv' | 'json') {
try {
const blob = await usageApi.exportUsage(format)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `usage-stats.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (error) {
log.error('导出失败:', error)
}
}
</script>
<style scoped>