단일 파일이 프로젝트 상한(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건.
282 lines
6.5 KiB
JavaScript
282 lines
6.5 KiB
JavaScript
// Part 14 of 15 of the operator console app script.
|
|
//
|
|
// Knowledge/correction/provider/evidence-status admin actions.
|
|
//
|
|
// 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 addKnowledgeEntryFromCase() {
|
|
|
|
// The control is labelled "주의 후보 보기" (view watchlist candidates), so show
|
|
// the registered list filtered to watchlist — it previously opened the blank
|
|
// manual-registration form, which is a different action entirely.
|
|
|
|
switchView("knowledge");
|
|
|
|
switchKnowledgeTab("registered");
|
|
|
|
knowledgeFilters.status = "watchlist";
|
|
|
|
const statusFilter = document.getElementById("knowledge-status-filter");
|
|
|
|
if (statusFilter) statusFilter.value = "watchlist";
|
|
|
|
renderAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function deactivateCorrectionEntry(correctionId) {
|
|
const correction = corrections.find((item) => item.id === correctionId);
|
|
if (!correction) return;
|
|
|
|
// Deactivate the derived knowledge entries through the real backend endpoint
|
|
// so the change is persisted and audited server-side. The previous version
|
|
// only mutated local state and appended a fake audit event, so the action was
|
|
// silently discarded on the next refreshFromApi().
|
|
// Only entries that are still active need deactivating. Skipping already-
|
|
// inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
|
|
// abort the whole loop on the first inactive entry.
|
|
const entryIds = (correction.derivedEntries || [])
|
|
.filter((entry) => entry && entry.id && entry.active)
|
|
.map((entry) => entry.id);
|
|
if (!entryIds.length) {
|
|
showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const failures = [];
|
|
try {
|
|
for (const entryId of entryIds) {
|
|
try {
|
|
await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
|
|
});
|
|
} catch (_perEntryError) {
|
|
// One entry failing must not strand the rest.
|
|
failures.push(entryId);
|
|
}
|
|
}
|
|
} finally {
|
|
// Always refresh so the UI reflects whatever was actually persisted, even on
|
|
// a partial failure. Guard it so a refresh error isn't an unhandled rejection.
|
|
try {
|
|
await refreshFromApi();
|
|
} catch (_refreshError) {
|
|
/* keep the per-entry failure summary below */
|
|
}
|
|
}
|
|
if (failures.length) {
|
|
showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function toggleProvider(providerId) {
|
|
|
|
const provider = providers.find((item) => item.id === providerId);
|
|
|
|
if (!provider) return;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
|
|
|
|
method: "PATCH",
|
|
|
|
body: JSON.stringify({ enabled: !provider.enabled }),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function setEvidenceStatus(evidenceId, status, note = "") {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission || !evidenceId) return;
|
|
|
|
try {
|
|
|
|
await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function markEvidenceIrrelevant(evidenceId) {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission) return;
|
|
|
|
const fallback = submission.evidence.find((item) => item.contributed);
|
|
|
|
const targetId = evidenceId || fallback?.id;
|
|
|
|
void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
|
|
|
|
}
|
|
|
|
|
|
|
|
async function saveKnowledgeEntryEdit(form) {
|
|
const entryId = form.dataset.knowledgeEditForm;
|
|
const body = {
|
|
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
|
|
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
|
|
memo: form.elements.memo.value.trim(),
|
|
};
|
|
try {
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
});
|
|
editingKnowledgeEntryId = "";
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
async function deactivateKnowledgeEntry(entryId) {
|
|
const reason = window.prompt("비활성 사유 메모(선택)");
|
|
if (reason === null) return;
|
|
try {
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: reason.trim() }),
|
|
});
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
async function reactivateKnowledgeEntry(entryId) {
|
|
const reason = window.prompt("재활성 사유 메모(필수)") || "";
|
|
if (!reason.trim()) {
|
|
showApiError("재활성에는 사유 메모가 필요합니다.");
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: reason.trim() }),
|
|
});
|
|
applyBootstrap(payload);
|
|
renderAll();
|
|
} catch (errorValue) {
|
|
showApiError(errorValue.message);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function promoteWatchlistEntry(entryId) {
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function excludeWatchlistEntry(entryId) {
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ reason: "오탐 확인" }),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function emergencyDisableExternalProviders() {
|
|
|
|
try {
|
|
|
|
await apiJson("/api/providers/emergency-disable", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
await refreshFromApi();
|
|
|
|
} catch (errorValue) {
|
|
|
|
showApiError(errorValue.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|