feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
@@ -0,0 +1,7 @@
<template>
<Dashboard />
</template>
<script setup lang="ts">
import Dashboard from '@/views/shared/Dashboard.vue'
</script>
File diff suppressed because it is too large Load Diff
+340 -314
View File
@@ -1,325 +1,351 @@
<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>
<LeaderboardTable
title="API Key 用量排行"
:items="apiKeyLeaderboard"
:metric="apiKeyLeaderboardMetric"
:loading="apiKeyLeaderboardLoading"
:show-metric-select="false"
@update:metric="apiKeyLeaderboardMetric = $event"
<main class="space-y-6 px-4 pb-8 sm:px-6 lg:px-0">
<OverviewToolbar
:title="t('成本分析', 'Cost analysis')"
:range="range"
:preset="relativePreset"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
:loading="loading"
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<template #actions>
<LeaderboardControls
:metric="apiKeyLeaderboardMetric"
:time-range="apiKeyLeaderboardTimeRange"
@update:metric="apiKeyLeaderboardMetric = $event"
@update:time-range="apiKeyLeaderboardTimeRange = $event"
/>
</template>
<template #pagination>
<Pagination
v-if="apiKeyLeaderboardTotal > 0"
:current="apiKeyLeaderboardPage"
:total="apiKeyLeaderboardTotal"
:page-size="apiKeyLeaderboardPageSize"
:page-size-options="apiKeyLeaderboardPageSizeOptions"
@update:current="apiKeyLeaderboardPage = $event"
@update:page-size="apiKeyLeaderboardPageSize = $event"
/>
</template>
</LeaderboardTable>
<Button
variant="outline"
size="sm"
:disabled="!accounts?.items.length"
@click="recording = true"
>
<Plus class="mr-1.5 h-3.5 w-3.5" />{{ t('登记支出', 'Record expense') }}
</Button>
</OverviewToolbar>
<div
class="grid grid-cols-2 gap-3 xl:grid-cols-4"
data-cost-summary
>
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
<p class="text-xs text-muted-foreground">
{{ t('已登记支出', 'Recorded expenses') }}
</p>
<div class="min-h-8 text-xl font-semibold tabular-nums">
<p
v-for="item in totalAmounts('amount')"
:key="item.currency"
>
{{ item.text }}
</p>
</div>
<p class="text-xs text-muted-foreground">
{{ t('所选时段', 'Selected range') }} · {{ count(expenses?.total) }} {{ t('笔', 'entries') }}
</p>
</Card>
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
<p class="text-xs text-muted-foreground">
{{ t('提供商充值', 'Provider recharges') }}
</p>
<div class="min-h-8 text-xl font-semibold tabular-nums">
<p
v-for="item in totalAmounts('recharge_amount')"
:key="item.currency"
>
{{ item.text }}
</p>
</div>
<p class="text-xs text-muted-foreground">
{{ t('已登记的余额充值', 'Recorded balance top-ups') }}
</p>
</Card>
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
<p class="text-xs text-muted-foreground">
{{ t('套餐与续费', 'Subscriptions and renewals') }}
</p>
<div class="min-h-8 text-xl font-semibold tabular-nums">
<p
v-for="item in totalAmounts('subscription_amount')"
:key="item.currency"
>
{{ item.text }}
</p>
</div>
<p class="text-xs text-muted-foreground">
{{ t('已登记的套餐实付金额', 'Recorded subscription payments') }}
</p>
</Card>
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
<p class="text-xs text-muted-foreground">
{{ t('提供商调用', 'Provider requests') }}
</p>
<p class="min-h-8 text-xl font-semibold tabular-nums">
{{ count(costs?.data.summary.request_count) }}
</p>
<p class="text-xs text-muted-foreground">
{{ count(costs?.data.summary.total_tokens) }} Tokens
</p>
</Card>
</div>
<p class="text-xs text-muted-foreground">
{{ t('支出以登记的付款记录为准,不同币种分别汇总;套餐额度与上游余额单独展示。', 'Expenses reflect recorded payments, grouped by currency. Plan quotas and upstream balances are shown separately.') }}
</p>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
<section
class="space-y-4"
data-provider-usage
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold">
{{ t('提供商用量', 'Provider usage') }}
</h2><p class="mt-1 text-xs text-muted-foreground">
{{ t('所选时段 · 按请求量排序', 'Selected range · Most requests first') }}
</p>
</div>
<Button
variant="outline"
size="sm"
:disabled="exporting || !providers?.data.total"
@click="exportCsv('breakdown', providerQuery)"
>
<Download class="mr-1.5 h-3.5 w-3.5" />{{ t('导出用量报表', 'Export usage') }}
</Button>
</div>
<OverviewStatus
:loading="providerLoading && !providers"
:error="providerError || exportError"
@retry="refreshProviders"
/>
<Card class="min-w-0 overflow-hidden">
<div class="overflow-x-auto px-4 sm:px-5">
<table class="w-full min-w-[760px] text-sm">
<thead class="border-b text-xs text-muted-foreground">
<tr>
<th class="py-3 text-left font-medium">
{{ t('提供商', 'Provider') }}
</th>
<th class="px-3 py-3 text-right font-medium">
{{ t('请求量', 'Requests') }}
</th>
<th class="px-3 py-3 text-right font-medium">
{{ t('输入 Token', 'Input tokens') }}
</th>
<th class="px-3 py-3 text-right font-medium">
{{ t('输出 Token', 'Output tokens') }}
</th>
<th class="px-3 py-3 text-right font-medium">
{{ t('已登记支出', 'Recorded expenses') }}
</th>
<th class="py-3 text-right font-medium">
{{ t('使用记录', 'Usage records') }}
</th>
</tr>
</thead>
<tbody class="divide-y">
<tr
v-for="provider in providers?.data.items || []"
:key="provider.id || 'unattributed'"
>
<td class="max-w-64 break-words py-4 font-medium">
{{ provider.label || t('未归属提供商', 'Unattributed provider') }}<p class="mt-1 text-xs font-normal text-muted-foreground">
{{ providerType(provider.id) }}
</p>
</td>
<td class="px-3 py-4 text-right tabular-nums">
{{ count(provider.request_count) }}
</td>
<td class="px-3 py-4 text-right tabular-nums">
{{ count(provider.input_tokens) }}
</td>
<td class="px-3 py-4 text-right tabular-nums">
{{ count(provider.output_tokens) }}
</td>
<td class="px-3 py-4 text-right tabular-nums">
<p
v-for="item in providerAmounts(provider.id)"
:key="item.currency"
class="whitespace-nowrap"
>
{{ item.text }}
</p>
</td>
<td class="py-4 text-right">
<RouterLink
v-if="provider.id"
:to="link('/admin/usage', { provider_id: provider.id })"
class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{{ t('查看', 'View') }}<ArrowUpRight class="h-3.5 w-3.5" />
</RouterLink><span v-else>-</span>
</td>
</tr>
<tr v-if="!providers?.data.items.length">
<td
colspan="6"
class="py-12 text-center text-muted-foreground"
>
{{ providerLoading ? t('加载提供商用量…', 'Loading provider usage…') : providers ? t('此时段暂无调用记录', 'No requests in this range') : t('暂未取得用量', 'Usage unavailable') }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="providers"
class="px-4 pb-3 sm:px-5"
>
<OverviewPagination
:total="providers.data.total"
:limit="providerLimit"
:offset="providerOffset"
:loading="providerLoading"
@page="value => providerOffset = value"
@size="value => { providerLimit = value; providerOffset = 0 }"
/>
</div>
</Card>
</section>
<OverviewStatus
:loading="accountLoading && !accounts"
:error="accountError"
@retry="refreshAccounts"
/>
</div>
<ProviderAccounts
v-if="accounts"
:accounts="accounts.items"
:observed-at="accounts.observed_at"
:timezone="range.timezone"
/>
<OverviewStatus
:loading="expenseLoading && !expenses"
:error="expenseError"
@retry="refreshExpenses"
/>
<ExpenseLedger
:data="expenses"
:range="range"
:offset="expenseOffset"
:loading="expenseLoading"
:can-record="Boolean(accounts?.items.length)"
@record="recording = true"
@page="selectExpensePage"
@size="value => { expenseLimit = value; expenseOffset = 0 }"
@changed="expensesChanged"
/>
<section
class="space-y-4"
data-cost-reports
>
<h2 class="text-sm font-semibold">
{{ t('用量报表', 'Usage reports') }}
</h2>
<OverviewStatus
:loading="costLoading && !costs"
:error="costError"
@retry="refreshCosts"
/>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="min-w-0 space-y-4 p-4 sm:p-5">
<h3 class="text-sm font-medium">
{{ t('调用趋势', 'Request trend') }}
</h3><div
v-if="hasRequests"
class="h-60"
>
<LineChart :data="requestChart" />
</div><p
v-else
class="flex h-60 items-center justify-center text-sm text-muted-foreground"
>
{{ costs ? t('此时段暂无调用', 'No requests in this range') : t('暂无报表数据', 'Report unavailable') }}
</p>
</Card>
<Card class="min-w-0 space-y-4 p-4 sm:p-5">
<h3 class="text-sm font-medium">
{{ t('Token 用量趋势', 'Token usage trend') }}
</h3><div
v-if="hasRequests"
class="h-60"
>
<LineChart :data="tokenChart" />
</div><p
v-else
class="flex h-60 items-center justify-center text-sm text-muted-foreground"
>
{{ costs ? t('此时段暂无用量', 'No usage in this range') : t('暂无报表数据', 'Report unavailable') }}
</p>
</Card>
</div>
</section>
<ExpenseDialog
v-model:open="recording"
:providers="accounts?.items || []"
:timezone="range.timezone"
@saved="expensesChanged"
/>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { Pagination } from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import { normalizeUsageProviderStats } from '@/features/usage/utils/providerStats'
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 apiKeyLeaderboard = ref<LeaderboardItem[]>([])
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const apiKeyLeaderboardPage = ref(1)
const apiKeyLeaderboardPageSize = ref(10)
const apiKeyLeaderboardTotal = ref(0)
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const apiKeyLeaderboardLoading = ref(false)
let forecastRequestId = 0
let savingsRequestId = 0
let quotaRequestId = 0
let providerStatsRequestId = 0
let apiKeyLeaderboardRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
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
}
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowUpRight, Download, Plus } from 'lucide-vue-next'
import { Button, Card } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { overviewApi } from '@/api/overview'
import { providerFinanceApi, type ProviderExpenseTotals } from '@/api/providerFinance'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
import OverviewPagination from '@/features/overview/components/OverviewPagination.vue'
import ExpenseDialog from '@/features/overview/costs/ExpenseDialog.vue'
import ExpenseLedger from '@/features/overview/costs/ExpenseLedger.vue'
import ProviderAccounts from '@/features/overview/costs/ProviderAccounts.vue'
import { useOverviewQuery } from '@/features/overview/query'
import { useOverviewAutoRefresh } from '@/features/overview/useOverviewAutoRefresh'
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const { t } = useOverviewI18n()
const { range, relativePreset, refreshRange, setRange, link } = useOverviewQuery('last30days', { rolling: true })
const { revision, autoRefresh, refreshTitle, toggleAutoRefresh } = useOverviewAutoRefresh(refreshRange)
const recording = ref(false)
const providerOffset = ref(0)
const expenseOffset = ref(0)
const providerLimit = ref(25)
const expenseLimit = ref(25)
const selectedScope = computed(() => JSON.stringify(relativePreset.value ? { preset: relativePreset.value, timezone: range.value.timezone } : range.value))
watch(selectedScope, () => { providerOffset.value = 0; expenseOffset.value = 0 })
const costQuery = computed(() => ({ ...range.value, granularity: 'day' as const }))
const providerQuery = computed(() => ({ ...range.value, group_by: 'provider', sort: 'request_count', order: 'desc' as const, limit: providerLimit.value, offset: providerOffset.value }))
const expenseQuery = computed(() => ({ ...range.value, limit: expenseLimit.value, offset: expenseOffset.value }))
const { data: costs, loading: costLoading, error: costError, refresh: refreshCosts } = useOverviewRequest(() => JSON.stringify([costQuery.value, revision.value]), signal => overviewApi.costs(costQuery.value, signal), { scopeKey: selectedScope })
const { data: providers, loading: providerLoading, error: providerError, refresh: refreshProviders } = useOverviewRequest(() => JSON.stringify([providerQuery.value, revision.value]), signal => overviewApi.breakdown(providerQuery.value, signal), { scopeKey: () => JSON.stringify([selectedScope.value, providerOffset.value]) })
const { data: accounts, loading: accountLoading, error: accountError, refresh: refreshAccounts } = useOverviewRequest(revision, signal => providerFinanceApi.accounts(signal))
const { data: expenses, loading: expenseLoading, error: expenseError, refresh: refreshExpenses } = useOverviewRequest(() => JSON.stringify([expenseQuery.value, revision.value]), signal => providerFinanceApi.expenses(expenseQuery.value, signal), { scopeKey: selectedScope })
const { exporting, exportError, exportCsv } = useOverviewExport()
const loading = computed(() => costLoading.value || providerLoading.value || accountLoading.value || expenseLoading.value)
function expensesChanged() { if (expenseOffset.value) expenseOffset.value = 0; else void refreshExpenses() }
function selectExpensePage(offset: number) { if (expenseOffset.value === offset) void refreshExpenses(); else expenseOffset.value = offset }
function amount(value: string, currency: string) { return money({ value, currency, basis: 'provider_expense', status: 'known' }) }
function totalAmounts(key: keyof Pick<ProviderExpenseTotals, 'amount' | 'recharge_amount' | 'subscription_amount'>) {
if (!expenses.value) return [{ currency: '', text: '-' }]
if (!expenses.value.totals.length) return [{ currency: '', text: t('暂无记录', 'No records') }]
return expenses.value.totals.map(row => ({ currency: row.currency, text: `${amount(row[key], row.currency)} ${row.currency}` }))
}
async function loadForecast() {
const requestId = ++forecastRequestId
forecastLoading.value = true
try {
const data = await adminApi.getCostForecast(buildTimeRangeParams())
if (requestId !== forecastRequestId) return
forecast.value = data
} finally {
if (requestId === forecastRequestId) {
forecastLoading.value = false
}
}
function providerAmounts(id: string | null) {
if (!expenses.value || !id) return [{ currency: '', text: '-' }]
const amounts = expenses.value.providers.filter(item => item.provider_id === id)
return amounts.length ? amounts.map(item => ({ currency: item.currency, text: `${amount(item.amount, item.currency)} ${item.currency}` })) : [{ currency: '', text: t('未登记', 'Unrecorded') }]
}
async function loadSavings() {
const requestId = ++savingsRequestId
const data = await adminApi.getCostSavings(buildTimeRangeParams())
if (requestId !== savingsRequestId) return
costSavings.value = data
function providerType(id: string | null) {
const type = accounts.value?.items.find(account => account.provider_id === id)?.billing_type
return type === 'monthly_quota' ? t('周期套餐', 'Period plan') : type === 'free_tier' ? t('免费额度', 'Free tier') : type === 'pay_as_you_go' ? t('按量付费', 'Pay as you go') : ''
}
async function loadQuotaUsage() {
const requestId = ++quotaRequestId
quotaLoading.value = true
try {
const response = await adminApi.getQuotaUsage()
if (requestId !== quotaRequestId) return
quotaProviders.value = response.providers
} finally {
if (requestId === quotaRequestId) {
quotaLoading.value = false
}
}
}
async function loadProviderStats() {
const requestId = ++providerStatsRequestId
const stats = await usageApi.getUsageByProvider({
...buildTimeRangeParams(),
limit: 8
})
if (requestId !== providerStatsRequestId) return
providerStats.value = normalizeUsageProviderStats(stats)
}
async function loadApiKeyLeaderboard() {
const requestId = ++apiKeyLeaderboardRequestId
apiKeyLeaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardApiKeys({
...buildApiKeyLeaderboardTimeRangeParams(),
metric: apiKeyLeaderboardMetric.value,
order: 'desc',
limit: apiKeyLeaderboardPageSize.value,
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
include_inactive: false,
exclude_admin: false
})
if (requestId !== apiKeyLeaderboardRequestId) return
apiKeyLeaderboard.value = response.items
apiKeyLeaderboardTotal.value = response.total
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
apiKeyLeaderboardPage.value = 1
scheduleApiKeyLeaderboardLoad()
}
} finally {
if (requestId === apiKeyLeaderboardRequestId) {
apiKeyLeaderboardLoading.value = false
}
}
}
function buildApiKeyLeaderboardTimeRangeParams() {
return {
start_date: apiKeyLeaderboardTimeRange.value.start_date,
end_date: apiKeyLeaderboardTimeRange.value.end_date,
preset: apiKeyLeaderboardTimeRange.value.preset,
timezone: apiKeyLeaderboardTimeRange.value.timezone,
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
}
}
async function loadAll() {
if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([
loadForecast(),
loadSavings(),
loadQuotaUsage(),
loadProviderStats(),
loadApiKeyLeaderboard()
])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
if (hasPendingLoadAll) {
hasPendingLoadAll = false
void loadAll()
}
})
return loadAllPromise
}
function scheduleLoadAll() {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
}
loadAllDebounceTimer = setTimeout(() => {
loadAllDebounceTimer = null
void loadAll()
}, 120)
}
function scheduleApiKeyLeaderboardLoad() {
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
}
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
apiKeyLeaderboardDebounceTimer = null
void loadApiKeyLeaderboard()
}, 120)
}
function resetApiKeyLeaderboardPage() {
if (apiKeyLeaderboardPage.value === 1) {
return
}
apiKeyLeaderboardPage.value = 1
}
watch(timeRange, () => {
resetApiKeyLeaderboardPage()
scheduleLoadAll()
}, { deep: true })
watch(apiKeyLeaderboardMetric, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
})
watch(apiKeyLeaderboardTimeRange, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
}, { deep: true })
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
onMounted(() => {
void loadAll()
})
onUnmounted(() => {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
apiKeyLeaderboardDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
forecastRequestId += 1
savingsRequestId += 1
quotaRequestId += 1
providerStatsRequestId += 1
apiKeyLeaderboardRequestId += 1
})
const points = computed(() => costs.value?.data.timeseries || [])
const hasRequests = computed(() => points.value.some(point => point.request_count > 0))
const labels = computed(() => points.value.map(point => timestamp(point.bucket_start, range.value.timezone)))
const requestChart = computed(() => ({ labels: labels.value, datasets: [{ label: t('请求数', 'Requests'), data: points.value.map(point => point.request_count), borderColor: '#ce6545', backgroundColor: '#ce654518', fill: true, pointRadius: 1, borderWidth: 2, tension: 0.2 }] }))
const tokenChart = computed(() => ({ labels: labels.value, datasets: [{ label: t('输入 Token', 'Input tokens'), data: points.value.map(point => point.input_tokens), borderColor: '#0d9488', pointRadius: 1, borderWidth: 2, tension: 0.2 }, { label: t('输出 Token', 'Output tokens'), data: points.value.map(point => point.output_tokens), borderColor: '#d97706', pointRadius: 1, borderWidth: 2, tension: 0.2 }] }))
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
<template>
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<RouterLink
:to="link('/admin/user-stats')"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
>
<ArrowLeft class="h-3.5 w-3.5" />{{ t('用户分析', 'User analysis') }}
</RouterLink>
<OverviewToolbar
:title="data?.data.user.username || t('用户账目', 'User account')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="selectRange"
@refresh="toggleAutoRefresh"
>
<template #subtitle>
<p
v-if="data"
class="mt-1 break-words text-xs text-muted-foreground"
>
{{ data.data.user.email }} · {{ data.data.user.is_active ? t('已启用', 'Enabled') : t('已停用', 'Disabled') }}
</p>
</template>
</OverviewToolbar>
<OverviewStatus
:error="error"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance"
detail
/>
<p class="text-xs text-muted-foreground">
{{ t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current') }}
</p>
<UserPayments
:payments="data?.data.payments"
:timezone="range.timezone"
:loading="loading"
:limit="paymentLimit"
:offset="paymentOffset"
@page="value => paymentOffset = value"
@size="resizePayments"
/>
<ConsumptionTable
:user-id="userId"
:revision="revision"
/>
<UserReports
:user-id="userId"
:revision="revision"
/>
</main>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft } from 'lucide-vue-next'
import { overviewApi, type OverviewRange } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
import UserFinanceSummary from '@/features/overview/users/UserFinanceSummary.vue'
import UserPayments from '@/features/overview/users/UserPayments.vue'
import UserReports from '@/features/overview/users/UserReports.vue'
import ConsumptionTable from '@/features/overview/users/ConsumptionTable.vue'
import { useUserAnalysisRefresh } from '@/features/overview/users/useUserAnalysisRefresh'
import { useOverviewQuery } from '@/features/overview/query'
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewI18n } from '@/features/overview/i18n'
const { t } = useOverviewI18n()
const { route, range, relativePreset, refreshRange, setRange, link } = useOverviewQuery('today', { rolling: true })
const userId = computed(() => String(route.params.userId))
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
const paymentLimit = ref(25)
const paymentOffset = ref(0)
const scope = computed(() => JSON.stringify({ user: userId.value, ...(relativePreset.value ? { preset: relativePreset.value, timezone: range.value.timezone } : range.value) }))
watch(scope, () => { paymentOffset.value = 0 }, { flush: 'sync' })
const requestQuery = computed(() => ({ ...range.value, user_id: userId.value, payment_limit: paymentLimit.value, payment_offset: paymentOffset.value }))
const { data, loading, error } = useOverviewRequest(() => JSON.stringify([requestQuery.value, revision.value]), signal => overviewApi.user(userId.value, requestQuery.value, signal), { scopeKey: scope })
function selectRange(value: OverviewRange, preset?: string) { paymentOffset.value = 0; void setRange(value, preset) }
function resizePayments(value: number) { paymentLimit.value = value; paymentOffset.value = 0 }
</script>
+235 -383
View File
@@ -1,393 +1,245 @@
<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="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="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"
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<h3 class="text-sm font-semibold">
用户对比趋势
</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, 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 leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
interface UsageSummary {
total_requests: number
total_tokens: number
total_cost: number
error_rate: number
}
interface TimeSeriesItem {
date: string
total_cost: number
}
const userSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
let leaderboardRequestId = 0
let summaryRequestId = 0
let seriesRequestId = 0
let leaderboardLoadPromise: Promise<void> | null = null
let hasPendingLeaderboardLoad = false
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let userPanelsLoadPromise: Promise<void> | null = null
let hasPendingUserPanelsLoad = false
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
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() {
if (leaderboardLoadPromise) {
hasPendingLeaderboardLoad = true
return leaderboardLoadPromise
}
leaderboardLoadPromise = (async () => {
const requestId = ++leaderboardRequestId
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
} finally {
if (requestId === leaderboardRequestId) {
leaderboardLoading.value = false
}
}
})().finally(() => {
leaderboardLoadPromise = null
if (hasPendingLeaderboardLoad) {
hasPendingLeaderboardLoad = false
void loadLeaderboard()
}
})
return leaderboardLoadPromise
}
async function loadSummary() {
if (!selectedUserId.value) return
const requestId = ++summaryRequestId
summaryLoading.value = true
try {
const summary = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
if (requestId !== summaryRequestId) return
userSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
} finally {
if (requestId === summaryRequestId) {
summaryLoading.value = false
}
}
}
async function loadSeries() {
if (!selectedUserId.value) return
const requestId = ++seriesRequestId
seriesLoading.value = true
try {
const baseParams = {
...buildTimeRangeParams(),
user_id: selectedUserId.value
}
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
})
: Promise.resolve([])
const [primarySeries, compareSeries] = await Promise.all([
adminApi.getTimeSeries(baseParams),
comparePromise
])
if (requestId !== seriesRequestId) return
series.value = primarySeries
comparisonSeries.value = compareSeries
} finally {
if (requestId === seriesRequestId) {
seriesLoading.value = false
}
}
}
async function loadUserPanels() {
if (userPanelsLoadPromise) {
hasPendingUserPanelsLoad = true
return userPanelsLoadPromise
}
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
.then(() => undefined)
.finally(() => {
userPanelsLoadPromise = null
if (hasPendingUserPanelsLoad) {
hasPendingUserPanelsLoad = false
void loadUserPanels()
}
})
return userPanelsLoadPromise
}
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
}
]
import { computed, ref, watch } from 'vue'
import { Download, Search, ReceiptText } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { overviewApi } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
import UserFinanceSummary from '@/features/overview/users/UserFinanceSummary.vue'
import UserReports from '@/features/overview/users/UserReports.vue'
import UserAccountDrawer from '@/features/overview/users/UserAccountDrawer.vue'
import { useUserAnalysisRefresh } from '@/features/overview/users/useUserAnalysisRefresh'
import { useOverviewQuery } from '@/features/overview/query'
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const { t } = useOverviewI18n()
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
const search = ref(query.value.search || '')
const accountUser = ref<{ user_id: string; username: string } | null>(null)
const paginationUpdating = ref(false)
watch(() => query.value.search, value => { search.value = value || '' })
const requestQuery = computed(() => ({
...range.value,
search: query.value.search,
limit: Math.max(1, Math.min(100, query.value.limit || 25)),
offset: query.value.offset || 0,
sort: ['billable_amount', 'request_count', 'total_tokens'].includes(query.value.sort || '') ? query.value.sort : 'billable_amount',
order: query.value.order || 'desc',
}))
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
}
]
}))
function scheduleLeaderboardLoad() {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
}
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
void loadLeaderboard()
}, 120)
const scope = computed(() => JSON.stringify({ ...requestQuery.value, ...(relativePreset.value ? { from: undefined, to: undefined, preset: relativePreset.value } : {}) }))
const { data, loading, error } = useOverviewRequest(() => JSON.stringify([requestQuery.value, revision.value]), signal => overviewApi.users(requestQuery.value, signal), { scopeKey: scope })
const { exporting, exportError, exportCsv } = useOverviewExport()
const columns = computed(() => [{ key: 'billable_amount', label: t('消费', 'Consumption') }, { key: 'request_count', label: t('请求数', 'Requests') }, { key: 'total_tokens', label: 'Tokens' }])
function sort({ key, direction }: { key: string; direction: 'asc' | 'desc' }) {
void patch({ sort: key, order: direction, offset: undefined })
}
function scheduleUserPanelsLoad() {
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
}
userPanelsDebounceTimer = setTimeout(() => {
userPanelsDebounceTimer = null
void loadUserPanels()
}, 120)
async function changePage(page: number) {
if (loading.value || paginationUpdating.value) return
paginationUpdating.value = true
try { await patch({ offset: (page - 1) * requestQuery.value.limit }) } finally { paginationUpdating.value = false }
}
async function changePageSize(limit: number) {
if (loading.value || paginationUpdating.value) return
paginationUpdating.value = true
try { await patch({ limit, offset: undefined }) } finally { paginationUpdating.value = false }
}
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
onMounted(async () => {
await Promise.all([
loadLeaderboard(),
loadUsers()
])
})
onUnmounted(() => {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = null
}
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
userPanelsDebounceTimer = null
}
hasPendingLeaderboardLoad = false
hasPendingUserPanelsLoad = false
leaderboardLoadPromise = null
userPanelsLoadPromise = null
leaderboardRequestId += 1
summaryRequestId += 1
seriesRequestId += 1
})
</script>
+2 -16
View File
@@ -250,7 +250,7 @@ import { parseApiError } from '@/utils/errorParser'
import {
entitlementsWillReplaceExisting,
isPlanEntitlementReplacementCandidate,
usagePolicyEntitlementLabels,
billingEntitlementLabels,
} from '@/utils/billingEntitlements'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { log } from '@/utils/logger'
@@ -563,21 +563,7 @@ function formatPlanDuration(plan: BillingPlan): string {
}
function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
return (items || []).flatMap((item) => {
if (item.type === 'wallet_credit') {
return `${legacyT('附赠余额')} $${Number(item.amount_usd || 0).toFixed(2)}`
}
if (item.type === 'daily_quota') {
return `${legacyT('每日额度')} $${Number(item.daily_quota_usd || 0).toFixed(2)}`
}
if (item.type === 'membership_group') {
return legacyT('会员权益')
}
if (item.type === 'usage_policy') {
return usagePolicyEntitlementLabels(item)
}
return []
})
return billingEntitlementLabels(items, legacyT)
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
@@ -1,60 +1,50 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, onMounted, onUnmounted } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AdminOperationsDashboard from '../AdminOperationsDashboard.vue'
const source = readFileSync(
resolve(process.cwd(), 'src/views/admin/AdminOperationsDashboard.vue'),
'utf8',
)
describe('AdminOperationsDashboard refresh behavior', () => {
it('refreshes on entry and supports toggling automatic refresh', () => {
expect(source).not.toContain('手动刷新')
expect(source).toContain('@click="toggleAutoRefresh"')
expect(source).toContain("autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'")
expect(source).toContain('onMounted(() => {')
expect(source).toContain('void refreshAll()')
expect(source).toContain('const AUTO_REFRESH_INTERVAL = 10_000')
expect(source).toContain('autoRefreshTimer = setInterval(')
expect(source).toContain('clearInterval(autoRefreshTimer)')
const rangeWatcher = source
.split('watch(timeRange, () => {')[1]
?.split('}, { deep: true })')[0]
expect(rangeWatcher).toBeTruthy()
expect(rangeWatcher).toContain('if (autoRefresh.value)')
expect(rangeWatcher).toContain('refreshAll()')
vi.mock('@/features/overview/components/OverviewToolbar.vue', () => ({ default: { render: () => null } }))
const lifecycle = vi.hoisted(() => ({ mounted: [] as string[], unmounted: [] as string[] }))
function view(name: string) {
return defineComponent({
setup(_, { slots }) {
onMounted(() => lifecycle.mounted.push(name))
onUnmounted(() => lifecycle.unmounted.push(name))
return () => h('div', { 'data-view': name }, [name, slots.default?.({ snapshot: { node_id: 'live-node' } }), slots.details?.(), slots.diagnostics?.()])
},
})
}
vi.mock('@/features/overview/operations/RuntimeView.vue', () => ({ default: view('runtime') }))
vi.mock('@/features/overview/operations/PerformanceView.vue', () => ({ default: view('performance') }))
vi.mock('@/features/overview/operations/LiveMetrics.vue', () => ({ default: view('details') }))
vi.mock('@/features/overview/operations/RuntimeFocus.vue', () => ({ default: { render: () => null } }))
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
it('does not request the heavyweight system-status fallback', () => {
expect(source).not.toContain('monitoringApi.getSystemStatus()')
})
describe('operations view lifecycle', () => {
afterEach(() => vi.useRealTimers())
it('forces fresh analytics and renders each result as soon as it settles', () => {
expect(source).toContain('adminApi.getTimeSeries(params, { skipCache: true })')
expect(source).toContain('adminApi.getPercentiles(params, { skipCache: true })')
expect(source).toContain('}, { skipCache: true })')
expect(source).toContain('adminApi.getErrorDistribution(params, { skipCache: true })')
expect(source).toContain('include_timeline: false')
const progressiveSetup = source.split('const results = await Promise.allSettled([')[0]
expect(progressiveSetup).toContain('timeSeries.value = value')
expect(progressiveSetup).toContain('percentiles.value = value')
expect(progressiveSetup).toContain('providerPerformance.value = value')
expect(progressiveSetup).toContain('errorDistribution.value = value.distribution')
expect(progressiveSetup).toContain('gatewayMetrics.value = value')
})
it('reuses analytics responses instead of requesting a duplicate usage summary', () => {
expect(source).not.toContain('usageApi.getUsageStats(')
expect(source).not.toContain('summaryStats')
expect(source).toContain('function seriesTokenTotal(')
expect(source).toContain('numeric(item.input_tokens)')
expect(source).toContain('numeric(item.output_tokens)')
expect(source).toContain('numeric(item.cache_creation_tokens)')
expect(source).toContain('numeric(item.cache_read_tokens)')
expect(source).toContain('timeSeries.value.map(seriesTokenTotal)')
expect(source).toContain('label="已分类错误"')
expect(source).toContain('errorDistribution.value.reduce(')
it('keeps every section mounted regardless of legacy view query values or browser history', async () => {
lifecycle.mounted.length = 0
lifecycle.unmounted.length = 0
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/operations', component: AdminOperationsDashboard }] })
await router.push('/admin/operations?view=performance&from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
const root = document.createElement('div')
const app = createApp(AdminOperationsDashboard).use(router)
app.mount(root)
expect([...lifecycle.mounted].sort()).toEqual(['details', 'details', 'performance', 'runtime'])
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('[role="tab"]')).toBeNull()
await router.push({ query: { ...router.currentRoute.value.query, view: 'resources' } })
await settle()
expect(lifecycle.mounted).toHaveLength(4)
expect(lifecycle.unmounted).toEqual([])
expect(router.currentRoute.value.query.from).toBe('2026-09-10T23:30:00Z')
router.back()
await settle()
for (const name of ['runtime', 'performance']) expect(root.querySelector(`[data-view="${name}"]`)).not.toBeNull()
expect(root.querySelectorAll('[data-view="details"]')).toHaveLength(2)
expect(lifecycle.unmounted).toEqual([])
app.unmount()
expect([...lifecycle.unmounted].sort()).toEqual(['details', 'details', 'performance', 'runtime'])
})
})
@@ -0,0 +1,319 @@
import { createApp, nextTick, type App } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AdminOperationsDashboard from '../AdminOperationsDashboard.vue'
import { setI18nLocale } from '@/i18n'
const api = vi.hoisted(() => ({ summary: vi.fn(), timeseries: vi.fn(), live: vi.fn(), performance: vi.fn(), resources: vi.fn() }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/components/common/TimeRangePicker.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
props: {
modelValue: { type: Object, required: true },
presetOnly: Boolean,
showGranularity: Boolean,
presetOptions: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup: (props, { emit }) => () => h('select', {
'aria-label': '时间范围',
'data-preset-only': String(props.presetOnly),
'data-show-granularity': String(props.showGranularity),
value: props.modelValue.preset || 'custom',
onChange: (event: Event) => emit('update:modelValue', { preset: (event.target as HTMLSelectElement).value, granularity: 'day' }),
}, [
h('option', { value: 'custom', disabled: true }, '已选时段'),
...props.presetOptions.map(preset => h('option', { value: String(preset) }, String(preset))),
]),
}) }
})
vi.mock('@/features/overview/components/OverviewMetrics.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { metrics: { type: Object, required: true } }, setup: props => () => h('p', { 'data-request-count': '' }, String(props.metrics.request_count)) }) }
})
vi.mock('@/features/overview/components/OverviewTrend.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/operations/LiveMetrics.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { snapshot: { type: Object, required: true }, resources: Boolean }, setup: props => () => h('p', { 'data-live-node': props.resources ? 'resources' : 'runtime' }, props.snapshot.node_id) }) }
})
vi.mock('@/features/overview/components/OverviewStatus.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ props: { error: String }, setup: props => () => props.error ? h('p', { role: 'alert' }, props.error) : null }) }
})
const mounted: App[] = []
async function settle() { await vi.advanceTimersByTimeAsync(0); await nextTick() }
async function mount(url = '/admin/operations?timezone=UTC') {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/operations', component: AdminOperationsDashboard }, { path: '/admin/usage', component: { render: () => null } }] })
await router.push(url)
const root = document.createElement('div')
const app = createApp(AdminOperationsDashboard).use(router)
app.mount(root)
mounted.push(app)
await settle()
return { root, router, app }
}
function change(root: HTMLElement, selector: string, value: string) {
const input = root.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
input.value = value
input.dispatchEvent(new Event(input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
}
function liveNodes(root: HTMLElement) { return Array.from(root.querySelectorAll('[data-live-node]'), node => node.textContent) }
function enableAutoRefresh(root: HTMLElement) {
const button = root.querySelector<HTMLButtonElement>('button[aria-label="开启自动刷新(每 10 秒)"]')!
expect(button.getAttribute('aria-pressed')).toBe('false')
button.click()
}
function disableAutoRefresh(root: HTMLElement) {
const button = root.querySelector<HTMLButtonElement>('button[aria-label="关闭自动刷新(每 10 秒)"]')!
expect(button.getAttribute('aria-pressed')).toBe('true')
button.click()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-18T08:00:00Z'))
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
setI18nLocale('zh-CN')
vi.clearAllMocks()
api.summary.mockResolvedValue({ data: { request_count: 42 } })
api.timeseries.mockResolvedValue({ data: { items: [] } })
api.live.mockResolvedValue({ data: { node_id: 'live-node' } })
api.performance.mockResolvedValue({ data: { summary: { request_count: 42 }, timeseries: [], providers: null, errors: [] } })
api.resources.mockResolvedValue({ data: { node_id: 'resource-node' } })
})
afterEach(() => { for (const app of mounted.splice(0)) app.unmount(); vi.restoreAllMocks(); vi.useRealTimers() })
describe('operations refresh behavior', () => {
it('passes grouped live load into both analysis tables and updates it using the existing two refresh queries', async () => {
const executionActivity = {
observed_at: '2026-09-18T08:00:00Z', observed_from: '2026-09-18T07:59:00Z',
window_seconds: 60, observed_window_seconds: 60, scope: { kind: 'node' }, coverage: 'complete',
providers: [{ provider_id: 'provider-a', provider: 'Provider A', requests_per_minute: 12, current_concurrency: 2 }],
models: [{ model: 'model-a', requests_per_minute: 10, current_concurrency: 1 }],
}
api.live.mockResolvedValue({ data: { node_id: 'live-node', execution_activity: executionActivity } })
api.performance.mockResolvedValue({ data: {
summary: { request_count: 42 }, timeseries: [], providers: { providers: [] }, models: [], errors: [],
} })
const { root } = await mount()
const values = (dimension: string) => {
const row = root.querySelector(`[data-performance-analysis="${dimension}"] [data-analysis-row]`)!
return [row.querySelector('[data-analysis-concurrency]')?.textContent?.trim(), row.querySelector('[data-analysis-rpm]')?.textContent?.trim()]
}
expect(values('provider')).toEqual(['2', '12'])
expect(values('model')).toEqual(['1', '10'])
api.live.mockResolvedValue({ data: { node_id: 'live-node', execution_activity: {
...executionActivity,
providers: [{ ...executionActivity.providers[0], requests_per_minute: 21, current_concurrency: 4 }],
models: [{ ...executionActivity.models[0], requests_per_minute: 18, current_concurrency: 3 }],
} } })
enableAutoRefresh(root)
await settle()
expect(values('provider')).toEqual(['4', '21'])
expect(values('model')).toEqual(['3', '18'])
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
for (const method of [api.resources, api.summary, api.timeseries]) expect(method).not.toHaveBeenCalled()
})
it('starts disabled, refreshes immediately when enabled and polls every ten seconds from that click without duplicate queries', async () => {
const { root } = await mount('/admin/operations?view=resources&timezone=UTC')
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('[role="tab"]')).toBeNull()
expect(root.querySelector('input[type="checkbox"]')).toBeNull()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(root.querySelector('[data-live-node="runtime"]')?.textContent).toBe('live-node')
expect(root.querySelector('[data-live-node="resources"]')?.textContent).toBe('live-node')
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(12_345)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
enableAutoRefresh(root)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
expect(root.querySelector('button[aria-label="关闭自动刷新(每 10 秒)"]')?.getAttribute('aria-pressed')).toBe('true')
await vi.advanceTimersByTimeAsync(9_999)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(3)
expect(api.resources).not.toHaveBeenCalled()
expect(api.summary).not.toHaveBeenCalled()
expect(api.timeseries).not.toHaveBeenCalled()
})
it('stops polling without a request when disabled and does not accumulate timers when toggled repeatedly', async () => {
const interval = vi.spyOn(globalThis, 'setInterval')
const clear = vi.spyOn(globalThis, 'clearInterval')
const { root } = await mount()
enableAutoRefresh(root)
await settle()
const firstTimer = interval.mock.results[interval.mock.results.length - 1]?.value
disableAutoRefresh(root)
await settle()
expect(clear).toHaveBeenCalledWith(firstTimer)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(20_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
for (let index = 0; index < 3; index += 1) {
enableAutoRefresh(root)
await settle()
await vi.advanceTimersByTimeAsync(1_000)
disableAutoRefresh(root)
await settle()
}
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(5)
enableAutoRefresh(root)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(9_999)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(1)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(7)
})
it('clears the active interval when leaving the page', async () => {
const interval = vi.spyOn(globalThis, 'setInterval')
const clear = vi.spyOn(globalThis, 'clearInterval')
const { root, app } = await mount()
enableAutoRefresh(root)
await settle()
const activeTimer = interval.mock.results[interval.mock.results.length - 1]?.value
app.unmount()
mounted.splice(mounted.indexOf(app), 1)
expect(clear).toHaveBeenCalledWith(activeTimer)
await vi.advanceTimersByTimeAsync(30_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
})
it('skips polling while the page is hidden and resumes at the next visible interval', async () => {
const { root } = await mount()
enableAutoRefresh(root)
await settle()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
await vi.advanceTimersByTimeAsync(20_000)
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(2)
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
await vi.advanceTimersByTimeAsync(10_000)
await settle()
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(3)
})
it('loads performance even when the initial live snapshot fails', async () => {
api.live.mockRejectedValue(new Error('live unavailable'))
const { root } = await mount()
expect(root.textContent).toContain('live unavailable')
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual([])
for (const method of [api.live, api.performance]) expect(method).toHaveBeenCalledTimes(1)
expect(api.resources).not.toHaveBeenCalled()
})
it('advances relative ranges when refreshing and changing the time preset while ignoring legacy analysis filters', async () => {
const { root, router } = await mount()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-18T00:00:00.000Z', to: '2026-09-18T08:00:00.000Z' })
vi.setSystemTime(new Date('2026-09-18T08:30:00Z'))
enableAutoRefresh(root)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T08:30:00.000Z')
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T08:30:10.000Z')
const callsBeforeLegacyFilters = api.performance.mock.calls.length
await router.replace({ query: {
...router.currentRoute.value.query,
provider_id: 'legacy-provider', model: 'legacy-model', is_stream: 'false',
api_format: 'openai', slow_threshold_ms: '7500',
} })
await settle()
expect(api.performance).toHaveBeenCalledTimes(callsBeforeLegacyFilters)
expect(root.querySelector('form, input')).toBeNull()
vi.setSystemTime(new Date('2026-09-18T08:59:50Z'))
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0].to).toBe('2026-09-18T09:00:00.000Z')
for (const key of ['provider_id', 'model', 'is_stream', 'api_format', 'slow_threshold_ms']) {
expect(api.performance.mock.lastCall?.[0][key]).toBeUndefined()
}
change(root, 'select[aria-label="时间范围"]', 'last1hour')
await settle()
vi.setSystemTime(new Date('2026-09-18T09:29:50Z'))
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-18T08:30:00.000Z', to: '2026-09-18T09:30:00.000Z' })
expect(router.currentRoute.value.query.relative_preset).toBe('last1hour')
})
it('keeps explicit historical ranges fixed when enabling refresh and polling', async () => {
const { root } = await mount('/admin/operations?from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
vi.setSystemTime(new Date('2026-09-18T09:00:00Z'))
enableAutoRefresh(root)
await settle()
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.calls.length).toBeGreaterThan(1)
for (const [query] of api.performance.mock.calls) expect(query).toMatchObject({ from: '2026-09-10T23:30:00.000Z', to: '2026-09-11T00:30:00.000Z' })
})
it('uses the shared preset picker without exact time, custom date or timezone controls', async () => {
const { root } = await mount()
const picker = root.querySelector<HTMLSelectElement>('select[aria-label="时间范围"]')!
expect(picker.dataset.presetOnly).toBe('true')
expect(picker.dataset.showGranularity).toBe('false')
expect(Array.from(picker.options).filter(option => !option.disabled).map(option => option.value)).toEqual(['last1hour', 'today', 'last24hours', 'last7days', 'last30days'])
expect(root.querySelector('button[aria-label="精确时间"]')).toBeNull()
expect(root.querySelector('input[type="datetime-local"], input[type="date"]')).toBeNull()
expect(root.querySelector('input[list="overview-timezones"]')).toBeNull()
})
it('restores rolling refresh when choosing a preset from an explicit historical range', async () => {
const { root, router } = await mount('/admin/operations?from=2026-09-10T23:30:00Z&to=2026-09-11T00:30:00Z&timezone=UTC')
expect(root.querySelector<HTMLSelectElement>('select[aria-label="时间范围"]')!.value).toBe('custom')
expect(router.currentRoute.value.query.relative_preset).toBeUndefined()
change(root, 'select[aria-label="时间范围"]', 'last24hours')
await settle()
expect(router.currentRoute.value.query.relative_preset).toBe('last24hours')
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T08:00:00.000Z', to: '2026-09-18T08:00:00.000Z' })
vi.setSystemTime(new Date('2026-09-18T09:00:00Z'))
enableAutoRefresh(root)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T09:00:00.000Z', to: '2026-09-18T09:00:00.000Z' })
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(api.performance.mock.lastCall?.[0]).toMatchObject({ from: '2026-09-17T09:00:10.000Z', to: '2026-09-18T09:00:10.000Z' })
})
it('retains data on a failed refresh and ignores legacy filters but clears performance results for a different time preset', async () => {
const { root, router } = await mount()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
api.live.mockRejectedValue(new Error('live unavailable'))
api.performance.mockRejectedValue(new Error('performance unavailable'))
vi.setSystemTime(new Date('2026-09-18T08:30:00Z'))
enableAutoRefresh(root)
await settle()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
for (const message of ['live unavailable', 'performance unavailable']) expect(root.textContent).toContain(message)
await router.replace({ query: { ...router.currentRoute.value.query, model: 'legacy-model' } })
await settle()
expect(root.querySelector('[data-request-count]')?.textContent).toBe('42')
expect(api.performance).toHaveBeenCalledTimes(2)
change(root, 'select[aria-label="时间范围"]', 'last1hour')
await settle()
expect(root.querySelector('[data-request-count]')).toBeNull()
expect(api.performance).toHaveBeenCalledTimes(3)
expect(liveNodes(root)).toEqual(['live-node', 'live-node'])
expect(root.textContent).toContain('performance unavailable')
expect(api.live).toHaveBeenCalledTimes(2)
expect(api.resources).not.toHaveBeenCalled()
})
})
@@ -1,41 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/views/admin/PerformanceAnalysis.vue'),
'utf8',
)
describe('PerformanceAnalysis progressive live data', () => {
it('commits each live response before the full request group settles', () => {
const liveDataBlock = source
.split('async function loadLiveData')[1]
?.split('const errorTrendChartData')[0]
expect(liveDataBlock).toBeTruthy()
expect(liveDataBlock).toContain('function commitIfCurrent<T>')
expect(liveDataBlock).toContain('if (requestId !== liveRequestId) return')
expect(liveDataBlock).toContain('systemStatus.value = value')
expect(liveDataBlock).toContain('resilienceStatus.value = value')
expect(liveDataBlock).toContain('circuitHistory.value = value.items')
expect(liveDataBlock).toContain('gatewayMetrics.value = value')
expect(liveDataBlock).toContain('liveReady.value = true')
expect(liveDataBlock).not.toContain('systemStatus.value = systemResult.value')
expect(liveDataBlock).not.toContain('resilienceStatus.value = resilienceResult.value')
expect(liveDataBlock).not.toContain('gatewayMetrics.value = metricsResult.value')
})
it('keeps group completion for loading state and error aggregation', () => {
const liveDataBlock = source
.split('async function loadLiveData')[1]
?.split('const errorTrendChartData')[0]
expect(liveDataBlock).toContain('await Promise.allSettled([')
expect(liveDataBlock).toContain("failedScopes.push('系统状态')")
expect(liveDataBlock).toContain("failedScopes.push('韧性状态')")
expect(liveDataBlock).toContain("failedScopes.push('熔断历史')")
expect(liveDataBlock).toContain("failedScopes.push('网关指标')")
expect(liveDataBlock).toContain('liveLoading.value = false')
expect(liveDataBlock).toContain('liveRefreshing.value = false')
})
})
@@ -3,7 +3,7 @@ import {
buildProviderPerformanceChartData,
formatDurationMs,
formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers'
} from '@/features/overview/operations/performanceHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
describe('performanceAnalysisHelpers', () => {
@@ -1,81 +0,0 @@
import type { ChartData } from 'chart.js'
import type {
ProviderPerformanceItem,
ProviderPerformanceTimelineItem,
} from '@/api/admin'
export type ProviderPerformanceMetricKey = 'avg_output_tps' | 'avg_first_byte_time_ms'
const PROVIDER_CHART_COLORS = [
'rgb(59, 130, 246)',
'rgb(16, 185, 129)',
'rgb(234, 179, 8)',
'rgb(239, 68, 68)',
'rgb(139, 92, 246)',
'rgb(14, 165, 233)',
'rgb(249, 115, 22)',
'rgb(20, 184, 166)',
]
export function formatProviderPerformanceMetric(
value: number | null | undefined,
suffix = '',
decimals = 2
): string {
if (value == null || Number.isNaN(value)) {
return '-'
}
if (suffix === 'ms') {
return formatDurationMs(value, decimals)
}
return `${value.toFixed(decimals)}${suffix}`
}
export function formatDurationMs(
value: number | null | undefined,
msDecimals = 0,
secondsDecimals = 2
): string {
if (value == null || Number.isNaN(value)) {
return '-'
}
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(secondsDecimals)}s`
}
return `${value.toFixed(msDecimals)}ms`
}
export function buildProviderPerformanceChartData(
timeline: ProviderPerformanceTimelineItem[],
metric: ProviderPerformanceMetricKey,
providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = []
): ChartData<'line'> {
const labels = Array.from(new Set(timeline.map(item => item.date)))
const providerMap = new Map<string, string>()
for (const provider of providers) {
providerMap.set(provider.provider_id, provider.provider)
}
for (const item of timeline) {
if (!providerMap.has(item.provider_id)) {
providerMap.set(item.provider_id, item.provider)
}
}
return {
labels,
datasets: Array.from(providerMap.entries()).map(([providerId, provider], index) => {
const byDate = new Map(
timeline
.filter(item => item.provider_id === providerId)
.map(item => [item.date, item[metric]] as const)
)
return {
label: provider,
data: labels.map(label => byDate.get(label) ?? null),
borderColor: PROVIDER_CHART_COLORS[index % PROVIDER_CHART_COLORS.length],
tension: 0.25,
pointRadius: 2,
}
}),
}
}
+32
View File
@@ -0,0 +1,32 @@
<template>
<main class="min-h-screen bg-background text-foreground">
<header class="border-b border-border">
<div class="mx-auto flex max-w-6xl items-center justify-between px-5 py-4">
<RouterLink
to="/"
class="flex items-center gap-3 font-semibold"
>
<HeaderLogo size="h-8 w-8" />{{ siteName }}
</RouterLink>
<RouterLink
to="/"
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="h-4 w-4" />首页
</RouterLink>
</div>
</header>
<div class="mx-auto max-w-6xl px-5 py-7">
<HealthMonitorView public-page />
</div>
</main>
</template>
<script setup lang="ts">
import { ArrowLeft } from 'lucide-vue-next'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { useSiteInfo } from '@/composables/useSiteInfo'
import HealthMonitorView from '@/features/health-monitor/HealthMonitorView.vue'
const { siteName } = useSiteInfo()
</script>
File diff suppressed because it is too large Load Diff
+176 -17
View File
@@ -6,6 +6,7 @@
defer
>
<button
v-if="!hasScopedRecordFilters"
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="statsExpanded ? '收起用量分析' : '展开用量分析'"
@click="statsExpanded = !statsExpanded"
@@ -23,14 +24,17 @@
<!-- 用量分析面板(可折叠) -->
<div
v-if="statsExpanded"
v-if="statsExpanded && !hasScopedRecordFilters"
class="space-y-4"
>
<!-- 活跃度热图 + 请求间隔时间线 -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div
v-if="!isAdminPage"
class="grid grid-cols-1 xl:grid-cols-2 gap-4"
>
<ActivityHeatmapCard
:data="activityHeatmapData"
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
title="我的活跃天数"
:is-loading="isLoadingHeatmap"
:has-error="heatmapError"
/>
@@ -43,6 +47,14 @@
</div>
<!-- 分析统计 -->
<div
v-if="isAdminPage"
class="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground"
>
<h2 class="text-sm font-medium text-foreground">
{{ overviewT('全站时段统计', 'Site-wide period statistics') }}
</h2><span>{{ analyticsRangeLabel }}</span>
</div>
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
<div
v-if="isAdminPage"
@@ -77,6 +89,41 @@
</div>
</div>
<div
v-if="hasScopedRecordFilters"
class="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground"
>
<span>{{ overviewT('请求筛选结果', 'Filtered requests') }}</span>
<Button
variant="ghost"
size="sm"
class="h-8"
@click="clearRecordFilters"
>
<X class="mr-1 h-3 w-3" />{{ overviewT('清除筛选', 'Clear filters') }}
</Button>
</div>
<div
v-if="isAdminPage && deepLinkFilters.length"
class="flex flex-wrap gap-2 text-xs"
>
<span
v-for="filter in deepLinkFilters"
:key="filter.key"
class="inline-flex max-w-full items-center gap-1 rounded border px-2 py-1"
>
<span class="min-w-0 break-all">{{ filter.label }}: {{ filter.value }}</span>
<button
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center hover:text-primary"
:aria-label="`${overviewT('清除', 'Clear')} ${filter.label}`"
:title="`${overviewT('清除', 'Clear')} ${filter.label}`"
@click="clearDeepLinkFilter(filter.key)"
><X class="h-3 w-3" /></button>
</span>
</div>
<!-- 使用记录 -->
<UsageRecordsTable
:records="displayRecords"
@@ -124,7 +171,7 @@
:is-open="detailModalOpen"
:request-id="selectedRequestId"
:summary-record="selectedRequestSummary"
@close="detailModalOpen = false"
@close="closeRequestDetail"
@request-state="handleDetailRequestState"
/>
</div>
@@ -132,7 +179,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage'
@@ -140,7 +187,7 @@ import type { ImageProgress } from '@/api/requestTrace'
import { usersApi } from '@/api/users'
import { meApi } from '@/api/me'
import { dashboardApi } from '@/api/dashboard'
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
import { PanelTopClose, PanelTopOpen, X } from 'lucide-vue-next'
import {
UsageModelTable,
UsageProviderTable,
@@ -180,8 +227,14 @@ import type { UserOption } from '@/features/usage/components/UsageRecordsTable.v
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
import { queryString, rangeFromQuery, presetRange } from '@/features/overview/query'
import { useOverviewI18n } from '@/features/overview/i18n'
import { attributionLabel, timestamp } from '@/features/overview/format'
import { Button } from '@/components/ui'
const route = useRoute()
const router = useRouter()
const { t: overviewT } = useOverviewI18n()
const { warning } = useToast()
const authStore = useAuthStore()
@@ -248,6 +301,43 @@ const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
const filterClientFamily = ref('__all__')
const filterProviderId = ref('')
const filterApiKeyId = ref('')
const filterRequestId = ref('')
const filterAttribution = ref('')
const filterEndpointKind = ref('')
const filterRequestType = ref('')
const filterIsStream = ref<boolean | undefined>()
const filterFormatConversion = ref<boolean | undefined>()
const filterSlowThreshold = ref<number | undefined>()
const hasScopedRecordFilters = computed(() => isAdminPage.value && (
filterSearch.value.trim() !== '' || [filterUser.value, filterModel.value, filterProvider.value, filterApiFormat.value, filterStatus.value, filterClientFamily.value].some(value => value !== '__all__') ||
!!filterProviderId.value || !!filterApiKeyId.value || !!filterRequestId.value || !!filterAttribution.value || !!filterEndpointKind.value || !!filterRequestType.value || filterIsStream.value !== undefined || filterFormatConversion.value !== undefined || filterSlowThreshold.value !== undefined || hideUnknownRecords.value
))
const analyticsRangeLabel = computed(() => {
const range = timeRange.value
if (range.from && range.to) return `${timestamp(range.from, range.timezone)} - ${timestamp(range.to, range.timezone)} · ${range.timezone || 'UTC'}`
if (range.start_date && range.end_date) return `${range.start_date} - ${range.end_date} · ${range.timezone || 'UTC'}`
const presets: Record<string, string> = { today: overviewT('今天', 'Today'), yesterday: overviewT('昨天', 'Yesterday'), last7days: overviewT('最近 7 天', 'Last 7 days'), last30days: overviewT('最近 30 天', 'Last 30 days'), last90days: overviewT('最近 90 天', 'Last 90 days') }
return `${presets[range.preset || ''] || overviewT('全部时间', 'All time')} · ${range.timezone || 'UTC'}`
})
function clearRecordFilters() {
const fields = ['search', 'user_id', 'model', 'provider', 'api_format', 'status', 'client_family', 'provider_id', 'api_key_id', 'request_id', 'attribution_kind', 'endpoint_kind', 'request_type', 'is_stream', 'has_format_conversion', 'slow_threshold_ms', 'detail_id', 'page']
hideUnknownRecords.value = false
void router.replace({ query: { ...route.query, ...Object.fromEntries(fields.map(key => [key, undefined])) } })
}
const deepLinkFilters = computed(() => [
{ key: 'provider_id', label: overviewT('提供商 ID', 'Provider ID'), value: filterProviderId.value },
{ key: 'api_key_id', label: overviewT('凭证 ID', 'Credential ID'), value: filterApiKeyId.value },
{ key: 'request_id', label: overviewT('请求 ID', 'Request ID'), value: filterRequestId.value },
{ key: 'attribution_kind', label: overviewT('归属', 'Attribution'), value: filterAttribution.value ? attributionLabel(filterAttribution.value) : '' },
{ key: 'endpoint_kind', label: overviewT('端点类型', 'Endpoint kind'), value: filterEndpointKind.value },
{ key: 'request_type', label: overviewT('请求类型', 'Request type'), value: filterRequestType.value },
{ key: 'is_stream', label: overviewT('流式', 'Streaming'), value: filterIsStream.value === undefined ? '' : filterIsStream.value ? overviewT('是', 'Yes') : overviewT('否', 'No') },
{ key: 'has_format_conversion', label: overviewT('格式转换', 'Format conversion'), value: filterFormatConversion.value === undefined ? '' : filterFormatConversion.value ? overviewT('是', 'Yes') : overviewT('否', 'No') },
{ key: 'slow_threshold_ms', label: overviewT('响应时间', 'Response time'), value: filterSlowThreshold.value ? `>= ${filterSlowThreshold.value} ms` : '' },
].filter(filter => filter.value !== ''))
function clearDeepLinkFilter(key: string) { void router.replace({ query: { ...route.query, [key]: undefined, page: undefined } }) }
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
@@ -272,7 +362,7 @@ const isLoadingHeatmap = ref(false)
const heatmapError = ref(false)
const intervalTimelineHours = computed(() => getIntervalTimelineHours(timeRange.value))
const intervalTimelineTitle = computed(() => {
const baseTitle = isAdminPage.value ? '请求间隔时间线' : '我的请求间隔'
const baseTitle = isAdminPage.value ? overviewT('全站最近请求间隔', 'Site-wide recent request intervals') : '我的请求间隔'
return `${baseTitle}(${formatIntervalTimelineWindow(intervalTimelineHours.value)})`
})
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
@@ -285,11 +375,7 @@ async function loadHeatmapData() {
isLoadingHeatmap.value = true
heatmapError.value = false
try {
if (isAdminPage.value) {
activityHeatmapData.value = await usageApi.getActivityHeatmap()
} else {
activityHeatmapData.value = await meApi.getActivityHeatmap()
}
activityHeatmapData.value = await meApi.getActivityHeatmap()
} catch (error) {
log.error('加载热力图数据失败:', error)
heatmapError.value = true
@@ -899,15 +985,73 @@ const selectedRequestSummary = computed(() => (
currentRecords.value.find(record => record.id === selectedRequestId.value) ?? null
))
function currentRouteState() {
return JSON.stringify([timeRange.value, currentPage.value, pageSize.value, filterSearch.value, filterUser.value, filterModel.value, filterProvider.value, filterApiFormat.value, filterStatus.value, filterClientFamily.value, filterProviderId.value, filterApiKeyId.value, filterRequestId.value, filterAttribution.value, filterEndpointKind.value, filterRequestType.value, filterIsStream.value, filterFormatConversion.value, filterSlowThreshold.value])
}
function applyUsageRoute() {
const query = route.query ?? {}
const before = currentRouteState()
const from = queryString(query, 'from')
const to = queryString(query, 'to')
if (from && to && Number.isFinite(Date.parse(from)) && Date.parse(from) < Date.parse(to)) {
timeRange.value = { ...rangeFromQuery(query, presetRange('today')) }
} else if (queryString(query, 'start_date') || queryString(query, 'preset')) {
timeRange.value = queryString(query, 'preset')
? getDateRangeFromPeriod(queryString(query, 'preset') as Parameters<typeof getDateRangeFromPeriod>[0])
: { start_date: queryString(query, 'start_date'), end_date: queryString(query, 'end_date'), timezone: queryString(query, 'timezone') || undefined }
}
filterSearch.value = queryString(query, 'search')
filterUser.value = queryString(query, 'user_id') || '__all__'
filterModel.value = queryString(query, 'model') || '__all__'
filterProvider.value = queryString(query, 'provider') || '__all__'
filterApiFormat.value = queryString(query, 'api_format') || '__all__'
const status = queryString(query, 'status')
filterStatus.value = (status === 'success' ? 'completed' : status || '__all__') as FilterStatusValue
filterClientFamily.value = queryString(query, 'client_family') || '__all__'
filterProviderId.value = queryString(query, 'provider_id')
filterApiKeyId.value = queryString(query, 'api_key_id')
filterRequestId.value = queryString(query, 'request_id')
filterAttribution.value = queryString(query, 'attribution_kind')
filterEndpointKind.value = queryString(query, 'endpoint_kind')
filterRequestType.value = queryString(query, 'request_type')
filterIsStream.value = ['true', 'false'].includes(queryString(query, 'is_stream')) ? queryString(query, 'is_stream') === 'true' : undefined
filterFormatConversion.value = ['true', 'false'].includes(queryString(query, 'has_format_conversion')) ? queryString(query, 'has_format_conversion') === 'true' : undefined
const slowThreshold = Number(queryString(query, 'slow_threshold_ms'))
filterSlowThreshold.value = Number.isFinite(slowThreshold) && slowThreshold > 0 ? slowThreshold : undefined
currentPage.value = Math.max(1, Number(queryString(query, 'page')) || 1)
pageSize.value = Math.min(100, Math.max(10, Number(queryString(query, 'page_size')) || 20))
const detail = queryString(query, 'detail_id')
selectedRequestId.value = detail || null
detailModalOpen.value = isAdminPage.value && !!detail
return before !== currentRouteState()
}
function syncUsageRoute() {
if (!isAdminPage.value) return
const filters = getCurrentFilters()
const next = { ...route.query, ...timeRange.value, ...filters, hideUnknownRecords: undefined, page: currentPage.value > 1 ? String(currentPage.value) : undefined, page_size: String(pageSize.value) }
if (timeRange.value.from) { next.start_date = undefined; next.end_date = undefined; next.preset = undefined }
else { next.from = undefined; next.to = undefined }
void router.replace({ query: Object.fromEntries(Object.entries(next).map(([key, value]) => [key, typeof value === 'boolean' ? String(value) : value])) })
}
let routeReady = false
applyUsageRoute()
watch(() => route.query, async () => {
if (applyUsageRoute() && routeReady && isAdminPage.value) {
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalyticsForSelectionChange()
}
})
watch([timeRange, currentPage, pageSize, filterSearch, filterUser, filterModel, filterProvider, filterApiFormat, filterStatus, filterClientFamily], () => { if (routeReady) syncUsageRoute() })
// 初始化加载
onMounted(async () => {
routeReady = true
document.addEventListener('visibilitychange', handleVisibilityChange)
if (isAdminPage.value) {
// 管理员页面优先启动热力图加载,避免被统计聚合链路阻塞。
const heatmapPromise = loadHeatmapData().catch(err => {
log.error('加载热力图数据失败:', err)
})
const adminUsersPromise = loadAdminUsers()
await loadRecords(
@@ -917,7 +1061,7 @@ onMounted(async () => {
)
void (async () => {
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
await Promise.all([heatmapPromise, adminUsersPromise])
await adminUsersPromise
})()
} else {
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
@@ -976,6 +1120,15 @@ async function handlePageSizeChange(size: number) {
// 获取当前筛选参数
function getCurrentFilters() {
return {
provider_id: filterProviderId.value || undefined,
api_key_id: filterApiKeyId.value || undefined,
request_id: filterRequestId.value || undefined,
attribution_kind: filterAttribution.value || undefined,
endpoint_kind: filterEndpointKind.value || undefined,
request_type: filterRequestType.value || undefined,
is_stream: filterIsStream.value,
has_format_conversion: filterFormatConversion.value,
slow_threshold_ms: filterSlowThreshold.value,
search: filterSearch.value.trim() || undefined,
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
@@ -1097,6 +1250,12 @@ function showRequestDetail(id: string) {
if (!isAdminPage.value) return
selectedRequestId.value = id
detailModalOpen.value = true
void router.push({ query: { ...route.query, detail_id: id } })
}
function closeRequestDetail() {
detailModalOpen.value = false
void router.replace({ query: { ...route.query, detail_id: undefined } })
}
function sameImageProgress(left?: ImageProgress | null, right?: ImageProgress | null): boolean {
@@ -0,0 +1,91 @@
import { createApp, nextTick, type App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OverviewDashboardSummary } from '@/api/overview'
import { dashboardSummary } from '@/features/overview/__tests__/fixtures/dashboardSummary'
import Dashboard from '../Dashboard.vue'
const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), daily: vi.fn() }))
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: api.daily } }))
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/dashboard/DashboardAnnouncements.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
return { promise, resolve, reject }
}
let app: App | undefined
function mount() {
const root = document.createElement('div')
app = createApp(Dashboard)
app.mount(root)
return root
}
async function settle() {
for (let i = 0; i < 8; i += 1) { await Promise.resolve(); await nextTick() }
}
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
api.dashboardSummary.mockResolvedValue(dashboardSummary())
api.daily.mockResolvedValue({ daily_stats: [], provider_summary: [] })
})
afterEach(() => { app?.unmount(); app = undefined; vi.useRealTimers() })
describe('dashboard snapshot loading', () => {
it('shows today and total from one compact snapshot independently of pending charts', async () => {
api.daily.mockReturnValue(new Promise(() => {}))
const root = mount()
await settle()
expect(root.textContent).toContain('总请求 12,345')
expect(root.textContent).toContain('$9.87')
expect(root.textContent).toContain('1.52s')
expect(root.querySelector('[aria-busy="true"]')).toBeNull()
expect(api.dashboardSummary).toHaveBeenCalledWith(Intl.DateTimeFormat().resolvedOptions().timeZone, expect.any(AbortSignal))
expect(api.summary).not.toHaveBeenCalled()
expect(api.dashboardTotal).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(240_000)
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
expect(api.daily).toHaveBeenCalledTimes(1)
})
it('does not loop requests with the real time picker when summary fails', async () => {
const snapshot = deferred<OverviewDashboardSummary>()
api.dashboardSummary.mockReturnValue(snapshot.promise)
const root = mount()
await settle()
expect(root.querySelector('[aria-busy="true"]')).not.toBeNull()
snapshot.reject(new Error('timeout'))
await settle()
expect(root.querySelector('[aria-busy="true"]')).toBeNull()
expect(root.querySelector('[role="alert"]')).toBeNull()
expect(root.textContent).not.toContain('加载失败')
expect(root.textContent).not.toContain('重试')
expect(root.querySelector('[data-request-metric="stream"]')?.textContent).toContain('—')
await vi.advanceTimersByTimeAsync(61_000)
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
expect(api.daily.mock.calls.length).toBeLessThanOrEqual(2)
})
it('aborts the compact snapshot request when unmounted and ignores late success', async () => {
const snapshot = deferred<OverviewDashboardSummary>()
api.dashboardSummary.mockReturnValue(snapshot.promise)
const root = mount()
await settle()
const signal = api.dashboardSummary.mock.calls[0]![1] as AbortSignal
app?.unmount()
app = undefined
expect(signal.aborted).toBe(true)
snapshot.resolve(dashboardSummary())
await settle()
expect(root.textContent).toBe('')
await vi.advanceTimersByTimeAsync(240_000)
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
})
})
@@ -7,6 +7,10 @@ const dashboardApiMocks = vi.hoisted(() => ({
getStats: vi.fn(),
getDailyStats: vi.fn(),
}))
const overviewApiMocks = vi.hoisted(() => ({ dashboard: vi.fn(), dashboardTotal: vi.fn(), summary: vi.fn(), dashboardSummary: vi.fn() }))
const announcementApiMocks = vi.hoisted(() => ({ getAnnouncements: vi.fn(), markAsRead: vi.fn() }))
vi.mock('@/api/overview', () => ({ overviewApi: overviewApiMocks }))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
@@ -21,10 +25,13 @@ vi.mock('@/api/dashboard', () => ({
}))
vi.mock('@/api/announcements', () => ({
announcementApi: {
getAnnouncements: vi.fn().mockResolvedValue({ items: [] }),
markAsRead: vi.fn().mockResolvedValue({}),
},
announcementApi: announcementApiMocks,
}))
vi.mock('@/features/overview/dashboard/DashboardAnnouncements.vue', () => ({
default: defineComponent({
setup: () => () => h('aside', { id: 'announcements-section' }, '系统公告'),
}),
}))
vi.mock('@/components/charts/BarChart.vue', async () => {
@@ -67,7 +74,6 @@ vi.mock('@/components/ui', async () => {
Badge: passthrough('BadgeStub', 'span'),
Button: passthrough('ButtonStub', 'button'),
Skeleton: defineComponent({ name: 'SkeletonStub', setup: () => () => h('div') }),
Dialog: passthrough('DialogStub'),
Table: passthrough('TableStub', 'table'),
TableHeader: passthrough('TableHeaderStub', 'thead'),
TableBody: passthrough('TableBodyStub', 'tbody'),
@@ -92,15 +98,10 @@ vi.mock('lucide-vue-next', async () => {
Key: Icon,
Hash: Icon,
Zap: Icon,
Bell: Icon,
AlertCircle: Icon,
AlertTriangle: Icon,
Info: Icon,
Wrench: Icon,
Loader2: Icon,
Clock: Icon,
Database: Icon,
Shuffle: Icon,
CheckCircle2: Icon,
}
})
@@ -123,6 +124,12 @@ async function settle() {
}
beforeEach(() => {
announcementApiMocks.getAnnouncements.mockReset()
announcementApiMocks.markAsRead.mockReset()
overviewApiMocks.dashboard.mockReset()
overviewApiMocks.dashboardTotal.mockReset()
overviewApiMocks.summary.mockReset()
overviewApiMocks.dashboardSummary.mockReset()
dashboardApiMocks.getStats.mockReset()
dashboardApiMocks.getDailyStats.mockReset()
dashboardApiMocks.getDailyStats.mockResolvedValue({
@@ -165,6 +172,35 @@ describe('Dashboard ordinary user wallet card', () => {
expect(root.textContent).toContain('$110.00')
expect(root.textContent).toContain('套餐额度 $100.00 · 钱包余额 $10.00')
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboardTotal).not.toHaveBeenCalled()
expect(overviewApiMocks.summary).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboardSummary).not.toHaveBeenCalled()
expect(root.querySelector('#announcements-section')).not.toBeNull()
expect(root.querySelector('[role="dialog"]')).toBeNull()
expect(root.textContent).toContain('系统公告')
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
expect(announcementApiMocks.markAsRead).not.toHaveBeenCalled()
})
it('preserves monthly cache and cost statistics without requesting announcements', async () => {
dashboardApiMocks.getStats.mockResolvedValue({
stats: [],
cache_stats: { cache_creation_tokens: 25, cache_read_tokens: 200, total_cache_tokens: 225, cache_hit_rate: 80 },
monthly_cost: 12.5,
})
const root = mountDashboard()
await settle()
expect(root.textContent).toContain('本月统计')
expect(root.textContent).toContain('缓存命中率')
expect(root.textContent).toContain('80%')
expect(root.textContent).toContain('缓存读取')
expect(root.textContent).toContain('缓存创建')
expect(root.textContent).toContain('本月费用')
expect(root.textContent).toContain('$12.50')
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
})
})
@@ -180,6 +216,8 @@ describe('Dashboard refresh controls', () => {
expect(root.textContent).not.toContain('自动刷新')
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
await settle()
@@ -15,7 +15,9 @@ describe('admin usage initial loading', () => {
expect(mountedBlock).toBeTruthy()
expect(mountedBlock).toContain('const adminUsersPromise = loadAdminUsers()')
expect(mountedBlock).toContain('Promise.all([heatmapPromise, adminUsersPromise])')
expect(mountedBlock).toContain('await adminUsersPromise')
const adminBlock = mountedBlock?.split('} else {')[0]
expect(adminBlock).not.toContain('loadHeatmapData')
expect(mountedBlock?.indexOf('const adminUsersPromise = loadAdminUsers()'))
.toBeLessThan(mountedBlock?.indexOf('await loadRecords(') ?? -1)
expect(mountedBlock).not.toContain('await loadAdminUsers()')
+56 -109
View File
@@ -140,7 +140,7 @@
variant="outline"
class="text-[10px] px-1.5 py-0"
>
必读
{{ popupReminderLabel }}
</Badge>
<Pin
v-if="announcement.is_pinned"
@@ -261,7 +261,7 @@
variant="outline"
class="text-[10px] shrink-0"
>
必读
{{ popupReminderLabel }}
</Badge>
<Pin
v-if="announcement.is_pinned"
@@ -446,7 +446,7 @@
</div>
</div>
<div class="flex items-center gap-6 p-3 border rounded-lg bg-muted/50">
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 p-3 border rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<input
id="pinned"
@@ -469,7 +469,7 @@
<Label
for="requires-ack"
class="cursor-pointer text-sm"
>必读确认</Label>
>{{ popupReminderLabel }}</Label>
</div>
<div
v-if="editingAnnouncement"
@@ -525,75 +525,21 @@
/>
<!-- 公告详情对话框 -->
<Dialog
<AnnouncementDialog
v-model="detailDialogOpen"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg flex-shrink-0"
:class="getDialogIconClass(viewingAnnouncement?.type)"
>
<component
:is="getAnnouncementIcon(viewingAnnouncement.type)"
v-if="viewingAnnouncement"
class="h-5 w-5"
:class="getIconColor(viewingAnnouncement.type)"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight truncate">
{{ viewingAnnouncement?.title || '公告详情' }}
</h3>
<p class="text-xs text-muted-foreground">
系统公告
</p>
</div>
</div>
</div>
</template>
<div
v-if="viewingAnnouncement"
class="space-y-4"
>
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-muted-foreground">
<span>{{ viewingAnnouncement.author.username }}</span>
<span>·</span>
<span>{{ formatFullDate(viewingAnnouncement.created_at) }}</span>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
translate="no"
class="prose prose-sm dark:prose-invert max-w-none"
v-html="renderMarkdown(viewingAnnouncement.content)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<template #footer>
<Button
variant="outline"
type="button"
class="h-10 px-5"
@click="detailDialogOpen = false"
>
关闭
</Button>
</template>
</Dialog>
:announcement="viewingAnnouncement"
/>
</div>
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { formatRelativeTime } from '@/utils/format'
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { announcementApi, type Announcement } from '@/api/announcements'
import { useAuthStore } from '@/stores/auth'
import { useAnnouncementStore } from '@/stores/announcements'
import {
Card,
Button,
@@ -618,20 +564,24 @@ import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { AlertDialog } from '@/components/common'
import AnnouncementDialog from '@/components/common/AnnouncementDialog.vue'
import { Bell, AlertCircle, AlertTriangle, Info, Pin, Wrench, Loader2, Plus, SquarePen, Trash2 } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { marked } from 'marked'
import { sanitizeMarkdown } from '@/utils/sanitize'
const { success, error: showError } = useToast()
const authStore = useAuthStore()
const announcementStore = useAnnouncementStore()
const route = useRoute()
const router = useRouter()
const isAdmin = computed(() => authStore.isAdmin)
const popupReminderLabel = computed(() => getI18nLocale() === 'zh-CN' ? '弹窗提醒' : 'Popup reminder')
const announcements = ref<Announcement[]>([])
const loading = ref(false)
const total = ref(0)
const unreadCount = ref(0)
const pageUnreadCount = ref(0)
const unreadCount = computed(() => announcementStore.unreadCount ?? pageUnreadCount.value)
const currentPage = ref(1)
const pageSize = ref(20)
@@ -660,18 +610,34 @@ onMounted(() => {
loadAnnouncements()
})
watch(() => route.query.create, value => {
if (value !== '1') return
if (isAdmin.value) openCreateDialog()
const query = { ...route.query }
delete query.create
void router.replace({ query })
}, { immediate: true })
watch([() => announcementStore.items, () => announcementStore.unreadCount], ([items, count]) => {
const readStates = new Map(items.map(item => [item.id, item.is_read]))
for (const announcement of announcements.value) {
if (count === 0 && isCurrentAnnouncement(announcement)) announcement.is_read = true
else if (readStates.has(announcement.id)) announcement.is_read = readStates.get(announcement.id)
}
})
async function loadAnnouncements(page = 1) {
loading.value = true
currentPage.value = page
try {
const response = await announcementApi.getAnnouncements({
active_only: !authStore.canAccessAdmin, // 管理员和审计管理员可以看到所有公告
const params = {
limit: pageSize.value,
offset: (page - 1) * pageSize.value
})
}
const response = await announcementApi.getUserAnnouncements(params)
announcements.value = response.items
total.value = response.total
unreadCount.value = response.unread_count || 0
pageUnreadCount.value = response.unread_count || 0
} catch (error) {
log.error('加载公告失败:', error)
showError('加载公告失败')
@@ -680,21 +646,27 @@ async function loadAnnouncements(page = 1) {
}
}
function isCurrentAnnouncement(announcement: Announcement) {
const now = Date.now()
return announcement.is_active
&& (!announcement.start_time || Date.parse(announcement.start_time) <= now)
&& (!announcement.end_time || Date.parse(announcement.end_time) >= now)
}
async function viewAnnouncementDetail(announcement: Announcement) {
// 标记为已读
if (!announcement.is_read && !isAdmin.value) {
viewingAnnouncement.value = announcement
detailDialogOpen.value = true
if (!announcement.is_read && isCurrentAnnouncement(announcement)) {
try {
await announcementApi.markAsRead(announcement.id)
announcement.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
await announcementStore.markRead(announcement.id)
if (!announcement.is_read) {
announcement.is_read = true
pageUnreadCount.value = Math.max(0, pageUnreadCount.value - 1)
}
} catch (error) {
log.error('标记已读失败:', error)
}
}
// 显示详情对话框
viewingAnnouncement.value = announcement
detailDialogOpen.value = true
}
function openCreateDialog() {
@@ -732,6 +704,7 @@ async function toggleAnnouncementPin(announcement: Announcement, newStatus: bool
})
announcement.is_pinned = newStatus
success(newStatus ? '已置顶' : '已取消置顶')
void announcementStore.refresh()
} catch (error) {
log.error('更新置顶状态失败:', error)
showError('更新置顶状态失败')
@@ -745,6 +718,7 @@ async function toggleAnnouncementActive(announcement: Announcement, newStatus: b
})
announcement.is_active = newStatus
success(newStatus ? '已启用' : '已禁用')
void announcementStore.refresh()
} catch (error) {
log.error('更新启用状态失败:', error)
showError('更新启用状态失败')
@@ -769,6 +743,7 @@ async function saveAnnouncement() {
success('公告创建成功')
}
dialogOpen.value = false
void announcementStore.refresh()
loadAnnouncements(currentPage.value)
} catch (error) {
log.error('保存失败:', error)
@@ -791,6 +766,7 @@ async function deleteAnnouncement() {
await announcementApi.deleteAnnouncement(deletingAnnouncement.value.id)
success('公告已删除')
deleteDialogOpen.value = false
void announcementStore.refresh()
loadAnnouncements(currentPage.value)
} catch (error) {
log.error('删除失败:', error)
@@ -852,35 +828,6 @@ function getTypeLabel(type: string): string {
}
}
function getDialogIconClass(type?: string) {
switch (type) {
case 'important':
return 'bg-rose-100 dark:bg-rose-900/30'
case 'warning':
return 'bg-amber-100 dark:bg-amber-900/30'
case 'maintenance':
return 'bg-orange-100 dark:bg-orange-900/30'
default:
return 'bg-primary/10 dark:bg-primary/20'
}
}
function formatFullDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function renderMarkdown(content: string): string {
const rawHtml = marked(content) as string
return sanitizeMarkdown(rawHtml)
}
function getPlainText(content: string): string {
// 简单地移除 Markdown 标记,用于预览
return content
@@ -0,0 +1,223 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import Announcements from '../Announcements.vue'
import type { Announcement } from '@/api/announcements'
import { setI18nLocale } from '@/i18n'
const api = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), createAnnouncement: vi.fn(), updateAnnouncement: vi.fn(), deleteAnnouncement: vi.fn() }))
const store = vi.hoisted(() => ({ markRead: vi.fn(), refresh: vi.fn(), items: [] as Announcement[], unreadCount: null as number | null }))
const auth = vi.hoisted(() => ({ isAdmin: true, canAccessAdmin: true }))
const route = vi.hoisted(() => ({ query: {} as Record<string, string> }))
const router = vi.hoisted(() => ({ replace: vi.fn() }))
vi.mock('@/api/announcements', () => ({ announcementApi: api }))
vi.mock('@/stores/announcements', async () => {
const { reactive } = await import('vue')
return { useAnnouncementStore: () => reactive(store) }
})
vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => router }))
vi.mock('@/composables/useToast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = (tag = 'div') => defineComponent({ setup: (_, { slots }) => () => h(tag, slots.default?.()) })
const input = (tag: string) => defineComponent({ props: { modelValue: { type: [String, Number], default: '' } }, emits: ['update:modelValue'], setup: (props, { emit }) => () => h(tag, { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) }) })
return {
Card: wrapper('section'), Button: wrapper('button'), Badge: wrapper('span'), Label: wrapper('label'), Input: input('input'), Textarea: input('textarea'),
Table: wrapper('table'), TableHeader: wrapper('thead'), TableBody: wrapper('tbody'), TableRow: wrapper('tr'), TableHead: wrapper('th'), TableCell: wrapper('td'),
Pagination: wrapper(), RefreshButton: wrapper('button'),
Dialog: defineComponent({ props: { modelValue: Boolean }, setup: (props, { slots }) => () => props.modelValue ? h('div', { role: 'dialog' }, [slots.header?.(), slots.default?.(), slots.footer?.()]) : null }),
Switch: defineComponent({ props: { modelValue: Boolean }, emits: ['update:modelValue'], setup: (props, { emit }) => () => h('button', { role: 'switch', 'aria-checked': props.modelValue, onClick: () => emit('update:modelValue', !props.modelValue) }) }),
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return { AlertDialog: defineComponent({ props: { modelValue: Boolean }, emits: ['confirm'], setup: (props, { emit }) => () => props.modelValue ? h('button', { 'data-confirm-delete': '', onClick: () => emit('confirm') }, '删除') : null }) }
})
const notice = (overrides: Partial<Announcement> = {}): Announcement => ({
id: 'notice-1', title: 'Release notice', content: 'The release is available.', type: 'info',
priority: 0, is_pinned: false, is_active: true, requires_ack: false, is_read: false,
author: { id: 'admin', username: 'Admin' }, created_at: '2026-09-14T00:00:00Z', updated_at: '2026-09-14T00:00:00Z', ...overrides,
})
const mounted: Array<{ app: App; root: HTMLElement }> = []
async function settle() { await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)); await nextTick() }
async function mount() {
const root = document.createElement('div')
document.body.append(root)
const app = createApp(Announcements)
app.mount(root)
mounted.push({ app, root })
await settle()
return root
}
function button(root: HTMLElement, text: string) {
const result = Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(item => item.textContent?.trim() === text)
if (!result) throw new Error(`Button not found: ${text}`)
return result
}
function fill(root: HTMLElement, selector: string, value: string) {
const input = root.querySelector<HTMLInputElement>(selector)!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
function fixture(items: Announcement[]) {
const response = { items, total: items.length, unread_count: items.filter(item => !item.is_read).length }
api.getAnnouncements.mockResolvedValue(response)
api.getUserAnnouncements.mockResolvedValue(response)
}
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-15T12:00:00Z'))
setI18nLocale('zh-CN')
auth.isAdmin = true
auth.canAccessAdmin = true
route.query = {}
store.items = []
store.unreadCount = null
fixture([notice()])
for (const action of [api.createAnnouncement, api.updateAnnouncement, api.deleteAnnouncement, store.markRead, store.refresh, router.replace]) action.mockResolvedValue(undefined)
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
vi.restoreAllMocks()
})
describe('announcement publication and reading', () => {
it('opens the administrator create deep link once and preserves unrelated query parameters', async () => {
route.query = { create: '1', page: '2' }
const root = await mount()
expect(api.getUserAnnouncements).toHaveBeenCalledWith({ limit: 20, offset: 0 })
expect(api.getAnnouncements).not.toHaveBeenCalled()
expect(router.replace).toHaveBeenCalledWith({ query: { page: '2' } })
expect(root.querySelector('[role="dialog"]')?.textContent).toContain('发布新的系统公告')
expect(root.querySelector<HTMLInputElement>('#requires-ack')?.checked).toBe(false)
expect(root.querySelector('label[for="requires-ack"]')?.textContent).toBe('弹窗提醒')
expect(root.querySelector('#requires-ack')?.parentElement?.parentElement?.classList.contains('flex-wrap')).toBe(true)
fill(root, '#title', 'Routine notice')
fill(root, '#content', 'No interruption expected.')
await nextTick()
button(root, '创建').click()
await settle()
expect(api.createAnnouncement).toHaveBeenCalledWith(expect.objectContaining({ title: 'Routine notice', requires_ack: false }))
expect(store.refresh).toHaveBeenCalledTimes(1)
expect(root.querySelector('[role="dialog"]')).toBeNull()
})
it.each([false, true])('uses the member list for non-admin roles (audit access: %s) and never opens publishing', async canAccessAdmin => {
auth.isAdmin = false
auth.canAccessAdmin = canAccessAdmin
route.query = { create: '1' }
const root = await mount()
expect(api.getUserAnnouncements).toHaveBeenCalledWith({ limit: 20, offset: 0 })
expect(api.getAnnouncements).not.toHaveBeenCalled()
expect(root.querySelector('[role="dialog"]')).toBeNull()
expect(root.querySelector('[title="新建公告"]')).toBeNull()
expect(router.replace).toHaveBeenCalledWith({ query: {} })
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(store.markRead).toHaveBeenCalledWith('notice-1')
expect(root.querySelector('[role="dialog"]')?.textContent).toContain('The release is available.')
})
it('marks a valid administrator preview as read once while opening its detail immediately', async () => {
let finish!: () => void
store.markRead.mockReturnValueOnce(new Promise<void>(resolve => { finish = resolve }))
const root = await mount()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await nextTick()
expect(root.querySelector('[role="dialog"]')?.textContent).toContain('The release is available.')
expect(store.markRead).toHaveBeenCalledWith('notice-1')
finish()
await settle()
button(root, '关闭').click()
await nextTick()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(store.markRead).toHaveBeenCalledTimes(1)
expect(root.textContent).not.toContain('1 条未读')
})
it.each([
{ is_active: false },
{ start_time: '2026-09-15T12:00:01Z' },
{ end_time: '2026-09-15T11:59:59Z' },
{ is_read: true },
])('does not acknowledge an inactive, scheduled, expired, or already-read preview: %j', async overrides => {
fixture([notice(overrides)])
const root = await mount()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(store.markRead).not.toHaveBeenCalled()
expect(root.querySelector('[role="dialog"]')?.textContent).toContain('The release is available.')
})
it('includes the exact end boundary in the effective window, matching the server', async () => {
fixture([notice({ end_time: '2026-09-15T12:00:00Z' })])
const root = await mount()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(store.markRead).toHaveBeenCalledWith('notice-1')
})
it('preserves an existing popup reminder during editing and refreshes shared state after every mutation', async () => {
fixture([notice({ requires_ack: true })])
const root = await mount()
expect(root.textContent).toContain('弹窗提醒')
expect(root.textContent).not.toContain('必读')
root.querySelector('tbody .lucide-square-pen')?.closest('button')?.click()
await nextTick()
expect(root.querySelector<HTMLInputElement>('#requires-ack')?.checked).toBe(true)
button(root, '保存').click()
await settle()
expect(api.updateAnnouncement).toHaveBeenLastCalledWith('notice-1', expect.objectContaining({ requires_ack: true }))
expect(store.refresh).toHaveBeenCalledTimes(1)
root.querySelectorAll<HTMLButtonElement>('tbody [role="switch"]')[0]!.click()
await settle()
expect(api.updateAnnouncement).toHaveBeenLastCalledWith('notice-1', { is_pinned: true })
root.querySelectorAll<HTMLButtonElement>('tbody [role="switch"]')[1]!.click()
await settle()
expect(api.updateAnnouncement).toHaveBeenLastCalledWith('notice-1', { is_active: false })
expect(store.refresh).toHaveBeenCalledTimes(3)
root.querySelector('tbody .lucide-trash-2')?.closest('button')?.click()
await nextTick()
root.querySelector<HTMLButtonElement>('[data-confirm-delete]')!.click()
await settle()
expect(api.deleteAnnouncement).toHaveBeenCalledWith('notice-1')
expect(store.refresh).toHaveBeenCalledTimes(4)
})
it('keeps failed read acknowledgements unread and allows a later retry', async () => {
store.markRead.mockRejectedValueOnce(new Error('Network unavailable'))
const root = await mount()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(root.textContent).toContain('1 条未读')
button(root, '关闭').click()
await nextTick()
root.querySelector<HTMLTableRowElement>('tbody tr')!.click()
await settle()
expect(store.markRead).toHaveBeenCalledTimes(2)
expect(root.textContent).not.toContain('1 条未读')
})
it('synchronizes the current page after single and bulk reads through the announcement bell', async () => {
fixture([notice(), notice({ id: 'notice-2', title: 'Second notice' })])
const root = await mount()
const { reactive } = await import('vue')
const shared = reactive(store)
shared.items = [notice({ is_read: true })]
shared.unreadCount = 1
await nextTick()
expect(root.textContent).toContain('1 条未读')
expect(root.querySelectorAll('tbody tr')[0]?.textContent).not.toContain('未读')
expect(root.querySelectorAll('tbody tr')[1]?.textContent).toContain('未读')
shared.unreadCount = 0
await nextTick()
expect(root.textContent).not.toContain('条未读')
expect(root.querySelector('tbody')?.textContent).not.toContain('未读')
expect(api.getUserAnnouncements).toHaveBeenCalledTimes(1)
expect(store.markRead).not.toHaveBeenCalled()
})
})
@@ -218,6 +218,9 @@ describe('MyApiKeys CC Switch import', () => {
?.click()
await flushPromises()
expect(meApiMock.createApiKey).toHaveBeenCalledOnce()
expect(meApiMock.createApiKey.mock.calls[0]?.[0]).toMatchObject({ name: 'new key' })
expect(meApiMock.createApiKey.mock.calls[0]?.[0]).not.toHaveProperty('credential_kind')
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-created-key"]')?.click()
await flushPromises()