| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- #!/usr/bin/env python3
- """Serve the SupplyAgent business-design visualizations locally."""
- from __future__ import annotations
- import argparse
- import html
- import mimetypes
- import threading
- import webbrowser
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
- from pathlib import Path
- from urllib.parse import unquote, urlparse
- VISUALIZATION_DIR = Path(__file__).resolve().parents[1]
- PAGES_DIR = VISUALIZATION_DIR / "pages"
- PAGES: tuple[tuple[str, str, str], ...] = (
- (
- "graph-structure-options.html",
- "01 · 图结构方案比较",
- "比较单一大树、自由图和树骨架关系图。",
- ),
- (
- "graph-structure-left-to-right.html",
- "02 · 从左到右的树图结构",
- "展示主题、分类树、具体节点、平台需求和证据的阅读方向。",
- ),
- (
- "business-graph-model.html",
- "03 · 业务图节点与边",
- "展示分类节点、需求元素、平台需求、关系和证据。",
- ),
- (
- "demand-feedback-loop.html",
- "04 · 需求—内容—表现闭环",
- "展示真实线上表现如何反向更新需求强度。",
- ),
- (
- "final-output-structure.html",
- "05 · 最终输出结构",
- "展示主题方向、平台需求和证据卡的组织方式。",
- ),
- (
- "node-drilldown-evidence-chain.html",
- "06 · 节点下钻证据链",
- "展示分类节点如何下挂元素、视频、短观点、长讨论并提取平台需求。",
- ),
- (
- "waiting-business-scope.html",
- "过程页 A · 业务范围确认",
- "对话过程中的可视化等待页。",
- ),
- (
- "waiting-demand-pipeline.html",
- "过程页 B · 需求流水线确认",
- "对话过程中的可视化等待页。",
- ),
- (
- "waiting-strength-lifecycle.html",
- "过程页 C · 强度生命周期确认",
- "对话过程中的可视化等待页。",
- ),
- )
- BASE_CSS = """
- * { box-sizing: border-box; }
- html, body { min-height: 100%; }
- body {
- margin: 0;
- color: #0f172a;
- background: #f5f7fb;
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", sans-serif;
- line-height: 1.5;
- }
- a { color: inherit; }
- .topbar {
- position: sticky;
- top: 0;
- z-index: 50;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 12px 24px;
- color: #e2e8f0;
- background: rgba(15, 23, 42, .96);
- border-bottom: 1px solid #334155;
- }
- .topbar a { text-decoration: none; }
- .topbar .brand { font-weight: 700; color: #fff; }
- .topbar .meta { color: #94a3b8; font-size: 13px; }
- .content {
- width: min(1480px, calc(100% - 32px));
- margin: 0 auto;
- padding: 28px 0 48px;
- }
- h1, h2, h3 { line-height: 1.25; }
- h2 { margin: 0 0 8px; font-size: 26px; }
- .subtitle { color: #64748b; margin: 0 0 20px; }
- .options { display: flex; flex-direction: column; gap: 12px; }
- .option, .card { cursor: pointer; }
- .option.selected, .card.selected {
- border-color: #2563eb !important;
- box-shadow: 0 0 0 3px rgba(37, 99, 235, .14) !important;
- }
- .gallery-title { margin: 4px 0 8px; font-size: 30px; }
- .gallery-intro { max-width: 880px; color: #64748b; margin: 0 0 24px; }
- .gallery {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
- gap: 16px;
- }
- .gallery-card {
- display: block;
- padding: 18px;
- text-decoration: none;
- background: #fff;
- border: 1px solid #dbe3ec;
- border-radius: 15px;
- transition: .18s ease;
- }
- .gallery-card:hover {
- transform: translateY(-2px);
- border-color: #60a5fa;
- box-shadow: 0 12px 28px rgba(15, 23, 42, .09);
- }
- .gallery-card h3 { margin: 0 0 7px; font-size: 17px; }
- .gallery-card p { margin: 0; color: #64748b; font-size: 13px; }
- .reference {
- margin-top: 24px;
- padding: 18px;
- border: 1px solid #dbe3ec;
- border-radius: 15px;
- background: #fff;
- }
- .reference img {
- display: block;
- width: min(100%, 650px);
- height: auto;
- margin-top: 12px;
- border: 1px solid #e2e8f0;
- }
- """
- def _page_metadata(filename: str) -> tuple[str, str] | None:
- for item_filename, title, description in PAGES:
- if filename == item_filename:
- return title, description
- return None
- def _document(title: str, body: str, *, subtitle: str = "") -> bytes:
- subtitle_html = (
- f'<div class="meta">{html.escape(subtitle)}</div>' if subtitle else ""
- )
- document = f"""<!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>{html.escape(title)} · SupplyAgent</title>
- <style>{BASE_CSS}</style>
- </head>
- <body>
- <header class="topbar">
- <a class="brand" href="/">SupplyAgent 业务可视化</a>
- {subtitle_html}
- </header>
- <main class="content">{body}</main>
- <script>
- window.toggleSelect = function (el) {{
- const container = el.closest('.options') || el.closest('.cards') || el.parentElement;
- if (container) {{
- container.querySelectorAll('[data-choice]').forEach(function (item) {{
- item.classList.remove('selected');
- }});
- }}
- el.classList.add('selected');
- }};
- </script>
- </body>
- </html>"""
- return document.encode("utf-8")
- def _gallery() -> bytes:
- cards = []
- for filename, title, description in PAGES:
- cards.append(
- f"""<a class="gallery-card" href="/view/{html.escape(filename)}">
- <h3>{html.escape(title)}</h3>
- <p>{html.escape(description)}</p>
- </a>"""
- )
- body = f"""
- <h1 class="gallery-title">SupplyAgent 业务框架可视化</h1>
- <p class="gallery-intro">
- 本目录保存需求汇总业务设计过程中形成的全部可视化材料。
- 正式业务结论以 README_myself.md 为准;图稿用于解释树、图、需求、内容和反馈之间的关系。
- </p>
- <section class="gallery">{"".join(cards)}</section>
- <section class="reference">
- <h2>用户提供的参考图</h2>
- <p class="subtitle">用于理解全局分类树、分类节点下挂元素,以及元素如何扩展成明确语言和长段讨论。</p>
- <a href="/assets/tree.jpeg" target="_blank" rel="noreferrer"><img src="/assets/tree.jpeg" alt="全局分类树局部参考图"></a>
- <a href="/assets/tree2.png" target="_blank" rel="noreferrer"><img src="/assets/tree2.png" alt="分类节点下挂元素参考图"></a>
- <a href="/assets/case.jpeg" target="_blank" rel="noreferrer"><img src="/assets/case.jpeg" alt="元素扩展为语言和讨论参考图"></a>
- </section>
- """
- return _document("可视化目录", body, subtitle="本地设计资料")
- class VisualizationHandler(SimpleHTTPRequestHandler):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, directory=str(VISUALIZATION_DIR), **kwargs)
- def do_GET(self) -> None: # noqa: N802
- path = unquote(urlparse(self.path).path)
- if path == "/":
- self._send_html(_gallery())
- return
- if path.startswith("/view/"):
- filename = Path(path.removeprefix("/view/")).name
- metadata = _page_metadata(filename)
- page_path = PAGES_DIR / filename
- if metadata is None or not page_path.is_file():
- self.send_error(404, "Visualization not found")
- return
- title, description = metadata
- fragment = page_path.read_text(encoding="utf-8")
- self._send_html(_document(title, fragment, subtitle=description))
- return
- super().do_GET()
- def _send_html(self, payload: bytes) -> None:
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(payload)))
- self.end_headers()
- self.wfile.write(payload)
- def log_message(self, format: str, *args: object) -> None:
- print(f"[visualization] {self.address_string()} - {format % args}")
- def main() -> None:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--host", default="127.0.0.1")
- parser.add_argument("--port", type=int, default=8765)
- parser.add_argument("--open", action="store_true", dest="open_browser")
- args = parser.parse_args()
- mimetypes.add_type("image/jpeg", ".jpeg")
- server = ThreadingHTTPServer((args.host, args.port), VisualizationHandler)
- url_host = "localhost" if args.host in {"127.0.0.1", "0.0.0.0"} else args.host
- url = f"http://{url_host}:{server.server_port}/"
- print(f"SupplyAgent visualization server: {url}")
- if args.open_browser:
- threading.Timer(0.3, webbrowser.open, args=(url,)).start()
- try:
- server.serve_forever()
- except KeyboardInterrupt:
- pass
- finally:
- server.server_close()
- if __name__ == "__main__":
- main()
|