fix: align dashboard charts with customer billing and history coverage

This commit is contained in:
elky
2026-10-09 21:53:43 +08:00
parent 63520c8c27
commit 6b6d5b3a85
19 changed files with 1561 additions and 153 deletions
+10 -5
View File
@@ -153,13 +153,18 @@ export interface OverviewDashboardSummary {
}
}
export interface OverviewPage<T> { items: T[]; total: number; limit: number; offset: number }
export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string }
export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string; unique_providers?: number | null }
export interface OverviewBreakdown extends OverviewMetrics { id: string | null; label: string | null }
export interface OverviewDashboardChartMetrics extends Omit<OverviewMetrics, 'usage_active_users' | 'unclassified_failure_count'> {
usage_active_users: number | null
slow_request_count: number | null
unclassified_failure_count: number | null
}
export interface OverviewDashboardCharts {
summary: OverviewMetrics
series: OverviewSeriesPoint[]
models: (OverviewBreakdown & { bucket_start: string })[]
providers: OverviewBreakdown[]
summary: OverviewDashboardChartMetrics
series: (OverviewDashboardChartMetrics & { bucket_start: string; unique_providers?: number | null })[]
models: (OverviewDashboardChartMetrics & { id: string | null; label: string | null; bucket_start: string })[]
providers: (OverviewDashboardChartMetrics & { id: string | null; label: string | null })[]
}
export interface OverviewEmployee extends OverviewMetrics {
user_id: string
@@ -3,9 +3,10 @@ import { createApp, nextTick, type App } from 'vue'
import { createPinia, disposePinia, type Pinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import AdminDashboard from '@/views/admin/AdminDashboard.vue'
import type { OverviewDashboardCharts, OverviewMetrics } from '@/api/overview'
import type { OverviewDashboardCharts, OverviewDashboardChartMetrics } from '@/api/overview'
import type { DateRangeParams } from '@/features/usage/types'
import { setI18nLocale } from '@/i18n'
import { dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
import { dashboardChartRange, dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
import { dashboardSummary } from './fixtures/dashboardSummary'
import { useAnnouncementStore } from '@/stores/announcements'
@@ -13,19 +14,20 @@ const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), das
const legacyApi = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn() }))
const announcementApi = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), markAsRead: vi.fn() }))
const auth = vi.hoisted(() => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }))
const picker = vi.hoisted(() => ({ next: null as DateRangeParams | null }))
vi.mock('@/api/overview', () => ({ overviewApi: api }))
vi.mock('@/api/dashboard', () => ({ dashboardApi: legacyApi }))
vi.mock('@/features/usage/components/IntervalTimelineCard.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { title: String, hours: Number, isAdmin: Boolean, refreshIntervalMs: Number }, setup: props => () => h('div', { 'data-timeline': '' }, props.title) }) } })
vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth }))
vi.mock('@/api/announcements', () => ({ announcementApi }))
vi.mock('@/components/charts/BarChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'bar' }) } } })
vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'doughnut' }) } } })
vi.mock('@/components/charts/BarChart.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { data: Object }, setup: props => () => h('div', { 'data-chart': 'bar', 'data-values': JSON.stringify(props.data) }) }) } })
vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { data: Object }, setup: props => () => h('div', { 'data-chart': 'doughnut', 'data-values': JSON.stringify(props.data) }) }) } })
vi.mock('@/components/charts/LineChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'line' }) } } })
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return { TimeRangePicker: defineComponent({
props: { modelValue: { type: Object, default: () => ({}) } }, emits: ['update:modelValue'],
setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'),
setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', picker.next ?? { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'),
}) }
})
vi.mock('@/components/ui', async () => {
@@ -39,9 +41,9 @@ vi.mock('@/components/ui', async () => {
})
const amount = (value: string | null, status = value === null ? 'unknown' : 'known') => ({ value, status, currency: 'USD', basis: 'billable' })
const metrics = (request_count = 0): OverviewMetrics => ({
request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0,
input_tokens: request_count * 10, output_tokens: request_count * 10, total_tokens: request_count * 20, usage_active_users: 3, enabled_users: 8,
const metrics = (request_count = 0): OverviewDashboardChartMetrics => ({
request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: null,
input_tokens: null, output_tokens: null, total_tokens: request_count * 20, usage_active_users: null, slow_request_count: null, enabled_users: 8,
success_rate: { value: request_count ? 1 : null, numerator: request_count, denominator: request_count }, latency_ms: { avg: 100, p50: 90, p95: 190, p99: 230, sample_count: request_count },
rated_amount: amount('10'), billable_amount: amount('5'), quota_covered_amount: amount('2'), wallet_consumed_amount: amount('3'), wallet_debit_amount: amount('3'),
})
@@ -85,8 +87,10 @@ beforeEach(() => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
setI18nLocale('zh-CN')
vi.resetAllMocks()
picker.next = null
Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })
api.dashboardSummary.mockResolvedValue(dashboardSummary())
api.dashboardCharts.mockResolvedValue({ data: charts() })
announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 })
legacyApi.getStats.mockResolvedValue({
stats: [{ name: 'Legacy request metric', value: '999999', icon: 'Activity' }],
@@ -154,8 +158,10 @@ describe('administrator dashboard compact presentation', () => {
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
expect(api.summary).not.toHaveBeenCalled()
expect(api.dashboardTotal).not.toHaveBeenCalled()
expect(api.dashboardCharts).not.toHaveBeenCalled()
expect(api.dashboardCharts).toHaveBeenCalledTimes(1)
expect(api.dashboardCharts).toHaveBeenCalledWith(expect.objectContaining({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, from: expect.any(String), to: expect.any(String) }), expect.any(AbortSignal))
expect(legacyApi.getStats).not.toHaveBeenCalled()
expect(legacyApi.getDailyStats).not.toHaveBeenCalled()
expect(announcementApi.getAnnouncements).not.toHaveBeenCalled()
expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 })
expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告')
@@ -167,7 +173,9 @@ describe('administrator dashboard compact presentation', () => {
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
await new Promise(resolve => setTimeout(resolve, 150))
await settle()
expect(legacyApi.getDailyStats).toHaveBeenCalledTimes(2)
expect(api.dashboardCharts).toHaveBeenCalledTimes(2)
expect(api.dashboardCharts.mock.calls[1]![0].from).not.toBe(api.dashboardCharts.mock.calls[0]![0].from)
expect(legacyApi.getDailyStats).not.toHaveBeenCalled()
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
expect(card(root, '今日请求')?.textContent).toContain('12,345')
})
@@ -231,9 +239,173 @@ describe('administrator dashboard compact presentation', () => {
}
expect(root.querySelector('[data-chart="bar"]')).not.toBeNull()
})
it('uses customer billing and all request types for daily rows, totals, and both cost charts', async () => {
const snapshot = dashboardSummary()
Object.assign(snapshot.today, { request_count: 25018, stream_requests: 24989, standard_requests: 29, billable_amount: amount('10855.49') })
api.dashboardSummary.mockResolvedValue(snapshot)
const metric = { ...metrics(25018), billable_amount: amount('10855.49'), rated_amount: amount('13580.42'), latency_ms: { ...metrics().latency_ms, avg: 1520 } }
const data: OverviewDashboardCharts = {
summary: metric,
series: [{ ...metric, bucket_start: '2026-09-19T00:00:00Z', unique_providers: 2 }],
models: [{ ...metric, id: 'model', label: 'Model', bucket_start: '2026-09-19T00:00:00+00:00' }],
providers: [{ ...metric, id: 'provider', label: 'Provider' }],
}
api.dashboardCharts.mockResolvedValue({ data })
const { root } = await mount()
const row = root.querySelector('[data-daily-date]')
expect(row?.textContent).toContain('25,018')
expect(row?.textContent).toContain('$10855.4900')
expect(row?.textContent).toContain('1.52s')
expect(row?.lastElementChild?.textContent?.trim()).toBe('2')
expect(primaryValue(root, '今日消费')?.textContent).toContain('$10855.49')
expect(metricValue(root, 'stream')).toBe('24,989')
expect(metricValue(root, 'standard')).toBe('29')
expect(root.querySelector('[data-daily-total]')?.textContent).toContain('$10855.4900')
for (const kind of ['bar', 'doughnut']) {
const chart = JSON.parse(root.querySelector(`[data-chart="${kind}"]`)!.getAttribute('data-values')!)
expect(chart.datasets[0].data).toEqual([10855.49])
}
expect(root.textContent).not.toContain('13580.42')
expect(legacyApi.getDailyStats).not.toHaveBeenCalled()
})
it.each([
{ value: null, status: 'unknown', display: '—' },
{ value: '123', status: 'unknown', display: '—' },
{ value: '0', status: 'known', display: '$0.0000' },
{ value: '0', status: 'known_subtotal', display: '$0.0000' },
{ value: '3.25', status: 'known_subtotal', display: '$3.2500' },
])('preserves daily amount $status/$value without inventing zero', async ({ value, status, display }) => {
const data = charts()
const billable = amount(value, status)
for (const metric of [data.summary, ...data.series, ...data.models, ...data.providers]) metric.billable_amount = billable
data.series[0]!.total_tokens = null
data.series[0]!.latency_ms.avg = null
api.dashboardCharts.mockResolvedValue({ data })
const { root } = await mount()
const cells = root.querySelector('[data-daily-date]')!.querySelectorAll('td')
expect(cells[2]!.textContent?.trim()).toBe('—')
expect(cells[3]!.textContent).toContain(display)
expect(cells[4]!.textContent?.trim()).toBe('—')
expect(cells[6]!.textContent?.trim()).toBe('—')
expect(root.querySelector('[data-daily-total]')?.textContent).toContain(display)
if (status === 'unknown') {
expect(cells[3]!.textContent).not.toContain('$')
expect(root.querySelector('[data-chart="bar"]')).toBeNull()
expect(root.querySelector('[data-chart="doughnut"]')).toBeNull()
expect(root.textContent).toContain('费用尚未确认')
}
if (status === 'known_subtotal') {
expect(cells[3]!.textContent).toContain('已知小计')
expect(root.querySelector('[data-daily-total]')?.textContent).toContain('已知小计')
expect(root.textContent).toContain('分布占比按已知金额计算')
if (value === '0') {
expect(root.textContent).toContain('费用尚未确认')
expect(root.textContent).not.toContain('此周期暂无计费费用')
}
}
})
it('takes period totals and latency from the canonical summary instead of averaging daily values', async () => {
const data = charts()
data.summary = { ...metrics(101), billable_amount: amount('12', 'known_subtotal'), latency_ms: { ...metrics().latency_ms, avg: 500 } }
data.series = [
{ ...metrics(100), bucket_start: '2026-09-05T00:00:00Z', latency_ms: { ...metrics().latency_ms, avg: 100, sample_count: 1 } },
{ ...metrics(1), bucket_start: '2026-09-06T00:00:00Z', latency_ms: { ...metrics().latency_ms, avg: 900, sample_count: 1 } },
]
api.dashboardCharts.mockResolvedValue({ data })
const { root } = await mount()
const total = root.querySelector('[data-daily-total]')
expect(total?.textContent).toContain('$12.0000')
expect(total?.textContent).toContain('500ms')
expect(total?.textContent).toContain('已知小计')
})
it('does not describe a positive summary with missing chart details as zero cost', async () => {
const data = charts()
data.models = []
data.providers = []
api.dashboardCharts.mockResolvedValue({ data })
const { root } = await mount()
expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$5.0000')
expect(root.textContent).toContain('暂无费用明细')
expect(root.textContent).not.toContain('此周期暂无计费费用')
})
it('hides the previous period total while loading the selected range', async () => {
const { root } = await mount()
expect(root.querySelector('[data-daily-total]')).not.toBeNull()
api.dashboardCharts.mockReturnValue(new Promise(() => {}))
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
await nextTick()
expect(root.querySelector('[data-daily-total]')).toBeNull()
await new Promise(resolve => setTimeout(resolve, 150))
await settle()
expect(root.querySelector('[data-daily-total]')).toBeNull()
})
it('cancels a previous chart range and ignores its late response after the new range has loaded', async () => {
let resolveOld!: (value: { data: OverviewDashboardCharts }) => void
api.dashboardCharts.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const { root } = await mount()
const oldSignal = api.dashboardCharts.mock.calls[0]![1] as AbortSignal
const current = charts()
current.series[0]!.billable_amount = amount('42')
api.dashboardCharts.mockResolvedValue({ data: current })
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
await nextTick()
expect(oldSignal.aborted).toBe(true)
await new Promise(resolve => setTimeout(resolve, 150))
await settle()
expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$42.0000')
resolveOld({ data: charts() })
await settle()
expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$42.0000')
expect(api.dashboardCharts).toHaveBeenCalledTimes(2)
})
it('shows a retryable daily load error without replacing the summary or inventing empty statistics', async () => {
api.dashboardCharts.mockRejectedValueOnce(new Error('timeout'))
const { root } = await mount()
const alert = root.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('统计加载失败,请重试')
expect(primaryValue(root, '今日消费')?.textContent).toContain('$9.87')
expect(root.querySelector('[data-daily-date]')).toBeNull()
expect(root.querySelector('[data-daily-total]')).toBeNull()
expect(root.textContent).not.toContain('暂无数据')
alert!.querySelector<HTMLButtonElement>('button')!.click()
await settle()
expect(root.querySelector('[role="alert"]')).toBeNull()
expect(root.querySelector('[data-daily-date]')?.textContent).toContain('$5.0000')
expect(api.dashboardCharts).toHaveBeenCalledTimes(2)
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
})
it('uses the selected calendar timezone for chart requests and daily date labels', async () => {
const { root } = await mount()
picker.next = { start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' }
const data = charts()
data.series[0]!.bucket_start = '2026-03-08T05:00:00Z'
api.dashboardCharts.mockResolvedValue({ data })
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
await new Promise(resolve => setTimeout(resolve, 150))
await settle()
expect(api.dashboardCharts).toHaveBeenLastCalledWith({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' }, expect.any(AbortSignal))
expect(api.dashboardSummary).toHaveBeenLastCalledWith('America/New_York', expect.any(AbortSignal))
expect(root.querySelector('[data-daily-date]')?.firstElementChild?.textContent).toContain('03/08')
})
})
describe('dashboard chart accounting', () => {
it('preserves inclusive calendar periods and explicit instants in the selected timezone', () => {
const now = new Date('2026-10-08T04:34:56Z')
expect(dashboardChartRange({ preset: 'last7days', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-01T16:00:00.000Z', to: '2026-10-08T16:00:00.000Z', timezone: 'Asia/Shanghai' })
expect(dashboardChartRange({ preset: 'yesterday', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-06T16:00:00.000Z', to: '2026-10-07T16:00:00.000Z', timezone: 'Asia/Shanghai' })
expect(dashboardChartRange({ start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' }, now)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' })
expect(dashboardChartRange({ start_date: '2026-09-06', end_date: '2026-09-06', timezone: 'America/Santiago' }, now)).toEqual({ from: '2026-09-06T04:00:00.000Z', to: '2026-09-07T03:00:00.000Z', timezone: 'America/Santiago' })
expect(dashboardChartRange({ from: '2026-10-07T16:12:34Z', to: '2026-10-08T16:56:00Z', timezone: 'Asia/Shanghai' }, now)).toEqual({ from: '2026-10-07T16:12:34.000Z', to: '2026-10-08T16:56:00.000Z', timezone: 'Asia/Shanghai' })
})
it('preserves the full provider sum when collapsing additional groups and excludes unknown amounts', () => {
const rows = Array.from({ length: 105 }, (_, i) => ({ ...metrics(1), id: String(i), label: `Provider ${i}`, billable_amount: amount(String(i + 1)) }))
rows.push({ ...metrics(1), id: 'unknown', label: 'Unknown cost', billable_amount: amount(null) })
@@ -253,6 +425,13 @@ describe('dashboard chart accounting', () => {
expect(modelDatasets(data, 'Unknown model', 'Others')[0]).toMatchObject({ label: 'gpt-example', data: [3, null] })
})
it.each(['unknown', 'known_subtotal', 'estimated_subtotal'])('keeps absent model rows unknown in an incomplete %s day', status => {
const data = charts()
data.series.push({ ...metrics(0), billable_amount: amount(status === 'unknown' ? null : '0', status), bucket_start: '2026-09-06T00:00:00Z' })
data.series.push({ ...metrics(0), billable_amount: amount('0'), bucket_start: '2026-09-07T00:00:00Z' })
expect(modelDatasets(data, 'Unknown model', 'Others')[0]!.data).toEqual([5, null, 0])
})
it('uses a 23-hour local day during daylight saving and clips it to report boundaries', () => {
const dstRange = { from: '2026-03-07T05:00:00Z', to: '2026-03-10T04:00:00Z', timezone: 'America/New_York' }
expect(dayRange('2026-03-08T05:00:00Z', dstRange)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: dstRange.timezone })
@@ -1,22 +1,23 @@
import type { OverviewBreakdown, OverviewDashboardCharts, OverviewRange } from '@/api/overview'
import type { OverviewDashboardChartMetrics, OverviewDashboardCharts, OverviewRange } from '@/api/overview'
import type { DateRangeParams } from '@/features/usage/types'
import { getI18nLocale } from '@/i18n'
import { zonedInput, zonedInstant } from '../query'
import { browserTimezone, zonedInput, zonedInstant } from '../query'
import { amountValue } from './amount'
export const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#94a3b8']
type ModelRow = OverviewDashboardCharts['models'][number]
type ModelRow = Pick<OverviewDashboardCharts['models'][number], 'id' | 'label' | 'bucket_start' | 'billable_amount'>
export function chartDate(value: string, timezone: string): string {
return new Intl.DateTimeFormat(getI18nLocale(), { timeZone: timezone, month: 'short', day: 'numeric' }).format(new Date(value))
}
function knownSum(rows: { billable_amount: OverviewBreakdown['billable_amount'] }[]): number | null {
function knownSum(rows: Pick<OverviewDashboardChartMetrics, 'billable_amount'>[]): number | null {
if (!rows.length) return 0
const values = rows.map(row => amountValue(row.billable_amount)).filter((value): value is number => value !== null)
return values.length ? values.reduce((sum, value) => sum + value, 0) : null
}
export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: string, otherLabel: string) {
export function modelDatasets(data: { series: Pick<OverviewDashboardCharts['series'][number], 'bucket_start' | 'billable_amount'>[]; models: ModelRow[] }, unknownLabel: string, otherLabel: string) {
const groups = new Map<string | null, ModelRow[]>()
for (const row of data.models) {
const rows = groups.get(row.id)
@@ -28,13 +29,18 @@ export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: strin
if (sorted.length > 6) visible.push({ id: '__other__', label: otherLabel, rows: sorted.slice(6).flatMap(([, rows]) => rows) })
return visible.map((group, index) => ({
label: group.label,
data: data.series.map(day => knownSum(group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start)))),
data: data.series.map(day => {
const rows = group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start))
const incomplete = amountValue(day.billable_amount) === null
|| day.billable_amount.status === 'known_subtotal' || day.billable_amount.status === 'estimated_subtotal'
return !rows.length && incomplete ? null : knownSum(rows)
}),
backgroundColor: chartColors[index % chartColors.length],
borderRadius: 2, stack: 'models', barPercentage: 0.6, categoryPercentage: 0.7,
}))
}
export function providerSlices(rows: OverviewBreakdown[], unknownLabel: string, otherLabel: string) {
export function providerSlices(rows: Pick<OverviewDashboardCharts['providers'][number], 'label' | 'billable_amount'>[], unknownLabel: string, otherLabel: string) {
const known = rows.map(row => ({ label: row.label || unknownLabel, value: amountValue(row.billable_amount) }))
.filter((row): row is { label: string; value: number } => row.value !== null && row.value > 0)
.sort((a, b) => b.value - a.value)
@@ -55,6 +61,25 @@ export function dayRange(bucket: string, range: OverviewRange): OverviewRange {
}
}
/** The dashboard picker uses inclusive calendar dates, not rolling 24-hour periods. */
export function dashboardChartRange(params: DateRangeParams, now = new Date()): OverviewRange {
const timezone = params.timezone || browserTimezone()
if (params.from && params.to) {
if (!Number.isFinite(Date.parse(params.from)) || Date.parse(params.from) >= Date.parse(params.to)) {
throw new Error('Invalid dashboard time range')
}
return { from: new Date(params.from).toISOString(), to: new Date(params.to).toISOString(), timezone }
}
const shiftDate = (date: string, days: number) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
const today = zonedInput(now, timezone).slice(0, 10)
const days = params.preset === 'last90days' ? 90 : params.preset === 'last30days' ? 30
: params.preset === 'today' || params.preset === 'yesterday' ? 1 : 7
const endDate = params.end_date || shiftDate(today, params.preset === 'yesterday' ? -1 : 0)
const startDate = params.start_date || shiftDate(endDate, 1 - days)
if (startDate > endDate) throw new Error('Invalid dashboard time range')
return { from: localDayStart(startDate, timezone), to: localDayStart(shiftDate(endDate, 1), timezone), timezone }
}
function localDayStart(date: string, timezone: string): string {
const midnight = Date.parse(`${date}T00:00:00Z`)
// Some timezone transitions skip midnight; start at the first existing local minute.
+196 -59
View File
@@ -356,12 +356,31 @@
</h3>
<TimeRangePicker
v-model="dailyTimeRange"
:allow-hourly="true"
:allow-hourly="!isAdmin"
:show-granularity="!isAdmin"
/>
</div>
<div
v-if="dailyError"
role="alert"
class="flex items-center justify-between gap-3 rounded-lg border border-border p-4 text-sm text-muted-foreground"
>
<span>{{ dailyError }}</span>
<Button
variant="outline"
size="sm"
@click="loadDailyStats"
>
{{ t('重试', 'Retry') }}
</Button>
</div>
<!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
v-if="!dailyError"
class="grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- 每日使用趋势(折线图)- 普通用户可见 -->
<Card
v-if="!isAdmin"
@@ -399,7 +418,7 @@
</div>
</Card>
<!-- 每日模型成本(堆叠柱状图)- 仅管理员可见 -->
<!-- 每日模型费用(堆叠柱状图)- 仅管理员可见 -->
<Card
v-if="isAdmin"
class="p-5"
@@ -407,7 +426,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
每日模型成本
每日模型费用
</h4>
<div
v-if="loadingDaily"
@@ -422,7 +441,7 @@
<BarChart
v-if="
dailyModelCostChartData.labels &&
dailyModelCostChartData.labels.length > 0
dailyModelCostChartData.labels.length > 0 && hasDailyModelCost
"
:data="dailyModelCostChartData"
:options="dailyModelCostChartOptions"
@@ -431,12 +450,12 @@
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
{{ dailyCostEmptyLabel }}
</div>
</div>
</Card>
<!-- 提供商成本分布(环形图)- 仅管理员可见 -->
<!-- 提供商费用分布(环形图)- 仅管理员可见 -->
<Card
v-if="isAdmin"
class="p-5"
@@ -444,7 +463,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
提供商成本分布
提供商费用分布
</h4>
<div
v-if="loadingDaily"
@@ -468,12 +487,12 @@
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
{{ dailyCostEmptyLabel }}
</div>
</div>
</Card>
<!-- 每日模型成本(堆叠柱状图)- 普通用户可见 -->
<!-- 每日模型费用(堆叠柱状图)- 普通用户可见 -->
<Card
v-if="!isAdmin"
class="p-5"
@@ -481,7 +500,7 @@
<h4
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
>
每日模型成本
每日模型费用
</h4>
<div
v-if="loadingDaily"
@@ -511,8 +530,18 @@
</Card>
</div>
<p
v-if="dailyCostsPartial && !loadingDaily"
class="mt-3 text-xs text-amber-700 dark:text-amber-400"
>
{{ t('统计为已知数据小计,分布占比按已知金额计算', 'Statistics are known subtotals; distribution shares use known amounts') }}
</p>
<!-- 每日统计 -->
<Card class="overflow-hidden mt-6">
<Card
v-if="!dailyError"
class="overflow-hidden mt-6"
>
<!-- 移动端:卡片列表 -->
<div class="sm:hidden">
<div class="px-4 py-3 border-b border-border/60">
@@ -528,7 +557,7 @@
<span class="ml-2 text-muted-foreground text-xs">加载中...</span>
</div>
<div
v-else-if="dailyStats.length === 0"
v-else-if="displayDailyStats.length === 0"
class="py-8 text-center text-muted-foreground text-xs"
>
暂无数据
@@ -538,19 +567,20 @@
class="divide-y divide-border/60"
>
<div
v-for="stat in dailyStats.slice().reverse()"
v-for="stat in displayDailyStats.slice().reverse()"
:key="stat.date"
class="p-4 space-y-2"
>
<div class="flex items-center justify-between">
<span class="font-medium text-sm">{{
formatDate(stat.date)
formatDailyDate(stat.date)
}}</span>
<Badge
variant="success"
class="text-[10px]"
>
${{ stat.cost.toFixed(4) }}
{{ formatDailyCost(stat.cost) }}
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
@@ -560,7 +590,7 @@
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Tokens</span>
<span>{{ formatTokens(stat.tokens) }}</span>
<span>{{ compactTokens(stat.tokens) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">响应</span>
@@ -617,7 +647,7 @@
</div>
</TableCell>
</TableRow>
<TableRow v-else-if="dailyStats.length === 0">
<TableRow v-else-if="displayDailyStats.length === 0">
<TableCell
:colspan="isAdmin ? 7 : 6"
class="text-center py-8 text-muted-foreground text-xs"
@@ -627,11 +657,12 @@
</TableRow>
<template v-else>
<TableRow
v-for="stat in dailyStats.slice().reverse()"
v-for="stat in displayDailyStats.slice().reverse()"
:key="stat.date"
:data-daily-date="stat.date"
>
<TableCell class="font-medium text-xs">
{{ formatDate(stat.date) }}
{{ formatDailyDate(stat.date) }}
</TableCell>
<TableCell class="text-center text-xs">
{{ stat.requests.toLocaleString() }}
@@ -641,7 +672,7 @@
variant="secondary"
class="text-[10px]"
>
{{ formatTokens(stat.tokens) }}
{{ compactTokens(stat.tokens) }}
</Badge>
</TableCell>
<TableCell class="text-center">
@@ -649,7 +680,8 @@
variant="success"
class="text-[10px]"
>
${{ stat.cost.toFixed(4) }}
{{ formatDailyCost(stat.cost) }}
<span v-if="stat.billableAmount">{{ amountStatus(stat.billableAmount, t) }}</span>
</Badge>
</TableCell>
<TableCell class="text-center">
@@ -667,7 +699,7 @@
v-if="isAdmin"
class="text-center text-xs"
>
{{ stat.unique_providers }}
{{ stat.unique_providers ?? '—' }}
</TableCell>
</TableRow>
</template>
@@ -676,8 +708,9 @@
<!-- 汇总信息 -->
<div
v-if="dailyStats.length > 0"
v-if="displayDailyStats.length > 0 && !loadingDaily"
class="border-t border-border bg-muted/30 backdrop-blur-sm px-4 py-3 text-xs"
data-daily-total
>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="text-center">
@@ -693,7 +726,7 @@
总Tokens
</div>
<div class="font-semibold text-book-cloth dark:text-kraft">
{{ formatTokens(totalStats.tokens) }}
{{ compactTokens(totalStats.tokens) }}
</div>
</div>
<div class="text-center">
@@ -701,7 +734,8 @@
总费用
</div>
<div class="font-semibold text-amber-600 dark:text-amber-400">
${{ totalStats.cost.toFixed(4) }}
{{ formatDailyCost(totalStats.cost) }}
<span v-if="adminDailyCharts">{{ amountStatus(adminDailyCharts.summary.billable_amount, t) }}</span>
</div>
</div>
<div class="text-center">
@@ -741,8 +775,10 @@ import {
type DailyStat,
type ProviderSummary,
} from "@/api/dashboard";
import { overviewApi, type OverviewDashboardSummary } from "@/api/overview";
import { amountValue } from "@/features/overview/dashboard/amount";
import { overviewApi, type OverviewAmount, type OverviewDashboardCharts, type OverviewDashboardSummary, type OverviewRange } from "@/api/overview";
import { amountStatus, amountValue } from "@/features/overview/dashboard/amount";
import { chartDate, dashboardChartRange, modelDatasets, providerSlices } from "@/features/overview/dashboard/charts";
import { zonedInput } from "@/features/overview/query";
import { count, percent, timestamp } from "@/features/overview/format";
import DashboardActivity from "@/features/overview/dashboard/DashboardActivity.vue";
import DashboardAnnouncements from "@/features/overview/dashboard/DashboardAnnouncements.vue";
@@ -926,19 +962,43 @@ const tokenBreakdown = ref<{
const dailyStats = ref<DailyStat[]>([]);
const providerSummary = ref<ProviderSummary[]>([]);
const adminDailyCharts = ref<OverviewDashboardCharts | null>(null);
const adminDailyRange = ref<OverviewRange | null>(null);
type DisplayDailyStat = Omit<DailyStat, 'tokens' | 'cost' | 'avg_response_time' | 'model_breakdown' | 'unique_providers'> & {
tokens: number | null;
cost: number | null;
avg_response_time: number | null;
unique_providers?: number | null;
billableAmount?: OverviewAmount;
};
const displayDailyStats = computed<DisplayDailyStat[]>(() => {
if (!isAdmin.value || (import.meta.env.DEV && isDemo.value)) return dailyStats.value;
const charts = adminDailyCharts.value;
if (!charts) return [];
return charts.series.map(day => ({
date: day.bucket_start,
requests: day.request_count,
tokens: day.total_tokens,
cost: amountValue(day.billable_amount),
billableAmount: day.billable_amount,
avg_response_time: day.latency_ms.avg === null ? null : day.latency_ms.avg / 1000,
unique_models: new Set(charts.models.filter(model => Date.parse(model.bucket_start) === Date.parse(day.bucket_start) && model.id !== null).map(model => model.id)).size,
unique_providers: day.unique_providers,
}));
});
const dailyTimeRange = ref<DateRangeParams>(
getDateRangeFromPeriod("last7days"),
{ ...getDateRangeFromPeriod("last7days"), granularity: 'day' },
);
// 统计周期
const loadingDaily = ref(false);
const dailyError = ref('');
const loading = ref(false);
const dashboardError = ref("");
let dashboardRequestId = 0;
let dashboardController: AbortController | null = null;
let dashboardTimezone: string | null = null;
let dailyStatsRequestId = 0;
let dailyStatsLoadPromise: Promise<void> | null = null;
let hasPendingDailyStatsLoad = false;
let dailyStatsController: AbortController | null = null;
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -975,6 +1035,15 @@ const emptyStatPlaceholders = computed(() => {
const statSkeletonCount = computed(() => emptyStatPlaceholders.value.length);
const totalStats = computed(() => {
if (isAdmin.value && adminDailyCharts.value) {
const summary = adminDailyCharts.value.summary;
return {
requests: summary.request_count,
tokens: summary.total_tokens,
cost: amountValue(summary.billable_amount),
avgResponseTime: summary.latency_ms.avg === null ? null : summary.latency_ms.avg / 1000,
};
}
if (dailyStats.value.length === 0) {
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 };
}
@@ -997,7 +1066,7 @@ const totalStats = computed(() => {
};
});
// 每日模型成本(堆叠柱状图)
// 每日模型费用(堆叠柱状图)
const MODEL_COLORS = [
"rgba(59, 130, 246, 0.8)", // blue
"rgba(239, 68, 68, 0.8)", // red
@@ -1010,6 +1079,13 @@ const MODEL_COLORS = [
];
const dailyModelCostChartData = computed<ChartData<"bar">>(() => {
if (isAdmin.value && adminDailyCharts.value && adminDailyRange.value) {
const timezone = adminDailyRange.value.timezone;
return {
labels: adminDailyCharts.value.series.map(day => chartDate(day.bucket_start, timezone)),
datasets: modelDatasets(adminDailyCharts.value, t('未知模型', 'Unknown model'), t('其他模型', 'Other models')),
};
}
if (dailyStats.value.length === 0) {
return { labels: [], datasets: [] };
}
@@ -1057,6 +1133,23 @@ const dailyModelCostChartData = computed<ChartData<"bar">>(() => {
};
});
const hasDailyModelCost = computed(() => dailyModelCostChartData.value.datasets.some(dataset => dataset.data.some(value => typeof value === 'number' && value !== 0)));
const dailyCostsPartial = computed(() => {
const data = adminDailyCharts.value;
if (!data) return false;
const amounts = [data.summary, ...data.series, ...data.models, ...data.providers].map(row => row.billable_amount);
return amounts.some(amount => amountValue(amount) !== null)
&& amounts.some(amount => amountValue(amount) === null || amount.status === 'known_subtotal' || amount.status === 'estimated_subtotal');
});
const dailyCostEmptyLabel = computed(() => {
const data = adminDailyCharts.value;
if (!data) return t('暂无数据', 'No data');
const amount = amountValue(data.summary.billable_amount);
if (amount === null || dailyCostsPartial.value) return t('费用尚未确认', 'Cost not yet known');
if (amount > 0) return t('暂无费用明细', 'No cost breakdown available');
return data.summary.request_count === 0 ? t('暂无数据', 'No data') : t('此周期暂无计费费用', 'No billable cost in this period');
});
const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -1106,7 +1199,7 @@ const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
},
}));
// 提供商成本分布(环形图)
// 提供商费用分布(环形图)
const PROVIDER_COLORS = [
"rgba(59, 130, 246, 0.8)", // blue
"rgba(239, 68, 68, 0.8)", // red
@@ -1119,6 +1212,13 @@ const PROVIDER_COLORS = [
];
const providerCostChartData = computed<ChartData<"doughnut">>(() => {
if (isAdmin.value && adminDailyCharts.value) {
const slices = providerSlices(adminDailyCharts.value.providers, t('未知提供商', 'Unknown provider'), t('其他提供商', 'Other providers'));
return {
labels: slices.map(slice => slice.label),
datasets: [{ data: slices.map(slice => slice.value), backgroundColor: slices.map((_, i) => PROVIDER_COLORS[i % PROVIDER_COLORS.length]), borderWidth: 2, borderColor: 'rgba(255, 255, 255, 0.1)' }],
};
}
if (providerSummary.value.length === 0) {
return { labels: [], datasets: [] };
}
@@ -1275,9 +1375,9 @@ onBeforeUnmount(() => {
clearTimeout(dailyStatsDebounceTimer);
dailyStatsDebounceTimer = null;
}
hasPendingDailyStatsLoad = false;
dailyStatsLoadPromise = null;
dailyStatsRequestId += 1;
dailyStatsController?.abort();
dailyStatsController = null;
dashboardRequestId += 1;
dashboardController?.abort();
dashboardController = null;
@@ -1380,40 +1480,50 @@ function adminStatCards(snapshot: OverviewDashboardSummary): DashboardStatCard[]
}
async function loadDailyStats() {
if (dailyStatsLoadPromise) {
hasPendingDailyStatsLoad = true;
return dailyStatsLoadPromise;
}
dailyStatsController?.abort();
const controller = new AbortController();
dailyStatsController = controller;
const requestId = ++dailyStatsRequestId;
loadingDaily.value = true;
dailyStatsLoadPromise = (async () => {
try {
dailyError.value = '';
try {
if (isAdmin.value && !(import.meta.env.DEV && isDemo.value)) {
const range = dashboardChartRange(dailyTimeRange.value);
const response = await overviewApi.dashboardCharts(range, controller.signal);
if (requestId !== dailyStatsRequestId || controller.signal.aborted) return;
adminDailyCharts.value = response.data;
adminDailyRange.value = range;
dailyStats.value = [];
providerSummary.value = [];
} else {
const response = import.meta.env.DEV && isDemo.value
? (await import('@/features/overview/dashboard/demo')).createDashboardDailyDemo(dailyTimeRange.value)
: await dashboardApi.getDailyStats(dailyTimeRange.value);
if (requestId !== dailyStatsRequestId) return;
if (requestId !== dailyStatsRequestId || controller.signal.aborted) return;
adminDailyCharts.value = null;
adminDailyRange.value = null;
dailyStats.value = response.daily_stats;
providerSummary.value = response.provider_summary || [];
} catch {
if (requestId !== dailyStatsRequestId) return;
dailyStats.value = [];
providerSummary.value = [];
} finally {
if (requestId === dailyStatsRequestId) {
loadingDaily.value = false;
}
}
})().finally(() => {
dailyStatsLoadPromise = null;
if (hasPendingDailyStatsLoad) {
hasPendingDailyStatsLoad = false;
void loadDailyStats();
} catch {
if (requestId !== dailyStatsRequestId || controller.signal.aborted) return;
if (isAdmin.value) dailyError.value = t('统计加载失败,请重试', 'Statistics could not be loaded. Please retry.');
dailyStats.value = [];
providerSummary.value = [];
adminDailyCharts.value = null;
adminDailyRange.value = null;
} finally {
if (requestId === dailyStatsRequestId) {
loadingDaily.value = false;
dailyStatsController = null;
}
});
return dailyStatsLoadPromise;
}
}
function scheduleDailyStatsLoad() {
dailyStatsController?.abort();
dailyStatsRequestId += 1;
loadingDaily.value = true;
if (dailyStatsDebounceTimer) {
clearTimeout(dailyStatsDebounceTimer);
}
@@ -1423,15 +1533,24 @@ function scheduleDailyStatsLoad() {
}, 120);
}
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true });
watch(() => JSON.stringify([
dailyTimeRange.value.from, dailyTimeRange.value.to,
dailyTimeRange.value.start_date, dailyTimeRange.value.end_date,
dailyTimeRange.value.preset, dailyTimeRange.value.granularity,
dailyTimeRange.value.timezone, dailyTimeRange.value.tz_offset_minutes,
]), scheduleDailyStatsLoad);
watch(isDemo, () => {
dashboardController?.abort();
dashboardController = null;
dashboardSnapshot.value = null;
demoTimeline.value = null;
dailyStatsRequestId += 1;
dailyStatsController?.abort();
dailyStats.value = [];
providerSummary.value = [];
adminDailyCharts.value = null;
adminDailyRange.value = null;
dailyError.value = '';
void loadDashboardData();
void loadDailyStats();
});
@@ -1463,7 +1582,25 @@ function formatDateForChart(dateString: string): string {
return date.toLocaleDateString(getI18nLocale(), { month: "numeric", day: "numeric" });
}
function formatResponseTime(seconds: number): string {
function formatDailyCost(cost: number | null): string {
return cost === null ? '—' : `$${cost.toFixed(4)}`;
}
function formatDailyDate(value: string): string {
if (isAdmin.value && adminDailyRange.value) {
const timezone = adminDailyRange.value.timezone;
const date = zonedInput(value, timezone).slice(0, 10);
const today = zonedInput(new Date(), timezone).slice(0, 10);
const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
if (date === today) return formatRelativeTime(0, 'day');
if (date === yesterday) return formatRelativeTime(-1, 'day');
return parseDateLike(date).toLocaleDateString(getI18nLocale(), { month: '2-digit', day: '2-digit', weekday: 'short' });
}
return formatDate(value);
}
function formatResponseTime(seconds: number | null): string {
if (seconds === null) return '—';
if (seconds === 0) return "-";
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
return `${seconds.toFixed(2)}s`;
@@ -1,13 +1,14 @@
import { createApp, nextTick, type App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OverviewDashboardSummary } from '@/api/overview'
import type { OverviewDashboardCharts, OverviewDashboardSummary, OverviewDashboardChartMetrics, OverviewRange, OverviewResponse } 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() }))
const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), dashboardCharts: vi.fn() }))
const legacyDaily = vi.hoisted(() => 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('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: legacyDaily } }))
vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
@@ -32,6 +33,33 @@ vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => nu
vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
function dashboardCharts(range: OverviewRange = {
from: '2026-09-19T00:00:00Z', to: '2026-09-19T04:00:00Z', timezone: 'UTC',
}): OverviewResponse<OverviewDashboardCharts> {
const amount = { value: '0', currency: 'USD', basis: 'billable', status: 'known' }
const summary: OverviewDashboardChartMetrics = {
request_count: 0, successful_request_count: 0, failed_request_count: 0,
cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: null,
input_tokens: null, output_tokens: null, total_tokens: 0, usage_active_users: null, slow_request_count: null,
success_rate: { value: null, numerator: 0, denominator: 0 },
latency_ms: { avg: null, p50: null, p95: null, p99: null, sample_count: 0 },
rated_amount: amount, billable_amount: amount, quota_covered_amount: amount,
wallet_consumed_amount: amount, wallet_debit_amount: amount,
}
return {
meta: {
schema_version: 1, metric_version: 'test', scope: { kind: 'admin' },
range: { ...range, time_basis: 'request_started_at' },
generated_at: range.to, data_through: range.to, read_revision: 'test',
coverage: {
status: 'complete', request_count: 0, usage_available_count: 0,
pricing_available_count: 0, settled_count: 0,
},
},
data: { summary, series: [], models: [], providers: [] },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
@@ -52,13 +80,13 @@ beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
api.dashboardSummary.mockResolvedValue(dashboardSummary())
api.daily.mockResolvedValue({ daily_stats: [], provider_summary: [] })
api.dashboardCharts.mockImplementation((range: OverviewRange) => Promise.resolve(dashboardCharts(range)))
})
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(() => {}))
api.dashboardCharts.mockReturnValue(new Promise(() => {}))
const root = mount()
await settle()
expect(root.textContent).toContain('总请求 12,345')
@@ -68,9 +96,14 @@ describe('dashboard snapshot loading', () => {
expect(api.dashboardSummary).toHaveBeenCalledWith(Intl.DateTimeFormat().resolvedOptions().timeZone, expect.any(AbortSignal))
expect(api.summary).not.toHaveBeenCalled()
expect(api.dashboardTotal).not.toHaveBeenCalled()
expect(api.dashboardCharts).toHaveBeenCalledWith({
from: expect.any(String), to: expect.any(String),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, expect.any(AbortSignal))
expect(legacyDaily).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(240_000)
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
expect(api.daily).toHaveBeenCalledTimes(1)
expect(api.dashboardCharts).toHaveBeenCalledTimes(1)
})
it('labels restored history with its UTC activity dates independently of the dashboard timezone', async () => {
@@ -181,7 +214,29 @@ describe('dashboard snapshot loading', () => {
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)
expect(api.dashboardCharts.mock.calls.length).toBeLessThanOrEqual(2)
expect(legacyDaily).not.toHaveBeenCalled()
})
it('aborts the chart request when unmounted and ignores late success', async () => {
const charts = deferred<OverviewResponse<OverviewDashboardCharts>>()
api.dashboardCharts.mockReturnValue(charts.promise)
const root = mount()
await settle()
const [range, signal] = api.dashboardCharts.mock.calls[0] as [OverviewRange, AbortSignal]
expect(signal.aborted).toBe(false)
expect(root.textContent).toContain('总请求 12,345')
app?.unmount()
app = undefined
expect(signal.aborted).toBe(true)
const response = dashboardCharts(range)
response.data.series = [{ ...response.data.summary, bucket_start: range.from, request_count: 987654 }]
charts.resolve(response)
await settle()
expect(root.textContent).toBe('')
await vi.advanceTimersByTimeAsync(240_000)
expect(api.dashboardCharts).toHaveBeenCalledTimes(1)
expect(legacyDaily).not.toHaveBeenCalled()
})
it('aborts the compact snapshot request when unmounted and ignores late success', async () => {
@@ -7,7 +7,7 @@ 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 overviewApiMocks = vi.hoisted(() => ({ dashboard: vi.fn(), dashboardTotal: vi.fn(), summary: vi.fn(), dashboardSummary: vi.fn(), dashboardCharts: vi.fn() }))
const announcementApiMocks = vi.hoisted(() => ({ getAnnouncements: vi.fn(), markAsRead: vi.fn() }))
vi.mock('@/api/overview', () => ({ overviewApi: overviewApiMocks }))
@@ -130,6 +130,7 @@ beforeEach(() => {
overviewApiMocks.dashboardTotal.mockReset()
overviewApiMocks.summary.mockReset()
overviewApiMocks.dashboardSummary.mockReset()
overviewApiMocks.dashboardCharts.mockReset()
dashboardApiMocks.getStats.mockReset()
dashboardApiMocks.getDailyStats.mockReset()
dashboardApiMocks.getDailyStats.mockResolvedValue({
@@ -176,6 +177,7 @@ describe('Dashboard ordinary user wallet card', () => {
expect(overviewApiMocks.dashboardTotal).not.toHaveBeenCalled()
expect(overviewApiMocks.summary).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboardSummary).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled()
expect(root.querySelector('#announcements-section')).not.toBeNull()
expect(root.querySelector('[role="dialog"]')).toBeNull()
expect(root.textContent).toContain('系统公告')
@@ -216,6 +218,7 @@ describe('Dashboard refresh controls', () => {
expect(root.textContent).not.toContain('自动刷新')
expect(dashboardApiMocks.getStats).toHaveBeenCalledTimes(1)
expect(dashboardApiMocks.getDailyStats).toHaveBeenCalledTimes(1)
expect(overviewApiMocks.dashboardCharts).not.toHaveBeenCalled()
expect(announcementApiMocks.getAnnouncements).not.toHaveBeenCalled()
expect(overviewApiMocks.dashboard).not.toHaveBeenCalled()