POSA_Copyrighter/web/operator-gui/app-navigation.js
changukyu 2735ce1194 feat: 운영자 신원 기록 + 게시처 성격(언론·공식 / 무단 게시처) 표시
④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청
   범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 →
   X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이
   막힌다. 인증이 아니라 기록 — 권한은 공유 토큰.
⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에
   '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지.

verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-26 14:46:20 +09:00

478 lines
9.5 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;
}
if (!requireOperatorName((message) => { error.textContent = message; })) 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] : [];
}
// 선례 승계. 별도 판정 경로를 만들지 않고 메모만 채워 setDecision 에 넘긴다 —
// 반려 메모 필수 검사, 판정 시점 스냅샷, 감사 이벤트 같은 기존 보호장치를 그대로
// 타야 하기 때문이다. 승계했다는 사실은 메모로 감사에 남는다.
async function inheritPriorDecision(decision) {
const submission = getSelectedCase();
const summary = submission ? priorDecisionSummary(submission) : null;
// 화면이 그 사이 바뀌었으면(다른 케이스 선택, 선례 판정 변경) 조용히 무시한다.
if (!summary || summary.inheritable !== decision) return;
const memoField = document.getElementById("decision-memo");
const peers = summary.priors.map((item) => item.submissionId).join(", ");
if (memoField && !memoField.value.trim()) {
memoField.value = `선례 승계: 동일 이미지 ${peers} 와 같은 판정`;
}
await setDecision(decision, decision === "rejected");
}