/** * 菜单管理公共工具(移植自 fms-vue 的 menu-management/utils.js)。 * * 只放无 Vue 状态依赖的纯逻辑:菜单类型选项、行提取、多语言键、入口权限点派生。 * 树本身的通用查找 / 过滤 / 插入 / 移除 / 位置派生见 @/utils/tree,不在此重复。 */ import { ExternalLink, FileText, Folder } from '@lucide/vue' import type { Component } from 'vue' import { buildMenuPowerCode, buildPowerDeleteDependents } from '@/utils/powerCodes' import type { TreeRow } from '@/utils/tree' /** 菜单类型(s_menu.b_menu_type):directory 目录 / page 页面 / external 外链 */ export const MENU_TYPE_OPTIONS: Array<{ label: string; value: string }> = [ { label: '目录', value: 'directory' }, { label: '页面', value: 'page' }, { label: '外链', value: 'external' }, ] /** 菜单类型 → 树节点图标(预载到 node.icon,G3Tree 自动渲染) */ export const MENU_TYPE_ICON_MAP: Record = { directory: Folder, page: FileText, external: ExternalLink, } /** 菜单树里能承载子节点的类型:页面 / 外链是末端节点 */ export const MENU_NON_LEAF_TYPES = ['directory'] /** 工具栏 / 右键「新增」菜单项(key 即 b_menu_type) */ export const MENU_ADD_OPTIONS: Array<{ key: string; label: string; icon: Component }> = [ { key: 'directory', label: '新增目录', icon: Folder }, { key: 'page', label: '新增页面', icon: FileText }, { key: 'external', label: '新增外链', icon: ExternalLink }, ] /** * 菜单树节点:s_menu 行业务列 + 树运行时挂上的 children / icon 等。 * 与 @/utils/tree 的 TreeRow 兼容(可直接交给建树 / 拖拽等纯函数)。 */ export type MenuNode = TreeRow & { b_id: string b_parent_id: string | null b_name: string b_i18n: string | null b_menu_type: string b_route: string | null b_icon: string | null b_xh: number b_canuse: number /** 备注 / 描述(s_menu.b_bz)。索引签名兜底,但显式声明便于面板取用 */ b_bz?: string | null children?: MenuNode[] } /** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */ export const MENU_ROW_FIELDS = [ 'b_id', 'b_parent_id', 'b_depth', 'b_path', 'b_name', 'b_i18n', 'b_menu_type', 'b_route', 'b_icon', 'b_xh', 'b_canuse', ] as const /** * 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段)。 * 未落库节点切回时,用它从树节点重建主表草稿。 */ export function menuRowOf(node: Partial): Record { const row: Record = {} // 字符串业务键:b_id 即菜单编码,不再有独立 b_code for (const field of MENU_ROW_FIELDS) row[field] = node[field] ?? null row.b_parent_id = node.b_parent_id ? String(node.b_parent_id) : null // b_path 非空且无默认值:缺省时退化为空串占位,不能为 null row.b_depth = Number(node.b_depth ?? 0) row.b_path = node.b_path == null ? '' : String(node.b_path) return row } /* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */ /** * 菜单多语言资源键:menu.{菜单编码}。 * 加载、保存、删除都以此键定位 s_i18n 行(s_i18n 无菜单 / 模块归属列)。 * * @returns 资源键;菜单编码为空时返回 null */ export function buildMenuI18nKey(menuId: string): string | null { const normalized = String(menuId || '').trim() return normalized ? `menu.${normalized}` : null } /* ================= 菜单入口权限点(s_power,b_object_type = 'menu') ================= */ /** * 需要入口权限点的菜单类型:目录只是导航分组(侧栏按子菜单裁剪), * 页面与外链才是「能否进入」的入口,各对应一行 menu.{菜单编码}(b_action = 'access')。 */ export const MENU_POWER_MENU_TYPES = ['page', 'external'] /** 参与同步的 s_power 列:b_bz 是备注,不随菜单变化,不参与 diff */ const MENU_POWER_FIELDS = [ 'b_id', 'b_name', 'b_i18n', 'b_type', 'b_object_type', 'b_object_id', 'b_action', 'b_canuse', 'b_xh', ] as const /** * 归一化 s_power 行为入口权限点的可比字段(剔除 b_bz 等不参与同步的列, * 数字列统一为 number,避免 '10' 与 10 产生假差异)。 */ export function normalizeMenuPowerRow( row: Record | null | undefined, ): Record | null { if (!row) return null const picked: Record = {} for (const field of MENU_POWER_FIELDS) picked[field] = row[field] ?? null picked.b_canuse = Number(picked.b_canuse ?? 0) picked.b_xh = Number(picked.b_xh ?? 0) return picked } /** * 由菜单行派生入口权限点行:名称、多语言键、启用状态、顺序全部跟随菜单。 * * 保存菜单时按它与加载基线 diff,权限点因此不需要单独的编辑界面 —— * 改菜单名 / 停用菜单,入口权限点自动跟着变。 * * @returns s_power 行;目录等不需要入口权限的菜单返回 null */ export function buildMenuPowerRow( menuRow: Record | null | undefined, ): Record | null { const menuId = String(menuRow?.b_id || '').trim() if (!menuId) return null if (!MENU_POWER_MENU_TYPES.includes(String(menuRow?.b_menu_type || ''))) return null return { b_id: buildMenuPowerCode(menuId), b_name: String(menuRow?.b_name ?? ''), b_i18n: buildMenuI18nKey(menuId), b_type: 'menu', b_object_type: 'menu', b_object_id: menuId, b_action: 'access', b_canuse: Number(menuRow?.b_canuse ?? 1) === 0 ? 0 : 1, b_xh: Number(menuRow?.b_xh ?? 0) || 0, } } /** 删入口权限点时的依赖声明(授权行随权限点删除),与删除入口共用一份 */ export { buildMenuPowerCode, buildPowerDeleteDependents } /* ================= 模块绑定(s_menu_module) ================= */ /** 可绑定模块的类型:module 只是模块树上的分类,不参与绑定 */ export const BINDABLE_MODULE_TYPES = ['data', 'virtual'] /** 只有页面菜单绑定模块 */ export const MENU_BIND_MENU_TYPE = 'page' /** * 该模块编码是否可绑定。 * 类型未知(模块清单里没有该键)时按**可绑定**处理:宁可保留历史绑定行, * 也不因为清单没加载全就把用户已配好的关系当成脏数据删掉。 */ export function isBindableModule(moduleType: string | null | undefined): boolean { if (moduleType == null || moduleType === '') return true return BINDABLE_MODULE_TYPES.includes(String(moduleType)) } /** 过滤出可绑定的模块编码(剔除分类节点,保持原顺序) */ export function filterBindableModules( keys: readonly unknown[], moduleTypes: Record, ): string[] { return (keys || []).map(String).filter((key) => isBindableModule(moduleTypes[key])) } /** * 顺序无关签名:比较两份编码集合是否一致。 * 树回写(勾父级会带回整棵子树的键,且顺序可能不同)不该被判定成「外部值变了」, * 否则刚点亮的分类节点会因为回写而闪掉。 */ export function moduleBindSignature(keys: readonly unknown[]): string { return [...(keys || [])].map(String).toSorted().join(',') } /** * 已绑模块编码 → s_menu_module 行。 * b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。 */ export function buildMenuModuleRows( menuId: string, moduleIds: readonly unknown[], ): Array> { const id = String(menuId || '').trim() if (!id) return [] return (moduleIds || []).map(String).map((moduleId, index) => ({ b_menu_id: id, b_module_id: moduleId, b_xh: (index + 1) * 10, b_canuse: 1, })) } /** * 按菜单类型收敛绑定草稿:非页面菜单不绑定模块,改为非页面类型时清空。 * * 由父级在**菜单类型变化时**调用(面板在非页面类型下不渲染树,草稿留着也看不见, * 一旦切回页面类型就会「凭空冒出」一批用户以为已经丢掉的绑定)。 * 保存前还会再按最终类型收敛一次,两道防线保证非页面菜单不会写进关系行。 */ export function normalizeMenuModulesByType( menuType: string | null | undefined, moduleIds: readonly unknown[], ): string[] { return String(menuType || '') === MENU_BIND_MENU_TYPE ? (moduleIds || []).map(String) : [] }