From bef1e8c15b323a269ab7496105cf4264e4344cb9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:00:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(564-3):=20E02=20[=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91]=20=C2=B7=20=EC=A7=84=ED=96=89=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=EB=8B=A8=EA=B3=84=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?[=EB=81=8A=EA=B8=B0]=20=E2=80=94=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EA=B0=94=EB=8B=A4=20=EC=99=80=EB=8F=84=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=9D=B4=EC=96=B4=20=EB=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [저장] 자리에 [해석 시작](POST /stab/run = 검사 · 저장 → 체인) · 잠금판은 저장 순간만 - 진행 막대(ui_template_progress) · 단계 줄 다섯(ui_template_list_row · 상태 띠 · 걸린 초) · [끊기] - 열 때 GET /stab/run/progress — 도는 중이면 이어서 봄 · 끝난 결과면 단계 줄만 - locale_e 글 · 저장 안 됨 글 = 「해석 전」 Co-Authored-By: Claude Opus 5.5 --- E02_StabGround/E02_StabGround_UI_Page.ts | 149 ++++++++++++++++++--- E02_StabGround/E02_StabGround_UI_Style.css | 17 +++ ui_template/ui_template_locale_e.ts | 34 ++++- 3 files changed, 181 insertions(+), 19 deletions(-) diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index a483e2d71..a32bae7c5 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -2,8 +2,10 @@ * E02_StabGround_UI_Page.ts * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · - * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). + * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · + * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 + * 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀). + * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ @@ -18,6 +20,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -38,8 +42,33 @@ interface GroundResponse { missing_stations?: number[]; } +interface RunStep { + key: string; + state: string; + seconds: number | null; +} + +/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */ +interface RunProgress { + running: boolean; + percent: number; + steps: RunStep[]; + message: string | null; + error: string | null; + cancelled: boolean; +} + const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; +/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */ +const STEP_TONE: Record = { + run: "var(--color-accent)", + done: "var(--color-success)", + skip: "var(--color-success)", + error: "var(--color-danger)", + cancel: "var(--color-warning)", +}; + /** 검사 열쇠 → 범위 열쇠(limits) */ function limitKey(path: string): string { const last = path.split(".").pop() ?? ""; @@ -56,13 +85,13 @@ function errorText(error: CheckError, defaults: GroundDefaults): string { return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text; } -async function request(path: string, init?: RequestInit): Promise { +async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); - const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown }; + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) { const detail = body.detail; if (typeof detail === "string") throw new Error(detail); @@ -76,29 +105,95 @@ export function renderE02StabGround(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e02-page" }); const status = el("div", { className: "e02-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), + const runButton = createButton({ + label: t("E02_Run"), variant: "filled", - onClick: () => void save(), + onClick: () => void runChain(), }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void reset(), }); - saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 + runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton); + actions.append(resetButton, runButton); + const progressBar = createProgressBar(); + const progressText = createText(""); + const stepList = el("div", { className: "e02-run__steps" }); + const cancelButton = createButton({ + label: t("E02_Run_Cancel"), + variant: "danger-ghost", + size: "sm", + onClick: () => void cancelChain(), + }); + const runBox = el("div", { className: "e02-run" }); + runBox.append(progressBar.root, progressText, stepList, cancelButton); + runBox.hidden = true; const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); - panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions); + panel.append( + hook(createText(t("E02_Note")), "e02-hint"), + hook(createText(t("E02_Run_Hint")), "e02-hint"), + status, + runBox, + actions, + ); mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let running = false; let missing: number[] = []; + const paintButtons = (): void => { + runButton.disabled = resetButton.disabled = !view?.editable || running; + }; + + const paintRun = (p: RunProgress): void => { + running = p.running; + runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait"); + progressBar.set(p.percent / 100, `${p.percent}%`); + progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : ""; + stepList.replaceChildren( + ...p.steps.map((s) => + createListRow({ + label: t(`E02_Run_Step_${s.key}` as LocaleKey), + trailing: [ + t(`E02_Run_State_${s.state}` as LocaleKey), + s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "", + ] + .filter(Boolean) + .join(" · "), + stripe: STEP_TONE[s.state] ?? "transparent", + active: s.state === "run", + size: 28, + }), + ), + ); + cancelButton.hidden = !p.running; + paintButtons(); + }; + + /** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */ + async function poll(): Promise { + while (root.isConnected) { + const p = await request(`/projects/${projectId}/stab/run/progress`).catch( + () => null, + ); + if (!p) return; + paintRun(p); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info"); + else showToast(t("E02_Run_Done"), "success"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E02_ReadOnly")); @@ -158,12 +253,13 @@ export function renderE02StabGround(root: HTMLElement): void { view.ground = ground; view.defaults = body.defaults; view.editable = body.editable ?? false; - saveButton.disabled = resetButton.disabled = !view.editable; + paintButtons(); render(); }; - async function save(): Promise { - if (!view?.editable) return; + /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ + async function runChain(): Promise { + if (!view?.editable || running) return; const errors = mark(); if (errors.length) { showToast( @@ -174,16 +270,30 @@ export function renderE02StabGround(root: HTMLElement): void { } showLoadingOverlay(); try { - const body = await request(`/projects/${projectId}/stab/ground`, { - method: "PUT", + const body = await request(`/projects/${projectId}/stab/run`, { + method: "POST", body: JSON.stringify({ ground: view.ground }), }); - adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); - showToast(t("E02_Saved"), "success"); + adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false); + paintRun(body); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return; + } finally { + hideLoadingOverlay(); + } + void poll(); + } + + async function cancelChain(): Promise { + cancelButton.disabled = true; + try { + await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" }); + showToast(t("E02_Run_Stopping"), "info"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { - hideLoadingOverlay(); + cancelButton.disabled = false; } } @@ -246,6 +356,9 @@ export function renderE02StabGround(root: HTMLElement): void { }, }; adopt(body, true); + const p = await request(`/projects/${projectId}/stab/run/progress`); + paintRun(p); + if (p.running) void poll(); } catch (error) { main.replaceChildren( hook( diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index bade5d2fd..0bf50e4be 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -121,3 +121,20 @@ gap: 2px; align-items: center; } + +/* [해석 시작] 진행 — 막대 · 단계 줄 · [끊기] */ +.e02-run { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e02-run[hidden] { + display: none; +} + +.e02-run__steps { + display: flex; + flex-direction: column; +} diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index d262b9d94..b05652da1 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -114,7 +114,10 @@ export const ui_locales_e = { E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"], /* --- 상태 · 알림 --- */ - E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E02_Unsaved: [ + "해석 전 — 바뀐 값은 [해석 시작] 때 저장됩니다.", + "Not analyzed — changes are saved on [Run analysis].", + ], E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E02_Saved: ["저장했습니다.", "Saved."], E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."], @@ -127,6 +130,35 @@ export const ui_locales_e = { E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"], E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + /* --- [해석 시작] 체인(PLAN 564-3) --- */ + E02_Run: ["해석 시작", "Run analysis"], + E02_Run_Hint: [ + "[해석 시작] = 지반 값 저장 → 2D · 3D(모든 구간 · 건기 · 우기) · 암반 · 광역 · 보고서를 차례로 셈. 입력이 같은 단계는 건너뜀 · 1.2 km 약 25~30 분 · 다른 페이지로 가도 계속 돎.", + "[Run analysis] = save ground values → 2D · 3D (all sections · dry · wet) · rock · area · report in turn. Unchanged steps are skipped · about 25–30 min per 1.2 km · keeps running on other pages.", + ], + E02_Run_Running: ["해석 중", "Running"], + E02_Run_Cancel: ["끊기", "Stop"], + E02_Run_Stopping: [ + "끊는 중 — 지금 조각이 끝나면 멈춥니다.", + "Stopping — halts after the current piece.", + ], + E02_Run_Done: ["해석을 마쳤습니다.", "Analysis finished."], + E02_Run_Cancelled: [ + "해석을 끊었습니다 — 끝난 단계는 저장됨.", + "Analysis stopped — finished steps are saved.", + ], + E02_Run_Step_2d: ["2D 측점 해석", "2D station analysis"], + E02_Run_Step_3d: ["3D 구간 해석", "3D section analysis"], + E02_Run_Step_rock: ["암반 해석", "Rock analysis"], + E02_Run_Step_area: ["광역 위험", "Area hazard"], + E02_Run_Step_report: ["보고서", "Report"], + E02_Run_State_wait: ["기다림", "Waiting"], + E02_Run_State_run: ["도는 중", "Running"], + E02_Run_State_done: ["끝남", "Done"], + E02_Run_State_skip: ["최신 · 건너뜀", "Up to date · skipped"], + E02_Run_State_error: ["실패", "Failed"], + E02_Run_State_cancel: ["끊음", "Stopped"], + /* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */ E02_Err_number: ["숫자를 넣으세요", "Enter a number"], E02_Err_range: ["범위 밖", "Out of range"],