Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-28 18:08:05 +09:00
26 changed files with 924 additions and 80 deletions
+21
View File
@@ -169,6 +169,27 @@ export async function purgeOtherProjects(projectId: string): Promise<void> {
});
}
/** 그 프로젝트 몫만 지운다(401 · 떠날 때) — 다른 탭 · 다른 프로젝트 줄은 둠 · 이 탭 준비 표식도 버림. */
export async function purgeProject(projectId: string): Promise<void> {
if (!projectId) return;
try {
const stamp = window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY);
if (stamp?.startsWith(`${projectId}|`))
window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY);
} catch {
/* 저장소가 막힌 브라우저 — 표식이 없다. */
}
await deleteWhere((store) => {
const cursorRequest = store.index("projectId").openKeyCursor(IDBKeyRange.only(projectId));
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
store.delete(cursor.primaryKey);
cursor.continue();
};
});
}
/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null.
* 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */
export async function readCachedBytes(projectId: string, url: string): Promise<ArrayBuffer | null> {
+2 -2
View File
@@ -12,7 +12,6 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createButton } from "@ui/ui_template_elements";
import {
@@ -21,6 +20,7 @@ import {
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "./b_workflow_nav";
import { currentProjectId } from "./b_project_switch";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
@@ -107,7 +107,7 @@ export async function renderPendingWorkflow(
opts: PendingWorkflowOptions,
): Promise<void> {
injectScaffoldStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
let workflowState: WorkflowState | undefined;
if (projectId) {
try {
+8
View File
@@ -214,6 +214,14 @@ export const STATE_REGISTRY = {
"route-draft-hold": { bucket: "draft", scope: "project" },
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
"d04-assignees": { bucket: "draft", scope: "project" },
/* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
"d02-points": { bucket: "draft", scope: "project", legacy: (p) => `d02_points_${p}` },
"e02-ground": { bucket: "draft", scope: "project", legacy: (p) => `e02_ground_cache:${p}` },
"e03-prob": { bucket: "draft", scope: "project", legacy: (p) => `e03_prob_cache:${p}` },
"e04-settings": { bucket: "draft", scope: "project", legacy: (p) => `e04_settings:${p}` },
"e05-report": { bucket: "draft", scope: "project", legacy: (p) => `e05_report_cache:${p}` },
"e06-rock": { bucket: "draft", scope: "project", legacy: (p) => `e06_rock_cache:${p}` },
"e07-settings": { bucket: "draft", scope: "project", legacy: (p) => `e07_settings:${p}` },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
+160 -9
View File
@@ -1,21 +1,70 @@
/* =============================================================================
* A00_Common/b_project_switch.ts
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
* 프로젝트마다 따로 동작(401) — 탭마다 지금 프로젝트 · 떠남 물음 · 떠날 때 · 들 때 비움.
*
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* 지금 프로젝트 = 탭 세션 칸 먼저 · 없으면 마지막으로 연 프로젝트(localStorage · 새 탭이 이어받음).
* 들 때(`enterProject`) — 다른 프로젝트 세션 칸 · 3D 보관함을 지움(290-1) · 저장 안 한 고침이 있으면 확인창.
* 떠날 때(`leaveProject`) — 그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫 · 탭 프로젝트 표식 · 취향은 남김.
* 다른 프로젝트 화면을 그리려 하면 새로 엶(`reloadIfOtherProject` · 모듈 메모리 통째 비움).
* 떠남 물음(`guardProjectLeave`) — 화면에 붙은 가드를 모아 [취소 · 폐기 · 저장] 한 번.
* 화면 모듈은 부를 때만 들임 · 라우터는 스스로 이어 줌(`linkRouter`) — 설계 가게 · 인쇄 묶음이 이 파일을 부름.
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { CURRENT_PROJECT_ID_KEY } from "../config/config_frontend";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
/** 이 탭이 마지막으로 들어온 프로젝트. */
const ENTERED_KEY = "aislo:entered-project";
/** 이 탭의 지금 프로젝트(401) — 탭 세션 칸 먼저 · 없으면 마지막으로 연 프로젝트(새 탭) · 없으면 "". */
export function currentProjectId(): string {
try {
return (
window.sessionStorage.getItem(ENTERED_KEY) ||
window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) ||
""
);
} catch {
return "";
}
}
/** 지금 프로젝트로 적음 — 이 탭 + 새 탭이 이어받을 마지막 값. 다른 프로젝트 캐시 비움은 `enterProject`. */
export function setCurrentProject(projectId: string): void {
try {
window.sessionStorage.setItem(ENTERED_KEY, projectId);
window.localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
} catch {
/* 저장소가 막힌 브라우저 — 기억 못 함. */
}
}
/** 지워진 · 권한 없는 프로젝트를 잊음 — 이 탭 표식 · 마지막 값이 그 프로젝트일 때만. */
export function forgetProject(projectId: string): void {
try {
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId)
window.sessionStorage.removeItem(ENTERED_KEY);
if (window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) === projectId)
window.localStorage.removeItem(CURRENT_PROJECT_ID_KEY);
} catch {
/* 저장소가 막힌 브라우저 — 잊을 것이 없다. */
}
}
/** 이 화면(모듈 메모리)이 담은 프로젝트 — 프로젝트 화면을 그릴 때마다 견줌. */
let heldProject = "";
/** 다른 프로젝트 화면을 그리려 하면 새로 엶(401 · 모듈 메모리 통째 비움) · true = 새로 여는 중. */
export function reloadIfOtherProject(): boolean {
const projectId = currentProjectId();
if (heldProject && projectId && projectId !== heldProject) {
window.location.reload();
return true;
}
if (projectId) heldProject = projectId;
return false;
}
const MARK_P = "\u0001";
const MARK_R = "\u0002";
@@ -107,6 +156,7 @@ export async function enterProject(
const keep = new Set<string>();
for (const other of plan.draftProjects) {
const name = await nameOf(other).catch(() => other);
const { showConfirmDialog } = await import("@ui/ui_template_elements");
const drop = await showConfirmDialog(
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
"지우고 열기",
@@ -115,7 +165,8 @@ export async function enterProject(
if (!drop) keep.add(other);
}
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
window.sessionStorage.setItem(ENTERED_KEY, projectId);
setCurrentProject(projectId);
const { purgeOtherProjects } = await import("./b_asset_cache");
await purgeOtherProjects(projectId);
} catch {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
@@ -136,6 +187,7 @@ export async function wipeAccountCache(): Promise<void> {
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
const { purgeOtherProjects } = await import("./b_asset_cache");
await purgeOtherProjects(""); // 빈 id 보다 큰 열쇠 = 모든 프로젝트
}
@@ -153,3 +205,102 @@ export async function wipeIfOwnerChanged(user: string | number): Promise<boolean
await wipeAccountCache();
return true;
}
/**
* 프로젝트를 떠남(401) — 이 탭 세션의 그 프로젝트 칸 · 준비 표식 · 3D 보관함 몫을 지움.
* 탭 프로젝트 표식(뒤로 가기 · 재진입) · 취향 · 입력 설정 · CAD 복구본(localStorage)은 남김.
*/
export async function leaveProject(projectId: string): Promise<void> {
if (!projectId) return;
try {
// 프로젝트 칸 열쇠는 모두 프로젝트 번호(uuid)를 품음 — 등록표 · 옛 키 · 페이지 칸을 한 줄로.
Object.keys(sessionEntries())
.filter((key) => key.includes(projectId))
.forEach((key) => window.sessionStorage.removeItem(key));
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
const { purgeProject } = await import("./b_asset_cache");
await purgeProject(projectId);
}
/** 페이지가 알리는 떠남 셋(401) — 고침 있나 · 저장(false · 던짐 = 머묾) · 폐기. */
export interface ProjectLeaveGuard {
dirty: () => boolean;
save: () => Promise<unknown>;
discard?: () => void | Promise<void>;
/** 물음 글 — 없으면 공용 */
message?: string;
/** 같은 페이지로 보는 주소(B05 ↔ B06) — 그리로 가면 안 묻고 다시 걺 */
samePage?: readonly string[];
}
/** 라우터의 떠남 물음 자리 — 라우터가 들 때 이어 줌(라우터 → 이 파일 한 방향 · 페이지 표를 안 끌게). */
interface RouterLink {
currentRoute: () => string;
setLeaveGuard: (guard: (() => Promise<boolean>) | null) => void;
}
let routerLink: RouterLink | null = null;
export function linkRouter(link: RouterLink): void {
routerLink = link;
}
const guards: Array<{ root: HTMLElement; guard: ProjectLeaveGuard }> = [];
let unloadHooked = false;
/** 화면에 붙은 가드만 — 빠진 페이지 것은 버림. */
function liveGuards(): ProjectLeaveGuard[] {
for (let index = guards.length - 1; index >= 0; index -= 1) {
if (!guards[index].root.isConnected) guards.splice(index, 1);
}
return guards.map((entry) => entry.guard);
}
/** 저장 안 한 고침이 있나 — 화면에 붙은 가드 모두. */
export const projectLeaveDirty = (): boolean => liveGuards().some((guard) => guard.dirty());
/** 떠나도 되나 — 고친 가드를 모아 [취소 · 폐기 · 저장] 한 번 · true = 넘어감. */
export async function askProjectLeave(): Promise<boolean> {
const live = liveGuards();
if (!live.length) return true;
const route = routerLink?.currentRoute() ?? "";
if (live.some((guard) => guard.samePage?.includes(route))) {
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
window.setTimeout(() => liveGuards().length && routerLink?.setLeaveGuard(askProjectLeave), 0);
return true;
}
const dirty = live.filter((guard) => guard.dirty());
if (!dirty.length) return true;
const [{ leaveOrStay }, { t }] = await Promise.all([
import("@ui/ui_template_leave_dialog"),
import("@ui/ui_template_locale"),
]);
const message = (dirty.length === 1 && dirty[0].message) || t("Common_Leave_Unsaved");
const got = await leaveOrStay(message, {
save: async () => {
for (const guard of dirty) {
if (guard.dirty() && (await guard.save()) === false) throw new Error("저장 못 함");
}
},
stillDirty: () => dirty.some((guard) => guard.dirty()),
discard: async () => {
for (const guard of dirty) await guard.discard?.();
},
});
return got !== "stay";
}
/** 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂 · 탭 닫기도 물음. */
export function guardProjectLeave(root: HTMLElement, guard: ProjectLeaveGuard): void {
liveGuards(); // 빠진 옛 페이지를 붙들지 않음
guards.push({ root, guard });
if (!unloadHooked) {
unloadHooked = true;
window.addEventListener("beforeunload", (event) => {
if (!projectLeaveDirty()) return;
event.preventDefault();
event.returnValue = "";
});
}
routerLink?.setLeaveGuard(askProjectLeave);
}
+10 -10
View File
@@ -1,9 +1,4 @@
import {
API_BASE_URL,
CURRENT_PROJECT_ID_KEY,
ROUTES,
type RoutePath,
} from "@config/config_frontend";
import { API_BASE_URL, ROUTES, type RoutePath } from "@config/config_frontend";
import {
hideLoadingOverlay,
setLoadingOverlayProgress,
@@ -16,7 +11,12 @@ import {
fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import {
currentProjectId,
enterProject,
forgetProject,
setCurrentProject,
} from "./b_project_switch";
import { navigateTo } from "./router";
export interface WorkflowState {
@@ -77,7 +77,7 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
/** 기억한 현재 프로젝트가 서버에 없으면(지워짐 · 권한 없음) 기억을 비우고 true.
* 그 밖의 실패(서버 오류 · 끊김)는 false — 기억을 지우지 않고 페이지가 하던 대로 둔다. */
export async function dropGoneProject(): Promise<boolean> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
if (!projectId) return false;
try {
await fetchWorkflowState(projectId);
@@ -85,7 +85,7 @@ export async function dropGoneProject(): Promise<boolean> {
} catch (error) {
const status = (error as { status?: number }).status;
if (status !== 403 && status !== 404) return false;
localStorage.removeItem(CURRENT_PROJECT_ID_KEY);
forgetProject(projectId);
return true;
}
}
@@ -105,7 +105,7 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): Promise<
projectId,
async (id) => (await fetchWorkflowState(id)).project_name || id,
).then(() => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
setCurrentProject(projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
+11 -30
View File
@@ -4,7 +4,7 @@
* 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]).
*
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김 · 소단 사본 = 구조물 파생) ·
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 다른 페이지 칸(b08 · b09 · D · E) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
* B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음.
@@ -12,9 +12,8 @@
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import { listenWhileAlive } from "@ui/ui_template_window_watch";
import { guardProjectLeave } from "../b_project_switch";
import {
clearState,
namesInBucket,
@@ -23,7 +22,6 @@ import {
stateKey,
type StateName,
} from "../b_page_state";
import { currentRoute, setLeaveGuard } from "../router";
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [폐기]에도 안 지움. */
const NOT_EDITS = new Set<StateName>([
@@ -34,7 +32,7 @@ const NOT_EDITS = new Set<StateName>([
"std-cross",
"berm", // 소단 사본 — 열 때 시설 줄이 구조물에서 씀(272 · 고침은 `structures` 칸)
]);
const OTHER_PAGE = /^b0[89]-/;
const OTHER_PAGE = /^(b0[89]|d0\d|e0\d)-/;
export function editDraftNames(): StateName[] {
return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name));
@@ -93,10 +91,8 @@ export function discardDesignEdits(projectId: string): void {
editDraftNames().forEach((name) => clearState(name, projectId));
}
const SAME_PAGE = new Set<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
/**
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂(공용 `guardProjectLeave` 위 얇은 껍질).
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
*/
export function guardDesignLeave(
@@ -104,26 +100,11 @@ export function guardDesignLeave(
root: HTMLElement,
save: () => Promise<unknown>,
): void {
const unload = (event: BeforeUnloadEvent): void => {
if (!root.isConnected || !designDirty(projectId)) return;
event.preventDefault();
event.returnValue = "";
};
listenWhileAlive(window, "beforeunload", root, unload); // 다시 세운 옛 페이지를 안 붙듦(298-3)
const guard = async (): Promise<boolean> => {
if (!root.isConnected) return true;
if (SAME_PAGE.has(currentRoute())) {
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0);
return true;
}
if (!designDirty(projectId)) return true;
const got = await leaveOrStay(L("B05_Design_Leave"), {
save,
stillDirty: () => designDirty(projectId),
discard: () => discardDesignEdits(projectId),
});
return got !== "stay";
};
setLeaveGuard(guard);
guardProjectLeave(root, {
dirty: () => designDirty(projectId),
save,
discard: () => discardDesignEdits(projectId),
message: L("B05_Design_Leave"),
samePage: [ROUTES.B05_PROFILE, ROUTES.B06_SECTION],
});
}
+3 -2
View File
@@ -15,7 +15,8 @@
* 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다.
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { currentProjectId } from "../b_project_switch";
import { showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout";
@@ -124,7 +125,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
const page = document.createElement("div");
page.className = "design-page";
root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const projectId = currentProjectId();
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
+2 -6
View File
@@ -23,7 +23,7 @@
import { onStateRemoved, readState, stateKey, writeState } from "../b_page_state";
import type { StateName } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import { currentProjectId } from "../b_project_switch";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PageSettingsBody } from "../../B05_Profile/B05_Profile_Api_Fetch";
@@ -91,11 +91,7 @@ export function pinDraftProject(projectId: string | null): void {
export function draftProjectId(projectId: string | null = null): string {
if (projectId) return projectId;
if (openedProjectId) return openedProjectId;
try {
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
} catch {
return "-";
}
return currentProjectId() || "-";
}
interface Slot<T> {
+7 -4
View File
@@ -9,7 +9,6 @@
import {
API_BASE_URL,
CURRENT_PROJECT_ID_KEY,
PLAN_STEP_ROUTES,
STAB_STEP_ROUTES,
type RoutePath,
@@ -17,6 +16,7 @@ import {
import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { guardProjectLeave, setCurrentProject, type ProjectLeaveGuard } from "./b_project_switch";
import { navigateTo, setLeaveGuard, type PageRenderer } from "./router";
interface GroupFlow {
@@ -60,15 +60,18 @@ export function mountGroupStep(
parts: {
mainContent?: HTMLElement;
leftPanel?: HTMLElement;
/** 떠나기 전 — 저장 등 · false = 머묾 */
/** 옛 떠남(저장 등 · false = 머묾) — 페이지가 dirty · save 로 옮기면 지움 */
beforeLeave?: () => Promise<boolean>;
} = {},
} & Partial<ProjectLeaveGuard> = {},
): void {
const flow = FLOWS.find((f) => f.routes.includes(route));
if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`);
const active = flow.routes.indexOf(route);
const leave = parts.beforeLeave;
if (leave) setLeaveGuard(async () => !root.isConnected || (await leave()));
// 떠남 셋(401) — 단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로 모두 라우터 물음 한 자리([취소 · 폐기 · 저장])
const { dirty, save, discard, message } = parts;
if (dirty && save) guardProjectLeave(root, { dirty, save, discard, message });
const layout = createWorkflowLayout({
title: t(flow.labels[active]),
steps: flow.labels.map((key) => t(key)),
@@ -114,7 +117,7 @@ export async function createGroupProject(
const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`;
throw new Error(detail);
}
localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id);
setCurrentProject(data.project_id);
showToast(t("DE_Reg_Success"), "success");
navigateTo(firstStep);
} catch (error) {
+10 -1
View File
@@ -8,6 +8,7 @@
* ========================================================================== */
import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend";
import { linkRouter, reloadIfOtherProject } from "./b_project_switch";
/**
* 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다
@@ -103,6 +104,8 @@ export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
leaveGuard = guard;
};
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
// 공용 떠남 물음(401)이 이 자리를 씀 — 함수 선언은 끌어올려져 아래 currentRoute 도 바로 씀
linkRouter({ currentRoute, setLeaveGuard });
/** 현재 프로젝트(기억한 id)로 여는 화면 — 그 프로젝트가 서버에 없으면 안내를 띄운다. */
const PROJECT_ROUTES: readonly RoutePath[] = [
@@ -116,6 +119,10 @@ const PROJECT_ROUTES: readonly RoutePath[] = [
ROUTES.B11_LOADING,
];
/** 프로젝트 화면(401) — B 단계 · 준비 화면 · 초안 길 `project: true`. */
export const isProjectRoute = (route: string): boolean =>
PROJECT_ROUTES.includes(route as RoutePath) || Boolean(draftRoute(route)?.project);
/** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */
let mounted: MountedPage | null = null;
let renderSeq = 0;
@@ -222,7 +229,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
if (PROJECT_ROUTES.includes(route) || draft?.project) {
if (isProjectRoute(route)) {
// 다른 프로젝트 화면이면 새로 엶 — 앞 프로젝트 모듈 메모리가 안 묻어감(401)
if (reloadIfOtherProject()) return;
const { dropGoneProject } = await import("./b_workflow_nav");
if (await dropGoneProject()) {
if (seq !== renderSeq) return;
+72 -2
View File
@@ -4,9 +4,11 @@
full DB 행(projects · project_members · input_files · processed_point_cloud ·
surface_models · terrain_layers · routes · route_points · route_statistics ·
longitudinal_sections · cross_sections · project_workflow_stages)을 새 id 로 다시 이어
longitudinal_sections · cross_sections · project_workflow_stages · D 의 plan_networks ·
plan_route_pieces · plan_route_years · plan_route_assignees)을 새 id 로 다시 이어
한 트랜잭션에 넣고 저장소 폴더 통째(robocopy) — 복제 도구(`db_management/
tools_clone_project.py`)의 본체.
tools_clone_project.py`)의 본체. 종류(B · D · E)는 `project_kind` 로 판별 — 같은 길로
뜨고(빈 표는 건너뜀) E 의 원본 칸(source_project_id 등)은 projects 행째 그대로.
stability DB 행은 넣지 않고 E 프로젝트 저장소 `E01_StabRegister/source/` 에 동결 스냅샷
(`db.json`) + 해석에 쓰는 파일만(B05 노선 · 구조물 · B06 종횡단 · 쓰인 DTM 하나 ·
최신 노선 corridor · 관 편집분) — 원본 LAS · 전처리 점군 · B07~B09 · 초기값 안 가져옴.
@@ -32,6 +34,10 @@ from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
Scope = Literal["full", "stability"]
KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"}
#: 자동증가 id 없이 project_id 만 바꿔 넣는 D 표
_PLAN_TABLES = ("plan_networks", "plan_route_pieces", "plan_route_years")
_PLAN_ALL = (*_PLAN_TABLES, "plan_route_assignees")
MANIFEST_NAME = "copy_manifest.json"
#: E 프로젝트 안 복사본 자리 — E 엔진은 `E01_StabRegister_Source` 로만 읽음
STABILITY_SOURCE_DIR = "E01_StabRegister/source"
@@ -168,9 +174,54 @@ async def _source_rows(cursor, source_id: str) -> dict[str, list]:
if not rows["projects"]:
raise LookupError(f"원본 프로젝트가 없음: {source_id}")
rows.update(await _route_children(cursor, [route["id"] for route in rows["routes"]]))
for table in _PLAN_ALL:
rows[table] = await _rows(
cursor, f"SELECT * FROM {table} WHERE project_id = %s", (source_id,)
)
return rows
async def _uncovered(cursor, source_id: str, covered) -> dict[str, int]:
"""project_id 칸이 있는데 복제 목록 밖인 표 중 원본 행이 있는 것 → 표 → 행 수."""
await cursor.execute(
"SELECT TABLE_NAME AS name FROM information_schema.COLUMNS"
" WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'project_id'"
)
found = {}
for row in await cursor.fetchall():
if row["name"] in covered:
continue
await cursor.execute(
f"SELECT COUNT(*) AS n FROM `{row['name']}` WHERE project_id = %s", (source_id,)
)
count = (await cursor.fetchone())["n"]
if count:
found[row["name"]] = count
return found
async def dry_run(connection, source_id: str) -> dict[str, Any]:
"""쓰지 않고 셈만 — 종류 · 표마다 행 수 · 목록 밖 표 · 폴더 크기."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
rows = await _source_rows(cursor, source_id)
skipped = await _uncovered(cursor, source_id, rows)
source = rows["projects"][0]
root = Path(resolve_stored_project_path(source["storage_path"]))
files = [path for path in root.rglob("*") if path.is_file()] if root.is_dir() else []
report = {
"project_id": source_id,
"name": source["name"],
"kind": KIND_LABELS.get(source.get("project_kind"), source.get("project_kind")),
"tables": {name: len(items) for name, items in rows.items() if items},
"skipped_tables": skipped,
"folder": str(root),
"files": len(files),
"bytes": sum(path.stat().st_size for path in files),
}
print(json.dumps(report, ensure_ascii=False, indent=1))
return report
async def _route_children(cursor, route_ids: list[int]) -> dict[str, list]:
children: dict[str, list] = {
name: [] for name in ("route_points", "route_statistics", "longitudinal_sections")
@@ -281,6 +332,17 @@ async def _insert_full(
data["surface_model_id"] = model_map.get(old_model, old_model)
row["params"] = json.dumps(data, ensure_ascii=False)
await _insert(cursor, "project_workflow_stages", row)
for table in _PLAN_TABLES:
for row in rows[table]:
await _insert(cursor, table, {**row, "project_id": new_id})
for row in rows["plan_route_assignees"]:
row = dict(row)
row.pop("id")
row["project_id"] = new_id
# 업체가 이은 임도는 원본 D 몫 — 복제본은 잇지 않은 채로 시작
row["linked_project_id"] = None
await _insert(cursor, "plan_route_assignees", row)
await connection.commit()
print(
f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}"
@@ -288,6 +350,7 @@ async def _insert_full(
f" · 노선 {len(route_map)}"
f" · 정점 {len(rows['route_points'])} · 횡단 {len(rows['cross_sections'])}"
f" · 단계 {len(rows['project_workflow_stages'])}"
f" · 예정노선 {' · '.join(f'{t} {len(rows[t])}' for t in _PLAN_ALL)}"
)
return model_map
@@ -303,7 +366,12 @@ async def clone_full(
"""통째 복제 → 새 project_id. 새 폴더 = 원본과 같은 회사/사용자 폴더."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
rows = await _source_rows(cursor, source_id)
skipped = await _uncovered(cursor, source_id, rows)
source = rows["projects"][0]
kind = KIND_LABELS.get(source.get("project_kind"), source.get("project_kind"))
print(f"원본 종류: {kind}")
if skipped:
print("⚠ 복제 목록 밖 표(안 뜸):", skipped)
new_id = str(uuid4())
source_root = Path(resolve_stored_project_path(source["storage_path"]))
new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id])
@@ -319,10 +387,12 @@ async def clone_full(
new_root,
{
"scope": "full",
"kind": kind,
"source_project_id": source_id,
"project_id": new_id,
"copied_at": datetime.now().isoformat(timespec="seconds"),
"tables": {name: len(items) for name, items in rows.items()},
"skipped_tables": skipped,
"files": _tree_files(new_root),
},
)
+20 -4
View File
@@ -11,7 +11,8 @@
무엇을 뜨나
DB projects · project_members · input_files · processed_point_cloud · surface_models ·
terrain_layers · routes · route_points · route_statistics · longitudinal_sections ·
cross_sections · project_workflow_stages
cross_sections · project_workflow_stages · (D) plan_networks · plan_route_pieces ·
plan_route_years · plan_route_assignees — 종류(B · D · E)는 project_kind 로 판별
⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다.
파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다.
치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**.
@@ -22,6 +23,8 @@
쓰는 법
./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>"
[--owner <사용자 id>] [--members <id,id>] (없으면 원본 주인 · 참여자 그대로)
셈만(쓰지 않음 — 종류 · 표마다 행 수 · 목록 밖 표 · 폴더 크기)
./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> - --dry-run
storage 만(DB 는 이미 복제됨 — 다른 PC 의 storage 를 맞출 때)
./venv/Scripts/python.exe db_management/tools_clone_project.py \\
<원본 UUID> <새 UUID> --storage-only
@@ -36,22 +39,32 @@ sys.path.append(str(Path(__file__).resolve().parent.parent))
import aiomysql
from common_util.common_util_project_copy import clone_full, clone_storage_only
from common_util.common_util_project_copy import clone_full, clone_storage_only, dry_run
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
def _args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).")
parser.add_argument("source_id", help="원본 프로젝트 UUID")
parser.add_argument("new_name", help="새 프로젝트 이름(--storage-only 면 새 프로젝트 UUID)")
parser.add_argument(
"new_name",
nargs="?",
help="새 프로젝트 이름(--storage-only 면 새 프로젝트 UUID · --dry-run 이면 없어도 됨)",
)
parser.add_argument(
"--storage-only",
action="store_true",
help="DB 는 읽기만 · storage 폴더 복사와 파일 안 id 바꿈만",
)
parser.add_argument(
"--dry-run", action="store_true", help="쓰지 않고 표마다 행 수 · 폴더 크기만"
)
parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)")
parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)")
return parser.parse_args(argv)
args = parser.parse_args(argv)
if not args.dry_run and not args.new_name:
parser.error("new_name 필요")
return args
async def main(argv: list[str] | None = None) -> str | None:
@@ -65,6 +78,9 @@ async def main(argv: list[str] | None = None) -> str | None:
charset="utf8mb4",
)
try:
if args.dry_run:
await dry_run(connection, args.source_id)
return None
if args.storage_only:
await clone_storage_only(connection, args.source_id, args.new_name)
return None
@@ -55,6 +55,8 @@ const SEED = {
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
"aislo.otp-pending": "x", // 프로젝트와 무관
[`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음)
[`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음
};
const seed = (extra = {}) => {
global.window.sessionStorage = fakeStorage();
@@ -58,6 +58,8 @@ const { loadTs } = makeLoader({
});
const drag = loadTs(path.join(ROOT, "ui_template", "ui_template_overlay_drag.ts"));
// 공용 떠남 가드(401) — 라우터 흉내를 이어 둠(같은 한 벌을 design_leave 가 부름)
loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts")).linkRouter(STUBS["../router"]);
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
let settles = 0;
@@ -20,6 +20,7 @@ global.IDBKeyRange = {
upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }),
lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }),
bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }),
only: (v) => ({ test: (k) => k === v }),
};
function makeIdb(options = {}) {
@@ -163,7 +164,11 @@ const fresh = (options) => {
};
const text = (buffer) => new TextDecoder().decode(buffer);
// 401 시험이 가짜 IndexedDB 만 빌려 씀 — 그때는 아래 차례를 안 돌림
module.exports = { makeIdb };
(async () => {
if (require.main !== module) return;
const out = {};
// 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐)
@@ -71,6 +71,10 @@ const { loadTs } = makeLoader({
},
});
// 공용 떠남 가드(401) — 라우터 흉내를 이어 둔 한 벌을 design_leave 에 줌
const projectSwitch = loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts"));
projectSwitch.linkRouter(STUBS["../router"]);
STUBS["../b_project_switch"] = projectSwitch;
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
const P = "p1";
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
+4 -3
View File
@@ -57,9 +57,10 @@ const sw = load("A00_Common/b_project_switch.ts", (id) => {
if (id.includes("config_frontend")) return config;
return { showConfirmDialog: async () => true };
});
const store = load("A00_Common/design/design_store.ts", (id) =>
id.includes("b_page_state") ? pageState : config,
);
const store = load("A00_Common/design/design_store.ts", (id) => {
if (id.includes("b_project_switch")) return sw; // 지금 프로젝트 = 탭 칸 먼저(401)
return id.includes("b_page_state") ? pageState : config;
});
const P = "aaaa1111";
const seed = () => {
@@ -0,0 +1,217 @@
/* 401 프로젝트마다 따로 동작 — 진짜 TS(b_page_state · b_project_switch · b_asset_cache)를 가짜 저장소 ·
* 가짜 IndexedDB 위에서 돌려 JSON 으로 냄. 탭 둘 = 세션 둘 · localStorage 하나 · 모듈 한 벌씩. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const { makeIdb } = require("./helper_343_3_asset_cache.cjs");
const ROOT = path.join(__dirname, "..", "..");
function fakeStorage() {
const map = new Map();
return {
get length() {
return map.size;
},
key: (i) => [...map.keys()][i] ?? null,
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
dump: () => Object.fromEntries(map),
};
}
const shared = { localStorage: fakeStorage(), idb: makeIdb() };
const router = { route: "/b07-design-detail", guard: null, sets: 0 };
let choice = "save";
const asked = [];
/** 탭 하나 = 창 하나 · 모듈 한 벌(모듈 메모리 따로). */
function openTab() {
const win = new EventTarget();
win.sessionStorage = fakeStorage();
win.localStorage = shared.localStorage;
win.indexedDB = shared.idb.indexedDB;
win.setTimeout = setTimeout;
win.reloads = 0;
win.location = { reload: () => (win.reloads += 1) };
const cache = new Map();
const load = (rel) => {
if (cache.has(rel)) return cache.get(rel);
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
cache.set(rel, exports);
new Function("exports", "require", "window", js)(exports, need, win);
return exports;
};
const need = (id) => {
if (id.includes("config_frontend"))
return { CURRENT_PROJECT_ID_KEY: "frd_current_project_id", API_BASE_URL: "http://x/api" };
if (id.endsWith("b_page_state")) return load("A00_Common/b_page_state.ts");
if (id.endsWith("b_asset_cache")) return load("A00_Common/b_asset_cache.ts");
if (id.endsWith("ui_template_locale")) return { t: (key) => key };
if (id.endsWith("ui_template_leave_dialog"))
return {
leaveOrStay: async (message, hooks) => {
asked.push(message);
if (choice === "cancel") return "stay";
if (choice === "discard") return (await hooks.discard(), "discarded");
try {
await hooks.save();
} catch {
return "stay";
}
return hooks.stillDirty() ? "stay" : "saved";
},
};
if (id.endsWith("ui_template_elements")) return { showConfirmDialog: async () => true };
return {}; // B04 · B05 받기(보관함이 부르지 않음)
};
const sw = load("A00_Common/b_project_switch.ts");
sw.linkRouter({
currentRoute: () => router.route,
setLeaveGuard: (fn) => ((router.guard = fn), (router.sets += 1)),
});
return { win, sw };
}
const X = "11111111-aaaa-4aaa-8aaa-000000000001";
const Y = "22222222-bbbb-4bbb-8bbb-000000000002";
/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */
class BeforeUnload extends Event {
returnValue = true;
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
const keys = (win) => Object.keys(win.sessionStorage.dump()).sort();
(async () => {
const out = {};
// 1. 탭 둘 — B 가 Y 로 바꿔도 A 는 X · 새 탭은 마지막 값(Y)
shared.localStorage.setItem("frd_current_project_id", X);
const A = openTab();
out.newTabStartsWithLast = A.sw.currentProjectId();
await A.sw.enterProject(X, async (id) => id);
const B = openTab();
await B.sw.enterProject(Y, async (id) => id);
out.tabs = {
a: A.sw.currentProjectId(),
b: B.sw.currentProjectId(),
fresh: openTab().sw.currentProjectId(),
last: shared.localStorage.getItem("frd_current_project_id"),
};
// 2. 다른 프로젝트 화면을 그리려 하면 새로 엶 — 같은 프로젝트는 그대로
out.reload = { first: A.sw.reloadIfOtherProject(), same: A.sw.reloadIfOtherProject() };
A.sw.setCurrentProject(Y);
out.reload.other = A.sw.reloadIfOtherProject();
out.reload.count = A.win.reloads;
A.sw.setCurrentProject(X);
// 3. 떠남 — 그 프로젝트 칸 · 준비 표식 · 보관함 몫만 · 밖 칸 · 취향 · CAD 복구본 그대로
const seedX = [
`aislo:draft:structures:${X}`,
`aislo:draft:crossw:${X}:12`,
`aislo:result:section-detail:${X}:12`,
`b06:culvertopt:${X}:12`,
`aislo:draft:d03-plan:${X}`,
`aislo:draft:d04-assignees:${X}`,
`aislo:draft:e05-report:${X}`,
`d02_points_${X}`,
`e02_ground_cache:${X}`,
`e03_prob_cache:${X}`,
`e04_settings:${X}`,
`e06_rock_cache:${X}`,
`e07_settings:${X}`,
];
const keep = [
`aislo:draft:structures:${Y}`,
"a06:otp-pending",
"aislo.adminCompany",
"aislo.provenance.tint",
"m01_master_draft",
"m02-hopyo-check:3",
"aislo:entered-project",
];
seedX.forEach((k) => A.win.sessionStorage.setItem(k, '{"v":1}'));
keep.slice(0, -1).forEach((k) => A.win.sessionStorage.setItem(k, "1"));
A.win.sessionStorage.setItem("frd_preloaded_signature", `${X}|sig`);
const localKeep = [`aislo:pref:routeedit-view:${X}`, `OPEN_WEB_CAD__RECOVERY__b07:${X}:1`];
localKeep.forEach((k) => shared.localStorage.setItem(k, "1"));
for (const [pid, url] of [
[X, "u1"],
[X, "u2"],
[Y, "u1"],
]) {
shared.idb.data.set(`${pid}|${url}`, { key: `${pid}|${url}`, projectId: pid, url });
}
await A.sw.leaveProject(X);
out.leave = {
keys: keys(A.win),
expected: [...keep].sort(),
entered: A.win.sessionStorage.getItem("aislo:entered-project"),
local: localKeep.map((k) => shared.localStorage.getItem(k)),
idb: [...shared.idb.data.keys()].sort(),
};
// 다른 탭(B) 세션은 안 건드림
B.win.sessionStorage.setItem(`aislo:draft:structures:${X}`, "1");
await A.sw.leaveProject(X);
out.leave.otherTabKept = B.win.sessionStorage.getItem(`aislo:draft:structures:${X}`);
// 4. 떠남 물음 — 고친 가드만 모아 한 번 · 저장 실패 = 머묾 · 폐기 · 같은 페이지 · 빠진 페이지
const root1 = { isConnected: true };
const root2 = { isConnected: true };
const state = { d1: false, d2: false, saves: [], discards: [], saveOk: true };
A.sw.guardProjectLeave(root1, {
dirty: () => state.d1,
save: async () => (state.saves.push(1), state.saveOk && (state.d1 = false), state.saveOk),
discard: () => state.discards.push(1),
});
A.sw.guardProjectLeave(root2, {
dirty: () => state.d2,
save: async () => (state.saves.push(2), (state.d2 = false), true),
message: "둘째 글",
});
await tick();
const ask = router.guard;
const unloadBlocked = () => {
const event = new BeforeUnload("beforeunload", { cancelable: true });
A.win.dispatchEvent(event);
return event.defaultPrevented;
};
out.guard = { armed: ask === A.sw.askProjectLeave, cleanLeave: await ask() };
out.guard.cleanUnload = unloadBlocked();
out.guard.asked0 = asked.length;
state.d2 = true;
out.guard.dirtyUnload = unloadBlocked();
choice = "cancel";
out.guard.cancel = await ask();
choice = "save";
out.guard.save = await ask();
out.guard.messages = [...asked];
state.d1 = true;
state.saveOk = false;
out.guard.saveFail = await ask();
choice = "discard";
out.guard.discard = await ask();
out.guard.saves = state.saves;
out.guard.discards = state.discards.length;
router.route = "/b06-section";
A.sw.guardProjectLeave(
{ isConnected: true },
{ dirty: () => true, save: async () => true, samePage: ["/b05-profile", "/b06-section"] },
);
await tick();
const sets = router.sets;
const askedBefore = asked.length;
out.guard.samePage = await ask();
await tick();
out.guard.reArmed = router.sets === sets + 1;
out.guard.samePageAsked = asked.length - askedBefore;
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
+4 -1
View File
@@ -17,7 +17,10 @@ const stubs = {
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {},
"./b_asset_cache": {},
"./b_project_switch": { enterProject: async () => calls.push("enter") },
"./b_project_switch": {
enterProject: async () => calls.push("enter"),
setCurrentProject: () => undefined, // 탭 프로젝트(401)
},
"./router": { navigateTo: (route) => calls.push(`go:${route}`) },
};
const js = ts.transpileModule(
+7 -2
View File
@@ -42,14 +42,19 @@ def test_지우고_열기_다른_프로젝트_모두_지움(out):
def test_남기기_그_프로젝트_초안만_둠(out):
kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"]
kept = [
f"aislo:draft:crossw:{A}:12",
f"aislo:draft:structures:{A}",
f"b06:culvertopt:{A}:12",
f"d02_points_{A}", # D02 옛 키도 초안(401)
]
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
assert out["keep"]["asked"] == 1
def test_같은_프로젝트_다시_들어오면_안_지움(out):
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
assert len(out["same"]["keys"]) == 11
assert len(out["same"]["keys"]) == 13
def test_빈_초안은_고침_아님_취향은_둠(out):
+7 -1
View File
@@ -46,7 +46,6 @@ def test_옛_페이지_풀림_지금_페이지는_들음(out) -> None:
@pytest.mark.parametrize(
("rel", "types"),
[
("A00_Common/design/design_leave.ts", "beforeunload"),
("ui_template/ui_template_overlay_drag.ts", "pointermove|pointerup|pointercancel|resize"),
(
"ui_template/ui_template_side_panels.ts",
@@ -76,3 +75,10 @@ def test_코리도_캐시는_지금_노선_한_칸() -> None:
in code
)
assert code.count("cache.set(") == 1
def test_떠남_가드는_탭_닫기_듣개_한_번만() -> None:
# 401 — 떠남 가드가 공용으로 옮김 · 듣개는 모듈에 한 번 · 페이지 노드는 가드 목록에서 빠지면 버림
code = _read("A00_Common/b_project_switch.ts")
assert "if (!unloadHooked) {" in code and code.count('addEventListener("beforeunload"') == 1
assert "liveGuards(); // 빠진 옛 페이지를 붙들지 않음" in code
+3 -1
View File
@@ -95,7 +95,9 @@ def test_초안_길은_선언으로_등록_정적_표엔_이름_없음():
in router
)
assert "routeTable[route] ?? draft?.load" in router
assert "draft?.workflow" in router and "draft?.project" in router
assert (
"draft?.workflow" in router and "draftRoute(route)?.project" in router
) # 401 isProjectRoute
assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts")
route = _text(DRAFT / "B09_Draft_Print_Route.ts")
assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route
@@ -0,0 +1,112 @@
"""401 프로젝트마다 따로 동작(요구 400~403 · PLAN 572).
- 지금 프로젝트 = 탭마다(탭 세션 칸 먼저 · 새 탭 = 마지막 값) · 다른 프로젝트 화면을 그리려 하면 새로 엶
- 떠남 = 그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫만 지움 · 밖 칸 · 취향 · CAD 복구본 · 다른 탭은 그대로
- 떠남 물음 = 화면에 붙은 가드 {dirty, save} 를 모아 [취소 · 폐기 · 저장] 한 번
`helper_401_project_isolation.cjs` 가 진짜 TS 를 가짜 저장소 · 가짜 IndexedDB 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
X = "11111111-aaaa-4aaa-8aaa-000000000001"
Y = "22222222-bbbb-4bbb-8bbb-000000000002"
#: 아직 직접 읽는 B · D · E 페이지(572-2 · 572-3 창 몫) — 고치면 여기서 뺌.
PENDING = {
"B03_FileInput/B03_FileInput_UI_Page.ts",
"B03_FileInput/B03_FileInput_UI_Page_Flow.ts",
"B03_FileInput/B03_FileInput_UI_PlanImport.ts",
"B04_PreProcess/B04_PreProcess_UI_Page.ts",
"B07_DesignDetail/B07_DesignDetail_UI_Page.ts",
"B08_Quantity/B08_Quantity_UI_Page.ts",
"B09_Estimation/B09_Estimation_UI_Haul_Modal.ts",
"B09_Estimation/B09_Estimation_UI_Shell.ts",
"B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts",
"B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts",
"B09_Estimation/draft/B09_Draft_Print_Page.ts",
"B11_Status/B11_Status_UI_Loading.ts",
"D02_PlanBase/D02_PlanBase_UI_Page.ts",
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts",
"D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts",
"E02_StabGround/E02_StabGround_UI_Page.ts",
"E03_StabSection/E03_StabSection_UI_Page.ts",
"E04_Stab3D/E04_Stab3D_UI_Page.ts",
"E05_StabReport/E05_StabReport_UI_Page.ts",
"E06_StabRock/E06_StabRock_UI_Page.ts",
"E07_StabArea/E07_StabArea_UI_Page.ts",
}
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_401_project_isolation.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_탭마다_지금_프로젝트(out):
assert out["newTabStartsWithLast"] == X
assert out["tabs"] == {"a": X, "b": Y, "fresh": Y, "last": Y}
def test_다른_프로젝트_화면은_새로_엶(out):
assert out["reload"] == {"first": False, "same": False, "other": True, "count": 1}
def test_떠남은_그_프로젝트_몫만_지움(out):
leave = out["leave"]
assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로
assert leave["entered"] == X # 탭 프로젝트 표식은 남김
assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본
assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움
assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림
def test_떠남_물음_한_번(out):
g = out["guard"]
assert g["armed"] and g["cleanLeave"] and not g["cleanUnload"] and g["asked0"] == 0
assert g["dirtyUnload"] and g["cancel"] is False and g["save"] is True
assert g["messages"] == ["둘째 글", "둘째 글"] # 고친 가드 하나 = 그 글
assert g["saveFail"] is False and g["discard"] is True
assert g["saves"] == [2, 1] and g["discards"] == 1 # 고친 가드만 저장
assert g["samePage"] and g["reArmed"] and g["samePageAsked"] == 0
def test_지금_프로젝트_직접_읽기는_한_곳():
hits = set()
tracked = subprocess.run(
["git", "ls-files", "*.ts"], cwd=ROOT, capture_output=True, text=True, check=True
).stdout.split() # storage · node_modules 는 훑지 않음
for rel in tracked:
text = (ROOT / rel).read_text(encoding="utf-8", errors="ignore")
if re.search(r"localStorage\.getItem\(\s*CURRENT_PROJECT_ID_KEY", text):
hits.add(rel)
assert hits - PENDING == {"A00_Common/b_project_switch.ts"} - PENDING
assert "A00_Common/b_project_switch.ts" in hits
def test_공용이_도우미를_씀():
router = (ROOT / "A00_Common/router.ts").read_text(encoding="utf-8")
assert "export const isProjectRoute" in router and "reloadIfOtherProject()" in router
group = (ROOT / "A00_Common/group_workflow.ts").read_text(encoding="utf-8")
assert "guardProjectLeave(root, { dirty, save, discard, message })" in group
leave = (ROOT / "A00_Common/design/design_leave.ts").read_text(encoding="utf-8")
assert "guardProjectLeave(root, {" in leave
state = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8")
for name in ("d02-points", "e02-ground", "e03-prob", "e04-settings", "e05-report"):
assert f'"{name}": {{ bucket: "draft", scope: "project"' in state
assert '"e06-rock"' in state and '"e07-settings"' in state
+228
View File
@@ -0,0 +1,228 @@
"""573-1 복제 도구 D · E 갈래 — 임시 storage 사본에서(요구 404 · 405).
- D(예정노선) — plan_networks · plan_route_pieces · plan_route_years · plan_route_assignees ·
plan_base_json · 지표면 · 지형층 행 수 같음 · 자동증가 id 새로 · 지형층 → 새 지표면 ·
업체 연결 끊김 · 지형 models_plan · _derived 파일 그대로
- E(안정성평가) — 원본 칸(source_project_id 등) 그대로 · 단계 행 여섯 상태 그대로 ·
E05 보고서 · _derived 파일 수 같음
- 둘 다 — 종류 판별 · 이름 그대로 · 파일 안 옛 UUID 없음 · 원본 행 · 파일 그대로 ·
--dry-run 은 쓰지 않음 · 만든 복제본은 끝에 hard_delete_project(임시 사본 폴더째)
"""
import asyncio
import hashlib
import importlib.util
import json
import os
import shutil
import sys
from contextlib import contextmanager
from pathlib import Path
import aiomysql
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from helper_temp_storage import _LINKED, temp_project_storage # noqa: E402
from common_util import common_util_project_copy as cp # noqa: E402
from common_util.common_util_storage import resolve_stored_project_path # noqa: E402
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
#: 가이드 예정노선 · 가이드 안정성평가(읽기만)
PLAN_ID = "87d47800-9936-4a56-9850-276fbcb34cac"
STAB_ID = "088e173d-a8ab-4c51-badb-f5a95afc43bb"
ROOT = Path(__file__).resolve().parents[2]
TABLES = (
"surface_models",
"project_members",
"project_workflow_stages",
*cp._PLAN_ALL,
)
async def _query(sql, args=()):
connection = await aiomysql.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
db=DB_NAME,
charset="utf8mb4",
autocommit=True,
)
try:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(sql, args)
return await cursor.fetchall()
finally:
connection.close()
def _q(sql, args=()):
return asyncio.run(_query(sql, args))
def _project(project_id):
rows = _q("SELECT * FROM projects WHERE id = %s", (project_id,))
return rows[0] if rows else None
def _counts(project_id):
return {
table: _q(f"SELECT COUNT(*) AS n FROM {table} WHERE project_id = %s", (project_id,))[0]["n"]
for table in TABLES
}
def _hashes(root: Path) -> dict[str, str]:
return {
path.relative_to(root).as_posix(): hashlib.sha1(path.read_bytes()).hexdigest()
for path in root.rglob("*")
if path.is_file() and path.suffix == ".json"
}
def _files(root: Path) -> set[str]:
"""정션 자리(시험 복사가 건너뜀) 빼고."""
names = {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
return {n for n in names if not n.startswith(tuple(f"{link}/" for link in _LINKED))}
def _cli():
spec = importlib.util.spec_from_file_location(
"tools_clone_project", ROOT / "db_management" / "tools_clone_project.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _copytree(source: Path, target: Path) -> None:
"""robocopy 대신 — 시험 사본의 정션(B03 · 전처리 점군)은 따라가지 않음."""
shutil.copytree(
source,
target,
ignore=lambda folder, names: [n for n in names if os.path.isjunction(Path(folder, n))],
)
async def _hard_delete(project_id, actor):
from common_util.common_util_project_delete import hard_delete_project
from config.config_db import close_db_pool, init_db_pool
await init_db_pool()
try:
return await hard_delete_project(project_id, actor)
finally:
await close_db_pool()
@contextmanager
def _clone(source_id, name, monkeypatch):
"""임시 storage 사본에서 복제 → (원본 사본 뿌리, 새 id) · 끝에 지움."""
source = _project(source_id)
if not source:
pytest.skip("원본 프로젝트가 DB 에 없음")
shared = Path(resolve_stored_project_path(source["storage_path"]))
with temp_project_storage(shared) as root:
monkeypatch.setattr(cp, "_copy_folder", _copytree)
before_rows = _counts(source_id)
before_files = _hashes(root)
new_id = None
try:
# dry-run 은 쓰지 않음
assert asyncio.run(_cli().main([source_id, "--dry-run"])) is None
assert not (root.parent / name).exists()
new_id = asyncio.run(_cli().main([source_id, name]))
yield source, root, new_id
# 원본 그대로
assert _counts(source_id) == before_rows
assert _hashes(root) == before_files
finally:
if new_id:
assert asyncio.run(_hard_delete(new_id, source["user_id"]))
assert _project(new_id) is None
assert not (root.parent / new_id).exists()
def _common(source, root, new_id):
new = _project(new_id)
assert new_id != source["id"] and new["name"].startswith("시험_573_1")
assert new["project_kind"] == source["project_kind"]
assert new["storage_path"].endswith(new_id)
assert _counts(new_id) == _counts(source["id"])
new_root = root.parent / new_id
manifest = json.loads((new_root / cp.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["kind"] == cp.KIND_LABELS[source["project_kind"]]
assert manifest["skipped_tables"] == {}
assert _files(new_root) - {cp.MANIFEST_NAME} == _files(root)
for path in new_root.rglob("*.json"):
if path.name != cp.MANIFEST_NAME:
assert source["id"] not in path.read_text(encoding="utf-8"), path
return new, new_root
def test_clone_plan(monkeypatch):
with _clone(PLAN_ID, "시험_573_1_D", monkeypatch) as (source, root, new_id):
new, new_root = _common(source, root, new_id)
assert json.loads(new["plan_base_json"]) == json.loads(source["plan_base_json"])
assert (new_root / "B04_PreProcess" / "models_plan").is_dir()
assert (new_root / "_derived").is_dir()
def rows(table, project_id, order):
return _q(
f"SELECT * FROM {table} WHERE project_id = %s ORDER BY {order}", (project_id,)
)
old_net, new_net = (
rows("plan_networks", PLAN_ID, "project_id"),
rows("plan_networks", new_id, "project_id"),
)
assert json.loads(new_net[0]["network_json"]) == json.loads(old_net[0]["network_json"])
for table, order in (("plan_route_pieces", "piece_id"), ("plan_route_years", "year")):
old, now = rows(table, PLAN_ID, order), rows(table, new_id, order)
drop = ("project_id", "updated_at")
assert [{k: v for k, v in r.items() if k not in drop} for r in now] == [
{k: v for k, v in r.items() if k not in drop} for r in old
]
old_a = rows("plan_route_assignees", PLAN_ID, "id")
new_a = rows("plan_route_assignees", new_id, "id")
assert not {r["id"] for r in old_a} & {r["id"] for r in new_a}
assert [(r["piece_id"], r["year"], r["company_name"]) for r in new_a] == [
(r["piece_id"], r["year"], r["company_name"]) for r in old_a
]
assert all(r["linked_project_id"] is None for r in new_a)
old_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (PLAN_ID,))
new_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (new_id,))
assert {r["id"] for r in new_m}.isdisjoint({r["id"] for r in old_m})
layers = _q(
"SELECT t.surface_model_id FROM terrain_layers t JOIN surface_models s"
" ON s.id = t.surface_model_id WHERE s.project_id = %s",
(new_id,),
)
assert len(layers) == 1
def test_clone_stability(monkeypatch):
with _clone(STAB_ID, "시험_573_1_E", monkeypatch) as (source, root, new_id):
new, new_root = _common(source, root, new_id)
for column in ("source_project_id", "source_copied_at", "source_fingerprint"):
assert new[column] == source[column]
def stages(project_id):
return _q(
"SELECT stage_no, stage_key, state FROM project_workflow_stages"
" WHERE project_id = %s ORDER BY stage_no",
(project_id,),
)
assert len(stages(new_id)) == 6 and stages(new_id) == stages(STAB_ID)
assert (new_root / "E05_StabReport" / "report.json").is_file()
inner = json.loads(
(new_root / cp.STABILITY_SOURCE_DIR / cp.MANIFEST_NAME).read_text(encoding="utf-8")
)
assert inner["project_id"] == new_id
assert inner["source_project_id"] == source["source_project_id"]
+1
View File
@@ -20,6 +20,7 @@ export const ui_locales_common = {
* ------------------------------------------------------------------------ */
Common_Btn_Confirm: ["확인", "Confirm"],
Common_Btn_Cancel: ["취소", "Cancel"],
Common_Leave_Unsaved: ["저장 안 한 고침이 있음", "Unsaved edits"],
Common_Btn_Save: ["저장", "Save"],
Common_Btn_Delete: ["삭제", "Delete"],
Common_Btn_Edit: ["수정", "Edit"],
+2 -2
View File
@@ -13,13 +13,13 @@
import "./ui_template_overlay.css";
// 옛 목록 줄(`.ui-sidebar-row` · M01 · M02 왼쪽 목록) 겉 = 목록 줄 부품
import "./ui_template_list_row.css";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { t } from "./ui_template_locale";
import { placeHelpButton } from "./ui_template_help";
import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { SIZE_BREAKS } from "../A00_Common/device_context";
@@ -162,7 +162,7 @@ export function splitSidebarActions(body: HTMLElement): void {
function createProjectNameTag(): HTMLElement {
const tag = document.createElement("span");
tag.className = "ui-workflow-overlay__project";
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
if (!projectId) return tag;
void fetchWorkflowState(projectId)
.then((state) => {