冲突解式:与伏击退役/格面资源八型批(3468066)双向共存—— - status_effects.tres:attack_up/ac_up refresh(本批)+ ambush 删行/alert 进(八型批) - 增益状态 v0.11(八型,原分支报 v0.8 旧基线编号顺延)/v0.12(本批续时) - registry:alert 行 + attack_up/ac_up refresh 两行并存 - active.md 台账 101(伏击/八型)+102(本批,让号) - 合并后冒烟 355 项 0 失败三闸全过
526 lines
23 KiB
Python
526 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
r"""loop_dashboard.py — 迭代闭环仪表盘生成器
|
||
|
||
用法:
|
||
python tools/loop_dashboard.py # 生成 production/loop-dashboard.html + 控制台摘要
|
||
python tools/loop_dashboard.py --open # 生成后用系统默认浏览器打开
|
||
python tools/loop_dashboard.py --out <path>
|
||
|
||
定位: 项目开发循环 = 「完善文档 → 更新垂直切片游玩体验 → 再完善文档」。
|
||
闭环状态分散在六处——active.md 快照节 / 台账 / GDD 版本史 / 06-待办清单 /
|
||
07-体验验证清单 / git 在飞改动——本工具把六处聚合成一个只读视图,每次迭代
|
||
收口后重跑一次即可看清: 走到哪了、什么在飞、什么待拍板、什么验证项已落后于文档改动。
|
||
|
||
只读工具: 不改任何被扫描的文件,产物只有仪表盘 HTML 本身。
|
||
|
||
解析约定(跟随仓库现行格式,格式变了这里要跟着改):
|
||
- active.md 台账区 = 文件最后一个二级标题之后(新条目追加在文件末尾)
|
||
- GDD 版本 = 「版本历史」表中版本号最大的一行(行按时间追加)
|
||
- 06-待办清单条目 = 四级标题,状态 = 其下「**状态**:」行
|
||
- 07-体验验证清单头部「当前批次来源」行内引用的 vX.Y 与 GDD 现行版本比对,
|
||
得出「体验验证清单落后于文档改动」的失效面
|
||
"""
|
||
import argparse
|
||
import html
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from collections import Counter, OrderedDict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
GDD_DIR = ROOT / "design" / "gdd"
|
||
ACTIVE_MD = ROOT / "production" / "session-state" / "active.md"
|
||
ROADMAP_MD = ROOT / "production" / "roadmap.md"
|
||
TODO_MD = GDD_DIR / "06-待办清单.md"
|
||
VERIFY_MD = GDD_DIR / "07-体验验证清单.md"
|
||
STAGE_TXT = ROOT / "production" / "stage.txt"
|
||
DEFAULT_OUT = ROOT / "production" / "loop-dashboard.html"
|
||
|
||
LOOP_ORDER = ["顶层文档", "排雷循环", "战斗循环", "冒险循环", "表现与支撑"]
|
||
|
||
# 状态分类前缀 → 桶。解决即移除(2026-09-18 新口径)下「已解决/已裁决」残留 = 清理债。
|
||
RESOLVED_PREFIX = ("已解决", "已裁决")
|
||
PARTIAL_PREFIX = ("部分收口", "部分清偿", "已部分收口", "已部分清偿")
|
||
OPEN_PREFIX = ("待办",)
|
||
DECIDED_PREFIX = ("方向已拍板", "已拍板")
|
||
|
||
|
||
# ---------------------------------------------------------------- git 侧
|
||
|
||
def git(args):
|
||
try:
|
||
return subprocess.run(
|
||
["git", "-c", "core.quotepath=false"] + args,
|
||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
cwd=ROOT, check=True,
|
||
).stdout
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
return ""
|
||
|
||
|
||
def classify_path(rel: str):
|
||
rel = rel.replace("\\", "/")
|
||
if rel.startswith("design/gdd/"):
|
||
return "GDD 文档"
|
||
if rel.startswith("design/"):
|
||
return "设计侧其他(registry/ux/art/research)"
|
||
if rel.startswith("prototypes/"):
|
||
if rel.endswith(".gd"):
|
||
return "切片代码"
|
||
if rel.endswith(".tres"):
|
||
return "数据表"
|
||
return "切片其他"
|
||
if rel.startswith("tools/"):
|
||
return "工具"
|
||
if rel.startswith("production/"):
|
||
return "会话产物"
|
||
return "其他"
|
||
|
||
|
||
def scan_git():
|
||
dirty, untracked = [], []
|
||
for line in git(["status", "--porcelain"]).splitlines():
|
||
if not line.strip():
|
||
continue
|
||
state, path = line[:2], line[3:].strip().strip('"')
|
||
if state.startswith("??"):
|
||
untracked.append(path)
|
||
else:
|
||
dirty.append((state.strip() or "M", path))
|
||
log = [l for l in git(["log", "--oneline", "-5"]).splitlines() if l.strip()]
|
||
grouped = Counter(classify_path(p) for _, p in dirty)
|
||
gdd_dirty = [p for s, p in dirty if classify_path(p) == "GDD 文档"]
|
||
return {
|
||
"dirty": dirty, "untracked": untracked, "log": log,
|
||
"grouped": grouped, "gdd_dirty": gdd_dirty,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- GDD 清单
|
||
|
||
VERSION_ROW_RE = re.compile(r"^\|\s*v?(\d+)\.(\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})")
|
||
|
||
|
||
def parse_doc(path: Path):
|
||
try:
|
||
text = path.read_text(encoding="utf-8")
|
||
except UnicodeDecodeError:
|
||
text = path.read_text(encoding="utf-8", errors="replace")
|
||
title = next((l.lstrip("# ").strip() for l in text.splitlines() if l.startswith("# ")), path.stem)
|
||
rel = path.relative_to(ROOT).as_posix()
|
||
parts = path.relative_to(GDD_DIR).parts
|
||
group = parts[0] if len(parts) > 1 else "顶层文档"
|
||
version, vdate = None, None
|
||
in_hist = False
|
||
for line in text.splitlines():
|
||
if line.startswith("## "):
|
||
in_hist = "版本历史" in line
|
||
continue
|
||
if in_hist:
|
||
m = VERSION_ROW_RE.match(line)
|
||
if m:
|
||
v = (int(m.group(1)), int(m.group(2)))
|
||
if version is None or v > version:
|
||
version, vdate = v, m.group(3)
|
||
mtime = datetime.fromtimestamp(path.stat().st_mtime).strftime("%m-%d %H:%M")
|
||
return {
|
||
"path": rel, "name": path.stem, "title": title, "group": group,
|
||
"version": f"v{version[0]}.{version[1]}" if version else "—",
|
||
"vdate": vdate or "—", "mtime": mtime,
|
||
}
|
||
|
||
|
||
def scan_gdd(git_info):
|
||
dirty_set = {p.replace("\\", "/") for p in git_info["gdd_dirty"]}
|
||
docs = [parse_doc(p) for p in sorted(GDD_DIR.rglob("*.md"))]
|
||
for d in docs:
|
||
d["dirty"] = d["path"] in dirty_set
|
||
return docs
|
||
|
||
|
||
# ---------------------------------------------------------------- active.md
|
||
|
||
LEDGER_ENTRY_RE = re.compile(r"^(\d+)\.\s+\*\*")
|
||
|
||
|
||
def scan_active():
|
||
info = {"snapshot_lines": [], "updated_line": "", "next_line": "",
|
||
"entries": [], "collisions": [], "next_number": None, "tail_head": ""}
|
||
if not ACTIVE_MD.exists():
|
||
info["error"] = "active.md 不存在"
|
||
return info
|
||
lines = ACTIVE_MD.read_text(encoding="utf-8").splitlines()
|
||
# 快照节(标题带纪律后缀,按关键词匹配)
|
||
in_snap = False
|
||
for line in lines:
|
||
if line.startswith("## "):
|
||
in_snap = "当前状态快照" in line
|
||
if in_snap:
|
||
continue
|
||
if in_snap and line.strip():
|
||
info["snapshot_lines"].append(line.strip())
|
||
for ln in info["snapshot_lines"]:
|
||
if ln.startswith("- **更新于**"):
|
||
info["updated_line"] = ln
|
||
if ln.startswith("- **下一步顺序**"):
|
||
info["next_line"] = ln
|
||
# 台账区 = 最后一个二级标题之后
|
||
last_h2 = max((i for i, l in enumerate(lines) if l.startswith("## ")), default=-1)
|
||
tail = lines[last_h2 + 1:]
|
||
info["tail_head"] = lines[last_h2].lstrip("# ").strip() if last_h2 >= 0 else ""
|
||
numbers = []
|
||
for line in tail:
|
||
m = LEDGER_ENTRY_RE.match(line)
|
||
if m:
|
||
numbers.append(int(m.group(1)))
|
||
info["entries"] = numbers
|
||
seen = Counter(numbers)
|
||
info["collisions"] = sorted(n for n, c in seen.items() if c > 1)
|
||
if numbers:
|
||
info["next_number"] = max(numbers) + 1
|
||
return info
|
||
|
||
|
||
# ---------------------------------------------------------------- 06 待办清单
|
||
|
||
def scan_todo():
|
||
items = []
|
||
if not TODO_MD.exists():
|
||
return items
|
||
loop = system = ""
|
||
cur = None
|
||
for line in TODO_MD.read_text(encoding="utf-8").splitlines():
|
||
if line.startswith("## "):
|
||
loop, system = line.lstrip("# ").strip(), ""
|
||
elif line.startswith("### "):
|
||
system = line.lstrip("# ").strip()
|
||
elif line.startswith("#### "):
|
||
cur = {
|
||
"loop": loop, "system": system,
|
||
"title": line.lstrip("# ").strip(),
|
||
"status": "",
|
||
}
|
||
items.append(cur)
|
||
elif cur is not None and line.startswith("**状态**"):
|
||
cur["status"] = line.replace("**状态**", "").lstrip(":: ").strip()
|
||
cur = None
|
||
for it in items:
|
||
s = it["status"]
|
||
if s.startswith(RESOLVED_PREFIX):
|
||
it["bucket"] = "解决态残留"
|
||
elif s.startswith(PARTIAL_PREFIX):
|
||
it["bucket"] = "部分收口"
|
||
elif s.startswith(OPEN_PREFIX):
|
||
it["bucket"] = "待办"
|
||
elif s.startswith(DECIDED_PREFIX):
|
||
it["bucket"] = "拍板待执行"
|
||
else:
|
||
it["bucket"] = "其他"
|
||
return items
|
||
|
||
|
||
# ---------------------------------------------------------------- 07 体验验证清单
|
||
|
||
VERSION_REF_RE = re.compile(r"(战斗结算|增益状态|地图战斗资源|敌人系统|行动经济|棋盘生成|排雷玩法|难度曲线与Boss|元进度|关卡节点地图|HUD框架|战斗表现|数据配置|存档序列化|音频|教学引导|设置与本地化|地形变异|冒险结构|魔法道具)\s*(v\d+\.\d+)")
|
||
|
||
|
||
def scan_verify(docs):
|
||
info = {"items": [], "obligations": [], "source_line": "", "refs": [], "error": None}
|
||
if not VERIFY_MD.exists():
|
||
info["error"] = "07-体验验证清单.md 不存在"
|
||
return info
|
||
text = VERIFY_MD.read_text(encoding="utf-8")
|
||
lines = text.splitlines()
|
||
section = ""
|
||
for line in lines:
|
||
if line.startswith("## "):
|
||
section = line.lstrip("# ").strip()
|
||
continue
|
||
if line.startswith("### ") and "观测项" in section:
|
||
info["items"].append(line.lstrip("# ").strip())
|
||
elif line.startswith("- ") and "重测批义务" in section:
|
||
info["obligations"].append(line[2:].strip())
|
||
m = re.search(r"当前批次来源:(.+)", text)
|
||
if m:
|
||
info["source_line"] = m.group(1).strip()
|
||
by_name = {d["name"]: d for d in docs}
|
||
for name, ver in VERSION_REF_RE.findall(info["source_line"]):
|
||
cur = by_name.get(name)
|
||
info["refs"].append({
|
||
"doc": name, "ref": ver, "current": cur["version"] if cur else "?",
|
||
"stale": bool(cur and cur["version"] != "—" and cur["version"] != ver),
|
||
})
|
||
return info
|
||
|
||
|
||
# ---------------------------------------------------------------- 路线图
|
||
|
||
def scan_roadmap():
|
||
steps = []
|
||
if not ROADMAP_MD.exists():
|
||
return steps
|
||
in_route = False
|
||
for line in ROADMAP_MD.read_text(encoding="utf-8").splitlines():
|
||
if line.startswith("## "):
|
||
in_route = "路线图" in line and "维护" not in line
|
||
continue
|
||
if in_route and re.match(r"^\d+\.\s", line):
|
||
steps.append(line.strip())
|
||
return steps
|
||
|
||
|
||
# ---------------------------------------------------------------- 控制台摘要
|
||
|
||
def print_summary(stage, git_info, docs, active, todos, verify, out_path):
|
||
def p(s=""):
|
||
print(s)
|
||
p("═" * 62)
|
||
p(f"迭代闭环仪表盘 · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 阶段: {stage}")
|
||
p("═" * 62)
|
||
if active.get("updated_line"):
|
||
p(active["updated_line"].lstrip("- "))
|
||
g = git_info["grouped"]
|
||
p(f"在飞改动: {len(git_info['dirty'])} 文件(" +
|
||
" / ".join(f"{k} {v}" for k, v in g.most_common()) +
|
||
f")+ 未跟踪 {len(git_info['untracked'])}")
|
||
dirty_docs = [d for d in docs if d["dirty"]]
|
||
versioned = [d for d in docs if d["version"] != "—"]
|
||
newest = max(versioned, key=lambda d: d["vdate"], default=None)
|
||
p(f"GDD: {len(docs)} 份,其中在飞 {len(dirty_docs)} 份"
|
||
+ (f";最近改版 {newest['name']} {newest['version']}({newest['vdate']})" if newest else ""))
|
||
buckets = Counter(t["bucket"] for t in todos)
|
||
p(f"待办清单: {len(todos)} 条(待办 {buckets.get('待办', 0)} / 部分收口 {buckets.get('部分收口', 0)}"
|
||
f" / 拍板待执行 {buckets.get('拍板待执行', 0)} / 解决态残留 {buckets.get('解决态残留', 0)}"
|
||
f"←按「解决即移除」口径应移除)")
|
||
p(f"体验验证: {len(verify['items'])} 项待真人观测")
|
||
if active["collisions"]:
|
||
p(f"台账: 最新编号 {active['entries'][-1]},下一编号建议 {active['next_number']}"
|
||
f"(撞号: {', '.join(str(n) for n in active['collisions'])})")
|
||
else:
|
||
p(f"台账: 最新编号 {active['entries'][-1] if active['entries'] else '—'},下一编号建议 {active['next_number']}")
|
||
stale = [r for r in verify["refs"] if r["stale"]]
|
||
if stale:
|
||
p("⚠ 07-体验验证清单引用版本已落后:")
|
||
for r in stale:
|
||
p(f" {r['doc']}: 07 引 {r['ref']} / 现行 {r['current']}")
|
||
if active.get("next_line"):
|
||
p(active["next_line"].lstrip("- "))
|
||
p(f"已生成: {out_path.relative_to(ROOT).as_posix()}")
|
||
p("═" * 62)
|
||
|
||
|
||
# ---------------------------------------------------------------- HTML
|
||
|
||
CSS = """
|
||
:root{--bg:#14161a;--card:#1d2026;--line:#2c3038;--tx:#d8dce3;--dim:#8b919c;
|
||
--acc:#5b9dd9;--warn:#d9a05b;--bad:#d95b6a;--ok:#6fbf7f;}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--tx);
|
||
font-family:"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;font-size:14px;line-height:1.6}
|
||
main{max-width:1080px;margin:0 auto;padding:24px 20px 60px}
|
||
h1{font-size:22px;margin:0 0 4px}
|
||
h2{font-size:17px;margin:36px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
|
||
.meta{color:var(--dim);font-size:12.5px;margin-bottom:8px}
|
||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
|
||
@media(max-width:800px){.grid{grid-template-columns:1fr}}
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 16px}
|
||
.card h3{margin:0 0 8px;font-size:14px;color:var(--acc)}
|
||
.card ul{margin:0;padding-left:18px}
|
||
.card li{margin:3px 0}
|
||
table{width:100%;border-collapse:collapse;background:var(--card);
|
||
border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px}
|
||
th,td{padding:6px 10px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||
th{color:var(--dim);font-weight:600;background:#22252c}
|
||
tr:last-child td{border-bottom:none}
|
||
.dirty{color:var(--warn);font-weight:600}
|
||
.tag{display:inline-block;padding:0 8px;border-radius:10px;font-size:12px;margin-right:6px}
|
||
.tag.open{background:#2b3a4a;color:#9cc7ec}
|
||
.tag.partial{background:#463d2b;color:#e0c08a}
|
||
.tag.decided{background:#3a3350;color:#c3b3ec}
|
||
.tag.resolved{background:#3c2b30;color:#e0999f}
|
||
.stale{color:var(--bad);font-weight:600}
|
||
.ok{color:var(--ok)}
|
||
.dim{color:var(--dim)}
|
||
footer{margin-top:40px;color:var(--dim);font-size:12px;border-top:1px solid var(--line);padding-top:12px}
|
||
code{background:#262a31;padding:1px 5px;border-radius:4px;font-size:12.5px}
|
||
"""
|
||
|
||
|
||
def esc(s):
|
||
return html.escape(str(s))
|
||
|
||
|
||
def bucket_tag(bucket):
|
||
cls = {"待办": "open", "部分收口": "partial", "拍板待执行": "decided", "解决态残留": "resolved"}.get(bucket, "partial")
|
||
return f'<span class="tag {cls}">{esc(bucket)}</span>'
|
||
|
||
|
||
def render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path):
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
buckets = Counter(t["bucket"] for t in todos)
|
||
dirty_docs = [d for d in docs if d["dirty"]]
|
||
stale_refs = [r for r in verify["refs"] if r["stale"]]
|
||
|
||
# 快照节
|
||
snap = "".join(f"<li>{esc(l.lstrip('- '))}</li>" for l in active["snapshot_lines"])
|
||
# 近期提交
|
||
commits = "".join(f"<li><code>{esc(l.split()[0])}</code> {esc(l[l.index(' ')+1:][:90])}</li>" for l in git_info["log"])
|
||
# 在飞分组
|
||
fly_rows = "".join(
|
||
f"<tr><td>{esc(k)}</td><td>{v}</td></tr>" for k, v in git_info["grouped"].most_common())
|
||
untracked = "".join(f"<li>{esc(u)}</li>" for u in git_info["untracked"]) or "<li class='dim'>无</li>"
|
||
# GDD 清单
|
||
doc_rows = []
|
||
for group in LOOP_ORDER:
|
||
group_docs = [d for d in docs if d["group"] == group]
|
||
if not group_docs:
|
||
continue
|
||
doc_rows.append(
|
||
f"<tr><td colspan='5' class='dim' style='background:#22252c'>{esc(group)}({len(group_docs)})</td></tr>")
|
||
for d in sorted(group_docs, key=lambda x: x["mtime"], reverse=True):
|
||
mark = ' <span class="dirty">在飞</span>' if d["dirty"] else ""
|
||
doc_rows.append(
|
||
f"<tr><td>{esc(d['name'])}{mark}</td>"
|
||
f"<td>{esc(d['version'])}</td><td class='dim'>{esc(d['vdate'])}</td>"
|
||
f"<td class='dim'>{esc(d['mtime'])}</td>"
|
||
f"<td class='dim'>{esc(d['title'][:40])}</td></tr>")
|
||
# 06 待办
|
||
todo_by_loop = OrderedDict((g, []) for g in LOOP_ORDER)
|
||
for t in todos:
|
||
todo_by_loop.setdefault(t["loop"], []).append(t)
|
||
todo_cards = []
|
||
for loop, items in todo_by_loop.items():
|
||
if not items:
|
||
continue
|
||
open_n = sum(1 for i in items if i["bucket"] in ("待办", "部分收口", "拍板待执行"))
|
||
lis = "".join(
|
||
f"<li>{bucket_tag(i['bucket'])}{esc(i['system'])} · {esc(i['title'][:46])}</li>"
|
||
for i in items if i["bucket"] != "解决态残留")
|
||
resolved = [i for i in items if i["bucket"] == "解决态残留"]
|
||
res_note = (f"<p class='dim' style='margin:8px 0 0'>另有 {len(resolved)} 条解决态残留"
|
||
f"(按「解决即移除」口径待清)</p>" if resolved else "")
|
||
todo_cards.append(
|
||
f"<div class='card'><h3>{esc(loop)} · 开放 {open_n}/{len(items)}</h3>"
|
||
f"<ul>{lis}</ul>{res_note}</div>")
|
||
resolved_all = [t for t in todos if t["bucket"] == "解决态残留"]
|
||
# 循环级计数(四问卡片②的汇总表)
|
||
loop_rows = ""
|
||
for loop, items in todo_by_loop.items():
|
||
if not items:
|
||
continue
|
||
open_n = sum(1 for i in items if i["bucket"] in ("待办", "部分收口", "拍板待执行"))
|
||
loop_rows += f"<tr><td>{esc(loop)}</td><td>{open_n}</td><td>{len(items)}</td></tr>"
|
||
# 07
|
||
verify_items = "".join(f"<li>{esc(i)}</li>" for i in verify["items"]) or "<li class='dim'>—</li>"
|
||
obligations = "".join(f"<li>{esc(o)}</li>" for o in verify["obligations"]) or "<li class='dim'>—</li>"
|
||
ref_rows = ""
|
||
for r in verify["refs"]:
|
||
verdict = '<span class="stale">已落后</span>' if r["stale"] else '<span class="ok">一致</span>'
|
||
ref_rows += (f"<tr><td>{esc(r['doc'])}</td><td>{esc(r['ref'])}</td><td>{esc(r['current'])}</td>"
|
||
f"<td>{verdict}</td></tr>")
|
||
if not ref_rows:
|
||
ref_rows = "<tr><td colspan='4' class='dim'>07 头部未解析出文档版本引用</td></tr>"
|
||
# 台账健康
|
||
if active["entries"]:
|
||
coll = (f"检出撞号 {', '.join(str(n) for n in active['collisions'])}(编号有效、历史不改写)"
|
||
if active["collisions"] else "无撞号")
|
||
ledger_html = (f"<p>台账区(「{esc(active['tail_head'])}」之后)最新编号 "
|
||
f"<b>{active['entries'][-1]}</b>,下一编号建议 <b>{active['next_number']}</b>;{esc(coll)}。</p>")
|
||
else:
|
||
ledger_html = "<p class='dim'>未解析出台账条目</p>"
|
||
# 路线图
|
||
route = "".join(f"<li>{esc(s)}</li>" for s in roadmap) or "<li class='dim'>—</li>"
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>迭代闭环仪表盘 · Dungeon Sweeper</title>
|
||
<style>{CSS}</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>迭代闭环仪表盘</h1>
|
||
<div class="meta">阶段: {esc(stage)} · 生成于 {now} · 只读视图,重跑 <code>python tools/loop_dashboard.py</code> 刷新</div>
|
||
|
||
<h2>当前状态快照(active.md)</h2>
|
||
<div class="card"><ul>{snap}</ul></div>
|
||
|
||
<h2>迭代闭环四问</h2>
|
||
<div class="grid">
|
||
<div class="card"><h3>① 什么在飞(git 未提交 {len(git_info['dirty'])} + 未跟踪 {len(git_info['untracked'])})</h3>
|
||
<table><tr><th>分组</th><th>文件数</th></tr>{fly_rows}</table>
|
||
<p class="dim" style="margin:8px 0 0">未跟踪: </p><ul>{untracked}</ul></div>
|
||
<div class="card"><h3>② 什么待拍板 / 待执行(06 清单 {len(todos)} 条)</h3>
|
||
<p>待办 {buckets.get('待办', 0)} · 部分收口 {buckets.get('部分收口', 0)} · 拍板待执行 {buckets.get('拍板待执行', 0)} ·
|
||
<span class="stale">解决态残留 {buckets.get('解决态残留', 0)}</span>(按「解决即移除」口径待清)</p>
|
||
<table><tr><th>循环</th><th>开放</th><th>总条数</th></tr>{"".join(loop_rows)}</table>
|
||
<p class="dim" style="margin:8px 0 0">完整清单见下方「待办清单全目」专区。</p></div>
|
||
<div class="card"><h3>③ 什么要人玩(07 体验验证 {len(verify['items'])} 项)</h3>
|
||
<ul>{verify_items}</ul></div>
|
||
<div class="card"><h3>④ 验证是否落后于文档(失效面检测)</h3>
|
||
<p class="dim">07 头部批次来源: {esc(verify['source_line'][:120])}</p>
|
||
<table><tr><th>文档</th><th>07 引用</th><th>现行版本</th><th>判定</th></tr>{ref_rows}</table>
|
||
{'<p class="stale">存在落后引用——07 观测项基于旧行为,验收读数以重测批义务为准。</p>' if stale_refs else '<p class="ok">引用与现行版本一致。</p>'}
|
||
<h3 style="margin-top:12px">重测批义务(07 原文)</h3>
|
||
<ul>{obligations}</ul></div>
|
||
</div>
|
||
|
||
<h2>GDD 文档新鲜度({len(docs)} 份,按改动时间)</h2>
|
||
<table><tr><th>文档</th><th>版本</th><th>版本日期</th><th>文件改动</th><th>标题</th></tr>{"".join(doc_rows)}</table>
|
||
|
||
<h2>待办清单全目(按循环)</h2>
|
||
<div class="grid">{"".join(todo_cards)}</div>
|
||
{'<div class="card" style="margin-top:14px"><h3>解决态残留(按「解决即移除」口径应从 06 移除,去向归台账与 git 历史)</h3><ul>' + "".join(f"<li>{esc(t['system'])} · {esc(t['title'])}</li>" for t in resolved_all) + '</ul></div>' if resolved_all else ''}
|
||
|
||
<h2>路线图(production/roadmap.md)</h2>
|
||
<div class="card"><ol>{route}</ol></div>
|
||
|
||
<h2>台账健康</h2>
|
||
<div class="card">{ledger_html}</div>
|
||
|
||
<h2>近期提交</h2>
|
||
<div class="card"><ul>{commits}</ul></div>
|
||
|
||
<footer>
|
||
数据源: production/session-state/active.md · production/roadmap.md · production/stage.txt ·
|
||
design/gdd/06-待办清单.md · design/gdd/07-体验验证清单.md · design/gdd/** · git status/log。
|
||
生成工具 tools/loop_dashboard.py;六处信息源格式变更时同步改解析约定(见工具头注)。
|
||
</footer>
|
||
</main>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def main():
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
ap = argparse.ArgumentParser(description="迭代闭环仪表盘生成器")
|
||
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
||
ap.add_argument("--open", action="store_true", help="生成后用默认浏览器打开")
|
||
args = ap.parse_args()
|
||
|
||
stage = STAGE_TXT.read_text(encoding="utf-8").strip() if STAGE_TXT.exists() else "—"
|
||
git_info = scan_git()
|
||
docs = scan_gdd(git_info)
|
||
active = scan_active()
|
||
todos = scan_todo()
|
||
verify = scan_verify(docs)
|
||
roadmap = scan_roadmap()
|
||
|
||
out_path = Path(args.out)
|
||
out_path.write_text(
|
||
render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path),
|
||
encoding="utf-8")
|
||
print_summary(stage, git_info, docs, active, todos, verify, out_path)
|
||
if args.open and sys.platform == "win32":
|
||
os.startfile(str(out_path)) # noqa: 仅 Windows 便捷入口
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|