업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을 되돌릴 수 없게 지운다. 창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이 사라진다. - `describe_existing_outputs`: 지워질 것을 사람 말로 낸다(전처리·노선·횡단·도면· 수량·원가·초기값). 빈 폴더는 세지 않는다. - 지울 것이 있는데 확인이 없으면 409 `confirm_required` — 무엇이 지워지는지 함께 준다. - 세 갈래(직행·청크·보관함 연결) 전부에 `confirm_replace` 를 붙임. - ⚠ 기본값은 확인 켬(True) — 자동 절차(체인·스크립트)는 물음에 안 걸린다. 사람이 올리는 라우터만 False 로 불러 확인을 받는다. - 화면: 409 를 받으면 「되돌릴 수 없습니다」와 지워질 목록을 보이고 한 번 묻는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
283 lines
10 KiB
TypeScript
283 lines
10 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 { fileFingerprint } from "./B03_FileInput_Fingerprint";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import {
|
|
checkWF1AnalysisStatus,
|
|
createUploadSession,
|
|
finalizeUploadSession,
|
|
ReplaceOutputsConfirmRequired,
|
|
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();
|
|
});
|
|
}
|
|
|
|
/** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */
|
|
/**
|
|
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
|
|
* 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다.
|
|
*/
|
|
/**
|
|
* 「이미 만들어 둔 결과가 지워집니다」를 사람에게 한 번 묻는다.
|
|
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
|
|
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
|
|
*/
|
|
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
|
|
try {
|
|
return await run(false);
|
|
} catch (error) {
|
|
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
|
|
const detail = error.targets.length
|
|
? `
|
|
|
|
지워지는 것: ${error.targets.join(" · ")}`
|
|
: "";
|
|
const agreed = window.confirm(
|
|
`${error.message}${detail}
|
|
|
|
되돌릴 수 없습니다. 계속할까요?`,
|
|
);
|
|
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
|
|
return await run(true);
|
|
}
|
|
}
|
|
|
|
export async function uploadOneFile(
|
|
projectId: string,
|
|
state: FileSlotState,
|
|
completeUpload: boolean,
|
|
onProgress: () => void,
|
|
lasFree = false,
|
|
// 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06).
|
|
target?: File,
|
|
): Promise<UploadedFileResult[]> {
|
|
const file = target ?? state.file;
|
|
if (!file) return [];
|
|
state.error = undefined;
|
|
state.uploadStatus = "uploading";
|
|
onProgress();
|
|
|
|
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
|
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
|
|
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
|
let session = state.uploadSessionId;
|
|
if (!session) {
|
|
const created = await askReplaceOutputs((confirmReplace) =>
|
|
createUploadSession(
|
|
projectId,
|
|
file,
|
|
chunkSizeBytes,
|
|
fingerprint,
|
|
completeUpload,
|
|
lasFree,
|
|
confirmReplace,
|
|
),
|
|
);
|
|
if (created.already_uploaded) {
|
|
state.progressBytes = file.size;
|
|
state.etaSeconds = 0;
|
|
state.uploadStatus = "completed";
|
|
onProgress();
|
|
return [];
|
|
}
|
|
session = created.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 askReplaceOutputs((confirmReplace) =>
|
|
finalizeUploadSession(
|
|
projectId,
|
|
session,
|
|
totalChunks,
|
|
completeUpload,
|
|
fingerprint,
|
|
lasFree,
|
|
confirmReplace,
|
|
),
|
|
);
|
|
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;
|
|
}
|