From d64c098a363280b89d3d9cfd57889da74c8fe7ad Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Wed, 18 Mar 2026 15:25:17 +0800 Subject: [PATCH] add user management interface --- frontend/src/components/UserFormModal.vue | 135 ++++++++++ .../src/pages/users/__tests__/index.spec.ts | 252 ++++++++++++++++++ .../pages/users/__tests__/user-form.spec.ts | 173 ++++++++++++ frontend/src/pages/users/index.vue | 247 +++++++++++++++++ frontend/src/stores/user-manage.ts | 68 +++++ 5 files changed, 875 insertions(+) create mode 100644 frontend/src/components/UserFormModal.vue create mode 100644 frontend/src/pages/users/__tests__/index.spec.ts create mode 100644 frontend/src/pages/users/__tests__/user-form.spec.ts create mode 100644 frontend/src/pages/users/index.vue create mode 100644 frontend/src/stores/user-manage.ts diff --git a/frontend/src/components/UserFormModal.vue b/frontend/src/components/UserFormModal.vue new file mode 100644 index 0000000..a3d2f2f --- /dev/null +++ b/frontend/src/components/UserFormModal.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/src/pages/users/__tests__/index.spec.ts b/frontend/src/pages/users/__tests__/index.spec.ts new file mode 100644 index 0000000..47bb16e --- /dev/null +++ b/frontend/src/pages/users/__tests__/index.spec.ts @@ -0,0 +1,252 @@ +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 { useUserManageStore, type UserRecord } from '@/stores/user-manage' + +// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 +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 mockUsers: UserRecord[] = [ + { id: 1, username: 'admin', email: 'admin@test.com', status: 1, role_id: 1, ext: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }, + { id: 2, username: 'user1', email: 'user1@test.com', status: 0, role_id: 2, ext: { note: 'test' }, created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' }, +] + +const mockPaginatedResponse = { + items: mockUsers, + total: 2, + page: 1, + per_page: 10, +} + +describe('useUserManageStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with empty users and default pagination', () => { + const store = useUserManageStore() + + expect(store.users).toEqual([]) + expect(store.loading).toBe(false) + expect(store.pagination.page).toBe(1) + expect(store.pagination.per_page).toBe(10) + expect(store.pagination.total).toBe(0) + expect(store.filters.username).toBe('') + expect(store.filters.email).toBe('') + expect(store.filters.status).toBeUndefined() + }) + }) + + describe('fetchUsers', () => { + it('loads data and updates state', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useUserManageStore() + await store.fetchUsers() + + expect(api.get).toHaveBeenCalledWith('/api/v1/users', { + page: 1, + per_page: 10, + username: undefined, + email: undefined, + status: undefined, + }) + expect(store.users).toEqual(mockUsers) + expect(store.pagination.total).toBe(2) + expect(store.loading).toBe(false) + }) + + it('sends filter params when set', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce({ + items: [mockUsers[0]], + total: 1, + page: 1, + per_page: 10, + }) + + const store = useUserManageStore() + store.filters.username = 'admin' + store.filters.status = 1 + await store.fetchUsers() + + expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({ + username: 'admin', + status: 1, + })) + expect(store.users).toHaveLength(1) + }) + + it('converts empty string filters to undefined', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) + + const store = useUserManageStore() + store.filters.username = '' + store.filters.email = '' + await store.fetchUsers() + + expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({ + username: undefined, + email: undefined, + })) + }) + + it('respects pagination params', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce({ ...mockPaginatedResponse, page: 2 }) + + const store = useUserManageStore() + store.pagination.page = 2 + store.pagination.per_page = 20 + await store.fetchUsers() + + expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({ + page: 2, + per_page: 20, + })) + expect(store.pagination.page).toBe(2) + }) + + it('sets loading to false even on error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error')) + + const store = useUserManageStore() + await expect(store.fetchUsers()).rejects.toThrow('Network error') + expect(store.loading).toBe(false) + }) + }) + + describe('resetFilters', () => { + it('clears all filters and resets page to 1', () => { + const store = useUserManageStore() + store.filters.username = 'test' + store.filters.email = 'test@test.com' + store.filters.status = 1 + store.pagination.page = 3 + + store.resetFilters() + + expect(store.filters.username).toBe('') + expect(store.filters.email).toBe('') + expect(store.filters.status).toBeUndefined() + expect(store.pagination.page).toBe(1) + }) + }) +}) + +describe('Users Page', () => { + 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).mockResolvedValue(mockPaginatedResponse) + + const { default: UsersPage } = await import('../index.vue') + + wrapper = mount(UsersPage, { + attachTo: document.body, + global: { + stubs: { + PlusOutlined: { template: '' }, + SearchOutlined: { template: '' }, + ReloadOutlined: { template: '' }, + UserFormModal: { template: '
' }, + }, + }, + }) + await flushPromises() + await nextTick() + return { wrapper, api } + } + + it('renders page title', async () => { + const { wrapper } = await mountPage() + expect(wrapper.find('h2').text()).toBe('用户管理') + }, 10000) + + it('renders filter form and action buttons', async () => { + await mountPage() + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const buttonTexts = buttons.map((b) => b.textContent?.trim()) + + expect(buttonTexts.some((t) => t?.includes('搜索'))).toBe(true) + expect(buttonTexts.some((t) => t?.includes('重置'))).toBe(true) + expect(buttonTexts.some((t) => t?.includes('新建用户'))).toBe(true) + }) + + it('calls fetchUsers on mount', async () => { + const { api } = await mountPage() + expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.any(Object)) + }) + + it('renders table with correct number of rows', async () => { + await mountPage() + const rows = document.body.querySelectorAll('.ant-table-tbody tr.ant-table-row') + expect(rows).toHaveLength(mockUsers.length) + }) + + it('displays status tags with correct colors', async () => { + await mountPage() + const tags = document.body.querySelectorAll('.ant-tag') + const tagArray = Array.from(tags) + + const greenTags = tagArray.filter((t) => t.classList.toString().includes('green')) + const redTags = tagArray.filter((t) => t.classList.toString().includes('red')) + expect(greenTags.length).toBeGreaterThanOrEqual(1) + expect(redTags.length).toBeGreaterThanOrEqual(1) + }) + + it('opens detail drawer when clicking view button', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValueOnce(mockUsers[0]) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') + expect(viewBtn).toBeDefined() + + viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawer = document.body.querySelector('.ant-drawer') + expect(drawer).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/users/__tests__/user-form.spec.ts b/frontend/src/pages/users/__tests__/user-form.spec.ts new file mode 100644 index 0000000..899166c --- /dev/null +++ b/frontend/src/pages/users/__tests__/user-form.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import UserFormModal from '@/components/UserFormModal.vue' + +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: { + post: vi.fn(), + put: vi.fn(), + }, +})) + +const mockUserData = { + id: 1, + username: 'testuser', + email: 'test@test.com', + status: 1, + role_id: 1, + ext: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +} + +describe('UserFormModal', () => { + let wrapper: ReturnType + + beforeEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + async function mountModal(props = {}) { + wrapper = mount(UserFormModal, { + props: { + open: true, + mode: 'create' as const, + userData: null, + ...props, + }, + attachTo: document.body, + }) + await flushPromises() + await nextTick() + return wrapper + } + + // 查找 teleported 到 body 的模态框内容 + function queryBody(selector: string) { + return document.body.querySelectorAll(selector) + } + + describe('create mode', () => { + it('renders create title', async () => { + await mountModal() + const title = document.body.querySelector('.ant-modal-title') + expect(title?.textContent).toBe('新建用户') + }) + + it('shows password field in create mode', async () => { + await mountModal() + const labels = Array.from(queryBody('.ant-form-item label')) + .map((el) => el.textContent?.trim()) + expect(labels).toContain('密码') + }) + + it('shows all four form fields', async () => { + await mountModal() + const formItems = queryBody('.ant-form-item') + expect(formItems).toHaveLength(4) // username, password, email, status + }) + + it('emits update:open false on cancel', async () => { + const w = await mountModal() + // 找到取消按钮(模态框 footer 中非 primary 按钮) + const buttons = Array.from(queryBody('.ant-modal-footer .ant-btn')) + const cancelBtn = buttons.find((b) => !b.classList.contains('ant-btn-primary')) + cancelBtn?.dispatchEvent(new Event('click')) + await flushPromises() + expect(w.emitted('update:open')?.[0]).toEqual([false]) + }) + }) + + describe('edit mode', () => { + it('renders edit title', async () => { + await mountModal({ mode: 'edit', userData: mockUserData }) + const title = document.body.querySelector('.ant-modal-title') + expect(title?.textContent).toBe('编辑用户') + }) + + it('hides password field in edit mode', async () => { + await mountModal({ mode: 'edit', userData: mockUserData }) + const labels = Array.from(queryBody('.ant-form-item label')) + .map((el) => el.textContent?.trim()) + expect(labels).not.toContain('密码') + }) + + it('shows three form fields (no password)', async () => { + await mountModal({ mode: 'edit', userData: mockUserData }) + const formItems = queryBody('.ant-form-item') + expect(formItems).toHaveLength(3) // username, email, status + }) + + it('prefills form with user data', async () => { + // 先以 open=false 挂载,再切换为 true 触发 watch 预填数据(与实际使用流程一致) + const w = await mountModal({ mode: 'edit', userData: mockUserData, open: false }) + await w.setProps({ open: true }) + await flushPromises() + await nextTick() + + const inputs = Array.from(queryBody('input')) as HTMLInputElement[] + const values = inputs.map((i) => i.value) + expect(values).toContain('testuser') + expect(values).toContain('test@test.com') + }) + }) + + describe('form submission', () => { + it('calls api.post for create mode', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({}) + + await mountModal() + + // 填充表单 + const inputs = Array.from(queryBody('.ant-form-item input')) as HTMLInputElement[] + expect(inputs.length).toBeGreaterThanOrEqual(3) + inputs[0]!.value = 'newuser' + inputs[0]!.dispatchEvent(new Event('input')) + inputs[1]!.value = 'password123' + inputs[1]!.dispatchEvent(new Event('input')) + inputs[2]!.value = 'new@test.com' + inputs[2]!.dispatchEvent(new Event('input')) + await flushPromises() + + // 点击确定按钮 + const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement + okBtn?.click() + await flushPromises() + }) + + it('calls api.put for edit mode', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.put).mockResolvedValueOnce({}) + + await mountModal({ mode: 'edit', userData: mockUserData }) + + // 点击确定按钮 + const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement + expect(okBtn).toBeTruthy() + okBtn?.click() + await flushPromises() + }) + }) +}) diff --git a/frontend/src/pages/users/index.vue b/frontend/src/pages/users/index.vue new file mode 100644 index 0000000..223a16a --- /dev/null +++ b/frontend/src/pages/users/index.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/stores/user-manage.ts b/frontend/src/stores/user-manage.ts new file mode 100644 index 0000000..5bb93c5 --- /dev/null +++ b/frontend/src/stores/user-manage.ts @@ -0,0 +1,68 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' + +export interface UserRecord { + id: number + username: string + email: string + status: number + role_id: number + ext: Record | null + created_at: string + updated_at: string +} + +export interface UserFilters { + username: string + email: string + status: number | undefined +} + +export const useUserManageStore = defineStore('user-manage', () => { + const users = ref([]) + const loading = ref(false) + const pagination = reactive({ + page: 1, + per_page: 10, + total: 0, + }) + const filters = reactive({ + username: '', + email: '', + status: undefined, + }) + + async function fetchUsers() { + loading.value = true + try { + const data = await api.get>('/api/v1/users', { + page: pagination.page, + per_page: pagination.per_page, + username: filters.username || undefined, + email: filters.email || undefined, + status: filters.status, + }) + users.value = data.items + pagination.total = data.total + pagination.page = data.page + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.username = '' + filters.email = '' + filters.status = undefined + pagination.page = 1 + } + + return { + users, + loading, + pagination, + filters, + fetchUsers, + resetFilters, + } +})