feat(B01,B03): 프로젝트 생성 전 임시 보관함 (temp upload)
라이다 원본은 업로드에 오래 걸려 프로젝트 정보 확정 전에 미리 올릴 수 있어야 한다.
계정에 묶인 임시 보관함을 만들고, 나중에 만든 프로젝트로 자료를 옮겨 쓴다.
저장·DB
- storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트 저장소와 동일한 구조를 써서
청크 저장·병합 엔진(resolve_upload_destination/merge_upload_chunks)을 그대로 재사용
- 010_temp_upload.sql: temp_upload_batches / temp_upload_files 신설,
upload_sessions.project_id NULL 허용 + temp_batch_id 추가(FK명 조회 후 재생성)
- config: TEMP_UPLOAD_DIR_NAME / TEMP_UPLOAD_RETENTION_DAYS(30) /
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS(6)
백엔드
- B03_FileInput_Router_Temp.py: 묶음 생성·목록·삭제, 일반/청크 업로드, finalize,
이어올리기 상태 조회, 프로젝트 연결(attach)
- attach: 파일 이동 후 input_files 등록, stage 0 완료, WF1·자동 설계 체인 트리거
- common_util_temp_cleanup.py: 완료 시각 기준 만료분 주기 삭제(서버 시작 시 1회 포함)
프론트엔드
- B01 대시보드 임시 보관함 섹션: 프로젝트 등록과 같은 폼 + 보관 목록.
진행률은 모달이 아니라 리스트 행에 표시, 새로고침 후 이어올리기 지원
- B03 업로드 컨테이너 내부 불러오기 버튼과 선택 모달.
완료된 묶음만 노출하고, 선택 후 업로드를 누르면 이동과 분석으로 이어짐
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_Api_Temp.ts
|
||||
* 임시 보관함(프로젝트 생성 전 업로드) API 클라이언트
|
||||
*
|
||||
* 백엔드 계약 (B03_FileInput_Router_Temp.py):
|
||||
* POST /api/temp-uploads 묶음 생성
|
||||
* GET /api/temp-uploads 내 보관함 목록
|
||||
* DELETE /api/temp-uploads/{batch_id} 묶음 삭제
|
||||
* POST /api/temp-uploads/{batch_id}/files 작은 파일 저장
|
||||
* POST /api/temp-uploads/{batch_id}/upload-sessions 청크 세션 생성
|
||||
* POST /api/temp-uploads/{batch_id}/chunks 청크 저장
|
||||
* POST /api/temp-uploads/{batch_id}/finalize 병합·완료
|
||||
* GET /api/temp-uploads/{batch_id}/upload-status/{s} 이어올리기 조회
|
||||
* POST /api/projects/{id}/temp-uploads/{batch_id}/attach 프로젝트로 이동
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export interface TempBatchFile {
|
||||
file_type: string;
|
||||
original_filename: string;
|
||||
file_size_bytes: number;
|
||||
crs_epsg: number | null;
|
||||
}
|
||||
|
||||
export interface TempBatchPendingSession {
|
||||
upload_session_id: string;
|
||||
original_filename: string;
|
||||
file_size_bytes: number;
|
||||
total_chunks: number;
|
||||
completed_chunks: number;
|
||||
progress_percent: number;
|
||||
}
|
||||
|
||||
export interface TempBatchItem {
|
||||
batch_id: string;
|
||||
name: string;
|
||||
memo: string | null;
|
||||
status: string;
|
||||
files: TempBatchFile[];
|
||||
pending_sessions: TempBatchPendingSession[];
|
||||
total_size_bytes: number;
|
||||
required_complete: boolean;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
linked_project_id: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface TempBatchListResponse {
|
||||
status: string;
|
||||
batches: TempBatchItem[];
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers:
|
||||
init.body instanceof FormData
|
||||
? init.headers
|
||||
: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.status === "error") {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function createTempBatch(
|
||||
name: string,
|
||||
memo?: string,
|
||||
): Promise<{ batch_id: string; name: string }> {
|
||||
return requestJson("/temp-uploads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, memo: memo || null }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTempBatches(): Promise<TempBatchListResponse> {
|
||||
return requestJson("/temp-uploads", { method: "GET" });
|
||||
}
|
||||
|
||||
export async function deleteTempBatch(batchId: string): Promise<void> {
|
||||
await requestJson(`/temp-uploads/${encodeURIComponent(batchId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** 작은 파일(csv·prj·tfw·tif)은 한 번에 보낸다. */
|
||||
export async function uploadTempBatchFiles(
|
||||
batchId: string,
|
||||
files: readonly File[],
|
||||
): Promise<{ required_complete: boolean }> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append("files", file, file.name);
|
||||
return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/files`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTempUploadSession(
|
||||
batchId: string,
|
||||
file: File,
|
||||
chunkSizeBytes: number,
|
||||
): Promise<{ upload_session_id: string; total_chunks: number }> {
|
||||
return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/upload-sessions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadTempChunk(
|
||||
batchId: string,
|
||||
sessionId: string,
|
||||
chunkIndex: number,
|
||||
chunk: Blob,
|
||||
): Promise<{ completed_chunks: number }> {
|
||||
const form = new FormData();
|
||||
form.append("session_id", sessionId);
|
||||
form.append("chunk_index", String(chunkIndex));
|
||||
form.append("chunk_data", chunk, `chunk_${chunkIndex}`);
|
||||
return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/chunks`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function finalizeTempUpload(
|
||||
batchId: string,
|
||||
sessionId: string,
|
||||
totalChunks: number,
|
||||
): Promise<{ required_complete: boolean }> {
|
||||
return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/finalize`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 이어올리기 — 이미 서버에 올라간 청크 번호. */
|
||||
export async function fetchTempUploadStatus(
|
||||
batchId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ completed_chunk_indexes: number[]; total_chunks: number }> {
|
||||
return requestJson(
|
||||
`/temp-uploads/${encodeURIComponent(batchId)}/upload-status/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface TempBatchAttachResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
batch_id: string;
|
||||
moved_files: number;
|
||||
analysis_started: boolean;
|
||||
}
|
||||
|
||||
/** 보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다. */
|
||||
export async function attachTempBatch(
|
||||
projectId: string,
|
||||
batchId: string,
|
||||
): Promise<TempBatchAttachResponse> {
|
||||
return requestJson(
|
||||
`/projects/${encodeURIComponent(projectId)}/temp-uploads/${encodeURIComponent(batchId)}/attach`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M
|
||||
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
||||
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
||||
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||
import { buildTempUploadPanel } from "./B01_Dashboard_UI_TempUpload";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
|
||||
export interface DashboardState {
|
||||
@@ -171,7 +172,10 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
);
|
||||
}
|
||||
|
||||
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳이라 프로젝트 목록
|
||||
// 바로 다음에 둔다(2026-08-08 사용자 지시).
|
||||
grid.append(
|
||||
section(L("B01_Temp_Section"), buildTempUploadPanel(), true),
|
||||
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Style_Temp.css
|
||||
* 임시 보관함 섹션 — 등록 폼 + 보관 목록(행에 진행률 표시)
|
||||
* 색·간격은 theme.css 변수만 사용한다.
|
||||
* ========================================================================== */
|
||||
|
||||
.b01-temp__form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: var(--spacing-16);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.b01-temp__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: 13px;
|
||||
color: var(--color-muted-text, var(--color-primary-text));
|
||||
}
|
||||
|
||||
.b01-temp__field input {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-paper);
|
||||
color: var(--color-primary-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b01-temp__hint {
|
||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
||||
font-size: 12px;
|
||||
color: var(--color-muted-text, var(--color-primary-text));
|
||||
}
|
||||
|
||||
.b01-temp__empty {
|
||||
padding: var(--spacing-16);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted-text, var(--color-primary-text));
|
||||
}
|
||||
|
||||
.b01-temp__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-temp__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 목록에서 현재 쓸 수 있는 자료와 구분한다. */
|
||||
.b01-temp__row.is-linked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b01-temp__row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-temp__badge {
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-temp__row.is-completed .b01-temp__badge {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.b01-temp__row.is-uploading .b01-temp__badge {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.b01-temp__row-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted-text, var(--color-primary-text));
|
||||
}
|
||||
|
||||
.b01-temp__files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-temp__chip {
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 업로드 진행률은 모달이 아니라 이 행 안에서 보여 준다(2026-08-08 사용자 지시). */
|
||||
.b01-temp__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.b01-temp__progress-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b01-temp__progress-bar > div {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.b01-temp__row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-temp__form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_TempUpload.ts
|
||||
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
||||
*
|
||||
* 등록 폼은 프로젝트 등록(B02)과 같은 모양으로 두고, 진행 상황은 별도 모달이 아니라
|
||||
* 보관함 **리스트 행**에 표시한다(2026-08-08 사용자 지시). 대용량 라이다는 청크로
|
||||
* 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createTempBatch,
|
||||
createTempUploadSession,
|
||||
deleteTempBatch,
|
||||
fetchTempBatches,
|
||||
finalizeTempUpload,
|
||||
fetchTempUploadStatus,
|
||||
uploadTempBatchFiles,
|
||||
uploadTempChunk,
|
||||
type TempBatchItem,
|
||||
} from "./B01_Dashboard_Api_Temp";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import "./B01_Dashboard_UI_Style_Temp.css";
|
||||
|
||||
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
||||
interface StoredTempSession {
|
||||
batchId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
sessionId: string;
|
||||
chunkSizeBytes: number;
|
||||
totalChunks: number;
|
||||
}
|
||||
|
||||
const CHUNK_UPLOAD_EXT = new Set([".las", ".laz"]);
|
||||
|
||||
function sessionKey(batchId: string, file: File): string {
|
||||
return `temp:session:${batchId}:${file.name}:${file.size}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
const index = name.lastIndexOf(".");
|
||||
return index >= 0 ? name.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관함 섹션을 만든다. 반환 요소는 대시보드 grid에 그대로 붙인다.
|
||||
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
|
||||
*/
|
||||
export function buildTempUploadPanel(): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-temp";
|
||||
|
||||
/* ── 등록 폼 (프로젝트 등록과 같은 모양) ───────────────────────────── */
|
||||
const form = document.createElement("div");
|
||||
form.className = "b01-temp__form";
|
||||
|
||||
const nameField = document.createElement("label");
|
||||
nameField.className = "b01-temp__field";
|
||||
const nameCaption = document.createElement("span");
|
||||
nameCaption.textContent = L("B01_Temp_Field_Name");
|
||||
const nameInput = document.createElement("input");
|
||||
nameInput.type = "text";
|
||||
nameInput.maxLength = 200;
|
||||
nameInput.placeholder = L("B01_Temp_Field_Name_Placeholder");
|
||||
nameField.append(nameCaption, nameInput);
|
||||
|
||||
const fileField = document.createElement("label");
|
||||
fileField.className = "b01-temp__field";
|
||||
const fileCaption = document.createElement("span");
|
||||
fileCaption.textContent = L("B01_Temp_Field_Files");
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = ".csv,.las,.laz,.tif,.tfw,.prj";
|
||||
fileField.append(fileCaption, fileInput);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "b01-temp__hint";
|
||||
hint.textContent = L("B01_Temp_Hint");
|
||||
|
||||
const submit = createButton({
|
||||
label: L("B01_Temp_Btn_Upload"),
|
||||
variant: "filled",
|
||||
onClick: () => void startUpload(),
|
||||
});
|
||||
|
||||
form.append(nameField, fileField, submit);
|
||||
panel.append(form, hint);
|
||||
|
||||
/* ── 보관함 리스트 ─────────────────────────────────────────────────── */
|
||||
const listHost = document.createElement("div");
|
||||
listHost.className = "b01-temp__list";
|
||||
panel.append(listHost);
|
||||
|
||||
let retentionDays = 30;
|
||||
|
||||
function renderRow(batch: TempBatchItem): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = `b01-temp__row is-${batch.status}`;
|
||||
row.dataset.batchId = batch.batch_id;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "b01-temp__row-head";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = batch.name;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "b01-temp__badge";
|
||||
badge.textContent =
|
||||
batch.status === "linked"
|
||||
? L("B01_Temp_Status_Linked")
|
||||
: batch.required_complete
|
||||
? L("B01_Temp_Status_Ready")
|
||||
: L("B01_Temp_Status_Uploading");
|
||||
head.append(title, badge);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b01-temp__row-meta";
|
||||
const parts = [
|
||||
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
|
||||
formatBytes(batch.total_size_bytes),
|
||||
];
|
||||
if (batch.status === "linked") {
|
||||
parts.push(L("B01_Temp_Meta_Linked"));
|
||||
} else if (batch.expires_at) {
|
||||
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
|
||||
}
|
||||
meta.textContent = parts.join(" · ");
|
||||
|
||||
const files = document.createElement("div");
|
||||
files.className = "b01-temp__files";
|
||||
for (const file of batch.files) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "b01-temp__chip";
|
||||
chip.textContent = `${file.file_type.toUpperCase()} · ${file.original_filename}`;
|
||||
files.append(chip);
|
||||
}
|
||||
// 진행 중인 청크 세션은 같은 리스트 행에 진행률 막대로 보여 준다(모달 없음).
|
||||
for (const pending of batch.pending_sessions) {
|
||||
const progressRow = document.createElement("div");
|
||||
progressRow.className = "b01-temp__progress";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${pending.original_filename} ${pending.progress_percent}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b01-temp__progress-bar";
|
||||
const fill = document.createElement("div");
|
||||
fill.style.width = `${pending.progress_percent}%`;
|
||||
bar.append(fill);
|
||||
progressRow.append(label, bar);
|
||||
files.append(progressRow);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-temp__row-actions";
|
||||
if (batch.status !== "linked") {
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void removeBatch(batch),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
row.append(head, meta, files, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetchTempBatches();
|
||||
retentionDays = response.retention_days;
|
||||
hint.textContent = `${L("B01_Temp_Hint")} (${retentionDays}${L("B01_Temp_Hint_Days")})`;
|
||||
listHost.replaceChildren();
|
||||
if (response.batches.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-temp__empty";
|
||||
empty.textContent = L("B01_Temp_Empty");
|
||||
listHost.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const batch of response.batches) listHost.append(renderRow(batch));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
||||
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
||||
try {
|
||||
await deleteTempBatch(batch.batch_id);
|
||||
showToast(L("B01_Temp_Delete_Success"), "success");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Delete_Failed"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 대용량 파일 1건을 청크로 올린다(이어올리기 포함). 진행률은 리스트 행에 반영. */
|
||||
async function uploadLargeFile(batchId: string, file: File): Promise<void> {
|
||||
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||
const key = sessionKey(batchId, file);
|
||||
let stored: StoredTempSession | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
stored = raw ? (JSON.parse(raw) as StoredTempSession) : null;
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
|
||||
let sessionId = stored?.sessionId ?? "";
|
||||
let totalChunks = stored?.totalChunks ?? 0;
|
||||
let done = new Set<number>();
|
||||
if (sessionId) {
|
||||
try {
|
||||
const status = await fetchTempUploadStatus(batchId, sessionId);
|
||||
done = new Set(status.completed_chunk_indexes);
|
||||
totalChunks = status.total_chunks;
|
||||
} catch {
|
||||
sessionId = "";
|
||||
}
|
||||
}
|
||||
if (!sessionId) {
|
||||
const created = await createTempUploadSession(batchId, file, chunkSizeBytes);
|
||||
sessionId = created.upload_session_id;
|
||||
totalChunks = created.total_chunks;
|
||||
done = new Set();
|
||||
}
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
sessionId,
|
||||
chunkSizeBytes,
|
||||
totalChunks,
|
||||
} satisfies StoredTempSession),
|
||||
);
|
||||
|
||||
for (let index = 0; index < totalChunks; index += 1) {
|
||||
if (done.has(index)) continue;
|
||||
const start = index * chunkSizeBytes;
|
||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||
await uploadTempChunk(batchId, sessionId, index, file.slice(start, end));
|
||||
// 리스트 행 진행률은 서버 정본을 다시 읽어 갱신한다(모달 없이 화면에서 확인).
|
||||
if (index % 2 === 1 || index === totalChunks - 1) await refresh();
|
||||
}
|
||||
await finalizeTempUpload(batchId, sessionId, totalChunks);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
async function startUpload(): Promise<void> {
|
||||
const name = nameInput.value.trim();
|
||||
const selected = Array.from(fileInput.files ?? []);
|
||||
if (!name) {
|
||||
showToast(L("B01_Temp_Error_Name"), "error");
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
showToast(L("B01_Temp_Error_Files"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const batch = await createTempBatch(name);
|
||||
const small = selected.filter((file) => !CHUNK_UPLOAD_EXT.has(extensionOf(file.name)));
|
||||
const large = selected.filter((file) => CHUNK_UPLOAD_EXT.has(extensionOf(file.name)));
|
||||
|
||||
if (small.length > 0) {
|
||||
await uploadTempBatchFiles(batch.batch_id, small);
|
||||
await refresh();
|
||||
}
|
||||
for (const file of large) {
|
||||
await uploadLargeFile(batch.batch_id, file);
|
||||
}
|
||||
nameInput.value = "";
|
||||
fileInput.value = "";
|
||||
showToast(L("B01_Temp_Upload_Success"), "success");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"), "error");
|
||||
await refresh();
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return panel;
|
||||
}
|
||||
Reference in New Issue
Block a user