POSA_Copyrighter/web/operator-gui/app-render-queue.js
changukyu fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(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건.
2026-08-08 09:48:52 +09:00

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>
`;
}