plc_control/web/ops/js/stations.js

315 lines
10 KiB
JavaScript

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 `
<form class="config-form" data-form="station">
<div class="form-row">
<label>Code<input name="code" value="${escapeHtml(data.code)}" required maxlength="100" /></label>
<label>名称<input name="name" value="${escapeHtml(data.name)}" required maxlength="100" /></label>
</div>
<div class="form-row">
<label>线路<input name="line_code" value="${escapeHtml(data.line_code)}" maxlength="50" /></label>
<label>段分组<input name="segment_code" value="${escapeHtml(data.segment_code)}" maxlength="50" /></label>
</div>
<div class="form-row">
<label>工位类型
<select name="station_type" required>
${STATION_TYPES.map(
(t) => `<option value="${t}"${data.station_type === t ? " selected" : ""}>${t}</option>`,
).join("")}
</select>
</label>
<label class="form-check">
<input type="checkbox" name="enabled" ${data.enabled === false ? "" : "checked"} />
启用
</label>
</div>
<label>说明<textarea name="description" maxlength="500">${escapeHtml(data.description)}</textarea></label>
<div class="form-actions">
<button type="button" data-action="cancel-form" class="secondary">取消</button>
<button type="submit">保存</button>
</div>
</form>
`;
}
function renderSignalForm() {
return `
<form class="config-form signal-form" data-form="signal">
<div class="form-row">
<label>角色
<select name="signal_role" required>
${SIGNAL_ROLES.map((r) => `<option value="${r}">${r}</option>`).join("")}
</select>
</label>
<label>Point ID<input name="point_id" placeholder="UUID 或留空走推导" /></label>
</div>
<div class="form-row">
<label>推导来源角色
<select name="derived_from_role">
<option value="">(none)</option>
${SIGNAL_ROLES.map((r) => `<option value="${r}">${r}</option>`).join("")}
</select>
</label>
<label class="form-check">
<input type="checkbox" name="invert_value" />取反
</label>
</div>
<div class="form-actions">
<button type="submit">绑定 / 更新</button>
</div>
</form>
`;
}
function renderSignals(signals) {
if (!signals?.length) {
return `<div class="muted card-empty">未绑定信号</div>`;
}
return `
<table class="config-table">
<thead>
<tr><th>角色</th><th>Point</th><th>推导</th><th>取反</th><th></th></tr>
</thead>
<tbody>
${signals
.map(
(sig) => `
<tr>
<td>${escapeHtml(sig.signal_role)}</td>
<td class="mono">${escapeHtml(sig.point_id || "")}</td>
<td>${escapeHtml(sig.derived_from_role || "")}</td>
<td>${sig.invert_value ? "是" : "否"}</td>
<td><button data-action="delete-signal" data-role="${escapeHtml(sig.signal_role)}" class="secondary">解绑</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
function renderRow(station) {
const isExpanded = expanded.has(station.id);
const isEditing = editing === station.id;
const detail = stationDetails.get(station.id);
return `
<article class="config-row" data-station-id="${station.id}">
<header class="row-head">
<div class="row-title">
<strong>${escapeHtml(station.code)}</strong>
<span class="muted">${escapeHtml(station.name)}</span>
${station.line_code ? `<span class="badge">${escapeHtml(station.line_code)}</span>` : ""}
<span class="badge">${escapeHtml(station.station_type)}</span>
${station.enabled ? "" : `<span class="badge badge-warn">已禁用</span>`}
</div>
<div class="row-actions">
<button data-action="toggle">${isExpanded ? "收起" : "信号"}</button>
<button data-action="edit">${isEditing ? "取消" : "编辑"}</button>
<button data-action="delete" class="danger">删除</button>
</div>
</header>
${isEditing ? `<div class="row-edit">${renderForm(station)}</div>` : ""}
${
isExpanded
? `<div class="row-body">
${renderSignals(detail?.signals)}
${renderSignalForm()}
</div>`
: ""
}
</article>
`;
}
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 ? `<div class="config-row creating">${renderForm({})}</div>` : ""}
${list.length === 0 ? `<div class="muted card-empty">尚无工位</div>` : 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());
}