#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ build_kanban_html.py — 项目状态看板 Markdown → HTML 单一源构建脚本 ===================================================================== 说明:将 docs/07-项目管理/项目状态看板.md 转为同目录 .html, 内嵌统一样式(accent #07C160 企微绿),零外部 CDN 依赖, 适合 jumpserver nginx 静态发布或本地离线查看。 用法: python scripts/build_kanban_html.py # 默认生成 python scripts/build_kanban_html.py --watch # 文件变化自动重建 python scripts/build_kanban_html.py --md --out python scripts/build_kanban_html.py --check # 仅校验(不写文件) 原理: 1. 读取 .md 2. python-markdown 转 body(启用 extra + tables + toc 扩展) 3. 嵌入统一 CSS(看板专用精简样式,复用巡检报告 accent 色板) 4. 顶部 header(项目名 + 版本徽章 + 最后更新时间) 5. 写入同目录 .html 依赖:markdown>=3.0(pip install markdown) ===================================================================== """ import argparse import re import sys import time from pathlib import Path from datetime import datetime, timezone, timedelta try: import markdown except ImportError: sys.exit("❌ 缺少依赖 `markdown`,请先安装:pip install markdown") try: from bs4 import BeautifulSoup except ImportError: sys.exit("❌ 缺少依赖 `beautifulsoup4`,请先安装:pip install beautifulsoup4") # ============================================================ # 路径常量 # ============================================================ SCRIPT_DIR = Path(__file__).resolve().parent PROJECT_DIR = SCRIPT_DIR.parent DEFAULT_MD = PROJECT_DIR / "docs" / "07-项目管理" / "项目状态看板.md" DEFAULT_HTML = DEFAULT_MD.with_suffix(".html") # ============================================================ # 样式(复用看板色板) # ============================================================ KANBAN_CSS = r""" :root { --accent: #07C160; --accent-soft: #E7F8EF; --warn: #FA9D3B; --danger: #FA5151; --info: #10AEFF; --text: #1F2329; --text-soft: #646A73; --bg: #FFFFFF; --bg-soft: #F7F8FA; --border: #DEE0E3; --shadow: 0 2px 12px rgba(0,0,0,0.06); --radius: 8px; } * { box-sizing: border-box; margin: 0; padding: 0; } html { scroll-behavior: smooth; scroll-padding-top: 20px; } body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--text); background: var(--bg-soft); line-height: 1.7; font-size: 14px; } .container { max-width: 1080px; margin: 0 auto; padding: 24px; } header { background: linear-gradient(135deg, var(--accent) 0%, #06AD56 100%); color: #fff; padding: 24px 28px; border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 24px; position: relative; } header h1 { font-size: 24px; font-weight: 600; margin-bottom: 6px; } header .meta { font-size: 13px; opacity: 0.92; } header .meta span { margin-right: 18px; } header .meta strong { font-weight: 600; } .version-badge { position: absolute; top: 24px; right: 28px; background: rgba(255,255,255,0.25); padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; backdrop-filter: blur(8px); } .version-badge.draft { background: rgba(250,157,59,0.95); } .version-badge.frozen { background: rgba(255,255,255,0.95); color: var(--accent); } h1, h2, h3, h4 { color: var(--text); margin: 28px 0 12px; line-height: 1.4; } h1 { font-size: 22px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); } h2 { font-size: 18px; padding-left: 10px; border-left: 4px solid var(--accent); } h3 { font-size: 16px; color: var(--text-soft); } h4 { font-size: 14px; color: var(--text-soft); } p { margin: 8px 0; } strong { color: var(--text); } blockquote { background: var(--accent-soft); border-left: 4px solid var(--accent); padding: 10px 16px; margin: 12px 0; border-radius: 4px; color: var(--text); } blockquote p { margin: 4px 0; } code { background: var(--bg-soft); padding: 2px 6px; border-radius: 3px; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; color: #C7254E; } pre { background: #1F2329; color: #E7E8EB; padding: 14px 18px; border-radius: var(--radius); overflow-x: auto; margin: 12px 0; } pre code { background: transparent; color: inherit; padding: 0; font-size: 13px; } hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; } table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); margin: 12px 0; font-size: 13px; } thead { background: var(--bg-soft); } th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } th { font-weight: 600; color: var(--text-soft); font-size: 12px; } tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background: var(--accent-soft); } ul, ol { padding-left: 24px; margin: 8px 0; } li { margin: 4px 0; } a { color: var(--accent); text-decoration: none; border-bottom: 1px dashed var(--accent); } a:hover { background: var(--accent-soft); } footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-soft); font-size: 12px; text-align: center; } .toc { background: #fff; padding: 12px 20px; border-radius: var(--radius); box-shadow: var(--shadow); margin: 16px 0 24px; font-size: 13px; } .toc-title { font-weight: 600; color: var(--text-soft); margin-bottom: 8px; } .toc ul { padding-left: 20px; list-style: none; } .toc li { margin: 4px 0; } .toc a { color: var(--text); border-bottom: none; display: block; padding: 4px 8px; border-radius: 4px; transition: all 0.15s ease; } .toc a:hover { background: var(--accent-soft); color: var(--accent); transform: translateX(2px); } :target { background: var(--accent-soft); transition: background 0.5s ease; } /* 标题旁的"📑 回到目录"小链接 */ .back-to-toc { display: inline-block; font-size: 12px; margin-left: 10px; padding: 2px 6px; border-radius: 4px; border-bottom: none; opacity: 0.25; transition: all 0.15s ease; vertical-align: middle; } .back-to-toc:hover { opacity: 1; background: var(--accent-soft); color: var(--accent); } .print-only { display: none; } @media print { header { background: var(--accent) !important; -webkit-print-color-adjust: exact; } body { background: #fff; } .toc, .filter-bar { display: none; } table { page-break-inside: avoid; } } """ HTML_TEMPLATE = """ {title}

