Files
twoJob-demo/docs/审核.html
T
2026-09-03 15:35:23 +08:00

470 lines
27 KiB
HTML
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.
<!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;--red:#ef4444;--bd:#e5e7eb;--muted:#6b7280;--bg:#f4f6f8;--ink:#1f2937;}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink);background:var(--bg);}
body{display:flex;flex-direction:column;height:100vh;overflow:hidden;}
/* 顶部进度条 */
header{flex:0 0 auto;background:#fff;border-bottom:1px solid var(--bd);padding:10px 18px;}
.htop{display:flex;align-items:center;gap:14px;}
.htop h1{margin:0;font-size:16px;white-space:nowrap;}
.htop .sub{color:var(--muted);font-size:12px;}
.progress{flex:1;height:8px;background:#eef1f4;border-radius:99px;overflow:hidden;min-width:120px;}
.progress > i{display:block;height:100%;background:var(--green);width:0;transition:width .2s;}
.counts{font-size:13px;color:var(--muted);white-space:nowrap;}
.counts b{color:var(--green);font-size:15px;}
.counts .drop{color:var(--red);}
.btn{cursor:pointer;border:1px solid var(--bd);background:#fff;border-radius:7px;padding:7px 12px;font-size:13px;}
.btn:hover{filter:brightness(.97);}
.btn.primary{background:var(--green);color:#fff;border-color:var(--green);}
/* 主体: 左目录 + 中截图 + 右信息 */
main{flex:1;display:flex;min-height:0;}
#sidebar{flex:0 0 262px;background:#fff;border-right:1px solid var(--bd);overflow:auto;transition:margin .2s;}
#sidebar.collapsed{margin-left:-262px;}
.side-search{position:sticky;top:0;background:#fff;padding:10px;border-bottom:1px solid var(--bd);}
.side-search input{width:100%;padding:7px 9px;border:1px solid var(--bd);border-radius:6px;font-size:13px;}
.mod{padding:2px 0;}
.mod-h{padding:7px 12px;font-size:13px;font-weight:700;color:#0f766e;background:#f0f9f6;position:sticky;top:52px;cursor:pointer;display:flex;justify-content:space-between;}
.mod-h .mc{font-weight:400;color:var(--muted);font-size:11px;}
.leaf{padding:6px 12px 6px 22px;font-size:12.5px;cursor:pointer;display:flex;align-items:center;gap:7px;border-left:3px solid transparent;}
.leaf:hover{background:#f7f8fa;}
.leaf.active{background:#eef7f4;border-left-color:var(--green);font-weight:600;}
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--green);}
.leaf.dropped .dot{background:var(--red);}
.leaf.dropped .lname{text-decoration:line-through;color:#9ca3af;}
.lname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.leaf-att{flex:0 0 auto;font-size:10.5px;color:#0f766e;background:#e6f4f0;border-radius:9px;padding:1px 6px;line-height:1.5;font-weight:600;}
.att-browse{color:var(--green);cursor:pointer;text-decoration:underline;margin-left:4px;}
/* 中间截图区 */
#stage{flex:1;min-width:0;overflow:auto;padding:18px;display:flex;flex-direction:column;align-items:center;background:#eceef1;}
#shotwrap{width:100%;max-width:1180px;background:#fff;border:1px solid var(--bd);border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.05);overflow:hidden;}
#shotwrap .shot-h{padding:9px 14px;border-bottom:1px solid var(--bd);font-size:12px;color:var(--muted);display:flex;justify-content:space-between;align-items:center;}
#shotwrap .shot-body{max-height:calc(100vh - 150px);overflow:auto;}
#shotwrap img{width:100%;display:block;cursor:zoom-in;transition:opacity .18s;}
#shotwrap.loading img{opacity:.3;}
.shot-h .badge2{font-size:12px;color:#0f766e;font-weight:700;}
#deepwrap{width:100%;max-width:1180px;margin-top:14px;background:#fff;border:1px solid var(--bd);border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.05);overflow:hidden;}
#deepwrap .deep-h{padding:9px 14px;border-bottom:1px solid var(--bd);font-size:12px;color:var(--muted);font-weight:700;}
#deepwrap .deep-h span{color:#0f766e;margin-left:6px;}
#deepGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px;padding:14px;}
.deep-card{border:1px solid var(--bd);border-radius:8px;overflow:hidden;cursor:zoom-in;background:#fafafa;transition:box-shadow .15s;}
.deep-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.12);}
.deep-card img{width:100%;display:block;height:150px;object-fit:cover;object-position:top;}
.deep-card .cap{padding:6px 9px;font-size:12px;color:#374151;border-top:1px solid var(--bd);}
.deep-card .cap b{color:#0f766e;}
#noshot{padding:60px;color:#9ca3af;text-align:center;}
/* 右信息栏 */
#info{flex:0 0 348px;background:#fff;border-left:1px solid var(--bd);overflow:auto;padding:0;display:flex;flex-direction:column;}
.info-body{padding:18px;flex:1;}
.crumb{font-size:12px;color:var(--muted);margin-bottom:6px;}
.crumb .sep{margin:0 5px;color:#cbd5e1;}
.fname{font-size:22px;font-weight:800;margin:0 0 4px;}
.furl{font-size:11px;color:#9ca3af;word-break:break-all;margin-bottom:16px;}
.sec{margin-bottom:16px;}
.sec .lab{font-size:12px;font-weight:700;color:#0f766e;margin-bottom:5px;display:flex;align-items:center;gap:5px;}
.sec .lab::before{content:"";width:3px;height:13px;background:var(--green);border-radius:2px;}
.sec .txt{font-size:14px;line-height:1.7;color:#374151;}
.sec.und .txt{background:#fcf8ee;border:1px solid #f3e9cf;color:#8a6d3b;border-radius:8px;padding:10px 12px;}
.sec.note textarea{width:100%;min-height:82px;resize:vertical;border:1px solid var(--bd);border-radius:8px;padding:9px 11px;font-size:13.5px;line-height:1.6;font-family:inherit;color:#1f2937;}
.sec.note textarea:focus{outline:none;border-color:var(--green);box-shadow:0 0 0 2px rgba(0,179,137,.15);}
.sec.note .lab .st{font-weight:400;font-size:11px;color:#9ca3af;margin-left:auto;}
.sec.note .notebar{display:flex;align-items:center;gap:9px;margin-top:8px;}
.sec.note .btn-note{background:var(--green);color:#fff;border:1px solid var(--green);border-radius:7px;padding:8px 15px;font-size:13px;cursor:pointer;font-weight:600;}
.sec.note .btn-note:hover{filter:brightness(.96);}
.sec.note .btn-note.dirty{animation:pulse 1.1s infinite;}
.sec.note .btn-note.saved{background:#fff;color:#059669;border-color:#a7f3d0;}
.sec.note .tip{font-size:11px;color:#9ca3af;line-height:1.4;}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(0,179,137,.45);}50%{box-shadow:0 0 0 5px rgba(0,179,137,0);}}
.leaf.hasnote .lname::after{content:"\270e";color:#f59e0b;margin-left:4px;font-size:11px;}
.badge{display:inline-block;font-size:12px;padding:3px 9px;border-radius:99px;margin-bottom:14px;}
.badge.keep{background:#e7f7f1;color:#059669;}
.badge.drop{background:#fee2e2;color:#dc2626;}
/* 导出样本(真实数据附件) */
.sec.att .lab .cnt{margin-left:auto;font-weight:400;font-size:11px;color:#9ca3af;}
.att-list{display:flex;flex-direction:column;gap:6px;}
.att-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--bd);border-radius:8px;font-size:12.5px;text-decoration:none;color:#1f2937;background:#fafafa;}
.att-item:hover{border-color:var(--green);background:#f0f9f6;}
.att-item .ico{flex:0 0 auto;font-size:15px;}
.att-item .an{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.att-item .asz{flex:0 0 auto;color:#9ca3af;font-size:11px;}
.att-row{display:flex;align-items:stretch;gap:4px;}
.att-row .att-item{flex:1;min-width:0;cursor:pointer;}
.att-prev:hover{border-color:var(--green);background:#f0f9f6;}
.att-dl{flex:0 0 auto;display:flex;align-items:center;padding:0 9px;border:1px solid var(--bd);border-radius:8px;text-decoration:none;color:#6b7280;background:#fafafa;font-size:13px;}
.att-dl:hover{border-color:var(--green);color:var(--green);background:#f0f9f6;}
#pvmodal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;z-index:120;}
#pvmodal .box{position:absolute;top:3%;left:50%;transform:translateX(-50%);width:min(1100px,96vw);max-height:92vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,.25);}
#pvmodal .mh{padding:12px 18px;border-bottom:1px solid var(--bd);display:flex;align-items:center;gap:10px;}
#pvmodal .mh h3{margin:0;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%;}
#pvmodal .mh .sub{color:var(--muted);font-size:12px;}
#pvmodal .mh .x{margin-left:auto;cursor:pointer;font-size:22px;color:var(--muted);}
#pvmodal .tabs{display:flex;gap:4px;padding:8px 18px 0;flex-wrap:wrap;}
#pvmodal .tab{padding:5px 12px;border:1px solid var(--bd);border-bottom:none;border-radius:7px 7px 0 0;font-size:12px;cursor:pointer;background:#f5f6f7;color:#4b5563;}
#pvmodal .tab.on{background:#fff;color:#0f766e;font-weight:700;border-color:var(--green);}
#pvmodal .mb{padding:0 18px 18px;overflow:auto;}
#pvmodal table{border-collapse:collapse;font-size:12px;width:max-content;min-width:100%;}
#pvmodal th,#pvmodal td{border:1px solid #e5e7eb;padding:4px 8px;text-align:left;white-space:nowrap;max-width:280px;overflow:hidden;text-overflow:ellipsis;}
#pvmodal thead th{position:sticky;top:0;background:#0f766e;color:#fff;font-weight:600;z-index:1;}
#pvmodal tbody tr:nth-child(even){background:#f8fafa;}
#pvmodal .rowno{background:#f1f5f9;color:#94a3b8;text-align:right;position:sticky;left:0;}
.att-empty{font-size:12.5px;color:#9ca3af;}
.att-more{font-size:11.5px;color:#0f766e;cursor:pointer;text-align:center;padding:4px;}
.att-more:hover{text-decoration:underline;}
/* 全量附件弹窗 */
#attmodal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;z-index:110;}
#attmodal .box{position:absolute;top:4%;left:50%;transform:translateX(-50%);width:min(760px,94vw);max-height:90vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,.25);}
#attmodal .mh{padding:14px 18px;border-bottom:1px solid var(--bd);display:flex;align-items:center;gap:10px;}
#attmodal .mh h3{margin:0;font-size:16px;}
#attmodal .mh .sub{color:var(--muted);font-size:12px;}
#attmodal .mh .x{margin-left:auto;cursor:pointer;font-size:22px;color:var(--muted);}
#attmodal .mb{padding:12px 18px 20px;overflow:auto;}
#attmodal .grp{margin-bottom:16px;}
#attmodal .grp .gh{font-size:13px;font-weight:700;color:#0f766e;padding:6px 0;border-bottom:1px solid #eef1f4;margin-bottom:8px;}
#attmodal .grp .gh .gc{font-weight:400;color:#9ca3af;font-size:11px;margin-left:6px;}
/* 底部决策条 */
.decide{flex:0 0 auto;border-top:1px solid var(--bd);padding:14px 18px;display:flex;flex-direction:column;gap:10px;background:#fff;}
.decide .big{display:flex;gap:10px;}
.decide .big button{flex:1;padding:14px 0;font-size:15px;font-weight:700;border-radius:9px;border:2px solid;cursor:pointer;}
.bkeep{background:var(--green);color:#fff;border-color:var(--green);}
.bkeep.off{background:#fff;color:var(--green);}
.bdrop{background:#fff;color:var(--red);border-color:var(--red);}
.bdrop.on{background:var(--red);color:#fff;}
.nav{display:flex;gap:8px;align-items:center;}
.nav button{flex:1;padding:9px 0;font-size:13px;border:1px solid var(--bd);border-radius:8px;background:#fff;cursor:pointer;}
.nav .pos{font-size:12px;color:var(--muted);white-space:nowrap;padding:0 4px;}
.hint{font-size:11px;color:#9ca3af;text-align:center;}
/* lightbox */
#lb{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;z-index:100;overflow:auto;text-align:center;}
#lb img{max-width:98%;margin:1% auto;}
#lb .x{position:fixed;top:12px;right:20px;color:#fff;font-size:32px;cursor:pointer;}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:9px 18px;border-radius:99px;font-size:13px;opacity:0;transition:opacity .2s;z-index:120;pointer-events:none;}
.toast.show{opacity:.95;}
@media(max-width:1100px){#info{flex-basis:300px;}#sidebar{flex-basis:220px;}}
</style>
</head>
<body>
<header>
<div class="htop">
<button class="btn" id="btnSide" title="显示/隐藏目录">☰</button>
<h1>2号人事部 · 功能审核 <span class="sub">一页一功能 · 决策实时写入数据库</span></h1>
<div class="progress"><i id="pbar"></i></div>
<div class="counts">已审 <b id="cReviewed">0</b>/<span id="cTotal">0</span> · 保留 <b id="cKeep">0</b> · 删除 <span class="drop" id="cDrop">0</span></div>
<button class="btn" id="btnAtt" title="查看已抓取的真实导出样本">📎 导出样本 <b id="attN">0</b></button>
<button class="btn primary" id="btnExport">⬇ 导出 selection.json</button>
</div>
</header>
<main>
<aside id="sidebar">
<div class="side-search"><input id="q" type="search" placeholder="搜索功能…"/></div>
<div id="side-list"></div>
</aside>
<section id="stage">
<div id="shotwrap">
<div class="shot-h"><span id="shotPath"></span><span>点击图片可放大 · 长图可在框内滚动</span></div>
<div class="shot-body" id="shotBody">
<img id="shotImg" onclick="zoom()" onerror="noShot()"/>
<div id="noshot" style="display:none">(此功能暂无整页截图)</div>
</div>
</div>
<div id="deepwrap" style="display:none">
<div class="deep-h">深层界面(点击按钮抓取)<span id="deepCnt"></span></div>
<div id="deepGrid"></div>
</div>
</section>
<aside id="info">
<div class="info-body">
<div class="crumb" id="crumb"></div>
<h2 class="fname" id="fname"></h2>
<div class="furl" id="furl"></div>
<span class="badge" id="badge"></span>
<div class="sec"><div class="lab">功能释义</div><div class="txt" id="desc"></div></div>
<div class="sec und"><div class="lab">我的理解 / 开发提示</div><div class="txt" id="und"></div></div>
<div class="sec att"><div class="lab">导出样本(真实数据)<span class="cnt" id="attCnt"></span></div>
<div class="att-list" id="attList"></div></div>
<div class="sec note"><div class="lab">审核备注 / 开发建议<span class="st" id="noteSt"></span></div>
<textarea id="note" placeholder="在此记录对该功能的处理建议(如:保留但简化字段 / 与某模块合并 / 二期再做…)"></textarea>
<div class="notebar">
<button class="btn-note" id="btnSaveNote" onclick="saveNote(false)">💾 保存备注到数据库</button>
<span class="tip">可提前预填,审核时会自动显示已备注信息</span>
</div>
</div>
</div>
<div class="decide">
<div class="big">
<button class="bkeep" id="bKeep" onclick="decide(1)">✓ 保留 (K)</button>
<button class="bdrop" id="bDrop" onclick="decide(0)">✕ 删除 (D)</button>
</div>
<div class="nav">
<button onclick="go(idx-1)">← 上一个</button>
<span class="pos" id="pos">0 / 0</span>
<button onclick="go(idx+1)">下一个 →</button>
</div>
<div class="hint">快捷键:← → 翻页 · K 保留 · D 删除(决策后自动跳下一个)</div>
</div>
</aside>
</main>
<div id="lb" onclick="this.style.display='none'"><span class="x">×</span><img id="lbimg"/></div>
<div id="attmodal">
<div class="box">
<div class="mh"><h3>📎 导出样本</h3><span class="sub" id="attSub"></span><span class="x" onclick="closeAtt()">×</span></div>
<div class="mb" id="attModalBody"></div>
</div>
</div>
<div id="pvmodal">
<div class="box">
<div class="mh"><h3 id="pvTitle">📊 预览</h3><span class="sub" id="pvSub"></span><span class="x" onclick="closePv()">×</span></div>
<div class="tabs" id="pvTabs"></div>
<div class="mb" id="pvBody"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let FEATS=[], idx=0;
let ATT={total:0,groups:[]}, ATT_BY_MOD={};
const $=s=>document.querySelector(s);
async function boot(){
const r=await fetch('/api/features'); const d=await r.json();
FEATS=d.items;
$('#cTotal').textContent=FEATS.length;
await loadAttachments();
buildSidebar();
const saved=parseInt(localStorage.getItem('twohr_review_idx')||'0');
go(isNaN(saved)?0:Math.min(saved,FEATS.length-1));
updateCounts();
}
async function loadAttachments(){
try{
const r=await fetch('/api/attachments'); ATT=await r.json();
}catch(e){ ATT={total:0,groups:[]}; }
ATT_BY_MOD={};
(ATT.groups||[]).forEach(g=>{ (ATT_BY_MOD[g.module]=ATT_BY_MOD[g.module]||[]).push(g); });
$('#attN').textContent=ATT.total||0;
}
function fmtSize(n){ return n>=1048576?(n/1048576).toFixed(1)+'MB':(n/1024).toFixed(1)+'KB'; }
function isSheet(n){ return /\.(xlsx|xls)$/i.test(n||''); }
function escj(s){ return (s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function attItem(fobj){
const sheet=isSheet(fobj.name), rel=encodeURI(fobj.rel);
const inner='<span class="ico">'+(sheet?'📊':'📄')+'</span><span class="an">'+esc(fobj.name)+'</span>'
+'<span class="asz">'+fmtSize(fobj.size)+'</span>';
const main=sheet
? '<span class="att-item att-prev" title="点击预览:'+esc(fobj.name)+'" onclick="previewFile(\''+escj(fobj.rel)+'\',\''+escj(fobj.name)+'\')">'+inner+'</span>'
: '<a class="att-item" href="/dl/'+rel+'" title="'+esc(fobj.name)+'">'+inner+'</a>';
return '<div class="att-row">'+main+'<a class="att-dl" href="/dl/'+rel+'" title="下载">⬇</a></div>';
}
function renderFeatAtt(){
const f=FEATS[idx]; const box=$('#attList'); const cnt=$('#attCnt'); if(!box) return;
const files=(f.downloads||[]);
cnt.textContent=files.length?('本页 '+files.length+' 个'):'';
if(!files.length){ box.innerHTML='<div class="att-empty">该页面未抓到导出文件(多为设置/模板页,或测试账号无数据)。<a class="att-browse" onclick="openAtt()">浏览全部导出 ›</a></div>'; return; }
let html=files.map(attItem).join('');
html+='<div class="att-more" onclick="openAtt()">浏览全站全部导出 ›</div>';
box.innerHTML=html;
}
function openAtt(filterMod){
const body=$('#attModalBody');
let groups=ATT.groups||[];
if(filterMod) groups=groups.filter(g=>g.module===filterMod);
$('#attSub').textContent=(filterMod?('模块:'+filterMod+' · '):'共 ')+ (filterMod?'':(ATT.total+' 个文件,'))+groups.length+' 个目录';
body.innerHTML=groups.map(g=>'<div class="grp"><div class="gh">'+esc(g.dir)+'<span class="gc">'+g.files.length+' 个</span></div>'
+'<div class="att-list">'+g.files.map(attItem).join('')+'</div></div>').join('')
|| '<div class="att-empty">暂无导出文件</div>';
$('#attmodal').style.display='block';
}
function closeAtt(){ $('#attmodal').style.display='none'; }
let PV={sheets:[],cur:0,name:''};
async function previewFile(rel,name){
$('#pvTitle').textContent='📊 '+name; $('#pvSub').textContent='加载中…';
$('#pvTabs').innerHTML=''; $('#pvBody').innerHTML='<div class="att-empty">正在解析…</div>';
$('#pvmodal').style.display='block';
let d;
try{ const r=await fetch('/api/preview?rel='+encodeURIComponent(rel)); d=await r.json(); }
catch(e){ d={ok:false,err:'请求失败'}; }
if(!d.ok){ $('#pvSub').textContent=''; $('#pvBody').innerHTML='<div class="att-empty">无法预览:'+esc(d.err||'未知错误')+'</div>'; return; }
PV={sheets:d.sheets||[],cur:0,name:d.name};
const st=PV.sheets.reduce((a,s)=>a+ (s.total_rows||0),0);
$('#pvSub').textContent=PV.sheets.length+' 个工作表 · 约 '+st+' 行(预览上限 '+d.max_rows+'×'+d.max_cols+')';
$('#pvTabs').innerHTML=PV.sheets.map((s,i)=>'<div class="tab'+(i===0?' on':'')+'" onclick="pvTab('+i+')">'+esc(s.name)+'</div>').join('');
renderSheet(0);
}
function pvTab(i){ PV.cur=i; document.querySelectorAll('#pvTabs .tab').forEach((t,j)=>t.classList.toggle('on',j===i)); renderSheet(i); }
function renderSheet(i){
const s=PV.sheets[i]; const box=$('#pvBody');
if(!s||!s.rows||!s.rows.length){ box.innerHTML='<div class="att-empty">空工作表</div>'; return; }
const rows=s.rows;
let h='<table><thead><tr><th class="rowno">#</th>';
rows[0].forEach(c=>h+='<th>'+esc(c)+'</th>'); h+='</tr></thead><tbody>';
for(let r=1;r<rows.length;r++){ h+='<tr><td class="rowno">'+r+'</td>'; rows[r].forEach(c=>h+='<td title="'+esc(c)+'">'+esc(c)+'</td>'); h+='</tr>'; }
h+='</tbody></table>';
if((s.total_rows||0)>rows.length) h+='<div class="att-empty">仅预览前 '+(rows.length-1)+' 行,完整数据请下载文件</div>';
box.innerHTML=h;
}
function closePv(){ $('#pvmodal').style.display='none'; }
function buildSidebar(){
const list=$('#side-list'); list.innerHTML='';
let curMod=null, modBox=null;
FEATS.forEach((f,i)=>{
if(f.module!==curMod){
curMod=f.module;
const first=i;
const mod=document.createElement('div'); mod.className='mod';
const h=document.createElement('div'); h.className='mod-h';
const cnt=FEATS.filter(x=>x.module===curMod).length;
h.innerHTML='<span>'+esc(curMod)+'</span><span class="mc">'+cnt+'</span>';
h.title='跳到该模块第一个功能';
h.onclick=()=>go(first);
mod.appendChild(h); list.appendChild(mod); modBox=mod;
}
const el=document.createElement('div'); el.className='leaf'; el.dataset.i=i;
const nd=(f.downloads||[]).length;
const clip=nd?'<span class="leaf-att" title="有 '+nd+' 个导出文件">📎'+nd+'</span>':'';
el.innerHTML='<span class="dot"></span><span class="lname">'+esc(f.name)+'</span>'+clip;
el.onclick=()=>go(i);
modBox.appendChild(el);
});
}
function render(){
const f=FEATS[idx]; if(!f) return;
$('#crumb').innerHTML=esc(f.module)+(f.group?'<span class="sep">›</span>'+esc(f.group):'')+'<span class="sep">›</span>'+esc(f.name);
$('#fname').textContent=f.name;
$('#furl').textContent=f.url;
$('#desc').textContent=f.description||'—';
$('#und').textContent=f.understanding||'—';
$('#note').value=f.note||'';
$('#noteSt').textContent=f.note?'已有备注':'';
renderFeatAtt();
markNoteDirty();
$('#pos').textContent=(idx+1)+' / '+FEATS.length;
$('#shotPath').textContent=f.full_path;
const img=$('#shotImg'), wrap=$('#shotwrap');
if(f.shot){
$('#noshot').style.display='none'; img.style.display='block';
if(decodeURIComponent(img.src)!==location.origin+decodeURIComponent(f.shot)){
wrap.classList.add('loading');
img.onload=()=>wrap.classList.remove('loading');
img.src=f.shot;
}
}
else { noShot(); }
renderDeep(f);
paintDecision(f);
// sidebar active
document.querySelectorAll('.leaf').forEach(el=>el.classList.toggle('active',+el.dataset.i===idx));
const act=document.querySelector('.leaf.active'); if(act) act.scrollIntoView({block:'nearest'});
$('#stage').scrollTop=0;
const sb=$('#shotBody'); if(sb) sb.scrollTop=0;
localStorage.setItem('twohr_review_idx',idx);
}
function paintDecision(f){
const keep=f.keep===1;
$('#bKeep').classList.toggle('off',!keep);
$('#bDrop').classList.toggle('on',!keep);
const b=$('#badge');
b.className='badge '+(keep?'keep':'drop');
b.textContent=keep?'当前:保留':'当前:删除';
}
function noShot(){ $('#shotImg').style.display='none'; $('#noshot').style.display='block'; }
function renderDeep(f){
const wrap=$('#deepwrap'), grid=$('#deepGrid'); const ds=(f.deep_shots||[]);
if(!ds.length){ wrap.style.display='none'; grid.innerHTML=''; return; }
$('#deepCnt').textContent=ds.length+' 张';
grid.innerHTML=ds.map(d=>{
const cap=d.kind_cn+(d.label&&d.label!==d.kind?(' · '+esc(d.label)):'');
return '<div class="deep-card" onclick="zoom(\''+d.shot+'\')"><img src="'+d.shot+'" loading="lazy"/><div class="cap"><b>'+esc(d.kind_cn)+'</b>'+(d.label&&d.label!==d.kind?(' '+esc(d.label)):'')+'</div></div>';
}).join('');
wrap.style.display='block';
}
async function decide(keep){
const f=FEATS[idx]; f.keep=keep?1:0;
paintDecision(f); markSidebar(idx); updateCounts();
toast(keep?'已保留 ✓':'已删除 ✕');
try{ await fetch('/api/keep',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({node_key:f.node_key,keep:f.keep})}); }catch(e){ toast('⚠ 写入失败'); }
// 自动跳下一个未到末尾
if(idx<FEATS.length-1) setTimeout(()=>go(idx+1),180);
}
function markSidebar(i){ const el=document.querySelector('.leaf[data-i="'+i+'"]');
if(el){ el.classList.toggle('dropped',FEATS[i].keep===0);
el.classList.toggle('hasnote',!!(FEATS[i].note && FEATS[i].note.trim())); } }
function go(i){ if(i<0||i>=FEATS.length) return; idx=i; render(); }
function updateCounts(){
const keep=FEATS.filter(f=>f.keep===1).length;
$('#cKeep').textContent=keep; $('#cDrop').textContent=FEATS.length-keep;
const reviewed=parseInt(localStorage.getItem('twohr_review_idx')||'0')+1;
$('#cReviewed').textContent=Math.min(idx+1,FEATS.length);
$('#pbar').style.width=((idx+1)/FEATS.length*100)+'%';
FEATS.forEach((f,i)=>markSidebar(i));
}
function zoom(src){ const s=src||FEATS[idx].shot; if(!s)return; $('#lbimg').src=s; $('#lb').style.display='block'; }
function esc(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show');
clearTimeout(tT); tT=setTimeout(()=>t.classList.remove('show'),1100); }
$('#btnSide').onclick=()=>$('#sidebar').classList.toggle('collapsed');
$('#btnAtt').onclick=()=>openAtt(null);
$('#attmodal').onclick=e=>{ if(e.target.id==='attmodal') closeAtt(); };
$('#btnExport').onclick=async()=>{
const r=await fetch('/api/export',{method:'POST'}); const d=await r.json();
if(d.ok) toast('已导出 docs/selection.json(保留 '+d.keep+'/'+d.total+')');
};
$('#q').addEventListener('input',e=>{
const q=e.target.value.trim().toLowerCase();
document.querySelectorAll('.leaf').forEach(el=>{
const f=FEATS[+el.dataset.i];
const hit=!q||(f.name+f.module+(f.group||'')+f.description).toLowerCase().includes(q);
el.style.display=hit?'':'none';
});
});
let noteT;
function markNoteDirty(){
const f=FEATS[idx]; const btn=$('#btnSaveNote'); if(!f||!btn) return;
const dirty=$('#note').value!==(f.note||'');
btn.classList.toggle('dirty',dirty); btn.classList.toggle('saved',!dirty&&!!(f.note&&f.note.trim()));
if(dirty) $('#noteSt').textContent='● 未保存';
}
async function saveNote(silent){
const f=FEATS[idx]; if(!f) return;
const val=$('#note').value;
if(val===(f.note||'')){ markNoteDirty(); return; }
f.note=val; markSidebar(idx);
$('#noteSt').textContent='保存中…'; $('#btnSaveNote').classList.remove('dirty');
try{ await fetch('/api/note',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({node_key:f.node_key,note:val})});
$('#noteSt').textContent='已保存到数据库 ✓'; markNoteDirty();
if(!silent) toast('备注已保存到数据库 ✓'); }
catch(e){ $('#noteSt').textContent='⚠ 保存失败'; if(!silent) toast('⚠ 备注保存失败'); }
}
$('#note').addEventListener('input',markNoteDirty);
$('#note').addEventListener('blur',()=>{ clearTimeout(noteT); saveNote(true); }); // 失焦兵底保存, 防丢失
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
if(e.key==='ArrowLeft') go(idx-1);
else if(e.key==='ArrowRight') go(idx+1);
else if(e.key==='k'||e.key==='K') decide(1);
else if(e.key==='d'||e.key==='D') decide(0);
});
boot();
</script>
</body>
</html>