Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -65,6 +65,7 @@ export function createDesignDock(
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
row.dataset.helpRow = "";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
|
||||
@@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(cancelBtn, submitBtn);
|
||||
// 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞
|
||||
actions.append(createHelpButton(), cancelBtn, submitBtn);
|
||||
const card = createCard({ body: [grid, actions], raised: true });
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
|
||||
@@ -1,77 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Guide.ts
|
||||
* 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시).
|
||||
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
|
||||
*
|
||||
* 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어
|
||||
* 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와
|
||||
* 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로
|
||||
* 옮기고, 본문은 고르는 자리만 남긴다.
|
||||
*
|
||||
* 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시):
|
||||
* 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에
|
||||
* `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다.
|
||||
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로
|
||||
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */
|
||||
function section(
|
||||
titleKey: keyof typeof ui_locales,
|
||||
itemKeys: (keyof typeof ui_locales)[],
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "b03-file__guide-legend ui-collapsible__title";
|
||||
title.textContent = L(titleKey);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b03-file__guide-list";
|
||||
for (const key of itemKeys) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = L(key);
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
group.append(title, list);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것.
|
||||
*
|
||||
* `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라
|
||||
* 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다
|
||||
* (2026-09-03 사용자 지시).
|
||||
*/
|
||||
/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */
|
||||
export function createInputGuide(statusNote?: HTMLElement): HTMLElement {
|
||||
const guide = document.createElement("div");
|
||||
guide.className = "b03-file__form";
|
||||
const howTo = section("B03_Guide_How_Title", [
|
||||
"B03_Guide_How_Drop",
|
||||
"B03_Guide_How_Card",
|
||||
"B03_Guide_How_Temp",
|
||||
]);
|
||||
if (statusNote) howTo.append(statusNote);
|
||||
guide.append(
|
||||
section("B03_Guide_Files_Title", [
|
||||
"B03_Guide_Files_Route",
|
||||
"B03_Guide_Files_Terrain",
|
||||
"B03_Guide_Files_Optional",
|
||||
]),
|
||||
howTo,
|
||||
section("B03_Guide_Notes_Title", [
|
||||
"B03_Guide_Notes_Replace",
|
||||
"B03_Guide_Notes_Crs",
|
||||
"B03_Guide_Notes_LasFree",
|
||||
]),
|
||||
);
|
||||
attachCollapsible(guide);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
if (statusNote) guide.append(statusNote);
|
||||
guide.append(actions);
|
||||
return guide;
|
||||
}
|
||||
|
||||
@@ -310,10 +310,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리.
|
||||
양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은
|
||||
`ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은
|
||||
`__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */
|
||||
/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */
|
||||
.b03-file__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -321,36 +318,6 @@
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b03-file__guide-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b03-file__guide-legend {
|
||||
margin: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은
|
||||
`--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */
|
||||
.b03-file__guide-list {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -373,16 +373,11 @@ export function createFacilityOptionsForm(
|
||||
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
||||
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
|
||||
|
||||
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
|
||||
const saveNote = document.createElement("p");
|
||||
saveNote.className = "b05-structure__owner-note";
|
||||
saveNote.textContent =
|
||||
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
|
||||
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
||||
|
||||
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||
root.append(
|
||||
saveNote,
|
||||
pipeForm.root,
|
||||
fordForm.root,
|
||||
paveForm.root,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||||
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
|
||||
|
||||
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -245,39 +244,18 @@ export interface RevetSideGroup {
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
|
||||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
|
||||
function sabangBasisLines(): string[] {
|
||||
const { items, scope } = revetmentSabang;
|
||||
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
|
||||
return [
|
||||
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
|
||||
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
|
||||
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
|
||||
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
|
||||
scope,
|
||||
];
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
|
||||
const fields = createRevetmentFields(keys, { height: "" });
|
||||
const basis = document.createElement("p");
|
||||
basis.className = "b05-route__note";
|
||||
basis.replaceChildren(
|
||||
...sabangBasisLines().flatMap((line, index) =>
|
||||
index ? [document.createElement("br"), line] : [line],
|
||||
),
|
||||
);
|
||||
// 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2)
|
||||
const slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
box.body.append(
|
||||
grid(labeled("구조", kind), fields.formField),
|
||||
...fields.rows,
|
||||
basis,
|
||||
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
|
||||
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
|
||||
slot,
|
||||
|
||||
@@ -9,23 +9,13 @@
|
||||
|
||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
||||
|
||||
export function field(
|
||||
labelText: string,
|
||||
input: HTMLElement,
|
||||
basis?: string | null,
|
||||
): HTMLLabelElement {
|
||||
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = labelText;
|
||||
// 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2)
|
||||
wrapper.append(caption, input);
|
||||
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
|
||||
if (basis) {
|
||||
const note = document.createElement("small");
|
||||
note.className = "b05-structure__basis";
|
||||
note.textContent = basis;
|
||||
wrapper.append(note);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ export interface StructuresFormElements {
|
||||
body: HTMLElement;
|
||||
groupSelect: HTMLSelectElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
|
||||
ownerNote: HTMLElement;
|
||||
startFields: StationFields;
|
||||
anchorFields: StationFields;
|
||||
endFields: StationFields;
|
||||
@@ -113,13 +111,6 @@ export function buildStructuresForm(options: {
|
||||
typeRow.className = "b05-structure__grid";
|
||||
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
||||
|
||||
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
|
||||
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
|
||||
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
|
||||
const ownerNote = document.createElement("p");
|
||||
ownerNote.className = "b05-structure__owner-note";
|
||||
ownerNote.hidden = true;
|
||||
|
||||
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
||||
const optionRow = document.createElement("div");
|
||||
optionRow.className = "b05-structure__grid";
|
||||
@@ -178,7 +169,6 @@ export function buildStructuresForm(options: {
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
positionRow,
|
||||
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
|
||||
positionDivider,
|
||||
@@ -195,7 +185,6 @@ export function buildStructuresForm(options: {
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
|
||||
@@ -78,7 +78,6 @@ export function createStructuresSection(
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
@@ -233,12 +232,8 @@ export function createStructuresSection(
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
|
||||
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
|
||||
ownerNote.textContent = type?.design_owner
|
||||
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
|
||||
: "";
|
||||
ownerNote.hidden = !type?.design_owner;
|
||||
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
|
||||
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
|
||||
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
|
||||
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
|
||||
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
||||
|
||||
@@ -101,11 +101,7 @@ export function renderOptionFields(
|
||||
// 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18)
|
||||
const grouped =
|
||||
option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key));
|
||||
const wrap = field(
|
||||
label,
|
||||
grouped ? stepper(input as HTMLInputElement, 0.1) : input,
|
||||
option.default_basis,
|
||||
);
|
||||
const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input);
|
||||
if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게)
|
||||
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
|
||||
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
|
||||
|
||||
@@ -215,13 +215,6 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
|
||||
.b05-structure__basis {
|
||||
color: var(--color-text-muted, #9aa1ad);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
|
||||
@@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement {
|
||||
text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
|
||||
);
|
||||
|
||||
// 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2)
|
||||
wrap.append(svg);
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b06-std__diagram-caption";
|
||||
caption.textContent = L("B06_Std_Diagram_Caption");
|
||||
wrap.append(caption);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ export function createStandardPanel(
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-std";
|
||||
root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김)
|
||||
|
||||
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
|
||||
root.append(buildStandardDiagram());
|
||||
@@ -367,9 +368,6 @@ export function createStandardPanel(
|
||||
const legend = document.createElement("summary");
|
||||
legend.className = "b06-std__legend";
|
||||
legend.textContent = L("B06_Std_Detail_Title");
|
||||
const note = document.createElement("p");
|
||||
note.className = "b06-std__note";
|
||||
note.textContent = L("B06_Std_LType_Note");
|
||||
fieldset.append(
|
||||
legend,
|
||||
buildScope("common"),
|
||||
@@ -377,7 +375,6 @@ export function createStandardPanel(
|
||||
buildScope("rock"),
|
||||
buildDivider("B06_Std_Section_PavedOnly"),
|
||||
buildScope("paved"),
|
||||
note,
|
||||
);
|
||||
return fieldset;
|
||||
};
|
||||
|
||||
@@ -173,12 +173,6 @@
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-std__diagram-caption {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
|
||||
.b06-std__loader {
|
||||
display: flex;
|
||||
@@ -307,12 +301,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-std__note {
|
||||
margin: 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b06-std__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
@@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
|
||||
const confirmButtonRow = document.createElement("div");
|
||||
confirmButtonRow.className = "b07-drawing-actions__row";
|
||||
confirmButtonRow.dataset.helpRow = "";
|
||||
confirmButtonRow.append(frameEditor.button, confirmButton);
|
||||
confirmActions.append(frameEditor.banner, confirmButtonRow);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌.
|
||||
|
||||
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
|
||||
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
|
||||
PAGE_RE = re.compile(r"^B0[2-9]$")
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
if not PAGE_RE.match(page):
|
||||
raise HTTPException(status_code=404, detail="없는 페이지")
|
||||
folder = root / page
|
||||
items = []
|
||||
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
|
||||
items.append({"key": path.stem, "title": head or path.stem, "text": text})
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{page}")
|
||||
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
|
||||
return {"items": read_help(page)}
|
||||
@@ -81,6 +81,7 @@ from common_util.common_util_auth import (
|
||||
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
||||
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
||||
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
||||
from common_util.common_util_help_router import router as help_router
|
||||
from common_util.common_util_resource_monitor import sample_resources_loop
|
||||
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
|
||||
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
||||
@@ -661,6 +662,8 @@ app.include_router(m02_project_items_router, dependencies=protected)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
# 그 위에 서버가 환경까지 한 번 더 본다.
|
||||
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||
# [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음.
|
||||
app.include_router(help_router, dependencies=protected)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 프로젝트 등록 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 파일입력 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 필요한 파일
|
||||
|
||||
- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.
|
||||
- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.
|
||||
- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 고르는 방법
|
||||
|
||||
- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.
|
||||
- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.
|
||||
- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 알아 둘 것
|
||||
|
||||
- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.
|
||||
- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.
|
||||
- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 전처리 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 종단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · BOX암거
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,13 @@
|
||||
# 구조물 · 기슭막이
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
|
||||
## 치수 근거
|
||||
|
||||
- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감)
|
||||
- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사
|
||||
- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식
|
||||
- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171
|
||||
- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(메)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(찰)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 물넘이포장
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 배관
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 비탈면 녹화
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 세월교
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 옹벽
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
@@ -0,0 +1,3 @@
|
||||
# 구조물 · 측구(옆도랑)
|
||||
|
||||
- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 큰돌쌓기
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,9 @@
|
||||
# 구조물 · 흙막이
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)
|
||||
- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음
|
||||
@@ -0,0 +1,9 @@
|
||||
# 횡단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 표준 횡단면 설정
|
||||
|
||||
## 변수 위치 안내
|
||||
|
||||
- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).
|
||||
|
||||
## 표준횡단면 상세값
|
||||
|
||||
- L형 측구는 각 횡단면도에서 선택합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 상세설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 수량산출 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 원가계산 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,38 @@
|
||||
"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜)."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from common_util.common_util_help_router import HELP_ROOT, read_help
|
||||
|
||||
|
||||
def test_every_page_has_manual_first():
|
||||
for n in range(2, 10):
|
||||
items = read_help(f"B0{n}")
|
||||
assert items and items[0]["key"] == "00_설명서"
|
||||
|
||||
|
||||
def test_title_order_and_live_edit():
|
||||
tmp_path = Path(tempfile.mkdtemp())
|
||||
page = tmp_path / "B05"
|
||||
page.mkdir()
|
||||
(page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8")
|
||||
(page / "00_설명서.md").write_text("제목 없음", encoding="utf-8")
|
||||
items = read_help("B05", tmp_path)
|
||||
assert [i["key"] for i in items] == ["00_설명서", "10_배관"]
|
||||
assert [i["title"] for i in items] == ["00_설명서", "배관"]
|
||||
(page / "10_배관.md").write_text("# 배관\n\n고친 글", encoding="utf-8")
|
||||
assert "고친 글" in read_help("B05", tmp_path)[1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"])
|
||||
def test_bad_page_refused(page):
|
||||
with pytest.raises(HTTPException):
|
||||
read_help(page)
|
||||
|
||||
|
||||
def test_root_is_repo_resources():
|
||||
assert HELP_ROOT.parts[-2:] == ("resources", "help")
|
||||
@@ -0,0 +1,114 @@
|
||||
/* 54-2 — [?] 설명서: B02~B09 좌측 패널 아래 단추 묶음 맨 왼쪽 · 모달 = 왼쪽 차례 · 오른쪽 파일 글 ·
|
||||
* 구조물 종류를 고르면 그 항목 먼저 · 패널 안 설명 글 0
|
||||
* 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트)
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로
|
||||
* `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번
|
||||
* 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로)
|
||||
* 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 →
|
||||
* `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null)
|
||||
* 기대 수치:
|
||||
* - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) ·
|
||||
* modal = true · toc[0] = 「… 설명서」 · text = 그 설명서 제목 글
|
||||
* - B03 toc = 설명서 + 필요한 파일 · 고르는 방법 · 알아 둘 것 · B06 toc 에 「표준 횡단면 설정」
|
||||
* - pipeTopic = 「구조물 · 배관」(B05 에서 배관을 고른 채 [?]) · leftovers = 0(옮긴 글이 패널에 없음)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 15000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const MOVED = [
|
||||
"[저장]은 이 폼에 있는 칸만",
|
||||
"에서 관리 — 여기서 넣어도",
|
||||
"사방기술교본",
|
||||
"표준 편절편성 단면 모식도",
|
||||
"L형 측구는 각 횡단면도에서",
|
||||
"끌어 놓으면 확장자로",
|
||||
"shapefile 한 벌",
|
||||
"작업 좌표계는 라이다 PRJ",
|
||||
"칸이 생기기 전의 동작",
|
||||
"소광리 도면 H=2.0",
|
||||
];
|
||||
const start = location.hash;
|
||||
const vis = (n) => n && n.offsetParent !== null;
|
||||
const leftText = () =>
|
||||
document.querySelector(".ui-workflow-overlay__panel--title")?.innerText ?? "";
|
||||
const openModal = async (btn) => {
|
||||
btn.click();
|
||||
const box = await until(() => document.querySelector(".ui-help .ui-help__text h2"));
|
||||
const res = {
|
||||
modal: !!box,
|
||||
toc: [...document.querySelectorAll(".ui-help__link")].map((b) => b.innerText.trim()),
|
||||
text: box?.innerText ?? null,
|
||||
active: document.querySelector(".ui-help__text")?.dataset.helpKey ?? null,
|
||||
};
|
||||
const temp = [...document.querySelectorAll(".ui-help__link")].find((b) =>
|
||||
b.dataset.helpKey?.startsWith("99_"),
|
||||
);
|
||||
if (temp) {
|
||||
temp.click();
|
||||
res.temp = document.querySelector(".ui-help__text p")?.innerText ?? null;
|
||||
}
|
||||
document.querySelector(".ui-help .ui-modal__top button")?.click();
|
||||
await w(200);
|
||||
res.closed = !document.querySelector(".ui-help");
|
||||
return res;
|
||||
};
|
||||
const out = { pages: [] };
|
||||
// orca eval 은 한 번에 1분쯤에서 끊김 — 앞에 `window.__help542 = ["b05-profile", …];` 를 붙이면 그 화면만
|
||||
const routes = window.__help542 ?? [
|
||||
"b02-proj-register",
|
||||
"b03-file-input",
|
||||
"b04-preprocess",
|
||||
"b05-profile",
|
||||
"b06-section",
|
||||
"b07-design-detail",
|
||||
"b08-quantity",
|
||||
"b09-estimation",
|
||||
];
|
||||
let prev = null;
|
||||
for (const route of routes) {
|
||||
location.hash = `#/${route}`;
|
||||
await w(3000); // 불러오는 동안의 임시 틀(B05 등)이 실제 화면으로 바뀔 때까지
|
||||
// 앞 화면 단추가 빠지고 이 화면 단추가 설 때까지
|
||||
const btn = await until(() =>
|
||||
[...document.querySelectorAll(".ui-help-btn")].find((b) => vis(b) && b !== prev),
|
||||
);
|
||||
prev = btn;
|
||||
await w(1500);
|
||||
const row = btn?.parentElement;
|
||||
const res = {
|
||||
page: route.slice(0, 3).toUpperCase(),
|
||||
btn: document.querySelectorAll(".ui-help-btn").length,
|
||||
first: !!row && row.firstElementChild === btn,
|
||||
inLeft: !!btn?.closest(".ui-sidebar-actions") || !!btn?.closest(".b02-proj__actions"),
|
||||
leftovers: MOVED.filter((s) => leftText().includes(s)),
|
||||
};
|
||||
if (btn) Object.assign(res, await openModal(btn));
|
||||
if (route === "b05-profile") {
|
||||
// 배관 하나를 골라 [?] — 구조물 종류 항목이 먼저 열림(이미 골라져 있으면 그대로 · 끝나면 처음 상태로)
|
||||
const picked = () => document.querySelector('[data-help-topic="구조물_배관"]');
|
||||
const was = !!picked();
|
||||
const item = await until(() =>
|
||||
[...document.querySelectorAll(".b05-route__irregular-item")].find((li) =>
|
||||
li.innerText.includes("배관"),
|
||||
),
|
||||
);
|
||||
if (!was) item?.click();
|
||||
await until(picked, 8000);
|
||||
await w(800);
|
||||
res.leftoversPipe = MOVED.filter((s) => leftText().includes(s));
|
||||
const again = await openModal([...document.querySelectorAll(".ui-help-btn")].find(vis));
|
||||
res.pipeTopic = again.text;
|
||||
if (!was) item?.click(); // 고름 풂
|
||||
}
|
||||
out.pages.push(res);
|
||||
}
|
||||
location.hash = start;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */
|
||||
.ui-sidebar-actions .ui-help-btn,
|
||||
.ui-help-btn {
|
||||
flex: none;
|
||||
width: auto;
|
||||
min-width: 2.25rem;
|
||||
padding-inline: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-help.ui-modal {
|
||||
width: min(960px, 92vw);
|
||||
height: var(--modal-max, 80vh);
|
||||
}
|
||||
|
||||
.ui-help .ui-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-help__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: var(--spacing-16);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-help__toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-right: var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ui-help__link {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-help__link:hover,
|
||||
.ui-help__link.is-active {
|
||||
background: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ui-help__text {
|
||||
overflow-y: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ui-help__text h2 {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.ui-help__text h3 {
|
||||
margin: var(--spacing-16) 0 var(--spacing-4);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.ui-help__text h4,
|
||||
.ui-help__text h5 {
|
||||
margin: var(--spacing-12) 0 var(--spacing-4);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.ui-help__text p,
|
||||
.ui-help__text ul,
|
||||
.ui-help__text ol {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-help__text ul,
|
||||
.ui-help__text ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =============================================================================
|
||||
* ui_template_help.ts
|
||||
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
|
||||
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
|
||||
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
|
||||
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
|
||||
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_help.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
interface HelpItem {
|
||||
key: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
|
||||
|
||||
function currentPage(): string {
|
||||
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
|
||||
return n ? `B0${n}` : "";
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string): string {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`(.+?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
|
||||
export function renderHelpMarkdown(text: string): string {
|
||||
const out: string[] = [];
|
||||
let list: "ul" | "ol" | null = null;
|
||||
let para: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
|
||||
para = [];
|
||||
if (list) out.push(`</${list}>`);
|
||||
list = null;
|
||||
};
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
const head = /^(#{1,4})\s+(.*)$/.exec(line);
|
||||
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
|
||||
if (!line) flush();
|
||||
else if (head) {
|
||||
flush();
|
||||
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
|
||||
} else if (item) {
|
||||
const kind = item[1] ? "ul" : "ol";
|
||||
if (para.length || list !== kind) flush();
|
||||
if (!list) out.push(`<${kind}>`);
|
||||
list = kind;
|
||||
out.push(`<li>${inline(item[2])}</li>`);
|
||||
} else {
|
||||
if (list) flush();
|
||||
para.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function visibleTopics(): string[] {
|
||||
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
|
||||
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
|
||||
.filter((node) => node.offsetParent !== null)
|
||||
.map((node) => node.dataset.helpTopic ?? "");
|
||||
}
|
||||
|
||||
async function fetchItems(page: string): Promise<HelpItem[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : [];
|
||||
}
|
||||
|
||||
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
|
||||
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
|
||||
|
||||
async function openHelp(): Promise<void> {
|
||||
const page = currentPage();
|
||||
const pair = PAIR[page];
|
||||
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
|
||||
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
|
||||
const topics = visibleTopics();
|
||||
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
|
||||
openModal({
|
||||
title: `${TEXT.title} · ${page}`,
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "ui-help",
|
||||
mount: (body) => {
|
||||
const article = el("article", { className: "ui-help__text" });
|
||||
const toc = el("nav", { className: "ui-help__toc" });
|
||||
const show = (key: string): void => {
|
||||
const item = items.find((entry) => entry.key === key);
|
||||
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
|
||||
article.dataset.helpKey = key;
|
||||
toc
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
|
||||
};
|
||||
for (const item of items) {
|
||||
const link = el("button", {
|
||||
className: "ui-help__link",
|
||||
text: item.title,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
link.dataset.helpKey = item.key;
|
||||
link.addEventListener("click", () => show(item.key));
|
||||
toc.append(link);
|
||||
}
|
||||
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
|
||||
show(first ?? items[0]?.key ?? "");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createHelpButton(): HTMLButtonElement {
|
||||
const button = createButton({
|
||||
label: TEXT.button,
|
||||
variant: "ghost",
|
||||
onClick: () => void openHelp(),
|
||||
});
|
||||
button.classList.add("ui-help-btn");
|
||||
button.title = button.ariaLabel = TEXT.tip;
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
|
||||
export function placeHelpButton(actions: HTMLElement): void {
|
||||
if (actions.querySelector(".ui-help-btn")) return;
|
||||
(actions.querySelector<HTMLElement>("[data-help-row]") ?? actions).prepend(createHelpButton());
|
||||
}
|
||||
@@ -228,46 +228,6 @@ export const ui_locales_b1 = {
|
||||
"Upload the required planned route, terrain, and point cloud files.",
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
/* --- 좌측 안내 패널 (2026-09-03) --- */
|
||||
B03_Guide_Files_Title: ["필요한 파일", "Files you need"],
|
||||
B03_Guide_Files_Route: [
|
||||
"계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.",
|
||||
"Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).",
|
||||
],
|
||||
B03_Guide_Files_Terrain: [
|
||||
"지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.",
|
||||
"Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.",
|
||||
],
|
||||
B03_Guide_Files_Optional: [
|
||||
"지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.",
|
||||
"The terrain TIF (DEM) is optional — surface analysis runs without it.",
|
||||
],
|
||||
B03_Guide_How_Title: ["고르는 방법", "How to pick"],
|
||||
B03_Guide_How_Drop: [
|
||||
"여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.",
|
||||
"Drop several files at once — each is assigned to a card by its extension.",
|
||||
],
|
||||
B03_Guide_How_Card: [
|
||||
"카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.",
|
||||
"Use a card's [Select] to set one slot, or ✕ to clear it.",
|
||||
],
|
||||
B03_Guide_How_Temp: [
|
||||
"[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.",
|
||||
"[Temporary storage] pulls files you staged on the dashboard.",
|
||||
],
|
||||
B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"],
|
||||
B03_Guide_Notes_Replace: [
|
||||
"이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.",
|
||||
"Uploading again to a finished project replaces the existing analysis.",
|
||||
],
|
||||
B03_Guide_Notes_Crs: [
|
||||
"작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.",
|
||||
"The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.",
|
||||
],
|
||||
B03_Guide_Notes_LasFree: [
|
||||
"라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.",
|
||||
"With no LiDAR, turn on [Design without LAS] and work from sheet contours.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
B03_File_Upload_Button: ["파일 업로드", "Upload files"],
|
||||
|
||||
@@ -528,10 +528,6 @@ export const ui_locales_b2 = {
|
||||
B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"],
|
||||
B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"],
|
||||
B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"],
|
||||
B06_Std_LType_Note: [
|
||||
"L형 측구는 각 횡단면도에서 선택합니다.",
|
||||
"L-type ditch is chosen per cross-section drawing.",
|
||||
],
|
||||
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
|
||||
B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"],
|
||||
B06_Std_ApplyAll_Success: [
|
||||
@@ -563,10 +559,6 @@ export const ui_locales_b2 = {
|
||||
B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
|
||||
B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
|
||||
B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
|
||||
B06_Std_Diagram_Caption: [
|
||||
"표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
|
||||
"Standard cut-fill section schematic — shows where each value applies (not to scale).",
|
||||
],
|
||||
|
||||
/* --- B07_DesignDetail 상세 설계 --- */
|
||||
B07_Design_Title: ["상세설계", "Detail Design"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./ui_template_overlay.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 실측). 그 창구가 짧은 시간 동안 캐시한다.
|
||||
@@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void {
|
||||
while (container.firstChild && container.firstChild !== actions)
|
||||
scroll.append(container.firstChild);
|
||||
container.prepend(scroll);
|
||||
placeHelpButton(actions);
|
||||
body.classList.add("ui-sidebar-split");
|
||||
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
|
||||
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
|
||||
|
||||
@@ -162,6 +162,12 @@ export function createStepBar(
|
||||
return bar;
|
||||
}
|
||||
|
||||
function helpOnlyRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ui-sidebar-actions";
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-workflow-layout";
|
||||
@@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
||||
});
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: options.title,
|
||||
optionsContent: options.leftPanel,
|
||||
// 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2)
|
||||
optionsContent: options.leftPanel ?? helpOnlyRow(),
|
||||
progressContent,
|
||||
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user