Files
workspace/code/g3soft-erp/web/src/views/module/menu-management/utils.ts
T
2026-10-15 17:31:35 +08:00

230 lines
8.4 KiB
TypeScript
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.
/**
* 菜单管理公共工具(移植自 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) : []
}