Files
wecom_it_smart_desk/scripts/build_kanban_html.py
T
2026-08-11 14:17:15 +08:00

450 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <path> --out <path>
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.0pip 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 = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
{css}
</style>
</head>
<body>
<div class="container">
<header>
<h1>📋 {title}</h1>
<div class="meta">
<span>📅 <strong>{update_time}</strong>GMT+8</span>
<span>📂 源文件:<strong>{md_relpath}</strong></span>
<span>🤖 生成:<strong>Duckula</strong> · {build_method}</span>
</div>
<span class="version-badge {badge_class}">{version_label}</span>
</header>
{toc_html}
<main>
{body}
</main>
<footer>
本页面由 <code>{script_name}</code> 自动生成于 {gen_time} (GMT+8) · 数据源:{md_relpath}<br>
修改流程:编辑 .md → 跑脚本生成 .html → (可选)部署到 jumpserver → commit & push
</footer>
</div>
</body>
</html>
"""
# ============================================================
# 解析版本徽章
# ============================================================
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 库
实际写入 <h2 id="..."> 的 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'<li style="margin-left:{(lvl - 2) * 16}px">'
f'<a href="#{anchor}">{text}</a>{children_html}</li>'
)
return "".join(out)
items_html = walk(toc_tokens)
return (
'<nav class="toc" id="toc"><div class="toc-title">📑 目录</div><ul>'
+ items_html
+ "</ul></nav>"
)
# ============================================================
# 主流程
# ============================================================
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()