generate_menu_tree_zh.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. # -*- coding: utf-8 -*-
  2. """
  3. Generate complete Chinese menu tree (M/C/F) from tenant_sys_menu + view title hints.
  4. Output: adminUI_menu_tree_zh.md
  5. """
  6. import os
  7. import re
  8. from collections import defaultdict
  9. from datetime import date
  10. try:
  11. import pymysql
  12. except ImportError:
  13. pymysql = None
  14. ROOT = os.path.normpath(
  15. os.path.join(os.path.dirname(__file__), '..', '..', 'ylrz_saas_his_scrm_adminUI')
  16. )
  17. OUT = os.path.join(os.path.dirname(__file__), 'adminUI_menu_tree_zh.md')
  18. MENU_JS = os.path.join(ROOT, 'src', 'views', 'admin', 'menu.js')
  19. DB = dict(
  20. host='cq-cdb-8fjmemkb.sql.tencentcdb.com',
  21. port=27220,
  22. user='root',
  23. password='Ylrz_1q2w3e4r5t6y',
  24. database='ylrz_saas',
  25. charset='utf8mb4',
  26. )
  27. TYPE_LABEL = {'M': '目录', 'C': '菜单', 'F': '按钮'}
  28. # Root menu_id -> fixed Chinese name
  29. ROOT_ZH = {
  30. 32361: '企微管理', 32380: '微信管理', 32347: 'CRM客户',
  31. 32357: '会员管理', 32351: '诊所管理', 32369: '商城管理',
  32. 32353: '直播管理', 32345: '课程管理', 32348: 'AI聊天',
  33. 32355: '龙虸引擎', 32331: '广告投放', 32372: '系统管理',
  34. 32339: '财务管理', 32341: '日程管理', 32368: '数据统计',
  35. 32379: '监控管理', 35300: '其他', 32333: '平台管理(归档)',
  36. 32644: '首页', 35100: '组织管理', 35101: '权限管理',
  37. 35102: '通信管理', 35105: '日志管理', 35106: '系统设置',
  38. 35001: '消息管理', 35002: '客户管理', 35003: '群聊管理',
  39. 35004: '朋友圈', 35005: '引流管理', 35006: '标签管理',
  40. 35007: '企微设置', 35040: '订单管理', 35041: '商品管理',
  41. 35042: '门店运营', 35010: '微信账号', 35011: '微信对话',
  42. 35012: '微信用户', 35013: '微信用户组', 35020: 'CRM客户管理',
  43. 35050: '直播运营', 35060: '课程内容', 35070: 'AI对话管理',
  44. 35080: '龙虸工作流', 35090: '投放运营',
  45. 35111: '充值管理', 35112: '扣费管理', 35113: '分账管理',
  46. 35114: '资金日志',
  47. 35201: '用户管理', 35202: '角色管理', 35203: '菜单管理',
  48. 35204: '部门管理', 35205: '岗位管理', 35206: '字典管理',
  49. 35207: '参数设置', 35208: '通知公告', 35209: '违规词语',
  50. }
  51. # component suffix / module -> Chinese
  52. COMP_ZH = {
  53. 'externalContact': '外部联系人', 'QwWorkTask': '企微工作任务',
  54. 'groupMsg': '群消息', 'contactWay': '渠道码', 'friendCircle': '朋友圈',
  55. 'storeOrder': '订单管理', 'storeProduct': '商品管理', 'storeShop': '门店管理',
  56. 'liveConsole': '直播控制台', 'liveOrder': '直播订单', 'videoResource': '视频资源',
  57. 'userCourse': '用户课程', 'fastGptRole': 'AI角色', 'fastGptDataset': 'AI知识库',
  58. 'companyUser': '企业用户', 'companyRole': '企业角色', 'companyMenu': '企业菜单',
  59. 'companyDept': '企业部门', 'companyRecharge': '充值记录',
  60. 'companyProfit': '分账记录', 'companyMoneyLogs': '资金流水',
  61. 'sysCompany': '租户管理', 'tenantMenu': '租户管理端菜单',
  62. 'operlog': '操作日志', 'logininfor': '登录日志', 'workflow-generate': 'AI生成工作流',
  63. 'dead-letter': '死信队列', 'workflow-canvas': '工作流画布',
  64. }
  65. WORD_ZH = {
  66. 'user': '用户', 'users': '用户', 'role': '角色', 'menu': '菜单',
  67. 'dept': '部门', 'post': '岗位', 'dict': '字典', 'config': '配置',
  68. 'notice': '公告', 'keyword': '关键词', 'order': '订单', 'store': '商城',
  69. 'product': '商品', 'customer': '客户', 'company': '企业', 'tenant': '租户',
  70. 'proxy': '代理', 'admin': '平台', 'live': '直播', 'course': '课程',
  71. 'doctor': '医生', 'patient': '患者', 'inquiry': '问诊', 'statistics': '统计',
  72. 'report': '报表', 'log': '日志', 'logs': '日志', 'record': '记录',
  73. 'tag': '标签', 'group': '群', 'msg': '消息', 'voice': '语音', 'sms': '短信',
  74. 'coupon': '优扣券', 'export': '导出', 'import': '导入', 'package': '套餐',
  75. 'wallet': '钱包', 'bill': '账单', 'calendar': '日程', 'monitor': '监控',
  76. 'watch': '监控', 'material': '素材', 'welcome': '欢迎语', 'sop': 'SOP',
  77. 'advertiser': '广告主', 'channel': '渠道', 'domain': '域名', 'site': '站点',
  78. 'recharge': '充值', 'deduct': '扣费', 'profit': '分账', 'withdraw': '提现',
  79. 'article': '文章', 'video': '视频', 'comment': '评论', 'question': '问题',
  80. 'answer': '答案', 'schedule': '排班', 'traffic': '流量', 'workflow': '工作流',
  81. 'lobster': '龙虸', 'prompt': '提示词', 'instance': '实例', 'template': '模板',
  82. 'shipping': '运费', 'prescribe': '处方', 'integral': '积分', 'member': '会员',
  83. 'blacklist': '黑名单', 'complaint': '投诉', 'transfer': '转移', 'external': '外部',
  84. 'contact': '联系人', 'qw': '企微', 'wx': '微信', 'crm': 'CRM', 'his': '诊所',
  85. 'adv': '广告', 'ad': '广告', 'tool': '工具', 'gen': '代码生成',
  86. 'job': '定时任务', 'online': '在线用户', 'cache': '缓存', 'server': '服务器',
  87. 'druid': '数据监控', 'iot': '物联网', 'device': '设备', 'index': '首页',
  88. 'list': '列表', 'manage': '管理', 'setting': '设置', 'info': '信息',
  89. 'detail': '详情', 'data': '数据', 'temp': '模板', 'api': 'API', 'Actual': '实际', 'Statistic': '统计', 'OnJob': '在职',
  90. 'Live': '直播', 'Code': '码', 'Circle': '圈', 'Task': '任务',
  91. 'Comments': '评论', 'Customer': '客户', 'Item': '明细',
  92. 'Advertising': '广告', 'Apply': '申请', 'Ipad': 'iPad',
  93. 'Behavior': '行为', 'Push': '推送', 'Count': '统计',
  94. 'Assign': '分配', 'assign': '分配', 'Rule': '规则', 'Batch': '批次',
  95. 'Way': '方式', 'Link': '链接', 'Drainage': '引流', 'drainage': '引流',
  96. 'Loss': '流失', 'Stage': '阶段', 'Transfer': '转移',
  97. 'Audit': '审核', 'Unassigned': '未分配', 'AfterSales': '售后',
  98. 'Promotion': '推广', 'Health': '健康', 'Inquiry': '问诊',
  99. 'Category': '分类', 'DarkRoom': '小黑屋', 'Recharge': '充值',
  100. 'Template': '模板', 'Follow': '随访', 'Audit': '审核',
  101. 'Offline': '线下', 'Audit': '审核', 'Staff': '员工',
  102. 'Cart': '购物车', 'Visit': '访问', 'Relation': '关联',
  103. 'Reply': '回复', 'Attr': '属性', 'Details': '详情',
  104. 'Category': '分类', 'Group': '分组', 'Console': '控制台',
  105. 'Config': '配置', 'Coupon': '优扣券', 'Question': '问题',
  106. 'Reward': '奖励', 'Favorite': '收藏', 'Watch': '观看',
  107. 'Talent': '达人', 'Training': '培训', 'Camp': '营',
  108. 'Material': '素材', 'Period': '期数', 'Resource': '资源',
  109. 'Finish': '完结', 'Temp': '模板', 'Bank': '题库',
  110. 'Answer': '答案', 'RedPacket': '红包', 'Traffic': '流量',
  111. 'Collection': '收藏', 'Dataset': '知识库', 'Session': '会话',
  112. 'Keyword': '关键词', 'Role': '角色', 'Replace': '替换',
  113. 'Words': '词条', 'Quality': '质检', 'Gateway': '网关',
  114. 'Sip': 'SIP', 'Call': '呼叫', 'Client': '客户端',
  115. 'Offline': '线下', 'Item': '明细',
  116. }
  117. PERM_ACTION_ZH = {
  118. 'add': '新增', 'edit': '修改', 'update': '修改', 'remove': '删除',
  119. 'delete': '删除', 'query': '查询', 'list': '列表', 'export': '导出',
  120. 'import': '导入', 'view': '查看', 'detail': '详情', 'reset': '重置',
  121. 'auth': '授权', 'assign': '分配', 'changeStatus': '状态变更',
  122. 'audit': '审核', 'approve': '审批', 'reject': '驳回', 'sync': '同步',
  123. 'refresh': '刷新', 'execute': '执行', 'cancel': '取消', 'submit': '提交',
  124. 'publish': '发布', 'copy': '复制', 'download': '下载', 'upload': '上传',
  125. 'bind': '绑定', 'unbind': '解绑', 'enable': '启用', 'disable': '停用',
  126. }
  127. def has_chinese(s):
  128. return bool(s and re.search(r'[一-鿿]', s))
  129. def split_camel(s):
  130. parts = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', s or '')
  131. parts = re.sub(r'[-_/]', ' ', parts)
  132. return [p for p in parts.split() if p]
  133. def translate_tokens(text):
  134. if not text:
  135. return ''
  136. out = []
  137. for tok in split_camel(text):
  138. low = tok.lower()
  139. if tok in WORD_ZH:
  140. out.append(WORD_ZH[tok])
  141. elif low in WORD_ZH:
  142. out.append(WORD_ZH[low])
  143. elif tok in COMP_ZH:
  144. out.append(COMP_ZH[tok])
  145. elif re.match(r'^[A-Z][a-z]+', tok):
  146. out.append(translate_tokens(tok[0].lower() + tok[1:]) or tok)
  147. else:
  148. out.append(tok)
  149. return ''.join(out) if out else text
  150. def load_admin_titles():
  151. titles = {}
  152. if not os.path.exists(MENU_JS):
  153. return titles
  154. with open(MENU_JS, 'r', encoding='utf-8') as f:
  155. content = f.read()
  156. for m in re.finditer(
  157. r"import\('@/views/([^']+)'\)[^}]*title:\s*'([^']+)'", content
  158. ):
  159. comp, title = m.group(1).replace('.vue', ''), m.group(2)
  160. if comp.endswith('/index'):
  161. comp = comp[:-6]
  162. titles[comp] = title
  163. return titles
  164. def load_menus():
  165. conn = pymysql.connect(**DB)
  166. cur = conn.cursor(pymysql.cursors.DictCursor)
  167. cur.execute(
  168. 'SELECT menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, perms '
  169. 'FROM tenant_sys_menu ORDER BY parent_id, order_num, menu_id'
  170. )
  171. rows = cur.fetchall()
  172. cur.close()
  173. conn.close()
  174. return rows
  175. def comp_key(component):
  176. if not component:
  177. return ''
  178. c = component.replace('/index/index', '/index').replace('/index', '')
  179. return c.strip('/')
  180. def to_chinese(menu, admin_titles, comp_zh=None, parent_zh=''):
  181. comp_zh = comp_zh or COMP_ZH
  182. mid = menu['menu_id']
  183. if mid in ROOT_ZH:
  184. return ROOT_ZH[mid]
  185. name = (menu.get('menu_name') or '').strip()
  186. if has_chinese(name) and not re.match(r'^[a-zA-Z\s]+$', name):
  187. # clean mixed English-only names
  188. if not re.fullmatch(r'[A-Za-z0-9\s\-_]+', name):
  189. return name
  190. comp = comp_key(menu.get('component') or '')
  191. if comp in admin_titles:
  192. return admin_titles[comp]
  193. if comp in comp_zh:
  194. return comp_zh[comp]
  195. # component last segment
  196. if comp:
  197. seg = comp.split('/')[-1]
  198. if seg in comp_zh:
  199. return comp_zh[seg]
  200. zh = translate_tokens(seg)
  201. if zh and zh != seg:
  202. return zh
  203. # two segments
  204. if '/' in comp:
  205. zh2 = translate_tokens(comp.split('/')[-2] + seg)
  206. if has_chinese(zh2):
  207. return zh2
  208. if menu['menu_type'] == 'F':
  209. perms = menu.get('perms') or ''
  210. if perms:
  211. parts = perms.split(':')
  212. action = parts[-1] if parts else ''
  213. act_zh = PERM_ACTION_ZH.get(action, PERM_ACTION_ZH.get(action.lower(), ''))
  214. if act_zh:
  215. if parent_zh and parent_zh not in ('按钮', act_zh):
  216. return parent_zh + act_zh
  217. if len(parts) >= 2:
  218. res = translate_tokens(parts[-2])
  219. if has_chinese(res):
  220. return res + act_zh
  221. return act_zh
  222. if name and name != '#':
  223. zh = translate_tokens(name)
  224. return zh if has_chinese(zh) or zh != name else (act_zh or '按钮')
  225. if name:
  226. zh = translate_tokens(name.replace(' ', ''))
  227. if has_chinese(zh):
  228. return zh
  229. # title case words
  230. if ' ' in name:
  231. parts = [WORD_ZH.get(w.lower(), w) for w in name.split()]
  232. joined = ''.join(parts)
  233. if has_chinese(joined):
  234. return joined
  235. path = menu.get('path') or ''
  236. if path and path != '#':
  237. zh = translate_tokens(path)
  238. if has_chinese(zh):
  239. return zh
  240. return name or comp or ('未命名菜单%d' % mid)
  241. def build_children_map(menus):
  242. ch = defaultdict(list)
  243. by_id = {}
  244. for m in menus:
  245. by_id[m['menu_id']] = m
  246. ch[m['parent_id']].append(m)
  247. for pid in ch:
  248. ch[pid].sort(key=lambda x: (x.get('order_num') or 0, x['menu_id']))
  249. return ch, by_id
  250. def build_comp_zh_from_db(menus):
  251. comp_zh = dict(COMP_ZH)
  252. for m in menus:
  253. if m['menu_type'] not in ('C', 'M'):
  254. continue
  255. name = (m.get('menu_name') or '').strip()
  256. comp = comp_key(m.get('component') or '')
  257. if comp and has_chinese(name) and not re.fullmatch(r'[A-Za-z0-9\s\-_:.]+', name):
  258. comp_zh[comp] = name
  259. seg = comp.split('/')[-1]
  260. if seg and has_chinese(name):
  261. comp_zh[seg] = name
  262. return comp_zh
  263. def collect_reachable(ch_map, roots):
  264. seen = set()
  265. stack = [r['menu_id'] for r in roots]
  266. while stack:
  267. mid = stack.pop()
  268. if mid in seen:
  269. continue
  270. seen.add(mid)
  271. for child in ch_map.get(mid, []):
  272. stack.append(child['menu_id'])
  273. return seen
  274. def render_node(menu, zh_name, depth, lines, ch_map, zh_cache, admin_titles, comp_zh):
  275. mtype = menu['menu_type']
  276. label = TYPE_LABEL.get(mtype, mtype)
  277. vis = '隐藏' if menu.get('visible') == '1' else '显示'
  278. comp = menu.get('component') or '-'
  279. perms = menu.get('perms') or '-'
  280. mid = menu['menu_id']
  281. indent = ' ' * depth
  282. if mtype == 'F':
  283. lines.append('%s- \u2514\u2500 [%s] **%s** `perms=%s`' % (indent, label, zh_name, perms))
  284. else:
  285. lines.append('%s- [%s] **%s** `id=%s` path=`%s` component=`%s` %s' % (
  286. indent, label, zh_name, mid, menu.get('path') or '', comp, vis))
  287. for child in ch_map.get(mid, []):
  288. if child['menu_id'] not in zh_cache:
  289. parent_name = zh_name if mtype == 'C' else ''
  290. zh_cache[child['menu_id']] = to_chinese(
  291. child, admin_titles, comp_zh, parent_zh=parent_name if child['menu_type'] == 'F' else '')
  292. render_node(child, zh_cache[child['menu_id']], depth + 1, lines, ch_map, zh_cache, admin_titles, comp_zh)
  293. def main():
  294. menus = load_menus()
  295. admin_titles = load_admin_titles()
  296. comp_zh = build_comp_zh_from_db(menus)
  297. ch_map, by_id = build_children_map(menus)
  298. zh_cache = {m['menu_id']: to_chinese(m, admin_titles, comp_zh) for m in menus}
  299. roots = ch_map.get(0, [])
  300. biz_roots = [r for r in roots if r['menu_id'] != 32333 and r.get('visible') == '0']
  301. hidden_roots = [r for r in roots if r not in biz_roots]
  302. biz_roots.sort(key=lambda x: (x.get('order_num') or 0, x['menu_id']))
  303. hidden_roots.sort(key=lambda x: (x.get('order_num') or 0, x['menu_id']))
  304. ordered_roots = biz_roots + hidden_roots
  305. reachable = collect_reachable(ch_map, roots)
  306. orphans = [m for m in menus if m['menu_id'] not in reachable]
  307. orphan_groups = defaultdict(list)
  308. for m in orphans:
  309. orphan_groups[m['parent_id']].append(m)
  310. for pid in orphan_groups:
  311. orphan_groups[pid].sort(key=lambda x: (x.get('order_num') or 0, x['menu_id']))
  312. stats = defaultdict(int)
  313. for m in menus:
  314. stats[m['menu_type']] += 1
  315. lines = []
  316. w = lines.append
  317. w('# 租户管理端菜单完整树(中文)')
  318. w('')
  319. w('> 数据来源:`ylrz_saas.tenant_sys_menu` + adminUI 视图/权限补全')
  320. w('> 生成日期:%s' % date.today().isoformat())
  321. w('> 总计:**%d** 条(目录 %d / 菜单 %d / 按钮 %d)' % (
  322. len(menus), stats['M'], stats['C'], stats['F']))
  323. w('> 树中可达:**%d** 条,孤立节点(parent_id 无效):**%d** 条' % (
  324. len(reachable), len(orphans)))
  325. w('')
  326. w('## 图例')
  327. w('')
  328. w('- `[目录]` = M 类型,顶栏/侧栏分组')
  329. w('- `[菜单]` = C 类型,可路由页面')
  330. w('- `[按钮]` = F 类型,页面内权限按钮(`perms`)')
  331. w('- 显示/隐藏 = visible 0/1')
  332. w('')
  333. w('---')
  334. w('')
  335. w('## 完整树状结构')
  336. w('')
  337. sec = 1
  338. for root in ordered_roots:
  339. zh = zh_cache[root['menu_id']]
  340. w('### %d. %s' % (sec, zh))
  341. w('')
  342. render_node(root, zh, 0, lines, ch_map, zh_cache, admin_titles, comp_zh)
  343. w('')
  344. sec += 1
  345. if orphans:
  346. w('### %d. 孤立菜单(parent_id 在库中不存在)' % sec)
  347. w('')
  348. w('> 共 **%d** 条,按原 parent_id 分组展示。建议后续数据清理时修复 parent_id 或删除废弃节点。' % len(orphans))
  349. w('')
  350. for pid in sorted(orphan_groups.keys()):
  351. w('#### parent_id = %s(无效)' % pid)
  352. w('')
  353. for node in orphan_groups[pid]:
  354. zh = zh_cache[node['menu_id']]
  355. render_node(node, zh, 0, lines, ch_map, zh_cache, admin_titles, comp_zh)
  356. w('')
  357. sec += 1
  358. w('---')
  359. w('')
  360. w('## 附录:按模块统计')
  361. w('')
  362. w('| 顶级模块 | 目录 | 菜单 | 按钮 | 合计 |')
  363. w('|----------|------|------|------|------|')
  364. def count_subtree(root_id):
  365. c = {'M': 0, 'C': 0, 'F': 0}
  366. seen = set()
  367. def walk(pid):
  368. if pid in seen:
  369. return
  370. seen.add(pid)
  371. for node in ch_map.get(pid, []):
  372. c[node['menu_type']] += 1
  373. walk(node['menu_id'])
  374. walk(root_id)
  375. return c
  376. for root in biz_roots:
  377. c = count_subtree(root['menu_id'])
  378. total = sum(c.values()) - 1 # exclude self
  379. w('| %s | %d | %d | %d | %d |' % (
  380. zh_cache[root['menu_id']], c['M'], c['C'], c['F'], total))
  381. w('')
  382. w('*Generated by `sql/generate_menu_tree_zh.py`*')
  383. with open(OUT, 'w', encoding='utf-8') as f:
  384. f.write('\n'.join(lines))
  385. print('Wrote', OUT, 'lines', len(lines))
  386. if __name__ == '__main__':
  387. main()