Files
Aislo/B03_FileInput/B03_FileInput_UI_Upload.ts
T
eomsangdonandClaude Opus 5 431791c257 feat(B03,B05,B06): 초기 계산 끝까지 업로드 잠금 + 자료 없으면 대시보드로 (결함 3·6 화면측)
업로드 잠금(결함 3)
- 종전에는 전처리(B04)만 끝나면 버튼이 풀려, 노선·횡단이 도는 동안 자료를 또 올릴 수
  있었다. isInitialPipelineRunning()/pollInitialPipeline()을 두어 **횡단 단계가 열릴
  때까지**(3단계가 NOT_STARTED를 벗어날 때까지) 잠근다.
- relockWhileInitialPipelineRuns(): 새로고침·재진입해도 계산이 도는 중이면 다시 잠그고
  스피너를 붙인다. 잠금이 화면 상태로만 있으면 새로고침 한 번에 풀렸다.
- 보관함 연결 경로도 같은 대기 흐름을 탄다.

자료 없으면 대시보드(결함 6)
- b_missing_data_guard 신설 — 근거 자료가 없으면 한 줄 안내 후 B01로 보낸다. 새 자료가
  올라오면 서버가 옛 산출물을 지우므로, 열어 둔 뒷단계 화면은 빈 화면이 아니라
  대시보드로 나가야 한다(2026-08-08 사용자 지시).
- B05: 확정 지표면이 없으면 이동. B06: 노선 또는 종단면이 없으면 이동.

검증: 자료가 없는 신규 프로젝트로 B05 진입 → 안내 후 #/b01-account 로 이동함을
헤드리스 크롬으로 확인. typecheck·prettier 통과, 정적 번들 재빌드.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:50:27 +09:00

238 lines
8.9 KiB
TypeScript

/* =============================================================================
* B03_FileInput_UI_Upload.ts
* 파일 입력 화면의 업로드 실행부 — 청크 업로드, 분석 대기, 결과 표시, 교체 확인 모달.
*
* 화면 조립(B03_FileInput_UI_Page.ts)에서 떼어낸 부분이다. 슬롯 상태를 그대로 받아
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
* ========================================================================== */
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
checkWF1AnalysisStatus,
createUploadSession,
finalizeUploadSession,
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
import {
makeSessionKey,
type FileSlotState,
type StoredUploadSession,
} from "./B03_FileInput_UI_Support";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/**
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal";
modal.setAttribute("role", "alertdialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("strong");
title.textContent = L("B03_File_Replace_Title");
const message = document.createElement("p");
message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`;
const actions = document.createElement("div");
actions.className = "b03-file__modal-actions";
const done = (accepted: boolean): void => {
backdrop.remove();
resolve(accepted);
};
const cancel = createButton({
label: L("B03_File_Replace_Cancel"),
variant: "ghost",
onClick: () => done(false),
});
const accept = createButton({
label: L("B03_File_Replace_Confirm"),
variant: "filled",
onClick: () => done(true),
});
actions.append(cancel, accept);
modal.append(title, message, actions);
backdrop.append(modal);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) done(false);
});
document.body.append(backdrop);
accept.focus();
});
}
/** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */
export function renderUploadResults(
list: HTMLElement,
results: readonly UploadedFileResult[],
): void {
list.replaceChildren();
for (const result of results) {
const item = document.createElement("li");
const filename = document.createElement("strong");
filename.textContent = result.original_filename;
const path = document.createElement("span");
path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`;
item.append(filename, path);
list.append(item);
}
}
/**
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
* 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다.
*/
export async function uploadOneFile(
projectId: string,
state: FileSlotState,
completeUpload: boolean,
onProgress: () => void,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
state.error = undefined;
state.uploadStatus = "uploading";
onProgress();
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
const session =
state.uploadSessionId ??
(await createUploadSession(projectId, file, chunkSizeBytes)).upload_session_id;
state.uploadSessionId = session;
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSizeBytes));
const storageKey = makeSessionKey(projectId, file);
const startedAt = performance.now();
let lastPaintAt = 0;
for (
let chunkIndex = Math.floor(state.progressBytes / chunkSizeBytes);
chunkIndex < totalChunks;
chunkIndex += 1
) {
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
const stored: StoredUploadSession = {
key: storageKey,
projectId,
slot: state.slot,
fileName: file.name,
fileSize: file.size,
uploadSessionId: session,
chunkSizeBytes,
totalChunks,
completedChunks: chunkIndex + 1,
updatedAt: Date.now(),
};
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
lastPaintAt = now;
onProgress();
}
}
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
fileName: file.name,
fileSize: file.size,
});
state.progressBytes = file.size;
state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
onProgress();
return response.files;
}
/**
* 지표면 분석(WF1)이 끝날 때까지 5초 간격으로 확인한다.
* 자동 확정이 보류되면 `onHold`로 사유를 넘기고 대기를 끝낸다.
*/
/**
* 초기 자동 계산이 아직 도는 중인지 — 업로드 버튼을 잠가 둘지 판단하는 기준.
*
* 자료를 올리면 서버가 전처리(B04) → 노선(B05) → 횡단(B06)까지 혼자 이어서 돈다. 그 사이에
* 자료를 또 올리면 분석 두 개가 같은 산출물 자리에서 부딪힌다. 그래서 **횡단 단계가
* 시작될 때까지**를 "도는 중"으로 본다 — 체인이 B06을 확정하면 3단계가 NOT_STARTED를
* 벗어난다(2026-08-08 사용자 지시).
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);
if (fileInput?.state !== "COMPLETE") return false;
if (preprocess?.state === "FAILED") return false;
return section?.state === "NOT_STARTED";
}
/** 초기 자동 계산(B04~B06)이 끝날 때까지 기다린다. 끝나면 true. */
export async function pollInitialPipeline(
projectId: string,
onHold: (message: string) => void,
maxAttempts = 360,
): Promise<boolean> {
const analysisDone = await pollWF1Analysis(projectId, onHold, maxAttempts);
if (!analysisDone) return false;
// 전처리가 끝나도 노선·횡단이 이어서 돈다 — 3단계가 열릴 때까지 더 기다린다.
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const state = await fetchWorkflowState(projectId);
if (!isInitialPipelineRunning(state)) return true;
} catch {
/* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
return false;
}
export async function pollWF1Analysis(
projectId: string,
onHold: (message: string) => void,
maxAttempts = 360,
): Promise<boolean> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const status = await checkWF1AnalysisStatus(projectId);
updateB03AnalysisState(projectId, status.status, status.progress_percent);
if (status.status === "completed") return true;
if (
status.current_stage === "awaiting_confirmation" &&
status.message.includes("자동 확정 보류")
) {
onHold(status.message);
return false;
}
} catch {
/* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return false;
}