This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
1 parent b4498e5350
commit bf9dca19de
47 files changed
+8592 -313

No files matched your search

@@ -0,0 +1,229 @@
/**
* 菜单管理公共工具(移植自 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) : []
}