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) <noreply@anthropic.com>
This commit is contained in:
parent
e2248fa04f
commit
3667d64243
|
|
@ -0,0 +1,24 @@
|
|||
<section class="panel ops-config" data-config-section>
|
||||
<div class="config-grid">
|
||||
<div class="config-pane">
|
||||
<div class="panel-head">
|
||||
<h2>工位</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshStationsBtn">刷新</button>
|
||||
<button type="button" id="addStationBtn">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-list" id="stationList"></div>
|
||||
</div>
|
||||
<div class="config-pane">
|
||||
<div class="panel-head">
|
||||
<h2>段</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshSegmentConfigBtn">刷新</button>
|
||||
<button type="button" id="addSegmentBtn">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-list" id="segmentConfigList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -10,8 +10,18 @@
|
|||
<body>
|
||||
<div data-partial="/ui/html/topbar.html"></div>
|
||||
|
||||
<nav class="ops-tabbar">
|
||||
<button type="button" class="tab-btn" id="tabMonitor">运行监控</button>
|
||||
<button type="button" class="tab-btn" id="tabConfig">段 / 工位配置</button>
|
||||
</nav>
|
||||
|
||||
<main class="ops-main">
|
||||
<div data-partial="/ui/html/segment-panel.html"></div>
|
||||
<div class="ops-view" data-view="monitor">
|
||||
<div data-partial="/ui/html/segment-panel.html"></div>
|
||||
</div>
|
||||
<div class="ops-view hidden" data-view="config">
|
||||
<div data-partial="/ui/html/config-panel.html"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/ui/js/index.js"></script>
|
||||
|
|
|
|||
|
|
@ -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 }, "更新资源声明失败"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 `
|
||||
<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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<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());
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue