From 3667d642435689c8804d9a66def956a007b5d0b0 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 19 May 2026 09:56:26 +0800 Subject: [PATCH] Add P10 ops config UI: stations + segments CRUD Adds Monitor/Config tab switcher. Config view splits into stations panel (inline create/edit/delete + per-station signal binding expand) and segments panel (inline create/edit/delete + expandable detail with step add/delete, interlock add/delete, and resource-keys replace). UI talks to the existing ops CRUD endpoints exclusively; no engine changes. node --check passes for all eight ops JS modules; backend tests still green. Browser verification still required end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/ops/html/config-panel.html | 24 ++ web/ops/index.html | 12 +- web/ops/js/api.js | 88 +++++- web/ops/js/app.js | 6 + web/ops/js/dom.js | 24 ++ web/ops/js/segments-config.js | 523 +++++++++++++++++++++++++++++++++ web/ops/js/segments.js | 4 +- web/ops/js/stations.js | 314 ++++++++++++++++++++ web/ops/js/views.js | 30 ++ web/ops/ops-styles.css | 239 ++++++++++++++- 10 files changed, 1245 insertions(+), 19 deletions(-) create mode 100644 web/ops/html/config-panel.html create mode 100644 web/ops/js/dom.js create mode 100644 web/ops/js/segments-config.js create mode 100644 web/ops/js/stations.js create mode 100644 web/ops/js/views.js diff --git a/web/ops/html/config-panel.html b/web/ops/html/config-panel.html new file mode 100644 index 0000000..a9c5d3c --- /dev/null +++ b/web/ops/html/config-panel.html @@ -0,0 +1,24 @@ +
+
+
+
+

工位

+
+ + +
+
+
+
+
+
+

