[split-upload 4/6] f2fd4fa backup via proxy
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
#!/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.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 = """<!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()
|
||||
Reference in New Issue
Block a user