260705_2
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/* B03 다중 파일 업로드 API 클라이언트 */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } 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 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);
|
||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as Partial<FileUploadResponse> & {
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload as FileUploadResponse;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""B03 파일 입력 저장 엔진."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
from common_util.common_util_storage import get_project_stage_path
|
||||
from config.config_system import UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
def resolve_upload_destination(
|
||||
project_root: str | Path,
|
||||
descriptor: FileUploadDescriptor,
|
||||
) -> Path:
|
||||
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
|
||||
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
|
||||
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
|
||||
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
|
||||
|
||||
if os.path.commonpath((stage_root, destination)) != str(stage_root):
|
||||
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
return destination
|
||||
|
||||
|
||||
async def save_upload_stream(upload: UploadFile, destination: Path) -> int:
|
||||
"""업로드 스트림을 크기 제한 내에서 임시 파일에 기록한 뒤 교체한다."""
|
||||
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
written_bytes = 0
|
||||
temporary_path: Path | None = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=destination.parent,
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".upload",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
while chunk := await upload.read(UPLOAD_CHUNK_SIZE_BYTES):
|
||||
written_bytes += len(chunk)
|
||||
if written_bytes > maximum_bytes:
|
||||
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
|
||||
temporary.write(chunk)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
|
||||
if written_bytes == 0:
|
||||
raise ValueError("빈 파일은 업로드할 수 없습니다.")
|
||||
os.replace(temporary_path, destination)
|
||||
temporary_path = None
|
||||
return written_bytes
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""B03 원본 입력 파일 메타데이터 분석."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from pyproj import CRS
|
||||
|
||||
|
||||
def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다."""
|
||||
source = Path(path)
|
||||
with laspy.open(source) as las_file:
|
||||
header = las_file.header
|
||||
point_format = header.point_format
|
||||
dimension_names = list(point_format.dimension_names)
|
||||
point_count = int(header.point_count)
|
||||
crs = header.parse_crs()
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"version": f"{header.version.major}.{header.version.minor}",
|
||||
"point_format": {
|
||||
"id": point_format.id,
|
||||
"dimensions": dimension_names,
|
||||
},
|
||||
"point_count": point_count,
|
||||
"bounds": {
|
||||
"x": [float(header.mins[0]), float(header.maxs[0])],
|
||||
"y": [float(header.mins[1]), float(header.maxs[1])],
|
||||
"z": [float(header.mins[2]), float(header.maxs[2])],
|
||||
},
|
||||
"scale": [float(value) for value in header.scales],
|
||||
"offset": [float(value) for value in header.offsets],
|
||||
"has_crs": crs is not None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
"has_classification": "classification" in dimension_names,
|
||||
"has_rgb": all(name in dimension_names for name in ("red", "green", "blue")),
|
||||
"has_intensity": "intensity" in dimension_names,
|
||||
"has_return_number": "return_number" in dimension_names,
|
||||
}
|
||||
|
||||
if metadata["has_classification"] and point_count > 0:
|
||||
classification_counts: dict[int, int] = {}
|
||||
for chunk in las_file.chunk_iterator(500_000):
|
||||
values, counts = np.unique(
|
||||
np.asarray(chunk.classification, dtype=np.uint8),
|
||||
return_counts=True,
|
||||
)
|
||||
for value, count in zip(values.tolist(), counts.tolist(), strict=True):
|
||||
classification_counts[value] = classification_counts.get(value, 0) + count
|
||||
metadata["classification_summary"] = {
|
||||
str(key): value for key, value in sorted(classification_counts.items())
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def analyze_prj_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""PRJ WKT에서 좌표계 식별자와 명칭을 추출한다."""
|
||||
source = Path(path)
|
||||
text = source.read_text(encoding="utf-8", errors="replace").strip()
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"text_preview": text[:500],
|
||||
"epsg": None,
|
||||
"name": None,
|
||||
"authority": None,
|
||||
"is_valid": False,
|
||||
}
|
||||
if not text:
|
||||
metadata["error"] = "PRJ 파일이 비어 있습니다."
|
||||
return metadata
|
||||
|
||||
try:
|
||||
crs = CRS.from_wkt(text)
|
||||
except Exception as exc:
|
||||
metadata["error"] = str(exc)
|
||||
return metadata
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"epsg": crs.to_epsg(),
|
||||
"name": crs.name,
|
||||
"authority": crs.to_authority(),
|
||||
"is_valid": True,
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TFW의 affine 변환 계수와 유효성을 분석한다."""
|
||||
source = Path(path)
|
||||
values = [
|
||||
float(line.strip())
|
||||
for line in source.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
if any(not math.isfinite(value) for value in values):
|
||||
raise ValueError("TFW 변환 계수는 유한한 숫자여야 합니다.")
|
||||
|
||||
return {
|
||||
"file": source.name,
|
||||
"values": values,
|
||||
"pixel_size_x": values[0] if len(values) > 0 else None,
|
||||
"rotation_y": values[1] if len(values) > 1 else None,
|
||||
"rotation_x": values[2] if len(values) > 2 else None,
|
||||
"pixel_size_y": values[3] if len(values) > 3 else None,
|
||||
"origin_x": values[4] if len(values) > 4 else None,
|
||||
"origin_y": values[5] if len(values) > 5 else None,
|
||||
"is_valid": len(values) == 6,
|
||||
}
|
||||
|
||||
|
||||
def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다."""
|
||||
source = Path(path)
|
||||
with rasterio.open(source) as dataset:
|
||||
crs = dataset.crs
|
||||
bounds = dataset.bounds
|
||||
return {
|
||||
"file": source.name,
|
||||
"width": int(dataset.width),
|
||||
"height": int(dataset.height),
|
||||
"count": int(dataset.count),
|
||||
"dtypes": list(dataset.dtypes),
|
||||
"nodata": float(dataset.nodata) if dataset.nodata is not None else None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
"bounds": {
|
||||
"left": float(bounds.left),
|
||||
"bottom": float(bounds.bottom),
|
||||
"right": float(bounds.right),
|
||||
"top": float(bounds.top),
|
||||
},
|
||||
"transform": [float(value) for value in list(dataset.transform)[:6]],
|
||||
"resolution": [float(value) for value in dataset.res],
|
||||
"likely_type": "dem" if dataset.count == 1 else "image",
|
||||
}
|
||||
|
||||
|
||||
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
|
||||
source = Path(path)
|
||||
extension = source.suffix.lower()
|
||||
if extension in {".las", ".laz"}:
|
||||
return analyze_las_metadata(source)
|
||||
if extension == ".prj":
|
||||
return analyze_prj_metadata(source)
|
||||
if extension == ".tfw":
|
||||
return analyze_tfw_metadata(source)
|
||||
if extension in {".tif", ".tiff"}:
|
||||
return analyze_tif_metadata(source)
|
||||
return {
|
||||
"file": source.name,
|
||||
"extension": extension.lstrip("."),
|
||||
"size_bytes": source.stat().st_size,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""B03 input_files 테이블의 aiomysql Raw SQL 접근."""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
|
||||
async def create_input_file(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
file_type: str,
|
||||
original_filename: str,
|
||||
relative_path: str,
|
||||
file_size_bytes: int,
|
||||
upload_by: int | None,
|
||||
crs_epsg: int | None,
|
||||
metadata: dict[str, Any],
|
||||
) -> int:
|
||||
"""업로드 원본 파일 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
normalized_path = PurePosixPath(relative_path)
|
||||
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if normalized_path.parts[:2] != ("B03_FileInput", "input"):
|
||||
raise ValueError("입력 파일 경로는 B03_FileInput/input 아래여야 합니다.")
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO input_files (
|
||||
project_id,
|
||||
file_type,
|
||||
original_filename,
|
||||
raw_file_path,
|
||||
file_size_mb,
|
||||
upload_by,
|
||||
crs_epsg,
|
||||
metadata,
|
||||
status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'UPLOADED')
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
file_type,
|
||||
original_filename,
|
||||
normalized_path.as_posix(),
|
||||
file_size_bytes / (1024 * 1024),
|
||||
upload_by,
|
||||
crs_epsg,
|
||||
json.dumps(metadata, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
input_file_id = cursor.lastrowid
|
||||
|
||||
if not input_file_id:
|
||||
raise RuntimeError("input_files 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(input_file_id)
|
||||
|
||||
|
||||
async def get_project_storage_relative_path(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> str:
|
||||
"""프로젝트의 검증된 저장소 상대 경로를 조회한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT storage_path
|
||||
FROM projects
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row or not row[0]:
|
||||
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
|
||||
normalized_path = PurePosixPath(str(row[0]).replace("\\", "/"))
|
||||
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
||||
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
||||
return normalized_path.as_posix()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""B03 파일 입력 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
resolve_upload_destination,
|
||||
save_upload_stream,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
get_project_storage_relative_path,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import (
|
||||
FileUploadDescriptor,
|
||||
FileUploadResponse,
|
||||
UploadedFileResult,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow import load_project_workflow
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import UPLOAD_MAX_FILES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
if not files or len(files) > UPLOAD_MAX_FILES:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다.",
|
||||
},
|
||||
)
|
||||
|
||||
filenames = [upload.filename or "" for upload in files]
|
||||
if len({filename.casefold() for filename in filenames}) != len(filenames):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
|
||||
)
|
||||
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
|
||||
if las_count != 1:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
|
||||
},
|
||||
)
|
||||
|
||||
pool = get_db_pool()
|
||||
saved_paths: list[Path] = []
|
||||
try:
|
||||
results: list[UploadedFileResult] = []
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
for upload in files:
|
||||
preliminary = FileUploadDescriptor(
|
||||
original_filename=upload.filename or "",
|
||||
size_bytes=max(upload.size or 0, 1),
|
||||
)
|
||||
destination = resolve_upload_destination(project_root, preliminary)
|
||||
written_bytes = await save_upload_stream(upload, destination)
|
||||
saved_paths.append(destination)
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename=preliminary.original_filename,
|
||||
size_bytes=written_bytes,
|
||||
)
|
||||
metadata = await asyncio.to_thread(analyze_input_metadata, destination)
|
||||
relative_path = destination.relative_to(project_root).as_posix()
|
||||
file_type = destination.suffix.lower().lstrip(".")
|
||||
crs_epsg = metadata.get("epsg")
|
||||
input_file_id = await create_input_file(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type=file_type,
|
||||
original_filename=descriptor.original_filename,
|
||||
relative_path=relative_path,
|
||||
file_size_bytes=written_bytes,
|
||||
upload_by=None,
|
||||
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
results.append(
|
||||
UploadedFileResult(
|
||||
input_file_id=input_file_id,
|
||||
original_filename=descriptor.original_filename,
|
||||
file_type=file_type,
|
||||
relative_path=relative_path,
|
||||
size_bytes=written_bytes,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
stage_root = project_root / "B03_FileInput"
|
||||
atomic_write_json(
|
||||
stage_root / "metadata.json",
|
||||
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
|
||||
)
|
||||
workflow_path = project_root / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
atomic_write_json(workflow_path, load_project_workflow(project_root))
|
||||
return FileUploadResponse(project_id=str(project_id), files=results)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
for saved_path in saved_paths:
|
||||
saved_path.unlink(missing_ok=True)
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
for saved_path in saved_paths:
|
||||
saved_path.unlink(missing_ok=True)
|
||||
logger.exception("B03 파일 업로드 처리 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "파일 업로드 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
finally:
|
||||
for upload in files:
|
||||
await upload.close()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""B03 파일 입력 요청·응답 검증 모델."""
|
||||
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
class FileUploadDescriptor(BaseModel):
|
||||
"""업로드 전에 검증할 단일 파일의 이름과 크기."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
original_filename: str = Field(min_length=1, max_length=255)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_file(self) -> "FileUploadDescriptor":
|
||||
filename = self.original_filename
|
||||
if PurePath(filename).name != filename or "/" in filename or "\\" in filename:
|
||||
raise ValueError("파일명에는 경로가 포함될 수 없습니다.")
|
||||
|
||||
extension = PurePath(filename).suffix.lower()
|
||||
if extension not in UPLOAD_ALLOWED_EXT:
|
||||
allowed = ", ".join(UPLOAD_ALLOWED_EXT)
|
||||
raise ValueError(f"허용되지 않은 파일 확장자입니다. 허용 형식: {allowed}")
|
||||
|
||||
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
if self.size_bytes > maximum_bytes:
|
||||
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
|
||||
return self
|
||||
|
||||
|
||||
class UploadedFileResult(BaseModel):
|
||||
"""저장 완료된 단일 입력 파일 정보."""
|
||||
|
||||
input_file_id: int
|
||||
original_filename: str
|
||||
file_type: str
|
||||
relative_path: str
|
||||
size_bytes: int
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""B03 다중 파일 업로드 성공 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
files: list[UploadedFileResult]
|
||||
@@ -1,29 +1,188 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Page.ts
|
||||
* 로그인 후 03: 파일 입력 (지형·포인트클라우드·도면 업로드)
|
||||
*
|
||||
* ⚠️ 본문(업로드 드롭존/파일 목록)은 준비 중 — 헤더/안내만 구현.
|
||||
* 실제 업로드 UI는 0_old 참고하여 추후 구체화.
|
||||
*
|
||||
* 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 컴포넌트 사용(§2).
|
||||
* ========================================================================== */
|
||||
/* B03 파일 입력 페이지 */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingContent } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
CURRENT_PROJECT_ID_KEY,
|
||||
UPLOAD_ALLOWED_EXT,
|
||||
UPLOAD_MAX_FILES,
|
||||
UPLOAD_MAX_MB,
|
||||
} from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
function validateFiles(files: readonly File[]): string | null {
|
||||
if (files.length === 0) return L("B03_File_Error_Required");
|
||||
if (files.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
|
||||
const maximumBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
const normalizedNames = new Set<string>();
|
||||
let lasCount = 0;
|
||||
for (const file of files) {
|
||||
const extensionIndex = file.name.lastIndexOf(".");
|
||||
const extension = extensionIndex >= 0 ? file.name.slice(extensionIndex).toLowerCase() : "";
|
||||
if (!UPLOAD_ALLOWED_EXT.includes(extension as (typeof UPLOAD_ALLOWED_EXT)[number])) {
|
||||
return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
}
|
||||
if (file.size === 0 || file.size > maximumBytes) {
|
||||
return `${L("B03_File_Error_Size")} ${file.name}`;
|
||||
}
|
||||
const normalizedName = file.name.toLocaleLowerCase();
|
||||
if (normalizedNames.has(normalizedName)) return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
normalizedNames.add(normalizedName);
|
||||
if (extension === ".las" || extension === ".laz") lasCount += 1;
|
||||
}
|
||||
return lasCount === 1 ? null : L("B03_File_Error_Las");
|
||||
}
|
||||
|
||||
export function renderB03FileInput(root: HTMLElement): void {
|
||||
renderPendingContent(root, {
|
||||
pageClass: "b03-file",
|
||||
title: L("B03_File_Title"),
|
||||
subtitle: L("B03_File_Subtitle"),
|
||||
let selectedFiles: File[] = [];
|
||||
const page = document.createElement("div");
|
||||
page.className = "b03-file";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "b03-file__header";
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b03-file__title";
|
||||
title.textContent = L("B03_File_Title");
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b03-file__subtitle";
|
||||
subtitle.textContent = L("B03_File_Subtitle");
|
||||
header.append(title, subtitle);
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = UPLOAD_ALLOWED_EXT.join(",");
|
||||
fileInput.className = "b03-file__native-input";
|
||||
|
||||
const dropzone = document.createElement("div");
|
||||
dropzone.className = "b03-file__dropzone";
|
||||
dropzone.tabIndex = 0;
|
||||
const dropzoneLabel = document.createElement("strong");
|
||||
dropzoneLabel.textContent = L("B03_File_Select_Label");
|
||||
const dropzoneHint = document.createElement("span");
|
||||
dropzoneHint.textContent = L("B03_File_Select_Hint");
|
||||
dropzone.append(dropzoneLabel, dropzoneHint, fileInput);
|
||||
|
||||
const errorSlot = document.createElement("p");
|
||||
errorSlot.className = "b03-file__error";
|
||||
errorSlot.setAttribute("role", "alert");
|
||||
const selectedTitle = document.createElement("h2");
|
||||
selectedTitle.className = "b03-file__section-title";
|
||||
selectedTitle.textContent = L("B03_File_Selected_Title");
|
||||
const selectedList = document.createElement("ul");
|
||||
selectedList.className = "b03-file__list";
|
||||
const resultList = document.createElement("ul");
|
||||
resultList.className = "b03-file__results";
|
||||
|
||||
function renderSelectedFiles(): void {
|
||||
selectedList.replaceChildren();
|
||||
if (selectedFiles.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "b03-file__empty";
|
||||
empty.textContent = L("B03_File_Selected_Empty");
|
||||
selectedList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const file of selectedFiles) {
|
||||
const item = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = file.name;
|
||||
const size = document.createElement("span");
|
||||
size.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
|
||||
item.append(name, size);
|
||||
selectedList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedFiles(files: readonly File[]): void {
|
||||
selectedFiles = Array.from(files);
|
||||
errorSlot.textContent = validateFiles(selectedFiles) ?? "";
|
||||
renderSelectedFiles();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function onB03_File_Select_Change(): void {
|
||||
setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
}
|
||||
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
setSelectedFiles(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
async function onB03_File_Upload_Click(): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const validationError = validateFiles(selectedFiles);
|
||||
if (!projectId) {
|
||||
errorSlot.textContent = L("B03_File_Error_Project");
|
||||
return;
|
||||
}
|
||||
if (validationError) {
|
||||
errorSlot.textContent = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
errorSlot.textContent = "";
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await uploadProjectFiles(projectId, selectedFiles);
|
||||
renderUploadResults(response.files);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
errorSlot.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
dropzone.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") fileInput.click();
|
||||
});
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
const uploadButton = createButton({
|
||||
label: L("B03_File_Upload_Button"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB03_File_Upload_Click(),
|
||||
});
|
||||
const body = document.createElement("div");
|
||||
body.className = "b03-file__body";
|
||||
body.append(dropzone, errorSlot, selectedTitle, selectedList, uploadButton, resultList);
|
||||
page.append(header, createCard({ body: [body], raised: true }));
|
||||
root.replaceChildren(page);
|
||||
renderSelectedFiles();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,118 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Style.css
|
||||
* 파일 입력 페이지 전용 스타일 (theme.css 변수만 사용)
|
||||
* ⚠️ 본문(업로드 드롭존) 준비 중 — 현재는 공통 스캐폴드 스타일에 위임.
|
||||
* ========================================================================== */
|
||||
.b03-file {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
/* 본문 구현 시 .b03-file 하위에 업로드 드롭존/파일 목록 스타일 추가 예정 */
|
||||
.b03-file__header,
|
||||
.b03-file__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b03-file__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-heading);
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b03-file__subtitle,
|
||||
.b03-file__dropzone span,
|
||||
.b03-file__empty {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b03-file__native-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__dropzone {
|
||||
min-height: 180px;
|
||||
padding: var(--spacing-24);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__dropzone:focus-visible,
|
||||
.b03-file__dropzone.is-dragging {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.b03-file__dropzone strong,
|
||||
.b03-file__section-title,
|
||||
.b03-file__results strong {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b03-file__section-title {
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.b03-file__error {
|
||||
min-height: var(--spacing-16);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b03-file__list,
|
||||
.b03-file__results {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b03-file__list li,
|
||||
.b03-file__results li {
|
||||
padding: var(--spacing-16);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b03-file__list li:nth-child(even),
|
||||
.b03-file__results li:nth-child(even) {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b03-file__list li:last-child,
|
||||
.b03-file__results li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b03-file__results:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__results li {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.b03-file__list li {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user