update failed messages

This commit is contained in:
2026-03-20 14:01:38 +08:00
parent 7aea1881f3
commit 4d89875105
5 changed files with 1214 additions and 0 deletions
@@ -11,6 +11,7 @@ import {
UnorderedListOutlined, UnorderedListOutlined,
DollarOutlined, DollarOutlined,
MonitorOutlined, MonitorOutlined,
WarningOutlined,
LogoutOutlined, LogoutOutlined,
SettingOutlined, SettingOutlined,
KeyOutlined, KeyOutlined,
@@ -80,6 +81,7 @@ const menuItems: MenuItem[] = [
{ key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true }, { key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true },
{ key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true }, { key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true },
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true }, { key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
{ key: '/failed-messages', icon: WarningOutlined, label: '失败消息', adminOnly: true },
] ]
const filteredMenuItems = computed(() => const filteredMenuItems = computed(() =>
@@ -113,6 +115,7 @@ const breadcrumbItems = computed(() => {
'/roles': '角色管理', '/roles': '角色管理',
'/route-groups': '路由组管理', '/route-groups': '路由组管理',
'/mq-status': '队列监控', '/mq-status': '队列监控',
'/failed-messages': '失败消息',
'/profile': '个人信息', '/profile': '个人信息',
'/profile/password': '修改密码', '/profile/password': '修改密码',
} }
@@ -0,0 +1,420 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { useFailedMessageStore } from '@/stores/failed-message'
import type { FailedMessageRecord, FailedMessageDetail } from '@/types/api'
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(),
patch: vi.fn(),
},
}))
const mockMessages: FailedMessageRecord[] = [
{
id: 1,
error_id: 'err_001',
data_type: 'order',
platform: 'Shopee',
platform_id: 1,
company_id: 1,
store_id: 1,
error_type: 'InvalidArgumentException',
error_message: 'Missing required field: platform_order_id',
retry_count: 3,
message_id: 'msg_abc123',
failed_at: '2026-03-19T10:30:00',
created_at: '2026-03-19T10:30:00',
},
{
id: 2,
error_id: 'err_002',
data_type: 'product',
platform: 'Lazada',
platform_id: 2,
company_id: 1,
store_id: 2,
error_type: 'RuntimeException',
error_message: 'Database connection timeout after 30s',
retry_count: 1,
message_id: 'msg_def456',
failed_at: '2026-03-19T11:00:00',
created_at: '2026-03-19T11:00:00',
},
{
id: 3,
error_id: 'err_003',
data_type: 'refund',
platform: 'Shopee',
platform_id: 1,
company_id: 2,
store_id: 3,
error_type: 'ValidationException',
error_message: 'Refund amount exceeds order total',
retry_count: 0,
message_id: 'msg_ghi789',
failed_at: '2026-03-20T08:15:00',
created_at: '2026-03-20T08:15:00',
},
]
const baseMock = mockMessages[0]!
const mockDetail: FailedMessageDetail = {
...baseMock,
error_code: 'ERR_MISSING_FIELD',
error_trace: '#0 /app/Entity/Parse/EntityParse.php(42)\n#1 /app/Consumer/OrderConsumer.php(58)\n#2 {main}',
original_message: { meta: { company_id: 1 }, data: [{ order_id: '123' }] },
}
const mockPlatforms = [
{ id: 1, name: 'Shopee' },
{ id: 2, name: 'Lazada' },
]
// ─── Store Tests ───────────────────────────────────────
describe('useFailedMessageStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('S1: starts with correct initial state', () => {
const store = useFailedMessageStore()
expect(store.messages).toEqual([])
expect(store.loading).toBe(false)
expect(store.pagination.page).toBe(1)
expect(store.pagination.per_page).toBe(15)
expect(store.pagination.total).toBe(0)
expect(store.filters.data_type).toBeUndefined()
expect(store.filters.platform_id).toBeUndefined()
expect(store.filters.failed_at_range).toBeNull()
})
})
describe('fetchMessages', () => {
it('S2: fetches messages successfully', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce({
items: mockMessages,
total: 3,
page: 1,
per_page: 15,
})
const store = useFailedMessageStore()
await store.fetchMessages()
expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', {
page: 1,
per_page: 15,
data_type: undefined,
platform_id: undefined,
failed_at_from: undefined,
failed_at_to: undefined,
})
expect(store.messages).toEqual(mockMessages)
expect(store.pagination.total).toBe(3)
expect(store.loading).toBe(false)
})
it('S3: fetches with filters applied', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce({
items: [mockMessages[0]],
total: 1,
page: 1,
per_page: 15,
})
const store = useFailedMessageStore()
store.filters.data_type = 'order'
store.filters.platform_id = 1
store.filters.failed_at_range = ['2026-03-19', '2026-03-20']
await store.fetchMessages()
expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', {
page: 1,
per_page: 15,
data_type: 'order',
platform_id: 1,
failed_at_from: '2026-03-19',
failed_at_to: '2026-03-20',
})
expect(store.messages).toHaveLength(1)
})
it('S4: handles fetch failure gracefully', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('网络超时'))
const store = useFailedMessageStore()
await store.fetchMessages()
expect(store.messages).toEqual([])
expect(store.pagination.total).toBe(0)
expect(store.loading).toBe(false)
})
})
describe('resetFilters', () => {
it('S5: resets all filters and page', () => {
const store = useFailedMessageStore()
store.filters.data_type = 'order'
store.filters.platform_id = 1
store.filters.failed_at_range = ['2026-03-01', '2026-03-20']
store.pagination.page = 3
store.resetFilters()
expect(store.filters.data_type).toBeUndefined()
expect(store.filters.platform_id).toBeUndefined()
expect(store.filters.failed_at_range).toBeNull()
expect(store.pagination.page).toBe(1)
})
})
describe('platformMap', () => {
it('S6: builds platformMap from loaded platforms', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPlatforms)
const store = useFailedMessageStore()
await store.loadLookups()
expect(store.platformMap.get(1)).toBe('Shopee')
expect(store.platformMap.get(2)).toBe('Lazada')
})
it('S7: handles loadLookups failure gracefully', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败'))
const store = useFailedMessageStore()
await store.loadLookups()
expect(store.platforms).toEqual([])
expect(store.platformMap.size).toBe(0)
})
})
})
// ─── Page Component Tests ──────────────────────────────
describe('FailedMessagesPage', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
async function mountPage() {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms)
if (url === '/api/v1/failed-messages') {
return Promise.resolve({
items: mockMessages,
total: 3,
page: 1,
per_page: 15,
})
}
return Promise.resolve(null)
})
const { default: FailedMessagesPage } = await import('../index.vue')
wrapper = mount(FailedMessagesPage, {
attachTo: document.body,
global: {
stubs: {
SearchOutlined: { template: '<span />' },
ReloadOutlined: { template: '<span />' },
EyeOutlined: { template: '<span />' },
},
},
})
await flushPromises()
await nextTick()
return { wrapper, api }
}
it('P1: renders page title', async () => {
await mountPage()
expect(wrapper.text()).toContain('失败消息管理')
}, 15000)
it('P2: calls API on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith('/api/v1/platforms')
expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', expect.any(Object))
})
it('P3: renders table with message data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('InvalidArgumentException')
expect(html).toContain('RuntimeException')
expect(html).toContain('Missing required field')
})
it('P4: renders data_type as colored tags with Chinese labels', async () => {
await mountPage()
const tags = wrapper.findAll('.ant-tag')
const tagTexts = tags.map((t) => t.text())
expect(tagTexts).toContain('订单')
expect(tagTexts).toContain('产品')
expect(tagTexts).toContain('退款')
})
it('P5: displays platform name from platformMap', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('Shopee')
expect(html).toContain('Lazada')
})
it('P6: formats failed_at as YYYY-MM-DD HH:mm', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('2026-03-19 10:30')
expect(html).toContain('2026-03-19 11:00')
})
it('P7: search button triggers fetch with page=1', async () => {
const { api } = await mountPage()
const store = useFailedMessageStore()
store.pagination.page = 3
vi.mocked(api.get).mockClear()
vi.mocked(api.get).mockResolvedValue({
items: [],
total: 0,
page: 1,
per_page: 15,
})
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const searchBtn = buttons.find((b) => b.textContent?.trim().includes('搜索'))
expect(searchBtn).toBeDefined()
searchBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(store.pagination.page).toBe(1)
expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', expect.any(Object))
})
it('P8: reset button clears filters and fetches', async () => {
const { api } = await mountPage()
const store = useFailedMessageStore()
store.filters.data_type = 'order'
store.filters.platform_id = 1
vi.mocked(api.get).mockClear()
vi.mocked(api.get).mockResolvedValue({
items: mockMessages,
total: 3,
page: 1,
per_page: 15,
})
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const resetBtn = buttons.find((b) => b.textContent?.trim().includes('重置'))
expect(resetBtn).toBeDefined()
resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(store.filters.data_type).toBeUndefined()
expect(store.filters.platform_id).toBeUndefined()
})
it('P9: clicking view button opens detail drawer', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValue(mockDetail)
const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link'))
expect(viewButtons.length).toBeGreaterThan(0)
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const drawerBody = document.body.querySelector('.ant-drawer')
expect(drawerBody).toBeDefined()
expect(document.body.innerHTML).toContain('失败消息详情')
})
it('P10: detail drawer shows error_trace in pre block', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValue(mockDetail)
const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link'))
expect(viewButtons.length).toBeGreaterThan(0)
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const pres = document.body.querySelectorAll('pre')
const preTexts = Array.from(pres).map((p) => p.textContent)
expect(preTexts.some((t) => t?.includes('#0 /app/Entity/Parse/EntityParse.php(42)'))).toBe(true)
})
it('P11: detail drawer shows original_message as formatted JSON', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValue(mockDetail)
const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link'))
expect(viewButtons.length).toBeGreaterThan(0)
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
const pres = document.body.querySelectorAll('pre')
const preTexts = Array.from(pres).map((p) => p.textContent)
expect(preTexts.some((t) => t?.includes('"company_id": 1'))).toBe(true)
})
it('P12: detail request failure shows error message', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockRejectedValue(new Error('详情加载失败'))
const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link'))
expect(viewButtons.length).toBeGreaterThan(0)
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
// drawer should be open but no detail content
const drawerBody = document.body.querySelector('.ant-drawer')
expect(drawerBody).toBeDefined()
})
})
@@ -0,0 +1,269 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { api } from '@/utils/request'
import { useFailedMessageStore, type FailedMessageRecord } from '@/stores/failed-message'
import type { FailedMessageDetail, FailedMessageDataType } from '@/types/api'
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
} from '@ant-design/icons-vue'
const store = useFailedMessageStore()
// Detail drawer
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const detail = ref<FailedMessageDetail | null>(null)
let detailRequestId = 0
const dataTypeColorMap: Record<FailedMessageDataType, string> = {
order: 'blue',
product: 'green',
refund: 'orange',
inventory: 'purple',
}
const dataTypeLabelMap: Record<FailedMessageDataType, string> = {
order: '订单',
product: '产品',
refund: '退款',
inventory: '库存',
}
const dataTypeOptions = Object.entries(dataTypeLabelMap).map(([value, label]) => ({
label,
value: value as FailedMessageDataType,
}))
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80, fixed: 'left' as const },
{ title: '数据类型', key: 'data_type', width: 100 },
{ title: '平台', key: 'platform', width: 100 },
{ title: '错误类型', dataIndex: 'error_type', width: 200, ellipsis: true },
{ title: '错误信息', key: 'error_message', width: 260 },
{ title: '重试次数', dataIndex: 'retry_count', width: 90 },
{ title: '失败时间', key: 'failed_at', width: 160 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
]
// RangePicker dayjs 桥接
const failedAtRange = computed({
get: () => {
if (!store.filters.failed_at_range) return undefined
return store.filters.failed_at_range.map((d) => dayjs(d)) as [dayjs.Dayjs, dayjs.Dayjs]
},
set: (val) => {
store.filters.failed_at_range = val
? [val[0].format('YYYY-MM-DD'), val[1].format('YYYY-MM-DD')]
: null
},
})
onMounted(() => {
store.loadLookups()
store.fetchMessages()
})
function handleSearch() {
store.pagination.page = 1
store.fetchMessages()
}
function handleReset() {
store.resetFilters()
store.fetchMessages()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchMessages()
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
async function handleViewDetail(record: { id: number }) {
const currentRequestId = ++detailRequestId
drawerVisible.value = true
drawerLoading.value = true
detail.value = null
try {
const data = await api.get<FailedMessageDetail>(`/api/v1/failed-messages/${record.id}`)
if (currentRequestId !== detailRequestId) return
detail.value = data
} catch {
if (currentRequestId !== detailRequestId) return
message.error('获取失败消息详情失败')
} finally {
if (currentRequestId === detailRequestId) {
drawerLoading.value = false
}
}
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">失败消息管理</h2>
<!-- Filter area -->
<a-card class="mb-4">
<a-form layout="inline" @submit.prevent="handleSearch">
<a-form-item label="数据类型">
<a-select
v-model:value="store.filters.data_type"
placeholder="全部类型"
allow-clear
style="width: 120px"
>
<a-select-option v-for="opt in dataTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="平台">
<a-select
v-model:value="store.filters.platform_id"
placeholder="全部平台"
allow-clear
style="width: 140px"
>
<a-select-option v-for="p in store.platforms" :key="p.id" :value="p.id">
{{ store.platformMap.get(p.id) || `平台 #${p.id}` }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="失败时间">
<a-range-picker v-model:value="failedAtRange" format="YYYY-MM-DD" />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- Table -->
<a-card>
<a-table
:columns="columns"
:data-source="store.messages"
:loading="store.loading"
:pagination="false"
:scroll="{ x: 1200 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'data_type'">
<a-tag :color="dataTypeColorMap[record.data_type as FailedMessageDataType] || 'default'">
{{ dataTypeLabelMap[record.data_type as FailedMessageDataType] || record.data_type }}
</a-tag>
</template>
<template v-else-if="column.key === 'platform'">
{{ store.platformMap.get(record.platform_id) || record.platform }}
</template>
<template v-else-if="column.key === 'error_message'">
<a-tooltip :title="record.error_message">
<span class="inline-block max-w-[240px] truncate align-bottom">
{{ record.error_message }}
</span>
</a-tooltip>
</template>
<template v-else-if="column.key === 'failed_at'">
{{ formatTime(record.failed_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button
type="link"
size="small"
@click="handleViewDetail(record as FailedMessageRecord)"
>
<template #icon><EyeOutlined /></template>
查看
</a-button>
</template>
</template>
</a-table>
<div class="mt-4 flex justify-end">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- Detail drawer -->
<a-drawer
title="失败消息详情"
:open="drawerVisible"
:width="720"
@close="drawerVisible = false"
>
<a-spin :spinning="drawerLoading">
<template v-if="detail">
<!-- 基本信息 -->
<a-descriptions title="基本信息" :column="2" bordered class="mb-4">
<a-descriptions-item label="错误 ID">{{ detail.error_id }}</a-descriptions-item>
<a-descriptions-item label="数据类型">
<a-tag :color="dataTypeColorMap[detail.data_type] || 'default'">
{{ dataTypeLabelMap[detail.data_type] || detail.data_type }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="平台">
{{ store.platformMap.get(detail.platform_id) || detail.platform }}
</a-descriptions-item>
<a-descriptions-item label="重试次数">{{ detail.retry_count }}</a-descriptions-item>
<a-descriptions-item label="失败时间">
{{ formatTime(detail.failed_at) }}
</a-descriptions-item>
<a-descriptions-item label="消息 ID">{{ detail.message_id }}</a-descriptions-item>
</a-descriptions>
<!-- 错误信息 -->
<a-descriptions title="错误信息" :column="1" bordered class="mb-4">
<a-descriptions-item label="错误类型">{{ detail.error_type }}</a-descriptions-item>
<a-descriptions-item label="错误码">
{{ detail.error_code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="错误信息">{{ detail.error_message }}</a-descriptions-item>
</a-descriptions>
<!-- 错误堆栈 -->
<h3 class="text-base font-medium mb-2">错误堆栈</h3>
<pre
v-if="detail.error_trace"
class="m-0 p-3 text-xs max-h-80 overflow-auto bg-gray-50 rounded border mb-4"
>{{ detail.error_trace }}</pre>
<p v-else class="text-gray-400 mb-4">暂无堆栈信息</p>
<!-- 原始消息 -->
<h3 class="text-base font-medium mb-2">原始消息</h3>
<pre
v-if="detail.original_message"
class="m-0 p-3 text-xs max-h-80 overflow-auto bg-gray-50 rounded border"
>{{ JSON.stringify(detail.original_message, null, 2) }}</pre>
<p v-else class="text-gray-400">暂无消息数据</p>
</template>
</a-spin>
</a-drawer>
</div>
</template>
@@ -0,0 +1,316 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { useMqStatusStore } from '@/stores/mq-status'
import type { MqStatusData } from '@/types/api'
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(),
patch: vi.fn(),
},
}))
const mockStatusData: MqStatusData = {
business_queues: [
{ queue: 'orders.queue', messages: 5, consumers: 2, status: 'active' },
{ queue: 'products.queue', messages: 0, consumers: 1, status: 'empty' },
{ queue: 'refunds.queue', messages: 120, consumers: 3, status: 'high_load' },
{ queue: 'inventory.queue', messages: 'N/A', consumers: 'N/A', status: 'error' },
],
retry_queues: [
{ queue: 'orders.retry.queue', messages: 2, consumers: 1, status: 'active' },
{ queue: 'products.retry.queue', messages: 0, consumers: 1, status: 'empty' },
],
error_queue: { queue: 'errors.queue', messages: 3, consumers: 1, status: 'processing' },
summary: { total_messages: 130, total_consumers: 9 },
fetched_at: '2026-03-20 14:30:00',
}
const mockFilteredStatusData: MqStatusData = {
business_queues: [
{ queue: 'orders.queue', messages: 5, consumers: 2, status: 'active' },
],
retry_queues: [
{ queue: 'orders.retry.queue', messages: 2, consumers: 1, status: 'active' },
],
error_queue: [],
summary: { total_messages: 7, total_consumers: 3 },
fetched_at: '2026-03-20 14:31:00',
}
// ─── Store Tests ───────────────────────────────────────
describe('useMqStatusStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('S1: starts with correct initial state', () => {
const store = useMqStatusStore()
expect(store.statusData).toBeNull()
expect(store.loading).toBe(false)
expect(store.errorMessage).toBe('')
expect(store.queueType).toBeUndefined()
})
})
describe('fetchStatus', () => {
it('S2: fetches status data successfully', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockStatusData)
const store = useMqStatusStore()
await store.fetchStatus()
expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {})
expect(store.statusData).toEqual(mockStatusData)
expect(store.loading).toBe(false)
expect(store.errorMessage).toBe('')
})
it('S3: fetches with queue parameter when filtered', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockFilteredStatusData)
const store = useMqStatusStore()
store.queueType = 'orders'
await store.fetchStatus()
expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', { queue: 'orders' })
expect(store.statusData).toEqual(mockFilteredStatusData)
})
it('S4: sets errorMessage on failure', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('连接超时'))
const store = useMqStatusStore()
await store.fetchStatus()
expect(store.errorMessage).toBe('连接超时')
expect(store.loading).toBe(false)
})
})
describe('healthySummary', () => {
it('S5: computes healthy/total correctly', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockStatusData)
const store = useMqStatusStore()
await store.fetchStatus()
// active(orders.queue, orders.retry.queue) + empty(products.queue, products.retry.queue) = 4 healthy
// total = 4 business + 2 retry + 1 error = 7
expect(store.healthySummary.healthy).toBe(4)
expect(store.healthySummary.total).toBe(7)
})
})
describe('resetFilter', () => {
it('S6: clears queue type filter', () => {
const store = useMqStatusStore()
store.queueType = 'orders'
store.resetFilter()
expect(store.queueType).toBeUndefined()
})
})
})
// ─── Page Component Tests ──────────────────────────────
describe('MqStatusPage', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
async function mountPage(data: MqStatusData = mockStatusData) {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValue(data)
const { default: MqStatusPage } = await import('../index.vue')
wrapper = mount(MqStatusPage, {
attachTo: document.body,
global: {
stubs: {
ReloadOutlined: { template: '<span />' },
},
},
})
await flushPromises()
await nextTick()
return { wrapper, api }
}
it('P1: renders page title and refresh button', async () => {
await mountPage()
expect(wrapper.text()).toContain('消息队列状态')
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 fetchStatus on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {})
})
it('P3: displays total_messages and total_consumers in stat cards', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('总消息数')
expect(html).toContain('130')
expect(html).toContain('总消费者数')
expect(html).toContain('9')
})
it('P4: renders business queues table', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('业务队列')
expect(html).toContain('orders.queue')
expect(html).toContain('products.queue')
expect(html).toContain('refunds.queue')
expect(html).toContain('inventory.queue')
})
it('P5: renders retry queues table', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('重试队列')
expect(html).toContain('orders.retry.queue')
expect(html).toContain('products.retry.queue')
})
it('P6: renders error queue with message count', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('错误队列')
expect(html).toContain('errors.queue')
})
it('P7: renders status tags with correct colors', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('活跃')
expect(html).toContain('空闲')
expect(html).toContain('高负载')
expect(html).toContain('异常')
})
it('P8: filter change triggers fetchStatus with queue param', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockClear()
vi.mocked(api.get).mockResolvedValue(mockFilteredStatusData)
const store = useMqStatusStore()
store.queueType = 'orders'
await store.fetchStatus()
expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', { queue: 'orders' })
})
it('P9: refresh button triggers fetchStatus', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockClear()
vi.mocked(api.get).mockResolvedValue(mockStatusData)
const buttons = Array.from(document.body.querySelectorAll('.ant-btn'))
const refreshBtn = buttons.find((b) => b.textContent?.trim().includes('刷新'))
expect(refreshBtn).toBeDefined()
refreshBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await nextTick()
expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {})
})
it('P10: shows error alert when API fails', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValue(new Error('RabbitMQ 连接失败'))
const { default: MqStatusPage } = await import('../index.vue')
wrapper = mount(MqStatusPage, {
attachTo: document.body,
global: {
stubs: { ReloadOutlined: { template: '<span />' } },
},
})
await flushPromises()
await nextTick()
const html = wrapper.html()
expect(html).toContain('RabbitMQ 连接失败')
})
it('P11: displays fetched_at timestamp', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('2026-03-20 14:30:00')
expect(html).toContain('最后更新')
})
it('P12: hides error queue section when filtered', async () => {
await mountPage(mockFilteredStatusData)
const cards = wrapper.findAll('.ant-card')
const cardTitles = cards.map((c) => c.text())
expect(cardTitles.some((t) => t.includes('业务队列'))).toBe(true)
expect(cardTitles.some((t) => t.includes('重试队列'))).toBe(true)
// error_queue is [] when filtered, so no error queue card should render
const errorCard = cards.find((c) => c.text().includes('errors.queue'))
expect(errorCard).toBeUndefined()
})
it('P13: error queue messages > 0 shown with red highlight', async () => {
await mountPage()
// error_queue has messages=3, should have red highlight class
const errorQueueCards = wrapper.findAll('.ant-card')
const errorCard = errorQueueCards.find((c) => c.text().includes('错误队列'))
expect(errorCard).toBeDefined()
const redSpan = errorCard?.find('.text-red-500')
expect(redSpan?.exists()).toBe(true)
expect(redSpan?.text()).toBe('3')
})
})
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useMqStatusStore } from '@/stores/mq-status'
import type { MqQueueInfo, MqQueueStatusEnum, MqQueueType } from '@/types/api'
const store = useMqStatusStore()
const statusTagMap: Record<MqQueueStatusEnum, { text: string; color: string }> = {
high_load: { text: '高负载', color: 'red' },
processing: { text: '处理中', color: 'orange' },
active: { text: '活跃', color: 'green' },
empty: { text: '空闲', color: 'default' },
error: { text: '异常', color: 'red' },
}
const queueTypeOptions = [
{ label: '全部', value: '' },
{ label: 'Orders', value: 'orders' },
{ label: 'Products', value: 'products' },
{ label: 'Refunds', value: 'refunds' },
{ label: 'Inventory', value: 'inventory' },
]
const queueColumns = [
{ title: '队列名称', dataIndex: 'queue', key: 'queue' },
{ title: '消息数', dataIndex: 'messages', key: 'messages' },
{ title: '消费者数', dataIndex: 'consumers', key: 'consumers' },
{ title: '状态', key: 'status' },
]
const filterValue = computed({
get: () => store.queueType ?? '',
set: (val: string) => {
store.queueType = val === '' ? undefined : (val as MqQueueType)
},
})
const showErrorQueue = computed(() => {
if (!store.statusData) return false
return !Array.isArray(store.statusData.error_queue)
})
const errorQueueRows = computed<MqQueueInfo[]>(() => {
if (!store.statusData || Array.isArray(store.statusData.error_queue)) return []
return [store.statusData.error_queue]
})
function formatValue(val: number | string): string {
return typeof val === 'number' ? String(val) : val
}
function handleFilterChange() {
store.fetchStatus()
}
function handleRefresh() {
store.fetchStatus()
}
onMounted(() => {
store.fetchStatus()
})
</script>
<template>
<div>
<!-- 标题行 -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold m-0">消息队列状态</h2>
<a-space>
<a-select
v-model:value="filterValue"
:options="queueTypeOptions"
style="width: 160px"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 错误提示 -->
<a-alert
v-if="store.errorMessage"
type="error"
:message="store.errorMessage"
show-icon
class="mb-4"
/>
<a-spin :spinning="store.loading">
<template v-if="store.statusData">
<!-- 统计卡片 -->
<a-row :gutter="16" class="mb-4">
<a-col :span="8">
<a-card>
<a-statistic
title="总消息数"
:value="store.statusData.summary.total_messages"
/>
</a-card>
</a-col>
<a-col :span="8">
<a-card>
<a-statistic
title="总消费者数"
:value="store.statusData.summary.total_consumers"
/>
</a-card>
</a-col>
<a-col :span="8">
<a-card>
<a-statistic
title="队列健康"
:value="`${store.healthySummary.healthy}/${store.healthySummary.total}`"
/>
</a-card>
</a-col>
</a-row>
<!-- 业务队列 -->
<a-card title="业务队列" class="mb-4">
<a-table
:columns="queueColumns"
:data-source="store.statusData.business_queues"
:pagination="false"
row-key="queue"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'messages'">
{{ formatValue((record as MqQueueInfo).messages) }}
</template>
<template v-else-if="column.key === 'consumers'">
{{ formatValue((record as MqQueueInfo).consumers) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusTagMap[(record as MqQueueInfo).status]?.color || 'default'">
{{ statusTagMap[(record as MqQueueInfo).status]?.text || (record as MqQueueInfo).status }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 重试队列 -->
<a-card title="重试队列" class="mb-4">
<a-table
:columns="queueColumns"
:data-source="store.statusData.retry_queues"
:pagination="false"
row-key="queue"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'messages'">
{{ formatValue((record as MqQueueInfo).messages) }}
</template>
<template v-else-if="column.key === 'consumers'">
{{ formatValue((record as MqQueueInfo).consumers) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusTagMap[(record as MqQueueInfo).status]?.color || 'default'">
{{ statusTagMap[(record as MqQueueInfo).status]?.text || (record as MqQueueInfo).status }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 错误队列仅未筛选时显示 -->
<a-card v-if="showErrorQueue" title="错误队列" class="mb-4">
<a-table
:columns="queueColumns"
:data-source="errorQueueRows"
:pagination="false"
row-key="queue"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'messages'">
<span
:class="{ 'text-red-500 font-bold': typeof (record as MqQueueInfo).messages === 'number' && Number((record as MqQueueInfo).messages) > 0 }"
>
{{ formatValue((record as MqQueueInfo).messages) }}
</span>
</template>
<template v-else-if="column.key === 'consumers'">
{{ formatValue((record as MqQueueInfo).consumers) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusTagMap[(record as MqQueueInfo).status]?.color || 'default'">
{{ statusTagMap[(record as MqQueueInfo).status]?.text || (record as MqQueueInfo).status }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 最后更新时间 -->
<div class="text-gray-400 text-sm text-right">
最后更新: {{ store.statusData.fetched_at }}
</div>
</template>
</a-spin>
</div>
</template>