단일 파일이 프로젝트 상한(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건.
382 lines
7.7 KiB
JavaScript
382 lines
7.7 KiB
JavaScript
// Part 12 of 15 of the operator console app script.
|
|
//
|
|
// Submission reload/import/upload and knowledge entry creation.
|
|
//
|
|
// 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.
|
|
|
|
async function rerunSelectedEnrichment() {
|
|
|
|
const ids = selectedBulkSubmissionIds();
|
|
|
|
const button = document.getElementById("bulk-rerun");
|
|
|
|
if (!ids.length) {
|
|
|
|
setImportStatus("재분석할 제출이 없습니다.", "bad");
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (button) button.disabled = true;
|
|
|
|
setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
|
|
try {
|
|
|
|
for (const [index, submissionId] of ids.entries()) {
|
|
|
|
setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
|
|
await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
state.selectedCaseId = ids[0];
|
|
|
|
await refreshFromApi();
|
|
|
|
setImportStatus(`${ids.length}건 재분석 완료`, "ok");
|
|
} catch (errorValue) {
|
|
|
|
// Best-effort refresh so already-rerun submissions aren't left stale in the
|
|
// UI when the loop fails partway through.
|
|
try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
|
|
|
|
setImportStatus(errorValue.message, "bad");
|
|
|
|
} finally {
|
|
|
|
if (button) button.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function resetQueueFiltersForImport() {
|
|
|
|
state.filters.risk = "all";
|
|
|
|
state.filters.source = "all";
|
|
|
|
state.filters.decision = "all";
|
|
|
|
state.filters.sort = "newest";
|
|
|
|
state.filters.query = "";
|
|
|
|
document.querySelectorAll("#risk-filter button").forEach((button) => {
|
|
|
|
button.classList.toggle("active", button.dataset.risk === "all");
|
|
|
|
});
|
|
|
|
document.getElementById("source-filter").value = "all";
|
|
|
|
document.getElementById("decision-filter").value = "all";
|
|
|
|
document.getElementById("queue-sort").value = "newest";
|
|
|
|
document.getElementById("queue-search").value = "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function importedFolderStatusMessage(payload, fallbackLabel) {
|
|
|
|
return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
|
|
|
|
}
|
|
|
|
|
|
|
|
function importedSubmissionStatusMessage(payload, submissionId) {
|
|
|
|
return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function reloadSubmissions() {
|
|
|
|
const button = document.getElementById("reload-submissions");
|
|
|
|
const folderInput = document.getElementById("submission-folder");
|
|
|
|
const folderPath = (folderInput?.value || "").trim();
|
|
|
|
const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
|
|
|
|
const body = folderPath ? { path: folderPath } : {};
|
|
|
|
button.disabled = true;
|
|
|
|
setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(endpoint, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
});
|
|
|
|
// Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
|
|
// otherwise the imported-diff below always compares against the new list and
|
|
// is always empty (no new case is ever auto-selected).
|
|
const existingIds = new Set(submissions.map((submission) => submission.id));
|
|
|
|
applyBootstrap(payload);
|
|
|
|
if (folderPath) {
|
|
|
|
folderInput.value = "";
|
|
|
|
const importedFolderSubmissions = payload.submissions || [];
|
|
|
|
if (importedFolderSubmissions[0]) {
|
|
|
|
state.selectedCaseId = importedFolderSubmissions[0].id;
|
|
|
|
resetQueueFiltersForImport();
|
|
|
|
setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
|
|
|
|
} else {
|
|
|
|
setImportStatus("추가된 제출 없음", "idle");
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
|
|
|
|
if (importedSubmissions.length) {
|
|
|
|
state.selectedCaseId = importedSubmissions[0].id;
|
|
|
|
resetQueueFiltersForImport();
|
|
|
|
setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
|
|
|
|
} else {
|
|
|
|
setImportStatus("추가된 제출 없음", "idle");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
setImportStatus(errorValue.message, "bad");
|
|
|
|
} finally {
|
|
|
|
button.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
function updateSubmissionImageName() {
|
|
|
|
const input = document.getElementById("submission-image");
|
|
|
|
const target = document.getElementById("submission-image-name");
|
|
|
|
if (!input || !target) return;
|
|
|
|
target.textContent = input.files[0]?.name || "선택된 파일 없음";
|
|
|
|
}
|
|
|
|
|
|
|
|
async function uploadSubmissionImage() {
|
|
|
|
const input = document.getElementById("submission-image");
|
|
|
|
const button = document.getElementById("upload-submission-image");
|
|
|
|
const file = input?.files?.[0];
|
|
|
|
if (!file) {
|
|
|
|
setImportStatus("추가할 사진을 선택하세요.", "bad");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
button.disabled = true;
|
|
|
|
setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
|
|
|
|
try {
|
|
|
|
const image = await readKnowledgeImage(file);
|
|
|
|
const payload = await apiJson("/api/submissions/upload-image", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ image }),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
input.value = "";
|
|
|
|
updateSubmissionImageName();
|
|
|
|
if (payload.uploadedSubmissionId) {
|
|
|
|
state.selectedCaseId = payload.uploadedSubmissionId;
|
|
|
|
resetQueueFiltersForImport();
|
|
|
|
switchView("workbench");
|
|
|
|
switchWorkbenchTab("evidence");
|
|
|
|
}
|
|
|
|
setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
setImportStatus(errorValue.message, "bad");
|
|
|
|
} finally {
|
|
|
|
button.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function readKnowledgeImage(file) {
|
|
|
|
return window.OperatorSubmissionImport.fileToImagePayload(file);
|
|
|
|
}
|
|
|
|
|
|
function updateKnowledgeImageName() {
|
|
|
|
const input = document.getElementById("knowledge-image");
|
|
|
|
const target = document.getElementById("knowledge-image-name");
|
|
|
|
if (!input || !target) return;
|
|
|
|
target.textContent = input.files[0]?.name || "선택된 파일 없음";
|
|
|
|
}
|
|
|
|
|
|
|
|
async function addKnowledgeEntry(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById("knowledge-name").value.trim();
|
|
|
|
const type = document.getElementById("knowledge-type").value;
|
|
|
|
const aliases = document
|
|
|
|
.getElementById("knowledge-aliases")
|
|
|
|
.value.split(",")
|
|
|
|
.map((item) => item.trim())
|
|
|
|
.filter(Boolean);
|
|
|
|
const keywords = document
|
|
|
|
.getElementById("knowledge-keywords")
|
|
|
|
.value.split(",")
|
|
|
|
.map((item) => item.trim())
|
|
|
|
.filter(Boolean);
|
|
|
|
const memo = document.getElementById("knowledge-memo").value.trim();
|
|
|
|
const imageInput = document.getElementById("knowledge-image");
|
|
|
|
const status = document.getElementById("knowledge-entry-status");
|
|
|
|
const submitButton = event.target.querySelector('button[type="submit"]');
|
|
|
|
|
|
|
|
if (!name || !memo) return;
|
|
|
|
|
|
|
|
submitButton.disabled = true;
|
|
|
|
status.textContent = "기준 DB에 저장하는 중입니다.";
|
|
|
|
try {
|
|
|
|
const image = await readKnowledgeImage(imageInput.files[0]);
|
|
|
|
const payload = await apiJson("/api/knowledge/manual", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
event.target.reset();
|
|
|
|
updateKnowledgeImageName();
|
|
|
|
status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
submitButton.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|