Files
wecom_it_smart_desk/scripts/build_inspection_report.py
T

505 lines
25 KiB
Python
Raw Normal View History

2026-08-11 14:16:50 +08:00
#!/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()