+
+ + +
+
+
+
+
+
diff --git a/web/ops/index.html b/web/ops/index.html index 05b15b4..57f7d07 100644 --- a/web/ops/index.html +++ b/web/ops/index.html @@ -10,8 +10,18 @@
+ +
-
+
+
+
+
diff --git a/web/ops/js/api.js b/web/ops/js/api.js index 987b8c4..3c57a4f 100644 --- a/web/ops/js/api.js +++ b/web/ops/js/api.js @@ -13,21 +13,81 @@ async function jsonOrThrow(response, fallbackMessage) { throw new Error(`${fallbackMessage}: ${response.status} ${detail || response.statusText}`); } -export async function fetchOverview() { - const response = await fetch("/api/runtime/overview"); - return jsonOrThrow(response, "加载段运行态失败"); -} - -async function postControl(path, label) { - const response = await fetch(path, { method: "POST" }); +async function get(path, label) { + const response = await fetch(path); return jsonOrThrow(response, label); } -export const segmentControl = { - startAuto: (id) => postControl(`/api/control/segment/${id}/start-auto`, "启动自动控制失败"), - stopAuto: (id) => postControl(`/api/control/segment/${id}/stop-auto`, "停止自动控制失败"), - ackFault: (id) => postControl(`/api/control/segment/${id}/ack-fault`, "故障确认失败"), - reset: (id) => postControl(`/api/control/segment/${id}/reset`, "复位失败"), - batchStart: () => postControl(`/api/control/segment/batch-start-auto`, "批量启动失败"), - batchStop: () => postControl(`/api/control/segment/batch-stop-auto`, "批量停止失败"), +async function postJson(path, body, label) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return jsonOrThrow(response, label); +} + +async function putJson(path, body, label) { + const response = await fetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return jsonOrThrow(response, label); +} + +async function del(path, label) { + const response = await fetch(path, { method: "DELETE" }); + return jsonOrThrow(response, label); +} + +export const runtimeApi = { + fetchOverview: () => get("/api/runtime/overview", "加载段运行态失败"), +}; + +export const segmentControl = { + startAuto: (id) => postJson(`/api/control/segment/${id}/start-auto`, undefined, "启动自动控制失败"), + stopAuto: (id) => postJson(`/api/control/segment/${id}/stop-auto`, undefined, "停止自动控制失败"), + ackFault: (id) => postJson(`/api/control/segment/${id}/ack-fault`, undefined, "故障确认失败"), + reset: (id) => postJson(`/api/control/segment/${id}/reset`, undefined, "复位失败"), + batchStart: () => postJson(`/api/control/segment/batch-start-auto`, undefined, "批量启动失败"), + batchStop: () => postJson(`/api/control/segment/batch-stop-auto`, undefined, "批量停止失败"), +}; + +export const stationApi = { + list: (lineCode) => { + const q = lineCode ? `?line_code=${encodeURIComponent(lineCode)}` : ""; + return get(`/api/station${q}`, "加载工位失败"); + }, + detail: (id) => get(`/api/station/${id}`, "加载工位详情失败"), + create: (payload) => postJson("/api/station", payload, "新增工位失败"), + update: (id, payload) => putJson(`/api/station/${id}`, payload, "更新工位失败"), + remove: (id) => del(`/api/station/${id}`, "删除工位失败"), + upsertSignal: (id, payload) => + postJson(`/api/station/${id}/signal`, payload, "绑定工位信号失败"), + deleteSignal: (id, role) => + del(`/api/station/${id}/signal/${encodeURIComponent(role)}`, "解除工位信号绑定失败"), +}; + +export const segmentApi = { + list: (lineCode) => { + const q = lineCode ? `?line_code=${encodeURIComponent(lineCode)}` : ""; + return get(`/api/segment${q}`, "加载段配置失败"); + }, + detail: (id) => get(`/api/segment/${id}/detail`, "加载段详情失败"), + create: (payload) => postJson("/api/segment", payload, "新增段失败"), + update: (id, payload) => putJson(`/api/segment/${id}`, payload, "更新段失败"), + remove: (id) => del(`/api/segment/${id}`, "删除段失败"), + createStep: (id, payload) => + postJson(`/api/segment/${id}/step`, payload, "新增步骤失败"), + updateStep: (id, stepNo, payload) => + putJson(`/api/segment/${id}/step/${stepNo}`, payload, "更新步骤失败"), + deleteStep: (id, stepNo) => + del(`/api/segment/${id}/step/${stepNo}`, "删除步骤失败"), + createInterlock: (id, payload) => + postJson(`/api/segment/${id}/interlock`, payload, "新增联锁失败"), + deleteInterlock: (id, interlockId) => + del(`/api/segment/${id}/interlock/${interlockId}`, "删除联锁失败"), + replaceResources: (id, keys) => + putJson(`/api/segment/${id}/resource`, { resource_keys: keys }, "更新资源声明失败"), }; diff --git a/web/ops/js/app.js b/web/ops/js/app.js index 587a0ac..1d3302e 100644 --- a/web/ops/js/app.js +++ b/web/ops/js/app.js @@ -1,8 +1,14 @@ import { bindSegmentEvents, loadSegments } from "./segments.js"; +import { bindSegmentConfigEvents } from "./segments-config.js"; +import { bindStationEvents } from "./stations.js"; +import { bindViewTabs } from "./views.js"; import { startOpsSocket } from "./ws.js"; async function bootstrap() { + bindViewTabs(); bindSegmentEvents(); + bindStationEvents(); + bindSegmentConfigEvents(); startOpsSocket(); try { await loadSegments(); diff --git a/web/ops/js/dom.js b/web/ops/js/dom.js new file mode 100644 index 0000000..8aa1021 --- /dev/null +++ b/web/ops/js/dom.js @@ -0,0 +1,24 @@ +/// Tiny DOM helpers shared across modules. + +export function el(id) { + return document.getElementById(id); +} + +export function escapeHtml(text) { + if (text === null || text === undefined) return ""; + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +export function setBanner(container, message, level = "info") { + if (!container) return; + const existing = container.querySelector(".ops-banner"); + if (existing) existing.remove(); + const div = document.createElement("div"); + div.className = `ops-banner banner-${level}`; + div.textContent = message; + container.prepend(div); + window.setTimeout(() => div.remove(), 4000); +} diff --git a/web/ops/js/segments-config.js b/web/ops/js/segments-config.js new file mode 100644 index 0000000..4b48ee6 --- /dev/null +++ b/web/ops/js/segments-config.js @@ -0,0 +1,523 @@ +import { segmentApi } from "./api.js"; +import { el, escapeHtml, setBanner } from "./dom.js"; + +const SEGMENT_TYPES = [ + "front_load", + "robot", + "front_release", + "front_transfer", + "kiln_infeed", + "kiln_step", + "kiln_outfeed", + "tail_transfer", + "tail_step", + "unload", + "return", +]; + +const SEGMENT_MODES = ["auto", "remote_manual", "local_manual", "disabled"]; + +const ACTION_KINDS = [ + "open_door", + "close_door", + "push_forward", + "push_retract", + "pull_run", + "pull_retract", + "transfer_move_to", + "step_once", + "robot_permit", + "robot_release", + "wait_signal", + "pulse_cmd", +]; + +const ON_TIMEOUT = ["fault", "retry", "block"]; + +const APPLIES_TO = ["start_allow", "start_deny", "run_halt"]; + +const RULE_KINDS = [ + "point_eq", + "station_vacant", + "station_occupied", + "equipment_origin", + "equipment_no_fault", + "equipment_remote", + "safety_chain_ok", +]; + +const segments = new Map(); +const segmentDetails = new Map(); // id -> { segment, steps, interlocks, resources } +const expanded = new Set(); +let editing = null; +let creating = false; + +function renderSegmentForm(initial) { + const data = initial || {}; + return ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ `; +} + +function renderStepRow(step) { + return ` + + ${step.step_no} + ${escapeHtml(step.step_code)} + ${escapeHtml(step.action_kind)} + ${escapeHtml(step.target_equipment_id || "")} + ${escapeHtml(step.target_station_id || "")} + ${escapeHtml(step.confirm_signal_role || "")} + ${step.timeout_ms} + ${step.hold_until_confirm ? "保持" : "脉冲"} + ${escapeHtml(step.on_timeout)} + + + `; +} + +function renderStepForm() { + return ` +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + +
+
+ +
+
+ `; +} + +function renderInterlockRow(rule) { + return ` + + ${escapeHtml(rule.applies_to)} + ${escapeHtml(rule.rule_kind)} + ${escapeHtml(rule.point_id || rule.station_id || rule.equipment_id || "")} + ${rule.expected_value === null || rule.expected_value === undefined ? "" : rule.expected_value ? "true" : "false"} + ${escapeHtml(rule.description || "")} + + + `; +} + +function renderInterlockForm() { + return ` +
+
+ + +
+
+ + + +
+
+ + +
+
+ +
+
+ `; +} + +function renderResourcesEditor(detail) { + const keys = (detail?.resources || []).map((r) => r.resource_key); + return ` +
+ +
+ +
+
+ `; +} + +function renderDetail(detail) { + const steps = detail?.steps || []; + const interlocks = detail?.interlocks || []; + return ` +
+

步骤

+ ${ + steps.length === 0 + ? `
暂无步骤
` + : ` + + + + ${steps.map(renderStepRow).join("")} +
#CodeAction设备工位确认超时方式超时策略
` + } + ${renderStepForm()} + +

联锁

+ ${ + interlocks.length === 0 + ? `
暂无联锁
` + : ` + + + + ${interlocks.map(renderInterlockRow).join("")} +
applies_torule_kind对象 ID期望说明
` + } + ${renderInterlockForm()} + +

资源声明

+ ${renderResourcesEditor(detail)} +
+ `; +} + +function renderRow(segment) { + const isExpanded = expanded.has(segment.id); + const isEditing = editing === segment.id; + const detail = segmentDetails.get(segment.id); + return ` +
+
+
+ ${escapeHtml(segment.code)} + ${escapeHtml(segment.name)} + ${segment.line_code ? `${escapeHtml(segment.line_code)}` : ""} + ${escapeHtml(segment.segment_type)} + ${escapeHtml(segment.mode)} + ${segment.enabled ? "" : `已禁用`} +
+
+ + + +
+
+ ${isEditing ? `
${renderSegmentForm(segment)}
` : ""} + ${isExpanded ? renderDetail(detail) : ""} +
+ `; +} + +function renderAll() { + const root = el("segmentConfigList"); + if (!root) return; + const list = Array.from(segments.values()).sort((a, b) => a.code.localeCompare(b.code)); + root.innerHTML = ` + ${creating ? `
${renderSegmentForm({})}
` : ""} + ${list.length === 0 ? `
尚无段
` : list.map(renderRow).join("")} + `; +} + +function segmentFormToPayload(form) { + const data = Object.fromEntries(new FormData(form)); + const payload = { + code: data.code?.trim(), + name: data.name?.trim(), + segment_type: data.segment_type, + mode: data.mode, + enabled: form.elements.enabled.checked, + require_manual_ack_after_fault: form.elements.require_manual_ack_after_fault.checked, + priority: Number(data.priority || 0), + }; + if (data.line_code) payload.line_code = data.line_code.trim(); + if (data.description) payload.description = data.description; + return payload; +} + +function stepFormToPayload(form) { + const data = Object.fromEntries(new FormData(form)); + const payload = { + step_no: Number(data.step_no), + step_code: data.step_code, + action_kind: data.action_kind, + on_timeout: data.on_timeout || "fault", + hold_until_confirm: form.elements.hold_until_confirm.checked, + cancel_on_fault: form.elements.cancel_on_fault.checked, + }; + for (const key of [ + "target_equipment_id", + "target_station_id", + "confirm_signal_role", + "command_role", + "stop_command_role", + ]) { + if (data[key]) payload[key] = data[key].trim(); + } + if (data.pulse_ms) payload.pulse_ms = Number(data.pulse_ms); + if (data.timeout_ms) payload.timeout_ms = Number(data.timeout_ms); + return payload; +} + +function interlockFormToPayload(form) { + const data = Object.fromEntries(new FormData(form)); + const payload = { + applies_to: data.applies_to, + rule_kind: data.rule_kind, + }; + for (const key of ["point_id", "station_id", "equipment_id"]) { + if (data[key]) payload[key] = data[key].trim(); + } + if (data.expected_value === "true") payload.expected_value = true; + else if (data.expected_value === "false") payload.expected_value = false; + if (data.description) payload.description = data.description; + return payload; +} + +async function refreshDetail(segmentId) { + try { + const detail = await segmentApi.detail(segmentId); + segmentDetails.set(segmentId, detail); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } +} + +async function handleClick(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const action = button.dataset.action; + const row = event.target.closest(".config-row"); + const segmentId = row?.dataset?.segmentId; + + switch (action) { + case "cancel-form": + creating = false; + editing = null; + return renderAll(); + case "toggle": + if (!segmentId) return; + if (expanded.has(segmentId)) { + expanded.delete(segmentId); + } else { + expanded.add(segmentId); + await refreshDetail(segmentId); + } + return renderAll(); + case "edit": + editing = editing === segmentId ? null : segmentId; + return renderAll(); + case "delete": + if (!segmentId) return; + if (!window.confirm("确认删除该段及其步骤 / 联锁 / 资源声明?")) return; + try { + await segmentApi.remove(segmentId); + segments.delete(segmentId); + segmentDetails.delete(segmentId); + expanded.delete(segmentId); + if (editing === segmentId) editing = null; + renderAll(); + setBanner(el("segmentConfigList"), "段已删除", "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + case "delete-step": { + if (!segmentId) return; + const stepNo = button.dataset.stepNo; + try { + await segmentApi.deleteStep(segmentId, stepNo); + await refreshDetail(segmentId); + renderAll(); + setBanner(el("segmentConfigList"), `已删除步骤 ${stepNo}`, "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + } + case "delete-interlock": { + if (!segmentId) return; + const interlockId = button.dataset.id; + try { + await segmentApi.deleteInterlock(segmentId, interlockId); + await refreshDetail(segmentId); + renderAll(); + setBanner(el("segmentConfigList"), "已删除联锁", "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + } + default: + return; + } +} + +async function handleSubmit(event) { + const form = event.target.closest("form[data-form]"); + if (!form) return; + event.preventDefault(); + const row = form.closest(".config-row"); + const segmentId = row?.dataset?.segmentId; + const kind = form.dataset.form; + + if (kind === "segment") { + const payload = segmentFormToPayload(form); + try { + if (segmentId && editing === segmentId) { + await segmentApi.update(segmentId, payload); + setBanner(el("segmentConfigList"), "段已更新", "info"); + } else { + await segmentApi.create(payload); + setBanner(el("segmentConfigList"), "段已创建", "info"); + } + creating = false; + editing = null; + await loadSegmentsConfig(); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + } + + if (!segmentId) return; + + if (kind === "step") { + const payload = stepFormToPayload(form); + try { + await segmentApi.createStep(segmentId, payload); + await refreshDetail(segmentId); + renderAll(); + setBanner(el("segmentConfigList"), "步骤已新增", "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + } + + if (kind === "interlock") { + const payload = interlockFormToPayload(form); + try { + await segmentApi.createInterlock(segmentId, payload); + await refreshDetail(segmentId); + renderAll(); + setBanner(el("segmentConfigList"), "联锁已新增", "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + return; + } + + if (kind === "resources") { + const raw = form.elements.resource_keys.value || ""; + const keys = raw + .split(/[,\n]/) + .map((k) => k.trim()) + .filter((k) => k.length > 0); + try { + await segmentApi.replaceResources(segmentId, keys); + await refreshDetail(segmentId); + renderAll(); + setBanner(el("segmentConfigList"), "资源声明已保存", "info"); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } + } +} + +export async function loadSegmentsConfig() { + try { + const rows = await segmentApi.list(); + segments.clear(); + rows.forEach((s) => segments.set(s.id, s)); + renderAll(); + } catch (err) { + setBanner(el("segmentConfigList"), err.message || String(err), "error"); + } +} + +export function bindSegmentConfigEvents() { + const root = el("segmentConfigList"); + if (root) { + root.addEventListener("click", handleClick); + root.addEventListener("submit", handleSubmit); + } + const addBtn = el("addSegmentBtn"); + if (addBtn) { + addBtn.addEventListener("click", () => { + creating = !creating; + editing = null; + renderAll(); + }); + } + const refreshBtn = el("refreshSegmentConfigBtn"); + if (refreshBtn) refreshBtn.addEventListener("click", () => loadSegmentsConfig()); +} diff --git a/web/ops/js/segments.js b/web/ops/js/segments.js index f6a0174..a540a8b 100644 --- a/web/ops/js/segments.js +++ b/web/ops/js/segments.js @@ -1,4 +1,4 @@ -import { fetchOverview, segmentControl } from "./api.js"; +import { runtimeApi, segmentControl } from "./api.js"; const STATE_LABEL = { idle: "空闲", @@ -140,7 +140,7 @@ function handleAction(event) { } export async function loadSegments() { - const data = await fetchOverview(); + const data = await runtimeApi.fetchOverview(); segments.clear(); (data?.segments || []).forEach((entry) => { segments.set(entry.segment.id, entry); diff --git a/web/ops/js/stations.js b/web/ops/js/stations.js new file mode 100644 index 0000000..00bc44b --- /dev/null +++ b/web/ops/js/stations.js @@ -0,0 +1,314 @@ +import { stationApi } from "./api.js"; +import { el, escapeHtml, setBanner } from "./dom.js"; + +const STATION_TYPES = [ + "load", + "dry_in", + "dry_step", + "dry_out", + "fire_in", + "fire_step", + "fire_out", + "transfer", + "unload", + "return", +]; + +const SIGNAL_ROLES = ["presence", "vacancy", "arrived", "allow_in", "done", "fault"]; + +const stations = new Map(); +const expanded = new Set(); +let stationDetails = new Map(); // station_id -> { signals: [...] } +let editing = null; // station_id being edited inline +let creating = false; + +function renderForm(initial) { + const data = initial || {}; + return ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ `; +} + +function renderSignalForm() { + return ` +
+
+ + +
+
+ + +
+
+ +
+
+ `; +} + +function renderSignals(signals) { + if (!signals?.length) { + return `
未绑定信号
`; + } + return ` + + + + + + ${signals + .map( + (sig) => ` + + + + + + + + `, + ) + .join("")} + +
角色Point推导取反
${escapeHtml(sig.signal_role)}${escapeHtml(sig.point_id || "")}${escapeHtml(sig.derived_from_role || "")}${sig.invert_value ? "是" : "否"}
+ `; +} + +function renderRow(station) { + const isExpanded = expanded.has(station.id); + const isEditing = editing === station.id; + const detail = stationDetails.get(station.id); + return ` +
+
+
+ ${escapeHtml(station.code)} + ${escapeHtml(station.name)} + ${station.line_code ? `${escapeHtml(station.line_code)}` : ""} + ${escapeHtml(station.station_type)} + ${station.enabled ? "" : `已禁用`} +
+
+ + + +
+
+ ${isEditing ? `
${renderForm(station)}
` : ""} + ${ + isExpanded + ? `
+ ${renderSignals(detail?.signals)} + ${renderSignalForm()} +
` + : "" + } +
+ `; +} + +function renderAll() { + const root = el("stationList"); + if (!root) return; + const list = Array.from(stations.values()).sort((a, b) => a.code.localeCompare(b.code)); + root.innerHTML = ` + ${creating ? `
${renderForm({})}
` : ""} + ${list.length === 0 ? `
尚无工位
` : list.map(renderRow).join("")} + `; +} + +function formToPayload(form) { + const data = Object.fromEntries(new FormData(form)); + const payload = { + code: data.code?.trim(), + name: data.name?.trim(), + station_type: data.station_type, + enabled: form.elements.enabled.checked, + }; + if (data.line_code) payload.line_code = data.line_code.trim(); + if (data.segment_code) payload.segment_code = data.segment_code.trim(); + if (data.description) payload.description = data.description; + return payload; +} + +function signalFormToPayload(form) { + const data = Object.fromEntries(new FormData(form)); + const payload = { signal_role: data.signal_role }; + if (data.point_id) payload.point_id = data.point_id.trim(); + if (data.derived_from_role) payload.derived_from_role = data.derived_from_role; + payload.invert_value = form.elements.invert_value.checked; + return payload; +} + +async function refreshDetail(stationId) { + try { + const detail = await stationApi.detail(stationId); + stationDetails.set(stationId, { signals: detail.signals || [] }); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } +} + +async function handleClick(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const action = button.dataset.action; + const row = event.target.closest(".config-row"); + const stationId = row?.dataset?.stationId; + + switch (action) { + case "cancel-form": + creating = false; + editing = null; + return renderAll(); + case "toggle": + if (!stationId) return; + if (expanded.has(stationId)) { + expanded.delete(stationId); + } else { + expanded.add(stationId); + await refreshDetail(stationId); + } + return renderAll(); + case "edit": + editing = editing === stationId ? null : stationId; + return renderAll(); + case "delete": + if (!stationId) return; + if (!window.confirm("确认删除该工位?此操作不可恢复。")) return; + try { + await stationApi.remove(stationId); + stations.delete(stationId); + expanded.delete(stationId); + if (editing === stationId) editing = null; + renderAll(); + setBanner(el("stationList"), "工位已删除", "info"); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } + return; + case "delete-signal": { + if (!stationId) return; + const role = button.dataset.role; + try { + await stationApi.deleteSignal(stationId, role); + await refreshDetail(stationId); + renderAll(); + setBanner(el("stationList"), `已解除 ${role} 绑定`, "info"); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } + return; + } + default: + return; + } +} + +async function handleSubmit(event) { + const form = event.target.closest("form[data-form]"); + if (!form) return; + event.preventDefault(); + const row = form.closest(".config-row"); + const stationId = row?.dataset?.stationId; + + if (form.dataset.form === "station") { + const payload = formToPayload(form); + try { + if (stationId && editing === stationId) { + await stationApi.update(stationId, payload); + setBanner(el("stationList"), "工位已更新", "info"); + } else { + await stationApi.create(payload); + setBanner(el("stationList"), "工位已创建", "info"); + } + creating = false; + editing = null; + await loadStations(); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } + return; + } + + if (form.dataset.form === "signal") { + if (!stationId) return; + const payload = signalFormToPayload(form); + try { + await stationApi.upsertSignal(stationId, payload); + await refreshDetail(stationId); + renderAll(); + setBanner(el("stationList"), "信号绑定已保存", "info"); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } + } +} + +export async function loadStations() { + try { + const rows = await stationApi.list(); + stations.clear(); + rows.forEach((s) => stations.set(s.id, s)); + renderAll(); + } catch (err) { + setBanner(el("stationList"), err.message || String(err), "error"); + } +} + +export function bindStationEvents() { + const root = el("stationList"); + if (root) { + root.addEventListener("click", handleClick); + root.addEventListener("submit", handleSubmit); + } + const addBtn = el("addStationBtn"); + if (addBtn) { + addBtn.addEventListener("click", () => { + creating = !creating; + editing = null; + renderAll(); + }); + } + const refreshBtn = el("refreshStationsBtn"); + if (refreshBtn) refreshBtn.addEventListener("click", () => loadStations()); +} diff --git a/web/ops/js/views.js b/web/ops/js/views.js new file mode 100644 index 0000000..8407036 --- /dev/null +++ b/web/ops/js/views.js @@ -0,0 +1,30 @@ +import { el } from "./dom.js"; +import { loadSegmentsConfig } from "./segments-config.js"; +import { loadStations } from "./stations.js"; + +let configLoaded = false; + +function show(viewName) { + const monitor = document.querySelector("[data-view='monitor']"); + const config = document.querySelector("[data-view='config']"); + if (monitor) monitor.classList.toggle("hidden", viewName !== "monitor"); + if (config) config.classList.toggle("hidden", viewName !== "config"); + + const tabMon = el("tabMonitor"); + const tabCfg = el("tabConfig"); + if (tabMon) tabMon.classList.toggle("active", viewName === "monitor"); + if (tabCfg) tabCfg.classList.toggle("active", viewName === "config"); + + if (viewName === "config" && !configLoaded) { + configLoaded = true; + Promise.allSettled([loadStations(), loadSegmentsConfig()]); + } +} + +export function bindViewTabs() { + const tabMon = el("tabMonitor"); + const tabCfg = el("tabConfig"); + if (tabMon) tabMon.addEventListener("click", () => show("monitor")); + if (tabCfg) tabCfg.addEventListener("click", () => show("config")); + show("monitor"); +} diff --git a/web/ops/ops-styles.css b/web/ops/ops-styles.css index 163aabf..37f2ba5 100644 --- a/web/ops/ops-styles.css +++ b/web/ops/ops-styles.css @@ -1,13 +1,34 @@ /* Operation-system specific styles. Loaded after /ui/styles.css. */ +.ops-tabbar { + display: flex; + gap: 4px; + padding: 6px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + main.ops-main { - height: calc(100vh - var(--topbar-h)); + height: calc(100vh - var(--topbar-h) - 42px); display: flex; flex-direction: column; overflow: hidden; } -.panel.ops-segments { +.ops-view { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ops-view.hidden { + display: none; +} + +.panel.ops-segments, +.panel.ops-config { flex: 1 1 auto; min-height: 0; } @@ -202,3 +223,217 @@ main.ops-main { border: 1px solid var(--danger); color: var(--danger); } + +.badge-warn { + border-color: var(--warning); + color: var(--warning); + background: rgba(217, 119, 6, 0.06); +} + +/* Config view layout */ +.panel.ops-config { + height: 100%; + display: flex; + flex-direction: column; +} + +.config-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + flex: 1 1 auto; + min-height: 0; +} + +@media (max-width: 1100px) { + .config-grid { + grid-template-columns: 1fr; + } +} + +.config-pane { + background: var(--surface); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.config-list { + flex: 1 1 auto; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.config-row { + border: 1px solid var(--border); + border-radius: 2px; + background: var(--surface); + display: flex; + flex-direction: column; +} + +.config-row.creating { + border-color: var(--accent); + background: var(--accent-bg); +} + +.row-head { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + align-items: center; + flex-wrap: wrap; +} + +.row-title { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + font-size: 13px; +} + +.row-actions { + display: flex; + gap: 4px; +} + +.row-actions button { + height: 24px; + font-size: 11px; + padding: 0 8px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; +} + +.row-actions button.danger { + border-color: var(--danger); + color: var(--danger); +} + +.row-actions button.danger:hover { + background: rgba(239, 68, 68, 0.06); +} + +.row-edit, +.row-body { + padding: 10px; + border-top: 1px solid var(--border-light); + display: flex; + flex-direction: column; + gap: 10px; +} + +.row-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-2); + border-bottom: 1px solid var(--border-light); + padding-bottom: 4px; + margin-top: 4px; +} + +.config-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 2px; +} + +.config-form label { + display: flex; + flex-direction: column; + font-size: 11px; + color: var(--text-2); + gap: 2px; +} + +.config-form label.form-check { + flex-direction: row; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.config-form input, +.config-form select, +.config-form textarea { + font-size: 12px; + padding: 4px 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-family: inherit; +} + +.config-form textarea { + resize: vertical; + min-height: 48px; +} + +.form-row { + display: grid; + gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding-top: 4px; +} + +.form-actions button { + font-size: 12px; + padding: 4px 12px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; +} + +.form-actions button[type="submit"] { + border-color: var(--accent); + color: var(--accent); +} + +.form-actions button[type="submit"]:hover { + background: var(--accent); + color: white; +} + +.config-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.config-table th, +.config-table td { + border-bottom: 1px solid var(--border-light); + padding: 4px 6px; + text-align: left; + vertical-align: top; +} + +.config-table th { + background: var(--surface-2); + font-weight: 600; + color: var(--text-2); +} + +.config-table td.mono { + font-family: ui-monospace, "JetBrains Mono", monospace; + font-size: 11px; + word-break: break-all; +}