795 lines
23 KiB
Vue
795 lines
23 KiB
Vue
<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> |