add user management interface

This commit is contained in:
2026-03-18 15:25:17 +08:00
parent 257668f3f3
commit d64c098a36
5 changed files with 875 additions and 0 deletions
@@ -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 不支持 matchMediaAnt 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<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).mockResolvedValue(mockPaginatedResponse)
const { default: UsersPage } = await import('../index.vue')
wrapper = mount(UsersPage, {
attachTo: document.body,
global: {
stubs: {
PlusOutlined: { template: '<span />' },
SearchOutlined: { template: '<span />' },
ReloadOutlined: { template: '<span />' },
UserFormModal: { template: '<div class="form-modal-stub" />' },
},
},
})
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()
})
})
@@ -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<typeof mount>
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()
})
})
})
+247
View File
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { api } from '@/utils/request'
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
import UserFormModal from '@/components/UserFormModal.vue'
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue'
const store = useUserManageStore()
// Detail drawer
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const currentUser = ref<UserRecord | null>(null)
// Form modal
const modalOpen = ref(false)
const modalMode = ref<'create' | 'edit'>('create')
const editingUser = ref<UserRecord | null>(null)
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 200 },
]
onMounted(() => {
store.fetchUsers()
})
function handleSearch() {
store.pagination.page = 1
store.fetchUsers()
}
function handleReset() {
store.resetFilters()
store.fetchUsers()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchUsers()
}
async function handleViewDetail(record: UserRecord) {
drawerVisible.value = true
drawerLoading.value = true
try {
currentUser.value = await api.get<UserRecord>(`/api/v1/users/${record.id}`)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '获取详情失败'
message.error(msg)
} finally {
drawerLoading.value = false
}
}
function handleCreate() {
modalMode.value = 'create'
editingUser.value = null
modalOpen.value = true
}
function handleEdit(record: UserRecord) {
modalMode.value = 'edit'
editingUser.value = record
modalOpen.value = true
}
async function handleToggleStatus(record: UserRecord) {
const newStatus = record.status === 1 ? 0 : 1
try {
await api.patch(`/api/v1/users/${record.id}/status`, { status: newStatus })
message.success('操作成功')
store.fetchUsers()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
}
}
function handleModalSuccess() {
store.fetchUsers()
}
function formatTime(time: string) {
return time ? time.replace('T', ' ').substring(0, 19) : '-'
}
</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.username"
placeholder="请输入用户名"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="邮箱">
<a-input
v-model:value="store.filters.email"
placeholder="请输入邮箱"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="store.filters.status"
placeholder="全部"
allow-clear
style="width: 120px"
>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</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>
<div class="mb-4">
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建用户
</a-button>
</div>
<a-table
:columns="columns"
:data-source="store.users"
:loading="store.loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record as UserRecord)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record as UserRecord)">
编辑
</a-button>
<a-popconfirm
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
@confirm="handleToggleStatus(record as UserRecord)"
>
<a-button type="link" size="small" :danger="record.status === 1">
{{ record.status === 1 ? '禁用' : '启用' }}
</a-button>
</a-popconfirm>
</a-space>
</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="480"
@close="drawerVisible = false"
>
<a-spin :spinning="drawerLoading">
<a-descriptions v-if="currentUser" :column="1" bordered>
<a-descriptions-item label="ID">{{ currentUser.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ currentUser.username }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentUser.email }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentUser.status === 1 ? 'green' : 'red'">
{{ currentUser.status === 1 ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="角色 ID">{{ currentUser.role_id }}</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentUser.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatTime(currentUser.updated_at) }}
</a-descriptions-item>
<a-descriptions-item label="扩展信息">
<pre class="m-0 text-xs">{{
currentUser.ext ? JSON.stringify(currentUser.ext, null, 2) : '-'
}}</pre>
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
<!-- Create/Edit modal -->
<UserFormModal
v-model:open="modalOpen"
:mode="modalMode"
:user-data="editingUser"
@success="handleModalSuccess"
/>
</div>
</template>