This commit is contained in:
2026-07-10 18:12:17 +09:00
parent 50d75fcfcd
commit e3d66e717c
28 changed files with 2542 additions and 99 deletions
+3
View File
@@ -145,6 +145,9 @@ export interface WF1AnalysisStatus {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
model_count: number;
progress_percent: number;
current_stage: string;
message: string;
error?: string;
}
+27 -3
View File
@@ -102,6 +102,20 @@ async def _get_project_notification_info(
return dict(row) if row else None
async def _update_project_status(project_id: UUID, status: str) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE projects
SET status = %s, updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL
""",
(status, str(project_id)),
)
await connection.commit()
async def _send_upload_complete_notification(
*,
project_id: UUID,
@@ -141,6 +155,7 @@ async def trigger_wf1_analysis_and_email(
pool = get_db_pool()
project_info: dict[str, Any] | None = None
try:
await _update_project_status(project_id, "WF1_ANALYZING")
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)
@@ -192,13 +207,18 @@ async def trigger_wf1_analysis_and_email(
source_filters=source_filters,
)
await connection.commit()
await _update_project_status(project_id, "WF1_COMPLETE")
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))
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(
@@ -209,11 +229,15 @@ async def trigger_wf1_analysis_and_email(
)
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 완료 이메일 발송 스킵: 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)
await _update_project_status(project_id, "WF1_FAILED")
if project_info and project_info.get("user_email"):
await send_analysis_error_email(
project_id=project_id,
+90
View File
@@ -0,0 +1,90 @@
export type B03AnalysisStatus = "pending" | "in_progress" | "completed" | "failed";
export interface B03StoredFileState {
slot: string;
fileName: string;
fileSize: number;
status: "completed" | "failed";
}
export interface B03ProjectState {
projectId: string;
uploadedAt: number;
files: B03StoredFileState[];
analysisStatus: B03AnalysisStatus;
analysisProgress: number;
lastCheckedAt: number;
}
function key(projectId: string): string {
return `b03_project_state_${projectId}`;
}
export function loadB03ProjectState(projectId: string): B03ProjectState | null {
const raw = localStorage.getItem(key(projectId));
if (!raw) return null;
try {
return JSON.parse(raw) as B03ProjectState;
} catch {
localStorage.removeItem(key(projectId));
return null;
}
}
export function saveB03UploadedFile(
projectId: string,
file: { slot: string; fileName: string; fileSize: number },
): B03ProjectState {
const current = loadB03ProjectState(projectId);
const nextFiles = (current?.files ?? []).filter((item) => item.slot !== file.slot);
nextFiles.push({ ...file, status: "completed" });
const next: B03ProjectState = {
projectId,
uploadedAt: current?.uploadedAt ?? Date.now(),
files: nextFiles,
analysisStatus: "pending",
analysisProgress: 0,
lastCheckedAt: Date.now(),
};
localStorage.setItem(key(projectId), JSON.stringify(next));
return next;
}
export function updateB03AnalysisState(
projectId: string,
status: B03AnalysisStatus,
progress: number,
): B03ProjectState {
const current = loadB03ProjectState(projectId);
const next: B03ProjectState = {
projectId,
uploadedAt: current?.uploadedAt ?? Date.now(),
files: current?.files ?? [],
analysisStatus: status,
analysisProgress: progress,
lastCheckedAt: Date.now(),
};
localStorage.setItem(key(projectId), JSON.stringify(next));
return next;
}
export function restoreB03ProjectState(options: {
projectId: string;
label: string;
container: HTMLElement;
poll: (projectId: string) => Promise<boolean>;
onComplete: () => void;
}): void {
const state = loadB03ProjectState(options.projectId);
if (!state) return;
options.container.replaceChildren();
const message = document.createElement("span");
message.textContent = `${options.label}: ${state.analysisProgress}%`;
options.container.append(message);
options.container.classList.add("is-visible");
if (state.analysisStatus === "in_progress" || state.analysisStatus === "pending") {
void options.poll(options.projectId).then((completed) => {
if (completed) options.onComplete();
});
}
}
+37 -39
View File
@@ -1,5 +1,3 @@
/* B03 파일 입력 페이지 - 가로형 드롭존 + 슬롯 그리드 + 청크 업로드 */
import {
CURRENT_PROJECT_ID_KEY,
PROGRESS_UPDATE_INTERVAL_MS,
@@ -9,15 +7,7 @@ import {
UPLOAD_MAX_MB,
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createCard,
createTag,
createWorkflowShell,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createButton, createTag, createWorkflowShell, showToast } from "@ui/ui_template_elements";
import {
checkWF1AnalysisStatus,
createUploadSession,
@@ -27,6 +17,11 @@ import {
} from "./B03_FileInput_Api_Fetch";
import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend";
import {
restoreB03ProjectState,
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import "./B03_FileInput_UI_Style.css";
type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
@@ -196,9 +191,8 @@ export function renderB03FileInput(root: HTMLElement): void {
});
shell.root.classList.add("b03-file");
// 레이아웃 전면 리팩토링: 좌측/우측 패널 구분 없이 하나의 와이드 컴포넌트로 결합
shell.leftPanel.remove(); // 기존 좁은 세로 영역 제거
shell.leftPanel.remove();
const contentContainer = document.createElement("div");
contentContainer.className = "b03-file__main-layout";
@@ -247,8 +241,7 @@ export function renderB03FileInput(root: HTMLElement): void {
);
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();
@@ -542,16 +535,16 @@ export function renderB03FileInput(root: HTMLElement): void {
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(),
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));
@@ -564,6 +557,11 @@ export function renderB03FileInput(root: HTMLElement): void {
const response = await finalizeUploadSession(projectId, session, totalChunks);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
fileName: file.name,
fileSize: file.size,
});
state.progressBytes = file.size;
state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
@@ -577,13 +575,11 @@ export function renderB03FileInput(root: HTMLElement): void {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const status = await checkWF1AnalysisStatus(projectId);
updateB03AnalysisState(projectId, status.status, status.progress_percent);
if (status.status === "completed") {
return true;
}
} catch {
// 상태 조회 실패는 무시하고 계속 폴링
}
// 5초마다 폴링 (최대 30분)
} catch {}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return false;
@@ -599,24 +595,20 @@ export function renderB03FileInput(root: HTMLElement): void {
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");
showToast(L("B03_File_Analysis_InProgress"), "info");
const analysisComplete = await pollWF1Analysis(activeProjectId);
if (analysisComplete) {
// 분석 완료 시 B04 페이지로 이동
navigateTo(ROUTES.B04_WF1_SURFACE);
} else {
showToast("분석이 진행 중입니다. 잠시 후 새로고침해주세요.", "warning");
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
@@ -624,8 +616,6 @@ export function renderB03FileInput(root: HTMLElement): void {
if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
@@ -694,5 +684,13 @@ export function renderB03FileInput(root: HTMLElement): void {
for (const slot of slots.keys()) renderSlot(slot);
void registerB03ServiceWorker();
void detectPausedUploads();
if (activeProjectId) {
restoreB03ProjectState({
projectId: activeProjectId,
label: L("B03_File_Restore_State"),
container: resumeBanner,
poll: pollWF1Analysis,
onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE),
});
}
}