add dashboard page

This commit is contained in:
2026-03-20 14:20:51 +08:00
parent e9ffb85aff
commit 1b04027b74
2 changed files with 305 additions and 48 deletions
+215
View File
@@ -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<typeof mount>
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
const stubs = {
ReloadOutlined: { template: '<span />' },
CheckCircleOutlined: { template: '<span />' },
CloseCircleOutlined: { template: '<span />' },
ShoppingCartOutlined: { template: '<span />' },
ShoppingOutlined: { template: '<span />' },
DollarOutlined: { template: '<span />' },
DatabaseOutlined: { template: '<span />' },
DashboardTrendChart: { template: '<div class="trend-stub" />' },
}
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('服务器错误')
})
})
+90 -48
View File
@@ -1,55 +1,97 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ReloadOutlined } from '@ant-design/icons-vue'
ShoppingCartOutlined, import { useDashboardStore } from '@/stores/dashboard'
ShoppingOutlined, import DashboardOverviewCards from '@/components/DashboardOverviewCards.vue'
DollarOutlined, import DashboardByTypeCards from '@/components/DashboardByTypeCards.vue'
CloudServerOutlined, import DashboardTrendChart from '@/components/DashboardTrendChart.vue'
} from '@ant-design/icons-vue' import DashboardBreakdownTable from '@/components/DashboardBreakdownTable.vue'
const store = useDashboardStore()
function handleRefresh() {
store.fetchAll()
}
function handleTrendGroupByChange(val: 'day' | 'week' | 'month') {
store.trendGroupBy = val
store.fetchTrend()
}
function handleTrendDataTypeChange(val: string | undefined) {
store.trendDataType = val
store.fetchTrend()
}
function handleBreakdownDimensionChange(val: 'company' | 'platform' | 'store') {
store.breakdownDimension = val
store.fetchBreakdown()
}
function handleBreakdownDataTypeChange(val: string | undefined) {
store.breakdownDataType = val
store.fetchBreakdown()
}
function handleBreakdownDateRangeChange(range: [string, string] | null) {
store.breakdownFrom = range?.[0]
store.breakdownTo = range?.[1]
store.fetchBreakdown()
}
onMounted(() => {
store.fetchAll()
})
</script> </script>
<template> <template>
<div> <div>
<h2 class="text-xl font-semibold mb-4">Dashboard</h2> <!-- 标题行 -->
<a-row :gutter="16"> <div class="flex items-center justify-between mb-4">
<a-col :span="6"> <h2 class="text-xl font-semibold m-0">Dashboard</h2>
<a-card :bordered="false"> <a-button type="primary" @click="handleRefresh">
<a-statistic title="订单总数" value="--"> <template #icon><ReloadOutlined /></template>
<template #prefix> 刷新
<ShoppingCartOutlined /> </a-button>
</template> </div>
</a-statistic>
</a-card> <!-- 错误提示 -->
</a-col> <a-alert
<a-col :span="6"> v-if="store.overviewError"
<a-card :bordered="false"> type="error"
<a-statistic title="产品总数" value="--"> :message="store.overviewError"
<template #prefix> show-icon
<ShoppingOutlined /> closable
</template> class="mb-4"
</a-statistic> />
</a-card>
</a-col> <!-- 概览统计卡片 -->
<a-col :span="6"> <DashboardOverviewCards :overview="store.overview" :loading="store.overviewLoading" />
<a-card :bordered="false">
<a-statistic title="退款总数" value="--"> <!-- 按数据类型统计 -->
<template #prefix> <DashboardByTypeCards
<DollarOutlined /> :by-type="store.overview?.by_type ?? []"
</template> :loading="store.overviewLoading"
</a-statistic> />
</a-card>
</a-col> <!-- 趋势折线图 -->
<a-col :span="6"> <DashboardTrendChart
<a-card :bordered="false"> :data="store.trendData"
<a-statistic title="队列消息" value="--"> :loading="store.trendLoading"
<template #prefix> :group-by="store.trendGroupBy"
<CloudServerOutlined /> :data-type="store.trendDataType"
</template> @update:group-by="handleTrendGroupByChange"
</a-statistic> @update:data-type="handleTrendDataTypeChange"
</a-card> />
</a-col>
</a-row> <!-- 分维度统计表格 -->
<a-card class="mt-4" title="欢迎使用 DataHub 数据管理平台"> <DashboardBreakdownTable
<p class="text-gray-500">请从左侧菜单选择功能模块开始使用</p> :data="store.breakdownData"
</a-card> :loading="store.breakdownLoading"
:dimension="store.breakdownDimension"
:data-type="store.breakdownDataType"
@update:dimension="handleBreakdownDimensionChange"
@update:data-type="handleBreakdownDataTypeChange"
@update:date-range="handleBreakdownDateRangeChange"
/>
</div> </div>
</template> </template>