From 1b04027b74b4cb0198ef0a308cb65424a711b03b Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 20 Mar 2026 14:20:51 +0800 Subject: [PATCH] add dashboard page --- frontend/src/pages/__tests__/index.spec.ts | 215 +++++++++++++++++++++ frontend/src/pages/index.vue | 138 ++++++++----- 2 files changed, 305 insertions(+), 48 deletions(-) create mode 100644 frontend/src/pages/__tests__/index.spec.ts diff --git a/frontend/src/pages/__tests__/index.spec.ts b/frontend/src/pages/__tests__/index.spec.ts new file mode 100644 index 0000000..e4b9a03 --- /dev/null +++ b/frontend/src/pages/__tests__/index.spec.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { setActivePinia, createPinia } from 'pinia' +import { useDashboardStore } from '@/stores/dashboard' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +// Stub G2Plot — jsdom has no Canvas +vi.mock('@antv/g2plot', () => ({ + Line: vi.fn().mockImplementation(() => ({ + render: vi.fn(), + changeData: vi.fn(), + destroy: vi.fn(), + })), +})) + +import { api } from '@/utils/request' + +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 }, +] + +const mockBreakdownData = [ + { id: 1, name: 'Acme Corp', success: 100, failed: 10 }, + { id: 2, name: 'Beta Inc', success: 80, failed: 0 }, +] + +function setupApiMocks() { + 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 + }) +} + +describe('DashboardPage', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + const stubs = { + ReloadOutlined: { template: '' }, + CheckCircleOutlined: { template: '' }, + CloseCircleOutlined: { template: '' }, + ShoppingCartOutlined: { template: '' }, + ShoppingOutlined: { template: '' }, + DollarOutlined: { template: '' }, + DatabaseOutlined: { template: '' }, + DashboardTrendChart: { template: '
' }, + } + + async function mountPage() { + setupApiMocks() + + const { default: DashboardPage } = await import('../index.vue') + + wrapper = mount(DashboardPage, { + attachTo: document.body, + global: { stubs }, + }) + await flushPromises() + await nextTick() + } + + it('P1: renders page title and refresh button', async () => { + await mountPage() + + expect(wrapper.text()).toContain('Dashboard') + const buttons = wrapper.findAll('.ant-btn') + const buttonTexts = buttons.map((b) => b.text()) + expect(buttonTexts.some((t) => t.includes('刷新'))).toBe(true) + }, 15000) + + it('P2: calls fetchAll on mount', async () => { + await mountPage() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/overview') + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/trend', expect.any(Object)) + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/breakdown', expect.any(Object)) + }) + + it('P3: displays overview period labels', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('今日') + expect(html).toContain('本周') + expect(html).toContain('本月') + }) + + it('P4: displays overview success/failed values', async () => { + await mountPage() + + const html = wrapper.html() + // Today's success=10, failed=2 + expect(html).toContain('10') + expect(html).toContain('2') + }) + + it('P5: displays by-type cards', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('订单') + expect(html).toContain('产品') + expect(html).toContain('退款') + expect(html).toContain('库存') + }) + + it('P6: displays breakdown table data', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('Acme Corp') + expect(html).toContain('Beta Inc') + }) + + it('P7: failed count > 0 gets red highlight in breakdown', async () => { + await mountPage() + + const redCells = document.body.querySelectorAll('.text-red-500') + expect(redCells.length).toBeGreaterThan(0) + }) + + it('P8: refresh button triggers fetchAll', async () => { + await mountPage() + vi.mocked(api.get).mockClear() + setupApiMocks() + + const refreshBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('刷新')) + await refreshBtn?.trigger('click') + await flushPromises() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/overview') + }) + + it('P9: dimension change triggers breakdown refetch', async () => { + await mountPage() + vi.mocked(api.get).mockClear() + setupApiMocks() + + const store = useDashboardStore() + store.breakdownDimension = 'platform' + await store.fetchBreakdown() + + expect(api.get).toHaveBeenCalledWith('/api/v1/dashboard/breakdown', expect.objectContaining({ + dimension: 'platform', + })) + }) + + it('P10: shows error alert when overview fails', async () => { + 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 { default: DashboardPage } = await import('../index.vue') + wrapper = mount(DashboardPage, { + attachTo: document.body, + global: { stubs }, + }) + await flushPromises() + await nextTick() + + const html = wrapper.html() + expect(html).toContain('服务器错误') + }) +}) diff --git a/frontend/src/pages/index.vue b/frontend/src/pages/index.vue index c813e6c..67b3329 100644 --- a/frontend/src/pages/index.vue +++ b/frontend/src/pages/index.vue @@ -1,55 +1,97 @@