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
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { api } from '@/utils/request'
import type { UserRecord } from '@/stores/user-manage'
import type { Rule } from 'ant-design-vue/es/form'
const props = defineProps<{
open: boolean
mode: 'create' | 'edit'
userData: UserRecord | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
success: []
}>()
const formRef = ref()
const submitting = ref(false)
const formState = reactive({
username: '',
password: '',
email: '',
status: 1 as number,
})
const rules = computed<Record<string, Rule[]>>(() => {
const base: Record<string, Rule[]> = {
username: [
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度为 3-20 个字符' },
],
email: [
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
],
status: [{ required: true, message: '请选择状态' }],
}
if (props.mode === 'create') {
base.password = [
{ required: true, message: '请输入密码' },
{ min: 6, max: 32, message: '密码长度为 6-32 个字符' },
]
}
return base
})
const title = computed(() => (props.mode === 'create' ? '新建用户' : '编辑用户'))
watch(
() => props.open,
(val) => {
if (val) {
if (props.mode === 'edit' && props.userData) {
formState.username = props.userData.username
formState.email = props.userData.email
formState.status = props.userData.status
formState.password = ''
} else {
formState.username = ''
formState.password = ''
formState.email = ''
formState.status = 1
}
// 清除上一次的校验状态
nextTick(() => formRef.value?.clearValidate())
}
},
)
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
submitting.value = true
try {
if (props.mode === 'create') {
await api.post('/api/v1/users', {
username: formState.username,
password: formState.password,
email: formState.email,
status: formState.status,
})
} else {
await api.put(`/api/v1/users/${props.userData!.id}`, {
username: formState.username,
email: formState.email,
ext: props.userData!.ext,
})
}
message.success('操作成功')
emit('update:open', false)
emit('success')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
submitting.value = false
}
}
function handleCancel() {
emit('update:open', false)
}
</script>
<template>
<a-modal
:open="open"
:title="title"
:confirm-loading="submitting"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="用户名" name="username">
<a-input v-model:value="formState.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item v-if="mode === 'create'" label="密码" name="password">
<a-input-password v-model:value="formState.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formState.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-select v-model:value="formState.status">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</template>
@@ -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>
+68
View File
@@ -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<string, unknown> | 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<UserRecord[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
per_page: 10,
total: 0,
})
const filters = reactive<UserFilters>({
username: '',
email: '',
status: undefined,
})
async function fetchUsers() {
loading.value = true
try {
const data = await api.get<PaginatedData<UserRecord>>('/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,
}
})