yeg
2026-09-05 7259e265b9237286996b09da03038ea5a133e1e0
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -24,9 +24,9 @@
    .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; }
    .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; }
@@ -49,7 +49,7 @@
    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; }
    .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; }
@@ -60,10 +60,12 @@
      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; }
      .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: 520px; }
      .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; }
@@ -76,10 +78,11 @@
    <div id="error" class="error"></div>
    <section>
      <div class="section-title">
        <h2>运行状态</h2>
        <h2>软件连接</h2>
        <div class="power-actions">
          <button id="power-on" type="button">一键模拟开机</button>
          <button id="power-off" class="danger" type="button">一键模拟关机</button>
          <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>
@@ -119,14 +122,14 @@
    <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>
        <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead>
        <tbody id="requests"></tbody>
      </table>
    </section>
  </main>
  <script>
    const api = '/dev/bts-simulator';
    const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' };
    const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' };
    const errorBox = document.querySelector('#error');
    async function request(path, options) {
@@ -157,18 +160,17 @@
        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
          && device.channels.every(channel => channel.online))
        && state.devices.every(device => device.online)
        && state.collectors.every(collector => collector.online);
      const devicesPoweredOff = state.devices.every(device => !device.online
          && device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED'))
      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.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线';
      devicePower.classList.toggle('online', devicesPoweredOn);
      document.querySelector('#power-on').disabled = devicesPoweredOn;
      document.querySelector('#power-off').disabled = devicesPoweredOff;
@@ -198,7 +200,7 @@
        heading.textContent = `检测柜 ${device.deviceId}`;
        const identity = document.createElement('span');
        identity.className = 'label';
        identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / ${device.profile.model}`;
        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');
@@ -240,7 +242,7 @@
        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>';
        table.innerHTML = '<thead><tr><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);
@@ -276,9 +278,6 @@
        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');
@@ -293,8 +292,7 @@
        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 }) }));
            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); }
        });
@@ -306,33 +304,10 @@
          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);
        settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
        const table = document.createElement('table');
        table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</th><th>测量项(每行:指标编码=值 单位)</th><th style="width:130px">操作</th></tr></thead>';
        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);
@@ -342,29 +317,17 @@
        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);
        actions.append(realtimeButton);
        panel.append(title, settings, table, actions, output);
        return panel;
      }));
    }
@@ -387,7 +350,17 @@
        catch (error) { showError(error); }
      });
      action.append(save);
      row.append(identity, metricsCell, action);
      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;
    }
@@ -402,10 +375,6 @@
    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) {
@@ -437,6 +406,7 @@
      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(); }
@@ -444,16 +414,7 @@
      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);
      row.append(identity, onlineCell, statusCell);
      return row;
    }
@@ -480,8 +441,8 @@
        command.textContent = item.command;
        const xml = document.createElement('td');
        xml.className = 'request-xml';
        xml.textContent = item.request;
        xml.title = item.request;
        xml.textContent = `${item.request}\n${item.response || '暂无响应'}`;
        xml.title = xml.textContent;
        row.append(time, command, xml);
        return row;
      }));
@@ -497,13 +458,18 @@
      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;
      if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return;
      try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); }
      catch (error) { showError(error); }
    });