add user management interface
This commit is contained in:
@@ -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 不支持 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<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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user