MenuContext.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. /**
  2. * 菜单上下文
  3. *
  4. * 提供全局菜单状态管理,支持动态加载用户菜单
  5. */
  6. import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
  7. import type { MenuProps } from 'antd';
  8. import type { MenuItem } from '../api/menu';
  9. // Ant Design 图标映射
  10. const iconMap: Record<string, React.ReactNode> = {
  11. DashboardOutlined: <span>📊</span>,
  12. PartitionOutlined: <span>📂</span>,
  13. MedicineBoxOutlined: <span>💊</span>,
  14. AlertOutlined: <span>⚠️</span>,
  15. DollarOutlined: <span>💰</span>,
  16. FileTextOutlined: <span>📄</span>,
  17. DatabaseOutlined: <span>🗄️</span>,
  18. TableOutlined: <span>📋</span>,
  19. UserOutlined: <span>👤</span>,
  20. SafetyOutlined: <span>🔒</span>,
  21. };
  22. /** 菜单上下文类型 */
  23. interface MenuContextType {
  24. /** 菜单数据 */
  25. menus: MenuItem[];
  26. /** Ant Design 格式的菜单项 */
  27. menuItems: MenuProps['items'];
  28. /** 设置菜单数据 */
  29. setMenus: (menus: MenuItem[]) => void;
  30. /** 从登录结果加载菜单 */
  31. loadMenusFromLogin: (menus: MenuItem[]) => void;
  32. /** 清空菜单 */
  33. clearMenus: () => void;
  34. /** 菜单是否已加载 */
  35. isLoaded: boolean;
  36. /** 转换为 Ant Design 菜单格式 */
  37. convertToMenuItems: (menus: MenuItem[]) => MenuProps['items'];
  38. }
  39. /** 创建上下文 */
  40. const MenuContext = createContext<MenuContextType | undefined>(undefined);
  41. /** 菜单提供组件 */
  42. export const MenuProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  43. const [menus, setMenus] = useState<MenuItem[]>([]);
  44. const [isLoaded, setIsLoaded] = useState(false);
  45. /** 转换为 Ant Design 菜单格式
  46. * 支持两种数据结构:
  47. * 1. 嵌套结构(有 children 数组且包含有效子菜单)
  48. * 2. 平铺结构(使用 mainMenuDr 表示父子关系)
  49. *
  50. * 注意:后端可能返回字符串类型的 mainMenuDr/menuLevel(如 "1"/"2"),
  51. * 需要做类型兼容处理。
  52. */
  53. const convertToMenuItems = useCallback((menuList: MenuItem[]): MenuProps['items'] => {
  54. if (!menuList || menuList.length === 0) {
  55. console.log('[MenuContext] 菜单列表为空');
  56. return [];
  57. }
  58. // 调试日志:输出原始菜单数据及其类型
  59. console.log('[MenuContext] ====== 原始菜单数据 ======');
  60. console.log('[MenuContext] 菜单总数:', menuList.length);
  61. console.log('[MenuContext] 各菜单字段详情:', menuList.map(m => ({
  62. code: m.code,
  63. label: m.label,
  64. mainMenuDr: m.mainMenuDr,
  65. 'mainMenuDr_type': typeof m.mainMenuDr,
  66. menuLevel: m.menuLevel,
  67. 'menuLevel_type': typeof m.menuLevel,
  68. parentCode: m.parentCode,
  69. hasChildren: !!(m.children && m.children.length > 0),
  70. menuGroup: m.menuGroup,
  71. 'menuGroup_type': typeof m.menuGroup,
  72. })));
  73. // ★ 辅助函数:兼容数字和字符串类型的层级判断
  74. const toNum = (val: any): number => {
  75. if (val === null || val === undefined) return 0;
  76. return Number(val);
  77. };
  78. // 用于判断是否为菜单组的辅助函数
  79. const isMenuGroup = (menuGroup: any): boolean => {
  80. const str = String(menuGroup || '').toUpperCase();
  81. return str === 'Y' || str === '1';
  82. };
  83. // ★ 检查是否为有效嵌套结构(有 children 数组且非空)
  84. const hasNestedStructure = menuList.some(m => {
  85. const children = m.children;
  86. return Array.isArray(children) && children.length > 0;
  87. });
  88. console.log('[MenuContext] 数据结构判断: hasNestedStructure=', hasNestedStructure);
  89. if (hasNestedStructure) {
  90. console.log('[MenuContext] → 使用嵌套结构模式处理');
  91. // 处理嵌套结构
  92. const result: MenuProps['items'] = [];
  93. menuList.forEach(menu => {
  94. if (isMenuGroup(menu.menuGroup) && menu.children && menu.children.length > 0) {
  95. const childrenItems: MenuProps['items'] = [];
  96. menu.children.forEach(child => {
  97. if (isMenuGroup(child.menuGroup)) {
  98. child.children?.forEach(subChild => {
  99. childrenItems.push({
  100. key: subChild.code || subChild.key || '',
  101. label: subChild.label || '',
  102. });
  103. });
  104. } else {
  105. childrenItems.push({
  106. key: child.code || child.key || '',
  107. label: child.label || '',
  108. });
  109. }
  110. });
  111. result.push({
  112. key: menu.code || menu.key || '',
  113. label: menu.label || '',
  114. icon: iconMap[menu.icon || ''] || undefined,
  115. children: childrenItems.length > 0 ? childrenItems : undefined,
  116. });
  117. } else if (!isMenuGroup(menu.menuGroup)) {
  118. result.push({
  119. key: menu.code || menu.key || '',
  120. label: menu.label || '',
  121. icon: iconMap[menu.icon || ''] || undefined,
  122. });
  123. }
  124. });
  125. console.log('[MenuContext] 嵌套结构转换结果:', result.length, '项');
  126. return result;
  127. } else {
  128. console.log('[MenuContext] → 使用平铺结构模式处理(mainMenuDr/parentCode)');
  129. // 处理平铺结构(使用 mainMenuDr 表示层级关系)
  130. // ★ 兼容数字和字符串:mainMenuDr === 1 或 "1" 表示一级菜单(父菜单)
  131. const parentMenus: MenuProps['items'] = [];
  132. const childrenMap: Record<string, MenuProps['items']> = {};
  133. // 第一步:找出所有父菜单(mainMenuDr = 1 或 menuLevel = 1)
  134. menuList.forEach(menu => {
  135. const key = (menu.code || menu.key || '') as string;
  136. if (!key) return;
  137. const md = toNum(menu.mainMenuDr);
  138. const ml = toNum(menu.menuLevel);
  139. // mainMenuDr = 1 或 menuLevel = 1 表示父菜单
  140. if (md === 1 || ml === 1) {
  141. console.log(`[MenuContext] 识别到父菜单: key=${key}, label=${menu.label}, mainMenuDr=${menu.mainMenuDr}(${typeof menu.mainMenuDr}), menuLevel=${menu.menuLevel}(${typeof menu.menuLevel})`);
  142. const menuItem = {
  143. key: key,
  144. label: menu.label || '',
  145. icon: iconMap[menu.icon || ''] || undefined,
  146. };
  147. parentMenus.push(menuItem);
  148. if (!childrenMap[key]) {
  149. childrenMap[key] = [];
  150. }
  151. }
  152. });
  153. console.log('[MenuContext] 识别到的父菜单数量:', parentMenus.length);
  154. // ★ 如果通过 mainMenuDr/menuLevel 没有找到父菜单,尝试另一种策略:
  155. // 将所有菜单作为顶级菜单(没有层级信息时兜底显示)
  156. if (parentMenus.length === 0) {
  157. console.warn('[MenuContext] ⚠️ 未识别到任何父菜单(mainMenuDr/menuLevel均不匹配)!');
  158. console.log('[MenuContext] 尝试兜底策略: 将所有菜单作为顶级菜单展示');
  159. // 兜底:区分有 parentCode 和无 parentCode 的菜单
  160. const topMenus: MenuProps['items'] = [];
  161. const childMenus: { key: string; label: string; parentKey: string }[] = [];
  162. menuList.forEach(menu => {
  163. const key = (menu.code || menu.key || '') as string;
  164. if (!key) return;
  165. if (menu.parentCode) {
  166. childMenus.push({ key, label: menu.label || '', parentKey: menu.parentCode });
  167. } else {
  168. topMenus.push({
  169. key,
  170. label: menu.label || '',
  171. icon: iconMap[menu.icon || ''] || undefined,
  172. });
  173. }
  174. });
  175. // 尝试将 childMenus 关联到正确的父菜单
  176. childMenus.forEach(child => {
  177. const parent = topMenus.find(p => p!.key === child.parentKey);
  178. if (parent) {
  179. if (!childrenMap[child.parentKey]) {
  180. childrenMap[child.parentKey] = [];
  181. }
  182. childrenMap[child.parentKey]!.push({ key: child.key, label: child.label });
  183. } else {
  184. // 父菜单不存在,作为顶级菜单添加
  185. topMenus.push({ key: child.key, label: child.label });
  186. }
  187. });
  188. const result = topMenus.map(parent => ({
  189. ...parent,
  190. children: childrenMap[parent!.key as string] && childrenMap[parent!.key as string]!.length > 0
  191. ? childrenMap[parent!.key as string]
  192. : undefined,
  193. }));
  194. console.log('[MenuContext] 兜底转换结果:', result.length, '项');
  195. return result;
  196. }
  197. // 第二步:处理子菜单(mainMenuDr = 2 或 menuLevel = 2),根据 parentCode 关联到父菜单
  198. menuList.forEach(menu => {
  199. const key = (menu.code || menu.key || '') as string;
  200. if (!key) return;
  201. const md = toNum(menu.mainMenuDr);
  202. const ml = toNum(menu.menuLevel);
  203. // mainMenuDr = 2 或 menuLevel = 2 表示子菜单
  204. if ((md === 2 || ml === 2) && menu.parentCode) {
  205. const parentKey = menu.parentCode;
  206. const menuItem = {
  207. key: key,
  208. label: menu.label || '',
  209. };
  210. console.log(`[MenuContext] 处理子菜单: key=${key}, label=${menu.label}, parentCode=${parentKey}, mainMenuDr=${menu.mainMenuDr}(${typeof menu.mainMenuDr})`);
  211. if (!childrenMap[parentKey]) {
  212. childrenMap[parentKey] = [];
  213. }
  214. childrenMap[parentKey]!.push(menuItem);
  215. }
  216. });
  217. // ★ 处理孤儿子菜单:如果 childrenMap 中有 key 对应的子菜单,但 parentMenus 中没有该父菜单
  218. // 则自动创建父菜单组(后端可能只分配了叶子菜单,但没有分配父级菜单组)
  219. const orphanParentKeys = Object.keys(childrenMap).filter(
  220. pk => !parentMenus.some(p => (p as any)?.key === pk)
  221. );
  222. if (orphanParentKeys.length > 0) {
  223. // 已知父菜单的友好标签映射
  224. const parentLabelMap: Record<string, string> = {
  225. 'user': '用户管理',
  226. 'basic-data': '数据管理',
  227. 'system': '系统管理',
  228. 'drg': 'DRG业务',
  229. 'dip': 'DIP业务',
  230. 'warning': '费用预警',
  231. 'profit': '盈亏分析',
  232. 'qc': '病案质控',
  233. 'data': 'HIS数据',
  234. };
  235. // 父菜单图标映射
  236. const parentIconKeyMap: Record<string, string> = {
  237. 'user': 'UserOutlined',
  238. 'basic-data': 'TableOutlined',
  239. 'system': 'SafetyOutlined',
  240. 'data': 'DatabaseOutlined',
  241. };
  242. console.warn('[MenuContext] ⚠️ 发现孤儿子菜单,自动创建父菜单:', orphanParentKeys);
  243. orphanParentKeys.forEach(pk => {
  244. parentMenus.push({
  245. key: pk,
  246. label: parentLabelMap[pk] || pk,
  247. icon: iconMap[parentIconKeyMap[pk] || ''] || undefined,
  248. });
  249. });
  250. }
  251. console.log('[MenuContext] childrenMap keys:', Object.keys(childrenMap));
  252. // 组装父子关系
  253. const result = parentMenus.map(parent => ({
  254. ...parent,
  255. children: childrenMap[parent!.key as string] && childrenMap[parent!.key as string]!.length > 0
  256. ? childrenMap[parent!.key as string]
  257. : undefined,
  258. }));
  259. console.log('[MenuContext] 平铺结构最终菜单结果:', result.length, '项:', JSON.stringify(result));
  260. return result;
  261. }
  262. }, []);
  263. /** 从登录结果加载菜单(支持空菜单,表示角色无任何菜单权限) */
  264. const loadMenusFromLogin = useCallback((loginMenus: MenuItem[]) => {
  265. console.log('[MenuContext] loadMenusFromLogin 被调用, 菜单数量:', loginMenus?.length || 0);
  266. // ★ 即使菜单为空也要标记已加载,避免回退到默认全量菜单
  267. setMenus(loginMenus || []);
  268. setIsLoaded(true);
  269. }, []);
  270. /** 清空菜单 */
  271. const clearMenus = useCallback(() => {
  272. setMenus([]);
  273. setIsLoaded(false);
  274. }, []);
  275. /** 转换为 Ant Design 菜单格式 */
  276. const menuItems = convertToMenuItems(menus);
  277. const value: MenuContextType = {
  278. menus,
  279. menuItems,
  280. setMenus,
  281. loadMenusFromLogin,
  282. clearMenus,
  283. isLoaded,
  284. convertToMenuItems,
  285. };
  286. return (
  287. <MenuContext.Provider value={value}>
  288. {children}
  289. </MenuContext.Provider>
  290. );
  291. };
  292. /** 使用菜单上下文 */
  293. export const useMenu = (): MenuContextType => {
  294. const context = useContext(MenuContext);
  295. if (!context) {
  296. throw new Error('useMenu must be used within a MenuProvider');
  297. }
  298. return context;
  299. };
  300. /** 导出默认菜单配置(备用) */
  301. export const defaultMenuItems: MenuProps['items'] = [
  302. {
  303. key: 'dashboard',
  304. icon: iconMap['DashboardOutlined'],
  305. label: '监控仪表盘',
  306. },
  307. {
  308. key: 'drg',
  309. icon: iconMap['PartitionOutlined'],
  310. label: 'DRG业务',
  311. children: [
  312. { key: 'drg-workbench', label: '分组工作台' },
  313. { key: 'drg-custom-query', label: 'DRG分组器' },
  314. { key: 'drg-results', label: '分组结果查询' },
  315. { key: 'drg-batch', label: '批量分组任务' },
  316. ],
  317. },
  318. {
  319. key: 'dip',
  320. icon: iconMap['MedicineBoxOutlined'],
  321. label: 'DIP业务',
  322. children: [
  323. { key: 'dip-workbench', label: 'DIP分组工作台' },
  324. { key: 'dip-values', label: '病种分值查询' },
  325. { key: 'dip-analysis', label: '分值偏差分析' },
  326. ],
  327. },
  328. {
  329. key: 'warning',
  330. icon: iconMap['AlertOutlined'],
  331. label: '费用预警',
  332. children: [
  333. { key: 'warning-monitor', label: '预警监控中心' },
  334. { key: 'warning-rules', label: '预警规则配置' },
  335. { key: 'warning-records', label: '预警处理记录' },
  336. ],
  337. },
  338. {
  339. key: 'profit',
  340. icon: iconMap['DollarOutlined'],
  341. label: '盈亏分析',
  342. children: [
  343. { key: 'profit-dept', label: '科室盈亏报表' },
  344. { key: 'profit-doctor', label: '医生盈亏分析' },
  345. { key: 'profit-disease', label: '病种盈亏统计' },
  346. { key: 'profit-structure', label: '费用结构分析' },
  347. ],
  348. },
  349. {
  350. key: 'qc',
  351. icon: iconMap['FileTextOutlined'],
  352. label: '病案质控',
  353. children: [
  354. { key: 'qc-center', label: '质控检查中心' },
  355. { key: 'qc-issues', label: '质控问题列表' },
  356. { key: 'qc-stats', label: '质控统计报表' },
  357. ],
  358. },
  359. {
  360. key: 'data',
  361. icon: iconMap['DatabaseOutlined'],
  362. label: 'HIS数据',
  363. children: [
  364. { key: 'data-records', label: '病案数据查询' },
  365. { key: 'data-settlement', label: '结算清单管理' },
  366. { key: 'data-sync', label: '数据同步监控' },
  367. ],
  368. },
  369. {
  370. key: 'basic-data',
  371. icon: iconMap['TableOutlined'],
  372. label: '数据管理',
  373. children: [
  374. { key: 'basic-data-dict', label: 'DRG基础数据' },
  375. { key: 'basic-data-icd-mapping', label: 'ICD编码映射' },
  376. { key: 'basic-data-icd-query', label: 'ICD编码查询' },
  377. { key: 'basic-data-adrg-rules', label: 'ADRG分组规则' },
  378. { key: 'basic-data-core-algorithm', label: 'DRG算法配置' },
  379. { key: 'basic-data-dip', label: 'DIP付费病种库' },
  380. { key: 'basic-data-drg-catalog', label: 'DRGs目录信息表' },
  381. { key: 'basic-data-segmentation-rules', label: 'ADRG细分规则表' },
  382. { key: 'basic-data-dip-core-algorithm', label: 'DIP算法配置' },
  383. ],
  384. },
  385. {
  386. key: 'user',
  387. icon: iconMap['UserOutlined'],
  388. label: '用户管理',
  389. children: [
  390. { key: 'system-user', label: '用户管理' },
  391. { key: 'system-role', label: '角色管理' },
  392. { key: 'system-menu', label: '菜单配置' },
  393. ],
  394. },
  395. {
  396. key: 'system',
  397. icon: iconMap['SafetyOutlined'],
  398. label: '系统管理',
  399. children: [
  400. { key: 'system-hospital', label: '医疗机构管理' },
  401. { key: 'system-api', label: '接口服务配置' },
  402. { key: 'system-logs', label: '接口日志' },
  403. ],
  404. },
  405. ];
  406. export default MenuContext;