ForgeFlow-ERP/frontend/src/views/SystemPermissionView.vue
2026-06-15 04:24:46 +08:00

1859 lines
53 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>
<section
class="system-permission-page"
:class="{ 'system-permission-page--maintenance': permissionMainTab === 'maintenance' }"
@click="closeContextMenu"
>
<header class="system-permission-hero compact-hero page-hero-card system-permission-hero-card">
<div class="page-hero-copy">
<p class="eyebrow">系统管理</p>
<div class="system-permission-title-row">
<h3>系统权限管理</h3>
<div class="system-permission-main-tabs admin-tab-switcher" role="tablist" aria-label="系统权限管理页签">
<button
type="button"
:class="{ active: permissionMainTab === 'org' }"
@click="permissionMainTab = 'org'"
>
组织架构
</button>
<button
type="button"
:class="{ active: permissionMainTab === 'maintenance' }"
@click="permissionMainTab = 'maintenance'"
>
角色维护
</button>
</div>
</div>
<p class="page-hero-subtitle">组织架构负责公司层级人员授权和系统账号角色维护负责菜单权限和业务身份</p>
</div>
</header>
<section
v-if="permissionMainTab === 'org'"
class="system-permission-map-card full-width-card"
:class="{ 'tree-layout-card': orgViewMode === 'tree' }"
>
<div class="section-head">
<div>
<p class="eyebrow">组织架构</p>
<h3>权限组织架构</h3>
<p class="permission-note">脑图和组织树共用同一套组织数据;脑图模式下员工节点默认收拢,人员授权和移除都在组织架构里处理。</p>
</div>
<div class="mindmap-card-actions">
<div class="org-view-switch" role="tablist" aria-label="组织架构展示模式">
<button
type="button"
:class="{ active: orgViewMode === 'mindmap' }"
@click.stop="orgViewMode = 'mindmap'"
>
脑图模式
</button>
<button
type="button"
:class="{ active: orgViewMode === 'tree' }"
@click.stop="orgViewMode = 'tree'"
>
组织树模式
</button>
</div>
<span class="status-pill">{{ orgNodeCount }} 个组织节点</span>
<div v-if="orgViewMode === 'mindmap'" class="mindmap-toolbar" aria-label="脑图工具栏">
<button type="button" title="放大" @click.stop="zoomMindMap(0.1)">放大</button>
<button type="button" title="缩小" @click.stop="zoomMindMap(-0.1)">缩小</button>
<button type="button" title="一屏展示" @click.stop="fitMindMapToScreen">一屏展示</button>
<button type="button" title="刷新" @click.stop="refreshMindMap">刷新</button>
</div>
</div>
</div>
<OrgMindMap
v-if="orgViewMode === 'mindmap'"
:nodes="orgNodes"
:zoom-scale="mindMapScale"
:fit-request-key="mindMapFitRequestKey"
@select-node="selectOrgNode"
@node-contextmenu="openContextMenu"
@toggle-employee-leaves="selectOrgNode"
@fit-scale="applyMindMapFitScale"
/>
<OrgPermissionTree
v-else
:nodes="orgNodes"
:selected-node-id="selectedOrgNodeId"
@select-node="selectOrgNode"
@node-contextmenu="openContextMenu"
@add-employee="openOrgEmployeeDrawer"
@authorize-employee="openAuthorizeDrawer"
@remove-employee="removeOrgEmployee"
/>
</section>
<section v-if="permissionMainTab === 'maintenance'" class="system-permission-maintenance-card full-width-card">
<div class="section-head">
<div>
<p class="eyebrow">角色权限</p>
<h3>角色维护</h3>
<p class="permission-note">角色用于配置菜单权限和业务身份;人员授权请回到组织架构中对人员节点操作。</p>
</div>
<div class="row-actions">
<button
class="primary-button"
type="button"
@click="openRoleDrawer()"
>
新增角色
</button>
</div>
</div>
<div class="maintenance-table-panel">
<div class="table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>角色名称</th>
<th>角色编码</th>
<th>系统人员数</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="role in paginatedRoles" :key="role.role_id || role.role_code || role.role_name">
<td>{{ role.role_name || role.name || "未命名角色" }}</td>
<td>{{ role.role_code || "未设置编码" }}</td>
<td>{{ role.user_count || 0 }} 人</td>
<td><StatusBadge :value="role.status" /></td>
<td class="action-row">
<button class="ghost-button" type="button" @click="openRoleDrawer(role)">编辑</button>
</td>
</tr>
<tr v-if="!roles.length">
<td colspan="5" class="empty-row">暂无角色数据</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="rolePage" v-model:page-size="rolePageSize" :total="roles.length" />
</div>
</section>
<div
v-if="contextMenu.visible"
class="org-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button
v-for="action in contextMenuActions"
:key="action.key"
type="button"
@click="runNodeAction(action.key, contextMenu.node)"
>
{{ action.label }}
</button>
</div>
<div v-if="showAuthorizeDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showAuthorizeDrawer = false">
<aside class="form-drawer system-permission-drawer authorize-drawer">
<div class="drawer-header authorize-drawer-header">
<div>
<p class="eyebrow">人员授权</p>
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
<p class="authorize-header-note">为该人员创建或更新系统账号,并分配可访问的系统角色。</p>
</div>
<button class="ghost-button authorize-close-button" type="button" @click="showAuthorizeDrawer = false">关闭窗口</button>
</div>
<section class="authorize-profile-card" aria-label="授权对象">
<div class="authorize-avatar">{{ authorizingEmployeeInitial }}</div>
<div class="authorize-profile-main">
<strong>{{ authorizingEmployee?.employee_name || "未命名人员" }}</strong>
<span>{{ authorizingOrganizationPath }}</span>
</div>
<div class="authorize-profile-meta">
<span>电话 / 账号:{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
<span>账号状态:<StatusBadge :value="authorizingSystemUser?.status" :text="authorizingSystemUser ? accountStatusLabel(authorizingSystemUser.status) : '未创建账号'" /></span>
</div>
</section>
<section class="authorize-section authorize-role-section" aria-label="角色授权">
<div class="authorize-section-head">
<div>
<p class="eyebrow">角色授权</p>
<h3>选择系统角色</h3>
</div>
<span class="authorize-selected-count">已选 {{ selectedAuthorizeRoles.length }} 个角色</span>
</div>
<div class="authorize-selected-strip">
<span v-if="selectedAuthorizeRoleNames.length">{{ selectedAuthorizeRoleNames.join("、") }}</span>
<span v-else>尚未选择角色,至少选择一个角色后才能保存。</span>
</div>
<div class="authorize-role-grid">
<button
v-for="role in roles"
:key="role.role_id"
type="button"
class="authorize-role-card"
:class="{ active: isAuthorizeRoleSelected(role.role_id), danger: isHighRiskRole(role) }"
@click="toggleAuthorizeRole(role.role_id)"
>
<span class="authorize-role-check" aria-hidden="true">{{ isAuthorizeRoleSelected(role.role_id) ? "✓" : "" }}</span>
<span class="authorize-role-body">
<strong>{{ roleDisplayName(role) }}</strong>
<small>{{ roleDisplayCode(role) }}</small>
<em>{{ roleRiskText(role) }}</em>
</span>
</button>
</div>
</section>
<section class="authorize-section authorize-account-section" aria-label="账号设置">
<div class="authorize-section-head">
<div>
<p class="eyebrow">账号设置</p>
<h3>{{ authorizingSystemUser ? "账号状态" : "创建账号" }}</h3>
</div>
</div>
<label v-if="!authorizingSystemUser" class="form-field authorize-password-field">
<span>初始密码</span>
<input v-model="authorizeForm.password" type="password" placeholder="首次创建账号必填最少6位" />
</label>
<div v-else class="authorize-account-note">
该人员已创建系统账号;本次仅更新角色和账号启停状态,如需修改密码请使用右上角账号重置入口。
</div>
<div class="authorize-status-switch" role="group" aria-label="账号状态">
<button type="button" :class="{ active: authorizeForm.status === 'ACTIVE' }" @click="authorizeForm.status = 'ACTIVE'">启用账号</button>
<button type="button" :class="{ active: authorizeForm.status === 'INACTIVE' }" @click="authorizeForm.status = 'INACTIVE'">停用账号</button>
</div>
</section>
<div class="authorize-save-summary">
将为 <strong>{{ authorizingEmployee?.employee_name || "该人员" }}</strong>
授权 <strong>{{ selectedAuthorizeRoles.length }}</strong> 个角色,账号状态为
<strong>{{ accountStatusLabel(authorizeForm.status) }}</strong>
</div>
<div class="drawer-actions authorize-actions">
<button class="primary-button" type="button" :disabled="!authorizeForm.role_ids.length" @click="submitAuthorizeUser">保存授权</button>
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">取消</button>
</div>
</aside>
</div>
<div v-if="showRoleDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showRoleDrawer = false">
<aside class="form-drawer system-permission-drawer">
<div class="drawer-header">
<div>
<p class="eyebrow">角色权限</p>
<h2>{{ editingRole ? "编辑角色" : "新增角色" }}</h2>
</div>
<button class="ghost-button" type="button" @click="showRoleDrawer = false">关闭</button>
</div>
<label class="form-field">
<span>角色编码</span>
<input v-model="roleForm.role_code" type="text" :readonly="Boolean(editingRole)" placeholder="请输入角色编码(大写字母、数字或下划线)" />
</label>
<label class="form-field">
<span>角色名称</span>
<input v-model="roleForm.role_name" type="text" placeholder="请输入角色名称" />
</label>
<label class="form-field">
<span>角色范围</span>
<select v-model="roleForm.role_scope">
<option value="SYSTEM">系统角色</option>
<option v-if="roleForm.role_scope && roleForm.role_scope !== 'SYSTEM'" :value="roleForm.role_scope">自定义范围</option>
</select>
</label>
<label class="form-field">
<span>状态</span>
<select v-model="roleForm.status">
<option value="ACTIVE">启用</option>
<option value="INACTIVE">停用</option>
</select>
</label>
<label class="form-field">
<span>备注</span>
<textarea v-model="roleForm.remark" rows="3" placeholder="请输入备注"></textarea>
</label>
<div class="form-field">
<span>菜单权限</span>
<div class="permission-tree">
<template v-for="group in permissionTree" :key="group.permission_code">
<label class="permission-check" :class="{ partial: isPermissionIndeterminate(group) }">
<input
type="checkbox"
:checked="isPermissionChecked(group)"
:indeterminate="isPermissionIndeterminate(group)"
@change="togglePermissionNode(group, $event.target.checked)"
/>
<strong>{{ group.permission_name }}</strong>
<span v-if="isPermissionIndeterminate(group)" class="partial-note">部分已选</span>
</label>
<label
v-for="child in group.children || []"
:key="child.permission_code"
class="permission-check child"
>
<input
type="checkbox"
:checked="isPermissionChecked(child)"
:indeterminate="isPermissionIndeterminate(child)"
@change="togglePermissionNode(child, $event.target.checked)"
/>
<span>{{ child.permission_name }}</span>
</label>
</template>
<p v-if="!permissionTree.length" class="empty-state">暂无菜单权限数据。</p>
</div>
</div>
<div class="drawer-actions">
<button class="primary-button" type="button" @click="submitRole">保存</button>
<button class="ghost-button" type="button" @click="showRoleDrawer = false">取消</button>
</div>
</aside>
</div>
<div v-if="showOrgDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showOrgDrawer = false">
<aside class="form-drawer system-permission-drawer">
<div class="drawer-header">
<div>
<p class="eyebrow">组织脑图</p>
<h2>{{ orgDrawerTitle }}</h2>
</div>
<button class="ghost-button" type="button" @click="showOrgDrawer = false">关闭</button>
</div>
<div v-if="orgDrawerMode === 'create' || orgDrawerMode === 'rename'">
<p v-if="orgDrawerMode === 'create'" class="permission-note">
将在「{{ orgForm.parent_label }}」下新增{{ nodeTypeLabel(orgForm.node_type) }},只需要填写名称。
</p>
<label class="form-field">
<span>{{ nodeTypeLabel(orgForm.node_type) }}名称</span>
<input v-model="orgForm.node_label" type="text" placeholder="请输入名称" />
</label>
</div>
<div v-else-if="orgDrawerMode === 'manager'">
<p class="permission-note">支持绑定多位主管,直接点击人员卡片即可选中或取消,不需要按键盘组合键。</p>
<div class="form-field">
<span>多位主管</span>
<div class="selected-manager-tags">
<span
v-for="manager in selectedManagerOptions"
:key="manager.employee_id"
class="selected-manager-tag"
>
{{ manager.employee_name }}
<button type="button" @click="toggleOrgManager(manager.employee_id)">×</button>
</span>
<em v-if="!selectedManagerOptions.length">暂未选择主管</em>
</div>
<label class="manager-search-box">
<span>搜索主管</span>
<input
v-model.trim="managerSearchKeyword"
type="search"
placeholder="搜索主管姓名或手机号,也可输入部门/岗位"
/>
</label>
<div class="manager-option-grid">
<button
v-for="employee in filteredManagerOptions"
:key="employee.employee_id"
type="button"
:class="{ active: isOrgManagerSelected(employee.employee_id) }"
@click="toggleOrgManager(employee.employee_id)"
>
<strong>{{ employee.employee_name }}</strong>
<span>{{ employee.mobile || "无电话" }}</span>
<span>{{ employee.dept_name || "未分部门" }}{{ employee.job_title ? ` · ${employee.job_title}` : "" }}</span>
</button>
</div>
<p v-if="!filteredManagerOptions.length" class="manager-empty-state">
{{ managerSearchKeyword ? "没有匹配的主管,请换个关键词试试。" : "暂无可选人员。" }}
</p>
</div>
</div>
<div v-else-if="orgDrawerMode === 'employee'">
<p class="permission-note">新增人员是把基础资料人员挂到当前脑图节点,不会修改小程序人员主数据。</p>
<label class="form-field">
<span>人员</span>
<select v-model="orgEmployeeForm.employee_id">
<option value="">请选择人员</option>
<option v-for="employee in employeeOptions" :key="employee.employee_id" :value="employee.employee_id">
{{ employee.employee_name }} · {{ employee.mobile || "无电话" }}
</option>
</select>
</label>
</div>
<div class="drawer-actions">
<button class="primary-button" type="button" @click="submitOrgNode">保存</button>
<button class="ghost-button" type="button" @click="showOrgDrawer = false">取消</button>
</div>
</aside>
</div>
</section>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import OrgMindMap from "../components/OrgMindMap.vue";
import OrgPermissionTree from "../components/OrgPermissionTree.vue";
import PaginationBar from "../components/PaginationBar.vue";
import StatusBadge from "../components/StatusBadge.vue";
import { deleteResource, fetchResource, postResource, putResource } from "../services/api";
import { notifyError, notifySuccess } from "../utils/notifications";
import { usePagination } from "../utils/pagination";
const orgTree = ref([]);
const roles = ref([]);
const users = ref([]);
const employeeOptions = ref([]);
const permissionTree = ref([]);
const permissionMainTab = ref("org");
const orgViewMode = ref("mindmap");
const selectedNode = ref(null);
const mindMapScale = ref(1);
const mindMapFitRequestKey = ref(0);
const showAuthorizeDrawer = ref(false);
const showRoleDrawer = ref(false);
const showOrgDrawer = ref(false);
const editingRole = ref(null);
const editingOrgNode = ref(null);
const orgDrawerMode = ref("create");
const managerSearchKeyword = ref("");
const authorizingEmployee = ref(null);
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
node: null
});
const authorizeForm = reactive({
role_ids: [],
password: "",
status: "ACTIVE"
});
const roleForm = reactive({
role_code: "",
role_name: "",
role_scope: "SYSTEM",
status: "ACTIVE",
remark: "",
permission_codes: []
});
const orgForm = reactive({
parent_id: "",
parent_label: "",
node_type: "DEPARTMENT",
node_label: "",
dept_code: "",
dept_type: "ADMIN",
manager_employee_id: "",
manager_employee_ids: [],
sort_no: 0,
remark: "",
status: "ACTIVE"
});
const orgEmployeeForm = reactive({
employee_id: "",
sort_no: 0
});
const orgNodes = computed(() => {
if (Array.isArray(orgTree.value)) {
return orgTree.value;
}
return orgTree.value ? [orgTree.value] : [];
});
const orgNodeCount = computed(() => flattenOrgNodes(orgNodes.value).length);
const selectedOrgNodeId = computed(() => selectedNode.value?.node_id || orgNodes.value[0]?.node_id || "");
const authorizingSystemUser = computed(() => {
const employeeId = authorizingEmployee.value?.employee_id;
if (!employeeId) {
return null;
}
return users.value.find((user) => String(user.employee_id) === String(employeeId)) || null;
});
const authorizingEmployeePhone = computed(() =>
authorizingEmployee.value?.mobile || authorizingSystemUser.value?.mobile || authorizingSystemUser.value?.username || ""
);
const authorizingOrganizationPath = computed(() =>
authorizingEmployee.value?.organization_path || authorizingEmployee.value?.parent_node_label || "未标记组织"
);
const authorizingEmployeeInitial = computed(() => {
const name = authorizingEmployee.value?.employee_name || authorizingEmployee.value?.display_name || authorizingEmployee.value?.username || "人";
return String(name).slice(0, 1);
});
const selectedAuthorizeRoles = computed(() => {
const selectedIds = new Set(authorizeForm.role_ids.map(String));
return roles.value.filter((role) => selectedIds.has(String(role.role_id)));
});
const selectedAuthorizeRoleNames = computed(() => selectedAuthorizeRoles.value.map((role) => roleDisplayName(role)));
const orgDrawerTitle = computed(() => {
if (orgDrawerMode.value === "rename") {
return "重命名";
}
if (orgDrawerMode.value === "manager") {
return "添加该节点的主管";
}
if (orgDrawerMode.value === "employee") {
return "新增人员";
}
return `新增${nodeTypeLabel(orgForm.node_type)}`;
});
const contextMenuActions = computed(() => (contextMenu.node ? nodeActions(contextMenu.node) : []));
const selectedManagerOptions = computed(() => {
const selectedIds = new Set(orgForm.manager_employee_ids.map(String));
return employeeOptions.value.filter((employee) => selectedIds.has(String(employee.employee_id)));
});
const filteredManagerOptions = computed(() => {
const keyword = managerSearchKeyword.value.trim().toLowerCase();
if (!keyword) {
return employeeOptions.value;
}
return employeeOptions.value.filter((employee) => {
const searchableText = [
employee.employee_name,
employee.mobile,
employee.phone,
employee.dept_name,
employee.job_title,
employee.position,
employee.employee_code
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchableText.includes(keyword);
});
});
const roleRowsForTable = computed(() => roles.value);
const {
page: rolePage,
pageSize: rolePageSize,
rows: paginatedRoles
} = usePagination(roleRowsForTable);
async function loadAll() {
const [orgRows, roleRows, userRows, employeeRows, menuPermissionRows] = await Promise.all([
fetchResource("/system-permissions/org-tree", []),
fetchResource("/system-permissions/roles", []),
fetchResource("/system-permissions/users", []),
fetchResource("/system-permissions/employee-options", []),
fetchResource("/system-permissions/menu-permission-tree", [])
]);
orgTree.value = Array.isArray(orgRows) ? orgRows : [];
roles.value = Array.isArray(roleRows) ? roleRows : [];
users.value = Array.isArray(userRows) ? userRows : [];
employeeOptions.value = Array.isArray(employeeRows) ? employeeRows : [];
permissionTree.value = Array.isArray(menuPermissionRows) ? menuPermissionRows : [];
}
function flattenOrgNodes(nodes) {
const result = [];
for (const node of nodes || []) {
result.push(node);
result.push(...flattenOrgNodes(node.children || []));
}
return result;
}
function selectOrgNode(node) {
selectedNode.value = node;
}
function zoomMindMap(delta) {
const nextScale = Number((mindMapScale.value + delta).toFixed(1));
setMindMapScale(nextScale);
}
function setMindMapScale(scale) {
const numericScale = Number(scale || 1);
mindMapScale.value = Math.min(1.8, Math.max(0.12, numericScale));
}
function applyMindMapFitScale(scale) {
const numericScale = Number(scale || 1);
mindMapScale.value = Math.min(1, Math.max(0.12, numericScale));
}
async function requestMindMapFitToScreen({ showToast = false, closeMenu = false } = {}) {
if (closeMenu) {
closeContextMenu();
}
await nextTick();
if (permissionMainTab.value !== "org" || orgViewMode.value !== "mindmap") {
return;
}
mindMapFitRequestKey.value += 1;
if (showToast) {
notifySuccess("脑图已按当前展开状态适配一屏");
}
}
function fitMindMapToScreen() {
requestMindMapFitToScreen({ showToast: true, closeMenu: true });
}
async function refreshMindMap() {
closeContextMenu();
await loadAll();
notifySuccess("脑图已刷新");
}
function nodeTypeLabel(type) {
return {
COMPANY: "总公司",
BRANCH: "分公司",
DEPARTMENT: "部门",
GROUP: "小组",
EMPLOYEE: "人员"
}[type] || "组织";
}
function isOrgManagerSelected(employeeId) {
return orgForm.manager_employee_ids.map(String).includes(String(employeeId));
}
function toggleOrgManager(employeeId) {
const targetId = String(employeeId);
if (isOrgManagerSelected(targetId)) {
orgForm.manager_employee_ids = orgForm.manager_employee_ids.filter((id) => String(id) !== targetId);
} else {
orgForm.manager_employee_ids = [...orgForm.manager_employee_ids, targetId];
}
}
function defaultDeptType(type) {
return {
COMPANY: "ADMIN",
BRANCH: "ADMIN",
DEPARTMENT: "ADMIN",
GROUP: "ADMIN"
}[type] || "ADMIN";
}
function isAuthorizeRoleSelected(roleId) {
return authorizeForm.role_ids.map(String).includes(String(roleId));
}
function toggleAuthorizeRole(roleId) {
const targetId = String(roleId);
if (isAuthorizeRoleSelected(targetId)) {
authorizeForm.role_ids = authorizeForm.role_ids.filter((id) => String(id) !== targetId);
} else {
authorizeForm.role_ids = [...authorizeForm.role_ids, targetId];
}
}
function roleDisplayName(role) {
return role?.role_name || role?.name || role?.role_code || "未命名角色";
}
function roleDisplayCode(role) {
return role?.role_code ? `角色编码:${role.role_code}` : "未设置角色编码";
}
function isHighRiskRole(role) {
const text = [role?.role_code, roleDisplayName(role)].filter(Boolean).join(" ").toUpperCase();
return text.includes("ADMIN") || text.includes("超级") || text.includes("管理员");
}
function roleRiskText(role) {
if (isHighRiskRole(role)) {
return "高权限角色,请谨慎授权";
}
return "业务角色,可按岗位职责授权";
}
function accountStatusLabel(status) {
if (status === "INACTIVE") {
return "停用";
}
return "启用";
}
function nodeActions(node) {
if (!node) {
return [];
}
if (node.employee_id) {
return [
{ key: "authorizeEmployee", label: "授权" },
{ key: "removeEmployee", label: "移除" }
];
}
const actions = [
{ key: "rename", label: "重命名" },
{ key: "manager", label: "添加该节点的主管" }
];
if (node.node_type === "COMPANY") {
actions.push({ key: "addBranch", label: "新增分公司节点" });
actions.push({ key: "addDepartment", label: "新增直属部门" });
}
if (node.node_type === "BRANCH") {
actions.push({ key: "addDepartment", label: "新增部门节点" });
}
if (node.node_type === "DEPARTMENT") {
actions.push({ key: "addGroup", label: "新增小组节点" });
actions.push({ key: "addEmployee", label: "新增人员" });
}
if (node.node_type === "GROUP") {
actions.push({ key: "addEmployee", label: "新增人员" });
}
if (node.dept_code !== "ORG_ROOT") {
actions.push({ key: "delete", label: "删除节点" });
}
return actions;
}
function openContextMenu(eventPayload) {
selectedNode.value = eventPayload.node;
const menuWidth = 220;
const menuHeight = 280;
const viewportWidth = window.innerWidth || 1280;
const viewportHeight = window.innerHeight || 720;
contextMenu.visible = true;
contextMenu.x = Math.max(12, Math.min(Number(eventPayload.x || 12), viewportWidth - menuWidth - 12));
contextMenu.y = Math.max(12, Math.min(Number(eventPayload.y || 12), viewportHeight - menuHeight - 12));
contextMenu.node = eventPayload.node;
}
function closeContextMenu() {
contextMenu.visible = false;
}
function runNodeAction(actionKey, node) {
closeContextMenu();
if (!node) {
return;
}
if (actionKey === "rename") {
openOrgRenameDrawer(node);
} else if (actionKey === "manager") {
openOrgManagerDrawer(node);
} else if (actionKey === "addBranch") {
openOrgCreateDrawer(node, "BRANCH");
} else if (actionKey === "addDepartment") {
openOrgCreateDrawer(node, "DEPARTMENT");
} else if (actionKey === "addGroup") {
openOrgCreateDrawer(node, "GROUP");
} else if (actionKey === "addEmployee") {
openOrgEmployeeDrawer(node);
} else if (actionKey === "authorizeEmployee") {
openAuthorizeDrawer(node);
} else if (actionKey === "removeEmployee") {
removeOrgEmployee(node);
} else if (actionKey === "delete") {
removeOrgNode(node);
}
}
function fillOrgFormFromNode(node) {
editingOrgNode.value = node;
orgForm.parent_id = node?.parent_id ?? "";
orgForm.parent_label = "";
orgForm.node_type = node?.node_type || "DEPARTMENT";
orgForm.node_label = node?.node_label || "";
orgForm.dept_code = node?.dept_code || "";
orgForm.dept_type = node?.dept_type || "ADMIN";
orgForm.manager_employee_id = node?.manager_employee_id ?? "";
orgForm.manager_employee_ids = Array.isArray(node?.manager_employee_ids) && node.manager_employee_ids.length
? node.manager_employee_ids.map(String)
: node?.manager_employee_id
? [String(node.manager_employee_id)]
: [];
orgForm.sort_no = node?.sort_no ?? 0;
orgForm.remark = node?.remark || "";
orgForm.status = node?.status || "ACTIVE";
}
function openOrgCreateDrawer(parent, nodeType) {
editingOrgNode.value = null;
orgDrawerMode.value = "create";
orgForm.parent_id = parent?.node_id ?? "";
orgForm.parent_label = parent?.node_label || "当前节点";
orgForm.node_type = nodeType;
orgForm.node_label = "";
orgForm.dept_code = "";
orgForm.dept_type = defaultDeptType(nodeType);
orgForm.manager_employee_id = "";
orgForm.manager_employee_ids = [];
orgForm.sort_no = 0;
orgForm.remark = "";
orgForm.status = "ACTIVE";
showOrgDrawer.value = true;
}
function openOrgRenameDrawer(node) {
orgDrawerMode.value = "rename";
fillOrgFormFromNode(node);
showOrgDrawer.value = true;
}
function openOrgManagerDrawer(node) {
orgDrawerMode.value = "manager";
fillOrgFormFromNode(node);
managerSearchKeyword.value = "";
showOrgDrawer.value = true;
}
function openOrgEmployeeDrawer(node) {
orgDrawerMode.value = "employee";
fillOrgFormFromNode(node);
orgEmployeeForm.employee_id = "";
orgEmployeeForm.sort_no = (node?.employees?.length || 0) + 1;
showOrgDrawer.value = true;
}
function openOrgDrawer(parent = null, node = null) {
if (node) {
openOrgRenameDrawer(node);
return;
}
openOrgCreateDrawer(parent || orgNodes.value[0], parent?.node_type === "COMPANY" ? "BRANCH" : "DEPARTMENT");
}
async function submitOrgNode() {
try {
if (orgDrawerMode.value === "employee") {
if (!orgEmployeeForm.employee_id) {
notifyError("请选择人员");
return;
}
await postResource(`/system-permissions/org-nodes/${editingOrgNode.value.node_id}/employees`, {
employee_id: Number(orgEmployeeForm.employee_id),
sort_no: Number(orgEmployeeForm.sort_no || 0)
});
notifySuccess("人员已加入组织节点");
} else {
if (!orgForm.node_label.trim() && orgDrawerMode.value !== "manager") {
notifyError("请输入名称");
return;
}
const updatePayload = {
node_label: orgForm.node_label.trim() || editingOrgNode.value?.node_label || "",
dept_type: orgForm.dept_type || "ADMIN",
manager_employee_id: orgForm.manager_employee_ids.length ? Number(orgForm.manager_employee_ids[0]) : null,
manager_employee_ids: orgForm.manager_employee_ids.map(Number),
sort_no: Number(orgForm.sort_no || 0),
remark: orgForm.remark,
status: orgForm.status
};
const createPayload = {
parent_id: orgForm.parent_id ? Number(orgForm.parent_id) : null,
node_type: orgForm.node_type,
dept_code: orgForm.dept_code.trim() || null,
...updatePayload
};
if (editingOrgNode.value) {
await putResource(`/system-permissions/org-nodes/${editingOrgNode.value.node_id}`, updatePayload);
notifySuccess(orgDrawerMode.value === "manager" ? "主管已更新" : "节点已更新");
} else {
await postResource("/system-permissions/org-nodes", createPayload);
notifySuccess("组织节点已新增");
}
}
showOrgDrawer.value = false;
editingOrgNode.value = null;
await loadAll();
} catch (error) {
notifyError(error.message || "组织架构保存失败");
}
}
async function removeOrgNode(node) {
if (!window.confirm(`确认删除「${node.node_label}」吗?有下级或人员时系统会阻止删除。`)) {
return;
}
try {
await deleteResource(`/system-permissions/org-nodes/${node.node_id}`);
notifySuccess("组织节点已删除");
selectedNode.value = null;
await loadAll();
} catch (error) {
notifyError(error.message || "组织节点删除失败");
}
}
async function removeOrgEmployee(node) {
const parentNodeId = node.parent_node_id;
if (!parentNodeId) {
notifyError("缺少上级组织节点,无法移除人员");
return;
}
if (!window.confirm(`确认从组织架构中移除「${node.employee_name}」吗?不会删除人员主数据。`)) {
return;
}
try {
await deleteResource(`/system-permissions/org-nodes/${parentNodeId}/employees/${node.employee_id}`);
notifySuccess("人员已从组织架构移除");
selectedNode.value = null;
await loadAll();
} catch (error) {
notifyError(error.message || "人员移除失败");
}
}
function openAuthorizeDrawer(employee) {
const existingUser = users.value.find((user) => String(user.employee_id) === String(employee?.employee_id));
authorizingEmployee.value = employee;
authorizeForm.role_ids = existingUser?.role_ids?.length
? existingUser.role_ids.map(String)
: (employee?.role_ids || []).map(String);
authorizeForm.password = "";
authorizeForm.status = existingUser?.status || "ACTIVE";
showAuthorizeDrawer.value = true;
}
async function submitAuthorizeUser() {
if (!authorizingEmployee.value?.employee_id) {
notifyError("请选择有效人员");
return;
}
if (!authorizingEmployeePhone.value) {
notifyError("该人员没有电话号码,不能作为账号");
return;
}
if (!authorizeForm.role_ids.length) {
notifyError("请选择角色");
return;
}
if (!authorizingSystemUser.value && (!authorizeForm.password || authorizeForm.password.length < 6)) {
notifyError("密码最少6位");
return;
}
try {
if (authorizingSystemUser.value) {
await putResource(`/system-permissions/users/${authorizingSystemUser.value.user_id}/roles`, {
role_ids: authorizeForm.role_ids.map(Number)
});
if (authorizingSystemUser.value.status !== authorizeForm.status) {
await putResource(`/system-permissions/users/${authorizingSystemUser.value.user_id}/status`, {
status: authorizeForm.status
});
}
notifySuccess("人员角色授权已更新");
} else {
await postResource("/system-permissions/users", {
employee_id: Number(authorizingEmployee.value.employee_id),
role_ids: authorizeForm.role_ids.map(Number),
password: authorizeForm.password,
status: authorizeForm.status
});
notifySuccess("系统账号已创建并授权");
}
showAuthorizeDrawer.value = false;
authorizingEmployee.value = null;
await loadAll();
} catch (error) {
notifyError(error.message || "人员授权失败");
}
}
function openRoleDrawer(role = null) {
editingRole.value = role;
roleForm.role_code = role?.role_code || "";
roleForm.role_name = role?.role_name || "";
roleForm.role_scope = role?.role_scope || "SYSTEM";
roleForm.status = role?.status || "ACTIVE";
roleForm.remark = role?.remark || "";
roleForm.permission_codes = [...(role?.permission_codes || [])];
showRoleDrawer.value = true;
}
function collectPermissionCodes(node) {
const codes = node?.permission_code ? [node.permission_code] : [];
for (const child of node?.children || []) {
codes.push(...collectPermissionCodes(child));
}
return codes;
}
function collectPersistablePermissionCodes(node) {
const children = node?.children || [];
if (children.length) {
return children.flatMap((child) => collectPersistablePermissionCodes(child));
}
return node?.permission_code ? [node.permission_code] : [];
}
function isPermissionChecked(node) {
const codes = collectPersistablePermissionCodes(node);
if (!codes.length) {
return false;
}
const selectedCodes = new Set(roleForm.permission_codes);
return codes.every((code) => selectedCodes.has(code));
}
function isPermissionIndeterminate(node) {
const descendantCodes = (node?.children || []).flatMap((child) => collectPersistablePermissionCodes(child));
if (!descendantCodes.length) {
return false;
}
const selectedCodes = new Set(roleForm.permission_codes);
const selectedDescendantCount = descendantCodes.filter((code) => selectedCodes.has(code)).length;
return selectedDescendantCount > 0 && selectedDescendantCount < descendantCodes.length;
}
function togglePermissionNode(node, checked) {
const next = new Set(roleForm.permission_codes);
for (const code of collectPersistablePermissionCodes(node)) {
if (checked) {
next.add(code);
} else {
next.delete(code);
}
}
roleForm.permission_codes = Array.from(next);
}
async function submitRole() {
if (!roleForm.role_name.trim()) {
notifyError("请输入角色名称");
return;
}
if (!editingRole.value && !roleForm.role_code.trim()) {
notifyError("请输入角色编码");
return;
}
const payload = {
role_name: roleForm.role_name.trim(),
role_scope: roleForm.role_scope.trim() || "SYSTEM",
status: roleForm.status,
remark: roleForm.remark,
permission_codes: roleForm.permission_codes
};
try {
if (editingRole.value) {
await putResource(`/system-permissions/roles/${editingRole.value.role_id}`, payload);
} else {
await postResource("/system-permissions/roles", {
role_code: roleForm.role_code.trim(),
...payload
});
}
notifySuccess("角色已保存");
showRoleDrawer.value = false;
await loadAll();
} catch (error) {
notifyError(error.message || "角色保存失败");
}
}
watch(
[permissionMainTab, orgViewMode],
([mainTab, viewMode]) => {
if (mainTab === "org" && viewMode === "mindmap") {
requestMindMapFitToScreen();
}
}
);
onMounted(async () => {
await loadAll();
await requestMindMapFitToScreen();
});
</script>
<style scoped>
.system-permission-page {
display: flex;
flex-direction: column;
gap: 18px;
position: relative;
}
.system-permission-hero,
.system-permission-map-card,
.system-permission-side-card {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.system-permission-hero {
padding: 20px 24px;
background:
radial-gradient(circle at 12% 20%, rgba(96, 165, 250, 0.2), transparent 28%),
linear-gradient(135deg, #f8fafc, #eff6ff);
}
.compact-hero {
min-height: unset;
}
.system-permission-hero h2,
.section-head h3,
.drawer-header h2 {
margin: 0;
color: #0f172a;
}
.system-permission-hero p {
margin: 8px 0 0;
color: #475569;
}
.system-permission-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.system-permission-title-row .system-permission-main-tabs {
display: inline-flex;
width: auto;
max-width: 100%;
grid-template-columns: none;
gap: 4px;
padding: 4px;
}
.system-permission-title-row .system-permission-main-tabs button {
min-width: clamp(58px, 8vw, 132px);
padding: 7px clamp(6px, 0.9vw, 12px);
font-size: clamp(13px, 0.8vw, 14px);
line-height: 1.15;
white-space: nowrap;
}
.system-permission-main-tabs {
display: inline-grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 8px;
align-self: flex-start;
padding: 6px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 18px;
background: #eef6ff;
}
.system-permission-main-tabs button {
border: 0;
border-radius: 14px;
padding: 12px 18px;
background: transparent;
color: #475569;
font-weight: 800;
cursor: pointer;
}
.system-permission-main-tabs button.active {
background: #ffffff;
color: #1d4ed8;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.14);
}
.system-permission-maintenance-card {
display: flex;
flex-direction: column;
gap: 14px;
}
.maintenance-table-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.full-width-card,
.system-permission-side-card {
padding: 22px;
}
.section-head,
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head.compact {
margin-bottom: 12px;
}
.eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
background: #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.permission-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.permission-list li {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
padding: 12px;
background: #f8fafc;
}
.permission-list li > div:first-child {
display: flex;
flex-direction: column;
gap: 4px;
}
.permission-list span,
.permission-note,
.empty-state {
color: #64748b;
font-size: 13px;
}
.row-actions,
.drawer-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.drawer-actions {
justify-content: flex-end;
margin-top: 18px;
}
.primary-button,
.ghost-button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-weight: 700;
cursor: pointer;
}
.primary-button {
background: #2563eb;
color: #ffffff;
}
.ghost-button {
background: #eff6ff;
color: #1d4ed8;
}
.mindmap-card-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.org-view-switch {
display: inline-flex;
gap: 6px;
padding: 6px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 999px;
background: rgba(239, 246, 255, 0.92);
}
.org-view-switch button {
border: 0;
border-radius: 999px;
padding: 8px 12px;
background: transparent;
color: #475569;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.org-view-switch button.active {
background: #ffffff;
color: #1d4ed8;
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.14);
}
.mindmap-toolbar {
display: inline-flex;
gap: 6px;
padding: 6px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 999px;
background: rgba(239, 246, 255, 0.92);
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
}
.mindmap-toolbar button {
border: 0;
border-radius: 999px;
padding: 8px 12px;
background: #ffffff;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.mindmap-toolbar button:hover {
background: #2563eb;
color: #ffffff;
}
.org-context-menu {
position: fixed;
z-index: 120;
display: flex;
flex-direction: column;
min-width: 180px;
padding: 8px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
}
.org-context-menu button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
background: transparent;
color: #0f172a;
text-align: left;
font-weight: 700;
cursor: pointer;
}
.org-context-menu button:hover {
background: #eff6ff;
color: #1d4ed8;
}
.system-permission-drawer-mask {
z-index: 90;
}
.system-permission-drawer {
display: flex;
flex-direction: column;
gap: 14px;
width: min(520px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow-y: auto;
padding: 24px;
background: #ffffff;
}
.authorize-drawer {
width: min(760px, calc(100vw - 32px));
gap: 16px;
}
.authorize-drawer-header {
align-items: flex-start;
}
.authorize-header-note {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.authorize-close-button {
min-width: 108px;
}
.authorize-profile-card,
.authorize-section,
.authorize-save-summary {
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.05);
}
.authorize-profile-card {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) minmax(220px, 0.72fr);
align-items: center;
gap: 14px;
padding: 16px;
background:
linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.92));
}
.authorize-avatar {
display: grid;
place-items: center;
width: 54px;
height: 54px;
border-radius: 18px;
background: linear-gradient(135deg, #2563eb, #38bdf8);
color: #ffffff;
font-size: 24px;
font-weight: 900;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.24);
}
.authorize-profile-main,
.authorize-profile-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.authorize-profile-main strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
}
.authorize-profile-main span,
.authorize-profile-meta span {
overflow: hidden;
color: #475569;
font-size: 13px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.authorize-section {
display: grid;
gap: 12px;
padding: 16px;
}
.authorize-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.authorize-section-head h3 {
margin: 0;
color: #0f172a;
font-size: 18px;
}
.authorize-selected-count {
flex: 0 0 auto;
border-radius: 999px;
padding: 6px 10px;
background: #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
}
.authorize-selected-strip {
min-height: 36px;
border: 1px dashed rgba(96, 165, 250, 0.5);
border-radius: 14px;
padding: 9px 12px;
background: #f8fbff;
color: #475569;
font-size: 13px;
font-weight: 750;
}
.authorize-role-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.authorize-role-card {
position: relative;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
min-height: 96px;
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 18px;
padding: 14px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
color: #0f172a;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, background 0.16s ease;
}
.authorize-role-card:hover {
border-color: rgba(37, 99, 235, 0.42);
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.authorize-role-card.active {
border-color: rgba(37, 99, 235, 0.78);
background: linear-gradient(180deg, #eff6ff, #ffffff);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18), 0 14px 28px rgba(37, 99, 235, 0.12);
}
.authorize-role-card.danger.active {
border-color: rgba(249, 115, 22, 0.86);
background: linear-gradient(180deg, #fff7ed, #ffffff);
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.2), 0 14px 28px rgba(249, 115, 22, 0.12);
}
.authorize-role-check {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid rgba(148, 163, 184, 0.72);
border-radius: 8px;
background: #ffffff;
color: #2563eb;
font-size: 14px;
font-weight: 950;
}
.authorize-role-card.active .authorize-role-check {
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
}
.authorize-role-card.danger.active .authorize-role-check {
border-color: #f97316;
background: #f97316;
}
.authorize-role-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.authorize-role-body strong,
.authorize-role-body small,
.authorize-role-body em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.authorize-role-body strong {
color: #0f172a;
font-size: 16px;
font-style: normal;
font-weight: 900;
}
.authorize-role-body small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.authorize-role-body em {
width: fit-content;
max-width: 100%;
border-radius: 999px;
padding: 3px 8px;
background: #eef6ff;
color: #2563eb;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.authorize-role-card.danger .authorize-role-body em {
background: #fff7ed;
color: #c2410c;
}
.authorize-account-section {
background:
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(255, 255, 255, 0.92));
}
.authorize-password-field {
margin: 0;
}
.authorize-account-note {
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 14px;
padding: 11px 12px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 750;
line-height: 1.6;
}
.authorize-status-switch {
display: inline-flex;
width: fit-content;
border: 1px solid rgba(190, 205, 224, 0.95);
border-radius: 999px;
padding: 4px;
background: #f8fafc;
gap: 4px;
}
.authorize-status-switch button {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: transparent;
color: #64748b;
font-weight: 850;
cursor: pointer;
}
.authorize-status-switch button.active {
background: #2563eb;
color: #ffffff;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.22);
}
.authorize-save-summary {
padding: 13px 16px;
color: #475569;
font-size: 13px;
font-weight: 750;
}
.authorize-save-summary strong {
color: #0f172a;
font-weight: 900;
}
.authorize-actions {
justify-content: flex-end;
}
.permission-tree {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
padding: 12px;
background: #f8fafc;
}
.authorize-summary {
display: grid;
gap: 8px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 16px;
padding: 12px;
background: #f8fbff;
color: #475569;
font-size: 13px;
font-weight: 700;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
color: #0f172a;
font-size: 14px;
}
.permission-check.child {
padding-left: 24px;
color: #475569;
}
.selected-manager-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 38px;
padding: 8px;
border: 1px dashed rgba(37, 99, 235, 0.24);
border-radius: 14px;
background: #f8fbff;
}
.selected-manager-tags em {
color: #94a3b8;
font-size: 13px;
font-style: normal;
}
.selected-manager-tag {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 7px 10px;
background: #dbeafe;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
}
.selected-manager-tag button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: #ffffff;
color: #2563eb;
cursor: pointer;
font-weight: 900;
}
.manager-search-box {
display: flex;
flex-direction: column;
gap: 6px;
margin: 4px 0;
}
.manager-search-box span {
color: #475569;
font-size: 13px;
font-weight: 800;
}
.manager-search-box input {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 14px;
padding: 10px 12px;
background: #ffffff;
color: #0f172a;
font-size: 14px;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.manager-search-box input:focus {
border-color: rgba(37, 99, 235, 0.62);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.manager-option-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
max-height: 280px;
overflow-y: auto;
padding: 4px;
}
.manager-option-grid button {
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
padding: 12px;
background: #f8fafc;
color: #0f172a;
text-align: left;
cursor: pointer;
}
.manager-option-grid button.active {
border-color: rgba(37, 99, 235, 0.7);
background: linear-gradient(135deg, #eff6ff, #dbeafe);
box-shadow: inset 4px 0 0 #2563eb;
}
.manager-option-grid button strong {
font-size: 14px;
}
.manager-option-grid button span {
color: #64748b;
font-size: 12px;
}
.manager-empty-state {
margin: 0;
border: 1px dashed rgba(148, 163, 184, 0.5);
border-radius: 14px;
padding: 12px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
text-align: center;
}
@media (max-width: 980px) {
.section-head,
.mindmap-card-actions {
align-items: flex-start;
flex-direction: column;
}
.system-permission-main-tabs {
width: 100%;
grid-template-columns: 1fr;
}
}
</style>