524 lines
18 KiB
JavaScript
524 lines
18 KiB
JavaScript
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 `
|
|
<form class="config-form" data-form="segment">
|
|
<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>段类型
|
|
<select name="segment_type" required>
|
|
${SEGMENT_TYPES.map(
|
|
(t) => `<option value="${t}"${data.segment_type === t ? " selected" : ""}>${t}</option>`,
|
|
).join("")}
|
|
</select>
|
|
</label>
|
|
<label>线路<input name="line_code" value="${escapeHtml(data.line_code)}" maxlength="50" /></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>模式
|
|
<select name="mode" required>
|
|
${SEGMENT_MODES.map(
|
|
(m) => `<option value="${m}"${(data.mode || "disabled") === m ? " selected" : ""}>${m}</option>`,
|
|
).join("")}
|
|
</select>
|
|
</label>
|
|
<label>优先级<input type="number" name="priority" value="${data.priority ?? 0}" /></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-check">
|
|
<input type="checkbox" name="enabled" ${data.enabled === false ? "" : "checked"} />
|
|
启用
|
|
</label>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="require_manual_ack_after_fault" ${data.require_manual_ack_after_fault === 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 renderStepRow(step) {
|
|
return `
|
|
<tr>
|
|
<td>${step.step_no}</td>
|
|
<td>${escapeHtml(step.step_code)}</td>
|
|
<td>${escapeHtml(step.action_kind)}</td>
|
|
<td class="mono">${escapeHtml(step.target_equipment_id || "")}</td>
|
|
<td class="mono">${escapeHtml(step.target_station_id || "")}</td>
|
|
<td>${escapeHtml(step.confirm_signal_role || "")}</td>
|
|
<td>${step.timeout_ms}</td>
|
|
<td>${step.hold_until_confirm ? "保持" : "脉冲"}</td>
|
|
<td>${escapeHtml(step.on_timeout)}</td>
|
|
<td><button data-action="delete-step" data-step-no="${step.step_no}" class="secondary">删除</button></td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function renderStepForm() {
|
|
return `
|
|
<form class="config-form step-form" data-form="step">
|
|
<div class="form-row">
|
|
<label>步序<input name="step_no" type="number" min="1" required /></label>
|
|
<label>Code<input name="step_code" required maxlength="64" /></label>
|
|
<label>Action
|
|
<select name="action_kind" required>
|
|
${ACTION_KINDS.map((a) => `<option value="${a}">${a}</option>`).join("")}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>目标设备 ID<input name="target_equipment_id" placeholder="UUID" /></label>
|
|
<label>目标工位 ID<input name="target_station_id" placeholder="UUID" /></label>
|
|
<label>确认信号<input name="confirm_signal_role" placeholder="arrived / done / ..." /></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>命令角色<input name="command_role" placeholder="留空走默认" /></label>
|
|
<label>停止角色<input name="stop_command_role" /></label>
|
|
<label>脉冲毫秒<input name="pulse_ms" type="number" min="1" /></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>超时 ms<input name="timeout_ms" type="number" min="1" value="30000" /></label>
|
|
<label>on_timeout
|
|
<select name="on_timeout">${ON_TIMEOUT.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
|
</label>
|
|
<label class="form-check"><input type="checkbox" name="hold_until_confirm" />持续命令</label>
|
|
<label class="form-check"><input type="checkbox" name="cancel_on_fault" checked />故障自动停止</label>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="submit">新增步骤</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
function renderInterlockRow(rule) {
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(rule.applies_to)}</td>
|
|
<td>${escapeHtml(rule.rule_kind)}</td>
|
|
<td class="mono">${escapeHtml(rule.point_id || rule.station_id || rule.equipment_id || "")}</td>
|
|
<td>${rule.expected_value === null || rule.expected_value === undefined ? "" : rule.expected_value ? "true" : "false"}</td>
|
|
<td>${escapeHtml(rule.description || "")}</td>
|
|
<td><button data-action="delete-interlock" data-id="${rule.id}" class="secondary">删除</button></td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function renderInterlockForm() {
|
|
return `
|
|
<form class="config-form interlock-form" data-form="interlock">
|
|
<div class="form-row">
|
|
<label>applies_to
|
|
<select name="applies_to" required>${APPLIES_TO.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
|
</label>
|
|
<label>rule_kind
|
|
<select name="rule_kind" required>${RULE_KINDS.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>Point ID<input name="point_id" /></label>
|
|
<label>Station ID<input name="station_id" /></label>
|
|
<label>Equipment ID<input name="equipment_id" /></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>期望值
|
|
<select name="expected_value">
|
|
<option value="">(none)</option>
|
|
<option value="true">true</option>
|
|
<option value="false">false</option>
|
|
</select>
|
|
</label>
|
|
<label>说明<input name="description" maxlength="200" /></label>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="submit">新增联锁</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
function renderResourcesEditor(detail) {
|
|
const keys = (detail?.resources || []).map((r) => r.resource_key);
|
|
return `
|
|
<form class="config-form resource-form" data-form="resources">
|
|
<label>资源键(逗号或换行分隔)
|
|
<textarea name="resource_keys" rows="2">${escapeHtml(keys.join(", "))}</textarea>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="submit">保存资源列表</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
function renderDetail(detail) {
|
|
const steps = detail?.steps || [];
|
|
const interlocks = detail?.interlocks || [];
|
|
return `
|
|
<div class="row-body">
|
|
<h3 class="row-section-title">步骤</h3>
|
|
${
|
|
steps.length === 0
|
|
? `<div class="muted card-empty">暂无步骤</div>`
|
|
: `<table class="config-table">
|
|
<thead>
|
|
<tr><th>#</th><th>Code</th><th>Action</th><th>设备</th><th>工位</th><th>确认</th><th>超时</th><th>方式</th><th>超时策略</th><th></th></tr>
|
|
</thead>
|
|
<tbody>${steps.map(renderStepRow).join("")}</tbody>
|
|
</table>`
|
|
}
|
|
${renderStepForm()}
|
|
|
|
<h3 class="row-section-title">联锁</h3>
|
|
${
|
|
interlocks.length === 0
|
|
? `<div class="muted card-empty">暂无联锁</div>`
|
|
: `<table class="config-table">
|
|
<thead>
|
|
<tr><th>applies_to</th><th>rule_kind</th><th>对象 ID</th><th>期望</th><th>说明</th><th></th></tr>
|
|
</thead>
|
|
<tbody>${interlocks.map(renderInterlockRow).join("")}</tbody>
|
|
</table>`
|
|
}
|
|
${renderInterlockForm()}
|
|
|
|
<h3 class="row-section-title">资源声明</h3>
|
|
${renderResourcesEditor(detail)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRow(segment) {
|
|
const isExpanded = expanded.has(segment.id);
|
|
const isEditing = editing === segment.id;
|
|
const detail = segmentDetails.get(segment.id);
|
|
return `
|
|
<article class="config-row" data-segment-id="${segment.id}">
|
|
<header class="row-head">
|
|
<div class="row-title">
|
|
<strong>${escapeHtml(segment.code)}</strong>
|
|
<span class="muted">${escapeHtml(segment.name)}</span>
|
|
${segment.line_code ? `<span class="badge">${escapeHtml(segment.line_code)}</span>` : ""}
|
|
<span class="badge">${escapeHtml(segment.segment_type)}</span>
|
|
<span class="badge">${escapeHtml(segment.mode)}</span>
|
|
${segment.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">${renderSegmentForm(segment)}</div>` : ""}
|
|
${isExpanded ? renderDetail(detail) : ""}
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
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 ? `<div class="config-row creating">${renderSegmentForm({})}</div>` : ""}
|
|
${list.length === 0 ? `<div class="muted card-empty">尚无段</div>` : 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());
|
|
}
|