guanlan_baseline_manifest.py 7.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """观澜 详细分析工作台 视觉基线 (detail_style_baseline) 清单构建/校验 (用户裁决 2026-09-06: 确认 C1 = /v2, 删末块与门户一致, 补返回门户后冻结).
  4. build: 从 src/windscada/ui 源重建 zh 页 → 摘要 {css_tokens, nav, dom, sha256, source blobs} → outputs/rudong/windscada/release-manifest.json (detail_style_baseline 段)
  5. check: 重建后摘要 vs 清单; 任一差异 = 样式/框架变化 = FAIL (除非用户批准新版本并重写清单).
  6. 用法: python scripts/guanlan_baseline_manifest.py build|check [--html <path>]"""
  7. import hashlib, datetime as dt, hashlib, json, re, subprocess, sys
  8. from pathlib import Path
  9. ROOT = Path(__file__).resolve().parents[1]; MAN = ROOT / "outputs/rudong/windscada/release-manifest.json"
  10. UI = ROOT / "src/windscada/ui"; SRC_FILES = ["src/windscada/ui/shell.html", "src/windscada/ui/app.css", "src/windscada/ui/app.js", "src/windscada/ui/charts.js", "src/windscada/ui/build.py", "src/windscada/lang.py"]
  11. TOKENS = ("--ground", "--panel", "--ink", "--muted", "--line", "--hair", "--accent", "--accent-deep", "--accent-soft", "--wash", "--critical", "--warning", "--good", "--excellent", "--sans", "--mono")
  12. sha = lambda b: hashlib.sha256(b).hexdigest()
  13. def render_zh() -> str:
  14. sys.path.insert(0, str(ROOT)); from src.windscada.ui import build; return build.render("zh")
  15. def summarize(html: str) -> dict:
  16. css = "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", html, flags=re.S)) # 全部 style 块按出现顺序 (服务端注入在后 → 覆盖)
  17. roots = [b for sel, b in re.findall(r"(?:^|[}\s;])(:root[^{]*)\{([^}]*)\}", css) if ":root" in sel]; eff = {} # 任何含 :root 的选择器块 (含 ':root,:root[data-theme]' 这种注入写法)
  18. for blk in roots: # 后块覆盖前块 = 浏览器生效值 (末块已删, 中段已删; 若有人再加覆盖, 摘要就变)
  19. for k, v in re.findall(r"(--[a-z-]+)\s*:\s*([^;]+)", blk): eff[k] = v.strip()
  20. tokens = {k: eff.get(k) for k in TOKENS}
  21. js = html[html.rfind("<script>") + 8: html.rfind("</script>")]
  22. tabs = re.search(r"const TABS = \[([^\]]*)\]", js).group(1); tabs = re.findall(r"'([a-z_]+)'", tabs)
  23. pack = json.loads(re.search(r"window\.__L=(\{.*?\});</script>", html, flags=re.S).group(1))
  24. nav = [pack.get("tab." + t) for t in tabs]; actions = {k: pack.get(k) for k in ("ui.back_portal", "ui.reselect", "ui.back_to", "win.label")}
  25. dom = {"shell_ids": re.findall(r'id="(top|tabs|root|foot)"', html), "header_controls": ["backPortal" if 'id="backPortal"' in html else None, "reselect" if 'id="reselect"' in html else None, "win" if 'id="win"' in html else None],
  26. "theme_attr": bool(re.search(r'<html[^>]*data-theme', html)), "lang": re.search(r'<html lang="([a-z]+)"', html).group(1)}
  27. return {"css_tokens": tokens, "n_root_blocks": len(roots), "tabs": tabs, "nav_zh": nav, "actions": actions, "dom": dom, "sha256": sha(html.encode("utf-8")), "size": len(html.encode("utf-8")),
  28. "fingerprint": sha(json.dumps({"t": tokens, "n": nav, "d": dom, "a": actions}, sort_keys=True, ensure_ascii=False).encode("utf-8"))[:16]}
  29. def live_check(url: str, man: dict) -> list:
  30. """线上 /v2 实际响应 (含服务端注入的样式) 的摘要必须等于基线 —— 服务端注入 (如 guanlan-unified-review) 改令牌 = 红."""
  31. import urllib.request
  32. html = urllib.request.urlopen(url, timeout=10).read().decode("utf-8"); s = summarize(html); diffs = []
  33. for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom"):
  34. if man[k] != s[k]: diffs.append(f"live {k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:100]} ≠ 线上 {json.dumps(s[k], ensure_ascii=False)[:100]}")
  35. inj = re.findall(r'<style id="([^"]+)"', html)
  36. if inj: diffs.append(f"live 服务端注入 <style id=…>: {inj} (基线构建物无)")
  37. return diffs
  38. def main():
  39. mode = sys.argv[1] if len(sys.argv) > 1 else "check"; html = render_zh(); s = summarize(html)
  40. if mode == "check" and "--live" in sys.argv:
  41. url = sys.argv[sys.argv.index("--live") + 1]; man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; d = live_check(url, man)
  42. print(("PASS" if not d else "FAIL") + f" live {url}"); [print(" -", x) for x in d]; return 0 if not d else 2
  43. git = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
  44. blobs = {f: subprocess.run(["git", "-C", str(ROOT), "ls-tree", "HEAD", "--", f], capture_output=True, text=True).stdout.split()[2][:16] for f in SRC_FILES}
  45. if mode == "build":
  46. def portal_sha(prev): # 门户参考 = 桌面固定单文件 (用户裁定; 2026-09-06 契约段就地注入后变更), 不在库内 → 缺失时沿用清单旧值, 禁写死
  47. rel = (ROOT / "release/portal.html"); pp = rel if rel.exists() else Path.home() / "Desktop/观澜_如东样板_门户_单文件.html" # 发布目录优先 (用户裁 2026-09-06), 回退桌面
  48. return hashlib.sha256(pp.read_bytes()).hexdigest() if pp.exists() else (prev.get("detail_style_baseline") or {}).get("portal_reference_sha256")
  49. man = json.loads(MAN.read_text(encoding="utf-8")) if MAN.exists() else {"schema": "release-manifest/v0"}
  50. man["detail_style_baseline"] = {"confirmed_by": "用户裁决 2026-09-06 (确认 C1; 删末块与门户一致; 补返回门户/重新选择后冻结)", "route": "/v2 (8033) → 目标架构 /detail/", "source": "src/windscada/ui + src/windscada/lang.py", "git_commit": git, "source_blobs_sha16": blobs,
  51. "build_cmd": "python -m src.windscada.ui.build zh", "built": dt.datetime.now().isoformat(timespec="minutes"), "artifact_sha256": s["sha256"], "artifact_size": s["size"],
  52. "portal_reference_sha256": portal_sha(man), "portal_tokens": {"--ground": "#F5F7F8", "--ink": "#1B2430", "--accent": "#1F6F8B", "--line": "#DDE3E8"},
  53. "css_tokens": s["css_tokens"], "n_root_blocks": s["n_root_blocks"], "tabs": s["tabs"], "nav_zh": s["nav_zh"], "actions": s["actions"], "dom": s["dom"], "fingerprint": s["fingerprint"],
  54. "screenshot_note": "2026-09-06 浏览器实测 computed style: --ground #F5F7F8 / --ink #1B2430 / --accent #1F6F8B / --line #DDE3E8, 10 tab, 返回门户 href=/ (?portal= 可覆盖), 重新选择 存在; 像素级截图指纹未建 (无基线截图机制), fingerprint = 令牌+导航+DOM 摘要 sha",
  55. "frozen": ["色彩令牌", "字体", "字号层级/留白/卡片/边框 (app.css)", "顶部导航与十模块顺序", "返回门户/重新选择 位置与语义", "板块框架 (总览…系统维护)"],
  56. "allowed_changes": "仅结构化事实契约驱动的数据/时间窗/结论/图表数据/报告文本; 不得改 DOM 布局/CSS 令牌/导航顺序/按钮层级"}
  57. MAN.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding="utf-8"); print("wrote", MAN.relative_to(ROOT), "commit", git, "sha", s["sha256"][:16], "fp", s["fingerprint"]); return 0
  58. man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; diffs = []
  59. for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom", "n_root_blocks"):
  60. if man[k] != s[k]: diffs.append(f"{k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:120]} ≠ 现 {json.dumps(s[k], ensure_ascii=False)[:120]}")
  61. for k, v in man["css_tokens"].items():
  62. if k in man["portal_tokens"] and s["css_tokens"].get(k) != man["portal_tokens"][k]: diffs.append(f"{k} ≠ 门户 {man['portal_tokens'][k]}")
  63. print(("PASS" if not diffs else "FAIL") + f" detail_style_baseline fp={s['fingerprint']} (基线 {man['fingerprint']}) sha={s['sha256'][:16]} (基线 {man['artifact_sha256'][:16]})"); [print(" -", d) for d in diffs]
  64. return 0 if not diffs else 2
  65. if __name__ == "__main__":
  66. sys.exit(main())