Files
pbf/tools/style-visual-object/reviewer/web/app.js

539 lines
24 KiB
JavaScript
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.
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 {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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 '<button class="queue-item ' + (idx===state.currentIndex?'active':'') + '" data-index="' + idx + '">' +
'<span class="queue-thumb"><img loading="lazy" src="/sprite/cm95/' + encodeURIComponent(item.anonymous_id) + '.png"></span>' +
'<span class="queue-info"><strong>' + esc(item.display_name_zh || item.anonymous_id) + '</strong>' +
'<small>' + esc(item.anonymous_id) + '</small>' +
'<small>' + esc(item.component_roles.join(", ") || "—") + ' · ' + item.occurrence_count + ' 次</small>' +
'<span class="queue-status">' + esc(st) + '</span></span></button>';
}).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 = "<b>旧视觉匹配建议(辅助证据)</b><br>" +
'<span class="candidate-name">' + esc(nameText) + '</span><br>' +
esc(s.status || "—") + " → " + esc(layerText || "—") +
(s.review_stage ? '<br><span class="muted">' + esc(s.review_stage) + "</span>" : "");
} 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 '<button class="component-kind-chip ' + (state.selectedComponentKind===k.id?'selected':'') +
'" data-kind="' + esc(k.id) + '">' + esc(k.name_zh) + '</button>';
}).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 '<button class="layer-chip ' + (state.selectedLayers.has(layer)?'selected':'') +
'" data-layer="' + esc(layer) + '">' + esc(zh) + '<span class="layer-code"> · ' + esc(layer) + '</span></button>';
}).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 = '<div class="component-recommendation">' +
'<div class="layer-rank-title">建议先作为组件处理</div>' +
'<div class="layer-rank-name">COMPONENT · ' + esc(kindName) + '</div>' +
'<div class="layer-rank-reason">' + esc(item.semantic.reason_zh || '') + '</div>' +
'<button id="applyComponentSuggestion" class="apply-suggestion-btn">采用 COMPONENT 建议</button>' +
'</div>' + 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 = '<div class="no-layer-suggestion">当前 S-52 语义没有可靠的 NavSea Layer 建议。请人工选择,或保留 UNKNOWN。</div>';
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 '<button class="layer-suggestion-card ' + (top?'top':'') + '" data-layer="' + esc(r.standard_layer) + '">' +
'<div class="layer-rank-head"><span class="layer-rank-index">' + (idx+1) + '</span>' +
'<span class="layer-rank-name">' + esc(r.standard_layer_zh || r.standard_layer) + '</span>' +
(top?'<span class="layer-rank-top">最可能</span>':'') + '</div>' +
'<div class="layer-rank-code">' + esc(r.standard_layer) + '</div>' +
'<div class="layer-rank-score">综合 ' + score + '% · 语义 ' + sem + '% · 视觉 ' + vis + '%</div>' +
'<div class="layer-rank-reason">' + esc(r.reason_zh || '') + '</div>' +
'</button>';
}).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 = '<div class="muted">没有 Standard Visual Object 图标参考。Layer 仍可单独人工确认。</div>';
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 '<div class="candidate ' + (selected?'selected ':'') + (recommended?'recommended':'') + '" data-key="' + esc(c.resource_key) +
'" data-layer="' + esc(c.standard_layer) + '">' +
'<div class="candidate-img"><img src="/sprite/std/' + encodeURIComponent(c.resource_key) + '.png"></div>' +
'<div class="candidate-name ' + (recommended?'recommended-name':'') + '">' +
esc(c.display_name_zh || c.standard_layer_zh || c.resource_key) +
(recommended ? '<span class="recommended-tag">最可能</span>' : '') +
'</div>' +
'<div class="candidate-code">' + esc(c.resource_key) + '</div>' +
'<small>' + esc(c.standard_layer_zh || c.standard_layer) + ' · ' + esc(c.standard_layer) + '</small>' +
'<small>' + esc(c.source) + score + recScore + '</small></div>';
}).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 = "<b>" + esc(sr.display_name_zh || sr.resource_key) + "</b> · " +
esc(sr.standard_layer_zh || sr.standard_layer) +
'<div class="candidate-code">' + esc(sr.resource_key) + ' · ' + esc(sr.standard_layer) + '</div>';
}
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 = '<option value="">全部标准 Layer</option>' +
state.data.standard_layers.map(function(x){ return '<option value="' + esc(x) + '">' + esc(state.data.standard_layer_zh[x] || x) + ' · ' + esc(x) + "</option>"; }).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 '<div class="library-item" data-key="' + esc(x.resource_key) + '" data-layer="' + esc(x.standard_layer) + '">' +
'<img loading="lazy" src="/sprite/std/' + encodeURIComponent(x.resource_key) + '.png">' +
'<div class="candidate-name">' + esc(x.display_name_zh || x.resource_key) + '</div>' +
'<div class="candidate-code">' + esc(x.resource_key) + '</div>' +
'<small>' + esc(x.standard_layer_zh || x.standard_layer) + ' · ' + esc(x.standard_layer) + '</small></div>';
}).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 = '<pre style="padding:20px;color:#b42318">' + esc(e.stack || e.message) + "</pre>";
});