230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
/**
|
||
* 菜单管理公共工具(移植自 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<string, Component> = {
|
||
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<MenuNode>): Record<string, unknown> {
|
||
const row: Record<string, unknown> = {}
|
||
// 字符串业务键: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<string, unknown> | null | undefined,
|
||
): Record<string, unknown> | null {
|
||
if (!row) return null
|
||
const picked: Record<string, unknown> = {}
|
||
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<string, unknown> | null | undefined,
|
||
): Record<string, unknown> | 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, string>,
|
||
): 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<Record<string, unknown>> {
|
||
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) : []
|
||
}
|