feat(admin): 角色权限分配抽屉增强与角色编码对齐

支持搜索/过滤/全选/级联权限码/脏检查;内置角色 i18n 改为 admin_admin;菜单类型增加圆点样式映射。
This commit is contained in:
DaxPay Dev
2026-07-14 15:24:25 +08:00
parent ddb8f39118
commit 0f9163c480
12 changed files with 466 additions and 58 deletions

View File

@@ -42,6 +42,18 @@ export const menuTypeColorMap: Record<string, string> = {
[MenuTypeEnum.LINK]: 'purple',
};
/**
* 菜单类型小圆点 Tailwind class树节点等轻量标识与 menuTypeColorMap 语义一致)
*/
export const menuTypeDotClassMap: Record<string, string> = {
[MenuTypeEnum.CATALOG]: 'bg-blue-500',
[MenuTypeEnum.MENU]: 'bg-green-500',
[MenuTypeEnum.SUBPAGE]: 'bg-cyan-500',
[MenuTypeEnum.SUBPAGE_GROUP]: 'bg-indigo-500',
[MenuTypeEnum.EMBEDDED]: 'bg-orange-500',
[MenuTypeEnum.LINK]: 'bg-purple-500',
};
/**
* 菜单类型国际化Key映射
*/

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "Platform Admin",
"agent_admin": "Agent Admin",
"daxpay_gateway": "Payment Gateway",
"admin_admin": "Operations Admin",
"merchant_admin": "Merchant Admin"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "Admin Peron",
"agent_admin": "Admin Agen",
"daxpay_gateway": "Gerbang Pembayaran",
"admin_admin": "Admin Operasi",
"merchant_admin": "Admin Pedagang"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "プラットフォーム管理者",
"agent_admin": "エージェント管理者",
"daxpay_gateway": "ペイメントゲートウェイ",
"admin_admin": "運営管理者",
"merchant_admin": "販売者管理者"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "플랫폼 관리자",
"agent_admin": "에이전트 관리자",
"daxpay_gateway": "결제 게이트웨이",
"admin_admin": "운영 관리자",
"merchant_admin": "판매자 관리자"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "Pentadbir Platform",
"agent_admin": "Pentadbir Agen",
"daxpay_gateway": "Gerbang Pembayaran",
"merchant_admin": "Admin saudagar"
"admin_admin": "Pentadbir Operasi",
"merchant_admin": "Admin Saudagar"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "ผู้ดูแลระบบแพลตฟอร์ม",
"agent_admin": "ตัวแทนผู้ดูแลระบบ",
"daxpay_gateway": "เกตเวย์การชำระเงิน",
"admin_admin": "ผู้ดูแลระบบปฏิบัติการ",
"merchant_admin": "ผู้ดูแลระบบร้านค้า"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "Quản trị viên nền tảng",
"agent_admin": "Quản trị viên đại lý",
"daxpay_gateway": "Cổng thanh toán",
"admin_admin": "Quản trị vận hành",
"merchant_admin": "Quản trị viên người bán"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "运营管理员",
"agent_admin": "代理商管理员",
"daxpay_gateway": "支付网关",
"admin_admin": "运营管理员",
"merchant_admin": "商户管理员"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "運營管理員",
"agent_admin": "代理商管理員",
"daxpay_gateway": "支付網關",
"merchant_admin": "商户管理員"
"admin_admin": "運營管理員",
"merchant_admin": "商管理員"
}

View File

