Files
wecom_it_smart_desk/scripts/build_inspection_report.py
T
2026-08-11 14:16:50 +08:00

505 lines
25 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_inspection_report.py — 巡检报告 HTML 生成脚本(2026-08-06 落地)
========================================================================
说明:从对话/看板/Markdown 抽取的巡检数据,按统一模板生成单页 HTML 报告。
套用 2026-08-05 早班报告样式 + 4 维分类筛选交互,零 CDN 依赖。
用法:
python scripts/build_inspection_report.py <report_date> <shift>
# 例:python scripts/build_inspection_report.py 2026-08-06 早班
输入参数:
- date YYYY-MM-DD
- shift 早班/午班/晚班
输出:
docs/07-项目管理/巡检报告/YYYY-MM/YYYY-MM-DD-{shift}.html
========================================================================
"""
import argparse
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DEFAULT_OUT_DIR = PROJECT_DIR / "docs" / "07-项目管理" / "巡检报告"
# ============================================================
# 巡检数据(2026-08-06 06:00 早班)
# ============================================================
DATA = {
"date": "2026-08-06",
"shift": "早班",
"time_str": "2026-08-06 06:00",
"kanban_version": "v1.9.0-DRAFT",
"filter_counts": {
"all": 11,
"p0": 5,
"decide": 2,
"doing": 1,
"server": 8,
"code": 5,
"security": 4,
"history": 5,
},
"p0_rows": [
{
"id": "P0-1",
"name": "<code>/itportal/</code> 入口 500 错误",
"category": "♻️ 历史遗留",
"desc": "2026-08-06 06:00 jumpserver-V2 实测仍 500nginx error: <code>rewrite or internal redirection cycle while internally redirecting to &quot;/itportal/index.html&quot;</code>。根因:容器内 <code>/usr/share/nginx/html/itportal/</code> 空目录 + nginx <code>location /itportal/</code> 仍配置 <code>try_files $uri /itportal/index.html</code>。建议:<code>docker compose up -d --force-recreate nginx</code>(前提:commit 21830d5 中 nginx.conf 已删除该 location 块)",
},
{
"id": "P0-3",
"name": "<code>closing_service.py:467</code> datetime 时区错位",
"category": "♻️ 历史遗留",
"desc": "<code>datetime.now() - close_time</code> 报 <code>TypeError: can't subtract offset-naive and offset-aware datetimes</code>。影响 <code>POST /h5/conversations/current/reopen</code>。同文件 18 处 <code>datetime.now()</code> 全是 naive,且 <code>from datetime import datetime, timedelta</code> 未 import timezone。24h 内未触发,但用户使用 H5 重开会话即会爆",
},
{
"id": "P0-4",
"name": "<code>employee_profile_service.py:236</code> SessionLocal NoneType 🔥",
"category": "🆕 位置修正",
"desc": "<strong>看板 v1.8 行号描述错误</strong>:实际错误位置是 <code>app/services/employee_profile_service.py:236</code> 而非 <code>h5_ai_task.py:1198</code>。<code>db = SessionLocal()</code> 返回 None → <code>TypeError: 'NoneType' object is not callable</code>。2026-08-05 24h 内累计 10+ 次(01:33 / 03:25 / 04:07 / 04:11 多波次),影响 H5 IT 资产推荐异步推送。<strong>同根因 P0-5</strong>",
},
{
"id": "P0-5",
"name": "容器 <code>app/constants/</code> 打包错误 🔥",
"category": "🆕 新增",
"desc": "容器内 <code>/app/app/constants/__init__.py</code> 11635 bytes 内容是 automation.py(两个文件互换了),<code>/app/app/constants/automation.py</code> <strong>不存在</strong>。直接后果:连续 10+ 次 <code>ModuleNotFoundError: No module named 'app.constants.ai_reply_mode'</code> + 同根因连锁导致 P0-4SessionLocal 绑定失败)。建议:① 临时 <code>docker cp</code> 修补 → ② 长期排查 backend 镜像构建流程",
},
{
"id": "P0-6",
"name": "后端 OAuth 40029 抖动",
"category": "️ 信息类",
"desc": "218.75.34.87 等外部 IP 持续试探 <code>/api/auth/validate-sso</code> + 偶发 OAuth code 失效。属 WAF 扫描背景噪声,已 fail2ban 监控。无需主动处理",
},
],
"decide_rows": [
{
"id": "BLK-A",
"name": "企微会议室 Secret",
"duration": "⚠️ <strong>26 天</strong>(自 2026-07-11",
"owner": "需企业微信管理后台申请。Owner: 平台组",
},
{
"id": "BLK-B",
"name": "ITSM API 授权",
"duration": "⚠️ <strong>26 天</strong>(自 2026-07-11",
"owner": "需向 ITSM 平台方申请 app_id/app_secret。Owner: 平台组",
},
],
"doing_rows": [
{
"id": "#81",
"name": "敏感词检测 + 语气优化",
"status": "v1.2 待排期",
"desc": "v1.1 阶段 1 已于 2026-07-28 完成(词库入库 + 后台管理 UI + 12 配套 API)。剩余 &quot;语气优化、灰度策略细化&quot; 待 v1.2 排期",
},
],
"server_rows": [
{"name": "Docker 容器", "status": "ok", "label": "正常", "desc": "5/5 healthynginx (Up 12h) / backend (Up 17h) / redis (Up 2w) / postgres (Up 3w) / neo4j (Up 3w)。无 Exited/Restarting/Unhealthy"},
{"name": "后端 health", "status": "ok", "label": "正常", "desc": "<code>/health</code> → HTTP 200(容器内 3.9ms);外部 <code>curl http://localhost:8000</code> 000(端口未映射给宿主,nginx 反代访问正常)"},
{"name": "后端 5xx 错误", "status": "fail", "label": "异常", "desc": "24h 内仍见 2 类 TracebackP0-5 ModuleNotFoundError10+ 次)+ P0-4 SessionLocal NoneType10+ 次)"},
{"name": "PostgreSQL", "status": "ok", "label": "正常", "desc": "<code>pg_isready -U postgres</code> → <code>accepting connections</code>"},
{"name": "Redis", "status": "ok", "label": "正常", "desc": "<code>redis_version:7.4.9</code> / 独立模式 + 密码认证(NOAUTH 正常);已配密码 <code>WKzl7jgKTkeWxFWGIBXfzokm59Gvfr76</code>"},
{"name": "Nginx 配置", "status": "ok", "label": "正常", "desc": "<code>nginx -t</code> → syntax OK80/443 已 bind"},
{"name": "前端入口", "status": "fail", "label": "异常", "desc": "<code>/itportal/</code> <strong>500</strong>rewrite cycle);<code>/h5/</code>、<code>/itdesk/</code> 200 OK<code>/</code> 200612 bytes root index.html"},
{"name": "磁盘 / 内存", "status": "ok", "label": "正常", "desc": "130G 可用(13% 用)/ 3.4G used / 11G available / 负载 0.99 / 已运行 54 天"},
],
"code_rows": [
{"name": "🟢 鉴权补漏", "desc": "sensitive_words.py 13 端点已正确加 <code>dependencies=[Depends(require_admin)]</code> (line 47-51)。看板描述脱节,v1.9 已迁移至已完成"},
{"name": "🔴 容器/源码不一致", "desc": "容器内 <code>app/constants/__init__.py</code> = automation.py 内容。排查镜像构建流程,重打 backend 镜像"},
{"name": "🟡 死代码 / 残留", "desc": "console.log 在 <code>src/frontend-*/src</code> 已搜不到。看板描述脱节,v1.9 已迁移至已完成"},
{"name": "🟢 时区隐患", "desc": "closing_service.py 全部 18 处 <code>datetime.now()</code> 全是 naive,无 import timezone。建议全文件改造为 <code>datetime.now(tz=timezone.utc)</code>"},
{"name": "🟢 未使用导入 / 硬编码", "desc": "未发现明显问题(快速扫描未命中);源码层无硬编码(生产 env 路径已隔离)"},
],
"security_rows": [
{"name": "🟡 API 鉴权", "desc": "sensitive_words.py 13 端点已 require_admin;其余 admin API 状态需逐项复核(本次未做全量扫描)"},
{"name": "🟢 敏感数据处理", "desc": "数据库连接串、Redis 密码均在 env 注入,未硬编码到源码"},
{"name": "🟡 依赖包", "desc": "<code>cryptography==42.0.8</code> / <code>pydantic==2.7.4</code>2026-08-05 巡检已记录,仍待升级)"},
{"name": "🟡 日志敏感信息", "desc": "生产 logs 记录了用户 OAuth code(如 <code>code=OFVMVR7IB2dgFVWX...</code>),属企微临时凭据,建议在 <code>wecom_service.py:1411</code> 打印前 mask"},
],
"history_rows": [
{"name": "P0-2 sensitive_words.py 13 端点无鉴权", "category": "♻️→✅", "desc": "看板 v1.8 标\"待治理\"2026-08-06 06:00 巡检发现<strong>代码已修复</strong>router 加 <code>dependencies=[Depends(require_admin)]</code> line 47-51),v1.9 看板已迁移至已完成区"},
{"name": "P1-1 前端 console.log 残留 208 行", "category": "♻️→✅", "desc": "看板 v1.8 标\"待清理\"2026-08-06 06:00 巡检发现 <code>rg/find</code> 搜不到任何 console.logv1.9 看板已迁移至已完成区"},
{"name": "P0-4 行号描述修正", "category": "🔄 修正", "desc": "看板 v1.8 写 <code>h5_ai_task.py:1198</code>,实际是 <code>employee_profile_service.py:236</code>。<strong>同根因 P0-5</strong>,建议合并排查"},
{"name": "BLK-A/B 阻塞时长刷新", "category": "📅 +1d", "desc": "自 2026-07-11 起,2026-08-06 已 <strong>26 天</strong>(较昨日 +1 自然日),远超 3 天阈值"},
{"name": "/itportal/ 500 持续 29 天", "category": "🔥 持续", "desc": "2026-07-08 起至今,今日 06:00 jumpserver-V2 实测仍 500。需 PM 派单部署 commit 21830d5 或清理 nginx location 块"},
],
"pm_alert": [
"🔴 P0-NEW1constants 包打包错误)必须当日修:直接导致 H5 异步任务全部崩溃(资产推荐推送 NoneType)。修复方向:① 临时 <code>docker cp</code> 修补 → ② 长期重打 backend 镜像",
"🔴 /itportal 500 已持续 29 天:根因明确(容器内空 dist + nginx location 块残留)。建议执行 <code>docker compose up -d --force-recreate nginx</code>",
"🟡 看板 v1.8.0-FROZEN 2 项描述脱节:P0-2 sensitive_words / P1-1 console.log 实际已修复 / 已清理,v1.9 已修正",
"⚠️ BLK-A/B 阻塞已 26 天(+23 天超过 3 天阈值):建议 PM 升级催办或重新评估优先级",
"🟡 closing_service.py:467 时区隐患:24h 内未触发,修复成本低(<code>from datetime import timezone</code> + 18 处替换)",
],
}
# ============================================================
# 样式(复用 2026-08-05 早班报告色板,零 CDN
# ============================================================
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; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: var(--bg-soft);
line-height: 1.6;
font-size: 14px;
}
.container { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
header {
background: linear-gradient(135deg, var(--accent) 0%, #06AD56 100%);
color: #fff;
padding: 32px;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 24px;
}
header h1 { font-size: 26px; font-weight: 600; margin-bottom: 8px; }
header .meta { font-size: 13px; opacity: 0.92; }
header .meta span { margin-right: 18px; }
header .meta strong { font-weight: 600; }
h2 {
font-size: 18px;
font-weight: 600;
margin: 32px 0 14px;
padding-left: 10px;
border-left: 4px solid var(--accent);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 8px;
}
.summary-card {
background: #fff;
padding: 14px 16px;
border-radius: var(--radius);
box-shadow: var(--shadow);
text-align: center;
}
.summary-card .num { font-size: 24px; font-weight: 700; color: var(--accent); }
.summary-card .label { font-size: 12px; color: var(--text-soft); margin-top: 4px; }
.summary-card.warn .num { color: var(--warn); }
.summary-card.danger .num { color: var(--danger); }
.summary-card.info .num { color: var(--info); }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; margin-bottom: 24px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: var(--bg-soft); font-size: 12px; font-weight: 600; color: var(--text-soft); }
tr:last-child td { border-bottom: none; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge.p0 { background: #FEE; color: var(--danger); border: 1px solid var(--danger); }
.badge.p1 { background: #FFF3E0; color: var(--warn); border: 1px solid var(--warn); }
.badge.decide { background: #E6F4FF; color: var(--info); border: 1px solid var(--info); }
.badge.doing { background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent); }
.badge.ok { background: var(--accent-soft); color: var(--accent); }
.badge.fail { background: #FEE; color: var(--danger); }
code { background: var(--bg-soft); padding: 2px 6px; border-radius: 3px; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
.alert { background: #FFF8E6; border-left: 4px solid var(--warn); padding: 12px 16px; margin: 16px 0; border-radius: 4px; }
.alert.danger { background: #FEE; border-left-color: var(--danger); }
.alert strong { display: block; margin-bottom: 4px; }
.legend { color: var(--text-soft); font-size: 12px; margin-top: 4px; margin-bottom: 16px; }
footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-soft); font-size: 12px; text-align: center; }
ul.condensed { list-style: none; padding: 0; }
ul.condensed li { padding: 8px 12px; border-bottom: 1px dashed var(--border); background: #fff; }
ul.condensed li:last-child { border-bottom: none; }
ul.condensed li:first-child { border-radius: var(--radius) var(--radius) 0 0; }
ul.condensed li:last-child { border-radius: 0 0 var(--radius) var(--radius); box-shadow: var(--shadow); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 8px; padding: 12px 14px; background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); align-items: center; }
.filter-bar .filter-label { font-size: 12px; color: var(--text-soft); font-weight: 600; margin-right: 4px; }
.filter-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--text-soft); padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 600; transition: all 0.15s ease; user-select: none; }
.filter-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
.filter-btn.active { color: #fff; border-color: transparent; }
.filter-btn[data-cat="all"].active { background: var(--text); }
.filter-btn[data-cat="p0"].active { background: var(--danger); }
.filter-btn[data-cat="decide"].active { background: var(--info); }
.filter-btn[data-cat="doing"].active { background: var(--accent); }
.filter-btn[data-cat="server"].active { background: var(--danger); }
.filter-btn[data-cat="code"].active { background: #8B5CF6; }
.filter-btn[data-cat="security"].active { background: #DC2626; }
.filter-btn[data-cat="history"].active { background: #64748B; }
.filter-btn .badge-count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: rgba(0,0,0,0.08); border-radius: 8px; font-size: 10px; }
.filter-btn.active .badge-count { background: rgba(255,255,255,0.25); }
.hidden { display: none !important; }
"""
# ============================================================
# HTML 渲染
# ============================================================
def render_html(data: dict) -> str:
fc = data["filter_counts"]
# P0 表格
p0_rows_html = ""
for r in data["p0_rows"]:
p0_rows_html += f""" <tr data-cat="p0">
<td><span class="badge p0">{r['id']}</span></td>
<td>{r['name']}</td>
<td>{r['category']}</td>
<td>{r['desc']}</td>
</tr>
"""
# 等决策表格
decide_rows_html = ""
for r in data["decide_rows"]:
decide_rows_html += f""" <tr data-cat="decide">
<td><span class="badge decide">{r['id']}</span></td>
<td>{r['name']}</td>
<td>{r['duration']}</td>
<td>{r['owner']}</td>
</tr>
"""
# 进行中表格
doing_rows_html = ""
for r in data["doing_rows"]:
doing_rows_html += f""" <tr data-cat="doing">
<td><span class="badge doing">{r['id']}</span></td>
<td>{r['name']}</td>
<td>{r['status']}</td>
<td>{r['desc']}</td>
</tr>
"""
# 服务器状态
server_rows_html = ""
for r in data["server_rows"]:
server_rows_html += f""" <tr data-cat="server">
<td>{r['name']}</td>
<td><span class="badge {r['status']}">{r['label']}</span></td>
<td>{r['desc']}</td>
</tr>
"""
# 代码质量
code_items_html = "\n".join(
f' <li data-cat="code"><strong>{r["name"]}</strong>{r["desc"]}</li>'
for r in data["code_rows"]
)
# 安全
security_items_html = "\n".join(
f' <li data-cat="security"><strong>{r["name"]}</strong>{r["desc"]}</li>'
for r in data["security_rows"]
)
# 对比/关注
history_rows_html = ""
for r in data["history_rows"]:
history_rows_html += f""" <tr data-cat="history">
<td>{r['name']}</td>
<td>{r['category']}</td>
<td>{r['desc']}</td>
</tr>
"""
# PM 立即关注
pm_items_html = "\n".join(
f' <li data-cat="all">{item}</li>' for item in data["pm_alert"]
)
# 摘要卡片
summary_html = f"""
<div class="summary-grid">
<div class="summary-card danger"><div class="num">{len(data['p0_rows'])}</div><div class="label">🔴 P0 必做</div></div>
<div class="summary-card info"><div class="num">{len(data['decide_rows'])}</div><div class="label">🟢 等决策</div></div>
<div class="summary-card"><div class="num">{len(data['doing_rows'])}</div><div class="label">⏳ 进行中</div></div>
<div class="summary-card {'danger' if any(r['status']=='fail' for r in data['server_rows']) else ''}"><div class="num">{sum(1 for r in data['server_rows'] if r['status']=='fail')}</div><div class="label">🖥️ 服务器异常项</div></div>
</div>
"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>📋</text></svg>">
<title>早班巡检报告 · {data['date']} 06:00 · v3.0</title>
<style>{CSS}</style>
</head>
<body>
<div class="container">
<header>
<h1>📋 早班巡检报告 · IT 智能服务台</h1>
<div class="meta">
<span>📅 <strong>{data['time_str']}</strong>GMT+8</span>
<span>🛰️ 服务器 <strong>10.90.5.110</strong></span>
<span>📄 看板 <strong>{data['kanban_version']}</strong></span>
<span>🤖 执行 <strong>Duckula</strong></span>
<span>📦 看板源 <code>docs/07-项目管理/项目状态看板.md</code></span>
</div>
</header>
{summary_html}
<!-- 分类筛选 -->
<div class="filter-bar">
<span class="filter-label">🔍 分类筛选</span>
<button class="filter-btn active" data-cat="all">全部<span class="badge-count">{fc['all']}</span></button>
<button class="filter-btn" data-cat="p0">🔴 P0<span class="badge-count">{fc['p0']}</span></button>
<button class="filter-btn" data-cat="decide">🟢 等决策<span class="badge-count">{fc['decide']}</span></button>
<button class="filter-btn" data-cat="doing">⏳ 进行中<span class="badge-count">{fc['doing']}</span></button>
<button class="filter-btn" data-cat="server">🖥️ 服务器<span class="badge-count">{fc['server']}</span></button>
<button class="filter-btn" data-cat="code">🔍 代码<span class="badge-count">{fc['code']}</span></button>
<button class="filter-btn" data-cat="security">🔐 安全<span class="badge-count">{fc['security']}</span></button>
<button class="filter-btn" data-cat="history">🔄 对比/关注<span class="badge-count">{fc['history']}</span></button>
</div>
<!-- P0 必做 -->
<h2 data-section="p0">🔴 P0 必做(高优先级)</h2>
<p class="legend">本次巡检最高优先级阻塞 / 待修复项</p>
<table>
<thead>
<tr><th>编号</th><th>任务</th><th>类别</th><th>说明</th></tr>
</thead>
<tbody>
{p0_rows_html} </tbody>
</table>
<!-- 等用户决策 -->
<h2 data-section="decide">🟢 等用户决策(阻塞项)</h2>
<p class="legend">均超 3 天阈值,需 PM 升级催办</p>
<table>
<thead><tr><th>编号</th><th>任务</th><th>阻塞时长</th><th>卡点 / Owner</th></tr></thead>
<tbody>
{decide_rows_html} </tbody>
</table>
<!-- 进行中 -->
<h2 data-section="doing">⏳ 进行中 (In Progress)</h2>
<table>
<thead><tr><th>编号</th><th>任务</th><th>状态</th><th>说明</th></tr></thead>
<tbody>
{doing_rows_html} </tbody>
</table>
<!-- 服务器状态 -->
<h2 data-section="server">🖥️ 服务器运行状态</h2>
<table>
<thead><tr><th>组件</th><th>状态</th><th>实测数据</th></tr></thead>
<tbody>
{server_rows_html} </tbody>
</table>
<!-- 代码规范与质量 -->
<h2 data-section="code">🔍 代码规范与质量检查</h2>
<ul class="condensed">
{code_items_html}
</ul>
<!-- 安全检查 -->
<h2 data-section="security">🔐 安全检查</h2>
<ul class="condensed">
{security_items_html}
</ul>
<!-- 对比/关注 -->
<h2 data-section="history">🔄 与昨日对比 / PM 关注项</h2>
<table>
<thead><tr><th>条目</th><th>变化</th><th>说明</th></tr></thead>
<tbody>
{history_rows_html} </tbody>
</table>
<!-- PM 立即关注 -->
<h2 data-section="all">⚠️ PM 立即关注</h2>
<ul class="condensed">
{pm_items_html}
</ul>
<footer>
📋 报告生成时间:{data['time_str']} · 看板版本 {data['kanban_version']} · Duckula(达寇拉)自动化生成<br>
📎 数据源:jumpserver-V2 实测 + 后端 24h 日志审计 + 源码扫描 + 容器内文件核对<br>
🔗 服务地址:<code>https://itsupport.servyou.com.cn/docs/inspection/2026-08/2026-08-06-早班.html</code>
</footer>
</div>
<script>
(function () {{
// 简易分类筛选
const all = {{ all: true }};
document.querySelectorAll('.filter-btn').forEach((btn) => {{
btn.addEventListener('click', () => filter(btn.dataset.cat));
}});
function filter(cat) {{
document.querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active'));
document.querySelector('.filter-btn[data-cat="' + cat + '"]').classList.add('active');
document.querySelectorAll('tr[data-cat], li[data-cat]').forEach((el) => {{
if (cat === 'all') {{ el.classList.remove('hidden'); }}
else if (el.dataset.cat === cat) {{ el.classList.remove('hidden'); }}
else {{ el.classList.add('hidden'); }}
}});
document.querySelectorAll('h2[data-section]').forEach((h) => {{
if (cat === 'all' || h.dataset.section === cat) {{ h.classList.remove('hidden'); }}
else {{ h.classList.add('hidden'); }}
}});
history.replaceState(null, '', '#' + cat);
}}
const initCat = (location.hash || '#all').slice(1);
if (['all','p0','decide','doing','server','code','security','history'].includes(initCat)) {{
filter(initCat);
}}
const keys = ['1','2','3','4','5','6','7','8','0'];
const map = ['p0','decide','doing','server','code','security','history','overview','all'];
document.addEventListener('keydown', (e) => {{
if (['INPUT','TEXTAREA'].includes(e.target.tagName)) return;
const idx = keys.indexOf(e.key);
if (idx >= 0 && map[idx]) filter(map[idx]);
}});
}})();
</script>
</body>
</html>
"""
return html
def main():
parser = argparse.ArgumentParser(description="Build inspection report HTML")
parser.add_argument("date", help="Report date (YYYY-MM-DD)")
parser.add_argument("shift", help="Shift (早班/午班/晚班)")
args = parser.parse_args()
year_month = args.date[:7] # YYYY-MM
out_dir = DEFAULT_OUT_DIR / year_month
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / f"{args.date}-{args.shift}.html"
DATA["date"] = args.date
DATA["shift"] = args.shift
DATA["time_str"] = f"{args.date} 06:00" if args.shift == "早班" else f"{args.date} 14:00"
html = render_html(DATA)
out_file.write_text(html, encoding="utf-8")
print(f"[OK] Generated: {out_file} ({len(html)} bytes)")
if __name__ == "__main__":
main()