Files
KCGL/inventory-web/src/views/system/PermissionConfig.vue

795 lines
23 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="app-container">
<el-card class="permission-card" shadow="never">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="title-block">
<span class="main-title">权限配置中心</span>
<span class="sub-title">设定各角色的系统访问级别与数据可见性</span>
</div>
</div>
<el-button
type="primary"
:loading="saving"
@click="handleSave"
:disabled="!currentRole"
size="large"
icon="Check"
class="save-btn"
>
保存配置
</el-button>
</div>
</template>
<div class="main-layout">
<div class="left-panel">
<div class="panel-header">
<el-icon><User /></el-icon> 角色列表
</div>
<el-scrollbar>
<ul class="role-list">
<li
v-for="role in roleList"
:key="role.value"
:class="['role-item', { active: currentRole === role.value }]"
@click="handleRoleSelect(role.value)"
>
<div class="role-icon">
<el-icon v-if="role.value === 'SUPER_ADMIN'"><Avatar /></el-icon>
<el-icon v-else><UserFilled /></el-icon>
</div>
<div class="role-info">
<span class="role-name">{{ role.label }}</span>
<span class="role-code">{{ role.value }}</span>
</div>
<el-icon v-if="currentRole === role.value" class="arrow-icon"><ArrowRight /></el-icon>
</li>
</ul>
</el-scrollbar>
</div>
<div class="right-panel" v-loading="loading">
<div v-if="!currentRole" class="empty-state">
<img src="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg" alt="empty" style="width: 200px; opacity: 0.5;" />
<p>请在左侧选择一个角色开始配置</p>
</div>
<div v-else class="config-content">
<div class="panel-header">
<span>权限明细</span>
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
</div>
<!-- 新增拉取其他角色权限的操作区 -->
<div class="pull-permission-bar">
<span class="pull-label">从其他角色复制权限</span>
<el-select
v-model="sourceRoleForClone"
placeholder="选择源角色..."
clearable
size="default"
@change="handleClonePermissions"
class="clone-select"
>
<el-option
v-for="role in availableSourceRoles"
:key="role.value"
:label="role.label"
:value="role.value"
:disabled="role.value === currentRole"
/>
</el-select>
<span class="pull-hint" v-if="!sourceRoleForClone">选择后将覆盖当前未保存的勾选状态</span>
</div>
<el-table
:data="tableData"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
class="permission-table"
>
<el-table-column prop="name" label="页面 / 模块" min-width="200">
<template #default="{ row }">
<span style="font-weight: 500;">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="访问权限" width="150" align="center">
<template #default="{ row }">
<!-- 父级目录隐藏复选框仅叶子节点可操作 -->
<el-checkbox
v-if="row.type !== 'menu' || !row.children?.length"
v-model="row.hasRead"
@change="(val) => handleReadChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="操作权限" width="180" align="center">
<template #default="{ row }">
<!-- 父级目录隐藏操作权限列 -->
<div v-if="row.type !== 'menu' || !row.children?.length">
<el-checkbox
v-if="row.operationCode"
v-model="row.hasWrite"
:disabled="!row.hasRead && row.operationCode !== 'crossDomain'"
@change="(val) => handleWriteChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead && row.operationCode !== 'crossDomain' }">
{{ row.operationCode === 'crossDomain' ? '允许全局跨域' : '可编辑 (Write)' }}
</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</div>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="字段级控制 (敏感列)" min-width="300">
<template #default="{ row }">
<div v-if="row.elements && row.elements.length > 0">
<el-popover placement="left" :width="300" trigger="click">
<template #reference>
<el-button link type="primary" :disabled="!row.hasRead">
<el-icon style="margin-right: 4px"><Setting /></el-icon>
配置 {{ getCheckedCount(row) }}/{{ row.elements.length }} 个字段
</el-button>
</template>
<div class="field-config-box">
<div class="field-header">
<span>勾选可见字段</span>
<el-checkbox
v-model="row.checkAllElements"
:indeterminate="row.isIndeterminate"
@change="(val) => handleCheckAllElements(val, row)"
size="small"
>全选</el-checkbox>
</div>
<el-checkbox-group v-model="row.checkedElements" @change="(val) => handleCheckedElementsChange(val, row)">
<el-row :gutter="10">
<el-col :span="12" v-for="col in row.elements" :key="col.code">
<el-checkbox :label="col.code" :title="col.name" style="width: 100%; overflow: hidden; text-overflow: ellipsis;">
{{ col.name }}
</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
</div>
</el-popover>
<span class="preview-tags">
<span v-for="code in row.checkedElements.slice(0, 2)" :key="code" class="mini-tag">{{ getElementName(row, code) }}</span>
<span v-if="row.checkedElements.length > 2" class="mini-tag">...</span>
</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
// --- 类型定义 ---
interface PermissionNode {
id: number
name: string
code: string
type: string
children?: PermissionNode[]
// 前端辅助字段
hasRead: boolean // 是否勾选了页面
hasWrite: boolean // 是否勾选了 operation
operationCode: string | null // 存储 operation 的 code
elements: any[] // 存储该页面的普通列
checkedElements: string[] // 已勾选的列code
checkAllElements: boolean
isIndeterminate: boolean
}
// --- 状态定义 ---
const loading = ref(false)
const saving = ref(false)
const currentRole = ref('')
const sourceRoleForClone = ref('') // 用于克隆权限的源角色
const roleList = [
{ label: '超级管理员', value: 'SUPER_ADMIN' },
{ label: '主管', value: 'SUPERVISOR' },
{ label: '财务', value: 'FINANCE' },
{ label: '库管', value: 'WAREHOUSE_MGR' },
{ label: '入库员', value: 'INBOUND' },
{ label: '出库员', value: 'OUTBOUND' },
{ label: '采购员', value: 'PURCHASER' },
{ label: '销售', value: 'SALES' }
]
// 原始树数据
const rawTreeData = ref<any[]>([])
// 表格展示数据 (经过处理)
const tableData = ref<PermissionNode[]>([])
// --- 方法 ---
// 1. 初始化:获取权限树结构
const fetchTree = async () => {
try {
const res: any = await getAllPermissionTree()
if (res.code === 200) {
// --- 注入全局特权虚拟节点 ---
const globalNode = {
id: 99999, // 虚拟ID
name: '🌍 全局系统特权',
code: 'global_privileges',
type: 'menu',
children: [
{
id: 999992,
name: '全局跨域访问',
code: 'crossDomain',
type: 'element'
},
{
id: 999991,
name: '跨组织/跨区域数据查询',
code: 'global:cross_company_op',
type: 'element'
}
]
};
// 将虚拟节点放在最前面,然后交给 transformData 处理
// ★ 从真实树中移除 crossDomain已由虚拟节点 global_privileges 统一管理,避免重复)
const stripCrossDomain = (nodes: any[]) => {
nodes.forEach(node => {
if (node.children) {
node.children = node.children.filter((c: any) => c.code !== 'crossDomain')
stripCrossDomain(node.children)
}
})
}
const realData = res.data || []
stripCrossDomain(realData)
const rawData = [globalNode, ...realData];
rawTreeData.value = rawData;
tableData.value = transformData(rawData);
}
} catch (e) {
ElMessage.error('加载权限配置失败')
}
}
// ★ 核心:将后端嵌套的 Tree 数据转换为适合表格展示的结构
// 分离 "页面"、"操作列" 和 "普通列"
const transformData = (nodes: any[]): PermissionNode[] => {
return nodes.map(node => {
// 找出所有子节点中的 element
const allChildren = node.children || []
// 1. 找操作列 (作为 Write 权限) — 含 crossDomain 等全局权限码
const opNode = allChildren.find((c: any) => c.type === 'element' && (
c.code === 'operation' || c.code.endsWith(':operation') || c.code.endsWith('_op') || c.code === 'crossDomain'
))
const operationCode = opNode ? opNode.code : null
// 2. 找普通列 (作为字段权限) — 排除操作权限码
const columns = allChildren.filter((c: any) => c.type === 'element'
&& c.code !== 'operation' && !c.code.endsWith('_op') && !c.code.endsWith(':operation')
&& c.code !== 'crossDomain')
// 3. 找子菜单 (如果有)
const subMenus = allChildren.filter((c: any) => c.type === 'menu')
const childrenTransformed = subMenus.length > 0 ? transformData(subMenus) : undefined
return {
id: node.id,
name: node.name,
code: node.code,
type: node.type,
children: childrenTransformed,
// 状态字段初始化
hasRead: false,
hasWrite: false,
operationCode: operationCode,
elements: columns,
checkedElements: [],
checkAllElements: false,
isIndeterminate: false
}
})
}
// 2. 切换角色:回显权限
const handleRoleSelect = async (roleCode: string) => {
currentRole.value = roleCode
// 切换角色时清空克隆选择
sourceRoleForClone.value = ''
loading.value = true
try {
// 获取该角色拥有的所有 code
const res: any = await getRolePermissions(roleCode)
if (res.code === 200) {
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
console.log('[权限加载] SUPERVISOR crossDomain 在权限中:', perms.has('crossDomain'))
// 递归设置表格每一行的状态
setRowStatus(tableData.value, perms)
}
} catch (e) {
ElMessage.error('获取角色权限失败')
} finally {
loading.value = false
}
}
// 可用的源角色列表(排除当前已选角色)
const availableSourceRoles = computed(() => {
return roleList.filter(r => r.value !== currentRole.value)
})
// ★ 新增:从其他角色拉取权限
const handleClonePermissions = async (sourceRole: string) => {
if (!sourceRole) {
sourceRoleForClone.value = ''
return
}
// 确认提示
try {
await ElMessageBox.confirm(
`将从角色【${getRoleLabel(sourceRole)}】复制权限到【${getRoleLabel(currentRole.value)}】,覆盖当前未保存的勾选状态,是否继续?`,
'权限拉取确认',
{
confirmButtonText: '确认拉取',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
// 用户取消
sourceRoleForClone.value = ''
return
}
loading.value = true
try {
// 调用后端 API 获取源角色的权限
const res: any = await getRolePermissions(sourceRole)
if (res.code === 200) {
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
// 递归设置表格每一行的状态
setRowStatus(tableData.value, perms)
ElMessage.success(`已从【${getRoleLabel(sourceRole)}】拉取权限,请修改后点击保存`)
}
} catch (e) {
ElMessage.error('拉取权限失败')
} finally {
sourceRoleForClone.value = '' // 清空选择器
loading.value = false
}
}
// 递归回显状态
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
rows.forEach(row => {
// 1. 设置可读 (页面Code是否存在)
row.hasRead = perms.has(row.code)
// 2. 设置可编辑 (操作列Code是否存在)
if (row.operationCode) {
row.hasWrite = perms.has(row.operationCode)
}
// 3. 设置已选字段
if (row.elements && row.elements.length > 0) {
row.checkedElements = row.elements
.filter(el => perms.has(el.code))
.map(el => el.code)
updateCheckAllStatus(row)
}
// 4. 递归子菜单
if (row.children) {
setRowStatus(row.children, perms)
}
})
}
// --- 交互逻辑 ---
// 当“可读”改变
const handleReadChange = (val: boolean, row: PermissionNode) => {
// 如果关闭可读,强制关闭可编辑,并清空字段选择
if (!val) {
row.hasWrite = false
// row.checkedElements = [] // 可选:是否同时也清空字段勾选?通常建议保留,方便误触恢复,但这里为了逻辑严谨先不清空,只在保存时过滤
} else {
// 如果开启可读,默认全选字段 (提升体验)
// row.checkedElements = row.elements.map(e => e.code)
// updateCheckAllStatus(row)
// 【新增功能】如果没有字段级控制,且存在操作权限,则自动联动勾选可编辑
if ((!row.elements || row.elements.length === 0) && row.operationCode) {
row.hasWrite = true
}
}
// 联动子菜单:如果父级关闭,子级是否关闭?通常不强制,但可以做
}
// 当“可编辑”改变
const handleWriteChange = (val: boolean, row: PermissionNode) => {
// 如果开启编辑,必须开启可读
if (val && !row.hasRead) {
row.hasRead = true
}
}
// 字段全选逻辑
const handleCheckAllElements = (val: boolean, row: PermissionNode) => {
row.checkedElements = val ? row.elements.map(e => e.code) : []
row.isIndeterminate = false
}
// 字段单选逻辑
const handleCheckedElementsChange = (val: string[], row: PermissionNode) => {
updateCheckAllStatus(row)
}
const updateCheckAllStatus = (row: PermissionNode) => {
const checkedCount = row.checkedElements.length
row.checkAllElements = checkedCount === row.elements.length && row.elements.length > 0
row.isIndeterminate = checkedCount > 0 && checkedCount < row.elements.length
}
// --- 保存逻辑 ---
const handleSave = async () => {
if (!currentRole.value) return
const permissions: string[] = []
// 递归收集所有状态为 true 的 code
const collectPermissions = (rows: PermissionNode[]) => {
rows.forEach(row => {
// 1. 页面权限
if (row.hasRead) {
permissions.push(row.code)
}
// 2. 操作权限 (crossDomain 不依赖页面可读)
if (row.hasWrite && row.operationCode) {
if (row.operationCode === 'crossDomain' || row.hasRead) {
permissions.push(row.operationCode)
}
}
// 3. 字段权限 (只有当页面可读时才有效)
if (row.hasRead && row.checkedElements.length > 0) {
permissions.push(...row.checkedElements)
}
// 递归
if (row.children) {
collectPermissions(row.children)
}
})
}
collectPermissions(tableData.value)
// [调试] 打印 crossDomain 是否在提交列表中
console.log('[权限保存] 总权限数:', permissions.length)
console.log('[权限保存] crossDomain 在提交列表中:', permissions.includes('crossDomain'))
console.log('[权限保存] global:cross_company_op 在提交列表中:', permissions.includes('global:cross_company_op'))
saving.value = true
try {
const res: any = await saveRolePermissions({
role_code: currentRole.value,
permissions: permissions
})
if (res.code === 200) {
ElMessage.success('权限保存成功!')
// 立即重新拉取权限刷新 UI带 cache-busting确保拿到最新数据
if (currentRole.value) {
try {
const refreshRes: any = await getRolePermissions(currentRole.value)
if (refreshRes.code === 200) {
const perms = new Set([...(refreshRes.data.menus || []), ...(refreshRes.data.elements || [])])
setRowStatus(tableData.value, perms)
}
} catch (e) { /* 静默失败 */ }
}
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
// --- 辅助 ---
const getRoleLabel = (val: string) => {
const found = roleList.find(r => r.value === val)
return found ? found.label : val
}
const getCheckedCount = (row: PermissionNode) => row.checkedElements.length
const getElementName = (row: PermissionNode, code: string) => {
const found = row.elements.find(e => e.code === code)
return found ? found.name : code
}
onMounted(() => {
fetchTree()
})
</script>
<style scoped>
.app-container {
padding: 15px;
height: calc(100vh - 84px);
background-color: #f0f2f5;
}
.permission-card {
height: 100%;
display: flex;
flex-direction: column;
border: none;
border-radius: 8px;
}
:deep(.el-card__header) {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
}
:deep(.el-card__body) {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title-block {
display: flex;
flex-direction: column;
}
.main-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.sub-title {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 布局 */
.main-layout {
display: flex;
flex: 1;
height: 100%;
}
/* 左侧样式 */
.left-panel {
width: 260px;
background: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 15px 20px;
font-size: 14px;
font-weight: 600;
color: #606266;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
}
.role-list {
list-style: none;
padding: 10px;
margin: 0;
}
.role-item {
display: flex;
align-items: center;
padding: 12px 15px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
color: #606266;
}
.role-item:hover {
background-color: #f5f7fa;
}
.role-item.active {
background-color: #e6f7ff;
color: #1890ff;
font-weight: 500;
}
.role-icon {
margin-right: 12px;
font-size: 18px;
display: flex;
align-items: center;
}
.role-info {
flex: 1;
display: flex;
flex-direction: column;
}
.role-name {
font-size: 14px;
}
.role-code {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.arrow-icon {
font-size: 14px;
}
/* 右侧样式 */
.right-panel {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
font-size: 14px;
}
.config-content {
height: 100%;
display: flex;
flex-direction: column;
}
.config-content .panel-header {
background: #fff;
padding: 0 0 15px 0;
justify-content: space-between;
}
.permission-table {
flex: 1;
overflow: auto;
}
/* 新增:拉取权限操作条 */
.pull-permission-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
margin-bottom: 15px;
background: #f0f7ff;
border-radius: 6px;
border: 1px solid #d9ecff;
}
.pull-label {
font-size: 14px;
color: #303133;
white-space: nowrap;
}
.clone-select {
width: 180px;
}
.pull-hint {
font-size: 12px;
color: #909399;
}
/* 表格内样式 */
.custom-checkbox {
height: auto;
}
.text-active {
color: #1890ff;
font-weight: 500;
}
.text-disabled {
color: #c0c4cc;
}
.text-gray {
color: #d9d9d9;
}
/* 字段配置 Popover 样式 */
.field-config-box {
padding: 5px;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 10px;
font-weight: bold;
}
.preview-tags {
display: inline-flex;
align-items: center;
margin-left: 8px;
gap: 4px;
}
.mini-tag {
background: #f5f5f5;
color: #909399;
font-size: 11px;
padding: 1px 4px;
border-radius: 3px;
}
</style>