<!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(180px, 1fr) 130px 140px auto auto; }
|
.collector-settings { display: grid; grid-template-columns: minmax(160px, 1fr) 110px 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
|
.sampling-settings { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 12px; align-items: end; padding: 0 16px 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: nowrap; }
|
.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; }
|
@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, .sampling-settings { grid-template-columns: 1fr 1fr; }
|
.device-settings button, .collector-settings button { width: 100%; }
|
.device { margin: 10px; overflow-x: auto; }
|
.device table { min-width: 520px; }
|
th, td { padding: 8px; }
|
.request-time { width: 130px; }
|
.request-command { width: 90px; }
|
}
|
</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">
|
<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>请求 XML</th></tr></thead>
|
<tbody id="requests"></tbody>
|
</table>
|
</section>
|
</main>
|
<script>
|
const api = '/dev/bts-simulator';
|
const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' };
|
const errorBox = document.querySelector('#error');
|
|
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');
|
}
|
|
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 : [],
|
};
|
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
|
&& device.channels.every(channel => channel.online))
|
&& state.collectors.every(collector => collector.online);
|
const devicesPoweredOff = state.devices.every(device => !device.online
|
&& device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED'))
|
&& 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} / ${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 style="width:18%">通道</th><th style="width:22%">在线状态</th><th style="width:20%">工作状态</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 sampleInterval = field('采样间隔(毫秒)', 'number', collector.sampleIntervalMs);
|
sampleInterval.input.min = '100';
|
sampleInterval.input.max = '60000';
|
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 {
|
await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) });
|
render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), ...collector.historyFaults }) }));
|
}
|
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, sampleInterval.label, onlineLabel, save, remove);
|
|
const faults = document.createElement('div');
|
faults.className = 'sampling-settings';
|
const missing = field('缺失序号(逗号分隔)', 'text', collector.historyFaults.missingSequences.join(','));
|
const duplicate = field('重复序号', 'number', collector.historyFaults.duplicateSequence || '');
|
const conflict = field('冲突序号', 'number', collector.historyFaults.conflictingSequence || '');
|
const outOfOrderLabel = document.createElement('label');
|
outOfOrderLabel.className = 'switch-label';
|
const outOfOrder = document.createElement('input');
|
outOfOrder.type = 'checkbox';
|
outOfOrder.checked = collector.historyFaults.outOfOrder;
|
const outOfOrderText = document.createElement('span');
|
outOfOrderText.textContent = '历史乱序';
|
outOfOrderLabel.append(outOfOrder, outOfOrderText);
|
const saveFaults = document.createElement('button');
|
saveFaults.type = 'button';
|
saveFaults.textContent = '保存历史场景';
|
saveFaults.addEventListener('click', async () => {
|
const missingSequences = missing.input.value.split(',').map(value => value.trim()).filter(Boolean).map(Number);
|
try { render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), missingSequences, duplicateSequence: numberOrNull(duplicate.input.value), conflictingSequence: numberOrNull(conflict.input.value), outOfOrder: outOfOrder.checked }) })); }
|
catch (error) { showError(error); }
|
});
|
faults.append(missing.label, duplicate.label, conflict.label, outOfOrderLabel, saveFaults);
|
|
const table = document.createElement('table');
|
table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</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 historyButton = document.createElement('button');
|
historyButton.type = 'button';
|
historyButton.textContent = '下载历史';
|
const historyCount = document.createElement('span');
|
historyCount.className = 'label';
|
historyCount.textContent = `原始历史 ${collector.historyRecordCount} 条`;
|
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); }
|
});
|
historyButton.addEventListener('click', async () => {
|
try {
|
const page = await request(`/collectors/${collector.collectorId}/history?offset=0&limit=20`);
|
output.textContent = `历史 ${page.total} 条 / 本页 ${page.records.length} 条\n${measurementLines(page.records)}`;
|
} catch (error) { showError(error); }
|
});
|
actions.append(realtimeButton, historyButton, historyCount);
|
panel.append(title, settings, faults, 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');
|
const metrics = document.createElement('textarea');
|
metrics.setAttribute('aria-label', `${channel.channelCode} 测量项`);
|
metrics.value = channel.metrics.map(item => `${item.metricCode}=${item.value} ${item.unit}`).join('\n');
|
metricsCell.append(metrics);
|
const action = document.createElement('td');
|
const save = document.createElement('button');
|
save.type = 'button';
|
save.textContent = '保存测量值';
|
save.addEventListener('click', async () => {
|
try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { method: 'PUT', body: JSON.stringify({ metrics: parseMetrics(metrics.value) }) })); }
|
catch (error) { showError(error); }
|
});
|
action.append(save);
|
row.append(identity, metricsCell, action);
|
return row;
|
}
|
|
function parseMetrics(text) {
|
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
const match = line.match(/^([^=\s]+)=(-?\d+(?:\.\d+)?)\s+(.+)$/);
|
if (!match) throw new Error(`测量项格式错误:${line}`);
|
return { metricCode: match[1], value: Number(match[2]), unit: match[3] };
|
});
|
}
|
|
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 numberOrNull(value) {
|
return value.trim() ? Number(value) : null;
|
}
|
|
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 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;
|
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 playbackCell = document.createElement('td');
|
const speed = document.createElement('select');
|
[['1','1x'], ['60','60x'], ['600','600x']].forEach(([value, label]) => speed.add(new Option(label, value)));
|
const controls = document.createElement('span');
|
controls.style.cssText = 'display:inline-flex;gap:5px;margin-left:6px';
|
const action = (label, path) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback/${path}`, { method: 'POST' }); await refresh(); } catch (error) { showError(error); } }); return button; };
|
speed.addEventListener('change', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback`, { method: 'PUT', body: JSON.stringify({ speedMultiplier: Number(speed.value) }) }); await refresh(); } catch (error) { showError(error); } });
|
controls.append(action('播放', 'play'), action('暂停', 'pause'), action('下一工步', 'next-step'), action('下一循环', 'next-cycle'), action('重置', 'reset'));
|
playbackCell.append(speed, controls);
|
row.append(identity, onlineCell, statusCell, playbackCell);
|
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;
|
xml.title = item.request;
|
row.append(time, command, xml);
|
return row;
|
}));
|
}
|
|
async function refresh() {
|
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('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>
|