feat: 新增角色管理(权限树配置/增删改查)并实现基于角色的菜单按钮权限裁剪

main
halo 2026-07-08 16:17:58 +08:00
parent 0e4a38fd6f
commit 18d4bf4067
7 changed files with 478 additions and 14 deletions

View File

@ -61,3 +61,21 @@ export function flattenButtons(tree) {
walk(tree)
return codes
}
// 依据角色勾选的 menuIds 集合裁剪菜单树:保留命中节点及其祖先节点(用于渲染路径),
// 同时按 buttonCodeSet 过滤每个节点的按钮列表,实现"页面权限+功能权限"双重收敛。
export function pruneMenuTree(tree, menuIdSet, buttonCodeSet) {
function walk(nodes) {
const result = []
for (const n of nodes) {
const children = n.children ? walk(n.children) : []
const selfMatched = menuIdSet.has(n.id)
if (!selfMatched && children.length === 0) continue
const buttons = (n.buttons || []).filter((b) => buttonCodeSet.has(b.code))
result.push({ ...n, children, buttons })
}
return result
}
return walk(tree)
}

View File

@ -1,7 +1,8 @@
import express from 'express'
import crypto from 'crypto'
import { users } from '../data/users.js'
import { menuTree, flattenButtons } from '../data/menu.js'
import { menuTree, flattenButtons, pruneMenuTree } from '../data/menu.js'
import { roles } from '../data/roles.js'
import {
accessTokens,
refreshTokens,
@ -17,6 +18,16 @@ function findUser(username) {
return users.find((u) => u.username === username)
}
// 依据用户所有已启用角色的 menuIds/buttonCodes 求并集,裁剪出该用户实际可见的菜单权限树
function resolveUserMenu(user) {
const userRoles = roles.filter((r) => user.roleIds.includes(r.id) && r.status === 'enabled')
const menuIdSet = new Set(userRoles.flatMap((r) => r.menuIds))
const buttonCodeSet = new Set(userRoles.flatMap((r) => r.buttonCodes))
const prunedTree = pruneMenuTree(menuTree, menuIdSet, buttonCodeSet)
return { menuTree: prunedTree, buttonCodes: flattenButtons(prunedTree) }
}
router.post('/sms/send', (req, res) => {
const { username, password } = req.body || {}
const user = findUser(username)
@ -54,14 +65,15 @@ router.post('/login', (req, res) => {
maxAge: REFRESH_TOKEN_TTL_MS,
sameSite: 'lax'
})
const { menuTree: userMenuTree, buttonCodes: userButtonCodes } = resolveUserMenu(user)
res.json({
code: 200,
msg: 'ok',
data: {
accessToken,
userInfo: { id: user.id, username: user.username, realName: user.realName, orgId: user.orgId, roleIds: user.roleIds },
menuTree,
buttonCodes: flattenButtons(menuTree),
menuTree: userMenuTree,
buttonCodes: userButtonCodes,
mustChangePassword: !!user.mustChangePassword
}
})
@ -91,7 +103,9 @@ router.get('/userinfo', requireAuth, (req, res) => {
})
router.get('/menu', requireAuth, (req, res) => {
res.json({ code: 200, msg: 'ok', data: { menuTree, buttonCodes: flattenButtons(menuTree) } })
const user = users.find((u) => u.id === req.userId)
const { menuTree: userMenuTree, buttonCodes: userButtonCodes } = resolveUserMenu(user)
res.json({ code: 200, msg: 'ok', data: { menuTree: userMenuTree, buttonCodes: userButtonCodes } })
})
export default router

View File

@ -1,13 +1,15 @@
import express from 'express'
import crypto from 'crypto'
import { orgs, nextOrgId, buildOrgTree, collectDescendantIds } from '../data/orgs.js'
import { users, nextUserId } from '../data/users.js'
import { roles } from '../data/roles.js'
import { roles, nextRoleId } from '../data/roles.js'
import { menuTree } from '../data/menu.js'
import { requireAuth } from '../middleware/requireAuth.js'
const router = express.Router()
router.use(requireAuth)
const DATA_SCOPES = ['own', 'ownAndSub', 'all']
function orgName(orgId) {
return orgs.find((o) => o.id === orgId)?.name || ''
}
@ -238,17 +240,98 @@ router.put('/user/:id/password/reset', (req, res) => {
res.json({ code: 200, msg: 'ok', data: null })
})
// ---------------- 角色管理(简版,供用户管理下拉使用;完整 CRUD 见后续路由) ----------------
// ---------------- 角色管理 ----------------
router.get('/role/list', (req, res) => {
const { status } = req.query || {}
const { name, orgId, status, page = 1, pageSize = 1000 } = req.query || {}
let list = roles
if (name) list = list.filter((r) => r.name.includes(name))
if (orgId) list = list.filter((r) => r.orgId === orgId)
if (status) list = list.filter((r) => r.status === status)
res.json({
code: 200,
msg: 'ok',
data: list.map((r) => ({ id: r.id, name: r.name, orgId: r.orgId, status: r.status, builtin: r.builtin }))
const total = list.length
const start = (Number(page) - 1) * Number(pageSize)
const pageList = list.slice(start, start + Number(pageSize)).map((r) => ({
id: r.id,
name: r.name,
orgId: r.orgId,
orgName: orgName(r.orgId),
status: r.status,
builtin: r.builtin,
createdAt: r.createdAt
}))
res.json({ code: 200, msg: 'ok', data: { list: pageList, total } })
})
router.get('/role/:id', (req, res) => {
const role = roles.find((r) => r.id === req.params.id)
if (!role) return res.json({ code: 40404, msg: '角色不存在', data: null })
res.json({ code: 200, msg: 'ok', data: role })
})
router.post('/role', (req, res) => {
const { name, orgId, menuIds, buttonCodes, dataScope } = req.body || {}
if (!name) return res.json({ code: 40016, msg: '请输入角色名称', data: null })
if (!orgId || !orgs.some((o) => o.id === orgId)) return res.json({ code: 40013, msg: '请选择有效的所属机构', data: null })
if (!menuIds?.length) return res.json({ code: 40017, msg: '请至少选择一个页面权限', data: null })
if (!DATA_SCOPES.includes(dataScope)) return res.json({ code: 40018, msg: '请选择数据权限范围', data: null })
const duplicate = roles.some((r) => r.orgId === orgId && r.name === name)
if (duplicate) return res.json({ code: 40019, msg: '同一所属机构下角色名称不可重复', data: null })
const role = {
id: nextRoleId(),
name,
orgId,
builtin: false,
status: 'enabled',
menuIds,
buttonCodes: buttonCodes || [],
dataScope,
createdAt: nowStr()
}
roles.push(role)
res.json({ code: 200, msg: 'ok', data: role })
})
router.put('/role/:id', (req, res) => {
const { id } = req.params
const { name, orgId, menuIds, buttonCodes, dataScope } = req.body || {}
const role = roles.find((r) => r.id === id)
if (!role) return res.json({ code: 40404, msg: '角色不存在', data: null })
if (!name) return res.json({ code: 40016, msg: '请输入角色名称', data: null })
if (role.builtin && name !== role.name) return res.json({ code: 40020, msg: '内置角色不可改名', data: null })
if (!orgId || !orgs.some((o) => o.id === orgId)) return res.json({ code: 40013, msg: '请选择有效的所属机构', data: null })
if (!menuIds?.length) return res.json({ code: 40017, msg: '请至少选择一个页面权限', data: null })
if (!DATA_SCOPES.includes(dataScope)) return res.json({ code: 40018, msg: '请选择数据权限范围', data: null })
const duplicate = roles.some((r) => r.id !== id && r.orgId === orgId && r.name === name)
if (duplicate) return res.json({ code: 40019, msg: '同一所属机构下角色名称不可重复', data: null })
role.name = name
role.orgId = orgId
role.menuIds = menuIds
role.buttonCodes = buttonCodes || []
role.dataScope = dataScope
res.json({ code: 200, msg: 'ok', data: role })
})
router.delete('/role/:id', (req, res) => {
const { id } = req.params
const index = roles.findIndex((r) => r.id === id)
if (index === -1) return res.json({ code: 40404, msg: '角色不存在', data: null })
const role = roles[index]
if (role.builtin) return res.json({ code: 40021, msg: '内置角色不可删除', data: null })
const relatedUsers = users.filter((u) => u.roleIds.includes(id))
if (relatedUsers.length) {
return res.json({ code: 40022, msg: `该角色已关联${relatedUsers.length}个用户,请先解除关联后再删除`, data: null })
}
roles.splice(index, 1)
res.json({ code: 200, msg: 'ok', data: null })
})
// ---------------- 权限字典树 ----------------
router.get('/permission/tree', (req, res) => {
res.json({ code: 200, msg: 'ok', data: menuTree })
})
export default router

View File

@ -18,3 +18,8 @@ export const resetUserPasswordApi = (id) => request.put(`/system/user/${id}/pass
// ---------------- 角色管理 ----------------
export const fetchRoleListApi = (params) => request.get('/system/role/list', { params })
export const fetchRoleDetailApi = (id) => request.get(`/system/role/${id}`)
export const createRoleApi = (data) => request.post('/system/role', data)
export const updateRoleApi = (id, data) => request.put(`/system/role/${id}`, data)
export const deleteRoleApi = (id) => request.delete(`/system/role/${id}`)
export const fetchPermissionTreeApi = () => request.get('/system/permission/tree')

View File

@ -0,0 +1,157 @@
<template>
<div class="permission-tree">
<div class="permission-columns">
<div class="menu-column">
<div class="column-title">页面权限</div>
<a-tree
v-if="treeData.length"
checkable
:disabled="readonly"
:tree-data="treeData"
:checked-keys="checkedKeys"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
default-expand-all
@check="handleCheck"
/>
<a-empty v-else description="加载中" />
</div>
<div class="button-column">
<div class="column-title">功能权限</div>
<div v-if="buttonGroups.length === 0" class="empty-hint"></div>
<div v-for="group in buttonGroups" :key="group.id" class="button-group">
<div class="button-group-title">{{ group.name }}</div>
<a-checkbox-group v-model:value="buttonCodesModel" :disabled="readonly" :options="group.options" />
</div>
</div>
</div>
<div class="data-scope-row">
<div class="column-title">数据权限范围</div>
<a-radio-group v-model:value="dataScopeModel" :disabled="readonly">
<a-radio value="own">本机构</a-radio>
<a-radio value="ownAndSub">本机构及下级机构</a-radio>
<a-radio value="all">全部机构</a-radio>
</a-radio-group>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { fetchPermissionTreeApi } from '@/api/system'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ menuIds: [], buttonCodes: [], dataScope: undefined })
},
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const rawTree = ref([])
const flatNodeMap = ref(new Map())
function toTreeData(nodes) {
return nodes.map((node) => {
flatNodeMap.value.set(node.id, node)
return {
key: node.id,
title: node.name,
children: node.children?.length ? toTreeData(node.children) : []
}
})
}
const treeData = computed(() => (rawTree.value.length ? toTreeData(rawTree.value) : []))
async function loadTree() {
const res = await fetchPermissionTreeApi()
if (res.data.code === 200) rawTree.value = res.data.data
}
onMounted(loadTree)
const checkedKeys = computed(() => props.modelValue.menuIds || [])
const buttonCodesModel = computed({
get: () => props.modelValue.buttonCodes || [],
set: (val) => emitChange({ buttonCodes: val })
})
const dataScopeModel = computed({
get: () => props.modelValue.dataScope,
set: (val) => emitChange({ dataScope: val })
})
const buttonGroups = computed(() => {
const groups = []
checkedKeys.value.forEach((id) => {
const node = flatNodeMap.value.get(id)
if (node?.buttons?.length) {
groups.push({
id: node.id,
name: node.name,
options: node.buttons.map((b) => ({ label: b.name, value: b.code }))
})
}
})
return groups
})
function emitChange(patch) {
emit('update:modelValue', {
menuIds: props.modelValue.menuIds || [],
buttonCodes: props.modelValue.buttonCodes || [],
dataScope: props.modelValue.dataScope,
...patch
})
}
function handleCheck(keys) {
// ,
const validCodes = new Set()
keys.forEach((id) => {
const node = flatNodeMap.value.get(id)
node?.buttons?.forEach((b) => validCodes.add(b.code))
})
const nextButtonCodes = (props.modelValue.buttonCodes || []).filter((code) => validCodes.has(code))
emit('update:modelValue', {
menuIds: keys,
buttonCodes: nextButtonCodes,
dataScope: props.modelValue.dataScope
})
}
</script>
<style scoped>
.permission-columns {
display: flex;
gap: 24px;
}
.menu-column,
.button-column {
flex: 1;
min-width: 0;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 12px;
max-height: 320px;
overflow-y: auto;
}
.column-title {
font-weight: 600;
margin-bottom: 8px;
}
.button-group {
margin-bottom: 16px;
}
.button-group-title {
color: #666;
margin-bottom: 4px;
}
.empty-hint {
color: #999;
}
.data-scope-row {
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div class="role-list-page">
<ProTable
ref="proTableRef"
:columns="columns"
:fetch-data="loadRoleList"
:initial-search="{ name: '', orgId: undefined }"
>
<template #search="{ form }">
<a-form-item label="角色名称">
<a-input v-model:value="form.name" placeholder="请输入角色名称" allow-clear style="width: 180px" />
</a-form-item>
<a-form-item label="所属机构">
<OrgTreeSelect v-model="form.orgId" style="width: 200px" />
</a-form-item>
</template>
<template #actions>
<a-button v-permission="'role:add'" type="primary" @click="openModal('create')">
<PlusOutlined /> 新增角色
</a-button>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'name'">
{{ record.name }}
<a-tag v-if="record.builtin" color="blue" style="margin-left: 4px"></a-tag>
</template>
<template v-else-if="column.dataIndex === 'action'">
<a-space>
<a @click="openModal('view', record)">查看</a>
<a v-permission="'role:edit'" @click="openModal('edit', record)"></a>
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
<a v-if="!record.builtin" v-permission="'role:delete'" class="danger-link"></a>
</a-popconfirm>
</a-space>
</template>
</template>
</ProTable>
<a-modal
v-model:open="modalOpen"
:title="modalTitleMap[modalMode]"
:width="760"
:footer="modalMode === 'view' ? null : undefined"
:confirm-loading="submitting"
@ok="handleSubmit"
>
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<a-form-item label="角色名称" name="name">
<a-input
v-model:value="form.name"
:disabled="modalMode === 'view' || (modalMode === 'edit' && form.builtin)"
placeholder="请输入角色名称"
/>
</a-form-item>
<a-form-item label="所属机构" name="orgId">
<OrgTreeSelect v-model="form.orgId" :disabled="modalMode === 'view'" />
</a-form-item>
<a-form-item label="权限配置" name="menuIds">
<PermissionTree v-model="permissionValue" :readonly="modalMode === 'view'" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import ProTable from '@/components/ProTable.vue'
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
import PermissionTree from '@/components/PermissionTree.vue'
import { fetchRoleListApi, fetchRoleDetailApi, createRoleApi, updateRoleApi, deleteRoleApi } from '@/api/system'
const columns = [
{ title: '序号', dataIndex: 'index', width: 60 },
{ title: '角色名称', dataIndex: 'name' },
{ title: '所属机构', dataIndex: 'orgName' },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', dataIndex: 'action', width: 180 }
]
const proTableRef = ref(null)
async function loadRoleList(params) {
const res = await fetchRoleListApi(params)
if (res.data.code === 200) return res.data.data
return { list: [], total: 0 }
}
const modalOpen = ref(false)
const modalMode = ref('create') // create | edit | view
const submitting = ref(false)
const formRef = ref(null)
const modalTitleMap = { create: '新增角色', edit: '修改角色', view: '查看角色' }
const form = reactive({ id: '', name: '', orgId: undefined, builtin: false })
const permissionValue = ref({ menuIds: [], buttonCodes: [], dataScope: undefined })
const rules = {
name: [{ required: true, message: '请输入角色名称' }],
orgId: [{ required: true, message: '请选择所属机构' }]
}
async function openModal(mode, record) {
modalMode.value = mode
if (mode === 'create') {
form.id = ''
form.name = ''
form.orgId = undefined
form.builtin = false
permissionValue.value = { menuIds: [], buttonCodes: [], dataScope: undefined }
modalOpen.value = true
return
}
const res = await fetchRoleDetailApi(record.id)
if (res.data.code === 200) {
const detail = res.data.data
form.id = detail.id
form.name = detail.name
form.orgId = detail.orgId
form.builtin = detail.builtin
permissionValue.value = {
menuIds: detail.menuIds || [],
buttonCodes: detail.buttonCodes || [],
dataScope: detail.dataScope
}
modalOpen.value = true
}
}
async function handleSubmit() {
if (modalMode.value === 'view') {
modalOpen.value = false
return
}
try {
await formRef.value.validate()
} catch {
return
}
if (!permissionValue.value.menuIds.length) {
message.error('请至少选择一个页面权限')
return
}
if (!permissionValue.value.dataScope) {
message.error('请选择数据权限范围')
return
}
submitting.value = true
try {
const payload = {
name: form.name,
orgId: form.orgId,
menuIds: permissionValue.value.menuIds,
buttonCodes: permissionValue.value.buttonCodes,
dataScope: permissionValue.value.dataScope
}
const res =
modalMode.value === 'create' ? await createRoleApi(payload) : await updateRoleApi(form.id, payload)
if (res.data.code === 200) {
message.success(modalMode.value === 'create' ? '新增成功' : '修改成功')
modalOpen.value = false
proTableRef.value.reload()
}
} finally {
submitting.value = false
}
}
async function handleDelete(record) {
const res = await deleteRoleApi(record.id)
if (res.data.code === 200) {
message.success('删除成功')
proTableRef.value.reload()
}
}
</script>
<style scoped>
.danger-link {
color: #ff4d4f;
}
</style>

View File

@ -136,7 +136,7 @@ const roleOptions = ref([])
async function loadRoleOptions() {
const res = await fetchRoleListApi({ status: 'enabled' })
if (res.data.code === 200) roleOptions.value = res.data.data
if (res.data.code === 200) roleOptions.value = res.data.data.list
}
async function loadUserList(params) {