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
+ ? `
暂无步骤
`
+ : `
+
+ | # | Code | Action | 设备 | 工位 | 确认 | 超时 | 方式 | 超时策略 | |
+
+ ${steps.map(renderStepRow).join("")}
+
`
+ }
+ ${renderStepForm()}
+
+
联锁
+ ${
+ interlocks.length === 0
+ ? `
暂无联锁
`
+ : `
+
+ | applies_to | rule_kind | 对象 ID | 期望 | 说明 | |
+
+ ${interlocks.map(renderInterlockRow).join("")}
+
`
+ }
+ ${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 `
+
+
+ | 角色 | Point | 推导 | 取反 | |
+
+
+ ${signals
+ .map(
+ (sig) => `
+
+ | ${escapeHtml(sig.signal_role)} |
+ ${escapeHtml(sig.point_id || "")} |
+ ${escapeHtml(sig.derived_from_role || "")} |
+ ${sig.invert_value ? "是" : "否"} |
+ |
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+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;
+}