315 lines
10 KiB
JavaScript
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());
|
|
}
|