diff --git a/frontend/src/pages/logs/__tests__/operations.spec.ts b/frontend/src/pages/logs/__tests__/operations.spec.ts new file mode 100644 index 0000000..c4110b4 --- /dev/null +++ b/frontend/src/pages/logs/__tests__/operations.spec.ts @@ -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 + + 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: '' }, + ReloadOutlined: { template: '' }, + EyeOutlined: { template: '' }, + }, + }, + }) + 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') + }) +}) diff --git a/frontend/src/pages/logs/__tests__/requests.spec.ts b/frontend/src/pages/logs/__tests__/requests.spec.ts new file mode 100644 index 0000000..94d6dce --- /dev/null +++ b/frontend/src/pages/logs/__tests__/requests.spec.ts @@ -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 + + 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: '' }, + ReloadOutlined: { template: '' }, + EyeOutlined: { template: '' }, + }, + }, + }) + 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') + }) +}) diff --git a/frontend/src/pages/logs/operations.vue b/frontend/src/pages/logs/operations.vue new file mode 100644 index 0000000..c6a9cef --- /dev/null +++ b/frontend/src/pages/logs/operations.vue @@ -0,0 +1,278 @@ + + + diff --git a/frontend/src/pages/logs/requests.vue b/frontend/src/pages/logs/requests.vue new file mode 100644 index 0000000..e3767e7 --- /dev/null +++ b/frontend/src/pages/logs/requests.vue @@ -0,0 +1,306 @@ + + +