Files
Aislo/B03_FileInput/B03_FileInput_Api_Fetch.ts
T
eomsangdonandClaude Opus 5 1eb5ad4a60 feat(B03): 입력 화면을 계획노선/지형 두 컨테이너로 나누고 파일마다 카드를 준다
노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시).
세트를 슬롯 하나에 몰아 담던 companions 구조를 걷어내고, 슬롯 하나가 파일 하나를
갖는 기존 구조로 되돌렸다 - 업로드 루프도 원래대로다.

- 왼쪽 컨테이너 계획노선 자료: csv(.csv/.shp) shx dbf cpg route_prj
- 오른쪽 컨테이너 지형 자료(LAS): las_laz prj(지형 좌표계) tfw tif + LAS 없는 설계 토글
- .prj만 확장자로 안 갈린다 - 노선 도형과 basename이 같으면 노선 좌표계 카드,
  아니면 지형 카드(planSlotAssignments). 재접속 현황은 저장 경로(input/shp/)로 가른다.
- 재접속 현황 응답에 relative_path 추가.
- 필수 판정이 노선 PRJ를 route_prj로 따로 센다 - 지형 PRJ 없이 통과하던 구멍을 막았다.
- 형제 카드(shx/dbf/route_prj)는 노선 도형이 shapefile일 때만 필수 - 확장자 줄의
  "선택" 꼬리표가 실시간으로 붙고 떨어진다.
- 카드가 좁아져 한글 제목이 글자 단위로 접히던 것을 word-break: keep-all과 헤더
  flex-wrap으로 고쳤다.

화면 검증(공용 브라우저): 실물 7파일을 한 번에 떨어뜨려 배정 실측 - route.prj는
노선 좌표계 카드, terrain.prj는 지형 카드로 갈렸고 카드 제목 9개 모두 한 줄.
tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과.

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

238 lines
6.3 KiB
TypeScript

/* B03 다중 파일 업로드 API 클라이언트 */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface UploadedFileResult {
input_file_id: number;
original_filename: string;
file_type: string;
relative_path: string;
size_bytes: number;
metadata: Record<string, unknown>;
}
export interface FileUploadResponse {
status: string;
project_id: string;
files: UploadedFileResult[];
}
export interface ChunkSessionCreateResponse {
status: string;
project_id: string;
upload_session_id: string;
original_filename: string;
file_size_bytes: number;
chunk_size_bytes: number;
total_chunks: number;
completed_chunks: number;
/** 같은 내용이 이미 올라와 있어 전송을 건너뛰어도 되는지. */
already_uploaded?: boolean;
}
export interface ChunkUploadResponse {
status: string;
upload_session_id: string;
chunk_index: number;
completed_chunks: number;
total_chunks: number;
chunk_hash: string;
}
export interface UploadStatusResponse {
status: string;
upload_session_id: string;
upload_status: string;
original_filename: string;
file_size_bytes: number;
chunk_size_bytes: number;
total_chunks: number;
completed_chunks: number;
completed_chunk_indexes: number[];
}
async function readJsonOrThrow<T>(response: Response): Promise<T> {
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
}
export async function uploadProjectFiles(
projectId: string,
files: readonly File[],
): Promise<FileUploadResponse> {
const formData = new FormData();
for (const file of files) formData.append("files", file, file.name);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/files`,
{
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
}
}
export async function createUploadSession(
projectId: string,
file: File,
chunkSizeBytes: number,
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
export async function uploadFileChunk(
projectId: string,
sessionId: string,
chunkIndex: number,
chunk: Blob,
): Promise<ChunkUploadResponse> {
const formData = new FormData();
formData.append("session_id", sessionId);
formData.append("chunk_index", String(chunkIndex));
formData.append("chunk_data", chunk, `${sessionId}-${chunkIndex}.chunk`);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/chunks`, {
method: "POST",
credentials: "include",
body: formData,
});
return await readJsonOrThrow<ChunkUploadResponse>(response);
}
export async function finalizeUploadSession(
projectId: string,
sessionId: string,
totalChunks: number,
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
): Promise<FileUploadResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/finalize`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
}
export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadStatusResponse>(response);
}
export interface UploadOverviewFile {
input_file_id: number;
file_type: string;
original_filename: string;
file_size_mb: number;
status: string;
uploaded_at: string | null;
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
relative_path: string | null;
}
export interface UploadOverviewSession {
upload_session_id: string;
original_filename: string;
file_size_bytes: number;
total_chunks: number;
completed_chunks: number;
progress_percent: number;
updated_at: string | null;
}
export interface UploadOverviewResponse {
status: string;
files: UploadOverviewFile[];
pending_sessions: UploadOverviewSession[];
required_complete: boolean;
analysis_complete: boolean;
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(
projectId: string,
): Promise<UploadOverviewResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
export interface WF1AnalysisStatus {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
model_count: number;
progress_percent: number;
current_stage: string;
message: string;
error?: string;
}
export async function checkWF1AnalysisStatus(
projectId: string,
): Promise<WF1AnalysisStatus> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/surface/status`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}