<!doctype html>
|
<html lang="zh-CN">
|
<head>
|
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>开发设备模拟器</title>
|
<style>
|
:root { color-scheme: light; font-family: "Microsoft YaHei", Arial, sans-serif; color: #26323d; background: #f3f6f8; }
|
* { box-sizing: border-box; }
|
body { margin: 0; min-width: 320px; }
|
header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 62px; padding: 0 28px; color: #fff; background: #213547; border-bottom: 3px solid #26a269; }
|
h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: 0; }
|
header span { font: 12px Consolas, monospace; color: #c9d5de; }
|
main { width: min(1120px, calc(100% - 32px)); margin: 22px auto 40px; }
|
section { margin-bottom: 20px; background: #fff; border: 1px solid #d5dde3; }
|
.section-title, .device-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 16px; background: #edf2f5; border-bottom: 1px solid #d5dde3; }
|
h2, h3 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0; }
|
.summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.summary > div { min-height: 76px; padding: 15px 16px; border-right: 1px solid #e2e8ec; }
|
.summary > div:last-child { border-right: 0; }
|
.label { display: block; margin-bottom: 7px; color: #6b7780; font-size: 12px; }
|
.value { font-size: 14px; font-weight: 600; }
|
.status { display: inline-flex; align-items: center; gap: 7px; }
|
.status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; }
|
.status.online::before { background: #218a5b; }
|
.add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
|
.device-settings { grid-template-columns: minmax(130px, 1fr) 90px minmax(150px, 1fr) 100px auto auto; }
|
.collector-settings { display: grid; grid-template-columns: minmax(130px, 1fr) 90px 110px auto auto; gap: 12px; align-items: end; padding: 16px; }
|
|
label { color: #5e6a73; font-size: 12px; }
|
label > span { display: block; margin-bottom: 6px; }
|
input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
|
textarea { min-height: 64px; padding: 7px 9px; resize: vertical; font: 12px Consolas, monospace; }
|
input[type="checkbox"] { width: 17px; height: 17px; accent-color: #218a5b; cursor: pointer; }
|
.switch-label { display: inline-flex; align-items: center; gap: 8px; height: 34px; }
|
.switch-label > span { margin: 0; }
|
button { height: 34px; padding: 0 13px; color: #fff; background: #2877a9; border: 1px solid #2877a9; border-radius: 3px; cursor: pointer; }
|
button:hover { background: #1f638e; }
|
button:disabled { cursor: not-allowed; opacity: .5; }
|
button.danger { color: #a03946; background: #fff; border-color: #d8a6ad; }
|
button.danger:hover { background: #fff3f4; }
|
.power-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.device { margin: 16px; border: 1px solid #d5dde3; }
|
.device-title { background: #f7f9fa; }
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
th, td { height: 48px; padding: 8px 14px; text-align: left; border-bottom: 1px solid #e3e9ed; font-size: 13px; }
|
th { height: 40px; color: #5e6a73; background: #f8fafb; font-size: 12px; font-weight: 600; }
|
tbody tr:last-child td { border-bottom: 0; }
|
td select { width: 150px; }
|
.request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; }
|
.request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; }
|
.request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: pre-wrap; overflow-wrap: anywhere; }
|
.collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; }
|
.collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; }
|
.empty { height: 96px; color: #7d8992; text-align: center; }
|
.error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
|
.error.visible { display: block; }
|
dialog { width: min(940px, calc(100% - 24px)); max-height: calc(100% - 32px); padding: 0; border: 1px solid #bdc8d0; border-radius: 4px; color: #26323d; }
|
dialog::backdrop { background: rgb(0 0 0 / 35%); }
|
.editor-body { padding: 16px; }
|
.editor-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
|
.editor-scroll { overflow-x: auto; margin-top: 12px; }
|
.editor-scroll table { min-width: 700px; }
|
.editor-scroll td { padding: 6px; }
|
.editor-scroll th { padding: 8px 6px; white-space: nowrap; }
|
.editor-scroll select { width: 100%; }
|
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 16px; border-top: 1px solid #d5dde3; }
|
.icon-button { width: 30px; padding: 0; color: #52616c; background: #fff; border-color: #bdc8d0; }
|
.row-actions { display: flex; gap: 4px; }
|
.editor-error { margin: 0 0 12px; }
|
@media (max-width: 760px) {
|
header { align-items: flex-start; flex-direction: column; padding: 14px 16px; }
|
main { width: calc(100% - 20px); margin-top: 12px; }
|
.summary { grid-template-columns: 1fr; }
|
.summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
|
.add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; }
|
.device-settings button, .collector-settings button { width: 100%; }
|
.device { margin: 10px; overflow-x: auto; }
|
.device table { min-width: 620px; }
|
.section-title, .device-title { flex-wrap: wrap; padding: 10px; }
|
.device-title .label { overflow-wrap: anywhere; }
|
th, td { padding: 8px; }
|
.request-time { width: 130px; }
|
.request-command { width: 90px; }
|
.editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
}
|
</style>
|
</head>
|
<body>
|
<header><h1>开发设备模拟器</h1><span>dev-sim / 仅限本机</span></header>
|
<main>
|
<div id="error" class="error"></div>
|
<section>
|
<div class="section-title">
|
<h2>软件连接</h2>
|
<div class="power-actions">
|
<label class="switch-label"><input id="software-accessible" type="checkbox"><span>新威软件可访问</span></label>
|
<button id="power-on" type="button">全部设备上线</button>
|
<button id="power-off" class="danger" type="button">全部设备离线</button>
|
<button id="reset-simulator" class="danger" type="button">重置模拟器</button>
|
</div>
|
</div>
|
<div class="summary">
|
<div><span class="label">模拟器服务</span><span id="runtime" class="value status">未知</span></div>
|
<div><span class="label">现场设备</span><span id="device-power" class="value status">未知</span></div>
|
<div><span class="label">监听地址</span><span id="address" class="value">-</span></div>
|
<div><span class="label">检测柜</span><span id="device-count" class="value">0 台</span></div>
|
<div><span class="label">采集器</span><span id="collector-count" class="value">0 台</span></div>
|
</div>
|
</section>
|
<section>
|
<div class="section-title"><h2>添加检测柜</h2></div>
|
<form id="add-device" class="add-form">
|
<label><span>虚拟 IP</span><input id="new-ip" type="text" value="127.0.0.3" required></label>
|
<label><span>通道数</span><input id="new-channel-count" type="number" min="1" max="99" value="4" required></label>
|
<label><span>设备 profile</span><select id="new-device-model"><option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option></select></label>
|
<button type="submit">添加检测柜</button>
|
</form>
|
</section>
|
<section>
|
<div class="section-title"><h2>检测柜与通道</h2><span class="label">约在下一轮设备发现后同步至业务页面</span></div>
|
<div id="devices"></div>
|
</section>
|
<section>
|
<div class="section-title"><h2>添加采集器</h2><span class="label">与检测柜分开配置</span></div>
|
<form id="add-collector" class="add-form">
|
<label><span>虚拟 IP</span><input id="new-collector-ip" type="text" value="127.0.1.3" required></label>
|
<label><span>通道数</span><input id="new-collector-channel-count" type="number" min="1" max="99" value="4" required></label>
|
<button type="submit">添加采集器</button>
|
</form>
|
</section>
|
<section>
|
<div class="section-title"><h2>采集器</h2><span class="label">在采集器页面“选择设备”中直接显示</span></div>
|
<div id="collectors"></div>
|
</section>
|
<section>
|
<div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div>
|
<table>
|
<thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead>
|
<tbody id="requests"></tbody>
|
</table>
|
</section>
|
</main>
|
<dialog id="channel-editor" aria-labelledby="editor-title">
|
<form id="editor-form">
|
<div class="section-title"><h3 id="editor-title"></h3><button class="icon-button" type="button" id="editor-close" aria-label="关闭配置" title="关闭配置">×</button></div>
|
<div class="editor-body"><div id="editor-error" class="error editor-error" role="alert"></div><div id="editor-content"></div></div>
|
<div class="editor-actions"><button type="button" id="editor-cancel" class="danger">取消</button><button type="submit" id="editor-save">保存通道配置</button></div>
|
</form>
|
</dialog>
|
<script>
|
const api = '/dev/bts-simulator';
|
const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' };
|
const errorBox = document.querySelector('#error');
|
const editor = document.querySelector('#channel-editor');
|
const editorForm = document.querySelector('#editor-form');
|
const editorContent = document.querySelector('#editor-content');
|
const stepTypes = { cc: '恒流充电', dc: '恒流放电', cv: '恒压充电', rest: '搁置', end: '结束', cccv: '恒流恒压充电' };
|
const metricLabels = { temperature: '温度', positive_potential: '正电位', negative_potential: '负电位' };
|
let saveEditor = null;
|
document.querySelector('#editor-close').onclick = () => editor.close();
|
document.querySelector('#editor-cancel').onclick = () => editor.close();
|
editorForm.addEventListener('submit', async event => {
|
event.preventDefault();
|
const save = document.querySelector('#editor-save');
|
save.disabled = true;
|
try { const state = await saveEditor(); editor.close(); render(state); }
|
catch (error) { showError(error); }
|
finally { save.disabled = false; }
|
});
|
|
async function request(path, options) {
|
const response = await fetch(api + path, { ...options, headers: { 'Content-Type': 'application/json' } });
|
if (!response.ok) {
|
let message = `请求失败 (${response.status})`;
|
try { const error = await response.json(); message = error?.msg || error?.message || message; } catch (_) { }
|
throw new Error(message);
|
}
|
const payload = await response.json();
|
if (payload && payload.data && typeof payload.data === 'object'
|
&& !Array.isArray(payload.devices) && payload.data.devices) return payload.data;
|
return payload;
|
}
|
|
function showError(error) {
|
errorBox.textContent = error instanceof Error ? error.message : '控制页请求失败';
|
errorBox.classList.add('visible');
|
if (editor.open) {
|
const message = document.querySelector('#editor-error');
|
message.textContent = errorBox.textContent;
|
message.classList.add('visible');
|
}
|
}
|
|
function render(state) {
|
errorBox.classList.remove('visible');
|
if (!state || typeof state !== 'object') throw new Error('模拟器响应缺少状态数据');
|
if (state.data && typeof state.data === 'object' && !Array.isArray(state.devices)) state = state.data;
|
state = {
|
...state,
|
devices: Array.isArray(state.devices) ? state.devices : [],
|
collectors: Array.isArray(state.collectors) ? state.collectors : [],
|
recentRequests: Array.isArray(state.recentRequests) ? state.recentRequests : [],
|
};
|
document.querySelector('#software-accessible').checked = state.softwareAccessible;
|
const runtime = document.querySelector('#runtime');
|
runtime.textContent = state.running ? '运行中' : '已停止';
|
runtime.classList.toggle('online', state.running);
|
const devicesPoweredOn = state.devices.length + state.collectors.length > 0
|
&& state.devices.every(device => device.online)
|
&& state.collectors.every(collector => collector.online);
|
const devicesPoweredOff = state.devices.every(device => !device.online)
|
&& state.collectors.every(collector => !collector.online);
|
const devicePower = document.querySelector('#device-power');
|
devicePower.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线';
|
devicePower.classList.toggle('online', devicesPoweredOn);
|
document.querySelector('#power-on').disabled = devicesPoweredOn;
|
document.querySelector('#power-off').disabled = devicesPoweredOff;
|
document.querySelector('#address').textContent = state.listenAddress;
|
document.querySelector('#device-count').textContent = `${state.devices.length} 台`;
|
document.querySelector('#collector-count').textContent = `${state.collectors.length} 台`;
|
renderDevices(state.devices);
|
renderCollectors(state.collectors);
|
renderRequests(state.recentRequests);
|
}
|
|
function renderDevices(devices) {
|
const container = document.querySelector('#devices');
|
if (!devices.length) {
|
const empty = document.createElement('div');
|
empty.className = 'empty';
|
empty.textContent = '暂无虚拟设备';
|
container.replaceChildren(empty);
|
return;
|
}
|
container.replaceChildren(...devices.map(device => {
|
const panel = document.createElement('article');
|
panel.className = 'device';
|
const title = document.createElement('div');
|
title.className = 'device-title';
|
const heading = document.createElement('h3');
|
heading.textContent = `检测柜 ${device.deviceId}`;
|
const identity = document.createElement('span');
|
identity.className = 'label';
|
identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / subdevid ${device.profile.subDeviceId} / ${device.profile.model}`;
|
title.append(heading, identity);
|
|
const settings = document.createElement('div');
|
settings.className = 'device-settings';
|
const ip = field('虚拟 IP', 'text', device.ip);
|
const channelCount = field('通道数', 'number', device.channels.length);
|
channelCount.input.min = '1';
|
channelCount.input.max = '99';
|
const model = document.createElement('label');
|
model.innerHTML = '<span>设备 profile</span>';
|
const modelInput = document.createElement('select');
|
modelInput.innerHTML = '<option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option>';
|
modelInput.value = device.profile.model.startsWith('CT-4008T') ? device.profile.model : '';
|
model.appendChild(modelInput);
|
const onlineLabel = document.createElement('label');
|
onlineLabel.className = 'switch-label';
|
const online = document.createElement('input');
|
online.type = 'checkbox';
|
online.checked = device.online;
|
const onlineText = document.createElement('span');
|
onlineText.textContent = device.online ? '整机在线' : '整机离线';
|
online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
|
onlineLabel.append(online, onlineText);
|
const save = document.createElement('button');
|
save.type = 'button';
|
save.textContent = '保存配置';
|
save.addEventListener('click', async () => {
|
try { render(await request(`/devices/${device.deviceId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked, model: modelInput.value || null }) })); }
|
catch (error) { showError(error); }
|
});
|
const remove = document.createElement('button');
|
remove.type = 'button';
|
remove.className = 'danger';
|
remove.textContent = '删除检测柜';
|
remove.addEventListener('click', async () => {
|
try { render(await request(`/devices/${device.deviceId}`, { method: 'DELETE' })); }
|
catch (error) { showError(error); }
|
});
|
settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove);
|
|
const table = document.createElement('table');
|
table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th><th>观测配置</th></tr></thead>';
|
const body = document.createElement('tbody');
|
body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
|
table.append(body);
|
panel.append(title, settings, table);
|
return panel;
|
}));
|
}
|
|
function renderCollectors(collectors) {
|
const container = document.querySelector('#collectors');
|
if (!collectors.length) {
|
const empty = document.createElement('div');
|
empty.className = 'empty';
|
empty.textContent = '暂无虚拟采集器';
|
container.replaceChildren(empty);
|
return;
|
}
|
container.replaceChildren(...collectors.map(collector => {
|
const panel = document.createElement('article');
|
panel.className = 'device';
|
const title = document.createElement('div');
|
title.className = 'device-title';
|
const heading = document.createElement('h3');
|
heading.textContent = collector.name;
|
const identity = document.createElement('span');
|
identity.className = 'label';
|
identity.textContent = `${collector.deviceCode} / ${collector.mac}`;
|
title.append(heading, identity);
|
|
const settings = document.createElement('div');
|
settings.className = 'collector-settings';
|
const ip = field('虚拟 IP', 'text', collector.ip);
|
const channelCount = field('通道数', 'number', collector.channelCount);
|
channelCount.input.min = '1';
|
channelCount.input.max = '99';
|
const onlineLabel = document.createElement('label');
|
onlineLabel.className = 'switch-label';
|
const online = document.createElement('input');
|
online.type = 'checkbox';
|
online.checked = collector.online;
|
const onlineText = document.createElement('span');
|
onlineText.textContent = collector.online ? '整机在线' : '整机离线';
|
online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
|
onlineLabel.append(online, onlineText);
|
const save = document.createElement('button');
|
save.type = 'button';
|
save.textContent = '保存配置';
|
save.addEventListener('click', async () => {
|
try {
|
render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) }));
|
}
|
catch (error) { showError(error); }
|
});
|
const remove = document.createElement('button');
|
remove.type = 'button';
|
remove.className = 'danger';
|
remove.textContent = '删除采集器';
|
remove.addEventListener('click', async () => {
|
try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
|
catch (error) { showError(error); }
|
});
|
settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
|
|
const table = document.createElement('table');
|
table.innerHTML = '<thead><tr><th style="width:100px">稳定通道</th><th style="width:170px">工作状态</th><th>测量项</th><th style="width:130px">操作</th></tr></thead>';
|
const channelBody = document.createElement('tbody');
|
channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel)));
|
table.append(channelBody);
|
|
const actions = document.createElement('div');
|
actions.className = 'collector-actions';
|
const realtimeButton = document.createElement('button');
|
realtimeButton.type = 'button';
|
realtimeButton.textContent = '查看实时';
|
const output = document.createElement('pre');
|
output.className = 'collector-output';
|
output.textContent = collector.latestMeasurements.length
|
? measurementLines(collector.latestMeasurements)
|
: '暂无成功观测';
|
realtimeButton.addEventListener('click', async () => {
|
try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); }
|
catch (error) { showError(error); }
|
});
|
actions.append(realtimeButton);
|
panel.append(title, settings, table, actions, output);
|
return panel;
|
}));
|
}
|
|
function collectorChannelRow(collectorId, channel) {
|
const row = document.createElement('tr');
|
const identity = document.createElement('td');
|
identity.textContent = channel.channelCode;
|
const metricsCell = document.createElement('td');
|
metricsCell.textContent = channel.metrics.map(item => `${metricLabels[item.metricCode] || item.metricCode} ${item.value} ${item.unit}`).join(' / ');
|
metricsCell.style.overflowWrap = 'anywhere';
|
const action = document.createElement('td');
|
const save = document.createElement('button');
|
save.type = 'button';
|
save.textContent = '编辑测量值';
|
save.addEventListener('click', () => editCollectorChannel(collectorId, channel));
|
action.append(save);
|
const statusCell = document.createElement('td');
|
const status = document.createElement('select');
|
status.setAttribute('aria-label', channel.channelCode + ' 工作状态');
|
Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
|
status.value = channel.workStatus;
|
status.addEventListener('change', async () => {
|
try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}/status`, { method: 'PUT', body: JSON.stringify({ workStatus: status.value }) })); }
|
catch (error) { showError(error); }
|
});
|
statusCell.append(status);
|
row.append(identity, statusCell, metricsCell, action);
|
return row;
|
}
|
|
function measurementLines(items) {
|
if (!items.length) return '暂无测量';
|
return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n');
|
}
|
|
function field(labelText, type, value) {
|
const label = document.createElement('label');
|
const text = document.createElement('span');
|
text.textContent = labelText;
|
const input = document.createElement('input');
|
input.type = type;
|
input.value = value;
|
label.append(text, input);
|
return { label, input };
|
}
|
|
function openEditor(title) {
|
editorContent.replaceChildren();
|
document.querySelector('#editor-title').textContent = title;
|
document.querySelector('#editor-error').classList.remove('visible');
|
editor.showModal();
|
}
|
|
function numericField(label, value, integer = false) {
|
const result = field(label, 'number', value);
|
result.input.required = true;
|
result.input.step = integer ? '1' : 'any';
|
return result;
|
}
|
|
function iconButton(symbol, label, action) {
|
const button = document.createElement('button');
|
button.type = 'button'; button.className = 'icon-button'; button.textContent = symbol;
|
button.title = label; button.setAttribute('aria-label', label); button.onclick = action;
|
return button;
|
}
|
|
function editCabinetChannel(deviceId, channel) {
|
openEditor(`检测柜 ${deviceId} · 通道 ${channel.channelId} 参数与方案`);
|
const config = channel.configuration;
|
const fields = document.createElement('div'); fields.className = 'editor-fields';
|
const inputs = {};
|
for (const [key, label, integer] of [
|
['voltageV', '电压 (V)'], ['currentA', '电流 (A)'], ['capacityAh', '容量 (Ah)'], ['energyWh', '能量 (Wh)'],
|
['cycleNo', '当前循环', true], ['stepNo', '当前工步', true], ['plannedCycleCount', '计划循环数', true],
|
['totalTimeSeconds', '总时间 (s)', true], ['relativeTimeSeconds', '工步时间 (s)', true],
|
]) {
|
const item = numericField(label, config[key], integer);
|
if (integer) item.input.min = key.endsWith('Seconds') ? '0' : '1';
|
inputs[key] = item.input; fields.append(item.label);
|
}
|
const heading = document.createElement('div'); heading.className = 'section-title';
|
const title = document.createElement('h3'); title.textContent = '工步方案';
|
const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加工步';
|
heading.append(title, add);
|
const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
|
const table = document.createElement('table');
|
table.innerHTML = '<thead><tr><th style="width:50px">序号</th><th>工步名称</th><th style="width:140px">工步类型</th><th style="width:110px">时长 (s)</th><th style="width:100px">电压 (V)</th><th style="width:112px">操作</th></tr></thead>';
|
const body = document.createElement('tbody'); table.append(body); scroll.append(table);
|
const steps = [];
|
function syncRows() {
|
body.replaceChildren(...steps.map((step, index) => {
|
step.number.textContent = index + 1;
|
step.up.disabled = index === 0; step.down.disabled = index === steps.length - 1;
|
step.remove.disabled = steps.length === 1;
|
return step.row;
|
}));
|
inputs.stepNo.max = String(steps.length);
|
if (Number(inputs.stepNo.value) > steps.length) inputs.stepNo.value = steps.length;
|
}
|
function addStep(value) {
|
const row = document.createElement('tr'); const number = document.createElement('td'); row.append(number);
|
const name = field('工步名称', 'text', value.name).input; name.required = true; name.setAttribute('aria-label', '工步名称');
|
const type = document.createElement('select'); type.setAttribute('aria-label', '工步类型');
|
Object.entries(stepTypes).forEach(([code, label]) => type.add(new Option(label, code))); type.value = value.stepType;
|
const duration = numericField('时长 (s)', value.durationSeconds).input; duration.min = '0'; duration.setAttribute('aria-label', '时长 (s)');
|
const voltage = numericField('电压 (V)', value.voltageV).input; voltage.setAttribute('aria-label', '工步电压 (V)');
|
for (const input of [name, type, duration, voltage]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
|
const step = { row, number, name, type, duration, voltage };
|
const move = delta => { const index = steps.indexOf(step); steps.splice(index, 1); steps.splice(index + delta, 0, step); syncRows(); };
|
step.up = iconButton('↑', '上移工步', () => move(-1));
|
step.down = iconButton('↓', '下移工步', () => move(1));
|
step.remove = iconButton('×', '删除工步', () => { steps.splice(steps.indexOf(step), 1); syncRows(); });
|
const actions = document.createElement('td'); const buttons = document.createElement('div'); buttons.className = 'row-actions';
|
buttons.append(step.up, step.down, step.remove); actions.append(buttons); row.append(actions);
|
steps.push(step); syncRows();
|
}
|
config.steps.forEach(addStep);
|
// 初次填充方案行时保留保存过的当前工步位置。
|
inputs.stepNo.value = config.stepNo;
|
add.onclick = () => addStep({ name: '新工步', stepType: 'rest', durationSeconds: 60, voltageV: 0 });
|
editorContent.append(fields, heading, scroll);
|
saveEditor = () => request(`/devices/${deviceId}/channels/${channel.channelId}/configuration`, {
|
method: 'PUT', body: JSON.stringify({
|
...Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, Number(input.value)])),
|
steps: steps.map(step => ({ name: step.name.value, stepType: step.type.value,
|
durationSeconds: Number(step.duration.value), voltageV: Number(step.voltage.value) })),
|
}),
|
});
|
}
|
|
function editCollectorChannel(collectorId, channel) {
|
openEditor(`采集器 ${collectorId} · ${channel.channelCode} 测量值`);
|
const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加测量项';
|
const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
|
const table = document.createElement('table'); table.style.minWidth = '560px';
|
table.innerHTML = '<thead><tr><th>测量项</th><th>数值</th><th style="width:100px">单位</th><th style="width:50px">操作</th></tr></thead>';
|
const body = document.createElement('tbody'); table.append(body); scroll.append(table);
|
const metrics = [];
|
function addMetric(item) {
|
const row = document.createElement('tr');
|
const kind = document.createElement('select'); kind.setAttribute('aria-label', '测量项');
|
Object.entries(metricLabels).forEach(([code, label]) => kind.add(new Option(label, code)));
|
kind.add(new Option('自定义', 'custom')); kind.value = metricLabels[item.metricCode] ? item.metricCode : 'custom';
|
const code = field('自定义名称', 'text', item.metricCode).input; code.setAttribute('aria-label', '自定义名称');
|
const value = numericField('数值', item.value).input; value.setAttribute('aria-label', '数值');
|
const unit = field('单位', 'text', item.unit).input; unit.required = true; unit.setAttribute('aria-label', '单位');
|
const updateKind = () => {
|
code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
|
unit.readOnly = kind.value !== 'custom';
|
if (kind.value !== 'custom') unit.value = kind.value === 'temperature' ? '℃' : 'V';
|
};
|
code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
|
kind.onchange = updateKind;
|
const identity = document.createElement('td'); identity.append(kind, code); row.append(identity);
|
for (const input of [value, unit]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
|
const metric = { row, kind, code, value, unit };
|
const action = document.createElement('td');
|
metric.remove = iconButton('×', '删除测量项', () => {
|
metrics.splice(metrics.indexOf(metric), 1); row.remove(); syncButtons();
|
});
|
action.append(metric.remove); row.append(action); metrics.push(metric); body.append(row); syncButtons();
|
}
|
function syncButtons() { metrics.forEach(item => { item.remove.disabled = metrics.length === 1; }); }
|
channel.metrics.forEach(addMetric);
|
add.onclick = () => addMetric({ metricCode: '', value: 0, unit: 'V' });
|
editorContent.append(add, scroll);
|
saveEditor = () => {
|
const values = metrics.map(item => ({ metricCode: item.kind.value === 'custom' ? item.code.value.trim() : item.kind.value,
|
value: Number(item.value.value), unit: item.unit.value.trim() }));
|
if (new Set(values.map(item => item.metricCode)).size !== values.length) throw new Error('测量项不能重复');
|
return request(`/collectors/${collectorId}/channels/${channel.channelCode}`, {
|
method: 'PUT', body: JSON.stringify({ metrics: values }),
|
});
|
};
|
}
|
|
function channelRow(deviceId, channel) {
|
const row = document.createElement('tr');
|
const identity = document.createElement('td');
|
identity.textContent = `通道 ${channel.channelId}`;
|
const onlineCell = document.createElement('td');
|
const onlineLabel = document.createElement('label');
|
onlineLabel.className = 'switch-label';
|
const online = document.createElement('input');
|
online.type = 'checkbox';
|
online.checked = channel.online;
|
const onlineText = document.createElement('span');
|
onlineText.textContent = channel.online ? '在线' : '离线';
|
onlineLabel.append(online, onlineText);
|
onlineCell.append(onlineLabel);
|
const statusCell = document.createElement('td');
|
const status = document.createElement('select');
|
Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
|
status.value = channel.workStatus;
|
status.setAttribute('aria-label', `检测柜 ${deviceId} 通道 ${channel.channelId} 工作状态`);
|
const update = async () => {
|
try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); }
|
catch (error) { showError(error); await refresh(); }
|
};
|
online.addEventListener('change', update);
|
status.addEventListener('change', update);
|
statusCell.append(status);
|
const configCell = document.createElement('td');
|
const configure = document.createElement('button'); configure.type = 'button'; configure.textContent = '参数与方案';
|
configure.onclick = () => editCabinetChannel(deviceId, channel);
|
configCell.append(configure);
|
row.append(identity, onlineCell, statusCell, configCell);
|
return row;
|
}
|
|
function renderRequests(items) {
|
const requests = document.querySelector('#requests');
|
document.querySelector('#request-count').textContent = `${items.length} 条`;
|
if (!items.length) {
|
const row = document.createElement('tr');
|
const cell = document.createElement('td');
|
cell.colSpan = 3;
|
cell.className = 'empty';
|
cell.textContent = '暂无请求';
|
row.append(cell);
|
requests.replaceChildren(row);
|
return;
|
}
|
requests.replaceChildren(...items.map(item => {
|
const row = document.createElement('tr');
|
const time = document.createElement('td');
|
time.className = 'request-time';
|
time.textContent = new Date(item.receivedAt).toLocaleString('zh-CN', { hour12: false });
|
const command = document.createElement('td');
|
command.className = 'request-command';
|
command.textContent = item.command;
|
const xml = document.createElement('td');
|
xml.className = 'request-xml';
|
xml.textContent = `${item.request}\n${item.response || '暂无响应'}`;
|
xml.title = xml.textContent;
|
row.append(time, command, xml);
|
return row;
|
}));
|
}
|
|
async function refresh() {
|
if (editor.open) return;
|
if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
try { render(await request('/state')); } catch (error) { showError(error); }
|
}
|
|
document.getElementById('reset-simulator').addEventListener('click', async () => {
|
if (!confirm('确认清空全部模拟配置和状态并恢复默认设备?')) return;
|
try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); }
|
});
|
|
document.getElementById('software-accessible').addEventListener('change', async event => {
|
try { render(await request('/software', { method: 'PUT', body: JSON.stringify({ accessible: event.target.checked }) })); }
|
catch (error) { showError(error); }
|
});
|
|
document.getElementById('power-on').addEventListener('click', async () => {
|
try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); }
|
catch (error) { showError(error); }
|
});
|
|
document.getElementById('power-off').addEventListener('click', async () => {
|
if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return;
|
try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); }
|
catch (error) { showError(error); }
|
});
|
|
document.querySelector('#add-device').addEventListener('submit', async event => {
|
event.preventDefault();
|
try {
|
render(await request('/devices', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-ip').value, channelCount: Number(document.querySelector('#new-channel-count').value), model: document.querySelector('#new-device-model').value || null }) }));
|
} catch (error) { showError(error); }
|
});
|
|
document.querySelector('#add-collector').addEventListener('submit', async event => {
|
event.preventDefault();
|
try {
|
render(await request('/collectors', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-collector-ip').value, channelCount: Number(document.querySelector('#new-collector-channel-count').value) }) }));
|
} catch (error) { showError(error); }
|
});
|
|
refresh();
|
setInterval(refresh, 2000);
|
</script>
|
</body>
|
</html>
|