11 管理员配置模块:维护账号、角色和分类
11 管理员配置模块:维护账号、角色和分类
本章你会做出什么
管理员可新增和启停用户、修改用户角色、为角色配置权限码,以及维护可选的工单分类。修改角色权限后,该角色账号重新刷新身份信息,页面菜单和接口访问会同步改变。
先理解三个概念
1. 基础资料管理
工单页面依赖人员、分类和权限。把这些内容写死在页面里很难维护,因此由管理员在系统中配置。
2. RBAC
RBAC 是“基于角色的访问控制”。用户绑定一个角色,角色绑定多项权限。首版限定每人一个角色,已经能展示清晰的权限模型。
3. 启停而不是直接删除
已经被工单引用的用户或分类不适合直接删除。将 status 或 enabled 改为停用,可以保留历史数据,同时阻止新选择。
本章最终目录变化
server/src/app.ts
server/src/store/store.ts
server/src/store/memory-store.ts
client/src/api/modules.ts
client/src/views/system/
UsersView.vue
RolesView.vue
CategoriesView.vue
一步一步操作
第 1 步:扩展存储接口
先在 server/src/types.ts 增加:
export interface UserQuery {
page: number
pageSize: number
keyword?: string
roleCode?: RoleCode
status?: 0 | 1
}
追加以下能力:
listUsers(query: UserQuery): Promise<Pagination<UserRecord>>
createUser(input: Omit<UserRecord, 'id' | 'createdAt' | 'updatedAt'>): Promise<UserRecord>
updateUser(id: number, input: Partial<Pick<UserRecord, 'name' | 'email' | 'roleId' | 'status'>>): Promise<UserRecord | undefined>
updateRolePermissions(roleId: number, permissionCodes: string[]): Promise<Role | undefined>
listPermissions(): Promise<Permission[]>
createCategory(name: string): Promise<Category>
updateCategory(id: number, input: Partial<Pick<Category, 'name' | 'enabled'>>): Promise<Category | undefined>
实现思路和工单内存数组一样:find 找记录、Object.assign 更新记录、用最大 ID 加一生成新记录。
第 2 步:保护管理接口
管理接口必须在后端加对应权限码,而不只是依赖左侧菜单隐藏:
| 接口 | 必需权限 |
|---|---|
/admin/users |
system:user |
/admin/roles |
system:role,查看角色列表时也可允许 system:user |
/admin/categories |
system:category |
第 3 步:前端建立管理 API
页面只调用 API 方法,不直接拼接 axios 请求。
第 4 步:按相同页面模式制作三个页面
三个管理页面都遵循:
页面加载 -> 请求列表 -> 表格展示 -> 打开弹窗或权限选择 -> 校验 -> 提交 -> 重新加载
分类页代码最短,先完成它,再复制交互方式完成用户页和角色页。
关键文件完整代码
client/src/api/modules.ts 中的管理 API
import type { Category, ManagedUser, PageResult, Permission, Role, RoleCode } from '../types'
// 将原 commonApi 的分类方法调整为可让管理员查看停用项:
// categories: (enabled = true) => api.get<Category[]>('/categories', { params: { enabled } })
export const adminApi = {
users: (params: { page: number; pageSize: number; keyword?: string; roleCode?: RoleCode; status?: 0 | 1 }) =>
api.get<PageResult<ManagedUser>>('/admin/users', { params }),
createUser: (input: { username: string; password: string; name: string; email: string; roleId: number }) =>
api.post<ManagedUser>('/admin/users', input),
updateUser: (id: number, input: Partial<Pick<ManagedUser, 'name' | 'email' | 'roleId' | 'status'>>) =>
api.patch<ManagedUser>(`/admin/users/${id}`, input),
roles: () => api.get<{ roles: Role[]; permissions: Permission[] }>('/admin/roles'),
updatePermissions: (id: number, permissionCodes: string[]) =>
api.put<Role>(`/admin/roles/${id}/permissions`, { permissionCodes }),
createCategory: (name: string) => api.post<Category>('/admin/categories', { name }),
updateCategory: (id: number, input: Partial<Pick<Category, 'name' | 'enabled'>>) =>
api.patch<Category>(`/admin/categories/${id}`, input)
}
在 client/src/types/index.ts 增加:
export interface ManagedUser {
id: number
username: string
name: string
email: string
roleId: number
roleCode: RoleCode
roleName: string
status: 0 | 1
}
后端路由的权限边界
将下列路由加入 server/src/app.ts,其中 store 方法为本章第 1 步扩展的实现:
app.get('/api/v1/admin/roles', authenticated, requirePermission('system:role', 'system:user'), async (_req, res) => {
success(res, { roles: await store.listRoles(), permissions: await store.listPermissions() })
})
app.put('/api/v1/admin/roles/:id/permissions', authenticated, requirePermission('system:role'), async (req, res) => {
const input = z.object({ permissionCodes: z.array(z.string()) }).parse(req.body)
const validCodes = new Set((await store.listPermissions()).map((item) => item.code))
if (input.permissionCodes.some((code) => !validCodes.has(code))) throw new AppError(400, '存在无效权限码')
const role = await store.updateRolePermissions(Number(req.params.id), input.permissionCodes)
if (!role) throw new AppError(404, '角色不存在')
success(res, role, '权限保存成功')
})
app.post('/api/v1/admin/categories', authenticated, requirePermission('system:category'), async (req, res) => {
const input = z.object({ name: z.string().trim().min(2).max(50) }).parse(req.body)
success(res, await store.createCategory(input.name), '创建成功')
})
app.patch('/api/v1/admin/categories/:id', authenticated, requirePermission('system:category'), async (req, res) => {
const input = z.object({ name: z.string().trim().min(2).max(50).optional(), enabled: z.boolean().optional() }).parse(req.body)
const category = await store.updateCategory(Number(req.params.id), input)
if (!category) throw new AppError(404, '分类不存在')
success(res, category, '更新成功')
})
app.get('/api/v1/admin/users', authenticated, requirePermission('system:user'), async (req, res) => {
const query = z
.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(10),
keyword: z.string().optional(),
roleCode: z.enum(['USER', 'AGENT', 'SUPERVISOR', 'ADMIN']).optional(),
status: z.coerce
.number()
.pipe(z.union([z.literal(0), z.literal(1)]))
.optional()
})
.parse(req.query)
const result = await store.listUsers(query)
success(res, {
...result,
list: result.list.map(({ passwordHash: _hidden, ...user }) => user)
})
})
app.post('/api/v1/admin/users', authenticated, requirePermission('system:user'), async (req, res) => {
const input = z
.object({
username: z.string().trim().min(3),
password: z.string().min(6),
name: z.string().trim().min(2),
email: z.string().email(),
roleId: z.number().int().positive()
})
.parse(req.body)
const user = await store.createUser({
username: input.username,
passwordHash: await hash(input.password, 8),
name: input.name,
email: input.email,
roleId: input.roleId,
status: 1
})
const { passwordHash: _hidden, ...visibleUser } = user
success(res, visibleUser, '创建成功')
})
app.patch('/api/v1/admin/users/:id', authenticated, requirePermission('system:user'), async (req, res) => {
const input = z
.object({
name: z.string().trim().min(2).optional(),
email: z.string().email().optional(),
roleId: z.number().int().positive().optional(),
status: z.union([z.literal(0), z.literal(1)]).optional()
})
.parse(req.body)
const user = await store.updateUser(parseId(req.params.id), input)
if (!user) throw new AppError(404, '用户不存在')
const { passwordHash: _hidden, ...visibleUser } = user
success(res, visibleUser, '更新成功')
})
创建用户路由使用密码哈希,因此还需在 app.ts 顶部增加 import { hash } from 'bcryptjs'。修改接口不接收密码与账号字段,并同样在响应前移除 passwordHash。
管理员需要看见停用分类以便重新启用,因此将公共分类路由更新为:
app.get('/api/v1/categories', authenticated, async (req, res) => {
const includeDisabled = req.currentUser!.permissionCodes.includes('system:category') && req.query.enabled !== 'true'
success(res, await store.listCategories(includeDisabled))
})
client/src/views/system/CategoriesView.vue
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { adminApi, commonApi } from '../../api/modules'
import type { Category } from '../../types'
const list = ref<Category[]>([])
const dialog = ref(false)
const editingId = ref<number>()
const form = reactive({ name: '' })
const load = async () => {
list.value = await commonApi.categories(false)
}
const openCreate = () => {
editingId.value = undefined
form.name = ''
dialog.value = true
}
const openEdit = (row: Category) => {
editingId.value = row.id
form.name = row.name
dialog.value = true
}
const save = async () => {
if (!form.name.trim()) return
if (editingId.value) await adminApi.updateCategory(editingId.value, { name: form.name })
else await adminApi.createCategory(form.name)
ElMessage.success('保存成功')
dialog.value = false
await load()
}
const toggle = async (row: Category) => {
await adminApi.updateCategory(row.id, { enabled: !row.enabled })
await load()
}
onMounted(load)
</script>
<template>
<el-card>
<template #header>
<div class="header"><span>工单分类</span><el-button type="primary" @click="openCreate">新增分类</el-button></div>
</template>
<el-table :data="list">
<el-table-column prop="name" label="分类名称" />
<el-table-column label="状态"
><template #default="{ row }">{{ row.enabled ? '启用' : '停用' }}</template></el-table-column
>
<el-table-column label="操作">
<template #default="{ row }">
<el-button link @click="openEdit(row)">编辑</el-button>
<el-button link @click="toggle(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialog" title="分类信息" width="420px">
<el-input v-model="form.name" placeholder="请输入分类名称" />
<template #footer
><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template
>
</el-dialog>
</template>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
client/src/views/system/UsersView.vue
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '../../api/modules'
import type { ManagedUser, PageResult, Role, RoleCode } from '../../types'
const loading = ref(false)
const result = ref<PageResult<ManagedUser>>({ list: [], page: 1, pageSize: 10, total: 0 })
const roles = ref<Role[]>([])
const query = reactive<{ keyword: string; roleCode?: RoleCode; status?: 0 | 1 }>({ keyword: '' })
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const editingId = ref<number>()
const form = reactive({
username: '',
password: '123456',
name: '',
email: '',
roleId: undefined as number | undefined
})
const rules: FormRules = {
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
password: [{ required: true, min: 6, message: '密码至少 6 位', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
roleId: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
const load = async () => {
loading.value = true
try {
result.value = await adminApi.users({ ...query, page: result.value.page, pageSize: result.value.pageSize })
} finally {
loading.value = false
}
}
const search = () => {
result.value.page = 1
load()
}
const openCreate = () => {
editingId.value = undefined
Object.assign(form, { username: '', password: '123456', name: '', email: '', roleId: undefined })
dialogVisible.value = true
}
const openEdit = (row: ManagedUser) => {
editingId.value = row.id
Object.assign(form, { username: row.username, password: '123456', name: row.name, email: row.email, roleId: row.roleId })
dialogVisible.value = true
}
const save = async () => {
if (!(await formRef.value?.validate().catch(() => false)) || !form.roleId) return
if (editingId.value) {
await adminApi.updateUser(editingId.value, { name: form.name, email: form.email, roleId: form.roleId })
ElMessage.success('用户信息已更新')
} else {
await adminApi.createUser({
username: form.username,
password: form.password,
name: form.name,
email: form.email,
roleId: form.roleId
})
ElMessage.success('用户已创建')
}
dialogVisible.value = false
await load()
}
const changeStatus = async (row: ManagedUser) => {
await adminApi.updateUser(row.id, { status: row.status })
ElMessage.success(row.status ? '账号已启用' : '账号已停用')
}
onMounted(async () => {
roles.value = (await adminApi.roles()).roles
await load()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">用户管理</h1>
<p class="page-subtitle">维护系统账号、岗位角色与启停状态</p>
</div>
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
<el-card shadow="never" class="filter-card">
<el-form inline>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="姓名或账号" /></el-form-item>
<el-form-item label="角色">
<el-select v-model="query.roleCode" clearable placeholder="全部角色" style="width: 130px">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.code" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width: 105px">
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" @click="search">查询</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="content-card">
<el-table v-loading="loading" :data="result.list">
<el-table-column prop="username" label="账号" width="150" />
<el-table-column prop="name" label="姓名" width="130" />
<el-table-column prop="email" label="邮箱" min-width="220" />
<el-table-column prop="roleName" label="角色" width="130" />
<el-table-column label="启用状态" width="120">
<template #default="{ row }"
><el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="changeStatus(row)"
/></template>
</el-table-column>
<el-table-column label="操作" width="100"
><template #default="{ row }"
><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template
></el-table-column
>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="result.page"
v-model:page-size="result.pageSize"
:total="result.total"
layout="total, prev, pager, next"
@change="load"
/>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="登录账号" prop="username"
><el-input v-model="form.username" :disabled="Boolean(editingId)"
/></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" prop="password">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
<el-form-item label="姓名" prop="name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="角色" prop="roleId">
<el-select v-model="form.roleId" style="width: 100%">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" @click="save">保存</el-button></template
>
</el-dialog>
</div>
</template>
client/src/views/system/RolesView.vue
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../../api/modules'
import type { Permission, Role } from '../../types'
const roles = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const selectedId = ref<number>()
const checked = ref<string[]>([])
const selected = computed(() => roles.value.find((role) => role.id === selectedId.value))
const choose = (role: Role) => {
selectedId.value = role.id
checked.value = [...role.permissionCodes]
}
const load = async () => {
const data = await adminApi.roles()
roles.value = data.roles
permissions.value = data.permissions
if (roles.value[0]) choose(roles.value[0])
}
const save = async () => {
if (!selected.value) return
await adminApi.updatePermissions(selected.value.id, checked.value)
ElMessage.success('权限保存成功,请目标账号重新刷新身份')
await load()
}
onMounted(load)
</script>
<template>
<el-row :gutter="16">
<el-col :span="8"
><el-card
><el-menu :default-active="String(selectedId)"
><el-menu-item v-for="role in roles" :key="role.id" :index="String(role.id)" @click="choose(role)">{{
role.name
}}</el-menu-item></el-menu
></el-card
></el-col
>
<el-col :span="16"
><el-card v-if="selected"
><template #header>{{ selected.name }} 的权限</template
><el-checkbox-group v-model="checked"
><el-checkbox v-for="item in permissions" :key="item.code" :value="item.code">{{
item.name
}}</el-checkbox></el-checkbox-group
>
<div><el-button type="primary" @click="save">保存权限</el-button></div></el-card
></el-col
>
</el-row>
</template>
以上三个文件已经覆盖新增、编辑、启停、用户组合筛选与权限保存的完整交互。系统管理页沿用前面建立的 .page-container、.page-header 和 .content-card 全局布局类即可显示与业务页面一致的后台样式。
启动并验证
- 使用
admin / 123456登录,应能进入三个系统管理页面。 - 新增一个分类并停用它;再进入新建工单,停用分类不应出现在可选项中。
- 将
USER的ticket:view_own权限取消。 - 重新以
user登录或刷新身份,菜单中不再显示“我的工单”,直接请求本人工单列表应返回空结果,访问详情应为 403。 - 恢复该权限,重新登录后可再次访问。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 普通用户调用管理接口成功 | 后端遗漏 requirePermission |
检查每条 /admin/* 路由 |
| 新用户密码无法登录 | 将明文或不同格式写入 passwordHash |
创建时调用 bcryptjs.hash |
| 修改权限后旧账号立即没变化 | 当前 Store 中用户权限需刷新 profile | 退出重新登录或重新调用恢复身份 |
本章完成清单
- 管理员能维护用户、权限与分类。
- 停用分类不影响历史工单,但不能再用于新建。
- 修改查看权限后,页面和后端访问结果一致。
- 密码哈希不会从列表接口返回给前端。
面试时这一章能怎么讲
权限模型采用用户绑定单角色、角色绑定多权限码的 RBAC 实现。前端根据最新 profile 更新路由与按钮;后端独立按权限码和数据范围做校验,因此撤销查看权限后即使直接请求接口也无法获取工单。