mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
fix: align dashboard charts with customer billing and history coverage
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user