📋 {title}

📅 {update_time}(GMT+8) 📂 源文件:{md_relpath} 🤖 生成:Duckula · {build_method}
{version_label}
{toc_html}
{body}
本页面由 {script_name} 自动生成于 {gen_time} (GMT+8) · 数据源:{md_relpath}
修改流程:编辑 .md → 跑脚本生成 .html → (可选)部署到 jumpserver → commit & push
""" # ============================================================ # 解析版本徽章 # ============================================================ def parse_version(md_text: str): """从 markdown 第一段引用块提取版本信息。""" version_label = "v?.?" badge_class = "draft" update_time = "未指定" # 抓首个引用块 m = re.search(r"^>\s*\*\*版本\*\*[::]\s*([^\n]+)", md_text, re.MULTILINE) if m: v = m.group(1).strip() version_label = v.split("|")[0].strip() if "FROZEN" in v.upper(): badge_class = "frozen" elif "DRAFT" in v.upper(): badge_class = "draft" m = re.search(r"\*\*更新日期\*\*[::]\s*([^\n(]+)", md_text) if m: update_time = m.group(1).strip() return version_label, badge_class, update_time # ============================================================ # MD → HTML body # ============================================================ def md_to_body(md_text: str) -> str: md = markdown.Markdown( extensions=[ "extra", # 表格、脚注、缩写、定义列表 "tables", "fenced_code", "toc", "sane_lists", "nl2br", ], extension_configs={ "toc": {"permalink": False, "toc_depth": "2-3"}, }, ) return md.convert(md_text), md.toc_tokens def add_back_to_toc_links(body_html: str) -> str: """给所有 h2/h3 标题后面追加一个 📑 小链接,指向 #toc 回到目录。 h1 是看板标题本身,不加(已在目录里)。""" soup = BeautifulSoup(body_html, "html.parser") for h in soup.find_all(["h2", "h3"]): if h.get("id") in (None, ""): continue a = soup.new_tag( "a", href="#toc", attrs={ "class": "back-to-toc", "title": "回到目录", }, ) a.string = "📑" h.append(" ") h.append(a) return str(soup) # ============================================================ # 渲染 TOC(必须使用 markdown 库实际生成的 id,不要自己算) # ============================================================ def render_toc(toc_tokens) -> str: """toc_tokens 中每项含 id/name/level/children 四个字段,id 是 markdown 库 实际写入

