feat: user management page (search/table/pagination/dialog/delete/reset-pwd)

占位数据源 src/api/user.ts(字段对齐后端 User),页面跑通 CRUD 模式,后续接后端只换数据源。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 01:16:08 +08:00
parent 246a305df5
commit 5549b2e75c
5 changed files with 475 additions and 1 deletions

27
components.d.ts vendored
View File

@ -14,22 +14,34 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider'] ElSlider: typeof import('element-plus/es')['ElSlider']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
Icon: typeof import('./src/components/icon/icon.vue')['default'] Icon: typeof import('./src/components/icon/icon.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
@ -42,28 +54,43 @@ declare module 'vue' {
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default'] TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
TyTabs: typeof import('./src/components/ty-tabs.vue')['default'] TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
} }
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
} }
// For TSX support // For TSX support
declare global { declare global {
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard']
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDrawer: typeof import('element-plus/es')['ElDrawer'] const ElDrawer: typeof import('element-plus/es')['ElDrawer']
const ElDropdown: typeof import('element-plus/es')['ElDropdown'] const ElDropdown: typeof import('element-plus/es')['ElDropdown']
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElMenu: typeof import('element-plus/es')['ElMenu'] const ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption']
const ElPagination: typeof import('element-plus/es')['ElPagination']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSlider: typeof import('element-plus/es')['ElSlider'] const ElSlider: typeof import('element-plus/es')['ElSlider']
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
const ElSwitch: typeof import('element-plus/es')['ElSwitch'] const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
const ElTabPane: typeof import('element-plus/es')['ElTabPane'] const ElTabPane: typeof import('element-plus/es')['ElTabPane']
const ElTabs: typeof import('element-plus/es')['ElTabs'] const ElTabs: typeof import('element-plus/es')['ElTabs']
const ElTag: typeof import('element-plus/es')['ElTag']
const Icon: typeof import('./src/components/icon/icon.vue')['default'] const Icon: typeof import('./src/components/icon/icon.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']

139
src/api/user.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* 用户管理数据源。
* TODO: 对接后端后,把下面 mock 实现替换为 http 调用(字段与返回形状保持不变)。
* 后端 User 实体字段:id/tenantCode/username/nickname/email/phone/status/orgCode/createdAt
*/
export type UserStatus = 'enabled' | 'disabled' | 'locked'
export interface User {
id: string
tenantCode: string
username: string
nickname: string | null
email: string | null
phone: string | null
status: UserStatus
orgCode: string | null
createdAt: number
}
export interface UserListQuery {
page: number
pageSize: number
username?: string
nickname?: string
phone?: string
status?: UserStatus | ''
}
export interface UserListResult {
list: User[]
total: number
page: number
pageSize: number
}
export interface UserUpsertData {
username: string
nickname?: string
email?: string
phone?: string
status?: UserStatus
orgCode?: string | null
password?: string
}
/** 占位组织(后续接后端换真实组织数据)。 */
export const mockOrgs: { code: string; name: string }[] = [
{ code: 'touyun', name: '投云总部' },
{ code: 'rd', name: '研发部' },
{ code: 'ops', name: '运维部' },
{ code: 'sales', name: '销售部' },
]
const now = Date.now()
const day = 24 * 60 * 60 * 1000
function mockUser(id: string, username: string, nickname: string, orgCode: string, status: UserStatus, daysAgo: number): User {
return {
id,
tenantCode: 'touyun',
username,
nickname,
email: `${username}@example.com`,
phone: `1380000${String(1000 + Number(id)).slice(-4)}`,
status,
orgCode,
createdAt: now - daysAgo * day,
}
}
let mockUsers: User[] = [
mockUser('1', 'touyun', '超级管理员', 'touyun', 'enabled', 30),
mockUser('2', 'zhangsan', '张三', 'rd', 'enabled', 20),
mockUser('3', 'lisi', '李四', 'rd', 'disabled', 18),
mockUser('4', 'wangwu', '王五', 'ops', 'locked', 15),
mockUser('5', 'zhaoliu', '赵六', 'sales', 'enabled', 12),
mockUser('6', 'sunqi', '孙七', 'ops', 'enabled', 9),
mockUser('7', 'zhouba', '周八', 'rd', 'enabled', 7),
mockUser('8', 'wujiu', '吴九', 'sales', 'disabled', 5),
mockUser('9', 'zhengshi', '郑十', 'rd', 'enabled', 3),
mockUser('10', 'chenyi', '陈一', 'sales', 'enabled', 1),
]
function uuid(): string {
return crypto.randomUUID()
}
export function orgName(code: string | null): string {
if (!code) return '-'
return mockOrgs.find((o) => o.code === code)?.name ?? code
}
export async function listUsers(query: UserListQuery): Promise<UserListResult> {
const { page, pageSize, username, nickname, phone, status } = query
const filtered = mockUsers.filter((u) => {
if (username && !u.username.includes(username)) return false
if (nickname && !(u.nickname ?? '').includes(nickname)) return false
if (phone && !(u.phone ?? '').includes(phone)) return false
if (status && u.status !== status) return false
return true
})
const total = filtered.length
const start = (page - 1) * pageSize
return { list: filtered.slice(start, start + pageSize), total, page, pageSize }
}
export async function createUser(data: UserUpsertData): Promise<User> {
const user: User = {
id: uuid(),
tenantCode: 'touyun',
username: data.username,
nickname: data.nickname ?? null,
email: data.email ?? null,
phone: data.phone ?? null,
status: data.status ?? 'enabled',
orgCode: data.orgCode ?? null,
createdAt: Date.now(),
}
mockUsers.unshift(user)
return user
}
export async function updateUser(id: string, data: Partial<UserUpsertData>): Promise<User> {
const user = mockUsers.find((u) => u.id === id)
if (!user) throw new Error('用户不存在')
Object.assign(user, data)
return user
}
export async function deleteUser(id: string): Promise<null> {
mockUsers = mockUsers.filter((u) => u.id !== id)
return null
}
export async function resetPassword(_id: string, _password: string): Promise<null> {
// mock:无真实密码存储,仅占位
return null
}

View File

@ -0,0 +1,295 @@
<template>
<div class="page-user">
<el-card class="page-user__search" shadow="never">
<el-form :inline="true" :model="query" @submit.prevent>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="query.nickname" placeholder="请输入昵称" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="query.phone" placeholder="请输入手机号" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="启用" value="enabled" />
<el-option label="停用" value="disabled" />
<el-option label="锁定" value="locked" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<div class="page-user__toolbar">
<el-button type="primary" @click="openCreate">新增</el-button>
</div>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="username" label="用户名" min-width="110" />
<el-table-column prop="nickname" label="昵称" min-width="100" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column prop="phone" label="手机" min-width="120" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status as UserStatus].type" size="small">
{{ STATUS_MAP[row.status as UserStatus].label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属组织" min-width="110">
<template #default="{ row }">{{ orgName(row.orgCode) }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" @click="openResetPwd(row)">重置密码</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
class="page-user__pagination"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="fetchList"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog v-model="dialog.visible" :title="dialog.isEdit ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="dialog.isEdit" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="!dialog.isEdit" label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入初始密码" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="enabled" />
<el-option label="停用" value="disabled" />
<el-option label="锁定" value="locked" />
</el-select>
</el-form-item>
<el-form-item label="所属组织" prop="orgCode">
<el-select v-model="form.orgCode" placeholder="请选择组织" clearable style="width: 100%">
<el-option v-for="o in mockOrgs" :key="o.code" :label="o.name" :value="o.code" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="用户名">
<span>{{ resetPwd.username }}</span>
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="resetPwd.password" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwd.visible = false">取消</el-button>
<el-button type="primary" @click="submitResetPwd">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import {
createUser,
deleteUser,
listUsers,
mockOrgs,
orgName,
resetPassword,
updateUser,
type User,
type UserStatus,
} from '@/api/user'
const STATUS_MAP: Record<UserStatus, { label: string; type: 'success' | 'info' | 'danger' }> = {
enabled: { label: '启用', type: 'success' },
disabled: { label: '停用', type: 'info' },
locked: { label: '锁定', type: 'danger' },
}
const loading = ref(false)
const list = ref<User[]>([])
const total = ref(0)
const query = reactive({
page: 1,
pageSize: 10,
username: '',
nickname: '',
phone: '',
status: '' as UserStatus | '',
})
const formRef = ref<FormInstance>()
const dialog = reactive({ visible: false, isEdit: false })
const form = reactive({
id: '',
username: '',
password: '',
nickname: '',
email: '',
phone: '',
status: 'enabled' as UserStatus,
orgCode: null as string | null,
})
const resetPwd = reactive({ visible: false, id: '', username: '', password: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
function formatDateTime(ms: number): string {
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function fetchList(): Promise<void> {
loading.value = true
try {
const result = await listUsers({ ...query, status: query.status })
list.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
function handleSearch(): void {
query.page = 1
fetchList()
}
function handleReset(): void {
query.username = ''
query.nickname = ''
query.phone = ''
query.status = ''
handleSearch()
}
function handleSizeChange(): void {
query.page = 1
fetchList()
}
function openCreate(): void {
dialog.visible = true
dialog.isEdit = false
Object.assign(form, { id: '', username: '', password: '', nickname: '', email: '', phone: '', status: 'enabled', orgCode: null })
}
function openEdit(row: User): void {
dialog.visible = true
dialog.isEdit = true
Object.assign(form, { id: row.id, username: row.username, password: '', nickname: row.nickname ?? '', email: row.email ?? '', phone: row.phone ?? '', status: row.status, orgCode: row.orgCode })
}
function openResetPwd(row: User): void {
resetPwd.visible = true
resetPwd.id = row.id
resetPwd.username = row.username
resetPwd.password = ''
}
async function submitForm(): Promise<void> {
try {
await formRef.value?.validate()
} catch {
return
}
if (dialog.isEdit) {
await updateUser(form.id, {
nickname: form.nickname,
email: form.email,
phone: form.phone,
status: form.status,
orgCode: form.orgCode,
})
ElMessage.success('更新成功')
} else {
await createUser({
username: form.username,
password: form.password,
nickname: form.nickname,
email: form.email,
phone: form.phone,
status: form.status,
orgCode: form.orgCode,
})
ElMessage.success('新增成功')
}
dialog.visible = false
fetchList()
}
async function handleDelete(row: User): Promise<void> {
await ElMessageBox.confirm(`确认删除用户「${row.username}」?`, '提示', { type: 'warning' })
await deleteUser(row.id)
ElMessage.success('删除成功')
fetchList()
}
async function submitResetPwd(): Promise<void> {
await resetPassword(resetPwd.id, resetPwd.password)
ElMessage.success('密码已重置')
resetPwd.visible = false
}
onMounted(fetchList)
</script>
<style scoped lang="scss">
.page-user {
&__search {
margin-bottom: 12px;
}
&__toolbar {
margin-bottom: 12px;
}
&__pagination {
margin-top: 12px;
justify-content: flex-end;
}
}
</style>

File diff suppressed because one or more lines are too long

13
typed-router.d.ts vendored
View File

@ -44,6 +44,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/system/user/': RouteRecordInfo<
'/system/user/',
'/system/user',
Record<never, never>,
Record<never, never>,
| never
>,
} }
/** /**
@ -75,6 +82,12 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/system/user/index.vue': {
routes:
| '/system/user/'
views:
| never
}
} }
/** /**