@@ -1,6 +1,4 @@
{
"daxpay_admin": "運營管理員",
"agent_admin": "代理商管理員",
"daxpay_gateway": "支付閘道器",
"admin_admin": "運營管理員",
"merchant_admin": "商戶管理員"
}

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup>
import type { RolePermAssignContext, RolePermAssignResult, RolePermTreeNode } from '#/api/iam/perm/role-perm.api';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { RoleApi } from '#/api/iam/perm/role.api';
import { RolePermApi } from '#/api/iam/perm/role-perm.api';
import { clientCodeColorMap, clientCodeI18nMap } from '#/enums/clientCode';
import { menuTypeDotClassMap, menuTypeI18nMap } from '#/enums/menuType';
import { useMessage } from '#/hooks/useMessage';
import { formatPermCodeTitle } from '#/utils/perm-i18n';
@@ -37,7 +39,7 @@
const menuLoading = ref(false);
// 当前角色信息
const role = ref<RolePermAssignContext>({});
// 树形数据
// 树形数据(源数据,索引与勾选逻辑基于此)
const treeData = ref<TreeNode[]>([]);
// 手动勾选的菜单ID列表
const manualCheckedMenuIds = ref<KeyValue[]>([]);
@@ -49,6 +51,16 @@
const checkedKeys = ref<KeyValue[]>([]);
// 半选状态的菜单key列表
const halfCheckedMenuKeys = ref<KeyValue[]>([]);
// 展开的节点key
const expandedKeys = ref<KeyValue[]>([]);
// 搜索关键字
const searchKeyword = ref('');
// 仅看已选
const onlySelected = ref(false);
// 只显示菜单(隐藏权限码节点)
const onlyMenus = ref(false);
// 勾选菜单时是否级联下属权限码(默认开启)
const cascadeCodes = ref(true);
// 节点映射表
const nodeMap = ref(new Map<KeyValue, TreeNode>());
// 菜单父级映射表
@@ -63,15 +75,63 @@
const menuIdValueMap = ref(new Map<KeyValue, IdValue>());
// 权限码ID值映射表
const codeIdValueMap = ref(new Map<KeyValue, IdValue>());
// 初始快照(脏检查)
const initialSnapshot = ref({ menuIds: [] as KeyValue[], codeIds: [] as KeyValue[] });
// 本地化后的树形数据
// 角色显示名
const roleDisplayName = computed(() => {
const r = role.value;
if (!r.i18nKey) {
return r.code || '';
}
const text = $t(r.i18nKey);
if (!text || text === r.i18nKey) {
return r.code || r.i18nKey;
}
return text;
});
// 本地化后的完整树
const localizedTreeData = computed(() => mapTree(treeData.value));
// 过滤后用于展示的树(只显示菜单 / 仅看已选 / 搜索)
const displayTreeData = computed(() => {
let tree = localizedTreeData.value;
if (onlyMenus.value) {
tree = filterTreeMenusOnly(tree);
}
if (onlySelected.value) {
tree = filterTreeBySelected(tree);
}
const keyword = searchKeyword.value.trim();
if (keyword) {
tree = filterTreeByKeyword(tree, keyword);
}
return tree;
});
// 树形组件勾选状态
const treeCheckedKeys = computed(() => ({
checked: checkedKeys.value,
halfChecked: halfCheckedMenuKeys.value,
}));
// 已选统计:菜单 = manual auto 祖先补齐前的真实选择;与提交口径一致取 manualauto
const selectedMenuCount = computed(() => {
return uniqueKeys([...manualCheckedMenuIds.value, ...autoCheckedMenuIds.value]).length;
});
const selectedCodeCount = computed(() => uniqueKeys(checkedCodeIds.value).length);
// 是否有未保存变更
const isDirty = computed(() => {
const currentMenus = uniqueKeys([...manualCheckedMenuIds.value, ...autoCheckedMenuIds.value]).sort();
const currentCodes = uniqueKeys(checkedCodeIds.value).sort();
return (
JSON.stringify(currentMenus) !== JSON.stringify(initialSnapshot.value.menuIds) ||
JSON.stringify(currentCodes) !== JSON.stringify(initialSnapshot.value.codeIds)
);
});
/** 规范化ID值转换为字符串键值 */
function normalizeId(value?: IdValue | null): KeyValue {
return value === undefined || value === null ? '' : String(value);
@@ -122,9 +182,106 @@
}));
}
/** 节点是否处于已选full状态 */
function isNodeSelected(node: TreeNode): boolean {
const key = String(node.key || '');
if (!key) {
return false;
}
if (node.type === 'code') {
return checkedKeys.value.includes(key);
}
return checkedKeys.value.includes(key) || halfCheckedMenuKeys.value.includes(key);
}
/** 只显示菜单:剔除权限码节点,保留菜单树结构 */
function filterTreeMenusOnly(nodes: TreeNode[]): TreeNode[] {
return nodes
.filter((node) => node.type !== 'code')
.map((node) => ({
...node,
children: node.children?.length ? filterTreeMenusOnly(node.children) : node.children,
}));
}
/** 仅看已选:保留已选节点及其祖先路径 */
function filterTreeBySelected(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
nodes.forEach((node) => {
const children = node.children?.length ? filterTreeBySelected(node.children) : [];
if (isNodeSelected(node) || children.length > 0) {
result.push({ ...node, children });
}
});
return result;
}
/** 关键字是否命中节点(标题 / 权限码 code */
function nodeMatchesKeyword(node: TreeNode, keyword: string): boolean {
const lower = keyword.toLowerCase();
const title = (node.displayTitle || '').toLowerCase();
if (title.includes(lower)) {
return true;
}
if (node.type === 'code' && node.code?.toLowerCase().includes(lower)) {
return true;
}
return false;
}
/** 按关键字过滤树:命中节点保留,并保留通往命中节点的祖先 */
function filterTreeByKeyword(nodes: TreeNode[], keyword: string): TreeNode[] {
const result: TreeNode[] = [];
nodes.forEach((node) => {
const children = node.children?.length ? filterTreeByKeyword(node.children, keyword) : [];
if (nodeMatchesKeyword(node, keyword) || children.length > 0) {
result.push({ ...node, children });
}
});
return result;
}
/** 收集过滤树中所有节点 key用于搜索后展开 */
function collectTreeKeys(nodes: TreeNode[]): KeyValue[] {
const keys: KeyValue[] = [];
function walk(list: TreeNode[]) {
list.forEach((node) => {
if (node.key) {
keys.push(String(node.key));
}
if (node.children?.length) {
walk(node.children);
}
});
}
walk(nodes);
return keys;
}
/** 默认展开:根菜单及其第一层子菜单(约 2 级),避免全展开卡顿 */
function getDefaultExpandedKeys(nodes: TreeNode[]): KeyValue[] {
const keys: KeyValue[] = [];
nodes.forEach((root) => {
if (root.key) {
keys.push(String(root.key));
}
root.children?.forEach((child) => {
if (child.type === 'menu' && child.key) {
keys.push(String(child.key));
}
});
});
return uniqueKeys(keys);
}
/** 初始化权限分配弹窗 */
async function init(roleId: number) {
visible.value = true;
searchKeyword.value = '';
onlySelected.value = false;
onlyMenus.value = false;
cascadeCodes.value = true;
expandedKeys.value = [];
const res = await RoleApi.findById(String(roleId));
role.value = { ...res.data };
resetAssignState();
@@ -146,6 +303,7 @@
codeMenuMap.value = new Map();
menuIdValueMap.value = new Map();
codeIdValueMap.value = new Map();
initialSnapshot.value = { menuIds: [], codeIds: [] };
}
/** 加载权限分配数据 */
@@ -169,6 +327,17 @@
manualCheckedMenuIds.value = uniqueKeys((data.checkedMenuIds || []).map((item) => normalizeId(item)));
checkedCodeIds.value = uniqueKeys((data.checkedCodeIds || []).map((item) => normalizeId(item)));
recomputeState();
// 打开时默认展开 2 级
expandedKeys.value = getDefaultExpandedKeys(localizedTreeData.value);
takeSnapshot();
}
/** 记录当前勾选快照,供脏检查 */
function takeSnapshot() {
initialSnapshot.value = {
menuIds: uniqueKeys([...manualCheckedMenuIds.value, ...autoCheckedMenuIds.value]).sort(),
codeIds: uniqueKeys(checkedCodeIds.value).sort(),
};
}
/** 构建索引映射表 */
@@ -255,7 +424,7 @@
return [...new Set(keys.filter(Boolean))];
}
/** 收集菜单的所有子孙菜单ID */
/** 收集菜单的所有子孙菜单ID(含自身) */
function collectDescendantMenuIds(menuId: KeyValue): KeyValue[] {
const result: KeyValue[] = [];
const visited = new Set<KeyValue>();
@@ -274,6 +443,28 @@
return result;
}
/** 收集菜单子树内全部权限码 ID */
function collectDescendantCodeIds(menuId: KeyValue): KeyValue[] {
const codes: KeyValue[] = [];
collectDescendantMenuIds(menuId).forEach((mid) => {
codes.push(...(menuCodeMap.value.get(mid) || []));
});
return uniqueKeys(codes);
}
/** 收集菜单的全部祖先 ID不含自身 */
function collectAncestorMenuIds(menuId: KeyValue): KeyValue[] {
const result: KeyValue[] = [];
let current = menuParentMap.value.get(menuId);
const visited = new Set<KeyValue>();
while (current && !visited.has(current)) {
visited.add(current);
result.push(current);
current = menuParentMap.value.get(current);
}
return result;
}
/** 重新计算勾选状态 */
function recomputeState() {
const manualMenuSet = new Set(uniqueKeys(manualCheckedMenuIds.value));
@@ -289,13 +480,19 @@
autoCheckedMenuIds.value = [...autoMenuSet];
const sourceSelectedMenuSet = new Set<KeyValue>([...manualMenuSet, ...autoMenuSet]);
// 显示与提交对齐:已选菜单的祖先也视为 full提交时会补齐
const displaySelectedMenuSet = new Set<KeyValue>(sourceSelectedMenuSet);
sourceSelectedMenuSet.forEach((menuId) => {
collectAncestorMenuIds(menuId).forEach((aid) => displaySelectedMenuSet.add(aid));
});
const nextCheckedMenuKeys: KeyValue[] = [];
const nextHalfCheckedMenuKeys: KeyValue[] = [];
/** 递归访问菜单,计算勾选状态 */
function visitMenu(menuId: KeyValue): MenuCheckState {
const childMenuIds = menuChildrenMap.value.get(menuId) || [];
const selfSelected = sourceSelectedMenuSet.has(menuId);
const selfSelected = displaySelectedMenuSet.has(menuId);
const childStates: MenuCheckState[] = childMenuIds.map((childMenuId) => visitMenu(childMenuId));
const allChildrenFull = childStates.length > 0 && childStates.every((item: MenuCheckState) => item.full);
const hasSelectedChild = childStates.some((item: MenuCheckState) => item.full || item.half);
@@ -335,16 +532,29 @@
return info?.node?.dataRef || info?.node;
}
/** 处理菜单勾选事件,联动勾选所有子孙菜单 */
/**
* 处理菜单勾选:
* - 勾选:联动子孙菜单;开启 cascadeCodes 时同时勾选子树内全部权限码
* - 取消:联动清除子孙菜单,并始终清除子树内权限码(避免 auto 勾回)
*/
function handleMenuCheck(menuId: KeyValue, checked: boolean) {
const manualMenuSet = new Set(uniqueKeys(manualCheckedMenuIds.value));
const checkedCodeSet = new Set(uniqueKeys(checkedCodeIds.value));
const descendantMenuIds = collectDescendantMenuIds(menuId);
const descendantCodeIds = collectDescendantCodeIds(menuId);
if (checked) {
descendantMenuIds.forEach((id) => manualMenuSet.add(id));
if (cascadeCodes.value) {
descendantCodeIds.forEach((codeId) => checkedCodeSet.add(codeId));
}
} else {
descendantMenuIds.forEach((id) => manualMenuSet.delete(id));
// 取消菜单时始终清码,防止 autoChecked 把菜单勾回
descendantCodeIds.forEach((codeId) => checkedCodeSet.delete(codeId));
}
manualCheckedMenuIds.value = [...manualMenuSet];
checkedCodeIds.value = [...checkedCodeSet];
recomputeState();
}
@@ -380,16 +590,60 @@
}
}
/** 展开/折叠回调 */
function handleExpand(keys: KeyValue[]) {
expandedKeys.value = keys;
}
/** 全选:所有菜单 + 所有权限码 */
function handleCheckAll() {
manualCheckedMenuIds.value = [...menuIdValueMap.value.keys()];
checkedCodeIds.value = [...codeIdValueMap.value.keys()];
recomputeState();
}
/** 清空全部勾选 */
function handleUncheckAll() {
manualCheckedMenuIds.value = [];
checkedCodeIds.value = [];
recomputeState();
}
/** 展开全部 */
function handleExpandAll() {
expandedKeys.value = collectTreeKeys(localizedTreeData.value);
}
/** 折叠全部 */
function handleCollapseAll() {
expandedKeys.value = [];
}
/** 搜索时自动展开过滤结果中的节点路径 */
watch(searchKeyword, (value) => {
if (!value.trim()) {
return;
}
// 展开当前过滤结果中的全部节点,便于看到命中项
expandedKeys.value = uniqueKeys([...expandedKeys.value, ...collectTreeKeys(displayTreeData.value)]);
});
/** 取消按钮点击事件 */
function handleCancel() {
visible.value = false;
confirmLoading.value = false;
}
/** 获取提交的菜单ID列表 */
/**
* 获取提交的菜单ID列表
* 在 manual auto 基础上向上补齐祖先,保证目录路径完整、与树显示 full 一致
*/
function getSubmitMenuIds() {
const menuIds = uniqueKeys([...manualCheckedMenuIds.value, ...autoCheckedMenuIds.value]);
return menuIds
const menuIdSet = new Set(uniqueKeys([...manualCheckedMenuIds.value, ...autoCheckedMenuIds.value]));
[...menuIdSet].forEach((menuId) => {
collectAncestorMenuIds(menuId).forEach((aid) => menuIdSet.add(aid));
});
return [...menuIdSet]
.map((menuId) => menuIdValueMap.value.get(menuId))
.filter((item): item is IdValue => item !== undefined && item !== null);
}
@@ -403,11 +657,18 @@
/** 确认按钮点击事件,弹出确认对话框 */
async function handleOk() {
if (!isDirty.value) {
message.info($t('iam.role.noChange'));
return;
}
confirm({
// 分配权限
title: $t('iam.role.assignPermission'),
// 确认要分配权限吗?
content: $t('iam.role.assignPermissionConfirm'),
// 确认要保存权限分配吗?(含统计)
content: $t('iam.role.assignPermissionConfirmStats', {
menu: selectedMenuCount.value,
code: selectedCodeCount.value,
}),
okText: $t('common.okText'),
cancelText: $t('common.cancelText'),
onOk: async () => {
@@ -427,8 +688,8 @@
await RolePermApi.save({
roleId,
clientCode,
menuIds: getSubmitMenuIds(),
codeIds: getSubmitCodeIds(),
menuIds: getSubmitMenuIds().map(String),
codeIds: getSubmitCodeIds().map(String),
updateChildren: false,
}).finally(() => {
confirmLoading.value = false;
@@ -439,6 +700,53 @@
emits('ok');
}
/** 标题文本:优先 displayTitle其次 i18n */
function resolveNodeTitle(node: TreeNode): string {
return node.displayTitle || getDisplayTitle(node) || '';
}
/** 权限码是否挂载到多个菜单(一码多挂) */
function isCodeMultiMounted(codeId?: IdValue | null): boolean {
const id = normalizeId(codeId);
return id ? (codeMenuMap.value.get(id)?.length ?? 0) > 1 : false;
}
/** 节点类型小圆点 class菜单按 menuType权限码中性色 */
function getNodeDotClass(node: TreeNode): string {
if (node.type === 'code') {
return 'bg-muted-foreground/50';
}
if (node.menuType && menuTypeDotClassMap[node.menuType]) {
return menuTypeDotClassMap[node.menuType]!;
}
return 'bg-muted-foreground/40';
}
/** 菜单类型悬停文案(有映射才返回) */
function getMenuTypeTip(menuType?: string): string {
if (!menuType || !menuTypeI18nMap[menuType]) {
return '';
}
return $t(menuTypeI18nMap[menuType]!);
}
/** 拆分搜索高亮片段(供 titleRender 模板使用) */
function splitHighlight(text: string): { before: string; hit: string; after: string } | null {
const keyword = searchKeyword.value.trim();
if (!keyword || !text) {
return null;
}
const idx = text.toLowerCase().indexOf(keyword.toLowerCase());
if (idx < 0) {
return null;
}
return {
before: text.slice(0, idx),
hit: text.slice(idx, idx + keyword.length),
after: text.slice(idx + keyword.length),
};
}
defineExpose({ init });
</script>
@@ -446,33 +754,141 @@
<!-- 国际化分配权限 -->
<a-drawer
:open="visible"
:title="$t('iam.role.assignPermission')"
:size="820"
:mask-closable="false"
@close="handleCancel"
>
<template #title>
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0">{{ $t('iam.role.assignPermission') }}</span>
<span v-if="roleDisplayName" class="truncate text-sm font-normal text-muted-foreground">
· {{ roleDisplayName }}
</span>
<span v-if="role.code" class="shrink-0 text-sm font-normal text-muted-foreground/80">
({{ role.code }})
</span>
<a-tag
v-if="role.clientCode"
:color="clientCodeColorMap[role.clientCode] || 'default'"
class="!m-0 shrink-0"
>
{{ $t(clientCodeI18nMap[role.clientCode] || role.clientCode) }}
</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-button @click="handleCancel">{{ $t('common.cancel') }}</a-button>
<a-button type="primary" :loading="confirmLoading" @click="handleOk">
<a-button type="primary" :loading="confirmLoading" :disabled="!isDirty && !menuLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>
</template>
<a-spin :spinning="menuLoading">
<a-tree
v-if="localizedTreeData.length > 0"
checkable
check-strictly
default-expand-all
:tree-data="localizedTreeData"
:checked-keys="treeCheckedKeys"
:field-names="{ children: 'children', key: 'key', title: 'displayTitle' }"
@check="handleCheck"
<div v-if="!menuLoading && localizedTreeData.length > 0" class="flex flex-col gap-3">
<!-- 工具条搜索 + 批量操作 + 过滤 -->
<div class="flex flex-col gap-2">
<a-input
v-model:value="searchKeyword"
allow-clear
:placeholder="$t('iam.role.searchPerm')"
/>
<div class="flex flex-wrap items-center gap-2">
<a-space wrap :size="8">
<a-button size="small" @click="handleCheckAll">{{ $t('iam.role.checkAll') }}</a-button>
<a-button size="small" @click="handleUncheckAll">{{ $t('iam.role.uncheckAll') }}</a-button>
<a-button size="small" @click="handleExpandAll">{{ $t('iam.role.expandAll') }}</a-button>
<a-button size="small" @click="handleCollapseAll">{{ $t('iam.role.collapseAll') }}</a-button>
</a-space>
<a-divider type="vertical" class="!h-6" />
<a-space wrap :size="12">
<a-tooltip :title="$t('iam.role.cascadeCodesTip')">
<a-checkbox v-model:checked="cascadeCodes">
{{ $t('iam.role.cascadeCodes') }}
</a-checkbox>
</a-tooltip>
<a-checkbox v-model:checked="onlyMenus">
{{ $t('iam.role.onlyMenus') }}
</a-checkbox>
<a-checkbox v-model:checked="onlySelected">
{{ $t('iam.role.onlySelected') }}
</a-checkbox>
</a-space>
</div>
<div class="text-sm text-muted-foreground">
{{
$t('iam.role.selectedStats', {
menu: selectedMenuCount,
code: selectedCodeCount,
})
}}
</div>
</div>
<a-tree
v-if="displayTreeData.length > 0"
checkable
check-strictly
:tree-data="displayTreeData"
:checked-keys="treeCheckedKeys"
:expanded-keys="expandedKeys"
:field-names="{ children: 'children', key: 'key', title: 'displayTitle' }"
@check="handleCheck"
@expand="handleExpand"
>
<!-- antdv-next Tree 自定义标题类型小圆点区分权限码多挂时才提示同步 -->
<template #titleRender="node">
<span class="inline-flex max-w-full items-center gap-1.5">
<!-- 菜单圆点悬停显示类型名 -->
<a-tooltip v-if="node.type !== 'code' && getMenuTypeTip(node.menuType)" :title="getMenuTypeTip(node.menuType)">
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :class="getNodeDotClass(node)" />
</a-tooltip>
<span
v-else
class="inline-block h-1.5 w-1.5 shrink-0 rounded-full"
:class="getNodeDotClass(node)"
/>
<!-- 权限码多挂才包 tip标题弱化 -->
<template v-if="node.type === 'code'">
<a-tooltip v-if="isCodeMultiMounted(node.codeId)" :title="$t('iam.role.codeMultiMountTip')">
<span class="text-muted-foreground">
<template v-for="hl in [splitHighlight(resolveNodeTitle(node))]" :key="'c'">
<template v-if="hl">
{{ hl.before }}<span class="text-red-500">{{ hl.hit }}</span>{{ hl.after }}
</template>
<template v-else>{{ resolveNodeTitle(node) }}</template>
</template>
</span>
</a-tooltip>
<span v-else class="text-muted-foreground">
<template v-for="hl in [splitHighlight(resolveNodeTitle(node))]" :key="'c2'">
<template v-if="hl">
{{ hl.before }}<span class="text-red-500">{{ hl.hit }}</span>{{ hl.after }}
</template>
<template v-else>{{ resolveNodeTitle(node) }}</template>
</template>
</span>
</template>
<template v-else>
<template v-for="hl in [splitHighlight(resolveNodeTitle(node))]" :key="'m'">
<span v-if="hl">
{{ hl.before }}<span class="text-red-500">{{ hl.hit }}</span>{{ hl.after }}
</span>
<span v-else>{{ resolveNodeTitle(node) }}</span>
</template>
</template>
</span>
</template>
</a-tree>
<a-empty v-else :description="$t('iam.role.assignPermissionEmpty')" />
</div>
<!-- 加载完成后树为空 -->
<a-empty
v-else-if="!menuLoading"
:description="$t('iam.role.assignPermissionEmpty')"
/>
<!-- 国际化当前终端下暂无可分配权限 -->
<a-empty v-else :description="$t('iam.role.assignPermissionEmpty')" />
</a-spin>
</a-drawer>
</template>