的 slug,必须直接使用,否则点击跳转失效。""" if not toc_tokens: return "" items = [] def walk(tok_list): out = [] for tok in tok_list: anchor = tok.get("id", "") text = tok.get("name", "") lvl = tok.get("level", 2) if not anchor or not text: continue children_html = walk(tok.get("children", [])) out.append( f'
  • ' f'{text}{children_html}
  • ' ) return "".join(out) items_html = walk(toc_tokens) return ( '" ) # ============================================================ # 主流程 # ============================================================ def build(md_path: Path, html_path: Path, build_method: str = "scripts/build_kanban_html.py"): if not md_path.exists(): sys.exit(f"❌ 源文件不存在:{md_path}") md_text = md_path.read_text(encoding="utf-8") body, toc_tokens = md_to_body(md_text) body = add_back_to_toc_links(body) # 给每个 h2/h3 加"📑 回到目录"链接 version_label, badge_class, update_time = parse_version(md_text) title = "项目状态看板 · IT 智能服务台" if "v1.8.0-FROZEN" in version_label: title = f"项目状态看板 · IT 智能服务台 · {version_label}" html = HTML_TEMPLATE.format( title=title, css=KANBAN_CSS, toc_html=render_toc(toc_tokens), body=body, version_label=version_label, badge_class=badge_class, update_time=update_time, md_relpath=md_path.relative_to(PROJECT_DIR).as_posix(), script_name=Path(__file__).name, gen_time=datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M"), build_method=build_method, ) html_path.write_text(html, encoding="utf-8") print(f"✅ 已生成:{html_path}") print(f" 版本:{version_label}({badge_class})") print(f" 大小:{len(html):,} chars / {len(html_text_lines(html))} lines") return html_path def html_text_lines(html: str) -> list: return html.splitlines() def check(md_path: Path): """仅校验 .md 是否能正常转 HTML,不写文件。""" if not md_path.exists(): sys.exit(f"❌ 源文件不存在:{md_path}") md_text = md_path.read_text(encoding="utf-8") body, toc = md_to_body(md_text) print(f"✅ MD 解析成功:{len(md_text):,} chars → {len(body):,} chars HTML body") print(f" TOC 项数:{len(toc)}") return True def watch(md_path: Path, html_path: Path): """简易 watch 模式:每 2 秒检查一次 mtime。""" print(f"👀 Watch 模式:监控 {md_path}") last_mtime = md_path.stat().st_mtime try: while True: time.sleep(2) cur_mtime = md_path.stat().st_mtime if cur_mtime != last_mtime: last_mtime = cur_mtime print(f"\n🔄 检测到变化 @ {datetime.now().strftime('%H:%M:%S')}") build(md_path, html_path, build_method="watch") except KeyboardInterrupt: print("\n👋 Watch 结束") # ============================================================ # CLI # ============================================================ def main(): p = argparse.ArgumentParser(description="项目状态看板 Markdown → HTML 构建脚本") p.add_argument("--md", type=Path, default=DEFAULT_MD, help=f"源 .md 路径(默认:{DEFAULT_MD.name})") p.add_argument("--out", type=Path, default=DEFAULT_HTML, help="输出 .html 路径(默认:同目录同名 .html)") p.add_argument("--watch", action="store_true", help="Watch 模式:文件变化自动重建") p.add_argument("--check", action="store_true", help="仅校验 MD 语法,不写文件") args = p.parse_args() if args.check: check(args.md) elif args.watch: watch(args.md, args.out) else: build(args.md, args.out) if __name__ == "__main__": main()