diff --git a/docs/images/菜单树总览.svg b/docs/images/菜单树总览.svg new file mode 100644 index 00000000..1ae2e7ec --- /dev/null +++ b/docs/images/菜单树总览.svg @@ -0,0 +1,235 @@ + + + +菜单树总览(层级 · 绑页 · 密码门) +方框 = 菜单(可展开);细线 = 叶(对应一个页面模块,或一处空缺) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +主界面 + +实时数据 + +参数定值 + +整定 + +查看 + +三遥设置 + +装置维护 + +通讯参数 + +记录查询 + +装置信息 +device_info + +交流量 +ac + +直流量 +dc + +遥信量 +yx + +参数 +view_param +P + +定值 +view_set +P + +控制字 +view_word +P + +软压板 +view_plate +P + +参数 +view_param + +定值 +view_set + +控制字 +view_word + +软压板 +view_plate + +遥测死区 +deadband +P + +遥测系数 +calib +P + +遥信类型 +P + +遥信防抖 +P + +双点遥信 +P + +时钟设置 +clock +P + +强制复归 +reset +P + + +手动录波 +wave_manual +P + +清除记录 +clear +P + +通讯设置 +comm +P + +网口设置 +net_cfg +P + +SNTP设置 +P + +SOE记录 +soe + +事故记录 +accident + +操作记录 +oper + +保护告警 +alarm + +保护启动 +start + +遥控记录 +yk + +自检记录 +selftest + +运行记录 +runlog + +运行报告 +runreport + + +录波回放 +wave_play + +本图读数 +节点 43 = 菜单 9 + 叶 34 +已绑页 30 · 未绑页 4 · 密码门 16 叶 +最深 4 级(主界面 → 叶) +怎么看 + +根:主界面本身,不是一个页面 + +白框:一级菜单,可展开、不绑页 + +紫底:二级菜单与它那一支的行带 + +绿底:录波两叶,已绑页面模块 + + +红字 + 虚线:未绑页的叶,进去只有菜单 +灰色小字 = 页面模块名(页面注册表的键) +行末的 P = 进这一叶要先过密码 +整定 与 查看 复用同一批页面模块 +未绑页的 4 叶 +· 遥信类型(三遥设置 下) +· 遥信防抖(三遥设置 下) +· 双点遥信(三遥设置 下) +· SNTP设置(通讯参数 下) +真值 = ui_menu_tree.txt(tools/menu_gen/gen_menu.py 的输入);本图由 tools/arch_diagram/gen_menu_tree_svg.py 生成 —— 改菜单树要同步脚本内的 NODE_SRC 重跑,勿手改 SVG + diff --git a/docs/产品需求/产品需求设计文档.md b/docs/产品需求/产品需求设计文档.md index 8ff0f2b3..3f3fa74b 100644 --- a/docs/产品需求/产品需求设计文档.md +++ b/docs/产品需求/产品需求设计文档.md @@ -1804,10 +1804,9 @@ pre-commit run --all-files ### 8.2 菜单树(43 节点) -![级联框总览](../images/级联框总览.svg) +![菜单树总览(层级 · 绑页 · 密码门)](../images/菜单树总览.svg) -> 该图仍是 42 节点时代所画(2026-10-29 新增的「录波回放」叶未进图):`tools/arch_diagram/` 里没有这张图的生成器, -> 重画要另开一笔并走 `generated-fresh`;**本节下面的文字与代码生成物是真值**。 +> 图里的节点数、绑页与密码门读数由 `tools/arch_diagram/gen_menu_tree_svg.py` 现算自真值 `ui_menu_tree.txt`(改菜单树 → 同步脚本内 `NODE_SRC` 重跑);级联框在屏上怎么摆(几何)→ `docs/UI设计/菜单页面设计.md` §4.1。 **规模**:菜单节点 **9** 个(含根)+ 叶子节点 **34** 个 = **43** 节点;最大深度 **4 级(主界面 / 一级 / 二级 / 三级菜单)**。 **绑定现状**(2026-10-29 复扫):**已绑内容页 30 叶 / 未绑 4 叶**(页面注册表 `UI_PAGE_MAP_COUNT` = 30, diff --git a/docs/架构设计/图形绘制流程.md b/docs/架构设计/图形绘制流程.md index 644c8fb0..e9879e45 100644 --- a/docs/架构设计/图形绘制流程.md +++ b/docs/架构设计/图形绘制流程.md @@ -54,6 +54,22 @@ | `gen_data_trust_ladder_svg.py` | `docs/images/数据可信台阶.svg` | `STEPS` 五级台阶(状态 / 问题 / 依据)+ `BAND_LABEL_1` / `GATE_LABEL` / `FOOTNOTE`(台阶状态变了才重画) | | `gen_data_trust_dc_ac_svg.py` | `docs/images/ADC直流标定与交流验证.svg` | `BOXES_A` / `BOXES_B` 两条泳道 + `CHIPS` 六件事 + `NOTES` 红线与"证不到"清单 | | `gen_wave_record_flow_svg.py` | `docs/images/故障录波数据流.svg` | `CHAIN` 主链四段 + `EDGE` 跨边界标签 + `TRAPS` 三条陷阱 + `FORBID` 四条红线(所有权、段形态或红线变了才重画) | +| `gen_menu_tree_svg.py` | `docs/images/菜单树总览.svg` | **内嵌 `NODE_SRC`**(= `ui_menu_tree.txt` 的 43 行节点)+ 版面常量;仓库里跑会与真值逐行互验,不一致 exit 1(临时目录里读不到真值 → 用内嵌表,输出逐字节一致) | + +### 2.1 钩子扫不到的图:refgraph 产物与历史手画稿 + +`generated-fresh` 的 `docs-svg` 条目只把 `tools/arch_diagram/gen_*.py` 拷进临时目录重跑逐字节比对。 +上表没列到的 `docs/images/*.svg` 就是下面这几张,**它们全都不在这条钩子的扫描面里**——改了不会有任何 +人报警,这就是「42 个节点的旧图挂在 43 个节点的标题下」能一路活到今天的原因。 + +| 图 | 谁画的 | 用在哪 | 维护口径 | +|---|---|---|---| +| `docs/images/引用关系图.svg`、`docs/images/引用关系图_模块.svg` | `tools/refgraph/gen_refgraph.py`(graphviz,不在 `gen_*.py` 命名面里) | `docs/架构设计/引用关系图说明.md` 的产品 | 改了代码引用就跑 `tools\refgraph\gen_refgraph.bat` 重生成,**svg 与改动同一笔提交**,别只更新 `.dot`;口径 → `docs/架构设计/引用关系图说明.md` | +| `docs/images/级联框总览.svg` | 2026-10-26 手画 | `docs/UI设计/菜单页面设计.md` §4.1(级联框显示几何:根框 + 整定子框) | 只作**显示几何示意**,不承载菜单树事实;树的结构 / 层级 / 绑页 → `docs/images/菜单树总览.svg`(有生成器) | +| `docs/images/级联框几何计算.svg` | 同上 | `docs/UI设计/菜单页面设计.md` §5.3(逐框坐标推导) | 同上;坐标公式变了要重算重画,别在 SVG 上描字 | + +新增精确布局图一律走 `tools/arch_diagram/gen_*.py`(`docs-svg` 条目自动发现,**不必登记基线**);上面这几张 +是这套纪律的**例外面**,再多一张先说清为什么不能用生成器。 --- diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index 6dfed728..cb428933 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -21,6 +21,7 @@ | `gen_data_trust_ladder_svg.py` | 数据可信台阶图 | 采集数据可信度的五级台阶:现在踩在哪一级、下一级挡什么 | | `gen_data_trust_dc_ac_svg.py` | ADC 直流标定与交流验证图 | 上排直流注入链、下排信号发生器正弦验证,中间列只有正弦能证的六件事 | | `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 | +| `gen_menu_tree_svg.py` | 菜单树总览图 | 43 节点的层级 / 绑页 / 密码门鸟瞰;内嵌 `ui_menu_tree.txt` 的节点行并与真值互验,防图与树漂移 | | `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 | | `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 | | `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 | diff --git a/tools/arch_diagram/gen_menu_tree_svg.py b/tools/arch_diagram/gen_menu_tree_svg.py new file mode 100644 index 00000000..a163796b --- /dev/null +++ b/tools/arch_diagram/gen_menu_tree_svg.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/菜单树总览.svg —— 菜单树的层级、绑页与密码门鸟瞰图。 + +数据源 = ui_menu_tree.txt(真值,tools/menu_gen/gen_menu.py 的输入)。本脚本内嵌同一张 +节点表 NODE_SRC,两种运行环境都要有确定行为: + · 在仓库里跑:先与 ui_menu_tree.txt 的节点行逐行互验,不一致直接 exit 1 打印差异 + —— 改了菜单树必须同步内嵌表,图才会跟着变(漂移摆在台面上,而不是让 SVG 悄悄失真); + · 在临时目录跑(pre-commit 的 generated-fresh 钩子把脚本拷出去重跑):读不到输入 + 文件 → 退回内嵌表 → 输出逐字节一致。 +""" +import html +import pathlib +import sys +import xml.etree.ElementTree as ET + +ROOT = pathlib.Path(__file__).resolve().parents[2] +TREE_FILE = ROOT / "ui_menu_tree.txt" +OUT = ROOT / "docs" / "images" / "菜单树总览.svg" + +# ui_menu_tree.txt 的节点行(缩进 2 空格 = 一层;#P = 需密码;#page=x = 已绑页面模块)。 +# 改菜单树 → 同步这张表 → 重跑本脚本 → 脚本与产物同一笔提交。 +NODE_SRC = [ + "主界面", + " 装置信息 #page=device_info", + " 实时数据", + " 交流量 #page=ac", + " 直流量 #page=dc", + " 遥信量 #page=yx", + " 参数定值", + " 整定", + " 参数 #P #page=view_param", + " 定值 #P #page=view_set", + " 控制字 #P #page=view_word", + " 软压板 #P #page=view_plate", + " 查看", + " 参数 #page=view_param", + " 定值 #page=view_set", + " 控制字 #page=view_word", + " 软压板 #page=view_plate", + " 三遥设置", + " 遥测死区 #P #page=deadband", + " 遥测系数 #P #page=calib", + " 遥信类型 #P", + " 遥信防抖 #P", + " 双点遥信 #P", + " 装置维护", + " 时钟设置 #P #page=clock", + " 强制复归 #P #page=reset", + " 手动录波 #P #page=wave_manual", + " 清除记录 #P #page=clear", + " 通讯参数", + " 通讯设置 #P #page=comm", + " 网口设置 #P #page=net_cfg", + " SNTP设置 #P", + " 记录查询", + " SOE记录 #page=soe", + " 事故记录 #page=accident", + " 操作记录 #page=oper", + " 保护告警 #page=alarm", + " 保护启动 #page=start", + " 遥控记录 #page=yk", + " 自检记录 #page=selftest", + " 运行记录 #page=runlog", + " 运行报告 #page=runreport", + " 录波回放 #page=wave_play", +] + +# ---- 版面 ---- +FS_LEAF = 12 +FS_MENU = 12 +FS_ROOT = 13 +FS_PAGE = 9 +PITCH = 18 +ROW_TOP = 96 +X_COL = (28, 150, 306, 474) +TRUNK_OFF = 22 +LEAF_TEXT = 14 +PAD_X = 9 +BAND_X = X_COL[1] - 12 +BAND_R = 624 +PANEL_X = 640 +W = 990 +TREE_LIMIT = 630 + +INK = "#0f172a" +MUTED = "#475569" +FAINT = "#94a3b8" +LINE = "#334155" +RULE = "#cbd5e1" +C_BLUE = "#1d4ed8" +BG_BLUE = "#eff6ff" +C_VIOLET = "#6d28d9" +BG_VIOLET = "#f5f3ff" +C_AMBER = "#b45309" +C_ROSE = "#be123c" +C_GREEN = "#15803d" +BG_GREEN = "#f0fdf4" +BG_GRAY = "#f8fafc" +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" + +_over = [] + + +def emit(msg): + """控制台可能是 GBK:中文/符号打印不能把生成器打崩(假红 = 钩子重跑失败)。""" + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + safe = str(msg).encode(enc, errors="replace").decode(enc, errors="replace") + sys.stdout.write(safe + "\n") + + +def tw(text, fs): + """近似字宽:CJK 约 1.0 em,其余 0.58 em。""" + return sum(fs * (1.0 if ord(c) > 0x2E80 else 0.58) for c in str(text)) + + +def esc(text): + return html.escape(str(text), quote=True) + + +def fit(x_right, limit, what): + if x_right > limit: + _over.append("%s: %.0f > %.0f" % (what, x_right, limit)) + + +def node_lines(raw): + """取节点行:丢空行与 # 指令行(指令改了不影响图的形状)。""" + out = [] + for ln in raw.splitlines(): + ln = ln.rstrip("\r") + if not ln.strip() or ln.strip().startswith("#"): + continue + out.append(ln) + return out + + +def verify_src(): + """仓库里跑 = 与真值互验;临时目录里跑 = 静默用内嵌表。""" + if not TREE_FILE.exists(): + emit("note: 未找到 ui_menu_tree.txt(临时目录重跑场景)→ 用内嵌 NODE_SRC") + return 0 + live = node_lines(TREE_FILE.read_text(encoding="utf-8-sig")) + if live == list(NODE_SRC): + emit("互验 OK:%d 行节点与 ui_menu_tree.txt 一致" % len(live)) + return 0 + emit("[FAIL] ui_menu_tree.txt 与本脚本内嵌 NODE_SRC 不一致 —— 图会失真,请同步内嵌表后重跑") + for i in range(max(len(live), len(NODE_SRC))): + a = live[i] if i < len(live) else "<缺>" + b = NODE_SRC[i] if i < len(NODE_SRC) else "<缺>" + if a != b: + emit(" 第 %d 行:真值 %r / 内嵌 %r" % (i + 1, a, b)) + return 1 + + +def parse(lines): + """节点行 → (根, 文档序节点表)。""" + root = None + docs = [] + stack = [] + for ln in lines: + depth = (len(ln) - len(ln.lstrip(" "))) // 2 + page = None + pwd = False + keep = [] + for tok in ln.strip().split(): + if tok == "#P": + pwd = True + elif tok.startswith("#page="): + page = tok[len("#page="):] + else: + keep.append(tok) + node = {"depth": depth, "label": " ".join(keep), "page": page, + "pwd": pwd, "kids": [], "y": 0.0, "w": 0.0} + docs.append(node) + while stack and stack[-1]["depth"] >= depth: + stack.pop() + if stack: + stack[-1]["kids"].append(node) + else: + root = node + stack.append(node) + return root, docs + + +def layout(root): + """叶按文档序占行;菜单 y = 首末子节点 y 的中点。返回叶行数。""" + state = [0] + + def walk(n): + if not n["kids"]: + n["y"] = ROW_TOP + state[0] * PITCH + state[0] += 1 + return + for k in n["kids"]: + walk(k) + n["y"] = (n["kids"][0]["y"] + n["kids"][-1]["y"]) / 2.0 + + walk(root) + return state[0] + + +def yspan(n): + """子树占用的 y 区间。""" + if not n["kids"]: + return n["y"], n["y"] + lo, _ = yspan(n["kids"][0]) + _, hi = yspan(n["kids"][-1]) + return lo, hi + + +def tally(docs): + menus = [n for n in docs if n["kids"]] + leaves = [n for n in docs if not n["kids"]] + unbound = [n for n in leaves if not n["page"]] + return {"total": len(docs), "menus": len(menus), "leaves": len(leaves), + "bound": len(leaves) - len(unbound), "unbound": unbound, + "pwd": len([n for n in leaves if n["pwd"]]), + "depth": max(n["depth"] for n in docs) + 1} + + +QA = chr(34) # 双引号:拼 SVG 属性值用,免得在字面量里嵌引号 + + +def r1(v): + """一位小数(决定输出逐字节稳定)。""" + return format(float(v), ".1f") + + +def attrs(pairs): + out = [] + for key, val in pairs: + out.append(key + "=" + QA + str(val) + QA) + return " ".join(out) + + +def txt(x, y, body, fs, fill, weight="normal", anchor="start"): + head = attrs([("x", r1(x)), ("y", r1(y)), ("font-size", fs), ("fill", fill), + ("font-weight", weight), ("text-anchor", anchor)]) + return "" + esc(body) + "" + + +def rrect(x, y, w, h, fill, stroke, rx=5, sw=1.2): + return "" + + +def line(x1, y1, x2, y2, stroke, sw=1.2, dash=None): + pairs = [("x1", r1(x1)), ("y1", r1(y1)), ("x2", r1(x2)), ("y2", r1(y2)), + ("stroke", stroke), ("stroke-width", sw)] + if dash: + pairs.append(("stroke-dasharray", dash)) + return "" + + +def svg_header(h): + return ["", + "", + ""] + + +def draw_tree(docs, s): + """分组带 -> 连接线 -> 方框与叶(后画的压在前面之上)。""" + band = 0 + for n in docs: + if n["depth"] != 1: + continue + lo, hi = yspan(n) + fill = BG_GRAY if band % 2 == 0 else "#ffffff" + s.append(rrect(BAND_X, lo - 9, BAND_R - BAND_X, (hi - lo) + 18, fill, + "none", 6, 0)) + band += 1 + for n in docs: + if n["depth"] == 2 and n["kids"]: + lo, hi = yspan(n) + s.append(rrect(X_COL[2] - 10, lo - 9, BAND_R - (X_COL[2] - 10), + (hi - lo) + 18, BG_VIOLET, "none", 5, 0)) + for n in docs: + if not n["kids"]: + continue + d = n["depth"] + tx = X_COL[d + 1] - TRUNK_OFF + s.append(line(tx, n["kids"][0]["y"], tx, n["kids"][-1]["y"], RULE)) + s.append(line(X_COL[d] + n["w"], n["y"], tx, n["y"], RULE)) + for k in n["kids"]: + kx = X_COL[k["depth"]] if k["kids"] else X_COL[k["depth"]] + 3 + s.append(line(tx, k["y"], kx, k["y"], RULE)) + for n in docs: + if n["kids"]: + d = n["depth"] + fs = FS_ROOT if d == 0 else FS_MENU + if d == 0: + ink, fill, stroke, bh = C_BLUE, BG_BLUE, C_BLUE, 26 + elif d == 1: + ink, fill, stroke, bh = INK, "#ffffff", LINE, 22 + else: + ink, fill, stroke, bh = C_VIOLET, "#ffffff", C_VIOLET, 20 + s.append(rrect(X_COL[d], n["y"] - bh / 2.0, n["w"], bh, fill, stroke, 5)) + s.append(txt(X_COL[d] + n["w"] / 2.0, n["y"] + fs * 0.36, n["label"], + fs, ink, "bold", "middle")) + if d + 1 < len(X_COL): + fit(X_COL[d] + n["w"], X_COL[d + 1] - TRUNK_OFF - 4, + "menu " + n["label"]) + for n in docs: + if n["kids"]: + continue + x = X_COL[n["depth"]] + y = n["y"] + wave = n["label"] in ("手动录波", "录波回放") + bound = bool(n["page"]) + lw = tw(n["label"], FS_LEAF) + if wave: + s.append(rrect(x + 1, y - 8.5, LEAF_TEXT + lw + 9, 17, BG_GREEN, + C_GREEN, 4, 0.9)) + if bound: + s.append(line(x + 2, y, x + 9, y, RULE)) + else: + s.append(line(x + 2, y, x + 9, y, C_ROSE, 1.2, "3,2")) + ink = INK + if not bound: + ink = C_ROSE + elif wave: + ink = C_GREEN + s.append(txt(x + LEAF_TEXT, y + 4, n["label"], FS_LEAF, ink)) + cur = x + LEAF_TEXT + lw + if bound: + s.append(txt(cur + 10, y + 3.5, n["page"], FS_PAGE, FAINT)) + cur += 10 + tw(n["page"], FS_PAGE) + if n["pwd"]: + s.append(txt(cur + 9, y + 3.5, "P", 9, C_AMBER, "bold")) + cur += 10 + tw("P", 9) + fit(cur, TREE_LIMIT, "leaf " + n["label"]) + + +def panel_rows(c): + rows = [{"k": "h", "t": "本图读数"}] + rows.append({"k": "t", "t": "节点 %d = 菜单 %d + 叶 %d" + % (c["total"], c["menus"], c["leaves"])}) + rows.append({"k": "t", "t": "已绑页 %d · 未绑页 %d · 密码门 %d 叶" + % (c["bound"], len(c["unbound"]), c["pwd"])}) + rows.append({"k": "t", "t": "最深 %d 级(主界面 → 叶)" % c["depth"]}) + rows.append({"k": "s", "t": ""}) + rows.append({"k": "h", "t": "怎么看"}) + rows.append({"k": "sw", "fill": BG_BLUE, "stroke": C_BLUE, + "t": "根:主界面本身,不是一个页面"}) + rows.append({"k": "sw", "fill": "#ffffff", "stroke": LINE, + "t": "白框:一级菜单,可展开、不绑页"}) + rows.append({"k": "sw", "fill": BG_VIOLET, "stroke": C_VIOLET, + "t": "紫底:二级菜单与它那一支的行带"}) + rows.append({"k": "sw", "fill": BG_GREEN, "stroke": C_GREEN, + "t": "绿底:录波两叶,已绑页面模块"}) + rows.append({"k": "sw", "fill": "#ffffff", "stroke": C_ROSE, "dash": "3,2", + "t": "红字 + 虚线:未绑页的叶,进去只有菜单"}) + rows.append({"k": "t", "t": "灰色小字 = 页面模块名(页面注册表的键)"}) + rows.append({"k": "t", "t": "行末的 P = 进这一叶要先过密码"}) + rows.append({"k": "t", "t": "整定 与 查看 复用同一批页面模块"}) + rows.append({"k": "s", "t": ""}) + rows.append({"k": "h", "t": "未绑页的 %d 叶" % len(c["unbound"])}) + for n in c["unbound"]: + par = n["parent"]["label"] if n["parent"] else "?" + rows.append({"k": "t", "t": "· " + n["label"] + "(" + par + " 下)"}) + return rows + + +def draw_panel(c, s): + """右侧读数与图例:数字全部现算,文档里不必再抄一份。""" + rows = panel_rows(c) + x = PANEL_X + pw = W - 30 - x + lh = 18.0 + hh = 25.0 + gap = 12.0 + body_h = 0.0 + for r in rows: + if r["k"] == "h": + body_h += hh + elif r["k"] == "s": + body_h += gap + else: + body_h += lh + y0 = ROW_TOP - 24 + s.append(rrect(x - 16, y0, pw + 30, body_h + 44, "#ffffff", RULE, 8)) + y = y0 + 26 + for r in rows: + if r["k"] == "s": + y += gap + continue + if r["k"] == "h": + s.append(txt(x, y + 4, r["t"], 12.5, INK, "bold")) + fit(x + tw(r["t"], 12.5), W - 40, "panel " + r["t"]) + y += hh + continue + tx = x + if r["k"] == "sw": + sw = r.get("dash") + s.append(rrect(x, y - 8, 15, 12, r["fill"], r["stroke"], 3, 1.0)) + if sw: + s.append(line(x, y - 2, x + 15, y - 2, r["stroke"], 1.0, sw)) + tx = x + 22 + s.append(txt(tx, y + 2, r["t"], 10.5, MUTED)) + fit(tx + tw(r["t"], 10.5), W - 40, "panel " + r["t"][:8]) + y += lh + + +def main(): + if verify_src(): + return 1 + root, docs = parse(NODE_SRC) + for n in docs: + n["parent"] = None + for n in docs: + for k in n["kids"]: + k["parent"] = n + leaf_rows = layout(root) + for n in docs: + if n["kids"]: + fs = FS_ROOT if n["depth"] == 0 else FS_MENU + n["w"] = 2 * PAD_X + tw(n["label"], fs) + c = tally(docs) + h = ROW_TOP + leaf_rows * PITCH + 76 + s = svg_header(h) + s.append(txt(28, 40, "菜单树总览(层级 · 绑页 · 密码门)", 17, INK, "bold")) + s.append(txt(28, 61, "方框 = 菜单(可展开);细线 = 叶(对应一个页面模块,或一处空缺)", + 11, MUTED)) + draw_tree(docs, s) + draw_panel(c, s) + foot = ROW_TOP + leaf_rows * PITCH + 42 + note = ("真值 = ui_menu_tree.txt(tools/menu_gen/gen_menu.py 的输入);本图由 " + "tools/arch_diagram/gen_menu_tree_svg.py 生成 —— 改菜单树要同步脚本内的 " + "NODE_SRC 重跑,勿手改 SVG") + s.append(txt(28, foot, note, 9.5, FAINT)) + fit(28 + tw(note, 9.5), W - 20, "foot") + s.append("") + body = "\n".join(s) + "\n" + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_bytes(body.encode("utf-8")) + try: + ET.fromstring(body) + except ET.ParseError as exc: + emit("XML FAIL: %s" % exc) + return 1 + emit("SVG %dx%d 节点 %d 菜单 %d 叶 %d 绑页 %d 未绑 %d 密码门 %d 最深 %d 级" + % (W, h, c["total"], c["menus"], c["leaves"], c["bound"], + len(c["unbound"]), c["pwd"], c["depth"])) + if _over: + for item in _over: + emit("OVERFLOW " + item) + emit("[FAIL] 版面溢出 %d 处 —— 调版面常量后重跑" % len(_over)) + return 1 + emit("OVERFLOW 0 → " + str(OUT.relative_to(ROOT))) + return 0 + + +if __name__ == "__main__": + sys.exit(main())