const state = {
data: null,
filter: "UNREVIEWED",
visibleItems: [],
currentIndex: 0,
selectedStatus: null,
selectedLayers: new Set(),
selectedStdKey: null,
selectedComponentKind: null
};
const $ = function(id){ return document.getElementById(id); };
function esc(v){
return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch){
return {"&":"&","<":"<",">":">","\"":""","'":"'"}[ch];
});
}
async function api(path, options){
const res = await fetch(path, options || {});
const data = await res.json();
if(!res.ok) throw new Error(data.error || ("HTTP " + res.status));
return data;
}
async function load(){
state.data = await api("/api/bootstrap");
populateLayerFilter();
applyFilter(true);
updateProgress(state.data.progress);
}
function decision(item){ return item.decision || null; }
function applyFilter(resetIndex){
const f = state.filter;
state.visibleItems = state.data.items.filter(function(item){
const d = decision(item);
if(f === "ALL") return true;
if(f === "UNREVIEWED") return !d;
return d && d.status === f;
});
if(resetIndex) state.currentIndex = 0;
if(state.currentIndex >= state.visibleItems.length) state.currentIndex = Math.max(0, state.visibleItems.length - 1);
renderQueue();
renderCurrent();
}
function renderQueue(){
$("queueCount").textContent = state.visibleItems.length + " 项";
$("queue").innerHTML = state.visibleItems.map(function(item, idx){
const d = decision(item);
const st = d ? d.status : "未确认";
return '';
}).join("");
document.querySelectorAll(".queue-item").forEach(function(btn){
btn.addEventListener("click", function(){
state.currentIndex = Number(btn.dataset.index);
renderQueue();
renderCurrent();
});
});
}
function renderCurrent(){
const item = state.visibleItems[state.currentIndex];
const empty = !item;
$("emptyState").classList.toggle("hidden", !empty);
$("reviewContent").classList.toggle("hidden", empty);
if(empty) return;
$("cmNameZh").textContent = item.display_name_zh || "未命名视觉对象";
$("anonId").textContent = item.anonymous_id;
$("cmOfficialDesc").textContent = item.official_description_en ? ("S-52: " + item.official_description_en) : (item.name_source || "");
$("cmImage").src = "/sprite/cm95/" + encodeURIComponent(item.anonymous_id) + ".png";
$("componentRoles").textContent = item.component_roles.join(", ") || "—";
$("geometryRoles").textContent = item.geometry_roles.join(", ") || "—";
$("occurrenceCount").textContent = item.occurrence_count;
const sem = item.semantic || {};
$("semanticCategory").textContent = sem.category_zh || "尚未建立语义分类";
$("semanticReason").textContent = sem.reason_zh || "当前没有可靠的 Provider 语义规则。";
const componentHint = $("semanticComponentHint");
if(sem.component_kind){
const kind = (state.data.component_kinds || []).find(function(x){ return x.id === sem.component_kind; });
componentHint.classList.remove("hidden");
componentHint.textContent = "建议操作:COMPONENT · " + (kind ? kind.name_zh : sem.component_kind);
} else {
componentHint.classList.add("hidden");
componentHint.textContent = "";
}
$("decisionConflict").classList.toggle("hidden", !item.decision_conflict);
const s = item.suggestion;
if(s){
const layers = s.standard_layer ? [s.standard_layer] : (s.standard_layers || []);
$("suggestionBox").classList.remove("hidden");
const layerText = layers.map(function(layer){
return (state.data.standard_layer_zh[layer] || layer) + "(" + layer + ")";
}).join(" / ");
const nameText = s.standard_name_zh || "未指定标准对象名";
$("suggestionBox").innerHTML = "旧视觉匹配建议(辅助证据)
" +
'' + esc(nameText) + '
' +
esc(s.status || "—") + " → " + esc(layerText || "—") +
(s.review_stage ? '
' + esc(s.review_stage) + "" : "");
} else {
$("suggestionBox").classList.add("hidden");
$("suggestionBox").innerHTML = "";
}
const d = decision(item);
state.selectedStatus = d ? d.status : null;
state.selectedLayers = new Set(d ? (d.standard_layers || []) : []);
state.selectedStdKey = d ? d.standard_resource_key : null;
state.selectedComponentKind = d ? (d.component_kind || null) : null;
$("note").value = d ? (d.note || "") : "";
renderDecisionBadge(d);
renderStatusButtons();
renderModeFields();
renderComponentKindSelection();
renderLayerSelection();
renderLayerSuggestions(item);
renderCandidates(item);
renderSelectedStd();
$("auditDetails").open = false;
$("auditText").textContent = "尚未读取。";
$("message").textContent = "";
$("message").className = "message";
}
function renderDecisionBadge(d){
const badge = $("decisionBadge");
const st = d ? d.status : null;
badge.className = "badge " + (st ? st.toLowerCase() : "neutral");
badge.textContent = st || "未确认";
}
function renderStatusButtons(){
document.querySelectorAll(".status-btn").forEach(function(btn){
btn.classList.toggle("active", btn.dataset.status === state.selectedStatus);
});
}
function renderModeFields(){
const isComponent = state.selectedStatus === "COMPONENT";
$("componentKindField").classList.toggle("hidden", !isComponent);
$("standardLayerField").classList.toggle("hidden", isComponent);
$("standardObjectField").classList.toggle("hidden", isComponent);
}
function renderComponentKindSelection(){
const kinds = state.data.component_kinds || [];
$("componentKindSelection").innerHTML = kinds.map(function(k){
return '';
}).join("");
document.querySelectorAll(".component-kind-chip").forEach(function(btn){
btn.addEventListener("click", function(){
const kind = btn.dataset.kind;
state.selectedComponentKind = state.selectedComponentKind === kind ? null : kind;
renderComponentKindSelection();
});
});
}
function renderLayerSelection(){
$("layerSelection").innerHTML = state.data.standard_layers.map(function(layer){
const zh = state.data.standard_layer_zh[layer] || layer;
return '';
}).join("");
document.querySelectorAll(".layer-chip").forEach(function(btn){
btn.addEventListener("click", function(){
const layer = btn.dataset.layer;
if(state.selectedStatus === "UNKNOWN" || state.selectedStatus === "COMPONENT") return;
if(state.selectedStatus === "MATCH" || state.selectedStatus === "PARTIAL"){
state.selectedLayers = new Set([layer]);
if(state.selectedStdKey){
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
if(sr && sr.standard_layer !== layer) state.selectedStdKey = null;
}
} else {
if(state.selectedLayers.has(layer)) state.selectedLayers.delete(layer);
else state.selectedLayers.add(layer);
}
renderLayerSelection();
renderSelectedStd();
});
});
}
function renderLayerSuggestions(item){
const rows = item.layer_suggestions || [];
const box = $("layerSuggestionList");
if(item.semantic && item.semantic.component_kind){
const kind = (state.data.component_kinds || []).find(function(x){ return x.id === item.semantic.component_kind; });
const kindName = kind ? kind.name_zh : item.semantic.component_kind;
box.innerHTML = '
' +
'
建议先作为组件处理
' +
'
COMPONENT · ' + esc(kindName) + '
' +
'
' + esc(item.semantic.reason_zh || '') + '
' +
'
' +
'
' + renderLayerRows(rows);
const btn = $("applyComponentSuggestion");
if(btn){ btn.addEventListener("click", function(){
chooseStatus("COMPONENT");
state.selectedComponentKind = item.semantic.component_kind;
renderComponentKindSelection();
}); }
bindLayerSuggestionClicks();
return;
}
if(!rows.length){
box.innerHTML = '当前 S-52 语义没有可靠的 NavSea Layer 建议。请人工选择,或保留 UNKNOWN。
';
return;
}
box.innerHTML = renderLayerRows(rows);
bindLayerSuggestionClicks();
}
function renderLayerRows(rows){
return rows.map(function(r, idx){
const top = idx === 0;
const score = (Number(r.combined_score || 0) * 100).toFixed(1);
const sem = (Number(r.semantic_score || 0) * 100).toFixed(0);
const vis = (Number(r.visual_score || 0) * 100).toFixed(0);
return '';
}).join('');
}
function bindLayerSuggestionClicks(){
document.querySelectorAll(".layer-suggestion-card").forEach(function(card){
card.addEventListener("click", function(){
const layer = card.dataset.layer;
state.selectedLayers = new Set([layer]);
if(!state.selectedStatus || state.selectedStatus === "UNKNOWN") state.selectedStatus = "PARTIAL";
if(state.selectedStdKey){
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
if(sr && sr.standard_layer !== layer) state.selectedStdKey = null;
}
renderStatusButtons();
renderModeFields();
renderLayerSelection();
renderSelectedStd();
showMessage("已选择 Layer:" + (state.data.standard_layer_zh[layer] || layer) + "。请确认后保存。", true);
});
});
}
function renderCandidates(item){
const seen = new Set();
const candidates = [];
if(item.suggestion && item.suggestion.standard_resource_key){
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === item.suggestion.standard_resource_key; });
const raw = (item.candidates || []).find(function(x){ return x.resource_key === item.suggestion.standard_resource_key; });
if(sr){
candidates.push({
resource_key:sr.resource_key,standard_layer:sr.standard_layer,standard_layer_zh:sr.standard_layer_zh,display_name_zh:sr.display_name_zh,
score:raw ? raw.score : null,text_score:raw ? raw.text_score : null,recommendation_score:raw ? raw.recommendation_score : null,
source:"已有建议"
});
seen.add(sr.resource_key);
}
}
(item.candidates || []).forEach(function(c){
if(!seen.has(c.resource_key)){
candidates.push({resource_key:c.resource_key,standard_layer:c.standard_layer,standard_layer_zh:c.standard_layer_zh,display_name_zh:c.display_name_zh,score:c.score,text_score:c.text_score,recommendation_score:c.recommendation_score,source:"图标参考"});
seen.add(c.resource_key);
}
});
if(state.selectedStdKey && !seen.has(state.selectedStdKey)){
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
if(sr) candidates.unshift({resource_key:sr.resource_key,standard_layer:sr.standard_layer,standard_layer_zh:sr.standard_layer_zh,display_name_zh:sr.display_name_zh,source:"当前选择"});
}
if(!candidates.length){
$("candidateGrid").innerHTML = '没有 Standard Visual Object 图标参考。Layer 仍可单独人工确认。
';
return;
}
$("candidateGrid").innerHTML = candidates.map(function(c){
const selected = c.resource_key === state.selectedStdKey;
const recommended = c.resource_key === item.recommended_resource_key;
const score = c.score != null ? (" · 视觉 " + (Number(c.score)*100).toFixed(1) + "%") : "";
const recScore = c.recommendation_score != null ? (" · 综合 " + (Number(c.recommendation_score)*100).toFixed(1) + "%") : "";
return '' +
'
' +
'
' +
esc(c.display_name_zh || c.standard_layer_zh || c.resource_key) +
(recommended ? '最可能' : '') +
'
' +
'
' + esc(c.resource_key) + '
' +
'
' + esc(c.standard_layer_zh || c.standard_layer) + ' · ' + esc(c.standard_layer) + '' +
'
' + esc(c.source) + score + recScore + ' ';
}).join("");
document.querySelectorAll("#candidateGrid .candidate").forEach(function(card){
card.addEventListener("click", function(){ selectStandard(card.dataset.key, card.dataset.layer); });
});
}
function selectStandard(key, layer){
if(state.selectedStatus === "COMPONENT" || state.selectedStatus === "UNKNOWN") return;
state.selectedStdKey = key;
if(state.selectedStatus !== "AMBIGUOUS" && state.selectedStatus !== "UNKNOWN"){
state.selectedLayers = new Set([layer]);
} else if(state.selectedStatus === "AMBIGUOUS"){
state.selectedLayers.add(layer);
}
renderCandidates(state.visibleItems[state.currentIndex]);
renderLayerSelection();
renderSelectedStd();
}
function renderSelectedStd(){
const el = $("selectedStd");
if(!state.selectedStdKey){
el.textContent = "未选择";
el.classList.add("muted");
return;
}
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
if(!sr) return;
el.classList.remove("muted");
el.innerHTML = "" + esc(sr.display_name_zh || sr.resource_key) + " · " +
esc(sr.standard_layer_zh || sr.standard_layer) +
'' + esc(sr.resource_key) + ' · ' + esc(sr.standard_layer) + '
';
}
function useLayerOnly(){
if(state.selectedLayers.size !== 1){
showMessage("请先选择一个标准 Layer。", false);
return;
}
state.selectedStdKey = null;
if(!state.selectedStatus || state.selectedStatus === "MATCH") state.selectedStatus = "PARTIAL";
renderStatusButtons();
renderCandidates(state.visibleItems[state.currentIndex]);
renderSelectedStd();
showMessage("已设为:仅确认 Layer,不绑定标准图标。", true);
}
function chooseStatus(status){
state.selectedStatus = status;
if(status === "UNKNOWN" || status === "COMPONENT"){
state.selectedLayers.clear();
state.selectedStdKey = null;
} else {
state.selectedComponentKind = null;
if((status === "MATCH" || status === "PARTIAL") && state.selectedLayers.size > 1){
state.selectedLayers = new Set([Array.from(state.selectedLayers)[0]]);
}
}
renderStatusButtons();
renderModeFields();
renderComponentKindSelection();
renderLayerSelection();
renderCandidates(state.visibleItems[state.currentIndex]);
renderSelectedStd();
}
async function saveDecision(goNext){
const item = state.visibleItems[state.currentIndex];
if(!item) return;
if(!state.selectedStatus){ showMessage("请选择 MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN。", false); return; }
const payload = {
anonymous_id:item.anonymous_id,
status:state.selectedStatus,
standard_layers:Array.from(state.selectedLayers),
standard_resource_key:state.selectedStdKey,
component_kind:state.selectedComponentKind,
note:$("note").value || ""
};
try{
const res = await api("/api/review", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
item.decision = res.decision;
updateProgress(res.progress);
showMessage("已保存。", true);
if(goNext){
if(state.filter === "UNREVIEWED") applyFilter(false);
else {
if(state.currentIndex < state.visibleItems.length-1) state.currentIndex++;
renderQueue(); renderCurrent();
}
} else {
renderDecisionBadge(item.decision); renderQueue();
}
}catch(e){ showMessage(e.message, false); }
}
async function clearDecision(){
const item = state.visibleItems[state.currentIndex];
if(!item || !item.decision) return;
try{
const res = await api("/api/unreview", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({anonymous_id:item.anonymous_id})});
item.decision = null;
updateProgress(res.progress);
applyFilter(false);
}catch(e){ showMessage(e.message, false); }
}
function showMessage(text, ok){
$("message").textContent = text;
$("message").className = "message " + (ok?"ok":"err");
}
function updateProgress(p){
const pct = p.total ? (p.reviewed/p.total*100) : 0;
$("progressText").textContent = p.reviewed + "/" + p.total + " 已确认 · MATCH " + p.MATCH +
" · PARTIAL " + p.PARTIAL + " · AMBIGUOUS " + p.AMBIGUOUS + " · COMPONENT " + p.COMPONENT + " · UNKNOWN " + p.UNKNOWN;
$("progressBar").style.width = pct + "%";
}
function move(delta){
if(!state.visibleItems.length) return;
state.currentIndex = Math.max(0, Math.min(state.visibleItems.length-1, state.currentIndex+delta));
renderQueue(); renderCurrent();
const active = document.querySelector(".queue-item.active");
if(active) active.scrollIntoView({block:"nearest"});
}
function populateLayerFilter(){
$("layerFilter").innerHTML = '' +
state.data.standard_layers.map(function(x){ return '"; }).join("");
}
function openLibrary(){ renderLibrary(); $("libraryDialog").showModal(); }
function renderLibrary(){
const layer = $("layerFilter").value;
const q = $("librarySearch").value.trim().toLowerCase();
const rows = state.data.standard_resources.filter(function(x){
return (!layer || x.standard_layer === layer) &&
(!q || x.resource_key.toLowerCase().includes(q) || x.standard_layer.toLowerCase().includes(q) ||
(x.display_name_zh || "").toLowerCase().includes(q) || (x.standard_layer_zh || "").toLowerCase().includes(q));
});
$("libraryGrid").innerHTML = rows.map(function(x){
return '' +
'
 + '.png)
' +
'
' + esc(x.display_name_zh || x.resource_key) + '
' +
'
' + esc(x.resource_key) + '
' +
'
' + esc(x.standard_layer_zh || x.standard_layer) + ' · ' + esc(x.standard_layer) + ' ';
}).join("");
document.querySelectorAll(".library-item").forEach(function(el){
el.addEventListener("click", function(){
selectStandard(el.dataset.key, el.dataset.layer);
$("libraryDialog").close();
});
});
}
async function loadAudit(){
const item = state.visibleItems[state.currentIndex];
if(!item) return;
try{
const data = await api("/api/audit?id=" + encodeURIComponent(item.anonymous_id));
$("auditText").textContent = JSON.stringify(data, null, 2);
}catch(e){ $("auditText").textContent = e.message; }
}
async function abandonAllUnreviewed(){
const p = state.data && state.data.progress ? state.data.progress : null;
const count = p ? p.unreviewed : 0;
if(!count){ alert("当前没有未确认项。 "); return; }
if(!confirm("确定放弃剩余 " + count + " 个未确认项吗?\n\n它们会全部设为 UNKNOWN;已经人工确认的项目不会改变。")) return;
try{
const res = await api("/api/abandon-unreviewed", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dry_run:false})});
state.data = await api("/api/bootstrap");
updateProgress(state.data.progress);
applyFilter(true);
alert("已放弃 " + res.affected + " 个未确认项,全部标记为 UNKNOWN。 ");
}catch(e){ alert("批量放弃失败:" + e.message); }
}
async function finalize(){
if(!confirm("生成当前人工确认结果?未确认项目会保持 UNKNOWN,不会自动继承机器建议。")) return;
try{
const res = await api("/api/finalize", {method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});
alert("已生成。\n\n" + res.assignment_dir);
}catch(e){ alert("生成失败:" + e.message); }
}
document.addEventListener("click", function(e){
const f = e.target.closest(".filter");
if(f){
document.querySelectorAll(".filter").forEach(function(x){ x.classList.remove("active"); });
f.classList.add("active"); state.filter = f.dataset.filter; applyFilter(true);
}
const s = e.target.closest(".status-btn");
if(s) chooseStatus(s.dataset.status);
});
$("prevBtn").addEventListener("click", function(){ move(-1); });
$("nextBtn").addEventListener("click", function(){ move(1); });
$("saveBtn").addEventListener("click", function(){ saveDecision(true); });
$("saveStayBtn").addEventListener("click", function(){ saveDecision(false); });
$("clearBtn").addEventListener("click", clearDecision);
$("layerOnlyBtn").addEventListener("click", useLayerOnly);
$("openLibraryBtn").addEventListener("click", openLibrary);
$("closeLibraryBtn").addEventListener("click", function(){ $("libraryDialog").close(); });
$("layerFilter").addEventListener("change", renderLibrary);
$("librarySearch").addEventListener("input", renderLibrary);
$("loadAuditBtn").addEventListener("click", loadAudit);
$("abandonAllBtn").addEventListener("click", abandonAllUnreviewed);
$("finalizeBtn").addEventListener("click", finalize);
document.addEventListener("keydown", function(e){
const tag = document.activeElement ? document.activeElement.tagName : "";
if(["TEXTAREA","INPUT","SELECT"].includes(tag)) return;
if(e.key === "m" || e.key === "M") chooseStatus("MATCH");
else if(e.key === "p" || e.key === "P") chooseStatus("PARTIAL");
else if(e.key === "a" || e.key === "A") chooseStatus("AMBIGUOUS");
else if(e.key === "c" || e.key === "C") chooseStatus("COMPONENT");
else if(e.key === "u" || e.key === "U") chooseStatus("UNKNOWN");
else if(e.key === "ArrowLeft") move(-1);
else if(e.key === "ArrowRight") move(1);
else if(e.key === "Enter") saveDecision(true);
});
load().catch(function(e){
document.body.innerHTML = '' + esc(e.stack || e.message) + "
";
});