coder-common-thin-frontend/src/views/system/role/index.vue

1660 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="role-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和角色表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="5" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="角色名称" path="roleName">
<n-input
v-model:value="searchForm.roleName"
placeholder="请输入角色名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="角色编码" path="roleCode">
<n-input
v-model:value="searchForm.roleCode"
placeholder="请输入角色编码"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="创建时间" path="timeRange">
<n-date-picker
v-model:value="searchForm.timeRange"
type="datetimerange"
clearable
placeholder="选择时间范围"
:shortcuts="{
今天: () => [new Date().setHours(0, 0, 0, 0), new Date().setHours(23, 59, 59, 999)],
昨天: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday.setHours(0, 0, 0, 0), yesterday.setHours(23, 59, 59, 999)];
},
最近7天: () => [Date.now() - 7 * 24 * 60 * 60 * 1000, Date.now()],
最近30天: () => [Date.now() - 30 * 24 * 60 * 60 * 1000, Date.now()],
}"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="角色状态" path="roleStatus">
<n-select
v-model:value="searchForm.roleStatus"
placeholder="请选择角色状态"
clearable
:options="getSelectOptions('sys_switch_status')"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline-search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline-refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<NButton v-button="PERMISSIONS.ROLE.ADD" type="primary" class="px-3 flex items-center" @click="handleAdd">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline-plus />
</NIcon>
</template>
新增
</NButton>
<NButton
v-button="PERMISSIONS.ROLE.UPDATE"
type="info"
:disabled="selectedRows.length !== 1"
class="px-3 flex items-center"
@click="handleBatchEdit"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline-edit />
</NIcon>
</template>
修改
</NButton>
<NButton
v-button="PERMISSIONS.ROLE.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<IconParkOutlineDelete />
</NIcon>
</template>
删除
</NButton>
<NButton
v-button="PERMISSIONS.ROLE.MENU"
type="warning"
:disabled="selectedRows.length !== 1 || (selectedRows.length === 1 && selectedRows[0].roleId === 1)"
class="px-3 flex items-center"
@click="handleBatchAssignMenu"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<IconParkOutlineKey />
</NIcon>
</template>
分配权限
</NButton>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>共 {{ pagination.itemCount }} 条</span>
<NButton text @click="getRoleList">
<template #icon>
<NIcon><icon-park-outline-refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: RoleVo) => row.roleId"
:bordered="false"
:single-line="false"
:scroll-x="1200"
size="medium"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
round
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<icon-park-outline-refresh v-if="hasSearchConditions()" />
<icon-park-outline-plus v-else />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="border-t border-gray-100">
<CoiPagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
:show-quick-jumper="true"
:show-current-info="true"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<!-- 角色表单弹框 -->
<CoiDialog
ref="roleDialogRef"
:title="modalTitle"
:width="800"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleSubmit"
@coi-cancel="handleCancel"
>
<template #content>
<div class="px-3 py-2">
<n-form
ref="formRef"
:model="formData"
:rules="rules"
label-placement="left"
label-width="90px"
require-mark-placement="right-hanging"
class="compact-form"
>
<!-- 第一行:角色名称 和 角色编码 -->
<n-grid :cols="2" :x-gap="10">
<n-grid-item>
<n-form-item label="角色名称" path="roleName" class="mb-2">
<n-input
v-model:value="formData.roleName"
placeholder="请输入角色名称"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="角色编码" path="roleCode" class="mb-2">
<n-input
v-model:value="formData.roleCode"
placeholder="请输入角色编码"
:disabled="isEdit"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 第二行:角色状态 和 显示排序 -->
<n-grid :cols="2" :x-gap="10">
<n-grid-item>
<n-form-item label="角色状态" path="roleStatus" class="mb-2">
<NRadioGroup v-model:value="formData.roleStatus">
<NRadio
v-for="item in dictOptions.sys_switch_status || []"
:key="item.dictValue"
:value="item.dictValue"
>
{{ item.dictLabel }}
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="显示排序" path="sorted" class="mb-2">
<n-input-number
v-model:value="formData.sorted"
placeholder="请输入排序号"
:min="1"
:max="999"
style="width: 100%"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 第三行:角色描述(占满整行) -->
<n-form-item label="角色描述" path="remark" class="mb-2">
<n-input
v-model:value="formData.remark"
type="textarea"
placeholder="请输入角色描述"
:rows="3"
/>
</n-form-item>
</n-form>
</div>
</template>
</CoiDialog>
<!-- 菜单权限分配弹框 -->
<CoiDialog
ref="menuDialogRef"
title="分配菜单权限"
:width="500"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleConfirmAssignMenu"
@coi-cancel="handleCancelAssignMenu"
>
<template #content>
<div class="p-3">
<div v-if="menuLoading" class="flex items-center justify-center py-8">
<div class="text-center">
<n-spin size="medium" />
<p class="mt-2 text-sm text-gray-500">
正在加载菜单数据...
</p>
</div>
</div>
<div v-else-if="menuData.length > 0">
<!-- 功能按钮区域 -->
<div class="flex items-center gap-2 mb-3 pb-3 border-b border-gray-200">
<NButton
size="small"
:type="allExpanded ? 'default' : 'primary'"
@click="toggleExpandAll"
>
<template #icon>
<NIcon style="transform: translateY(-1px)">
<icon-park-outline-minus v-if="allExpanded" />
<icon-park-outline-plus v-else />
</NIcon>
</template>
{{ allExpanded ? '折叠' : '展开' }}
</NButton>
<NButton
size="small"
:type="allSelected ? 'default' : 'primary'"
@click="toggleSelectAll"
>
<template #icon>
<NIcon style="transform: translateY(-1px)">
<icon-park-outline-close v-if="allSelected" />
<icon-park-outline-check v-else />
</NIcon>
</template>
{{ allSelected ? '全不选' : '全选' }}
</NButton>
<NButton
size="small"
:type="cascadeEnabled ? 'primary' : 'default'"
@click="toggleCascade"
>
<template #icon>
<NIcon style="transform: translateY(-1px)">
<icon-park-outline-link />
</NIcon>
</template>
父子联动
</NButton>
<NButton
size="small"
:type="showPermissionCode ? 'primary' : 'default'"
@click="togglePermissionCode"
>
<template #icon>
<NIcon style="transform: translateY(-1px)">
<icon-park-outline-battery-charge />
</NIcon>
</template>
权限标识
</NButton>
</div>
<NTree
v-model:checked-keys="checkedKeys"
v-model:expanded-keys="expandedKeys"
:data="menuData"
checkable
:cascade="cascadeEnabled"
check-strategy="all"
expand-on-click
:render-suffix="showPermissionCode ? renderPermissionCode : undefined"
class="clean-menu-tree"
/>
</div>
<div v-else class="flex items-center justify-center py-8">
<div class="text-center">
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-2">
<NIcon size="16" class="text-gray-400">
<icon-park-outline-folder-close />
</NIcon>
</div>
<p class="text-sm text-gray-500">
暂无可分配的菜单
</p>
</div>
</div>
</div>
</template>
</CoiDialog>
</div>
</template>
<script setup lang="ts">
import { h, nextTick, onMounted, ref, watch } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NRadio, NRadioGroup, NSpace, NSwitch, NTag, NTree } from 'naive-ui'
import IconParkOutlineEditOne from '~icons/icon-park-outline/edit-one'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineKey from '~icons/icon-park-outline/key'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import DictTag from '@/components/common/DictTag.vue'
import {
addRole,
batchDeleteRoles,
deleteRole,
getRolePage,
getRoleSorted,
updateRole,
updateRoleStatus,
} from '@/service/api/system/role'
import type { RoleForm, RoleSearchForm, RoleVo } from '@/service/api/system/role'
import {
fetchMenuIdsByRoleId,
fetchMenuPermissionData,
saveRoleMenuPermission,
} from '@/service/api/system/menu'
import type { MenuPermissionData, MenuVo } from '@/service/api/system/menu'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { useDict, usePermission } from '@/hooks'
// 权限相关
const { hasButton } = usePermission()
const { dictOptions, getSelectOptions, getDictLabel } = useDict(['sys_switch_status'])
// 响应式数据
const loading = ref(false)
const tableData = ref<RoleVo[]>([])
const modalTitle = ref('新增角色')
const formRef = ref<FormInst | null>(null)
const searchFormRef = ref<FormInst | null>(null)
const isEdit = ref(false)
const currentRole = ref<RoleVo | null>(null)
const selectedRows = ref<RoleVo[]>([])
// 弹框引用
const roleDialogRef = ref()
const menuDialogRef = ref()
// 菜单权限分配相关
const menuModalTitle = ref('分配菜单权限')
const currentAssignRole = ref<RoleVo | null>(null)
const menuData = ref<any[]>([])
const expandedKeys = ref<string[]>([])
const checkedKeys = ref<string[]>([])
const menuLoading = ref(false)
// 功能按钮状态
const allExpanded = ref(false)
const allSelected = ref(false)
const cascadeEnabled = ref(true)
const showPermissionCode = ref(false)
// 分页数据
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
// 搜索表单数据
const searchForm = ref<RoleSearchForm>({})
// 表单数据
const formData = ref<RoleForm>({
roleName: '',
roleCode: '',
roleStatus: '0',
remark: '',
sorted: 1,
})
// 表单验证规则
const rules = {
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 20, message: '角色名称长度为 2-20 位', trigger: 'blur' },
],
roleCode: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ min: 2, max: 20, message: '角色编码长度为 2-20 位', trigger: 'blur' },
{ pattern: /^\w+$/, message: '角色编码只能包含字母、数字和下划线', trigger: 'blur' },
],
roleStatus: [
{ required: true, message: '请选择角色状态', trigger: 'change' },
],
}
// 表格列定义
const columns: DataTableColumns<RoleVo> = [
{
type: 'selection',
width: 50,
disabled: (row: RoleVo) => row.roleId === 1, // 超级管理员不可选择
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '角色名称',
key: 'roleName',
width: 150,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.roleName)
},
},
{
title: '角色编码',
key: 'roleCode',
width: 150,
align: 'center',
render: row => h(NTag, { type: 'primary', size: 'small' }, { default: () => row.roleCode }),
},
{
title: '角色状态',
key: 'roleStatus',
width: 120,
align: 'center',
render: (row) => {
return h('div', { class: 'flex items-center justify-center gap-2' }, [
h(DictTag, { dictType: 'sys_switch_status', value: row.roleStatus }),
h(NPopconfirm, {
onPositiveClick: () => handleToggleStatus(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要将角色「${row.roleName}」状态切换为「${getDictLabel('sys_switch_status', row.roleStatus === '0' ? '1' : '0')}」吗?`,
trigger: () => h(NSwitch, {
value: row.roleStatus === '0',
size: 'small',
checkedChildren: getDictLabel('sys_switch_status', '0', '启用'),
uncheckedChildren: getDictLabel('sys_switch_status', '1', '停用'),
loading: false,
}),
}),
])
},
},
{
title: '角色描述',
key: 'remark',
width: 200,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.remark || '-')
},
},
{
title: '排序',
key: 'sorted',
width: 80,
align: 'center',
render: (row) => {
return h(NTag, { type: 'primary', size: 'small' }, { default: () => row.sorted || '-' })
},
},
{
title: '创建时间',
key: 'createTime',
width: 160,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.createTime || '-')
},
},
{
title: '操作',
key: 'actions',
width: 200,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
// 编辑按钮
if (hasButton(PERMISSIONS.ROLE.UPDATE)) {
buttons.push(h(NButton, {
type: 'primary',
size: 'small',
class: 'action-btn-primary',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineEditOne),
}),
default: () => '编辑',
}))
}
// 删除按钮
if (hasButton(PERMISSIONS.ROLE.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.roleId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => row.roleId === 1 ? '超级管理员角色不可删除' : '确定删除此角色吗?',
trigger: () => h(NButton, {
type: 'error',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-danger',
disabled: row.roleId === 1,
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '删除',
}),
}))
}
// 分配权限按钮 - 超级管理员不显示
if (hasButton(PERMISSIONS.ROLE.MENU) && row.roleId !== 1) {
buttons.push(h(NButton, {
type: 'warning',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-warning',
onClick: () => handleAssignMenu(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineKey),
}),
default: () => '权限',
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
// 构建树形菜单数据
function buildMenuTree(menuList: MenuVo[]): any[] {
const map = new Map()
const result: any[] = []
// 先将所有菜单存入map确保key的唯一性和类型一致性
menuList.forEach((menu) => {
const permissionCode = menu.auth || menu.perms || menu.permission || ''
map.set(menu.menuId, {
key: String(menu.menuId), // 确保key为字符串类型
label: menu.menuName,
menuId: menu.menuId,
parentId: menu.parentId,
menuType: menu.menuType,
auth: menu.auth || '', // 权限标识
perms: permissionCode, // 权限标识
permissionCode, // 权限标识的别名
})
})
// 构建树形结构
menuList.forEach((menu) => {
const node = map.get(menu.menuId)
if (menu.parentId === 0) {
result.push(node)
}
else {
const parent = map.get(menu.parentId)
if (parent) {
if (!parent.children) {
parent.children = []
}
parent.children.push(node)
}
}
})
// 清理空的children数组让叶子节点真正成为叶子节点
function cleanEmptyChildren(nodes: any[]) {
nodes.forEach((node) => {
if (node.children && node.children.length === 0) {
delete node.children
}
else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children)
}
})
}
cleanEmptyChildren(result)
return result
}
// 获取角色列表
async function getRoleList() {
loading.value = true
try {
// 构建请求参数,处理时间范围
const { timeRange, ...otherParams } = searchForm.value
// 过滤掉空值和null值只保留有效的搜索条件
const filteredParams = Object.entries(otherParams).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params: any = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
// 处理时间范围转换为beginTime和endTime需要后端支持
if (timeRange && timeRange.length === 2) {
// 使用本地时间格式,避免时区问题
const startDate = new Date(timeRange[0])
const endDate = new Date(timeRange[1])
// 格式化为 yyyy-MM-dd HH:mm:ss 格式
params.beginTime = `${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}-${String(startDate.getDate()).padStart(2, '0')} ${String(startDate.getHours()).padStart(2, '0')}:${String(startDate.getMinutes()).padStart(2, '0')}:${String(startDate.getSeconds()).padStart(2, '0')}`
params.endTime = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')} ${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}:${String(endDate.getSeconds()).padStart(2, '0')}`
}
const { isSuccess, data } = await getRolePage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
}
else {
coiMsgError('获取角色列表失败,请检查网络连接或联系管理员')
tableData.value = []
pagination.value.itemCount = 0
}
}
catch {
coiMsgError('获取角色列表失败,请检查网络连接')
tableData.value = []
pagination.value.itemCount = 0
}
finally {
loading.value = false
}
}
// 分页变化处理
function handlePageChange(page: number) {
pagination.value.page = page
getRoleList()
}
function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getRoleList()
}
// 搜索
function handleSearch() {
pagination.value.page = 1
getRoleList()
}
// 重置搜索
async function handleReset() {
// 重置搜索表单的所有字段
searchForm.value = {
roleName: '',
roleCode: '',
roleStatus: null,
timeRange: null,
}
// 使用 nextTick 确保 DOM 更新后再重置表单状态
await nextTick()
// 重置表单验证状态
if (searchFormRef.value) {
searchFormRef.value.restoreValidation()
}
// 重置分页到第一页
pagination.value.page = 1
// 重新获取数据
getRoleList()
}
// 行选择变化
function handleRowSelectionChange(rowKeys: (string | number)[]) {
// 保持字符串格式避免精度丢失
const stringKeys = rowKeys.map(key => String(key))
selectedRows.value = tableData.value.filter(row => stringKeys.includes(String(row.roleId)))
}
// 批量修改(选中一个角色进行修改)
function handleBatchEdit() {
if (selectedRows.value.length !== 1) {
coiMsgWarning('请选择一个角色进行修改')
return
}
handleEdit(selectedRows.value[0])
}
// 批量分配权限(选中一个角色进行权限分配)
function handleBatchAssignMenu() {
if (selectedRows.value.length !== 1) {
coiMsgWarning('请选择一个角色进行权限分配')
return
}
const selectedRole = selectedRows.value[0]
if (selectedRole.roleId === 1) {
coiMsgError('超级管理员角色无需分配权限')
return
}
handleAssignMenu(selectedRole)
}
// 新增角色
async function handleAdd() {
modalTitle.value = '新增角色'
isEdit.value = false
currentRole.value = null
try {
// 获取最新排序号
const { isSuccess, data } = await getRoleSorted()
const sorted = isSuccess && data ? data : 1
formData.value = {
roleName: '',
roleCode: '',
roleStatus: '0',
remark: '',
sorted,
}
}
catch {
formData.value = {
roleName: '',
roleCode: '',
roleStatus: '0',
remark: '',
sorted: 1,
}
}
roleDialogRef.value?.coiOpen()
}
// 编辑角色
function handleEdit(role: RoleVo) {
modalTitle.value = '编辑角色'
isEdit.value = true
currentRole.value = role
formData.value = {
roleId: role.roleId,
roleName: role.roleName,
roleCode: role.roleCode,
roleStatus: role.roleStatus,
remark: role.remark || '',
sorted: role.sorted || 1,
}
roleDialogRef.value?.coiOpen()
}
// 删除角色
async function handleDelete(roleId: number) {
if (roleId === 1) {
coiMsgError('超级管理员角色不可删除')
return
}
try {
const { isSuccess } = await deleteRole(roleId)
if (isSuccess) {
coiMsgSuccess('删除成功')
await getRoleList()
}
else {
coiMsgError('删除失败')
}
}
catch {
coiMsgError('删除失败')
}
}
// 批量删除角色
async function handleBatchDelete() {
if (selectedRows.value.length === 0) {
coiMsgWarning('请先选择要删除的角色')
return
}
// 检查是否包含超级管理员角色
const hasSuperAdmin = selectedRows.value.some(role => role.roleId === 1)
if (hasSuperAdmin) {
coiMsgError('所选角色中包含超级管理员,无法批量删除')
return
}
try {
// 确认删除操作
await coiMsgBox(`确定要删除选中的 ${selectedRows.value.length} 个角色吗?`, '批量删除确认')
}
catch {
// 用户取消删除操作,直接返回,不显示任何提示
return
}
// 用户确认删除,执行删除操作
try {
const roleIds = selectedRows.value.map(role => role.roleId)
const { isSuccess } = await batchDeleteRoles(roleIds)
if (isSuccess) {
coiMsgSuccess('批量删除成功')
selectedRows.value = []
await getRoleList()
}
else {
coiMsgError('批量删除失败')
}
}
catch {
coiMsgError('批量删除失败')
}
}
// 切换角色状态
async function handleToggleStatus(role: RoleVo) {
try {
const newStatus = role.roleStatus === '0' ? '1' : '0'
const statusText = getDictLabel('sys_switch_status', newStatus, newStatus === '0' ? '启用' : '停用')
const { isSuccess } = await updateRoleStatus(role.roleId, newStatus)
if (isSuccess) {
role.roleStatus = newStatus
coiMsgSuccess(`角色「${role.roleName}${statusText}成功`)
// 刷新角色列表以确保数据同步
await getRoleList()
}
else {
coiMsgError(`角色${statusText}失败`)
}
}
catch {
coiMsgError('状态修改失败,请检查网络连接')
}
}
// 分配菜单权限
async function handleAssignMenu(role: RoleVo) {
if (!role?.roleId) {
coiMsgError('角色信息无效')
return
}
try {
menuLoading.value = true
currentAssignRole.value = role
menuModalTitle.value = `分配菜单权限 - ${role.roleName}`
// 重置状态
menuData.value = []
expandedKeys.value = []
checkedKeys.value = []
// 获取菜单权限数据
const menuResponse = await fetchMenuPermissionData()
if (menuResponse.isSuccess && menuResponse.data) {
const permissionData = menuResponse.data as MenuPermissionData
menuData.value = buildMenuTree(permissionData.menuList)
// 将展开的节点ID转换为字符串格式保持与key格式一致
expandedKeys.value = (permissionData.spreadList || []).map(id => String(id))
// 获取当前角色的菜单权限
const roleMenuResponse = await fetchMenuIdsByRoleId(role.roleId)
if (roleMenuResponse.isSuccess && roleMenuResponse.data) {
// 将权限ID转换为字符串格式保持与key格式一致
checkedKeys.value = roleMenuResponse.data.map(id => String(id))
}
menuDialogRef.value?.coiOpen()
}
else {
coiMsgError('获取菜单数据失败')
}
}
catch {
coiMsgError('获取菜单数据失败,请检查网络连接')
}
finally {
menuLoading.value = false
}
}
// 确保选中子菜单时父菜单也被包含
function ensureParentMenusIncluded(selectedIds: string[], menuTree: any[]): string[] {
const allMenuMap = new Map<number, any>()
const result = new Set(selectedIds)
// 递归收集所有菜单项到map中
function collectMenus(menus: any[]) {
menus.forEach((menu) => {
allMenuMap.set(menu.menuId, menu)
if (menu.children && menu.children.length > 0) {
collectMenus(menu.children)
}
})
}
collectMenus(menuTree)
// 为每个选中的菜单添加其所有父菜单
selectedIds.forEach((menuIdStr) => {
const menuId = Number.parseInt(menuIdStr)
const menu = allMenuMap.get(menuId)
if (menu && menu.parentId !== 0) {
let parentId = menu.parentId
while (parentId && parentId !== 0) {
result.add(String(parentId))
const parentMenu = allMenuMap.get(parentId)
if (parentMenu && parentMenu.parentId !== 0) {
parentId = parentMenu.parentId
}
else {
break
}
}
}
})
return Array.from(result)
}
// 确认分配菜单权限
async function handleConfirmAssignMenu() {
if (!currentAssignRole.value) {
coiMsgError('角色信息缺失')
return
}
try {
menuLoading.value = true
// 确保选中子菜单时父菜单也被包含
const completeMenuIds = ensureParentMenusIncluded(checkedKeys.value, menuData.value)
// 保持字符串格式避免精度丢失
const response = await saveRoleMenuPermission(String(currentAssignRole.value.roleId), completeMenuIds)
if (response.isSuccess) {
coiMsgSuccess('菜单权限分配成功')
menuDialogRef.value?.coiClose()
}
else {
coiMsgError(response.message || '菜单权限分配失败')
}
}
catch {
coiMsgError('菜单权限分配失败,请检查网络连接')
}
finally {
menuLoading.value = false
}
}
// 取消分配菜单权限
function handleCancelAssignMenu() {
menuDialogRef.value?.coiClose()
currentAssignRole.value = null
menuData.value = []
expandedKeys.value = []
checkedKeys.value = []
menuModalTitle.value = '分配菜单权限'
menuLoading.value = false
// 重置功能按钮状态
allExpanded.value = false
allSelected.value = false
cascadeEnabled.value = true
showPermissionCode.value = false
}
// 获取所有菜单节点的key
function getAllMenuKeys(menus: any[]): string[] {
const keys: string[] = []
function traverse(nodes: any[]) {
nodes.forEach((node) => {
keys.push(node.key)
if (node.children && node.children.length > 0) {
traverse(node.children)
}
})
}
traverse(menus)
return keys
}
// 展开/折叠所有节点
function toggleExpandAll() {
if (allExpanded.value) {
// 折叠所有
expandedKeys.value = []
allExpanded.value = false
}
else {
// 展开所有
expandedKeys.value = getAllMenuKeys(menuData.value)
allExpanded.value = true
}
}
// 全选/全不选
function toggleSelectAll() {
if (allSelected.value) {
// 全不选
checkedKeys.value = []
allSelected.value = false
}
else {
// 全选
checkedKeys.value = getAllMenuKeys(menuData.value)
allSelected.value = true
}
}
// 切换父子联动
function toggleCascade() {
cascadeEnabled.value = !cascadeEnabled.value
}
// 切换权限标识显示
function togglePermissionCode() {
showPermissionCode.value = !showPermissionCode.value
}
// 渲染权限标识
function renderPermissionCode({ option }: { option: any }) {
const code = option.auth || option.perms || option.permissionCode || ''
// 如果没有权限标识返回null
if (!code || code.trim() === '') {
return null
}
return h(NTag, {
type: 'primary',
size: 'small',
class: 'ml-2 font-mono',
style: 'font-size: 11px; line-height: 1.2; transform: translateY(-5px); display: inline-flex; align-items: center;',
}, { default: () => code })
}
// 提交表单
async function handleSubmit() {
if (!formRef.value)
return
try {
// 先进行表单验证
await formRef.value.validate()
}
catch {
// 表单验证失败,提示用户检查填写内容
coiMsgWarning('验证失败,请检查填写内容')
return
}
// 表单验证通过执行API调用
try {
const submitData = { ...formData.value }
const { isSuccess } = isEdit.value ? await updateRole(submitData) : await addRole(submitData)
if (isSuccess) {
coiMsgSuccess(isEdit.value ? '角色信息更新成功' : '角色创建成功')
roleDialogRef.value?.coiClose()
await getRoleList()
}
else {
coiMsgError(isEdit.value ? '更新失败,请稍后重试' : '创建失败,请稍后重试')
}
}
catch {
coiMsgError(isEdit.value ? '更新失败,请检查网络连接' : '创建失败,请检查网络连接')
}
}
// 取消操作
function handleCancel() {
roleDialogRef.value?.coiClose()
}
// 判断是否有搜索条件
function hasSearchConditions() {
return Object.values(searchForm.value).some((value) => {
if (value === null || value === undefined || value === '') {
return false
}
if (Array.isArray(value) && value.length === 0) {
return false
}
return true
})
}
// 获取空状态类型
function getEmptyType() {
if (hasSearchConditions()) {
return 'search'
}
return 'default'
}
// 获取空状态标题
function getEmptyTitle() {
if (hasSearchConditions()) {
return '搜索无结果'
}
return '暂无角色数据'
}
// 获取空状态描述
function getEmptyDescription() {
if (hasSearchConditions()) {
return '未找到符合搜索条件的角色,请尝试调整搜索条件或重置筛选'
}
return '当前还没有角色数据,点击"新增"按钮创建第一个角色'
}
// 获取空状态操作文字
function getEmptyActionText() {
if (hasSearchConditions()) {
return '重置筛选'
}
return '新增角色'
}
// 处理空状态操作
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
}
else {
handleAdd()
}
}
// 监听选中状态变化,自动更新全选按钮状态
watch(checkedKeys, (newKeys) => {
const allKeys = getAllMenuKeys(menuData.value)
allSelected.value = allKeys.length > 0 && newKeys.length === allKeys.length
}, { deep: true })
// 监听展开状态变化,自动更新展开按钮状态
watch(expandedKeys, (newKeys) => {
const allKeys = getAllMenuKeys(menuData.value)
allExpanded.value = allKeys.length > 0 && newKeys.length === allKeys.length
}, { deep: true })
// 组件挂载时获取数据
onMounted(() => {
getRoleList()
})
</script>
<style scoped>
.role-management {
height: 100vh;
overflow: hidden;
}
.custom-table :deep(.n-data-table-td) {
padding: 7px 12px;
font-size: 14px;
}
.custom-table :deep(.n-data-table-th) {
padding: 9px 12px;
background-color: #fafafa;
font-weight: normal;
font-size: 14px;
color: #262626;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8faff;
}
.search-form :deep(.n-form-item-label) {
font-weight: 500;
font-size: 14px;
color: #262626;
}
/* 操作按钮样式优化 */
.custom-table :deep(.n-button--small-type) {
font-size: 12px;
padding: 4px 8px;
}
/* 标签尺寸优化 */
.custom-table :deep(.n-tag--small-type) {
font-size: 11px;
padding: 2px 6px;
}
/* 自定义滚动条样式 */
.table-wrapper {
min-height: 0;
}
.table-wrapper::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.table-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.table-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.table-wrapper::-webkit-scrollbar-corner {
background: #f1f1f1;
}
/* 表格滚动条样式 */
.custom-table :deep(.n-data-table-base-table) {
overflow: auto;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-track {
background: #f8f9fa;
border-radius: 3px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb {
background: #dee2e6;
border-radius: 3px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb:hover {
background: #adb5bd;
}
.role-modal :deep(.n-card-header) {
padding: 24px 24px 0;
font-size: 18px;
font-weight: 600;
}
.role-modal :deep(.n-card__content) {
padding: 24px;
}
/* 优化后的菜单权限分配模态框样式 */
.menu-assign-modal {
backdrop-filter: blur(8px);
}
.menu-assign-modal :deep(.n-card) {
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.menu-assign-modal :deep(.n-card-header) {
padding: 24px 28px 0;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 16px 16px 0 0;
}
.menu-assign-modal :deep(.n-card__content) {
padding: 24px 28px;
background: #ffffff;
}
.menu-assign-modal :deep(.n-card__footer) {
padding: 20px 28px 24px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
border-radius: 0 0 16px 16px;
}
/* 简洁的菜单树样式 */
.clean-menu-tree {
background: transparent;
}
.clean-menu-tree :deep(.n-tree-node) {
margin: 2px 0;
border-radius: 4px;
transition: background 0.15s;
}
.clean-menu-tree :deep(.n-tree-node:hover) {
background: rgba(59, 130, 246, 0.05);
}
/* 去掉选中时的背景色 */
.clean-menu-tree :deep(.n-tree-node--selected) {
background: transparent !important;
}
.clean-menu-tree :deep(.n-tree-node--selected:hover) {
background: rgba(59, 130, 246, 0.05) !important;
}
.clean-menu-tree :deep(.n-tree-node-content) {
padding: 6px 8px;
min-height: 32px;
align-items: center;
display: flex;
}
.clean-menu-tree :deep(.n-tree-node-content__text) {
flex: 1;
font-size: 14px;
color: #374151;
line-height: 1.5;
font-weight: 400;
display: flex;
align-items: center;
transform: translateY(-5px);
}
.clean-menu-tree :deep(.n-checkbox) {
margin-right: 8px;
display: flex;
align-items: center;
}
/* 移除选中状态的背景色 */
.clean-menu-tree :deep(.n-tree-node--selected),
.clean-menu-tree :deep(.n-tree-node--selected .n-tree-node-content),
.clean-menu-tree :deep(.n-tree-node-content--selected) {
background-color: transparent !important;
}
/* 移除悬停状态的背景色变化 */
.clean-menu-tree :deep(.n-tree-node-content:hover) {
background-color: transparent !important;
}
.clean-menu-tree :deep(.n-tree-node-switcher) {
margin-right: 4px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
transform: translateY(5px);
}
.clean-menu-tree :deep(.n-tree-node-switcher .n-base-icon) {
font-size: 14px;
color: #9ca3af;
}
/* 隐藏叶子节点的展开图标 */
.clean-menu-tree :deep(.n-tree-node--leaf .n-tree-node-switcher) {
visibility: hidden;
}
/* 模态框样式优化 */
.menu-assign-modal :deep(.n-card) {
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.menu-assign-modal :deep(.n-card-header) {
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
background: #fafafa;
}
.menu-assign-modal :deep(.n-card__content) {
padding: 16px 20px;
}
.menu-assign-modal :deep(.n-card__footer) {
padding: 12px 20px;
border-top: 1px solid #e5e7eb;
background: #fafafa;
}
/* 响应式设计 */
@media (max-width: 900px) {
.menu-assign-modal :deep(.n-card) {
margin: 10px;
width: calc(100% - 20px) !important;
max-height: calc(100vh - 20px);
}
}
@media (min-width: 1200px) {
.menu-assign-modal :deep(.n-card) {
width: 900px !important;
}
}
/* 紧凑表单样式 */
.compact-form :deep(.n-form-item) {
margin-bottom: 8px !important;
}
.compact-form :deep(.n-form-item .n-form-item-feedback-wrapper) {
min-height: 0 !important;
padding-top: 2px !important;
}
.compact-form :deep(.n-form-item .n-form-item-label) {
padding-bottom: 2px !important;
}
.compact-form :deep(.n-input),
.compact-form :deep(.n-input-number),
.compact-form :deep(.n-select),
.compact-form :deep(.n-radio-group) {
font-size: 14px !important;
}
.compact-form :deep(.n-input .n-input__input-el),
.compact-form :deep(.n-input-number .n-input__input-el) {
padding: 2px 1px !important;
min-height: 32px !important;
}
.compact-form :deep(.n-select .n-base-selection) {
min-height: 32px !important;
padding: 2px 1px !important;
}
.compact-form :deep(.n-select .n-base-selection .n-base-selection-label) {
padding: 0 6px !important;
min-height: 30px !important;
line-height: 30px !important;
}
/* 统一按钮样式系统 - 支持动态主题色彩 */
.action-btn-primary {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #ffffff !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 2px 4px var(--primary-color-suppl) !important;
}
.action-btn-primary:hover {
background: var(--primary-color-hover) !important;
border-color: var(--primary-color-hover) !important;
transform: translateY(-1px) !important;
box-shadow: 0 4px 8px var(--primary-color-suppl) !important;
}
.action-btn-primary:active {
background: var(--primary-color-pressed) !important;
border-color: var(--primary-color-pressed) !important;
transform: translateY(0) !important;
}
.action-btn-secondary {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
}
.action-btn-secondary:hover {
transform: translateY(-1px) !important;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.12) !important;
}
.action-btn-secondary:active {
transform: translateY(0) !important;
}
.action-btn-danger {
border-color: var(--error-color) !important;
color: var(--error-color) !important;
}
.action-btn-danger:hover {
border-color: var(--error-color-hover) !important;
color: var(--error-color-hover) !important;
background: var(--error-color-suppl) !important;
}
.action-btn-warning {
border-color: var(--warning-color) !important;
color: var(--warning-color) !important;
}
.action-btn-warning:hover {
border-color: var(--warning-color-hover) !important;
color: var(--warning-color-hover) !important;
background: var(--warning-color-suppl) !important;
}
.action-btn-info {
border-color: var(--info-color) !important;
color: var(--info-color) !important;
}
.action-btn-info:hover {
border-color: var(--info-color-hover) !important;
color: var(--info-color-hover) !important;
background: var(--info-color-suppl) !important;
}
</style>