serve.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  2. """Serve the SupplyAgent business-design visualizations locally."""
  3. from __future__ import annotations
  4. import argparse
  5. import html
  6. import mimetypes
  7. import threading
  8. import webbrowser
  9. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  10. from pathlib import Path
  11. from urllib.parse import unquote, urlparse
  12. VISUALIZATION_DIR = Path(__file__).resolve().parents[1]
  13. PAGES_DIR = VISUALIZATION_DIR / "pages"
  14. PAGES: tuple[tuple[str, str, str], ...] = (
  15. (
  16. "全局分类树-需求热度与关系分析.html",
  17. "00 · 平台全局需求地图",
  18. "用全量冰柱热力图呈现冷热分布,并从上游数据直接形成数百条平台需求和可追溯 Reason。",
  19. ),
  20. (
  21. "graph-structure-options.html",
  22. "01 · 图结构方案比较",
  23. "比较单一大树、自由图和树骨架关系图。",
  24. ),
  25. (
  26. "graph-structure-left-to-right.html",
  27. "02 · 从左到右的树图结构",
  28. "展示主题、分类树、具体节点、平台需求和证据的阅读方向。",
  29. ),
  30. (
  31. "business-graph-model.html",
  32. "03 · 业务图节点与边",
  33. "展示分类节点、需求元素、平台需求、关系和证据。",
  34. ),
  35. (
  36. "demand-feedback-loop.html",
  37. "04 · 需求—内容—表现闭环",
  38. "展示真实线上表现如何反向更新需求强度。",
  39. ),
  40. (
  41. "final-output-structure.html",
  42. "05 · 最终输出结构",
  43. "展示主题方向、平台需求和证据卡的组织方式。",
  44. ),
  45. (
  46. "node-drilldown-evidence-chain.html",
  47. "06 · 节点下钻证据链",
  48. "展示分类节点如何下挂元素、视频、短观点、长讨论并提取平台需求。",
  49. ),
  50. (
  51. "waiting-business-scope.html",
  52. "过程页 A · 业务范围确认",
  53. "对话过程中的可视化等待页。",
  54. ),
  55. (
  56. "waiting-demand-pipeline.html",
  57. "过程页 B · 需求流水线确认",
  58. "对话过程中的可视化等待页。",
  59. ),
  60. (
  61. "waiting-strength-lifecycle.html",
  62. "过程页 C · 强度生命周期确认",
  63. "对话过程中的可视化等待页。",
  64. ),
  65. )
  66. BASE_CSS = """
  67. * { box-sizing: border-box; }
  68. html, body { min-height: 100%; }
  69. body {
  70. margin: 0;
  71. color: #0f172a;
  72. background: #f5f7fb;
  73. font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
  74. "Segoe UI", sans-serif;
  75. line-height: 1.5;
  76. }
  77. a { color: inherit; }
  78. .topbar {
  79. position: sticky;
  80. top: 0;
  81. z-index: 50;
  82. display: flex;
  83. align-items: center;
  84. justify-content: space-between;
  85. gap: 16px;
  86. padding: 12px 24px;
  87. color: #e2e8f0;
  88. background: rgba(15, 23, 42, .96);
  89. border-bottom: 1px solid #334155;
  90. }
  91. .topbar a { text-decoration: none; }
  92. .topbar .brand { font-weight: 700; color: #fff; }
  93. .topbar .meta { color: #94a3b8; font-size: 13px; }
  94. .content {
  95. width: min(1480px, calc(100% - 32px));
  96. margin: 0 auto;
  97. padding: 28px 0 48px;
  98. }
  99. h1, h2, h3 { line-height: 1.25; }
  100. h2 { margin: 0 0 8px; font-size: 26px; }
  101. .subtitle { color: #64748b; margin: 0 0 20px; }
  102. .options { display: flex; flex-direction: column; gap: 12px; }
  103. .option, .card { cursor: pointer; }
  104. .option.selected, .card.selected {
  105. border-color: #2563eb !important;
  106. box-shadow: 0 0 0 3px rgba(37, 99, 235, .14) !important;
  107. }
  108. .gallery-title { margin: 4px 0 8px; font-size: 30px; }
  109. .gallery-intro { max-width: 880px; color: #64748b; margin: 0 0 24px; }
  110. .gallery {
  111. display: grid;
  112. grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
  113. gap: 16px;
  114. }
  115. .gallery-card {
  116. display: block;
  117. padding: 18px;
  118. text-decoration: none;
  119. background: #fff;
  120. border: 1px solid #dbe3ec;
  121. border-radius: 15px;
  122. transition: .18s ease;
  123. }
  124. .gallery-card:hover {
  125. transform: translateY(-2px);
  126. border-color: #60a5fa;
  127. box-shadow: 0 12px 28px rgba(15, 23, 42, .09);
  128. }
  129. .gallery-card h3 { margin: 0 0 7px; font-size: 17px; }
  130. .gallery-card p { margin: 0; color: #64748b; font-size: 13px; }
  131. .reference {
  132. margin-top: 24px;
  133. padding: 18px;
  134. border: 1px solid #dbe3ec;
  135. border-radius: 15px;
  136. background: #fff;
  137. }
  138. .reference img {
  139. display: block;
  140. width: min(100%, 650px);
  141. height: auto;
  142. margin-top: 12px;
  143. border: 1px solid #e2e8f0;
  144. }
  145. """
  146. def _page_metadata(filename: str) -> tuple[str, str] | None:
  147. for item_filename, title, description in PAGES:
  148. if filename == item_filename:
  149. return title, description
  150. return None
  151. def _document(title: str, body: str, *, subtitle: str = "") -> bytes:
  152. subtitle_html = (
  153. f'<div class="meta">{html.escape(subtitle)}</div>' if subtitle else ""
  154. )
  155. document = f"""<!doctype html>
  156. <html lang="zh-CN">
  157. <head>
  158. <meta charset="utf-8">
  159. <meta name="viewport" content="width=device-width, initial-scale=1">
  160. <link rel="icon" href="data:,">
  161. <title>{html.escape(title)} · SupplyAgent</title>
  162. <style>{BASE_CSS}</style>
  163. </head>
  164. <body>
  165. <header class="topbar">
  166. <a class="brand" href="/">SupplyAgent 业务可视化</a>
  167. {subtitle_html}
  168. </header>
  169. <main class="content">{body}</main>
  170. <script>
  171. window.toggleSelect = function (el) {{
  172. const container = el.closest('.options') || el.closest('.cards') || el.parentElement;
  173. if (container) {{
  174. container.querySelectorAll('[data-choice]').forEach(function (item) {{
  175. item.classList.remove('selected');
  176. }});
  177. }}
  178. el.classList.add('selected');
  179. }};
  180. </script>
  181. </body>
  182. </html>"""
  183. return document.encode("utf-8")
  184. def _gallery() -> bytes:
  185. cards = []
  186. for filename, title, description in PAGES:
  187. cards.append(
  188. f"""<a class="gallery-card" href="/view/{html.escape(filename)}">
  189. <h3>{html.escape(title)}</h3>
  190. <p>{html.escape(description)}</p>
  191. </a>"""
  192. )
  193. body = f"""
  194. <h1 class="gallery-title">SupplyAgent 业务框架可视化</h1>
  195. <p class="gallery-intro">
  196. 本目录保存需求汇总业务设计过程中形成的全部可视化材料。
  197. 正式业务结论以 README_myself.md 为准;图稿用于解释树、图、需求、内容和反馈之间的关系。
  198. </p>
  199. <section class="gallery">{"".join(cards)}</section>
  200. <section class="reference">
  201. <h2>用户提供的参考图</h2>
  202. <p class="subtitle">用于理解全局分类树、分类节点下挂元素,以及元素如何扩展成明确语言和长段讨论。</p>
  203. <a href="/assets/tree.jpeg" target="_blank" rel="noreferrer"><img src="/assets/tree.jpeg" alt="全局分类树局部参考图"></a>
  204. <a href="/assets/tree2.png" target="_blank" rel="noreferrer"><img src="/assets/tree2.png" alt="分类节点下挂元素参考图"></a>
  205. <a href="/assets/case.jpeg" target="_blank" rel="noreferrer"><img src="/assets/case.jpeg" alt="元素扩展为语言和讨论参考图"></a>
  206. <a href="/assets/liubai.png" target="_blank" rel="noreferrer"><img src="/assets/liubai.png" alt="全局需求地图留白调整参考图"></a>
  207. </section>
  208. """
  209. return _document("可视化目录", body, subtitle="本地设计资料")
  210. class VisualizationHandler(SimpleHTTPRequestHandler):
  211. def __init__(self, *args, **kwargs):
  212. super().__init__(*args, directory=str(VISUALIZATION_DIR), **kwargs)
  213. def do_GET(self) -> None: # noqa: N802
  214. path = unquote(urlparse(self.path).path)
  215. if path == "/":
  216. self._send_html(_gallery())
  217. return
  218. if path.startswith("/view/"):
  219. filename = Path(path.removeprefix("/view/")).name
  220. metadata = _page_metadata(filename)
  221. page_path = PAGES_DIR / filename
  222. if metadata is None or not page_path.is_file():
  223. self.send_error(404, "Visualization not found")
  224. return
  225. title, description = metadata
  226. fragment = page_path.read_text(encoding="utf-8")
  227. self._send_html(_document(title, fragment, subtitle=description))
  228. return
  229. super().do_GET()
  230. def _send_html(self, payload: bytes) -> None:
  231. self.send_response(200)
  232. self.send_header("Content-Type", "text/html; charset=utf-8")
  233. self.send_header("Content-Length", str(len(payload)))
  234. self.end_headers()
  235. self.wfile.write(payload)
  236. def log_message(self, format: str, *args: object) -> None:
  237. print(f"[visualization] {self.address_string()} - {format % args}")
  238. def main() -> None:
  239. parser = argparse.ArgumentParser(description=__doc__)
  240. parser.add_argument("--host", default="127.0.0.1")
  241. parser.add_argument("--port", type=int, default=8765)
  242. parser.add_argument("--open", action="store_true", dest="open_browser")
  243. args = parser.parse_args()
  244. mimetypes.add_type("image/jpeg", ".jpeg")
  245. server = ThreadingHTTPServer((args.host, args.port), VisualizationHandler)
  246. url_host = "localhost" if args.host in {"127.0.0.1", "0.0.0.0"} else args.host
  247. url = f"http://{url_host}:{server.server_port}/"
  248. print(f"SupplyAgent visualization server: {url}")
  249. if args.open_browser:
  250. threading.Timer(0.3, webbrowser.open, args=(url,)).start()
  251. try:
  252. server.serve_forever()
  253. except KeyboardInterrupt:
  254. pass
  255. finally:
  256. server.server_close()
  257. if __name__ == "__main__":
  258. main()