add dashboard page
This commit is contained in:
@@ -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('服务器错误')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user