Files
twoJob-demo/scripts/gen_review_html.py
T
2026-09-03 15:35:23 +08:00

194 lines
10 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 -*-
"""
从 SQLite 生成"带复选框的交互式功能树" HTML (阶段3 交付物)。
管理员可勾选保留/删除, 支持展开折叠、搜索、查看截图、导出勾选结果 JSON。
输出: docs/功能树-勾选.html 及 docs/selection-template.json
"""
import os, json, sqlite3
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB = os.path.join(BASE, "db", "research.db")
OUT = os.path.join(BASE, "docs", "功能树-勾选.html")
def build_tree():
conn = sqlite3.connect(DB); conn.row_factory = sqlite3.Row
rows = [dict(r) for r in conn.execute(
"SELECT node_key,parent_key,level,name,full_path,url,description,understanding,screenshot_path,sort_order,keep "
"FROM features ORDER BY level,sort_order,id").fetchall()]
conn.close()
by_parent = {}
for r in rows:
by_parent.setdefault(r["parent_key"] or "", []).append(r)
def build(pk):
out = []
for r in by_parent.get(pk, []):
n = dict(r)
# 截图相对 docs/ 的路径
sp = r.get("screenshot_path")
n["shot"] = ("../" + sp) if sp else ""
n["children"] = build(r["node_key"])
out.append(n)
return out
return build("")
HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>2号人事部 · 功能调研树(第一阶段·管理员勾选)</title>
<style>
:root{--green:#00b389;--bd:#e5e7eb;--muted:#6b7280;--bg:#f7f8fa;}
*{box-sizing:border-box;}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#1f2937;background:var(--bg);}
header{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--bd);padding:14px 20px;}
header h1{margin:0 0 4px;font-size:18px;}
.sub{color:var(--muted);font-size:13px;}
.toolbar{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.toolbar input[type=search]{padding:7px 10px;border:1px solid var(--bd);border-radius:6px;width:240px;font-size:13px;}
button{cursor:pointer;border:1px solid var(--bd);background:#fff;border-radius:6px;padding:7px 12px;font-size:13px;}
button.primary{background:var(--green);color:#fff;border-color:var(--green);}
button:hover{filter:brightness(0.97);}
.stat{font-size:13px;color:var(--muted);margin-left:auto;}
.stat b{color:var(--green);font-size:15px;}
#tree{padding:14px 20px 80px;}
.node{border-left:2px solid transparent;}
.row{display:flex;align-items:flex-start;gap:8px;padding:6px 8px;border-radius:8px;}
.row:hover{background:#fff;}
.lv0>.row{background:#eef7f4;border:1px solid #d6ede6;margin-top:12px;}
.lv0>.row .name{font-size:15px;font-weight:700;}
.lv1>.row .name{font-weight:600;color:#0f766e;}
.toggle{width:18px;text-align:center;color:var(--muted);cursor:pointer;user-select:none;flex:0 0 auto;padding-top:2px;}
.toggle.leaf{visibility:hidden;}
input[type=checkbox]{width:16px;height:16px;margin-top:3px;flex:0 0 auto;cursor:pointer;}
.meta{flex:1;min-width:0;}
.name{font-size:14px;}
.name .path{color:#9ca3af;font-weight:400;font-size:11px;margin-left:6px;}
.desc{color:#374151;font-size:12.5px;margin-top:2px;line-height:1.5;}
.und{color:#8a6d3b;background:#fcf8ee;border:1px solid #f3e9cf;border-radius:4px;padding:2px 6px;font-size:12px;margin-top:3px;display:inline-block;}
.url{color:#9ca3af;font-size:11px;margin-top:2px;word-break:break-all;}
.thumb{flex:0 0 auto;}
.thumb img{width:120px;height:76px;object-fit:cover;object-position:top;border:1px solid var(--bd);border-radius:6px;cursor:zoom-in;background:#fff;}
.children{margin-left:26px;border-left:1px dashed #e5e7eb;padding-left:6px;}
.hidden{display:none;}
.dropped>.row{opacity:.42;}
.dropped>.row .name{text-decoration:line-through;}
/* lightbox */
#lb{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;z-index:100;overflow:auto;text-align:center;}
#lb img{max-width:96%;margin:2% auto;border-radius:6px;}
#lb .close{position:fixed;top:14px;right:20px;color:#fff;font-size:30px;cursor:pointer;}
.legend{font-size:12px;color:var(--muted);}
</style>
</head>
<body>
<header>
<h1>2号人事部 · 功能调研树 <span class="sub">第一阶段 · 管理员勾选(勾选=保留,取消=删除)</span></h1>
<div class="sub">共 __MODN__ 个模块 / __LEAFN__ 个功能页。勾选后点【导出勾选结果】保存 JSON,用于生成第二阶段开发需求文档。</div>
<div class="toolbar">
<input type="search" id="q" placeholder="搜索功能名称/释义…"/>
<button onclick="expandAll(true)">展开全部</button>
<button onclick="expandAll(false)">折叠全部</button>
<button onclick="checkAll(true)">全选</button>
<button onclick="checkAll(false)">全不选</button>
<button class="primary" onclick="exportSel()">⬇ 导出勾选结果(JSON)</button>
<span class="legend">(选择自动保存到浏览器本地)</span>
<span class="stat">保留 <b id="cntKeep">0</b> / <span id="cntAll">0</span> 项</span>
</div>
</header>
<div id="tree"></div>
<div id="lb" onclick="this.style.display='none'"><span class="close">×</span><img id="lbimg"/></div>
<script>
const DATA = __DATA__;
const STORE = "twohr_sel_v1";
let sel = JSON.parse(localStorage.getItem(STORE) || "{}");
function leafKeys(n, acc){ if(!n.children.length){acc.push(n.node_key);} n.children.forEach(c=>leafKeys(c,acc)); return acc; }
const ALL_LEAVES = []; DATA.forEach(n=>leafKeys(n, ALL_LEAVES));
function isKeep(k){ return sel[k] !== 0; } // 默认保留
function render(){
const root = document.getElementById('tree'); root.innerHTML='';
DATA.forEach(n=>root.appendChild(renderNode(n)));
updateStats(); refreshDropped();
}
function renderNode(n){
const wrap=document.createElement('div'); wrap.className='node lv'+n.level; wrap.dataset.key=n.node_key;
const row=document.createElement('div'); row.className='row';
const hasCh=n.children.length>0;
const tg=document.createElement('span'); tg.className='toggle'+(hasCh?'':' leaf'); tg.textContent=hasCh?'▾':'•';
tg.onclick=()=>{const c=wrap.querySelector(':scope>.children');if(c){c.classList.toggle('hidden');tg.textContent=c.classList.contains('hidden')?'▸':'▾';}};
const cb=document.createElement('input'); cb.type='checkbox'; cb.dataset.key=n.node_key;
cb.onchange=()=>{ setSubtree(n, cb.checked); save(); render(); };
const meta=document.createElement('div'); meta.className='meta';
let html='<div class="name">'+esc(n.name)+'<span class="path">'+esc(n.full_path||'')+'</span></div>';
if(n.description) html+='<div class="desc">'+esc(n.description)+'</div>';
if(n.understanding) html+='<div class="und">理解:'+esc(n.understanding)+'</div>';
if(n.url) html+='<div class="url">'+esc(n.url)+'</div>';
meta.innerHTML=html;
row.appendChild(tg); row.appendChild(cb); row.appendChild(meta);
if(n.shot){ const t=document.createElement('div'); t.className='thumb';
t.innerHTML='<img loading="lazy" src="'+n.shot+'" onclick="zoom(this.src)" onerror="this.style.opacity=.2;this.title=\'截图待生成\'"/>'; row.appendChild(t); }
wrap.appendChild(row);
// checkbox state
const st=subtreeState(n);
cb.checked = st!=='none'; cb.indeterminate = st==='some';
if(hasCh){ const ch=document.createElement('div'); ch.className='children';
n.children.forEach(c=>ch.appendChild(renderNode(c))); wrap.appendChild(ch); }
return wrap;
}
function setSubtree(n, val){ if(!n.children.length){ sel[n.node_key]= val?1:0; } n.children.forEach(c=>setSubtree(c,val)); }
function subtreeState(n){ const ls=leafKeys(n,[]); const k=ls.filter(isKeep).length;
if(k===0)return 'none'; if(k===ls.length)return 'all'; return 'some'; }
function refreshDropped(){
document.querySelectorAll('.node.lv2').forEach(w=>{ w.classList.toggle('dropped', !isKeep(w.dataset.key)); });
}
function updateStats(){ const keep=ALL_LEAVES.filter(isKeep).length;
document.getElementById('cntKeep').textContent=keep; document.getElementById('cntAll').textContent=ALL_LEAVES.length; }
function save(){ localStorage.setItem(STORE, JSON.stringify(sel)); }
function checkAll(v){ ALL_LEAVES.forEach(k=>sel[k]=v?1:0); save(); render(); }
function expandAll(v){ document.querySelectorAll('.children').forEach(c=>c.classList.toggle('hidden',!v));
document.querySelectorAll('.toggle:not(.leaf)').forEach(t=>t.textContent=v?'▾':'▸'); }
function zoom(src){ document.getElementById('lbimg').src=src; document.getElementById('lb').style.display='block'; }
function esc(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function exportSel(){
const rows=[]; function walk(n,mod){ const m=n.level===0?n.name:mod;
if(!n.children.length){ rows.push({module:m,node_key:n.node_key,name:n.name,full_path:n.full_path,url:n.url,keep:isKeep(n.node_key)?1:0}); }
n.children.forEach(c=>walk(c,m)); }
DATA.forEach(n=>walk(n,n.name));
const out={generated_at:new Date().toISOString(),total:rows.length,keep:rows.filter(r=>r.keep).length,items:rows};
const blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='selection.json'; a.click();
}
function search(q){ q=q.trim().toLowerCase();
document.querySelectorAll('.node').forEach(w=>{ const t=w.querySelector(':scope>.row .meta').innerText.toLowerCase();
const hit=!q||t.includes(q); w.style.display = hit||hasVisibleChild(w,q)?'':'none'; });
}
function hasVisibleChild(w,q){ return [...w.querySelectorAll('.node')].some(c=>c.querySelector(':scope>.row .meta').innerText.toLowerCase().includes(q)); }
document.getElementById('q').addEventListener('input',e=>search(e.target.value));
render();
</script>
</body>
</html>
"""
def main():
tree = build_tree()
modn = len(tree)
leafn = sum(1 for _ in _iter_leaves(tree))
html = (HTML.replace("__DATA__", json.dumps(tree, ensure_ascii=False))
.replace("__MODN__", str(modn)).replace("__LEAFN__", str(leafn)))
os.makedirs(os.path.dirname(OUT), exist_ok=True)
open(OUT, "w").write(html)
print("written:", OUT, "modules:", modn, "leaves:", leafn)
def _iter_leaves(nodes):
for n in nodes:
if not n["children"]:
yield n
else:
yield from _iter_leaves(n["children"])
if __name__ == "__main__":
main()