feat(B03): 입력 자료 컨테이너 통합·3열 배치 + 업로드 중 화면 잠금
지형 래스터(tif)만 선택 항목이고 나머지는 모두 필수라, 계획노선과 지형 자료를 따로 묶을 이유가 없다. - 그룹 2개(원청 계획노선/지형 분석자료)를 "입력 자료" 한 그룹으로 통합 - 계획노선 카드에만 걸려 있던 전용 서식(1열 폭·보라 배경·테두리) 제거 — 다른 카드와 같은 템플릿으로 통일 - 카드 배치 2열 -> 3열 (1280px 이하 2열, 720px 이하 1열) - 선택 항목(tif)은 확장자 옆에 "선택" 표시 - [파일 업로드]를 누르면 버튼을 잠그고 안에 회전 원을 넣어 진행 중임을 보이며, 업로드·분석이 끝날 때까지 화면 조작을 막는다(중복 업로드·단계 이동 방지) 파일 분리: 업로드 실행부(청크 업로드/분석 대기/결과 표시/교체 확인 모달)를 B03_FileInput_UI_Upload.ts로 이관해 페이지 파일을 700줄 제한 안으로 되돌림(830 -> 622). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
CURRENT_PROJECT_ID_KEY,
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
ROUTES,
|
||||
UPLOAD_ALLOWED_EXT,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
UPLOAD_MAX_FILES,
|
||||
UPLOAD_MAX_MB,
|
||||
} from "@config/config_frontend";
|
||||
@@ -12,14 +10,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
fetchUploadOverview,
|
||||
finalizeUploadSession,
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
import { createTempPicker } from "./B03_FileInput_UI_TempPicker";
|
||||
@@ -29,11 +20,13 @@ import {
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { restoreB03ProjectState } from "./B03_FileInput_State";
|
||||
import {
|
||||
restoreB03ProjectState,
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
confirmReplaceUpload,
|
||||
pollWF1Analysis,
|
||||
renderUploadResults,
|
||||
uploadOneFile,
|
||||
} from "./B03_FileInput_UI_Upload";
|
||||
import {
|
||||
createFileCardTemplate,
|
||||
formatBytes,
|
||||
@@ -65,6 +58,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
let uploadButton: HTMLButtonElement;
|
||||
let resumeBanner: HTMLDivElement;
|
||||
let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
// 업로드가 도는 동안은 화면 전체를 잠근다 — 같은 파일을 두 번 올리거나 도중에 다른
|
||||
// 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시).
|
||||
let pageRoot: HTMLElement | null = null;
|
||||
let isUploading = false;
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b03-file__subtitle";
|
||||
@@ -89,6 +86,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.className = "b03-file__error";
|
||||
pageError.setAttribute("role", "alert");
|
||||
|
||||
// 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다.
|
||||
const uploadSpinner = document.createElement("span");
|
||||
uploadSpinner.className = "ui-spinner b03-file__upload-spinner";
|
||||
uploadSpinner.setAttribute("aria-hidden", "true");
|
||||
|
||||
resumeBanner = document.createElement("div");
|
||||
resumeBanner.className = "b03-file__resume";
|
||||
|
||||
@@ -133,7 +135,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 업로드 중 표시 — 버튼 안에 도는 원을 넣고 화면을 잠근다. */
|
||||
function setUploading(busy: boolean): void {
|
||||
isUploading = busy;
|
||||
pageRoot?.classList.toggle("b03-file--busy", busy);
|
||||
const label = uploadButton.querySelector<HTMLSpanElement>(".ui-btn__label");
|
||||
if (busy) {
|
||||
uploadButton.disabled = true;
|
||||
uploadButton.prepend(uploadSpinner);
|
||||
if (label) label.textContent = L("B03_File_Upload_Busy");
|
||||
return;
|
||||
}
|
||||
uploadSpinner.remove();
|
||||
if (label) label.textContent = L("B03_File_Upload_Button");
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function updateUploadButton(): void {
|
||||
// 업로드가 도는 동안에는 어떤 이유로도 다시 눌리면 안 된다.
|
||||
if (isUploading) {
|
||||
uploadButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
// 보관함 자료를 지정했으면 슬롯 검사와 무관하게 업로드(=이동)를 열어 준다.
|
||||
if (tempPicker.selected()) {
|
||||
uploadButton.disabled = false;
|
||||
@@ -200,14 +223,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function clearErrorMessage(slot: FileSlot): void {
|
||||
const state = slots.get(slot);
|
||||
if (!state) return;
|
||||
state.error = undefined;
|
||||
if (state.uploadStatus === "failed") state.uploadStatus = "pending";
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
@@ -304,49 +319,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
|
||||
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
|
||||
@@ -403,7 +375,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
card.dataset.slotId = state.slot;
|
||||
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
|
||||
card.querySelector(".b03-file__card-ext")!.textContent = state.extensions.join(", ");
|
||||
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
|
||||
const extLabel = state.extensions.join(", ");
|
||||
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
@@ -441,19 +417,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderUploadResults(results: readonly UploadedFileResult[]): void {
|
||||
resultList.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);
|
||||
resultList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPausedUploads(): Promise<void> {
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
resumeBanner.replaceChildren();
|
||||
@@ -492,100 +455,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOneFile(
|
||||
projectId: string,
|
||||
state: FileSlotState,
|
||||
completeUpload: boolean,
|
||||
): Promise<UploadedFileResult[]> {
|
||||
const file = state.file;
|
||||
if (!file) return [];
|
||||
clearErrorMessage(state.slot);
|
||||
state.uploadStatus = "uploading";
|
||||
renderSlot(state.slot);
|
||||
|
||||
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;
|
||||
renderSlot(state.slot);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
|
||||
localStorage.removeItem(storageKey);
|
||||
saveB03UploadedFile(projectId, {
|
||||
slot: state.slot,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
/** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */
|
||||
const pollAnalysis = (projectId: string): Promise<boolean> =>
|
||||
pollWF1Analysis(projectId, (message) => {
|
||||
pageError.textContent = message;
|
||||
showToast(message, "warning");
|
||||
});
|
||||
state.progressBytes = file.size;
|
||||
state.speedMbs =
|
||||
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
state.etaSeconds = 0;
|
||||
state.uploadStatus = "completed";
|
||||
renderSlot(state.slot);
|
||||
return response.files;
|
||||
}
|
||||
|
||||
async function pollWF1Analysis(projectId: string, 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("자동 확정 보류")
|
||||
) {
|
||||
pageError.textContent = status.message;
|
||||
showToast(status.message, "warning");
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
|
||||
@@ -611,7 +486,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||
const analysisComplete = await pollAnalysis(activeProjectId);
|
||||
if (analysisComplete) {
|
||||
navigateTo(completionRoute);
|
||||
} else {
|
||||
@@ -625,9 +500,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
if (isUploading) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
await attachSelectedTempBatch();
|
||||
setUploading(true);
|
||||
try {
|
||||
await attachSelectedTempBatch();
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
@@ -638,19 +519,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
pageError.textContent = "";
|
||||
setUploading(true);
|
||||
const uploaded: UploadedFileResult[] = [];
|
||||
try {
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
uploaded.push(
|
||||
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)),
|
||||
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () =>
|
||||
renderSlot(state.slot),
|
||||
)),
|
||||
);
|
||||
}
|
||||
renderUploadResults(uploaded);
|
||||
renderUploadResults(resultList, uploaded);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||
const analysisComplete = await pollAnalysis(activeProjectId);
|
||||
|
||||
if (analysisComplete) {
|
||||
navigateTo(completionRoute);
|
||||
@@ -663,6 +547,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,13 +613,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
resultList,
|
||||
);
|
||||
|
||||
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
|
||||
routeGroup.classList.add("b03-file__group--route");
|
||||
const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]);
|
||||
// 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다
|
||||
// (2026-08-08 사용자 지시).
|
||||
const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [
|
||||
"csv",
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
]);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(routeGroup, filesGroup);
|
||||
cardsContainer.append(inputsGroup);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
@@ -762,6 +654,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showTitlePanel: false,
|
||||
});
|
||||
layout.root.append(overlays.root);
|
||||
pageRoot = layout.root;
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
@@ -773,7 +666,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
projectId: activeProjectId,
|
||||
label: L("B03_File_Restore_State"),
|
||||
container: resumeBanner,
|
||||
poll: pollWF1Analysis,
|
||||
poll: pollAnalysis,
|
||||
onComplete: () => navigateTo(completionRoute),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,17 +195,28 @@
|
||||
|
||||
.b03-file__group-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr); /* 2행 2열 구조 */
|
||||
grid-template-columns: repeat(3, 1fr); /* 3열 구조 */
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b03-file__group--route .b03-file__group-content {
|
||||
grid-template-columns: 1fr;
|
||||
/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */
|
||||
.b03-file--busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b03-file__group--route .b03-file__card {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
.b03-file--busy .b03-file__card-select,
|
||||
.b03-file--busy .b03-file__card-remove,
|
||||
.b03-file--busy .b03-file__dropzone {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */
|
||||
.b03-file__upload-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
border-color: rgb(255 255 255 / 40%);
|
||||
border-top-color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
@@ -448,6 +459,12 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.b03-file__group-content {
|
||||
grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.b03-file {
|
||||
padding: var(--spacing-16) var(--spacing-12);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/* =============================================================================
|
||||
* 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 { 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`로 사유를 넘기고 대기를 끝낸다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user