feat(m02-df): 다른 로직 입력 = 마스터 로직 고르기 — 원문 · 장 · 찾기 · 쪽 · 순환 막음 · 값은 서버 길
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
@@ -206,6 +206,25 @@ export async function cfLogicValue(
|
||||
return send("/logic-value", { key, designs });
|
||||
}
|
||||
|
||||
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
|
||||
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
|
||||
export async function cfLogicFlow(
|
||||
file: string,
|
||||
key: string,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
throw new CfError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
|
||||
return data as { flow: CfLogic; converted: boolean; notes: string[] };
|
||||
}
|
||||
|
||||
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
|
||||
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
|
||||
const qs = new URLSearchParams({ key }).toString();
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Logic.ts
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
|
||||
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
|
||||
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
|
||||
import {
|
||||
filterLogics,
|
||||
logicBooks,
|
||||
logicChapters,
|
||||
logicRefOf,
|
||||
masterRef,
|
||||
pageRange,
|
||||
setKind,
|
||||
setLogicInput,
|
||||
wouldCycle,
|
||||
type MasterLogicBrief,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Help: [
|
||||
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use a saved logic or sample as an input",
|
||||
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use any master logic or a logic of mine as an input",
|
||||
],
|
||||
Search: ["로직 이름으로 찾기", "Search by name"],
|
||||
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
Book: ["원문", "Source"],
|
||||
Chapter: ["장", "Chapter"],
|
||||
All: ["전체", "All"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 로직 없음", "No logic found"],
|
||||
Sample: ["견본", "Sample"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
Blocked: ["읽을 수 없음", "Unreadable"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
|
||||
Converted: [
|
||||
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
|
||||
"Old logic read as a flow — the file is unchanged",
|
||||
],
|
||||
Notes: ["못 옮긴 곳", "Not carried over"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
|
||||
Designs: [
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
|
||||
"Pills it takes — its design inputs",
|
||||
],
|
||||
NoOut: ["출력 상자가 없음", "No output box"],
|
||||
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
|
||||
Change: ["다른 로직 고르기", "Pick another"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
|
||||
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
|
||||
Cycle: [
|
||||
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
|
||||
"That would loop — a logic that uses this one can't be picked",
|
||||
],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
Wait: ["값 셈 중…", "Calculating…"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
const plain = (e: unknown): string => {
|
||||
try {
|
||||
@@ -46,6 +87,40 @@ const plain = (e: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const PAGE = 12;
|
||||
const MINE = "흐름도";
|
||||
|
||||
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
|
||||
let cache: Promise<MasterLogicBrief[]> | null = null;
|
||||
const loadAll = (): Promise<MasterLogicBrief[]> =>
|
||||
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
|
||||
.then(([master, mine]) => [
|
||||
...mine
|
||||
.filter((i) => !i.sample)
|
||||
.map((i): MasterLogicBrief => ({
|
||||
file: MINE,
|
||||
구분: MINE,
|
||||
상세구분: t("Mine"),
|
||||
키: i.key,
|
||||
원문번호: "",
|
||||
이름: i.name,
|
||||
결과단위: i.resultUnit,
|
||||
})),
|
||||
...master,
|
||||
])
|
||||
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
|
||||
|
||||
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
|
||||
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
|
||||
|
||||
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
|
||||
async function flowOf(
|
||||
item: Pick<MasterLogicBrief, "file" | "키">,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
|
||||
return cfLogicFlow(item.file, item.키);
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 판 */
|
||||
export function renderLogicInput(
|
||||
host: HTMLElement,
|
||||
@@ -55,32 +130,86 @@ export function renderLogicInput(
|
||||
): void {
|
||||
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
|
||||
if (ref) return void showChosen(host, lg, boxId, ref, on);
|
||||
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
let all: CfListItem[] = [];
|
||||
const draw = (): void => {
|
||||
const word = q.input.value.trim().toLowerCase();
|
||||
const rows = all.filter(
|
||||
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
|
||||
const all = { value: "", text: t("All") };
|
||||
let book = "";
|
||||
let chapter = "";
|
||||
let page = 1;
|
||||
let items: MasterLogicBrief[] = [];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
|
||||
});
|
||||
const chapterBox = createSelectField({
|
||||
label: t("Chapter"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((chapter = v), (page = 1), draw()),
|
||||
});
|
||||
const fillChapters = (): void =>
|
||||
chapterBox.setOptions(
|
||||
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
|
||||
chapter,
|
||||
);
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const info = el("span", { className: "m02-dfip__muted" });
|
||||
const prev = createButton({
|
||||
label: t("Prev"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page -= 1), draw()),
|
||||
});
|
||||
const next = createButton({
|
||||
label: t("Next"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page += 1), draw()),
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
const rows = filterLogics(
|
||||
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
|
||||
{ text: q.input.value, book, chapter },
|
||||
);
|
||||
const range = pageRange(rows.length, page, PAGE);
|
||||
page = Math.min(page, range.pages);
|
||||
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
|
||||
info.textContent = t("Total", {
|
||||
n: rows.length,
|
||||
a: range.from,
|
||||
b: range.to,
|
||||
p: page,
|
||||
m: range.pages,
|
||||
});
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= range.pages;
|
||||
list.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map((i) => {
|
||||
...(shown.length
|
||||
? shown.map((i) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
attrs: {
|
||||
type: "button",
|
||||
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
|
||||
},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: i.name }),
|
||||
el("strong", { text: i.이름 }),
|
||||
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
|
||||
el("span", {
|
||||
className: "m02-dfip__tag",
|
||||
text: i.sample ? t("Sample") : t("Mine"),
|
||||
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
|
||||
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pick(i));
|
||||
@@ -89,56 +218,114 @@ export function renderLogicInput(
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
const pick = async (i: CfListItem): Promise<void> => {
|
||||
|
||||
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
|
||||
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
|
||||
const it = items.find((x) => x.키 === key);
|
||||
return it ? (await flowOf(it)).flow : null;
|
||||
};
|
||||
|
||||
const pick = async (i: MasterLogicBrief): Promise<void> => {
|
||||
try {
|
||||
const other = await cfGet(i.key);
|
||||
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
|
||||
if (i.키 === lg.key) return void showToast(t("Self"), "error");
|
||||
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
|
||||
const got = await flowOf(i);
|
||||
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
|
||||
const picked = got.converted
|
||||
? masterRef(i.키, got.flow, true, i.결과단위)
|
||||
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
|
||||
const done = setLogicInput(lg, boxId, picked);
|
||||
// 기본 이름이면 고른 로직 이름으로
|
||||
on.change({
|
||||
...next,
|
||||
boxes: next.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
|
||||
...done,
|
||||
boxes: done.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(plain(e), "error");
|
||||
}
|
||||
};
|
||||
q.input.addEventListener("input", draw);
|
||||
|
||||
let timer = 0;
|
||||
q.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => ((page = 1), draw()), 200);
|
||||
});
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
|
||||
void cfList().then(
|
||||
(items) => ((all = items), draw()),
|
||||
host.replaceChildren(
|
||||
el("p", { className: "m02-dfip__muted", text: t("Help") }),
|
||||
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
|
||||
info,
|
||||
list,
|
||||
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
||||
);
|
||||
void loadAll().then(
|
||||
(rows) => {
|
||||
items = rows;
|
||||
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
|
||||
fillChapters();
|
||||
draw();
|
||||
},
|
||||
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
|
||||
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
|
||||
ref: CfLogicRef,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
if (!ref) return;
|
||||
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
|
||||
const read = readNotes.get(ref.key);
|
||||
const values = new Map<string, HTMLElement>();
|
||||
const errors = el("p", { className: "m02-dfip__muted is-bad" });
|
||||
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: p.name }),
|
||||
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
|
||||
el("span", { className: "m02-dfip__name", text: name }),
|
||||
...(valueNode
|
||||
? [valueNode]
|
||||
: extra
|
||||
? [el("span", { className: "m02-dfip__muted", text: extra })]
|
||||
: []),
|
||||
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
||||
...(read?.converted
|
||||
? [
|
||||
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
|
||||
...(read.notes.length
|
||||
? [
|
||||
el("details", {
|
||||
children: [
|
||||
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
|
||||
...read.notes.map((n) => el("p", { text: n })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
|
||||
...(ref.outputs.length
|
||||
? ref.outputs.map(pillLine)
|
||||
? ref.outputs.map((o) => {
|
||||
const v = el("strong", { className: "m02-dfip__value", text: "—" });
|
||||
values.set(o.name, v);
|
||||
return line(o.name, o.unit, v);
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
|
||||
errors,
|
||||
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
||||
...(ref.designs.length
|
||||
? ref.designs.map(pillLine)
|
||||
? ref.designs.map((d) =>
|
||||
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
|
||||
)
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
@@ -158,4 +345,17 @@ function showChosen(
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 값 — 서버 계산 길(받는 알약 예시값으로)
|
||||
errors.textContent = t("Wait");
|
||||
void cfLogicValue(ref.key, ref.examples ?? {}).then(
|
||||
(r) => {
|
||||
for (const [name, node] of values) {
|
||||
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
|
||||
if (typeof v === "number") node.textContent = fmt(v);
|
||||
else if (typeof v === "string") node.textContent = v;
|
||||
}
|
||||
errors.textContent = r.errors.join(" · ");
|
||||
},
|
||||
(e) => (errors.textContent = plain(e)),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user