From f522c9ffa487e3710d691e8b98df8c4f0cfafd43 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 20:07:26 +0800
Subject: [PATCH] 支持模拟器逐通道参数与工步方案配置
---
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html | 203 ++++++++++++++++++++++++++++++++++++++++++++++----
1 files changed, 184 insertions(+), 19 deletions(-)
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
index b01db74..a0ae882 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -55,6 +55,19 @@
.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; }
@@ -69,6 +82,7 @@
th, td { padding: 8px; }
.request-time { width: 130px; }
.request-command { width: 90px; }
+ .editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
</head>
@@ -127,10 +141,33 @@
</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' } });
@@ -148,6 +185,11 @@
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) {
@@ -242,7 +284,7 @@
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></tr></thead>';
+ 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);
@@ -337,18 +379,13 @@
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);
+ 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', async () => {
- try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { method: 'PUT', body: JSON.stringify({ metrics: parseMetrics(metrics.value) }) })); }
- catch (error) { showError(error); }
- });
+ save.textContent = '编辑测量值';
+ save.addEventListener('click', () => editCollectorChannel(collectorId, channel));
action.append(save);
const statusCell = document.createElement('td');
const status = document.createElement('select');
@@ -362,14 +399,6 @@
statusCell.append(status);
row.append(identity, statusCell, 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) {
@@ -386,6 +415,137 @@
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) {
@@ -414,7 +574,11 @@
online.addEventListener('change', update);
status.addEventListener('change', update);
statusCell.append(status);
- row.append(identity, onlineCell, statusCell);
+ 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;
}
@@ -449,6 +613,7 @@
}
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); }
}
--
Gitblit v1.7.1