단일 파일이 프로젝트 상한(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건.
322 lines
9.7 KiB
JavaScript
322 lines
9.7 KiB
JavaScript
// Part 5 of 15 of the operator console app script.
|
|
//
|
|
// Queue list, import status, and the case review shell.
|
|
//
|
|
// 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 renderQueue() {
|
|
|
|
const rows = filteredSubmissions();
|
|
|
|
const queueLabel = state.submissionQueue?.label || "";
|
|
|
|
const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
|
|
|
|
const body = document.getElementById("queue-body");
|
|
|
|
|
|
|
|
document.getElementById("queue-health").innerHTML = state.apiError
|
|
|
|
? `
|
|
|
|
<span class="status-dot warn" aria-hidden="true"></span>
|
|
|
|
<span>${escapeHtml(state.apiError)}</span>
|
|
|
|
`
|
|
|
|
: `
|
|
|
|
<span class="status-dot ${rows.some((item) => item.riskBand === "failed") ? "warn" : "ok"}" aria-hidden="true"></span>
|
|
|
|
<span>${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}</span>
|
|
|
|
`;
|
|
|
|
|
|
|
|
if (!rows.length) {
|
|
|
|
body.innerHTML = `
|
|
|
|
<tr>
|
|
|
|
<td colspan="9">
|
|
|
|
<div class="empty-state">현재 필터에 맞는 심사 건이 없습니다.</div>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
`;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
body.innerHTML = rows
|
|
|
|
.map((submission) => {
|
|
|
|
const providerHtml = visibleProviderStateEntries(submission.providerState)
|
|
|
|
.map(
|
|
|
|
([provider, status]) => {
|
|
|
|
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
|
|
|
|
return `<span class="provider-chip queue-provider-chip ${providerClass(status)}" title="${escapeHtml(fullLabel)}">${escapeHtml(formatQueueProviderStatus(provider, status))}</span>`;
|
|
|
|
},
|
|
|
|
)
|
|
|
|
.join("");
|
|
|
|
const reasonHtml = submission.reasons
|
|
|
|
.slice(0, 2)
|
|
|
|
.map((reason) => `<span title="${escapeHtml(formatReason(reason))}">${escapeHtml(formatReason(reason))}</span>`)
|
|
|
|
.join("");
|
|
|
|
const submittedTime = formatQueueTimestamp(submission.submittedAt);
|
|
|
|
return `
|
|
|
|
<tr class="queue-row ${submission.id === state.selectedCaseId ? "selected-row" : ""}" data-row-case="${escapeHtml(submission.id)}">
|
|
|
|
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
|
|
|
|
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td>
|
|
|
|
<td>
|
|
|
|
<div class="queue-submission-cell">
|
|
|
|
<button class="row-action queue-case-button" type="button" data-select-case="${escapeHtml(submission.id)}" title="${escapeHtml(submission.id)}">
|
|
|
|
<span class="submission-id">${escapeHtml(submission.id)}</span>
|
|
|
|
</button>
|
|
|
|
${duplicateBadgeHtml(submission)}
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<div class="score-stack queue-risk-cell" title="${escapeHtml(submission.lastAnalysis)}">
|
|
|
|
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
<td><div class="reason-cell">${reasonHtml}</div></td>
|
|
|
|
<td><div class="provider-strip queue-provider-strip">${providerHtml}</div></td>
|
|
|
|
<td>${escapeHtml(submission.applicantStatus)}</td>
|
|
|
|
<td><span class="decision-badge">${decisionLabels[submission.decisionStatus]}</span></td>
|
|
|
|
<td>
|
|
|
|
<div class="queue-time-cell" title="${escapeHtml(submission.fileFacts.submitted)}">
|
|
|
|
<strong>${escapeHtml(submittedTime)}</strong>
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
`;
|
|
|
|
})
|
|
|
|
.join("");
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderImportStatus() {
|
|
|
|
const target = document.getElementById("submission-import-status");
|
|
|
|
if (!target) return;
|
|
|
|
const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
|
|
|
|
target.innerHTML = `
|
|
|
|
<span class="status-dot ${tone}" aria-hidden="true"></span>
|
|
|
|
<span>${escapeHtml(state.importStatus.message)}</span>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderNoSelectedCase() {
|
|
document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
|
|
document.getElementById("case-image").removeAttribute("src");
|
|
document.getElementById("case-image").alt = "";
|
|
document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
|
|
document.getElementById("case-score").className = "score-pill risk-pending";
|
|
document.getElementById("case-score").textContent = "대기";
|
|
document.getElementById("floating-case-score").className = "score-pill risk-pending";
|
|
document.getElementById("floating-case-score").textContent = "대기";
|
|
document.getElementById("file-facts").innerHTML = "";
|
|
document.getElementById("similar-strip").innerHTML = "";
|
|
document.getElementById("face-crop-strip").innerHTML = "";
|
|
activeRerunAddedIds = new Set();
|
|
document.getElementById("rerun-diff-summary").innerHTML = "";
|
|
document.getElementById("case-reasons").innerHTML = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
|
|
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
|
|
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
|
|
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
|
|
// Clear the evidence-gap recommendations too: they belong to the case that was
|
|
// just deselected, and leaving them rendered invited running suggested queries
|
|
// against nothing.
|
|
const nextActions = document.getElementById("evidence-next-actions");
|
|
if (nextActions) nextActions.innerHTML = "";
|
|
const llmDraft = document.getElementById("llm-draft");
|
|
if (llmDraft) {
|
|
llmDraft.hidden = true;
|
|
llmDraft.innerHTML = "";
|
|
}
|
|
}
|
|
|
|
|
|
function renderCaseReview() {
|
|
|
|
const submission = getSelectedCase();
|
|
|
|
if (!submission) {
|
|
|
|
renderNoSelectedCase();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
renderLlmDraft(submission);
|
|
|
|
document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
|
|
|
|
const caseImageElement = document.getElementById("case-image");
|
|
|
|
caseImageElement.src = safeUrl(submission.asset);
|
|
|
|
caseImageElement.alt = `${submission.id} 제출 이미지`;
|
|
|
|
// Reset the view controls (rotate / fit) so a new case starts unrotated.
|
|
caseImageElement.dataset.rotation = "0";
|
|
|
|
caseImageElement.style.transform = "";
|
|
|
|
caseImageElement.style.objectFit = "";
|
|
|
|
document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
|
|
|
|
document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
|
|
|
|
document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
|
|
|
|
document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
|
|
|
|
document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
|
|
|
|
document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
|
|
|
|
.map(([label, value]) => `<div class="fact-item"><span>${escapeHtml(formatFactLabel(label))}</span><strong>${escapeHtml(value)}</strong></div>`)
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
document.getElementById("similar-strip").innerHTML = submission.similar
|
|
|
|
.map(
|
|
|
|
(item) => `
|
|
|
|
<div class="similar-item">
|
|
|
|
<img src="${escapeHtml(safeUrl(item.asset))}" alt="${escapeHtml(item.label)}">
|
|
|
|
<span>${escapeHtml(formatReason(item.label))}</span>
|
|
|
|
</div>
|
|
|
|
`,
|
|
|
|
)
|
|
|
|
.join("");
|
|
|
|
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
|
|
.map(
|
|
(crop) => `
|
|
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(safeUrl(crop.url))}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
|
|
<img src="${escapeHtml(safeUrl(crop.url))}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
|
|
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
|
|
</a>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
|
|
document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
|
|
? renderRerunDiffSummary(submission.lastRerunDiff)
|
|
: "";
|
|
|
|
document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
|
|
|
|
document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
|
|
|
|
|
|
document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
|
|
|
|
document.getElementById("recommendation-box").innerHTML = `
|
|
|
|
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]}</span>
|
|
|
|
<strong>${escapeHtml(submission.recommendation.label)}</strong>
|
|
|
|
<span class="muted">${escapeHtml(formatReason(submission.recommendation.detail))}</span>
|
|
|
|
<span class="muted small">현재 운영 결정: ${decisionLabels[submission.decisionStatus]}</span>
|
|
|
|
`;
|
|
|
|
document.getElementById("derived-preview").innerHTML = `
|
|
|
|
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
|
|
|
|
<span>${escapeHtml(submission.derivedPreview.entryName)}</span>
|
|
|
|
<span class="muted small">${escapeHtml(submission.derivedPreview.effect)}</span>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|