단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며 어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본 app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B). 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧 계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록 2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라 순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정: - test_index_loads_every_app_part_exactly_once_in_declared_order 각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가 전부 배선됐는지 + 구 app.js 가 남아있지 않은지 - test_app_parts_stay_within_the_file_size_budget (800줄 상한) 정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는 '어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드 순서대로 이어붙여 읽는 _read_app() 으로 바꿨다. 검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함). 운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0건.
450 lines
8.4 KiB
JavaScript
450 lines
8.4 KiB
JavaScript
// Part 8 of 15 of the operator console app script.
|
|
//
|
|
// View/tab switching, case selection, decisions, manual search, rerun.
|
|
//
|
|
// Load order is significant: these are classic (non-module) scripts sharing one
|
|
// global scope, and index.html must keep them in the order listed above/below.
|
|
// Splitting is a pure line-range extraction of the former single app.js.
|
|
|
|
function switchView(view, updateHash = true) {
|
|
|
|
if (view === "case" || view === "evidence") view = "workbench";
|
|
|
|
let knowledgeTabToApply = "";
|
|
|
|
if (view === "corrections") {
|
|
|
|
view = "knowledge";
|
|
|
|
// Setting state alone left the visible panel on whatever knowledge tab was
|
|
// last shown — the tab DOM is switched by switchKnowledgeTab, which must run
|
|
// (applied at the end, after the view panels are toggled).
|
|
|
|
knowledgeTabToApply = "corrections";
|
|
|
|
}
|
|
|
|
if (!viewNames.has(view)) return;
|
|
|
|
state.currentView = view;
|
|
|
|
// Stats are read on demand (a fresh cross-tab each open) rather than shipped
|
|
// in every bootstrap payload.
|
|
|
|
if (view === "stats") {
|
|
|
|
void loadDecisionStats();
|
|
|
|
}
|
|
|
|
document.querySelectorAll(".nav-button").forEach((button) => {
|
|
|
|
const active = button.dataset.view === view;
|
|
|
|
button.classList.toggle("active", active);
|
|
|
|
if (active) {
|
|
|
|
button.setAttribute("aria-current", "page");
|
|
|
|
} else {
|
|
|
|
button.removeAttribute("aria-current");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
|
|
|
|
const active = panel.dataset.viewPanel === view;
|
|
|
|
panel.classList.toggle("active", active);
|
|
|
|
panel.hidden = !active;
|
|
|
|
});
|
|
|
|
if (knowledgeTabToApply) {
|
|
|
|
switchKnowledgeTab(knowledgeTabToApply);
|
|
|
|
}
|
|
|
|
if (updateHash && window.location.hash.replace("#", "") !== view) {
|
|
|
|
window.history.replaceState(null, "", `#${view}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function switchKnowledgeTab(tab) {
|
|
|
|
const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect";
|
|
|
|
state.knowledgeTab = nextTab;
|
|
|
|
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
|
|
|
|
const active = button.dataset.knowledgeTab === nextTab;
|
|
|
|
button.classList.toggle("active", active);
|
|
|
|
button.setAttribute("aria-selected", active ? "true" : "false");
|
|
|
|
});
|
|
|
|
document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => {
|
|
|
|
const active = panel.dataset.knowledgePanel === nextTab;
|
|
|
|
panel.classList.toggle("active", active);
|
|
|
|
panel.hidden = !active;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function switchWorkbenchTab(tab) {
|
|
|
|
const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence";
|
|
|
|
state.workbenchTab = nextTab;
|
|
|
|
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
|
|
|
|
const active = button.dataset.workbenchTab === nextTab;
|
|
|
|
button.classList.toggle("active", active);
|
|
|
|
button.setAttribute("aria-selected", active ? "true" : "false");
|
|
|
|
});
|
|
|
|
document.querySelectorAll("[data-workbench-panel]").forEach((panel) => {
|
|
|
|
const panelName = panel.dataset.workbenchPanel;
|
|
|
|
const active = panelName === nextTab;
|
|
|
|
panel.classList.toggle("active", active);
|
|
|
|
panel.hidden = !active;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectCase(caseId) {
|
|
|
|
state.selectedCaseId = caseId;
|
|
|
|
suggestedQueryRunSummary = null;
|
|
|
|
// Reset per-case inputs. The decision memo is submitted with whatever case is
|
|
// selected at click time, so a memo typed for case A and left in the box was
|
|
// recorded verbatim as the rejection/correction reason for case B.
|
|
|
|
resetCaseScopedInputs();
|
|
|
|
switchView("workbench");
|
|
|
|
switchWorkbenchTab("evidence");
|
|
renderAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
function resetCaseScopedInputs() {
|
|
|
|
const memo = document.getElementById("decision-memo");
|
|
|
|
if (memo) {
|
|
|
|
memo.value = "";
|
|
|
|
}
|
|
|
|
const memoError = document.getElementById("memo-error");
|
|
|
|
if (memoError) {
|
|
|
|
memoError.textContent = "";
|
|
|
|
}
|
|
|
|
const manualQuery = document.getElementById("manual-query");
|
|
|
|
if (manualQuery) {
|
|
|
|
manualQuery.value = "";
|
|
|
|
}
|
|
|
|
const manualStatus = document.getElementById("manual-query-status");
|
|
|
|
if (manualStatus) {
|
|
|
|
manualStatus.textContent = "";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isDecisionInFlight = false;
|
|
|
|
async function setDecision(decision, requiresMemo = false) {
|
|
|
|
// In-flight guard: a double-click, or clicking two different decisions
|
|
// quickly, would otherwise POST two conflicting decisions concurrently.
|
|
if (isDecisionInFlight) return;
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
const memo = document.getElementById("decision-memo").value.trim();
|
|
|
|
const error = document.getElementById("memo-error");
|
|
|
|
|
|
|
|
if (!submission) {
|
|
|
|
error.textContent = "판정할 제출 이미지가 없습니다.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (requiresMemo && !memo) {
|
|
|
|
error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
error.textContent = "";
|
|
|
|
isDecisionInFlight = true;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ decision, memo }),
|
|
|
|
});
|
|
|
|
document.getElementById("decision-memo").value = "";
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
error.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
isDecisionInFlight = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isManualSearchActive = false;
|
|
|
|
async function runManualSearch(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
if (isManualSearchActive) return;
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
const provider = document.getElementById("manual-query-provider").value;
|
|
|
|
const queryInput = document.getElementById("manual-query");
|
|
|
|
const status = document.getElementById("manual-query-status");
|
|
|
|
const query = queryInput.value.trim();
|
|
|
|
|
|
|
|
if (!submission) {
|
|
|
|
status.textContent = "검색을 연결할 제출 이미지가 없습니다.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!query) {
|
|
|
|
status.textContent = "쿼리를 입력하세요.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const providerConfig = providers.find((item) => item.id === provider);
|
|
|
|
if (!providerConfig || !providerConfig.enabled) {
|
|
|
|
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
isManualSearchActive = true;
|
|
|
|
try {
|
|
|
|
const targetCaseId = submission.id;
|
|
|
|
await apiJson("/api/search/manual", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
|
|
|
|
});
|
|
|
|
// The operator may have moved to another case while the search ran. Naming
|
|
// the case the evidence actually landed on prevents "added to the current
|
|
// case" from pointing at the wrong one, and the input is only cleared when
|
|
// it still belongs to the case we searched for.
|
|
|
|
if (state.selectedCaseId === targetCaseId) {
|
|
|
|
queryInput.value = "";
|
|
|
|
status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
|
|
|
|
} else {
|
|
|
|
status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
|
|
|
|
}
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
isManualSearchActive = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function normalizeManualSearchProvider(provider) {
|
|
|
|
return window.OperatorSearch.normalizeManualSearchProvider(provider);
|
|
|
|
}
|
|
|
|
|
|
|
|
function rerunHistoricalQuery(button) {
|
|
|
|
const queryInput = document.getElementById("manual-query");
|
|
|
|
const providerSelect = document.getElementById("manual-query-provider");
|
|
|
|
const provider = normalizeManualSearchProvider(button.dataset.rerunProvider);
|
|
|
|
queryInput.value = button.dataset.rerunQuery || "";
|
|
|
|
providerSelect.value = provider;
|
|
|
|
void runManualSearch(new Event("submit"));
|
|
|
|
}
|
|
|
|
|
|
|
|
async function rerunEnrichment() {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
const button = document.getElementById("rerun-enrichment");
|
|
|
|
if (!submission) {
|
|
|
|
showApiError("재분석할 제출 이미지가 없습니다.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (button) button.disabled = true;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
} finally {
|
|
|
|
if (button) button.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectedBulkSubmissionIds() {
|
|
|
|
const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId);
|
|
|
|
return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : [];
|
|
|
|
}
|
|
|
|
|