add log page test
This commit is contained in:
@@ -0,0 +1,373 @@
|
|||||||
|
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 { useOperationLogStore } from '@/stores/operation-log'
|
||||||
|
import type { OperationLogRecord, OperationLogDetail } 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 mockLogs: OperationLogRecord[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
action: 'user.create',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: 5,
|
||||||
|
description: '创建用户 test_user',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
created_at: '2026-03-20T10:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
user_id: 1,
|
||||||
|
action: 'role.update',
|
||||||
|
target_type: 'role',
|
||||||
|
target_id: 3,
|
||||||
|
description: '更新角色权限',
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
created_at: '2026-03-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
user_id: 2,
|
||||||
|
action: 'order.delete',
|
||||||
|
target_type: 'order',
|
||||||
|
target_id: 100,
|
||||||
|
description: '删除订单',
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
created_at: '2026-03-20T11:30:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockDetail: OperationLogDetail = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
action: 'user.create',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: 5,
|
||||||
|
description: '创建用户 test_user',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
created_at: '2026-03-20T10:30:00Z',
|
||||||
|
detail: { before: { role: 'viewer' }, after: { role: 'editor' } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUsers = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, username: 'admin' },
|
||||||
|
{ id: 2, username: 'editor' },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
per_page: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store Tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('useOperationLogStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('S1: starts with correct initial state', () => {
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
expect(store.logs).toEqual([])
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
expect(store.pagination.per_page).toBe(20)
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
expect(store.filters.user_id).toBeUndefined()
|
||||||
|
expect(store.filters.action).toBeUndefined()
|
||||||
|
expect(store.filters.target_type).toBeUndefined()
|
||||||
|
expect(store.filters.created_at_range).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchLogs', () => {
|
||||||
|
it('S2: fetches logs successfully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce({
|
||||||
|
items: mockLogs,
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/operations', {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
user_id: undefined,
|
||||||
|
action: undefined,
|
||||||
|
target_type: undefined,
|
||||||
|
created_at_from: undefined,
|
||||||
|
created_at_to: undefined,
|
||||||
|
})
|
||||||
|
expect(store.logs).toEqual(mockLogs)
|
||||||
|
expect(store.pagination.total).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S3: fetches with filters applied', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce({
|
||||||
|
items: [mockLogs[0]],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
store.filters.action = 'user.create'
|
||||||
|
store.filters.target_type = 'user'
|
||||||
|
store.filters.user_id = 1
|
||||||
|
store.filters.created_at_range = ['2026-03-19', '2026-03-20']
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/operations', {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
user_id: 1,
|
||||||
|
action: 'user.create',
|
||||||
|
target_type: 'user',
|
||||||
|
created_at_from: '2026-03-19',
|
||||||
|
created_at_to: '2026-03-20',
|
||||||
|
})
|
||||||
|
expect(store.logs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S4: handles fetch failure gracefully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('网络超时'))
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(store.logs).toEqual([])
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetFilters', () => {
|
||||||
|
it('S5: resets all filters and page', () => {
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
store.filters.action = 'user.create'
|
||||||
|
store.filters.target_type = 'user'
|
||||||
|
store.filters.user_id = 1
|
||||||
|
store.filters.created_at_range = ['2026-03-01', '2026-03-20']
|
||||||
|
store.pagination.page = 3
|
||||||
|
|
||||||
|
store.resetFilters()
|
||||||
|
|
||||||
|
expect(store.filters.action).toBeUndefined()
|
||||||
|
expect(store.filters.target_type).toBeUndefined()
|
||||||
|
expect(store.filters.user_id).toBeUndefined()
|
||||||
|
expect(store.filters.created_at_range).toBeNull()
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('userMap', () => {
|
||||||
|
it('S6: builds userMap from loaded users', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockUsers)
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.userMap.get(1)).toBe('admin')
|
||||||
|
expect(store.userMap.get(2)).toBe('editor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S7: handles loadLookups failure gracefully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败'))
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.users).toEqual([])
|
||||||
|
expect(store.userMap.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Page Component Tests ──────────────────────────────
|
||||||
|
|
||||||
|
describe('OperationLogPage', () => {
|
||||||
|
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/users') return Promise.resolve(mockUsers)
|
||||||
|
if (url === '/api/v1/logs/operations') {
|
||||||
|
return Promise.resolve({
|
||||||
|
items: mockLogs,
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/logs/operations/')) return Promise.resolve(mockDetail)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { default: OperationLogPage } = await import('../operations.vue')
|
||||||
|
wrapper = mount(OperationLogPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
SearchOutlined: { template: '<span />' },
|
||||||
|
ReloadOutlined: { template: '<span />' },
|
||||||
|
EyeOutlined: { template: '<span />' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return { 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/users', expect.any(Object))
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/operations', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P3: renders table with log data', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('创建用户 test_user')
|
||||||
|
expect(html).toContain('更新角色权限')
|
||||||
|
expect(html).toContain('删除订单')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P4: renders action as colored tags', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const tags = wrapper.findAll('.ant-tag')
|
||||||
|
const tagTexts = tags.map((t) => t.text())
|
||||||
|
expect(tagTexts).toContain('user.create')
|
||||||
|
expect(tagTexts).toContain('role.update')
|
||||||
|
expect(tagTexts).toContain('order.delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P5: displays target as type + id', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('用户 #5')
|
||||||
|
expect(html).toContain('角色 #3')
|
||||||
|
expect(html).toContain('订单 #100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P6: formats created_at as YYYY-MM-DD HH:mm', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('2026-03-20 10:30')
|
||||||
|
expect(html).toContain('2026-03-20 11:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P7: search button triggers fetch with page=1', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
store.pagination.page = 3
|
||||||
|
vi.mocked(api.get).mockClear()
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttons = document.body.querySelectorAll('button')
|
||||||
|
const searchBtn = Array.from(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/logs/operations', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P8: reset button clears filters and fetches', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
store.filters.action = 'user.create'
|
||||||
|
store.filters.target_type = 'user'
|
||||||
|
vi.mocked(api.get).mockClear()
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttons = document.body.querySelectorAll('button')
|
||||||
|
const resetBtn = Array.from(buttons).find((b) => b.textContent?.trim().includes('重置'))
|
||||||
|
expect(resetBtn).toBeDefined()
|
||||||
|
|
||||||
|
resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.filters.action).toBeUndefined()
|
||||||
|
expect(store.filters.target_type).toBeUndefined()
|
||||||
|
expect(api.get).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P9: view button opens drawer and loads detail', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
|
||||||
|
const viewButtons = document.body.querySelectorAll('.ant-btn-link')
|
||||||
|
expect(viewButtons.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/operations/1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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 { useRequestLogStore } from '@/stores/request-log'
|
||||||
|
import type { RequestLogRecord, RequestLogDetail } 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 mockLogs: RequestLogRecord[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/orders',
|
||||||
|
status_code: 200,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
response_code: 0,
|
||||||
|
duration_ms: 45,
|
||||||
|
created_at: '2026-03-20T10:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
user_id: null,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/products',
|
||||||
|
status_code: 500,
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
response_code: 500,
|
||||||
|
duration_ms: 3500,
|
||||||
|
created_at: '2026-03-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
user_id: 2,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/users/5',
|
||||||
|
status_code: 404,
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
response_code: 404,
|
||||||
|
duration_ms: 1200,
|
||||||
|
created_at: '2026-03-20T11:30:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockDetail: RequestLogDetail = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/orders',
|
||||||
|
status_code: 200,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
response_code: 0,
|
||||||
|
duration_ms: 45,
|
||||||
|
created_at: '2026-03-20T10:30:00Z',
|
||||||
|
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
request_body: { filter: { status: 'active' } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUsers = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, username: 'admin' },
|
||||||
|
{ id: 2, username: 'editor' },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
per_page: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store Tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('useRequestLogStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('S1: starts with correct initial state', () => {
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
expect(store.logs).toEqual([])
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
expect(store.pagination.per_page).toBe(20)
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
expect(store.filters.user_id).toBeUndefined()
|
||||||
|
expect(store.filters.method).toBeUndefined()
|
||||||
|
expect(store.filters.path).toBe('')
|
||||||
|
expect(store.filters.status_code).toBeUndefined()
|
||||||
|
expect(store.filters.created_at_range).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchLogs', () => {
|
||||||
|
it('S2: fetches logs successfully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce({
|
||||||
|
items: mockLogs,
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/requests', {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
user_id: undefined,
|
||||||
|
method: undefined,
|
||||||
|
path: undefined,
|
||||||
|
status_code: undefined,
|
||||||
|
created_at_from: undefined,
|
||||||
|
created_at_to: undefined,
|
||||||
|
})
|
||||||
|
expect(store.logs).toEqual(mockLogs)
|
||||||
|
expect(store.pagination.total).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S3: fetches with filters applied', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce({
|
||||||
|
items: [mockLogs[0]],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
store.filters.method = 'GET'
|
||||||
|
store.filters.status_code = 200
|
||||||
|
store.filters.user_id = 1
|
||||||
|
store.filters.created_at_range = ['2026-03-19', '2026-03-20']
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/requests', {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
user_id: 1,
|
||||||
|
method: 'GET',
|
||||||
|
path: undefined,
|
||||||
|
status_code: 200,
|
||||||
|
created_at_from: '2026-03-19',
|
||||||
|
created_at_to: '2026-03-20',
|
||||||
|
})
|
||||||
|
expect(store.logs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S4: handles fetch failure gracefully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('网络超时'))
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
await store.fetchLogs()
|
||||||
|
|
||||||
|
expect(store.logs).toEqual([])
|
||||||
|
expect(store.pagination.total).toBe(0)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetFilters', () => {
|
||||||
|
it('S5: resets all filters and page', () => {
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
store.filters.method = 'GET'
|
||||||
|
store.filters.status_code = 200
|
||||||
|
store.filters.user_id = 1
|
||||||
|
store.filters.path = '/api'
|
||||||
|
store.filters.created_at_range = ['2026-03-01', '2026-03-20']
|
||||||
|
store.pagination.page = 3
|
||||||
|
|
||||||
|
store.resetFilters()
|
||||||
|
|
||||||
|
expect(store.filters.method).toBeUndefined()
|
||||||
|
expect(store.filters.status_code).toBeUndefined()
|
||||||
|
expect(store.filters.user_id).toBeUndefined()
|
||||||
|
expect(store.filters.path).toBe('')
|
||||||
|
expect(store.filters.created_at_range).toBeNull()
|
||||||
|
expect(store.pagination.page).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('userMap', () => {
|
||||||
|
it('S6: builds userMap from loaded users', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockResolvedValueOnce(mockUsers)
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.userMap.get(1)).toBe('admin')
|
||||||
|
expect(store.userMap.get(2)).toBe('editor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S7: handles loadLookups failure gracefully', async () => {
|
||||||
|
const { api } = await import('@/utils/request')
|
||||||
|
vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败'))
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
await store.loadLookups()
|
||||||
|
|
||||||
|
expect(store.users).toEqual([])
|
||||||
|
expect(store.userMap.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Page Component Tests ──────────────────────────────
|
||||||
|
|
||||||
|
describe('RequestLogPage', () => {
|
||||||
|
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/users') return Promise.resolve(mockUsers)
|
||||||
|
if (url === '/api/v1/logs/requests') {
|
||||||
|
return Promise.resolve({
|
||||||
|
items: mockLogs,
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/logs/requests/')) return Promise.resolve(mockDetail)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { default: RequestLogPage } = await import('../requests.vue')
|
||||||
|
wrapper = mount(RequestLogPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
SearchOutlined: { template: '<span />' },
|
||||||
|
ReloadOutlined: { template: '<span />' },
|
||||||
|
EyeOutlined: { template: '<span />' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return { 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/users', expect.any(Object))
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/requests', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P3: renders table with log data', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('/api/v1/orders')
|
||||||
|
expect(html).toContain('/api/v1/products')
|
||||||
|
expect(html).toContain('/api/v1/users/5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P4: renders method as colored tags', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const tags = wrapper.findAll('.ant-tag')
|
||||||
|
const tagTexts = tags.map((t) => t.text())
|
||||||
|
expect(tagTexts).toContain('GET')
|
||||||
|
expect(tagTexts).toContain('POST')
|
||||||
|
expect(tagTexts).toContain('DELETE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P5: renders status_code as colored tags', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const tags = wrapper.findAll('.ant-tag')
|
||||||
|
const tagTexts = tags.map((t) => t.text())
|
||||||
|
expect(tagTexts).toContain('200')
|
||||||
|
expect(tagTexts).toContain('500')
|
||||||
|
expect(tagTexts).toContain('404')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P6: formats created_at as YYYY-MM-DD HH:mm', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('2026-03-20 10:30')
|
||||||
|
expect(html).toContain('2026-03-20 11:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P7: search button triggers fetch with page=1', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
store.pagination.page = 3
|
||||||
|
vi.mocked(api.get).mockClear()
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttons = document.body.querySelectorAll('button')
|
||||||
|
const searchBtn = Array.from(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/logs/requests', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P8: reset button clears filters and fetches', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
store.filters.method = 'GET'
|
||||||
|
store.filters.status_code = 200
|
||||||
|
vi.mocked(api.get).mockClear()
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttons = document.body.querySelectorAll('button')
|
||||||
|
const resetBtn = Array.from(buttons).find((b) => b.textContent?.trim().includes('重置'))
|
||||||
|
expect(resetBtn).toBeDefined()
|
||||||
|
|
||||||
|
resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.filters.method).toBeUndefined()
|
||||||
|
expect(store.filters.status_code).toBeUndefined()
|
||||||
|
expect(api.get).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('P9: view button opens drawer and loads detail', async () => {
|
||||||
|
const { api } = await mountPage()
|
||||||
|
|
||||||
|
const viewButtons = document.body.querySelectorAll('.ant-btn-link')
|
||||||
|
expect(viewButtons.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/v1/logs/requests/1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
import { useOperationLogStore } from '@/stores/operation-log'
|
||||||
|
import type { OperationLogDetail } from '@/types/api'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const store = useOperationLogStore()
|
||||||
|
|
||||||
|
// Detail drawer
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerLoading = ref(false)
|
||||||
|
const detail = ref<OperationLogDetail | null>(null)
|
||||||
|
let detailRequestId = 0
|
||||||
|
|
||||||
|
const actionColorMap = (action: string): string => {
|
||||||
|
if (action.startsWith('auth.')) return 'purple'
|
||||||
|
if (action.endsWith('.create')) return 'green'
|
||||||
|
if (action.endsWith('.update')) return 'blue'
|
||||||
|
if (action.endsWith('.delete')) return 'red'
|
||||||
|
if (action.endsWith('.status_change')) return 'orange'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTypeLabels: Record<string, string> = {
|
||||||
|
user: '用户',
|
||||||
|
role: '角色',
|
||||||
|
route_group: '路由组',
|
||||||
|
product: '产品',
|
||||||
|
order: '订单',
|
||||||
|
refund: '退款',
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTypeOptions = Object.entries(targetTypeLabels).map(([value, label]) => ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '操作', key: 'action', width: 140, fixed: 'left' as const },
|
||||||
|
{ title: '对象', key: 'target', width: 120 },
|
||||||
|
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||||
|
{ title: '操作人', key: 'user_id', width: 100 },
|
||||||
|
{ title: 'IP', dataIndex: 'ip', width: 120 },
|
||||||
|
{ title: '时间', key: 'created_at', width: 160 },
|
||||||
|
{ title: '详情', key: 'action_btn', width: 80, fixed: 'right' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
// RangePicker dayjs 桥接
|
||||||
|
const createdAtRange = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!store.filters.created_at_range) return undefined
|
||||||
|
return store.filters.created_at_range.map((d) => dayjs(d)) as [dayjs.Dayjs, dayjs.Dayjs]
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
store.filters.created_at_range = val
|
||||||
|
? [val[0].format('YYYY-MM-DD'), val[1].format('YYYY-MM-DD')]
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadLookups()
|
||||||
|
store.fetchLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
store.pagination.page = 1
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
store.resetFilters()
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
store.pagination.page = page
|
||||||
|
store.pagination.per_page = pageSize
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string | null) {
|
||||||
|
if (!time) return '-'
|
||||||
|
return time.replace('T', ' ').substring(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTarget(type: string, id: number) {
|
||||||
|
const label = targetTypeLabels[type] || type
|
||||||
|
return `${label} #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewDetail(record: { id: number }) {
|
||||||
|
const currentRequestId = ++detailRequestId
|
||||||
|
drawerVisible.value = true
|
||||||
|
drawerLoading.value = true
|
||||||
|
detail.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.get<OperationLogDetail>(`/api/v1/logs/operations/${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-input
|
||||||
|
v-model:value="store.filters.action"
|
||||||
|
placeholder="如 user.create"
|
||||||
|
allow-clear
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="对象类型">
|
||||||
|
<a-select
|
||||||
|
v-model:value="store.filters.target_type"
|
||||||
|
placeholder="全部类型"
|
||||||
|
allow-clear
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="opt in targetTypeOptions"
|
||||||
|
: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.user_id"
|
||||||
|
placeholder="全部用户"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="u in store.users" :key="u.id" :value="u.id">
|
||||||
|
{{ u.username }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="时间范围">
|
||||||
|
<a-range-picker v-model:value="createdAtRange" 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.logs"
|
||||||
|
:loading="store.loading"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-tag :color="actionColorMap(record.action)">
|
||||||
|
{{ record.action }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'target'">
|
||||||
|
{{ formatTarget(record.target_type, record.target_id) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'user_id'">
|
||||||
|
{{
|
||||||
|
record.user_id
|
||||||
|
? (store.userMap.get(record.user_id) || `#${record.user_id}`)
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
{{ formatTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action_btn'">
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewDetail(record as { id: number })"
|
||||||
|
>
|
||||||
|
<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="操作类型">
|
||||||
|
<a-tag :color="actionColorMap(detail.action)">
|
||||||
|
{{ detail.action }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="对象">
|
||||||
|
{{ formatTarget(detail.target_type, detail.target_id) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="描述" :span="2">
|
||||||
|
{{ detail.description }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="操作人">
|
||||||
|
{{
|
||||||
|
detail.user_id
|
||||||
|
? (store.userMap.get(detail.user_id) || `#${detail.user_id}`)
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="IP">{{ detail.ip }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="时间" :span="2">
|
||||||
|
{{ formatTime(detail.created_at) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<h3 class="text-base font-medium mb-2">操作详情</h3>
|
||||||
|
<pre
|
||||||
|
v-if="detail.detail"
|
||||||
|
class="m-0 p-3 text-xs max-h-80 overflow-auto bg-gray-50 rounded border"
|
||||||
|
>{{ JSON.stringify(detail.detail, null, 2) }}</pre>
|
||||||
|
<p v-else class="text-gray-400">无详情数据</p>
|
||||||
|
</template>
|
||||||
|
</a-spin>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
import { useRequestLogStore } from '@/stores/request-log'
|
||||||
|
import type { RequestLogDetail } from '@/types/api'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const store = useRequestLogStore()
|
||||||
|
|
||||||
|
// Detail drawer
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerLoading = ref(false)
|
||||||
|
const detail = ref<RequestLogDetail | null>(null)
|
||||||
|
let detailRequestId = 0
|
||||||
|
|
||||||
|
const methodColorMap: Record<string, string> = {
|
||||||
|
GET: 'green',
|
||||||
|
POST: 'blue',
|
||||||
|
PUT: 'orange',
|
||||||
|
DELETE: 'red',
|
||||||
|
PATCH: 'cyan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCodeColor = (code: number) => {
|
||||||
|
if (code >= 200 && code < 300) return 'green'
|
||||||
|
if (code >= 400 && code < 500) return 'orange'
|
||||||
|
if (code >= 500) return 'red'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationClass = (ms: number) => {
|
||||||
|
if (ms > 3000) return 'text-red-500 font-semibold'
|
||||||
|
if (ms > 1000) return 'text-yellow-600 font-semibold'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map((m) => ({
|
||||||
|
label: m,
|
||||||
|
value: m,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const statusCodeOptions = [200, 201, 400, 401, 403, 404, 500].map((c) => ({
|
||||||
|
label: String(c),
|
||||||
|
value: c,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '方法', key: 'method', width: 80, fixed: 'left' as const },
|
||||||
|
{ title: '路径', dataIndex: 'path', ellipsis: true },
|
||||||
|
{ title: '状态码', key: 'status_code', width: 80 },
|
||||||
|
{ title: '用户', key: 'user_id', width: 100 },
|
||||||
|
{ title: '耗时', key: 'duration_ms', width: 80 },
|
||||||
|
{ title: 'IP', dataIndex: 'ip', width: 120 },
|
||||||
|
{ title: '时间', key: 'created_at', width: 160 },
|
||||||
|
{ title: '操作', key: 'action', width: 80, fixed: 'right' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
// RangePicker dayjs 桥接
|
||||||
|
const createdAtRange = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!store.filters.created_at_range) return undefined
|
||||||
|
return store.filters.created_at_range.map((d) => dayjs(d)) as [dayjs.Dayjs, dayjs.Dayjs]
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
store.filters.created_at_range = val
|
||||||
|
? [val[0].format('YYYY-MM-DD'), val[1].format('YYYY-MM-DD')]
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadLookups()
|
||||||
|
store.fetchLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
store.pagination.page = 1
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
store.resetFilters()
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
store.pagination.page = page
|
||||||
|
store.pagination.per_page = pageSize
|
||||||
|
store.fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RequestLogDetail>(`/api/v1/logs/requests/${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.method"
|
||||||
|
placeholder="全部方法"
|
||||||
|
allow-clear
|
||||||
|
style="width: 110px"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="路径">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.path"
|
||||||
|
placeholder="模糊搜索"
|
||||||
|
allow-clear
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="状态码">
|
||||||
|
<a-select
|
||||||
|
v-model:value="store.filters.status_code"
|
||||||
|
placeholder="全部"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100px"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="opt in statusCodeOptions"
|
||||||
|
: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.user_id"
|
||||||
|
placeholder="全部用户"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="u in store.users" :key="u.id" :value="u.id">
|
||||||
|
{{ u.username }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="时间范围">
|
||||||
|
<a-range-picker v-model:value="createdAtRange" 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.logs"
|
||||||
|
:loading="store.loading"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'method'">
|
||||||
|
<a-tag :color="methodColorMap[record.method] || 'default'">
|
||||||
|
{{ record.method }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status_code'">
|
||||||
|
<a-tag :color="statusCodeColor(record.status_code)">
|
||||||
|
{{ record.status_code }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'user_id'">
|
||||||
|
{{ record.user_id ? (store.userMap.get(record.user_id) || `#${record.user_id}`) : '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'duration_ms'">
|
||||||
|
<span :class="durationClass(record.duration_ms)">
|
||||||
|
{{ record.duration_ms }}ms
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
{{ formatTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewDetail(record as { id: number })"
|
||||||
|
>
|
||||||
|
<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="方法">
|
||||||
|
<a-tag :color="methodColorMap[detail.method] || 'default'">
|
||||||
|
{{ detail.method }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="状态码">
|
||||||
|
<a-tag :color="statusCodeColor(detail.status_code)">
|
||||||
|
{{ detail.status_code }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="路径" :span="2">{{ detail.path }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="用户">
|
||||||
|
{{
|
||||||
|
detail.user_id
|
||||||
|
? (store.userMap.get(detail.user_id) || `#${detail.user_id}`)
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="IP">{{ detail.ip }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="响应码">{{ detail.response_code }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="耗时">
|
||||||
|
<span :class="durationClass(detail.duration_ms)">
|
||||||
|
{{ detail.duration_ms }}ms
|
||||||
|
</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="User-Agent" :span="2">
|
||||||
|
{{ detail.user_agent || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="时间" :span="2">
|
||||||
|
{{ formatTime(detail.created_at) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<h3 class="text-base font-medium mb-2">请求体</h3>
|
||||||
|
<pre
|
||||||
|
v-if="detail.request_body"
|
||||||
|
class="m-0 p-3 text-xs max-h-80 overflow-auto bg-gray-50 rounded border"
|
||||||
|
>{{ JSON.stringify(detail.request_body, null, 2) }}</pre>
|
||||||
|
<p v-else class="text-gray-400">无请求体数据</p>
|
||||||
|
</template>
|
||||||
|
</a-spin>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user