Files

194 lines
10 KiB
Python
Raw Permalink Normal View History

2026-09-03 15:35:23 +08:00
#!/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()