diff --git a/frontend/src/stores/__tests__/dashboard.spec.ts b/frontend/src/stores/__tests__/dashboard.spec.ts new file mode 100644 index 0000000..6effef2 --- /dev/null +++ b/frontend/src/stores/__tests__/dashboard.spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useDashboardStore } from '../dashboard' + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +const mockOverview = { + today: { success: 10, failed: 2 }, + this_week: { success: 50, failed: 5 }, + this_month: { success: 200, failed: 20 }, + by_type: [ + { data_type: 'order', success: 80, failed: 10 }, + { data_type: 'product', success: 60, failed: 5 }, + { data_type: 'refund', success: 40, failed: 3 }, + { data_type: 'inventory', success: 20, failed: 2 }, + ], +} + +const mockTrendData = [ + { date: '2026-03-18', success: 30, failed: 2 }, + { date: '2026-03-19', success: 25, failed: 3 }, + { date: '2026-03-20', success: 40, failed: 1 }, +] + +const mockBreakdownData = [ + { id: 1, name: 'Acme Corp', success: 100, failed: 10 }, + { id: 2, name: 'Beta Inc', success: 80, failed: 5 }, +] + +describe('useDashboardStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('S1: starts with correct initial state', () => { + const store = useDashboardStore() + expect(store.overview).toBeNull() + expect(store.overviewLoading).toBe(false) + expect(store.trendData).toEqual([]) + expect(store.trendGroupBy).toBe('day') + expect(store.breakdownData).toEqual([]) + expect(store.breakdownDimension).toBe('company') + }) + }) + + describe('fetchOverview', () => { + it('S2: fetches overview data successfully', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockOverview) + + const store = useDashboardStore() + await store.fetchOverview() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/overview') + expect(store.overview).toEqual(mockOverview) + expect(store.overviewLoading).toBe(false) + expect(store.overviewError).toBe('') + }) + + it('S3: handles overview fetch error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('网络错误')) + + const store = useDashboardStore() + await store.fetchOverview() + + expect(store.overview).toBeNull() + expect(store.overviewError).toBe('网络错误') + expect(store.overviewLoading).toBe(false) + }) + }) + + describe('fetchTrend', () => { + it('S4: fetches trend data with default params', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockTrendData) + + const store = useDashboardStore() + await store.fetchTrend() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/trend', expect.objectContaining({ + group_by: 'day', + })) + expect(store.trendData).toEqual(mockTrendData) + }) + + it('S5: passes filter params to trend API', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockTrendData) + + const store = useDashboardStore() + store.trendGroupBy = 'week' + store.trendDataType = 'order' + store.trendFrom = '2026-03-01' + store.trendTo = '2026-03-20' + await store.fetchTrend() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/trend', expect.objectContaining({ + group_by: 'week', + data_type: 'order', + from: '2026-03-01', + to: '2026-03-20', + })) + }) + + it('S6: handles trend fetch error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('趋势接口超时')) + + const store = useDashboardStore() + await store.fetchTrend() + + expect(store.trendData).toEqual([]) + expect(store.trendError).toBe('趋势接口超时') + }) + }) + + describe('fetchBreakdown', () => { + it('S7: fetches breakdown data with default params', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockBreakdownData) + + const store = useDashboardStore() + await store.fetchBreakdown() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/breakdown', expect.objectContaining({ + dimension: 'company', + })) + expect(store.breakdownData).toEqual(mockBreakdownData) + }) + + it('S8: passes filter params to breakdown API', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockBreakdownData) + + const store = useDashboardStore() + store.breakdownDimension = 'store' + store.breakdownDataType = 'refund' + store.breakdownFrom = '2026-03-01' + store.breakdownTo = '2026-03-20' + await store.fetchBreakdown() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/breakdown', expect.objectContaining({ + dimension: 'store', + data_type: 'refund', + from: '2026-03-01', + to: '2026-03-20', + })) + }) + + it('S9: handles breakdown fetch error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('分维度接口失败')) + + const store = useDashboardStore() + await store.fetchBreakdown() + + expect(store.breakdownData).toEqual([]) + expect(store.breakdownError).toBe('分维度接口失败') + }) + }) + + describe('fetchAll', () => { + it('S10: calls all three fetch functions in parallel', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('overview')) return Promise.resolve(mockOverview) as never + if (url.includes('trend')) return Promise.resolve(mockTrendData) as never + if (url.includes('breakdown')) return Promise.resolve(mockBreakdownData) as never + return Promise.resolve(null) as never + }) + + const store = useDashboardStore() + await store.fetchAll() + + expect(store.overview).toEqual(mockOverview) + expect(store.trendData).toEqual(mockTrendData) + expect(store.breakdownData).toEqual(mockBreakdownData) + }) + + it('S11: one API failure does not block others', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('overview')) return Promise.reject(new Error('概览失败')) as never + if (url.includes('trend')) return Promise.resolve(mockTrendData) as never + if (url.includes('breakdown')) return Promise.resolve(mockBreakdownData) as never + return Promise.resolve(null) as never + }) + + const store = useDashboardStore() + await store.fetchAll() + + expect(store.overview).toBeNull() + expect(store.overviewError).toBe('概览失败') + expect(store.trendData).toEqual(mockTrendData) + expect(store.breakdownData).toEqual(mockBreakdownData) + }) + }) +}) diff --git a/frontend/src/stores/dashboard.ts b/frontend/src/stores/dashboard.ts new file mode 100644 index 0000000..5b89480 --- /dev/null +++ b/frontend/src/stores/dashboard.ts @@ -0,0 +1,125 @@ +import { api } from '@/utils/request' +import type { + DashboardOverview, + DashboardTrendPoint, + DashboardTrendParams, + DashboardBreakdownItem, + DashboardBreakdownParams, +} from '@/types/api' + +export const useDashboardStore = defineStore('dashboard', () => { + // ─── Overview ─── + const overview = ref(null) + const overviewLoading = ref(false) + const overviewError = ref('') + + // ─── Trend ─── + const trendData = ref([]) + const trendLoading = ref(false) + const trendError = ref('') + const trendGroupBy = ref<'day' | 'week' | 'month'>('day') + const trendDataType = ref(undefined) + const trendFrom = ref(undefined) + const trendTo = ref(undefined) + + // ─── Breakdown ─── + const breakdownData = ref([]) + const breakdownLoading = ref(false) + const breakdownError = ref('') + const breakdownDimension = ref<'company' | 'platform' | 'store'>('company') + const breakdownDataType = ref(undefined) + const breakdownFrom = ref(undefined) + const breakdownTo = ref(undefined) + + async function fetchOverview() { + overviewLoading.value = true + overviewError.value = '' + try { + overview.value = await api.get('/api/v1/dashboard/overview') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取概览数据失败' + overviewError.value = msg + message.error(msg) + } finally { + overviewLoading.value = false + } + } + + async function fetchTrend() { + trendLoading.value = true + trendError.value = '' + try { + const params: DashboardTrendParams = { + group_by: trendGroupBy.value, + data_type: trendDataType.value, + from: trendFrom.value, + to: trendTo.value, + } + trendData.value = await api.get( + '/api/v1/dashboard/trend', + params as unknown as Record, + ) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取趋势数据失败' + trendError.value = msg + message.error(msg) + } finally { + trendLoading.value = false + } + } + + async function fetchBreakdown() { + breakdownLoading.value = true + breakdownError.value = '' + try { + const params: DashboardBreakdownParams = { + dimension: breakdownDimension.value, + data_type: breakdownDataType.value, + from: breakdownFrom.value, + to: breakdownTo.value, + } + breakdownData.value = await api.get( + '/api/v1/dashboard/breakdown', + params as unknown as Record, + ) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取分维度数据失败' + breakdownError.value = msg + message.error(msg) + } finally { + breakdownLoading.value = false + } + } + + async function fetchAll() { + await Promise.allSettled([fetchOverview(), fetchTrend(), fetchBreakdown()]) + } + + return { + // Overview + overview, + overviewLoading, + overviewError, + // Trend + trendData, + trendLoading, + trendError, + trendGroupBy, + trendDataType, + trendFrom, + trendTo, + // Breakdown + breakdownData, + breakdownLoading, + breakdownError, + breakdownDimension, + breakdownDataType, + breakdownFrom, + breakdownTo, + // Actions + fetchOverview, + fetchTrend, + fetchBreakdown, + fetchAll, + } +}) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c781375..13cfd97 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -208,6 +208,51 @@ export interface FailedMessageFilters { failed_at_range: [string, string] | null } +/** ─── Dashboard 统计 ─── */ + +export interface SuccessFailedCount { + success: number + failed: number +} + +export interface DataTypeCount extends SuccessFailedCount { + data_type: string +} + +export interface DashboardOverview { + today: SuccessFailedCount + this_week: SuccessFailedCount + this_month: SuccessFailedCount + by_type: DataTypeCount[] +} + +export interface DashboardTrendPoint { + date: string + success: number + failed: number +} + +export interface DashboardTrendParams { + from?: string + to?: string + group_by?: 'day' | 'week' | 'month' + data_type?: string +} + +export interface DashboardBreakdownItem { + id: number + name: string + success: number + failed: number +} + +export interface DashboardBreakdownParams { + dimension?: 'company' | 'platform' | 'store' + from?: string + to?: string + data_type?: string +} + /** 业务异常 */ export class ApiError extends Error { code: number