260710_0
This commit is contained in:
@@ -17,6 +17,46 @@ export interface FileUploadResponse {
|
||||
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;
|
||||
}
|
||||
|
||||
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[],
|
||||
@@ -33,14 +73,85 @@ export async function uploadProjectFiles(
|
||||
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;
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUploadSession(
|
||||
projectId: string,
|
||||
file: File,
|
||||
chunkSizeBytes: number,
|
||||
): 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,
|
||||
}),
|
||||
});
|
||||
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,
|
||||
): 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 }),
|
||||
});
|
||||
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 WF1AnalysisStatus {
|
||||
project_id: string;
|
||||
status: "pending" | "in_progress" | "completed" | "failed";
|
||||
model_count: number;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""B03 업로드 및 B04 WF1 분석 결과 이메일 발송."""
|
||||
|
||||
import html
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from common_util.common_util_email import send_email
|
||||
from config.config_system import APP_PUBLIC_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
if isinstance(value, int | float):
|
||||
return f"{value:,}"
|
||||
return "N/A"
|
||||
|
||||
|
||||
def _format_elevation(value: Any) -> str:
|
||||
if isinstance(value, int | float):
|
||||
return f"{value:.2f}"
|
||||
return "N/A"
|
||||
|
||||
|
||||
def _extract_elevation_range(bounds: dict[str, Any], statistics: dict[str, Any]) -> tuple[Any, Any]:
|
||||
if isinstance(bounds.get("z"), list | tuple) and len(bounds["z"]) >= 2:
|
||||
return bounds["z"][0], bounds["z"][1]
|
||||
return (
|
||||
bounds.get("min_z", statistics.get("min_z")),
|
||||
bounds.get("max_z", statistics.get("max_z")),
|
||||
)
|
||||
|
||||
|
||||
def _app_url(path: str) -> str:
|
||||
return f"{APP_PUBLIC_BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _email_shell(title: str, body: str, *, accent: str = "#2563eb") -> str:
|
||||
box_style = (
|
||||
f"border: 1px solid #e5e7eb; border-left: 4px solid {accent}; "
|
||||
"padding: 18px; margin: 20px 0;"
|
||||
)
|
||||
row_style = (
|
||||
"display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; "
|
||||
"border-bottom: 1px solid #f3f4f6;"
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #111827;
|
||||
background: #f3f4f6;
|
||||
}}
|
||||
.container {{ max-width: 640px; margin: 0 auto; padding: 24px; }}
|
||||
.header {{
|
||||
background: {accent};
|
||||
color: #ffffff;
|
||||
padding: 28px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{ margin: 0; font-size: 24px; letter-spacing: 0; }}
|
||||
.content {{ background: #ffffff; padding: 28px; border-radius: 0 0 8px 8px; }}
|
||||
.box {{ {box_style} }}
|
||||
.row {{ {row_style} }}
|
||||
.row:last-child {{ border-bottom: 0; }}
|
||||
.label {{ color: #6b7280; font-weight: 700; }}
|
||||
.value {{ color: #111827; text-align: right; }}
|
||||
.notice {{ background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; border-radius: 6px; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
background: {accent};
|
||||
color: #ffffff !important;
|
||||
padding: 12px 22px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.footer {{ color: #6b7280; font-size: 12px; text-align: center; margin-top: 24px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>{html.escape(title)}</h1></div>
|
||||
<div class="content">
|
||||
{body}
|
||||
<div class="footer">
|
||||
<p>© 2026 Aislo - AI 임도 설계 솔루션</p>
|
||||
<p>문의: support@aislo.example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _summary_row(label: str, value: str) -> str:
|
||||
return (
|
||||
'<div class="row">'
|
||||
f'<span class="label">{html.escape(label)}</span>'
|
||||
f'<span class="value">{value}</span>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
async def send_file_upload_complete_email(
|
||||
*,
|
||||
to_email: str,
|
||||
user_name: str,
|
||||
project_name: str,
|
||||
file_name: str,
|
||||
file_size_mb: float,
|
||||
metadata: dict[str, Any],
|
||||
) -> bool:
|
||||
"""파일 업로드 완료 직후 사용자에게 WF1 분석 시작을 안내한다."""
|
||||
bounds = metadata.get("bounds") or {}
|
||||
statistics = metadata.get("statistics") or {}
|
||||
min_z, max_z = _extract_elevation_range(bounds, statistics)
|
||||
point_count = metadata.get("point_count", metadata.get("points", 0))
|
||||
epsg = metadata.get("epsg", "N/A")
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("파일명", html.escape(file_name)),
|
||||
_summary_row("파일 크기", f"{file_size_mb:.2f} MB"),
|
||||
_summary_row("포인트 수", _format_number(point_count)),
|
||||
_summary_row("좌표계", f"EPSG:{html.escape(str(epsg))}"),
|
||||
_summary_row(
|
||||
"고도 범위",
|
||||
f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
body = f"""
|
||||
<p>안녕하세요, <strong>{html.escape(user_name or "사용자")}</strong>님.</p>
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 입력 파일이 저장되었습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<div class="notice">
|
||||
지표면 분석(WF1)이 백그라운드에서 자동으로 시작되었습니다.
|
||||
완료되면 분석 요약과 결과 페이지 링크를 다시 보내드립니다.
|
||||
</div>
|
||||
"""
|
||||
subject = f"파일 업로드 완료 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body),
|
||||
)
|
||||
logger.info("파일 업로드 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
|
||||
|
||||
async def send_analysis_completion_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
project_name: str,
|
||||
to_email: str,
|
||||
analysis_result: dict[str, Any],
|
||||
) -> bool:
|
||||
"""WF1 Surface 분석 완료 후 결과 요약 이메일을 발송한다."""
|
||||
processed = analysis_result.get("processed") or {}
|
||||
statistics = processed.get("statistics") or {}
|
||||
bounds = processed.get("bounds") or {}
|
||||
models = analysis_result.get("models") or []
|
||||
min_z = statistics.get("min_z", bounds.get("min_z"))
|
||||
max_z = statistics.get("max_z", bounds.get("max_z"))
|
||||
point_count = processed.get("point_count", 0)
|
||||
result_url = _app_url(f"/projects/{project_id}/surface")
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("분석 포인트 수", _format_number(point_count)),
|
||||
_summary_row(
|
||||
"고도 범위",
|
||||
f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m",
|
||||
),
|
||||
_summary_row("생성 모델 수", f"{len(models):,}개"),
|
||||
_summary_row("포함 데이터", "DTM, TIN, 미리보기 레이어"),
|
||||
]
|
||||
)
|
||||
|
||||
body = f"""
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 지표면 분석이 완료되었습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<p>결과는 지표면 분석 페이지에서 확인할 수 있습니다.</p>
|
||||
<p style="text-align: center; margin: 26px 0;">
|
||||
<a class="button" href="{html.escape(result_url)}">분석 결과 보기</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;">
|
||||
로그인이 필요한 경우 인증 후 같은 결과 페이지로 이동합니다.
|
||||
</p>
|
||||
"""
|
||||
subject = f"임도 지표면 분석 완료 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body),
|
||||
)
|
||||
logger.info("WF1 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
|
||||
|
||||
async def send_analysis_error_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
project_name: str,
|
||||
to_email: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""WF1 Surface 분석 실패 알림 이메일을 발송한다."""
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("프로젝트 ID", html.escape(str(project_id))),
|
||||
_summary_row("오류 내용", html.escape(error_message)),
|
||||
]
|
||||
)
|
||||
body = f"""
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 지표면 분석 중 오류가 발생했습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<p>기술 지원팀에서 확인할 수 있도록 서버 로그에도 같은 오류를 기록했습니다.</p>
|
||||
"""
|
||||
subject = f"지표면 분석 실패 알림 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body, accent="#dc2626"),
|
||||
)
|
||||
logger.info("WF1 오류 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B03 파일 입력 저장 엔진."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,7 +9,7 @@ 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
|
||||
from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
def resolve_upload_destination(
|
||||
@@ -58,3 +59,118 @@ async def save_upload_stream(upload: UploadFile, destination: Path) -> int:
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resolve_chunk_session_dir(project_root: str | Path, session_id: str) -> Path:
|
||||
"""청크 업로드 세션의 임시 저장 폴더를 B03 단계 내부에 만든다."""
|
||||
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
|
||||
chunk_root = (Path(project_root) / CHUNK_TEMP_DIR).resolve()
|
||||
session_dir = (chunk_root / session_id).resolve()
|
||||
|
||||
if os.path.commonpath((stage_root, session_dir)) != str(stage_root):
|
||||
raise ValueError("청크 임시 저장 경로가 B03 단계 폴더를 벗어났습니다.")
|
||||
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return session_dir
|
||||
|
||||
|
||||
async def save_upload_chunk(
|
||||
upload: UploadFile,
|
||||
session_dir: Path,
|
||||
chunk_index: int,
|
||||
expected_max_bytes: int = UPLOAD_CHUNK_SIZE_BYTES,
|
||||
) -> tuple[Path, int, str]:
|
||||
"""단일 청크를 임시 파일로 저장하고 SHA256 해시를 반환한다."""
|
||||
import hashlib
|
||||
|
||||
if chunk_index < 0:
|
||||
raise ValueError("청크 인덱스는 0 이상이어야 합니다.")
|
||||
|
||||
destination = (session_dir / f"{chunk_index:08d}.chunk").resolve()
|
||||
if os.path.commonpath((session_dir, destination)) != str(session_dir):
|
||||
raise ValueError("청크 파일 경로가 세션 폴더를 벗어났습니다.")
|
||||
|
||||
digest = hashlib.sha256()
|
||||
written_bytes = 0
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=session_dir,
|
||||
prefix=f".{chunk_index:08d}.",
|
||||
suffix=".chunk.upload",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
while chunk := await upload.read(min(1024 * 1024, expected_max_bytes)):
|
||||
written_bytes += len(chunk)
|
||||
if written_bytes > expected_max_bytes:
|
||||
raise ValueError("청크 크기가 허용 범위를 초과했습니다.")
|
||||
digest.update(chunk)
|
||||
temporary.write(chunk)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
|
||||
if written_bytes == 0:
|
||||
raise ValueError("빈 청크는 업로드할 수 없습니다.")
|
||||
os.replace(temporary_path, destination)
|
||||
temporary_path = None
|
||||
return destination, written_bytes, digest.hexdigest()
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def merge_upload_chunks(
|
||||
project_root: str | Path,
|
||||
descriptor: FileUploadDescriptor,
|
||||
session_id: str,
|
||||
total_chunks: int,
|
||||
) -> Path:
|
||||
"""세션 청크를 순서대로 병합해 최종 입력 파일로 저장한다."""
|
||||
if total_chunks <= 0:
|
||||
raise ValueError("병합할 청크 개수가 올바르지 않습니다.")
|
||||
|
||||
project_root_path = Path(project_root)
|
||||
session_dir = resolve_chunk_session_dir(project_root_path, session_id)
|
||||
destination = resolve_upload_destination(project_root_path, descriptor)
|
||||
temporary_path: Path | None = None
|
||||
merged_bytes = 0
|
||||
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=destination.parent,
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".merge",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
for index in range(total_chunks):
|
||||
chunk_path = session_dir / f"{index:08d}.chunk"
|
||||
if not chunk_path.exists():
|
||||
raise ValueError(f"누락된 청크가 있습니다. index={index}")
|
||||
with chunk_path.open("rb") as chunk_file:
|
||||
while data := chunk_file.read(1024 * 1024):
|
||||
merged_bytes += len(data)
|
||||
if merged_bytes > maximum_bytes:
|
||||
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
|
||||
temporary.write(data)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
|
||||
if merged_bytes != descriptor.size_bytes:
|
||||
raise ValueError("병합 파일 크기가 업로드 세션 정보와 일치하지 않습니다.")
|
||||
os.replace(temporary_path, destination)
|
||||
temporary_path = None
|
||||
return destination
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def remove_chunk_session(project_root: str | Path, session_id: str) -> None:
|
||||
"""B03 임시 청크 세션 폴더를 제거한다."""
|
||||
session_dir = resolve_chunk_session_dir(project_root, session_id)
|
||||
shutil.rmtree(session_dir, ignore_errors=True)
|
||||
|
||||
@@ -84,3 +84,184 @@ async def get_project_storage_relative_path(
|
||||
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
||||
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
||||
return normalized_path.as_posix()
|
||||
|
||||
|
||||
async def create_upload_session(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
project_id: UUID,
|
||||
original_filename: str,
|
||||
file_size_bytes: int,
|
||||
chunk_size_bytes: int,
|
||||
total_chunks: int,
|
||||
) -> None:
|
||||
"""청크 업로드 세션을 생성하거나 동일 세션 ID를 갱신한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO upload_sessions (
|
||||
id,
|
||||
project_id,
|
||||
original_filename,
|
||||
file_size_bytes,
|
||||
chunk_size_bytes,
|
||||
total_chunks,
|
||||
completed_chunks,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 0, 'in_progress', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
original_filename = VALUES(original_filename),
|
||||
file_size_bytes = VALUES(file_size_bytes),
|
||||
chunk_size_bytes = VALUES(chunk_size_bytes),
|
||||
total_chunks = VALUES(total_chunks),
|
||||
status = 'in_progress',
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
session_id,
|
||||
str(project_id),
|
||||
original_filename,
|
||||
file_size_bytes,
|
||||
chunk_size_bytes,
|
||||
total_chunks,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_upload_session(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""청크 업로드 세션을 조회한다."""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
project_id,
|
||||
original_filename,
|
||||
file_size_bytes,
|
||||
chunk_size_bytes,
|
||||
total_chunks,
|
||||
completed_chunks,
|
||||
status
|
||||
FROM upload_sessions
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(session_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise LookupError("업로드 세션을 찾을 수 없습니다.")
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def upsert_upload_chunk(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
chunk_index: int,
|
||||
chunk_hash: str,
|
||||
size_bytes: int,
|
||||
stored_at: str,
|
||||
) -> int:
|
||||
"""청크 저장 정보를 기록하고 완료 청크 수를 반환한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO upload_chunks (
|
||||
session_id,
|
||||
chunk_index,
|
||||
chunk_hash,
|
||||
size_bytes,
|
||||
stored_at,
|
||||
completed_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chunk_hash = VALUES(chunk_hash),
|
||||
size_bytes = VALUES(size_bytes),
|
||||
stored_at = VALUES(stored_at),
|
||||
completed_at = NOW()
|
||||
""",
|
||||
(session_id, chunk_index, chunk_hash, size_bytes, stored_at),
|
||||
)
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM upload_chunks
|
||||
WHERE session_id = %s
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
completed_chunks = int(row[0]) if row else 0
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE upload_sessions
|
||||
SET completed_chunks = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(completed_chunks, session_id),
|
||||
)
|
||||
return completed_chunks
|
||||
|
||||
|
||||
async def list_completed_chunk_indexes(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> list[int]:
|
||||
"""완료된 청크 인덱스 목록을 반환한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT chunk_index
|
||||
FROM upload_chunks
|
||||
WHERE session_id = %s
|
||||
ORDER BY chunk_index ASC
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [int(row[0]) for row in rows]
|
||||
|
||||
|
||||
async def mark_upload_session_completed(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""업로드 세션을 완료 상태로 표시한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE upload_sessions
|
||||
SET status = 'completed', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
|
||||
async def mark_upload_session_failed(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""업로드 세션을 실패 상태로 표시한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE upload_sessions
|
||||
SET status = 'failed', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
@@ -3,35 +3,226 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Email import (
|
||||
send_analysis_completion_email,
|
||||
send_analysis_error_email,
|
||||
send_file_upload_complete_email,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
merge_upload_chunks,
|
||||
remove_chunk_session,
|
||||
resolve_chunk_session_dir,
|
||||
resolve_upload_destination,
|
||||
save_upload_chunk,
|
||||
save_upload_stream,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
create_upload_session,
|
||||
get_project_storage_relative_path,
|
||||
get_upload_session,
|
||||
list_completed_chunk_indexes,
|
||||
mark_upload_session_completed,
|
||||
mark_upload_session_failed,
|
||||
upsert_upload_chunk,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import (
|
||||
ChunkSessionCreateRequest,
|
||||
ChunkSessionCreateResponse,
|
||||
ChunkUploadResponse,
|
||||
FileUploadDescriptor,
|
||||
FileUploadResponse,
|
||||
UploadedFileResult,
|
||||
UploadFinalizeRequest,
|
||||
UploadStatusResponse,
|
||||
)
|
||||
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
|
||||
from config.config_system import (
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
UPLOAD_CHUNK_SIZE_BYTES,
|
||||
UPLOAD_MAX_FILES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
||||
|
||||
|
||||
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
|
||||
return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes)
|
||||
|
||||
|
||||
def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
return result.file_type.lower() in {"las", "laz"}
|
||||
|
||||
|
||||
def _schedule_background_task(coro: Any, *, task_name: str) -> None:
|
||||
task = asyncio.create_task(coro, name=task_name)
|
||||
|
||||
def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
try:
|
||||
completed.result()
|
||||
except Exception:
|
||||
logger.exception("백그라운드 작업 실패: %s", task_name)
|
||||
|
||||
task.add_done_callback(_log_task_failure)
|
||||
|
||||
|
||||
async def _get_project_notification_info(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.name AS project_name,
|
||||
u.email AS user_email,
|
||||
u.name AS user_name
|
||||
FROM projects p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _send_upload_complete_notification(
|
||||
*,
|
||||
project_id: UUID,
|
||||
uploaded_file: UploadedFileResult,
|
||||
) -> None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
project_info = await _get_project_notification_info(connection, project_id)
|
||||
if not project_info or not project_info.get("user_email"):
|
||||
logger.warning("업로드 완료 이메일 수신자 없음: project_id=%s", project_id)
|
||||
return
|
||||
|
||||
await send_file_upload_complete_email(
|
||||
to_email=str(project_info["user_email"]),
|
||||
user_name=str(project_info.get("user_name") or "사용자"),
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
file_name=uploaded_file.original_filename,
|
||||
file_size_mb=uploaded_file.size_bytes / (1024 * 1024),
|
||||
metadata=uploaded_file.metadata,
|
||||
)
|
||||
|
||||
|
||||
async def trigger_wf1_analysis_and_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
input_file_id: int,
|
||||
) -> None:
|
||||
"""WF1 분석 실행, DB 저장, 완료/오류 이메일 발송을 백그라운드에서 수행한다.
|
||||
|
||||
실행 흐름:
|
||||
1. 프로젝트 저장 경로와 입력 파일 정보를 조회한다.
|
||||
2. 무거운 WF1 분석은 워커 스레드에서 실행한다.
|
||||
3. 분석 결과를 단일 DB 트랜잭션으로 저장한다.
|
||||
4. 완료 이메일은 SEND_ANALYSIS_COMPLETION_EMAIL 설정이 켜진 경우에만 발송한다.
|
||||
5. 분석 또는 DB 저장 실패 시 오류 이메일을 발송한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
project_info: dict[str, Any] | None = None
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_info = await _get_project_notification_info(connection, project_id)
|
||||
if not project_info or not project_info.get("user_email"):
|
||||
logger.warning("WF1 분석 이메일 수신자 없음: project_id=%s", project_id)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file
|
||||
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
las_path = project_root / Path(str(input_file["raw_file_path"]))
|
||||
if not las_path.is_file():
|
||||
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
|
||||
|
||||
logger.info(
|
||||
"WF1 백그라운드 분석 시작: project_id=%s input_file_id=%s",
|
||||
project_id,
|
||||
input_file_id,
|
||||
)
|
||||
source_filters = list(SURFACE_MODEL_SOURCE_FILTERS)
|
||||
methods = list(SURFACE_MODEL_PRECOMPUTE)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db
|
||||
|
||||
logger.info("WF1 분석 엔진 시작: las_path=%s", las_path)
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
)
|
||||
logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", [])))
|
||||
|
||||
logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection:
|
||||
# save_surface_analysis_to_db는 트랜잭션을 열지 않는다.
|
||||
# 호출자가 begin/commit/rollback을 한 번만 책임진다.
|
||||
await connection.begin()
|
||||
try:
|
||||
await save_surface_analysis_to_db(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
input_file_id=input_file_id,
|
||||
analysis_result=analysis_result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
await connection.commit()
|
||||
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
|
||||
except Exception as e:
|
||||
logger.exception("WF1 분석 결과 DB 저장 실패: %s", e)
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
logger.info("SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s", SEND_ANALYSIS_COMPLETION_EMAIL, bool(project_info))
|
||||
if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"):
|
||||
logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"])
|
||||
await send_analysis_completion_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
analysis_result=analysis_result,
|
||||
)
|
||||
logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id)
|
||||
else:
|
||||
logger.info("WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s",
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL, project_info and project_info.get("user_email") is not None)
|
||||
logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id)
|
||||
except Exception as exc:
|
||||
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
|
||||
if project_info and project_info.get("user_email"):
|
||||
await send_analysis_error_email(
|
||||
project_id=project_id,
|
||||
project_name=str(project_info.get("project_name") or project_id),
|
||||
to_email=str(project_info["user_email"]),
|
||||
error_message=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
@@ -124,6 +315,25 @@ async def upload_project_files(
|
||||
workflow_path = project_root / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
atomic_write_json(workflow_path, load_project_workflow(project_root))
|
||||
point_cloud_result = next(
|
||||
(result for result in results if _is_point_cloud_result(result)),
|
||||
None,
|
||||
)
|
||||
if point_cloud_result:
|
||||
_schedule_background_task(
|
||||
_send_upload_complete_notification(
|
||||
project_id=project_id,
|
||||
uploaded_file=point_cloud_result,
|
||||
),
|
||||
task_name=f"b03-upload-email-{project_id}",
|
||||
)
|
||||
_schedule_background_task(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=point_cloud_result.input_file_id,
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
)
|
||||
return FileUploadResponse(project_id=str(project_id), files=results)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
@@ -142,3 +352,268 @@ async def upload_project_files(
|
||||
finally:
|
||||
for upload in files:
|
||||
await upload.close()
|
||||
|
||||
|
||||
@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse)
|
||||
async def create_project_upload_session(
|
||||
project_id: UUID,
|
||||
payload: ChunkSessionCreateRequest,
|
||||
) -> ChunkSessionCreateResponse | JSONResponse:
|
||||
"""대용량 파일 청크 업로드 세션을 생성한다."""
|
||||
chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES)
|
||||
total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes)
|
||||
session_id = str(uuid4())
|
||||
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await get_project_storage_relative_path(connection, project_id)
|
||||
await create_upload_session(
|
||||
connection,
|
||||
session_id=session_id,
|
||||
project_id=project_id,
|
||||
original_filename=payload.original_filename,
|
||||
file_size_bytes=payload.size_bytes,
|
||||
chunk_size_bytes=chunk_size_bytes,
|
||||
total_chunks=total_chunks,
|
||||
)
|
||||
return ChunkSessionCreateResponse(
|
||||
project_id=str(project_id),
|
||||
upload_session_id=session_id,
|
||||
original_filename=payload.original_filename,
|
||||
file_size_bytes=payload.size_bytes,
|
||||
chunk_size_bytes=chunk_size_bytes,
|
||||
total_chunks=total_chunks,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse)
|
||||
async def upload_project_chunk(
|
||||
project_id: UUID,
|
||||
session_id: str = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
chunk_data: UploadFile = File(...),
|
||||
) -> ChunkUploadResponse | JSONResponse:
|
||||
"""단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
session = await get_upload_session(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
if chunk_index < 0 or chunk_index >= int(session["total_chunks"]):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
session_dir = resolve_chunk_session_dir(project_root, session_id)
|
||||
chunk_path, size_bytes, chunk_hash = await save_upload_chunk(
|
||||
chunk_data,
|
||||
session_dir,
|
||||
chunk_index,
|
||||
expected_max_bytes=int(session["chunk_size_bytes"]),
|
||||
)
|
||||
relative_chunk_path = chunk_path.relative_to(project_root).as_posix()
|
||||
completed_chunks = await upsert_upload_chunk(
|
||||
connection,
|
||||
session_id=session_id,
|
||||
chunk_index=chunk_index,
|
||||
chunk_hash=chunk_hash,
|
||||
size_bytes=size_bytes,
|
||||
stored_at=relative_chunk_path,
|
||||
)
|
||||
return ChunkUploadResponse(
|
||||
upload_session_id=session_id,
|
||||
chunk_index=chunk_index,
|
||||
completed_chunks=completed_chunks,
|
||||
total_chunks=int(session["total_chunks"]),
|
||||
chunk_hash=chunk_hash,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B03 청크 업로드 실패: project_id=%s session_id=%s",
|
||||
project_id,
|
||||
session_id,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."},
|
||||
)
|
||||
finally:
|
||||
await chunk_data.close()
|
||||
|
||||
|
||||
@router.post("/{project_id}/finalize", response_model=FileUploadResponse)
|
||||
async def finalize_project_upload(
|
||||
project_id: UUID,
|
||||
payload: UploadFinalizeRequest,
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
|
||||
pool = get_db_pool()
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
session = await get_upload_session(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
session_id=payload.session_id,
|
||||
)
|
||||
if int(session["total_chunks"]) != payload.total_chunks:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."},
|
||||
)
|
||||
completed_indexes = await list_completed_chunk_indexes(
|
||||
connection,
|
||||
session_id=payload.session_id,
|
||||
)
|
||||
expected_indexes = list(range(payload.total_chunks))
|
||||
if completed_indexes != expected_indexes:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."},
|
||||
)
|
||||
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename=str(session["original_filename"]),
|
||||
size_bytes=int(session["file_size_bytes"]),
|
||||
)
|
||||
final_path = merge_upload_chunks(
|
||||
project_root,
|
||||
descriptor,
|
||||
payload.session_id,
|
||||
payload.total_chunks,
|
||||
)
|
||||
metadata = await asyncio.to_thread(analyze_input_metadata, final_path)
|
||||
relative_path = final_path.relative_to(project_root).as_posix()
|
||||
file_type = final_path.suffix.lower().lstrip(".")
|
||||
crs_epsg = metadata.get("epsg")
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
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=descriptor.size_bytes,
|
||||
upload_by=None,
|
||||
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
await mark_upload_session_completed(connection, session_id=payload.session_id)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
await mark_upload_session_failed(connection, session_id=payload.session_id)
|
||||
raise
|
||||
|
||||
remove_chunk_session(project_root, payload.session_id)
|
||||
result = UploadedFileResult(
|
||||
input_file_id=input_file_id,
|
||||
original_filename=descriptor.original_filename,
|
||||
file_type=file_type,
|
||||
relative_path=relative_path,
|
||||
size_bytes=descriptor.size_bytes,
|
||||
metadata=metadata,
|
||||
)
|
||||
stage_root = project_root / "B03_FileInput"
|
||||
atomic_write_json(
|
||||
stage_root / "metadata.json",
|
||||
{"project_id": str(project_id), "files": [result.model_dump()]},
|
||||
)
|
||||
if _is_point_cloud_result(result):
|
||||
_schedule_background_task(
|
||||
_send_upload_complete_notification(
|
||||
project_id=project_id,
|
||||
uploaded_file=result,
|
||||
),
|
||||
task_name=f"b03-upload-email-{project_id}",
|
||||
)
|
||||
_schedule_background_task(
|
||||
trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=result.input_file_id,
|
||||
),
|
||||
task_name=f"b04-wf1-auto-{project_id}",
|
||||
)
|
||||
return FileUploadResponse(project_id=str(project_id), files=[result])
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
if final_path is not None:
|
||||
final_path.unlink(missing_ok=True)
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
if final_path is not None:
|
||||
final_path.unlink(missing_ok=True)
|
||||
logger.exception("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse)
|
||||
async def get_project_upload_status(
|
||||
project_id: UUID,
|
||||
session_id: str,
|
||||
) -> UploadStatusResponse | JSONResponse:
|
||||
"""업로드 세션의 청크 완료 상태를 조회한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
session = await get_upload_session(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
completed_indexes = await list_completed_chunk_indexes(
|
||||
connection,
|
||||
session_id=session_id,
|
||||
)
|
||||
return UploadStatusResponse(
|
||||
upload_session_id=session_id,
|
||||
upload_status=str(session["status"]),
|
||||
original_filename=str(session["original_filename"]),
|
||||
file_size_bytes=int(session["file_size_bytes"]),
|
||||
chunk_size_bytes=int(session["chunk_size_bytes"]),
|
||||
total_chunks=int(session["total_chunks"]),
|
||||
completed_chunks=len(completed_indexes),
|
||||
completed_chunk_indexes=completed_indexes,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B03 업로드 상태 조회 실패: project_id=%s session_id=%s",
|
||||
project_id,
|
||||
session_id,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_MAX_MB
|
||||
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
class FileUploadDescriptor(BaseModel):
|
||||
@@ -50,3 +50,56 @@ class FileUploadResponse(BaseModel):
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
files: list[UploadedFileResult]
|
||||
|
||||
|
||||
class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
"""청크 업로드 세션 생성 요청."""
|
||||
|
||||
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
|
||||
|
||||
|
||||
class ChunkSessionCreateResponse(BaseModel):
|
||||
"""청크 업로드 세션 생성 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
upload_session_id: str
|
||||
original_filename: str
|
||||
file_size_bytes: int
|
||||
chunk_size_bytes: int
|
||||
total_chunks: int
|
||||
completed_chunks: int = 0
|
||||
|
||||
|
||||
class ChunkUploadResponse(BaseModel):
|
||||
"""단일 청크 저장 응답."""
|
||||
|
||||
status: str = "success"
|
||||
upload_session_id: str
|
||||
chunk_index: int
|
||||
completed_chunks: int
|
||||
total_chunks: int
|
||||
chunk_hash: str
|
||||
|
||||
|
||||
class UploadFinalizeRequest(BaseModel):
|
||||
"""청크 업로드 완료 및 병합 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
session_id: str = Field(min_length=1, max_length=36)
|
||||
total_chunks: int = Field(gt=0)
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
"""청크 업로드 상태 조회 응답."""
|
||||
|
||||
status: str = "success"
|
||||
upload_session_id: str
|
||||
upload_status: str
|
||||
original_filename: str
|
||||
file_size_bytes: int
|
||||
chunk_size_bytes: int
|
||||
total_chunks: int
|
||||
completed_chunks: int
|
||||
completed_chunk_indexes: list[int]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
sw.addEventListener("install", () => {
|
||||
sw.skipWaiting();
|
||||
});
|
||||
|
||||
sw.addEventListener("activate", (event) => {
|
||||
event.waitUntil(sw.clients.claim());
|
||||
});
|
||||
|
||||
sw.addEventListener("message", (event) => {
|
||||
const data = event.data as { type?: string } | undefined;
|
||||
if (data?.type !== "B03_SW_PING") return;
|
||||
event.source?.postMessage({ type: "B03_SW_READY" });
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
/* B03 파일 입력 페이지 */
|
||||
/* B03 파일 입력 페이지 - 가로형 드롭존 + 슬롯 그리드 + 청크 업로드 */
|
||||
|
||||
import {
|
||||
CURRENT_PROJECT_ID_KEY,
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
UPLOAD_ALLOWED_EXT,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
UPLOAD_MAX_FILES,
|
||||
UPLOAD_MAX_MB,
|
||||
} from "@config/config_frontend";
|
||||
@@ -10,55 +12,199 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createTag,
|
||||
createWorkflowShell,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
finalizeUploadSession,
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
|
||||
type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
interface SlotConfig {
|
||||
slot: FileSlot;
|
||||
labelKey: keyof typeof ui_locales;
|
||||
icon: string;
|
||||
extensions: readonly string[];
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
interface FileSlotState extends SlotConfig {
|
||||
file?: File;
|
||||
uploadSessionId?: string;
|
||||
uploadStatus: UploadStatus;
|
||||
progressBytes: number;
|
||||
speedMbs: number;
|
||||
etaSeconds: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StoredUploadSession {
|
||||
key: string;
|
||||
projectId: string;
|
||||
slot: FileSlot;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadSessionId: string;
|
||||
chunkSizeBytes: number;
|
||||
totalChunks: number;
|
||||
completedChunks: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
icon: "●",
|
||||
extensions: [".las", ".laz"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "prj",
|
||||
labelKey: "B03_File_Slot_Projection",
|
||||
icon: "◇",
|
||||
extensions: [".prj"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tfw",
|
||||
labelKey: "B03_File_Slot_RasterCoord",
|
||||
icon: "□",
|
||||
extensions: [".tfw"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tif",
|
||||
labelKey: "B03_File_Slot_TerrainDem",
|
||||
icon: "▧",
|
||||
extensions: [".tif"],
|
||||
isRequired: false,
|
||||
},
|
||||
];
|
||||
|
||||
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");
|
||||
function getExtension(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
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;
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatEta(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return "-";
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function makeSessionKey(projectId: string, file: File): string {
|
||||
return `b03_upload_${projectId}_${file.name}_${file.size}`;
|
||||
}
|
||||
|
||||
function initializeSlots(): Map<FileSlot, FileSlotState> {
|
||||
const map = new Map<FileSlot, FileSlotState>();
|
||||
for (const config of SLOT_CONFIGS) {
|
||||
map.set(config.slot, {
|
||||
...config,
|
||||
uploadStatus: "pending",
|
||||
progressBytes: 0,
|
||||
speedMbs: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
}
|
||||
return lasCount === 1 ? null : L("B03_File_Error_Las");
|
||||
return map;
|
||||
}
|
||||
|
||||
function createFileCardTemplate(): HTMLTemplateElement {
|
||||
const template = document.createElement("template");
|
||||
template.id = "file-card-template";
|
||||
template.innerHTML = `
|
||||
<article class="b03-file__card b03-file__card--empty">
|
||||
<div class="b03-file__card-header">
|
||||
<span class="b03-file__card-icon" aria-hidden="true"></span>
|
||||
<div class="b03-file__card-heading">
|
||||
<strong class="b03-file__card-label"></strong>
|
||||
<span class="b03-file__card-ext"></span>
|
||||
</div>
|
||||
<div class="b03-file__card-badge-container"></div>
|
||||
<button class="b03-file__card-remove" type="button" aria-label="파일 제거"></button>
|
||||
</div>
|
||||
<div class="b03-file__card-content">
|
||||
<button class="b03-file__card-select" type="button"></button>
|
||||
<input class="b03-file__slot-input" type="file" />
|
||||
<div class="b03-file__file-info">
|
||||
<span class="b03-file__file-name"></span>
|
||||
<span class="b03-file__file-size"></span>
|
||||
</div>
|
||||
<div class="b03-file__progress-section">
|
||||
<div class="b03-file__progress-bar-container">
|
||||
<div class="b03-file__progress-bar"></div>
|
||||
</div>
|
||||
<div class="b03-file__progress-info">
|
||||
<span class="b03-file__progress-bytes"></span>
|
||||
<span class="b03-file__progress-speed"></span>
|
||||
<span class="b03-file__progress-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b03-file__error-message" role="alert"></div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
return template;
|
||||
}
|
||||
|
||||
export function renderB03FileInput(root: HTMLElement): void {
|
||||
let selectedFiles: File[] = [];
|
||||
const page = document.createElement("div");
|
||||
page.className = "b03-file";
|
||||
const slots = initializeSlots();
|
||||
const cardMap = new Map<FileSlot, HTMLElement>();
|
||||
const resultList = document.createElement("ul");
|
||||
resultList.className = "b03-file__results";
|
||||
let uploadButton: HTMLButtonElement;
|
||||
let resumeBanner: HTMLDivElement;
|
||||
let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
|
||||
const shell = createWorkflowShell({
|
||||
title: L("B03_File_Title"),
|
||||
steps: [
|
||||
L("B03_File_Title"),
|
||||
L("WF_Step_Surface"),
|
||||
L("WF_Step_Route"),
|
||||
L("WF_Step_ProfileCross"),
|
||||
L("WF_Step_DesignDetail"),
|
||||
L("WF_Step_Quantity"),
|
||||
L("WF_Step_Estimation"),
|
||||
],
|
||||
activeStep: 0,
|
||||
});
|
||||
shell.root.classList.add("b03-file");
|
||||
|
||||
// 레이아웃 전면 리팩토링: 좌측/우측 패널 구분 없이 하나의 와이드 컴포넌트로 결합
|
||||
shell.leftPanel.remove(); // 기존 좁은 세로 영역 제거
|
||||
|
||||
const contentContainer = document.createElement("div");
|
||||
contentContainer.className = "b03-file__main-layout";
|
||||
|
||||
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";
|
||||
@@ -75,41 +221,238 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
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";
|
||||
const pageError = document.createElement("p");
|
||||
pageError.className = "b03-file__error";
|
||||
pageError.setAttribute("role", "alert");
|
||||
|
||||
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);
|
||||
resumeBanner = document.createElement("div");
|
||||
resumeBanner.className = "b03-file__resume";
|
||||
|
||||
const template = createFileCardTemplate();
|
||||
|
||||
function selectedStates(): FileSlotState[] {
|
||||
return Array.from(slots.values()).filter((state) => state.file);
|
||||
}
|
||||
|
||||
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
|
||||
const card = cardMap.get(slot);
|
||||
if (!card) return;
|
||||
card.classList.remove(
|
||||
"b03-file__card--empty",
|
||||
"b03-file__card--selected",
|
||||
"b03-file__card--uploading",
|
||||
"b03-file__card--completed",
|
||||
"b03-file__card--failed",
|
||||
"b03-file__card--error",
|
||||
);
|
||||
const cssState = stateName === "failed" ? "error" : stateName;
|
||||
card.classList.add(`b03-file__card--${cssState}`);
|
||||
|
||||
// 배지 상태 연동 업데이트
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
|
||||
if (badgeContainer) {
|
||||
badgeContainer.replaceChildren();
|
||||
if (stateName === "empty") {
|
||||
badgeContainer.append(createTag("미등록", "neutral"));
|
||||
} else if (stateName === "selected") {
|
||||
badgeContainer.append(createTag("대기중", "neutral"));
|
||||
} else if (stateName === "uploading") {
|
||||
badgeContainer.append(createTag("업로드중", "warning"));
|
||||
} else if (stateName === "completed") {
|
||||
badgeContainer.append(createTag("완료", "success"));
|
||||
} else if (stateName === "failed") {
|
||||
badgeContainer.append(createTag("오류", "danger"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedFiles(files: readonly File[]): void {
|
||||
selectedFiles = Array.from(files);
|
||||
errorSlot.textContent = validateFiles(selectedFiles) ?? "";
|
||||
renderSelectedFiles();
|
||||
function updateUploadButton(): void {
|
||||
const validation = validateSlots();
|
||||
uploadButton.disabled = validation !== null;
|
||||
pageError.textContent = validation ?? "";
|
||||
}
|
||||
|
||||
function renderSlot(slot: FileSlot): void {
|
||||
const state = slots.get(slot);
|
||||
const card = cardMap.get(slot);
|
||||
if (!state || !card) return;
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
|
||||
|
||||
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
|
||||
if (fileName) fileName.textContent = state.file?.name ?? "";
|
||||
if (fileSize) fileSize.textContent = state.file ? formatBytes(state.file.size) : "";
|
||||
if (progress) progress.style.width = `${percent}%`;
|
||||
if (progressBytes) {
|
||||
progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes(
|
||||
state.progressBytes,
|
||||
)} / ${state.file ? formatBytes(state.file.size) : "-"}`;
|
||||
}
|
||||
if (progressSpeed) {
|
||||
progressSpeed.textContent = `${L("B03_File_Progress_Speed")}: ${state.speedMbs.toFixed(
|
||||
1,
|
||||
)} MB/s`;
|
||||
}
|
||||
if (progressEta) {
|
||||
progressEta.textContent = `${L("B03_File_Progress_Eta")}: ${formatEta(state.etaSeconds)}`;
|
||||
}
|
||||
if (error) error.textContent = state.error ?? "";
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file) setCardState(slot, "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function showErrorMessage(slot: FileSlot, error: string): void {
|
||||
const state = slots.get(slot);
|
||||
if (!state) return;
|
||||
state.error = error;
|
||||
state.uploadStatus = "failed";
|
||||
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;
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
function assignFileToSlot(file: File, targetSlot?: FileSlot): void {
|
||||
const extension = getExtension(file.name);
|
||||
const state = targetSlot
|
||||
? slots.get(targetSlot)
|
||||
: Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension));
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
return;
|
||||
}
|
||||
const validation = validateFileForSlot(file, state);
|
||||
if (validation) {
|
||||
showErrorMessage(state.slot, `${validation} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.file = file;
|
||||
state.uploadSessionId = undefined;
|
||||
state.uploadStatus = "pending";
|
||||
state.progressBytes = 0;
|
||||
state.speedMbs = 0;
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(state.slot);
|
||||
}
|
||||
|
||||
function onFileSelected(files: readonly File[], targetSlot?: FileSlot): void {
|
||||
if (files.length === 0) return;
|
||||
if (selectedStates().length + files.length > UPLOAD_MAX_FILES) {
|
||||
pageError.textContent = L("B03_File_Error_Count");
|
||||
return;
|
||||
}
|
||||
for (const file of files) assignFileToSlot(file, targetSlot);
|
||||
void detectPausedUploads();
|
||||
}
|
||||
|
||||
function removeFile(slot: FileSlot): void {
|
||||
const state = slots.get(slot);
|
||||
if (!state) return;
|
||||
if (activeProjectId && state.file) {
|
||||
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
|
||||
}
|
||||
state.file = undefined;
|
||||
state.uploadSessionId = undefined;
|
||||
state.uploadStatus = "pending";
|
||||
state.progressBytes = 0;
|
||||
state.speedMbs = 0;
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateSlots(): string | null {
|
||||
if (!activeProjectId) return L("B03_File_Error_Project");
|
||||
const selected = selectedStates();
|
||||
if (selected.length === 0) return L("B03_File_Error_Required");
|
||||
if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) => state.isRequired && !state.file,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file) return L("B03_File_Error_Las");
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
const validation = validateFileForSlot(state.file!, state);
|
||||
if (validation) return validation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createFileCard(state: FileSlotState): HTMLElement {
|
||||
const fragment = template.content.cloneNode(true) as DocumentFragment;
|
||||
const card = fragment.querySelector<HTMLElement>(".b03-file__card");
|
||||
if (!card) throw new Error("file-card-template is invalid");
|
||||
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 input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
select.textContent = L("B03_File_Card_Select");
|
||||
select.addEventListener("click", () => input.click());
|
||||
input.addEventListener("change", () => {
|
||||
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
|
||||
input.value = "";
|
||||
});
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
|
||||
remove.textContent = "×";
|
||||
remove.title = L("B03_File_Card_Remove");
|
||||
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
|
||||
remove.addEventListener("click", () => removeFile(state.slot));
|
||||
cardMap.set(state.slot, card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__group";
|
||||
if (title) {
|
||||
const groupTitle = document.createElement("h3");
|
||||
groupTitle.className = "b03-file__group-title";
|
||||
groupTitle.textContent = title;
|
||||
group.append(groupTitle);
|
||||
}
|
||||
const content = document.createElement("div");
|
||||
content.className = "b03-file__group-content";
|
||||
for (const slot of groupSlots) {
|
||||
const state = slots.get(slot);
|
||||
if (state) content.append(createFileCard(state));
|
||||
}
|
||||
group.append(content);
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderUploadResults(results: readonly UploadedFileResult[]): void {
|
||||
@@ -125,41 +468,193 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPausedUploads(): Promise<void> {
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
resumeBanner.replaceChildren();
|
||||
resumeBanner.classList.remove("is-visible");
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
state.progressBytes = session.completedChunks * session.chunkSizeBytes;
|
||||
renderSlot(state.slot);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`;
|
||||
const resume = createButton({
|
||||
label: L("B03_File_Resume_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => void startChunkedUpload([state]),
|
||||
});
|
||||
const fresh = createButton({
|
||||
label: L("B03_File_New_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
localStorage.removeItem(session.key);
|
||||
state.uploadSessionId = undefined;
|
||||
state.progressBytes = 0;
|
||||
renderSlot(state.slot);
|
||||
resumeBanner.replaceChildren();
|
||||
resumeBanner.classList.remove("is-visible");
|
||||
},
|
||||
});
|
||||
resumeBanner.append(text, resume, fresh);
|
||||
resumeBanner.classList.add("is-visible");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOneFile(
|
||||
projectId: string,
|
||||
state: FileSlotState,
|
||||
): 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);
|
||||
localStorage.removeItem(storageKey);
|
||||
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);
|
||||
if (status.status === "completed") {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 상태 조회 실패는 무시하고 계속 폴링
|
||||
}
|
||||
// 5초마다 폴링 (최대 30분)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
const validation = validateSlots();
|
||||
if (validation) {
|
||||
pageError.textContent = validation;
|
||||
return;
|
||||
}
|
||||
|
||||
pageError.textContent = "";
|
||||
const uploaded: UploadedFileResult[] = [];
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const state of targetStates) {
|
||||
uploaded.push(...(await uploadOneFile(activeProjectId, state)));
|
||||
}
|
||||
renderUploadResults(uploaded);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
hideLoadingOverlay();
|
||||
|
||||
// WF1 분석 상태 폴링 시작
|
||||
showToast("WF1 분석 진행 중... 완료되면 자동으로 이동합니다.", "info");
|
||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||
|
||||
if (analysisComplete) {
|
||||
// 분석 완료 시 B04 페이지로 이동
|
||||
navigateTo(ROUTES.B04_WF1_SURFACE);
|
||||
} else {
|
||||
showToast("분석이 진행 중입니다. 잠시 후 새로고침해주세요.", "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function registerB03ServiceWorker(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
registration.active?.postMessage({ type: "B03_SW_PING" });
|
||||
showToast(L("B03_File_ServiceWorker_Ready"), "info");
|
||||
} catch {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
}
|
||||
}
|
||||
|
||||
function onB03_File_Select_Change(): void {
|
||||
setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
onFileSelected(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
fileInput.value = "";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -174,15 +669,30 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
const uploadButton = createButton({
|
||||
uploadButton = createButton({
|
||||
label: L("B03_File_Upload_Button"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB03_File_Upload_Click(),
|
||||
onClick: () => void startChunkedUpload(),
|
||||
disabled: true,
|
||||
});
|
||||
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();
|
||||
|
||||
const uploadControlPanel = document.createElement("div");
|
||||
uploadControlPanel.className = "b03-file__control-panel";
|
||||
uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList);
|
||||
|
||||
const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기
|
||||
cardsContainer.append(filesGroup);
|
||||
|
||||
contentContainer.append(uploadControlPanel, cardsContainer);
|
||||
|
||||
shell.rightArea.replaceChildren(contentContainer);
|
||||
root.replaceChildren(shell.root);
|
||||
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
void registerB03ServiceWorker();
|
||||
void detectPausedUploads();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
.b03-file {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24);
|
||||
min-height: calc(100vh - var(--app-header-height, 0px));
|
||||
background: linear-gradient(180deg, var(--color-canvas, #ffffff) 0%, var(--color-mist-violet, #edecff) 100%);
|
||||
padding: 0 var(--spacing-24) var(--spacing-48) var(--spacing-24); /* 상단 여백은 main-layout 마진으로 조정하므로 padding-top은 0 */
|
||||
}
|
||||
|
||||
.b03-file__main-layout {
|
||||
max-width: var(--page-max-width, 1200px);
|
||||
margin: var(--spacing-40) auto 0 auto; /* 상단 구분선(헤더)과의 간격을 확실하게 40px 벌림 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
gap: var(--spacing-32);
|
||||
}
|
||||
|
||||
.b03-file__header,
|
||||
.b03-file__body {
|
||||
.b03-file__control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
gap: var(--spacing-20);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
padding: var(--spacing-32);
|
||||
border-radius: var(--radius-large, 24px); /* Wiza 24px 대형 둥글기 */
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.b03-file__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-heading);
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
.b03-file__cards-container-panel {
|
||||
margin-top: 0; /* 단일 layout gap에 의해 제어되도록 설정 */
|
||||
}
|
||||
|
||||
.b03-file__subtitle,
|
||||
.b03-file__dropzone span,
|
||||
.b03-file__empty {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
.b03-file__subtitle {
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
margin: 0 0 var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.b03-file__native-input {
|
||||
.b03-file__native-input,
|
||||
.b03-file__slot-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */
|
||||
.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);
|
||||
min-height: 160px;
|
||||
padding: var(--spacing-32) var(--spacing-24);
|
||||
border: 2px dashed var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-large, 24px);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -45,61 +52,298 @@
|
||||
gap: var(--spacing-8);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s), box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__dropzone:hover {
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.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);
|
||||
outline: none;
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: 0 0 0 4px rgba(62, 0, 121, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__dropzone strong,
|
||||
.b03-file__section-title,
|
||||
.b03-file__results strong {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
.b03-file__dropzone strong {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body, 16px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.b03-file__section-title {
|
||||
font-size: var(--text-subheading);
|
||||
.b03-file__dropzone span {
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
}
|
||||
|
||||
.b03-file__error {
|
||||
min-height: var(--spacing-16);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-danger, #dc2626);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.b03-file__resume {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__resume.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b03-file__cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-32);
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-20);
|
||||
}
|
||||
|
||||
.b03-file__group-title {
|
||||
font-size: var(--text-subheading, 24px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-family: var(--font-britti-sans, inherit);
|
||||
margin: 0 0 var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.b03-file__group-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr); /* 2행 2열 구조 */
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
.b03-file__card {
|
||||
min-height: 220px;
|
||||
padding: var(--spacing-24); /* 내부 전체 패딩 확대 */
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-20);
|
||||
transition: transform var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s), box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.b03-file__card--selected {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: var(--shadow-lg-2);
|
||||
}
|
||||
|
||||
.b03-file__card--completed {
|
||||
border-color: #10b981; /* Success Green */
|
||||
}
|
||||
|
||||
.b03-file__card--error {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
.b03-file__card-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto; /* 아이콘 및 닫기 버튼 주변 확보 */
|
||||
gap: var(--spacing-12);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 2px; /* 제목 상단 여유 추가 */
|
||||
}
|
||||
|
||||
.b03-file__card-label {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
.b03-file__file-size,
|
||||
.b03-file__progress-info {
|
||||
color: var(--color-slate, #615e6e);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b03-file__card-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card-remove:hover {
|
||||
color: var(--color-danger, #dc2626);
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.b03-file__card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-16); /* 버튼 하단 및 내부 간격 확대 */
|
||||
padding-bottom: var(--spacing-8); /* 파일 선택 버튼 하단 패딩 확보 */
|
||||
}
|
||||
|
||||
.b03-file__card-select {
|
||||
width: 100%;
|
||||
min-height: 40px; /* 버튼 높이 증가 */
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-8); /* 파일 선택 버튼 하단 마진 추가 */
|
||||
transition: background var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__file-name {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b03-file__progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-mist, #e6e2e3);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-royal-amethyst, #3e0079);
|
||||
transition: width var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b03-file__error-message {
|
||||
display: none;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
font-size: var(--text-caption, 12px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__card--empty .b03-file__file-info,
|
||||
.b03-file__card--empty .b03-file__progress-section,
|
||||
.b03-file__card--empty .b03-file__error-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--selected .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__card-select,
|
||||
.b03-file__card--completed .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__error-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__list,
|
||||
.b03-file__results {
|
||||
margin: 0;
|
||||
margin: var(--spacing-8) 0 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
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;
|
||||
background: var(--color-canvas, #ffffff);
|
||||
}
|
||||
|
||||
.b03-file__results:empty {
|
||||
@@ -107,12 +351,26 @@
|
||||
}
|
||||
|
||||
.b03-file__results li {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-mist, #e6e2e3);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.b03-file__list li {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
.b03-file__results li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.b03-file {
|
||||
padding: var(--spacing-16) var(--spacing-12);
|
||||
}
|
||||
|
||||
.b03-file__group-content {
|
||||
grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user