feat(564-3): E02 [해석 시작] · 진행 막대 · 단계 줄 · [끊기] — [저장] 뺌 · 갔다 와도 진행 이어 봄

- [저장] 자리에 [해석 시작](POST /stab/run = 검사 · 저장 → 체인) · 잠금판은 저장 순간만
- 진행 막대(ui_template_progress) · 단계 줄 다섯(ui_template_list_row · 상태 띠 · 걸린 초) · [끊기]
- 열 때 GET /stab/run/progress — 도는 중이면 이어서 봄 · 끝난 결과면 단계 줄만
- locale_e 글 · 저장 안 됨 글 = 「해석 전」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:00:59 +09:00
co-authored by Claude Opus 5.5
parent 8f7bb8e7e6
commit bef1e8c15b
3 changed files with 181 additions and 19 deletions
+131 -18
View File
@@ -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<string, string> = {
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<GroundResponse> {
async function request<T = GroundResponse>(path: string, init?: RequestInit): Promise<T> {
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<void> {
while (root.isConnected) {
const p = await request<RunProgress>(`/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<void> {
if (!view?.editable) return;
/** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */
async function runChain(): Promise<void> {
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<GroundResponse & RunProgress>(`/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<void> {
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<RunProgress>(`/projects/${projectId}/stab/run/progress`);
paintRun(p);
if (p.running) void poll();
} catch (error) {
main.replaceChildren(
hook(
@@ -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;
}
+33 -1
View File
@@ -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"],