add log page test

This commit is contained in:
2026-03-20 15:30:08 +08:00
parent c56f3802b4
commit 3a007713b2
4 changed files with 1341 additions and 0 deletions
@@ -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')
})
})
+278
View File
@@ -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>
+306
View File
@@ -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>