update failed messages
This commit is contained in:
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user