This commit is contained in:
2026-07-10 16:41:22 +09:00
parent bbda38a013
commit 50d75fcfcd
47 changed files with 6309 additions and 350 deletions
+118 -7
View File
@@ -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);
}
+240
View File
@@ -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
+117 -1
View File
@@ -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)
+181
View File
@@ -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,),
)
+478 -3
View File
@@ -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": "업로드 상태 조회 중 오류가 발생했습니다."},
)
+54 -1
View File
@@ -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" });
});
+609 -99
View File
@@ -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();
}
+322 -64
View File
@@ -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열 구조 */
}
}