新增功能: - 线圈参数管理页 (/coil-info): 增删改查,日志记录 - 模拟车辆管理页 (/simulate-car): 增删改查,日志记录 - 工装配置页新增线圈/模拟车辆选择区,保存时关联到 tb_fixture_param - 测试信息查询页新增「测试环境」列,显示当前线圈和模拟车辆信息 - edc_server 写入测试数据时自动从 fixture 获取线圈/车辆关联 数据库: - 新增 tb_coil_info、tb_simulate_car 表 - tb_fixture_param 增加 coil_id/simulate_car_id 字段 - tb_state_tst 增加 coil_id/simulate_car_id 字段 后端: - models.py 新增线圈/模拟车辆 CRUD - get_fixture_param 改为 LEFT JOIN 返回线圈/车辆详情 - upsert_fixture_param 支持 coil_id/simulate_car_id - 测试数据查询 LEFT JOIN 线圈/车辆信息
481 lines
19 KiB
JavaScript
481 lines
19 KiB
JavaScript
// 测试信息页 — 三视图 (全部 / B2 / B4)
|
||
|
||
// ─── 视图定义 ───────────────────────────────────
|
||
|
||
const VIEWS = {
|
||
all: {
|
||
label: '全部数据',
|
||
data_source: '', // '' = 不过滤
|
||
cols: [
|
||
{ key: 'id', title: 'ID' },
|
||
{ key: 'serial', title: '设备编码' },
|
||
{ key: 'dpg430_addr', title: '地址' },
|
||
{ key: 'model', title: '型号', render: r => r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-' },
|
||
{ key: 'str_type', title: '类型' },
|
||
{ key: 'data_source', title: '来源' },
|
||
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
|
||
{ key: 'ppvalue', title: '峰峰值(V)', render: r => r.ppvalue?.toFixed(2) || '-' },
|
||
{ key: 'idle_freq', title: '开始频率' },
|
||
{ key: 'enter_dist', title: '进入距离' },
|
||
{ key: 'exit_dist', title: '离开距离' },
|
||
{ key: 'remain_count', title: '剩余次数' },
|
||
{ key: 'curr_dist', title: '当前距离' },
|
||
{ key: 'env', title: '测试环境', render: r => envLabel(r) },
|
||
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
|
||
],
|
||
},
|
||
b2: {
|
||
label: '灵敏度测试',
|
||
data_source: 'B2',
|
||
cols: [
|
||
{ key: 'id', title: 'ID' },
|
||
{ key: 'serial', title: '设备编码' },
|
||
{ key: 'dpg430_addr', title: '地址' },
|
||
{ key: 'model', title: '型号', render: r => r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-' },
|
||
{ key: 'str_type', title: '类型' },
|
||
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
|
||
{ key: 'iffinish', title: '完成', render: r => r.iffinish === '1' ? '是' : '否' },
|
||
{ key: 'fault_info', title: '故障信息' },
|
||
{ key: 'relay_out', title: '继电器', render: r => decodeRelay(r.relay_code) },
|
||
{ key: 'ppvalue', title: '峰峰值(V)', render: r => r.ppvalue?.toFixed(2) || '-' },
|
||
{ key: 'idle_freq', title: '开始频率' },
|
||
{ key: 'enter_freq', title: '进入频率' },
|
||
{ key: 'exit_freq', title: '离开频率' },
|
||
{ key: 'enter_dist', title: '进入距离' },
|
||
{ key: 'exit_dist', title: '离开距离' },
|
||
{ key: 'enter_speed', title: '进入速度', render: r => toSpeed(r.enter_speed) },
|
||
{ key: 'exit_speed', title: '离开速度', render: r => toSpeed(r.exit_speed) },
|
||
{ key: 'env', title: '测试环境', render: r => envLabel(r) },
|
||
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
|
||
],
|
||
},
|
||
b4: {
|
||
label: '波动测试',
|
||
data_source: 'B4',
|
||
cols: [
|
||
{ key: 'id', title: 'ID' },
|
||
{ key: 'serial', title: '设备编码' },
|
||
{ key: 'dpg430_addr', title: '地址' },
|
||
{ key: 'remain_count', title: '剩余次数' },
|
||
{ key: 'work_freq', title: '工作频率(Hz)' },
|
||
{ key: 'curr_dist', title: '当前距离(mm)' },
|
||
{ key: 'speed', title: '速度(dm/s)' },
|
||
{ key: 'near_dist', title: '最近距离(mm)' },
|
||
{ key: 'far_dist', title: '最远距离(mm)' },
|
||
{ key: 'b4_enter_dist', title: '进入高度(mm)' },
|
||
{ key: 'b4_leave_dist', title: '离开高度(mm)' },
|
||
{ key: 'relay_out', title: '继电器', render: r => decodeRelay(r.relay_code) },
|
||
{ key: 'env', title: '测试环境', render: r => envLabel(r) },
|
||
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
|
||
],
|
||
},
|
||
};
|
||
|
||
// ─── 状态 ───────────────────────────────────────
|
||
|
||
let currentView = 'all';
|
||
let currentPage = 1;
|
||
let totalPages = 1;
|
||
|
||
function toSpeed(v) {
|
||
if (v === null || v === undefined || v === '') return '-';
|
||
return (parseFloat(v) / 10).toFixed(1);
|
||
}
|
||
|
||
function fmtTime(v) {
|
||
if (!v) return '-';
|
||
// Flask jsonify 给 MySQL DATETIME 加 "GMT" 后缀,但实际值是服务器本地时间(UTC+8)
|
||
// 去掉 "GMT" 让 JS 按本地时间解析,避免时区偏移 8 小时
|
||
const cleaned = String(v).replace(/ GMT$/, '');
|
||
const d = new Date(cleaned);
|
||
if (isNaN(d.getTime())) return String(v).substring(0, 19);
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const d2 = String(d.getDate()).padStart(2, '0');
|
||
const h = String(d.getHours()).padStart(2, '0');
|
||
const min = String(d.getMinutes()).padStart(2, '0');
|
||
const s = String(d.getSeconds()).padStart(2, '0');
|
||
return `${y}-${m}-${d2} ${h}:${min}:${s}`;
|
||
}
|
||
|
||
const RELAY_MAP = {
|
||
0: '无输出',
|
||
1: '存在信号',
|
||
2: '脉冲信号',
|
||
3: '存在信号; 脉冲信号',
|
||
};
|
||
function decodeRelay(v) {
|
||
if (v === null || v === undefined || v === '') return '-';
|
||
return RELAY_MAP[parseInt(v)] || `0x${parseInt(v).toString(16).toUpperCase().padStart(2, '0')}`;
|
||
}
|
||
|
||
/** 构建测试环境标签 (线圈 + 模拟车辆) */
|
||
function envLabel(r) {
|
||
const parts = [];
|
||
if (r.coil_num || r.coil_name) {
|
||
parts.push('🧵' + (r.coil_num || r.coil_name));
|
||
}
|
||
if (r.simulate_num || r.car_name) {
|
||
parts.push('🚗' + (r.simulate_num || r.car_name));
|
||
}
|
||
return parts.join(' ') || '-';
|
||
}
|
||
|
||
// ─── 视图切换 ────────────────────────────────────
|
||
|
||
function switchView(view) {
|
||
currentView = view;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('tab-' + view).classList.add('active');
|
||
// 重置分页
|
||
currentPage = 1;
|
||
searchData(1);
|
||
}
|
||
|
||
// ─── 查询 ────────────────────────────────────────
|
||
|
||
/** 合并日期和时间输入框,返回 "YYYY-MM-DD" 或 "YYYY-MM-DD HH:MM:SS" 或 "" */
|
||
function getDatetime(dateId, timeId) {
|
||
const d = document.getElementById(dateId).value;
|
||
const t = document.getElementById(timeId).value;
|
||
if (!d) return "";
|
||
if (!t) return d;
|
||
return d + " " + t;
|
||
}
|
||
|
||
async function searchData(page = 1) {
|
||
currentPage = page;
|
||
const serial = document.getElementById("search-serial").value;
|
||
const dateFrom = getDatetime("search-date-from", "search-time-from");
|
||
const dateTo = getDatetime("search-date-to", "search-time-to");
|
||
const v = VIEWS[currentView];
|
||
|
||
const perPage = parseInt(document.getElementById("per-page").value) || 20;
|
||
|
||
const params = new URLSearchParams({ page, per_page: perPage });
|
||
if (serial) params.set("serial", serial);
|
||
if (dateFrom) params.set("date_from", dateFrom);
|
||
if (dateTo) params.set("date_to", dateTo);
|
||
// 按 data_source 过滤(全部不过滤)
|
||
if (v.data_source) {
|
||
params.set("data_source", v.data_source);
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch(`/api/test-data?${params}`);
|
||
const data = await resp.json();
|
||
renderTable(data.records);
|
||
totalPages = data.pages;
|
||
renderPagination();
|
||
} catch (e) {
|
||
console.error("查询失败:", e);
|
||
}
|
||
}
|
||
|
||
// ─── 渲染表头 ────────────────────────────────────
|
||
|
||
function renderHead() {
|
||
const thead = document.querySelector("#test-data-table thead");
|
||
const v = VIEWS[currentView];
|
||
thead.innerHTML = '<tr>' +
|
||
v.cols.map(c => `<th>${c.title}</th>`).join('') +
|
||
'</tr>';
|
||
}
|
||
|
||
// ─── 渲染数据行 ──────────────────────────────────
|
||
|
||
function renderTable(records) {
|
||
renderHead();
|
||
const tbody = document.querySelector("#test-data-table tbody");
|
||
const v = VIEWS[currentView];
|
||
const nCols = v.cols.length;
|
||
|
||
if (!records.length) {
|
||
tbody.innerHTML = `<tr><td colspan="${nCols}" style="text-align:center;color:#999;">暂无数据</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = records.map(r =>
|
||
'<tr>' + v.cols.map(c => {
|
||
if (c.render) return `<td>${c.render(r)}</td>`;
|
||
const val = r[c.key];
|
||
return `<td>${val !== null && val !== undefined && val !== '' ? val : '-'}</td>`;
|
||
}).join('') + '</tr>'
|
||
).join("");
|
||
}
|
||
|
||
// ─── 分页 ────────────────────────────────────────
|
||
|
||
function renderPagination() {
|
||
const div = document.getElementById("pagination");
|
||
let html = "";
|
||
html += `<button onclick="searchData(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
if (i === currentPage) {
|
||
html += `<button class="active">${i}</button>`;
|
||
} else {
|
||
html += `<button onclick="searchData(${i})">${i}</button>`;
|
||
}
|
||
}
|
||
html += `<button onclick="searchData(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
|
||
div.innerHTML = html;
|
||
}
|
||
|
||
// ─── 导出 ────────────────────────────────────────
|
||
|
||
function exportCSV() {
|
||
const serial = document.getElementById("search-serial").value;
|
||
const dateFrom = getDatetime("search-date-from", "search-time-from");
|
||
const dateTo = getDatetime("search-date-to", "search-time-to");
|
||
const v = VIEWS[currentView];
|
||
|
||
const params = new URLSearchParams();
|
||
if (serial) params.set("serial", serial);
|
||
if (dateFrom) params.set("date_from", dateFrom);
|
||
if (dateTo) params.set("date_to", dateTo);
|
||
if (v.data_source) params.set("data_source", v.data_source);
|
||
|
||
window.location.href = `/api/test-data/export?${params}`;
|
||
}
|
||
|
||
// ─── 图表 ────────────────────────────────────────
|
||
|
||
let chartMode = false;
|
||
let chartInstance = null;
|
||
|
||
// 图表系列定义
|
||
const CHART_SERIES = {
|
||
b2: [
|
||
{ key: 'ppvalue', name: '峰峰值', unit: 'V', yAxisIndex: 0 },
|
||
{ key: 'idle_freq', name: '开始频率', unit: 'Hz', yAxisIndex: 0 },
|
||
{ key: 'enter_freq', name: '进入频率', unit: 'Hz', yAxisIndex: 0 },
|
||
{ key: 'exit_freq', name: '离开频率', unit: 'Hz', yAxisIndex: 0 },
|
||
{ key: 'enter_dist', name: '进入距离', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'exit_dist', name: '离开距离', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'enter_speed', name: '进入速度', unit: 'dm/s',yAxisIndex: 2 },
|
||
{ key: 'exit_speed', name: '离开速度', unit: 'dm/s',yAxisIndex: 2 },
|
||
],
|
||
b4: [
|
||
{ key: 'work_freq', name: '工作频率', unit: 'Hz', yAxisIndex: 0 },
|
||
{ key: 'curr_dist', name: '当前距离', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'speed', name: '速度', unit: 'dm/s',yAxisIndex: 2 },
|
||
{ key: 'near_dist', name: '最近距离', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'far_dist', name: '最远距离', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'b4_enter_dist', name: '进入高度', unit: 'mm', yAxisIndex: 1 },
|
||
{ key: 'b4_leave_dist', name: '离开高度', unit: 'mm', yAxisIndex: 1 },
|
||
],
|
||
};
|
||
|
||
// 继电器系列(添加到任意视图的图表末尾)
|
||
function buildRelaySeries(records) {
|
||
return {
|
||
name: '继电器输出',
|
||
type: 'line',
|
||
step: 'end',
|
||
yAxisIndex: 3,
|
||
symbol: 'triangle',
|
||
symbolSize: 8,
|
||
lineStyle: { type: 'dotted', width: 2, color: '#e74c3c' },
|
||
itemStyle: { color: '#e74c3c' },
|
||
data: records.map(r => r.relay_code ?? null),
|
||
// tooltip 中显示解码后的文本
|
||
tooltip: {
|
||
valueFormatter: function (value) {
|
||
return RELAY_MAP[value] || `未知(${value})`;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
function toggleChart() {
|
||
const container = document.getElementById('chart-container');
|
||
const btn = document.getElementById('btn-chart');
|
||
const table = document.getElementById('test-data-table');
|
||
const pagination = document.getElementById('pagination');
|
||
|
||
chartMode = !chartMode;
|
||
if (chartMode) {
|
||
container.style.display = 'block';
|
||
table.style.display = 'none';
|
||
pagination.style.display = 'none';
|
||
btn.textContent = '📋 表格';
|
||
btn.classList.add('active');
|
||
// 只对 B2/B4 视图显示图表
|
||
if (currentView === 'all') switchView('b2');
|
||
loadChart();
|
||
} else {
|
||
container.style.display = 'none';
|
||
table.style.display = '';
|
||
pagination.style.display = '';
|
||
btn.textContent = '📈 图表';
|
||
btn.classList.remove('active');
|
||
if (chartInstance) { chartInstance.dispose(); chartInstance = null; }
|
||
}
|
||
}
|
||
|
||
async function loadChart() {
|
||
const container = document.getElementById('chart-container');
|
||
if (!container || container.style.display === 'none') return;
|
||
|
||
const serial = document.getElementById('search-serial').value;
|
||
const dateFrom = getDatetime('search-date-from', 'search-time-from');
|
||
const dateTo = getDatetime('search-date-to', 'search-time-to');
|
||
const v = VIEWS[currentView];
|
||
|
||
// 全部视图不适用,用 B2 或 B4
|
||
const ds = v.data_source || (currentView === 'all' ? 'B2' : v.data_source);
|
||
|
||
const params = new URLSearchParams();
|
||
if (serial) params.set('serial', serial);
|
||
if (dateFrom) params.set('date_from', dateFrom);
|
||
if (dateTo) params.set('date_to', dateTo);
|
||
if (ds) params.set('data_source', ds);
|
||
|
||
let resp, data;
|
||
try {
|
||
resp = await fetch(`/api/test-data/chart?${params}`);
|
||
data = await resp.json();
|
||
} catch (e) {
|
||
console.error('加载图表数据失败:', e);
|
||
return;
|
||
}
|
||
|
||
const records = data.records || [];
|
||
if (!records.length) {
|
||
container.innerHTML = '<p style="text-align:center;color:#999;padding:100px;">暂无数据</p>';
|
||
return;
|
||
}
|
||
|
||
// 选系列定义
|
||
const seriesDef = CHART_SERIES[ds === 'B4' ? 'b4' : 'b2'] || CHART_SERIES.b2;
|
||
|
||
// 时间轴
|
||
const times = records.map(r => r.create_time);
|
||
|
||
// 构建 series
|
||
const series = seriesDef.map(def => ({
|
||
name: `${def.name}(${def.unit})`,
|
||
type: 'line',
|
||
yAxisIndex: def.yAxisIndex,
|
||
symbol: 'circle',
|
||
symbolSize: 4,
|
||
data: records.map(r => r[def.key] ?? null),
|
||
connectNulls: false,
|
||
}));
|
||
|
||
// 添加继电器状态系列
|
||
series.push(buildRelaySeries(records));
|
||
|
||
// 渲染 ECharts
|
||
if (chartInstance) chartInstance.dispose();
|
||
chartInstance = echarts.init(container);
|
||
|
||
const option = {
|
||
title: {
|
||
text: ds === 'B4' ? '波动测试 (0xB4) 数据趋势' : '灵敏度测试 (0xB2) 数据趋势',
|
||
left: 'center',
|
||
textStyle: { fontSize: 14 },
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
},
|
||
legend: {
|
||
type: 'scroll',
|
||
bottom: 0,
|
||
},
|
||
toolbox: {
|
||
right: 10,
|
||
top: 10,
|
||
feature: {
|
||
saveAsImage: {
|
||
title: '保存图片',
|
||
pixelRatio: 2,
|
||
},
|
||
},
|
||
},
|
||
grid: { left: 60, right: 200, top: 60, bottom: 80 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: times,
|
||
axisLabel: {
|
||
formatter: v => fmtTime(v).substring(5, 16), // MM-dd HH:mm
|
||
rotate: 30,
|
||
},
|
||
},
|
||
yAxis: [
|
||
{ type: 'value', name: '频率/电压', nameTextStyle: { fontSize: 11 } },
|
||
{ type: 'value', name: '距离(mm)', nameTextStyle: { fontSize: 11 } },
|
||
{ type: 'value', name: '速度(dm/s)',nameTextStyle: { fontSize: 11 },
|
||
offset: 80 },
|
||
{ type: 'value', name: '继电器输出', nameTextStyle: { fontSize: 11 },
|
||
min: -0.5, max: 3.5, interval: 1,
|
||
offset: 160,
|
||
axisLabel: {
|
||
formatter: function (v) {
|
||
return RELAY_MAP[v] || '';
|
||
},
|
||
fontSize: 10,
|
||
}},
|
||
],
|
||
dataZoom: [
|
||
{ type: 'slider', start: 0, end: 100, height: 20, bottom: 30 },
|
||
{ type: 'inside' },
|
||
],
|
||
series: series,
|
||
};
|
||
|
||
chartInstance.setOption(option);
|
||
|
||
// 窗口 resize 时自适应
|
||
window.addEventListener('resize', () => {
|
||
if (chartInstance) chartInstance.resize();
|
||
}, { once: false });
|
||
}
|
||
|
||
// ─── 初始加载 ────────────────────────────────────
|
||
|
||
renderHead();
|
||
searchData(1);
|
||
|
||
// ─── 删除(admin)─────────────────────────────────
|
||
|
||
function confirmDelete() {
|
||
const serial = document.getElementById('search-serial').value;
|
||
const dateFrom = getDatetime('search-date-from', 'search-time-from');
|
||
const dateTo = getDatetime('search-date-to', 'search-time-to');
|
||
const v = VIEWS[currentView];
|
||
const ds = v.data_source || '';
|
||
|
||
let desc = '';
|
||
if (serial) desc += `设备: ${serial}\n`;
|
||
if (dateFrom || dateTo) desc += `日期: ${dateFrom || '不限'} ~ ${dateTo || '不限'}\n`;
|
||
if (ds) desc += `数据来源: ${ds}\n`;
|
||
if (!desc) desc = '⚠ 未设置任何筛选条件,不会删除任何数据';
|
||
|
||
const msg = `确认删除以下条件的测试数据?\n\n${desc}\n此操作不可撤销!`;
|
||
if (!confirm(msg)) return;
|
||
|
||
doDelete(serial, dateFrom, dateTo, ds);
|
||
}
|
||
|
||
async function doDelete(serial, dateFrom, dateTo, dataSource) {
|
||
try {
|
||
const resp = await fetch('/api/test-data/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
serial, date_from: dateFrom,
|
||
date_to: dateTo, data_source: dataSource,
|
||
}),
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
alert(`已删除 ${data.deleted} 条记录`);
|
||
searchData(1);
|
||
} else {
|
||
alert('删除失败: ' + (data.error || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('删除请求失败: ' + e.message);
|
||
}
|
||
}
|