단일 파일이 프로젝트 상한(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.7 KiB
JavaScript
450 lines
8.7 KiB
JavaScript
// Part 9 of 15 of the operator console app script.
|
|
//
|
|
// Keyboard review mode, duplicate grouping, and batch decisions.
|
|
//
|
|
// 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.
|
|
|
|
// --- Keyboard review mode ------------------------------------------------
|
|
|
|
|
|
|
|
const KEYBOARD_HELP = [
|
|
|
|
"J / ↓ : 다음 케이스",
|
|
|
|
"K / ↑ : 이전 케이스",
|
|
|
|
"A : 승인 H : 보류 R : 반려(메모로 이동)",
|
|
|
|
"N : 다음 미심사 케이스",
|
|
|
|
"? : 이 도움말 열기/닫기",
|
|
|
|
"Esc : 도움말 닫기",
|
|
|
|
].join("\n");
|
|
|
|
|
|
|
|
function isTypingTarget(target) {
|
|
|
|
if (!target) return false;
|
|
|
|
const tag = String(target.tagName || "").toLowerCase();
|
|
|
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
|
|
|
}
|
|
|
|
|
|
|
|
function moveSelection(offset) {
|
|
|
|
const rows = filteredSubmissions();
|
|
|
|
if (!rows.length) return;
|
|
|
|
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
|
|
|
|
// From "no selection", J lands on the first row and K on the last.
|
|
|
|
const nextIndex = index === -1
|
|
|
|
? (offset > 0 ? 0 : rows.length - 1)
|
|
|
|
: Math.min(rows.length - 1, Math.max(0, index + offset));
|
|
|
|
selectCase(rows[nextIndex].id);
|
|
|
|
}
|
|
|
|
|
|
|
|
function moveToNextUnreviewed() {
|
|
|
|
const rows = filteredSubmissions();
|
|
|
|
const index = rows.findIndex((item) => item.id === state.selectedCaseId);
|
|
|
|
const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
|
|
|
|
const next = ordered.find((item) => item.decisionStatus === "unreviewed");
|
|
|
|
if (next) {
|
|
|
|
selectCase(next.id);
|
|
|
|
} else {
|
|
|
|
setImportStatus("미심사 케이스가 없습니다.", "ok");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderReviewProgress() {
|
|
|
|
const target = document.getElementById("review-progress");
|
|
|
|
if (!target) return;
|
|
|
|
const rows = filteredSubmissions();
|
|
|
|
const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
|
|
|
|
const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
|
|
|
|
target.textContent = rows.length
|
|
|
|
? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
|
|
|
|
: "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleReviewShortcut(event) {
|
|
|
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
|
|
if (isTypingTarget(event.target)) return;
|
|
|
|
const key = event.key;
|
|
|
|
if (key === "Escape") {
|
|
|
|
const help = document.getElementById("keyboard-help");
|
|
|
|
if (help && !help.hidden) help.hidden = true;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (key === "?") {
|
|
|
|
const help = document.getElementById("keyboard-help");
|
|
|
|
if (help) {
|
|
|
|
help.textContent = KEYBOARD_HELP;
|
|
|
|
help.hidden = !help.hidden;
|
|
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// Navigation works from the queue and the workbench; decisions only make
|
|
// sense once a case is open.
|
|
|
|
if (key === "j" || key === "J" || key === "ArrowDown") {
|
|
|
|
moveSelection(1);
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (key === "k" || key === "K" || key === "ArrowUp") {
|
|
|
|
moveSelection(-1);
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (key === "n" || key === "N") {
|
|
|
|
moveToNextUnreviewed();
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (!state.selectedCaseId) return;
|
|
|
|
if (key === "a" || key === "A") {
|
|
|
|
setDecision("approved").then(() => moveToNextUnreviewed());
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (key === "h" || key === "H") {
|
|
|
|
setDecision("held").then(() => moveToNextUnreviewed());
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (key === "r" || key === "R") {
|
|
|
|
// Rejection needs a memo, so hand the operator the field instead of firing
|
|
// a request that would fail validation.
|
|
|
|
switchView("workbench");
|
|
|
|
const memo = document.getElementById("decision-memo");
|
|
|
|
if (memo) memo.focus();
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// --- Duplicate / prior-case grouping ------------------------------------
|
|
|
|
|
|
|
|
function duplicateBadgeHtml(submission) {
|
|
|
|
const group = submission.duplicateGroup;
|
|
|
|
if (!group || !group.size) return "";
|
|
|
|
const priors = group.priorDecisions || [];
|
|
|
|
const priorText = priors.length
|
|
|
|
? `선례: ${priors
|
|
|
|
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
|
|
|
|
.join(", ")}`
|
|
|
|
: "아직 판정된 선례 없음";
|
|
|
|
const tone = group.conflicting ? " conflicting" : "";
|
|
|
|
const conflictNote = group.conflicting ? " · 판정 불일치" : "";
|
|
|
|
return `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${conflictNote}</button>`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectDuplicateGroup(submissionId) {
|
|
|
|
// Tick the whole group so the operator can hand it to batch re-analysis or a
|
|
// batch decision in one move.
|
|
|
|
const submission = submissions.find((item) => item.id === submissionId);
|
|
|
|
const group = submission && submission.duplicateGroup;
|
|
|
|
if (!group) return;
|
|
|
|
const ids = new Set([submissionId, ...(group.peerIds || [])]);
|
|
|
|
document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
|
|
|
|
input.checked = ids.has(input.dataset.bulkId);
|
|
|
|
});
|
|
|
|
renderBulkDecisionCount();
|
|
|
|
const status = document.getElementById("bulk-decision-status");
|
|
|
|
if (status) {
|
|
|
|
const priors = group.priorDecisions || [];
|
|
|
|
status.textContent = priors.length
|
|
|
|
? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
|
|
|
|
.map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
|
|
|
|
.join(", ")}`
|
|
|
|
: `동일 이미지 ${ids.size}건을 선택했습니다.`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// --- Batch decisions ---------------------------------------------------
|
|
|
|
|
|
|
|
function checkedBulkSubmissionIds() {
|
|
|
|
// Unlike selectedBulkSubmissionIds(), this never falls back to the current
|
|
// case: a batch decision must act on an explicit selection only.
|
|
|
|
return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
|
|
|
|
(input) => input.dataset.bulkId,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderBulkDecisionCount() {
|
|
|
|
const target = document.getElementById("bulk-decision-count");
|
|
|
|
if (!target) return;
|
|
|
|
const ids = checkedBulkSubmissionIds();
|
|
|
|
const highRisk = ids.filter((id) => {
|
|
|
|
const submission = submissions.find((item) => item.id === id);
|
|
|
|
return submission && submission.riskBand === "high";
|
|
|
|
}).length;
|
|
|
|
target.textContent = highRisk
|
|
|
|
? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
|
|
|
|
: `선택 ${ids.length}건`;
|
|
|
|
}
|
|
|
|
|
|
|
|
let isBulkDecisionInFlight = false;
|
|
|
|
|
|
|
|
async function applyBulkDecision(decision) {
|
|
|
|
if (isBulkDecisionInFlight) return;
|
|
|
|
const ids = checkedBulkSubmissionIds();
|
|
|
|
const status = document.getElementById("bulk-decision-status");
|
|
|
|
const setStatus = (message) => {
|
|
|
|
if (status) status.textContent = message;
|
|
|
|
};
|
|
|
|
if (!ids.length) {
|
|
|
|
setStatus("판정할 케이스를 먼저 선택하세요.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const memo = document.getElementById("decision-memo")?.value.trim() || "";
|
|
|
|
if (decision === "rejected" && !memo) {
|
|
|
|
setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const highRisk = ids.filter((id) => {
|
|
|
|
const submission = submissions.find((item) => item.id === id);
|
|
|
|
return submission && submission.riskBand === "high";
|
|
|
|
});
|
|
|
|
// Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
|
|
// action here, so name the count instead of asking a generic "are you sure".
|
|
|
|
if (highRisk.length) {
|
|
|
|
const label = decisionLabels[decision] || decision;
|
|
|
|
if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
|
|
|
|
setStatus("일괄 판정을 취소했습니다.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
isBulkDecisionInFlight = true;
|
|
|
|
setStatus(`${ids.length}건 판정 중…`);
|
|
|
|
try {
|
|
|
|
const payload = await apiJson("/api/submissions/decision-batch", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ submission_ids: ids, decision, memo }),
|
|
|
|
});
|
|
|
|
const result = payload.batchDecision || { applied: [], failed: [] };
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
const failures = result.failed || [];
|
|
|
|
setStatus(
|
|
|
|
failures.length
|
|
|
|
? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
|
|
|
|
.map((item) => `${item.submissionId}(${item.error})`)
|
|
|
|
.join(", ")}`
|
|
|
|
: `${(result.applied || []).length}건 판정을 적용했습니다.`,
|
|
|
|
);
|
|
|
|
} catch (errorValue) {
|
|
|
|
setStatus(`일괄 판정 실패: ${errorValue.message}`);
|
|
|
|
} finally {
|
|
|
|
isBulkDecisionInFlight = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|