194 lines
10 KiB
Python
194 lines
10 KiB
Python
#!/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=>({'&':'&','<':'<','>':'>','"':'"'}[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()
|