Compare commits

...

64 Commits

Author SHA1 Message Date
wangfq
8a6b5c6d07 测试信息页面优化: 限制6000条、字段调整、车检器序列号搜索
- 查询/导出/图表统一 LIMIT 6000 条
- 列顺序: 时间→第一列, 测试模式→最后一列, 隐藏ID
- 设备编码只显示后6位, 默认每页100条
- 新增车检器序列号搜索 (detector_serial LIKE)
- 四个文件同步修改: models.py, test_data.py, test_data.js, test_data.html
2026-06-18 09:44:18 +08:00
wangfq
f0ec79ca2f chore: 更新 edc_server 子模块 (fix detector_serial B4 缺失) 2026-06-15 16:45:53 +08:00
wangfq
4b082e35df refactor: 调整车检器序列号相关命名 2026-06-15 13:41:22 +08:00
wangfq
521cbe4107 feat: edc-web 支持车检器序列号输入与显示
- 自动化测试区域新增「车检器序列号」输入框
- 回车键自动触发「开始」按钮
- /api/automation/start 接收 detector_serial,写入 tb_pending_detector
- 测试操作页 + 测试信息页(全部/B2/B4)显示序列号,空时显示 '-'
- 页面加载和测试结束后焦点自动回到序列号输入框(全选),方便连续测试
2026-06-15 10:02:51 +08:00
wangfq
4ac6cbb2fe docs: 2026.06.09~06.12 周报 2026-06-12 18:22:30 +08:00
wangfq
0dfb928375 feat: 被动轮询间隔改为 3 秒 + 新增 B2 记录条数显示
- 数据轮询独立于设备状态刷新:refreshLatestData 每 3 秒,refreshAll 每 5 秒
- 「当前测试数据」标题旁显示新记录条数 (x 条新记录)
- 仅 B2(灵敏度测试) 新记录计数,页面加载/自动化开始时复位
- pollProgress 同步更新计数器,自动化与被动模式无冲突
2026-06-12 11:10:09 +08:00
wangfq
366c7f909a feat: 测试操作页添加被动轮询,实时显示工装本地按键触发的上报数据
- 新增 refreshLatestData() 每 5 秒轮询最新测试数据(不传 since 参数)
- lastLatestId/lastWaveId 跟踪最新记录,避免重复渲染
- 自动化运行中跳过被动轮询,避免与 pollProgress 冲突
- pollProgress 同步更新跟踪 ID,自动化结束后无缝衔接
2026-06-12 10:45:58 +08:00
wangfq
b4b7387b39 fix: 前端型号显示改为从 API 动态获取,修复新增加型号显示 Unknown(3)
- test_op.js: renderConfigOverview 硬编码 devTypeMap → devTypeNameCache[DevType]
- test_op.js: renderLatest 增加 sub_type 回退查找,兼容旧数据
- test_op.js: 每 5 秒刷新型号名称缓存,工装页新增型号后自动同步
- test_data.js: 型号列三元硬编码 → getDevTypeName(sub_type)
- 子模块 edc_server: 同步设备型号名称数据库查询
2026-06-12 10:00:33 +08:00
wangfq
aa2815b5cc fix: analyst 访问受限页面时自动跳转到测试数据页,并显示提示 2026-06-11 17:32:44 +08:00
wangfq
17e1d232e8 feat: 增加 analyst 角色——仅测试数据查询/下载+修改密码 2026-06-11 17:21:49 +08:00
wangfq
317c15aff2 chore: 从 git 跟踪中移除 __pycache__/*.pyc 2026-06-11 10:02:01 +08:00
wangfq
501e58b65f fix: UI 标签优化、继电器着色、工装配置概览面板
- fixture.js: FarTol/NearTol/StepTol 容差字段去掉 ×10 换算
- test_data.js: relay_out 列增加 fmtRelay() 着色渲染
- test_op.js: 新增工装配置概览面板 (renderConfigOverview + toggleConfig);新增 fmtRelay();renderLatest 继电器着色
- fixture.html: 标签文本优化 — 触发距离/释放距离/mm/V 单位标注
- test_op.html: 新增配置概览面板 HTML,隐藏旧 test-mode-indicator
- vehicle_base_test.html: 标签文本统一(触发/释放距离 + 单位)
- .gitignore: 新增,排除 __pycache__/*.pyc/.venv
2026-06-11 10:01:49 +08:00
wangfq
000e4f8d3a feat: 增加 manager 角色,admin+manager 共享管理权限(用户管理除外),所有用户可自行修改密码
- auth.py: 新增 privileged_required 装饰器 (admin+manager),admin_required 仅限用户管理
- 路由权限: fixture/logs/device_logs/test_data 的 admin 检查改为 admin+manager
- 前端: 导航栏/删除按钮/配置按钮扩展为 admin+manager 可见
- 用户管理: 角色下拉增加 manager 选项,仍仅 admin 可访问
- 新增 /change-password 路由+模板,所有登录用户可自行修改密码
- edc_server models.py: role COMMENT 更新 + ALTER TABLE 迁移
2026-06-11 09:11:54 +08:00
wangfq
50451de2df chore: 更新 edc_server 子模块 (0xB4 relay_out 格式化) 2026-06-10 17:27:47 +08:00
wangfq
2458127cfb fix: relay_out 调整仅改后端格式化,前端直接显示 DB relay_out 字段
- relay_code 保持原始 int 值不变
- decode_relay_info 输出新格式: '存在继电器有输出,脉冲继电器无输出'
- 前端不再 JS 端重新解码 relay_code,直接显示 DB 中的 relay_out 字段
- 保留 RELAY_MAP/decodeRelay 作为降级方案(relay_out 为空时用)
2026-06-10 16:28:30 +08:00
wangfq
a26d8807cb fix: 前端继电器显示同步为新格式 + 更新子模块
与后端 decode_relay_info 保持一致:bit 0/1 始终显示 有/无,不再用缩写 MAP
2026-06-10 16:25:38 +08:00
wangfq
67da0c9368 fix: renderLatest 不再覆盖测试模式,根除显示回退问题
根因: loadTestMode() 先从工装配置读取 TestMode=0(灵敏度) 并正确显示,
但紧接着 renderLatest() 看到最后一条测试数据 test_mode=1(波动),
又调用 updateTestModeUI(1) 把显示覆盖回去。

测试模式的权威来源是工装配置(tb_fixture_param),renderLatest 只应
展示最新测试数据的内容,不应修改模式指示器。
2026-06-10 14:51:47 +08:00
wangfq
4c337b60ae fix: 修复浏览器缓存导致工装参数 GET 请求返回旧数据
根因: /api/fixture/param/<id> GET 没有 Cache-Control 头,F5 刷新时
浏览器直接用磁盘缓存返回旧 TestMode,导致测试页显示旧模式。

修复:
- 服务端 fixture.py: GET 响应加 Cache-Control: no-store/no-cache/must-revalidate
- 客户端 test_op.js + fixture.js: 4 处 GET fetch 全部加 ?_=Date.now() 缓存破坏参数
2026-06-10 14:29:14 +08:00
wangfq
4b91455485 fix: 测试操作页面定期刷新测试模式,解决工装页修改后不同步
loadTestMode 只在页面加载时调用一次,工装页改完 TestMode 后切回测试页,
如果页面已打开则不会刷新。现在改为每 5s 与设备状态一起同步刷新。
2026-06-10 14:24:52 +08:00
wangfq
f2e9cc9345 feat: 测试操作页非在线状态禁止发送指令
- 新增 currentDeviceState 变量跟踪设备状态
- refreshDeviceStatus 同步更新 currentDeviceState
- sendCmd/manual cmds(B0/B1/BA/BB/BC) 调用前检查在线状态
- startAuto(自动化开始) 调用前检查在线状态
- 非在线时弹窗提示「设备当前状态为「离线/通信不良」,无法发送指令」
2026-06-10 13:55:48 +08:00
wangfq
d3b6d79a03 feat: 设备日志增加时间范围查询 + CSV 导出
- 查询 API 增加 date_from/date_to 参数(前端日期+时间选择器)
- 新增 /api/device-logs/export CSV 导出端点
- 新增 export_device_logs() 模型函数(全量不分页)
- 删除校验放宽:允许纯时间范围作为删除条件
- 前端增加导出 CSV 按钮,遵循 test_data 页面模式
2026-06-10 10:44:19 +08:00
wangfq
6b35d07025 fix: 设备日志页 fmtTime 修复时区偏移 8 小时
Flask jsonify 把 MySQL DATETIME 序列化为 '... GMT' 但实际是本地时间。
JS new Date() 按 GMT 解析后在 UTC+8 显示会多 8 小时。
统一去除 GMT 后缀再解析(与 test_op.js 相同方案)。
2026-06-10 10:14:07 +08:00
wangfq
5df08a26a9 fix: 设备日志页增加 TCP 连接/断开事件类型支持 2026-06-10 10:01:12 +08:00
wangfq
0bddb46605 chore: 更新 edc_server submodule (设备状态监控全表扫描) 2026-06-10 09:36:05 +08:00
wangfq
ee136cc707 feat: edc-web 设备日志管理页 + 在线状态实时刷新
- 新增 /device-logs 设备事件日志管理页 (admin 权限)
  - 支持按设备序列号/事件类型筛选查询
  - 支持 admin 按条件删除日志
  - 不同事件类型彩色标识 (在线=绿, 离线=红, 通信不良=橙)
- 新增 /api/devices/<id>/status 设备状态 API
- 设备列表页:每 5s 异步刷新所有设备在线状态
- 测试操作页:顶部显示设备状态,每 5s 异步刷新
- dnt_info state 支持三态显示 (在线/离线/通信不良)
- 导航栏增加「设备日志」入口 (admin only)
2026-06-10 09:14:32 +08:00
wangfq
60c11fe719 feat: 测试操作页显示当前测试模式,灵敏度模式下隐藏波动数据区
- 页面头部新增测试模式指示器(灵敏度测试/波动测试)
- 灵敏度模式时隐藏「波动测试数据」区块
- loadTestMode() 从工装参数获取 TestMode,无参数时回退到最新测试数据
- renderLatest 收到新数据时同步更新模式指示
2026-06-09 17:41:07 +08:00
wangfq
8aaa8440d1 feat: 配置功能仅admin可用,operator隐藏配置按钮+后端403拦截
- devices.html: 注入 USER_ROLE 全局变量
- devices.js: 配置按钮仅 USER_ROLE===admin 时渲染
- fixture.py: 页面/指令/保存三个路由均校验 admin 角色
2026-06-09 15:36:08 +08:00
wangfq
e863dfbe2f fix: 故障信息列限制12em宽,超长截断省略+title悬停显示全文 2026-06-09 14:29:21 +08:00
wangfq
69babe9994 style: 测试信息表格列不换行,列宽不小于标题 2026-06-09 14:17:25 +08:00
wangfq
421d735274 refactor: 测试信息页去掉地址/类型列,表格横向可滚动
- all/B2/B4 三视图均移除 dpg430_addr(地址) 和 str_type(类型) 列
- 表格包裹在 overflow-x:auto 容器中,列多时横向滚动
2026-06-09 14:11:55 +08:00
wangfq
3fb51c35f2 feat: 全部数据视图显示tb_state_tst全部字段,B2/B4差异字段显示'-'
- 排除 dpg430_addr 和 sub_type(型号列用 render 转换)
- B4 记录: ppvalue/idle_freq/enter_freq/exit_freq/enter_speed/exit_speed 显示 '-'
- B2 记录: remain_count/work_freq/curr_dist/speed/near_dist/far_dist 显示 '-'
- 进入距离(B2) 与 进入高度(B4) 合并为「进入高度/距离」
- 离开距离(B2) 与 离开高度(B4) 合并为「离开高度/距离」
- 新增列: iffinish(完成), fault_info(故障信息), relay_out(继电器), enter_freq, exit_freq, enter_speed, exit_speed, work_freq, speed, near_dist, far_dist
2026-06-09 13:48:56 +08:00
wangfq
172af49765 fix: 配置页频率/峰峰值前端显示与DB原始值双向转换
根据 DPG430 串口协议:
- 频率 f(Hz) = 10 * X, X 为 DB/设备原始值
- 峰峰值 V = ((X*3.3)/4095)*4, X 为正整数

前端 fixture.js 和 vehicle_base_test.js 增加转换层:
- 显示: raw X → f(Hz) / V(V)(fillFormFromParam、renderTable、openModal)
- 保存: f(Hz)/V(V) → raw X(getFormParams、saveRecord)

DB 已存原始值无需迁移,后端 build_4b_packet 透传原始值无需改。
2026-06-09 08:38:38 +08:00
wangfq
c0e77398d4 fix(fixture): 配置页面距离/容差值跟随单位 cm→mm 转换
- fillFormFromParam: DB值(cm) ×10 显示(mm)
- getFormParams: 表单值(mm) ÷10 存回(cm)
- HTML max: 255→2550 (适配mm)
- 0x4B下发/0x4C回读均正确:前端处理转换,协议仍用cm
2026-06-08 16:54:54 +08:00
wangfq
2b71abaec8 fix(fixture): 复位距离/皮距/容差单位 cm→mm 2026-06-08 16:51:43 +08:00
wangfq
92c2c2b408 docs: V2.0.0 培训手册
V2 vs V1 新增内容:
- 波动测试模式 (B4) 及完整流程说明
- 线圈参数管理 + 模拟车辆参数管理
- 工装配置关联线圈/车辆 + 新设备配置检查清单
- 测试信息三视图 + ECharts 图表
- 精确时间筛选(日期+时分秒)
- 自动化测试间隔/超时配置
- Admin 数据删除、继电器状态重构
- 全量操作日志覆盖
2026-06-08 12:04:50 +08:00
wangfq
78ff0a6c2c chore: 更新 edc_server submodule (B4 补充型号/类型) 2026-06-08 11:31:46 +08:00
wangfq
c5fb4fc9c0 fix(fixture): 保存工装参数时记录操作日志 2026-06-08 11:19:13 +08:00
wangfq
431653d033 feat(edc-web): 线圈参数/模拟车辆参数管理 + 工装关联 + 测试环境显示
新增功能:
- 线圈参数管理页 (/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 线圈/车辆信息
2026-06-08 10:42:13 +08:00
wangfq
e538efafb5 feat(test_data): 搜索页面增加时分秒时间筛选
- HTML: 日期范围旁加 time input (step=1s),标签改为'时间范围'
- JS: 新增 getDatetime() 合并日期+时间,统一查/导出/图表/删除
- 后端: date 参数智能判断,纯日期自动补 23:59:59,带时间原样使用
2026-06-08 08:56:01 +08:00
wangfq
bbfe085140 chore: 更新 edc_server 子模块 (relay_code 存储重构) 2026-06-05 17:56:56 +08:00
wangfq
8148aef332 docs: V2.0.4 周报 (6/1-6/5) — 波动测试+继电器重构+页面增强+Bug修复 2026-06-05 17:53:35 +08:00
wangfq
87752f12e6 feat: 图表视图增加继电器输出状态系列
- 新增 buildRelaySeries() 函数构建继电器阶梯线系列
  (type=line, step=end, 红色三角标记)
- 新增第4 Y轴(继电器输出),刻度 0-3,标签解码为
  无输出/存在信号/脉冲信号/存在+脉冲
- tooltip 中继电器值自动解码为可读文本
- grid right 扩大到 200px 容纳第4 Y轴
2026-06-05 15:03:17 +08:00
wangfq
aadd498851 fix: 修复时间显示时区偏移8小时 + 自动化完成时跳过渲染
问题1(时区):Flask jsonify 将 MySQL DATETIME 输出为
'Fri, 05 Jun 2026 14:37:52 GMT',但实际值是服务器本地时间
(UTC+8)。JS new Date() 把 GMT 当真,getHours() 加 8 小时。

修复:fmtTime() 先 strip ' GMT' 后缀再解析,让 JS 按本地
时间处理。

问题2(跳过渲染):pollProgress 中'全部完成'时 stopAuto()
+ return 跳过了 renderAverages/renderRecords,导致自动化
平均值和本轮明细永远不显示最后一轮数据。

修复:将 4 个 render 调用移到所有 return 之前执行。
2026-06-05 14:44:13 +08:00
wangfq
86c6046fbc fix: 测试操作页三个数据显示区因 relay_code 改动不显示
问题原因:
1. pollProgress 中所有 render 调用共用一个 try-catch,
   一处报错会导致后续所有渲染被跳过(级联失败)
2. 页面加载时没有初始化数据查询——不启动自动化就永远显示
   占位符

修复:
- 每个 render 调用独立 try-catch,互不影响
- 页面加载时调用 loadInitialData(),自动显示最新测试数据
   和波动测试数据
- records 增加 length>0 判断,避免空数组误触发渲染
2026-06-05 14:34:13 +08:00
wangfq
b4c27e30c8 refactor: relay存储原始hex值(relay_code),前端解码显示
- tb_state_tst新增relay_code TINYINT列,ALTER TABLE自动迁移
- edc_server: B2/B4都以原始int值插入relay_code
- edc-web: decodeRelay()解码 0=无输出/1=存在信号/2=脉冲信号/3=存在+脉冲
- relay_out VARCHAR列保留兼容旧数据
2026-06-05 14:18:45 +08:00
wangfq
6a3aaf3c05 feat: admin用户支持删除测试数据(按条件/日期范围),含确认框+日志 2026-06-05 13:44:46 +08:00
wangfq
0ea3511b90 feat: 图表增加保存图片功能(toolbox saveAsImage) 2026-06-05 12:02:37 +08:00
wangfq
877770aeab feat: 测试信息增加图表视图(ECharts),表格/图表一键切换
- B2视图:峰峰值/频率/距离/速度 趋势折线图,三Y轴
- B4视图:工作频率/距离/速度 趋势折线图,三Y轴
- dataZoom时间范围缩放,图例可切换系列显隐
- 新增 /api/test-data/chart 接口返回全量数据
2026-06-05 11:49:35 +08:00
wangfq
470c148861 fix: fmtTime改用Date解析,兼容HTTP日期格式('Fri, 05 Jun 2026...') 2026-06-05 10:50:32 +08:00
wangfq
522f40a3c2 fix: 统一时间格式为 yyyy-MM-dd HH:mm:ss,去掉毫秒部分 2026-06-05 10:47:48 +08:00
wangfq
3151d71cdc feat: 测试信息页增加每页记录数选择(20/50/100) 2026-06-05 10:41:50 +08:00
wangfq
3509caf79d docs: V2.0.4 — 第6章拆分为灵敏度测试+波动测试流程说明
- 6.1 灵敏度测试模式 (TestMode=0): 初始→进入→离开→上报0xB2
- 6.2 波动测试流程 (TestMode=1): 增加波动范围计算+步进容差
- 6.3 波动测试关键参数
- 6.4 0xB4上报时机
2026-06-04 17:06:56 +08:00
wangfq
59ddbe8d90 chore: 默认间隔改为10s,超时改为5s 2026-06-04 09:56:09 +08:00
wangfq
79ec89b3a9 feat: 自动化测试增加间隔时间和超时时间参数
- 新增两个输入框:间隔时间(秒)、超时时间(秒)
- 间隔逻辑:收到回复后等待间隔时间再发下一条 0xB0
- 超时逻辑:超时后不等间隔,立即发下一条
- 状态机驱动:IDLE→SENT→(回复→WAIT_INTERVAL→SEND)/(超时→SEND)
- 增加实时状态提示栏
2026-06-04 09:54:13 +08:00
wangfq
d00d199558 refactor: 测试信息页拆为三视图(全部/B2/B4),按data_source自动切换列
- 移除混杂的24列大表,改为三个标签页切换
- 全部视图: 精简核心字段
- B2视图: 峰峰值、频率、距离、速度、故障、完成状态
- B4视图: 剩余次数、当前距离、速度、波动范围、进入/离开高度
- 后端增加data_source查询/导出参数支持
2026-06-03 17:02:27 +08:00
wangfq
a69d7ab1d0 feat: 波动测试模式前端适配 — tb_state_tst扩展+0xB4存库+页面更新
- edc_server/models.py: tb_state_tst DDL增加test_mode/data_source + B4字段
  + ALTER TABLE自动迁移 + insert_test_result扩展 + insert_wave_data
- edc_server/handlers.py: 0xB2处理传test_mode、0xB4处理调用insert_wave_data存库
- edc-web/models.py: 新增get_latest_wave_data/get_wave_records + test_mode筛选
- edc-web/routes: test_op返回wave数据、test_data支持test_mode筛选
- 前端: test_op页面增加波动数据显示区+测试模式列
  test_data页面增加test_mode下拉筛选+B4字段列+CSV导出适配
2026-06-03 14:14:52 +08:00
wangfq
cf0b308e22 chore: 移除误提交的 vim swap 文件 2026-06-03 13:40:29 +08:00
wangfq
6929faddfc fix: 恢复 FarStay 为 2 字节 — 协议+代码+前端全部回退 2026-06-03 13:40:23 +08:00
wangfq
3d7aec4cad fix: FarStay 2B→1B 匹配设备实际固件
协议: 0x4B LEN=0x16, 0x4C LEN=0x1A
代码: build_4b FarStay 1B, parse_4c FarStay payload[24]
前端: FarStay max=255
2026-06-03 11:52:42 +08:00
wangfq
eadeed5e0f fix: edc_server — ALTER TABLE 迁移补充 tb_fixture_param 缺列 2026-06-02 18:33:27 +08:00
wangfq
ef796f6213 feat: DG430 V2.0.3 — 波动测试模式 前端+后端同步
edc_server:
  - dg430.py: 新增0xB4解析; 0x4C扩展6字段(向后兼容)
  - models.py: tb_fixture_param DDL + upsert 新增6字段
  - handlers.py: parse_loop 添加0xB4处理; 0x4C传参扩展

edc-web:
  - fixture.py: build_4b_packet() 新增6个波动参数
  - models.py: upsert_fixture_param 字段列表扩展
  - fixture.html: 新增波动测试参数输入区(6字段)
  - fixture.js: getFormParams/fillForm/saveToDb/sendConfig 全部扩展
2026-06-02 18:06:14 +08:00
wangfq
e7607481e1 docs: DG430协议 V2.0.3 — 波动测试模式
- TestMode=1 从模拟过车改为波动测试模式
- 0x4B/0x4C 新增6个波动参数:FarTol/NearTol/StepTol/BackForth/NearStay/FarStay
- 新增 0xB4 波动测试上报指令 (16字节状态内容)
- 新增第6章 波动测试模式流程说明
2026-06-02 17:20:11 +08:00
wangfq
b67de6e9de chore: 更新 requirements.txt — 精简为仅含直接依赖
edc_server: aiomysql + uvloop(移除 cffi/cryptography/pycparser/PyMySQL)
edc-web:   flask + flask-login + pymysql(增加版本约束)
2026-06-01 08:34:44 +08:00
wangfq
00ec02eb9e docs: V1.0 培训手册 — 项目概述、架构、硬件、edc_server、edc-web、协议、操作指南 2026-06-01 08:03:14 +08:00
42 changed files with 5118 additions and 273 deletions

View File

@@ -2,7 +2,7 @@
DG430地感测试工装协议说明
V2.0.1-20260522
V2.0.4-20260601
# 1 硬件介绍
@@ -157,7 +157,7 @@ Flag:是否故障标志
          02-----DLD110系列
测试模式0 为灵敏度测试模式1为模拟过车测试模式
测试模式0 为灵敏度测试模式1为波动测试模式
是否正常完成测试00-----正常;
@@ -219,6 +219,40 @@ RFU保留字节
  7F 81 02 B3 01 31 37
## 5.4.1 波动测试上报指令命令0xB4 (V2.0.3 新增)
  设备主动上报格式:
| 7Fh | 80h+ADD | 11h | B4h | 状态内容(16字节) | XOR | SUM |
| --- | --- | --- | --- | --- | --- | --- |
| 1B | 1B | 1B | 1B | 16B | 1B | 1B |
说明:该协议为波动测试过程中 DG430 主动上报,上位机无回复。在以下三种情况触发上报:
1. 金属板行驶到波动**最近距离**时
2. 金属板行驶到波动**最远距离**时
3. 波动的过程中**车检器的输出状态发生变化**时
状态内容详细说明16字节除 RemainCount 外均为小端模式):
| 字段 | 字节数 | 说明 |
| --- | --- | --- |
| RemainCount | 1B | 当前剩余波动次数 |
| Relay | 1B | 继电器输出状态(同 0xB2 继电器输出字段BIT0=存在继电器信号BIT1=脉冲继电器信号 |
| WorkFreq | 2B | 工作频率,单位 Hz计算公式Freq = 10 × X |
| CurrDist | 2B | 当前距离/高度(激光检测值,未减皮距),单位 mm |
| Speed | 2B | 当前速度,单位 dm/s |
| NearDist | 2B | 波动最近距离(皮距 + 最近容差),单位 mm |
| FarDist | 2B | 波动最远距离(离开高度 + 皮距 - 最远容差),单位 mm |
| EnterDist | 2B | 进入高度(已减皮距),单位 mm |
| LeaveDist | 2B | 离开高度(已减皮距),单位 mm |
7F 81 13 B4 03 01 2F 0D 00 C8 01 28 64 00 20 03 A0 0F 28 00 3C 00 XX XX
解析:
RemainCount=3还剩3次波动Relay=01存在继电器有信号WorkFreq=3375Hz
CurrDist=200mmSpeed=296dm/sNearDist=800mmFarDist=4000mm
EnterDist=40mmLeaveDist=60mm。
## 5.5 电机前进指令命令 0xBA
  上位机发送格式:
@@ -319,33 +353,45 @@ eg: 7F 81 08 4A 00 03 03 00 03 04 00 C4 E0, 表示硬件版本号3
  上位机发送格式:
| 7Fh | 80h+ADD | 10h | 4Bh | Addr | DevType | TestMode | ResetDis | MinusDis | SensMin | SensMax | FreMin | FreMax | PeakMin | PeakMax | XOR | SUM |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 2B | 2B | 2B | 2B | 2B | 2B | 1B | 1B |
| 7Fh | 80h+ADD | 17h | 4Bh | Addr | DevType | TestMode | ResetDis | MinusDis | SensMin | SensMax | FreMin | FreMax | PeakMin | PeakMax | FarTol | NearTol | StepTol | BackForth | NearStay | FarStay | XOR | SUM |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 2B | 2B | 2B | 2B | 2B | 2B | 1B | 1B | 1B | 1B | 2B | 2B | 1B | 1B |
说明该指令为上位机发送给DG430。DG430接收到该指令后返回协议并更新测试参数。
Addr1Byte, 设备地址、485地址
Addr1Byte, 设备地址、485地址
DevType1Byte, 设备型号
DevType1Byte, 设备型号
TestMode: 1Byte, 测试模式0 灵敏度测试模式1 模拟过车模式
TestMode1Byte, 测试模式0 灵敏度测试模式1 波动测试模式
ResetDis: 复位距离单位cm
ResetDis复位距离单位cm
MinusDis: 皮距激光到线圈的距离测算的实际高度要减去这个皮距单位cm
MinusDis皮距激光到线圈的距离测算的实际高度要减去这个皮距单位cm
SensMin,SensMax: 2Byte, 灵敏度最小、最大值
SensMin, SensMax2Byte, 灵敏度最小、最大值(小端模式)
FreMin, FreMax: 2Byte, 频率最小、最大值
FreMin, FreMax2Byte, 频率最小、最大值(小端模式)
PeakMin, PeakMax: 2Byte, 峰峰值最小、最大值
PeakMin, PeakMax2Byte, 峰峰值最小、最大值(小端模式)
测试用例7F 81 12 4B 01 01 00 30 0D 00 8B 00 E6 07 76 06 D6 09 B0 0C 1C 80 C8
FarTol1Byte, 最远容差,波动测试时,离开高度- 最远容差= 波动最远距离不包含皮距单位cm
NearTol1Byte, 最近容差,最近容差=波动最近距离不包含皮距单位cm
StepTol1Byte, 步进容差默认0。非0时每次波动后最远容差递加该值最多(波动次数-1)次单位cm
BackForth1Byte, 来回次数金属板从最远距离→最近距离→最远距离记为1次
NearStay2Byte, 最近停留时间到达波动最近距离后停留的时间单位ms小端模式
FarStay2Byte, 最远停留时间到达波动最远距离后停留的时间单位ms小端模式
测试用例7F 81 19 4B 01 01 01 30 0D 00 8B 00 E6 07 76 06 D6 09 B0 0C 1C 03 02 00 05 C8 00 F4 01 2B 9F
用例说明:
Addr0x01 DevType: 0x01, 00: Sensity Test Mode, ResetDis: 48cm,  MinusDis: 13cm,  SensMin:  139, SensMax: 230, Fre\_Min:1910, FreMax: 1750, PeakMin: 2480, PeakMax: 3100。
Addr0x01 DevType0x01 TestMode1波动测试 ResetDis48cm MinusDis13cm SensMin139 SensMax230 Fre_Min1910 FreMax1750 PeakMin2480 PeakMax3100 FarTol3cm NearTol2cm StepTol0cm BackForth5次 NearStay200ms FarStay500ms
返回格式:
@@ -372,19 +418,41 @@ Addr0x01 DevType: 0x01, 00: Sensity Test Mode, ResetDis: 48cm,  
测试用例7F 81 01 4C CC CE
返回格式:
| 7Fh | 80h+ADD | 1Bh | 4Ch | Flag | Addr | DevType | TestMode | ResetDis | MinusDis | SensMin | SensMax | FreMin | FreMax | PeakMin | PeakMax | FarTol | NearTol | StepTol | BackForth | NearStay | FarStay | XOR | SUM |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 2B | 2B | 2B | 2B | 2B | 2B | 1B | 1B | 1B | 1B | 2B | 2B | 1B | 1B |
| 7Fh | 80h+ADD | 13h | 4Ch | Flag | Addr | DevType | TestMode | ResetDis | MinusDis | SensMin | SensMax | FreMin | FreMax | PeakMin | PeakMax | XOR | SUM |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 1B | 2B | 2B | 2B | 2B | 2B | 2B | 1B | 1B |
 Flag是否故障标志00=正常01=故障
 Flag:是否故障标志
Addr设备地址
00----正常
DevType设备型号
01----故障
TestMode测试模式0=灵敏度测试, 1=波动测试)
     测试用例7F 81 13 4C 00 01 01 00 30 0D 00 8B 00 E6 07 76 06 D6 09 B0 0C 1C 86 CA
ResetDis复位距离单位cm
MinusDis皮距单位cm
SensMin, SensMax灵敏度最小、最大值小端模式
FreMin, FreMax频率最小、最大值小端模式
PeakMin, PeakMax峰峰值最小、最大值小端模式
FarTol最远容差单位cm
NearTol最近容差单位cm
StepTol步进容差单位cm
BackForth来回次数
NearStay最近停留时间单位ms小端模式
FarStay最远停留时间单位ms小端模式
测试用例7F 81 1B 4C 00 01 01 01 30 0D 00 8B 00 E6 07 76 06 D6 09 B0 0C 1C 03 02 00 05 C8 00 F4 01 8A 5E
## 5.11 出厂初始化指令命令0x4D
@@ -413,11 +481,9 @@ eg: 7F 81 01 4D CD CF
| 7Fh | 80H +ADD | LEN | 4Eh | XOR | SUM |
| --- | --- | --- | --- | --- | --- |
获取控制卡的版本号
LEN: 0x01
eg: 7F 81 01 4E CA CC
eg: 7F 81 01 4E CE D0
返回格式:
@@ -432,6 +498,63 @@ eg: 7F 81 01 4E CA CC
     测试用例7F 81 02 4E 00 CD D1 
# 6 测试模式流程说明
DG430 支持两种测试模式,通过 TestMode 参数切换。
## 6.1 灵敏度测试模式
TestMode=0 为灵敏度测试模式,用于模拟基本的过车情况。
**物理布局**
电机前端有一个金属板,线圈在激光传感器和金属板之间,激光到线圈的距离为 `MinusDis` 皮距。线圈接到车检器上,车检器的输出信号接到测试工装的输入口。
**测试流程**
1. **初始阶段** — 开始测试时,金属板从设定的**复位距离**开始向线圈侧前进。
2. **进入检测** — 金属板靠近线圈过程中,车检器输出信号(继电器吸合),此时测试工装记录激光检测的距离为**进入距离/进入高度**(需减去皮距),记录此前金属板运行的**进入速度**、**进入频率**等参数。电机立刻停止,等待一个延时后再反向运动(远离线圈)。
3. **离开检测** — 车检器输出信号消失(继电器释放),此时测试工装记录激光检测的距离为**离开距离/离开高度**需减去皮距。离开到接近复位距离时测试工装上报测试数据0xB2数码管显示进入高度和离开高度。电机回到复位距离位置等待下一次开始测试指令。
**上报数据 (0xB2)**
每次完成一次完整测试后上报 0xB2 状态数据,包含:设备型号、峰峰值、工作频率、进入/离开高度、进入/离开速度、故障信息、继电器状态等。详见 [5.3 上报状态指令](#53-上报状态指令命令-0xb2)。
## 6.2 波动测试流程
TestMode=1 为波动测试模式,用于模拟复杂的过车情况。
电机前端有一个金属板,线圈在激光传感器和金属板之间,激光到线圈的距离为 `MinusDis` 皮距。线圈接到车检器上,车检器的输出信号接到测试工装的输入口。
1. **初始阶段** — 开始测试时,金属板从设定的**复位距离**开始向线圈侧前进。
2. **进入检测** — 金属板靠近线圈过程中,车检器输出信号(继电器吸合),此时测试工装记录激光检测的距离为**进入距离/进入高度**(需减去皮距)。电机立刻停止,然后反向运动(远离线圈)。
3. **离开检测** — 车检器输出信号消失(继电器释放),此时测试工装记录激光检测的距离为**离开距离/离开高度**需减去皮距。测试工装上报测试数据0xB2数码管显示进入高度和离开高度。
4. **计算波动范围**
- **波动最远距离** = 离开高度 最远容差,实际计算的时候要加上皮距 来比较。
- **波动最近距离** = 最近容差,实际计算的时候要加上皮距 来比较。
5. **波动测试** — 金属板从波动最远距离开始,前进到波动最近距离,电机停止 → 上报 0xB4 → 等待最近停留时间 → 反向运动到波动最远距离 → 电机停止 → 上报 0xB4 → 等待最远停留时间。记为**一次来回**。
6. **步进容差** — 若步进容差 ≠ 0每次波动后最远容差递加步进容差值最多递加 (波动次数 1) 次。
7. **完成阶段** — 最后一次波动结束后,电机继续远离线圈运动,检测到车检器输出信号释放后再次上报 0xB2电机回到复位距离位置等待下一次开始测试指令。
## 6.3 波动测试关键参数
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| 最远容差 (FarTol) | 1B, cm | 离开高度 + 皮距 该值 = 波动最远距离 |
| 最近容差 (NearTol) | 1B, cm | 皮距 + 该值 = 波动最近距离 |
| 步进容差 (StepTol) | 1B, cm | 默认0。非0时每次波动后最远容差递加 |
| 来回次数 (BackForth) | 1B | 波动来回次数 |
| 最近停留时间 (NearStay) | 2B, ms | 到达最近距离时停留的时间 |
| 最远停留时间 (FarStay) | 2B, ms | 到达最远距离时停留的时间 |
## 6.4 0xB4 上报时机
0xB4 在以下三种情况下触发:
1. 金属板行驶到**波动最近距离**时上报
2. 金属板行驶到**波动最远距离**时上报
3. 波动的过程中**车检器输出状态有变化**时上报
# 更新记录
| **版本号** | **更新日期** | **状态** | **更新内容** | **更新人** |
@@ -440,4 +563,6 @@ eg: 7F 81 01 4E CA CC
| V2.0.0 | 2026-05-21 | | 增加配置指令:电机前进、后退、停止指令,获取版本号、读写测试指令 | 王飞强 |
| V2.0.1 | 2026-05-22 | | 增加出厂初始化指令、设备复位指令 | 王飞强 |
| V2.0.2 | 2026-05-25 | | 增加测试模式,增加进入速度和离开速度 | 王飞强 |
| | | | | |
| V2.0.3 | 2026-05-31 | | TestMode=1改为波动测试模式0x4B/0x4C新增6个波动参数新增0xB4波动测试上报指令 | 王飞强 |
| V2.0.4 | 2026-06-01 | | 第6章重构拆分为灵敏度测试(6.1)和波动测试(6.2-6.4)流程说明 | 王飞强 |
| | | | | |

View File

@@ -0,0 +1,572 @@
# VD 测试工装 V1.0 培训手册
> **版本**: V1.0
> **日期**: 2026-05-31
> **作者**: wangfq
> **适用对象**: 测试工程师、生产操作员、系统管理员
---
## 目录
1. [项目概述](#1-项目概述)
2. [系统架构](#2-系统架构)
3. [硬件环境](#3-硬件环境)
4. [EDC 服务](#4-edc-服务-edc_server)
5. [EDC 管理系统](#5-edc-管理系统-edc-web)
6. [通信协议](#6-通信协议)
7. [操作指南](#7-操作指南)
8. [常见问题](#8-常见问题)
9. [附录](#9-附录)
---
## 1. 项目概述
### 1.1 项目简介
**VD 测试工装**vd_test_fixture是一套车检器自动化测试系统用于**批量检测车检器Vehicle Detector的核心性能指标**。
**核心能力**:
- **灵敏度测试**: 检测车检器对不同信号强度的响应
- **产品一致性测试**: 批量产品之间的性能差异分析
- **自动化测试**: 支持设定测试次数,自动循环执行,实时进度反馈
- **工装配置管理**: 支持 DG430 V2.0.x 协议的设备参数配置、版本查询、出厂初始化
### 1.2 术语说明
| 术语 | 全称 | 说明 |
|------|------|------|
| **EDC** | Edge Data Center | 边缘数据中心,系统的核心服务 |
| **DNT** | Data Network Terminal | 联网终端PGLC连接设备与 EDC |
| **DG430** | — | 地感测试工装硬件,执行实际测试动作 |
| **VD** | Vehicle Detector | 车检器(被测设备) |
| **SerialNet** | Serial Network | 串口网络透传,通过 UDP 将指令转发到 DG430 串口 |
### 1.3 系统组成
```
vd_test_fixture/
├── edc_server/ # EDC 边缘数据中心(后端服务)
│ ├── src/server.py # UDP/TCP 异步网络服务
│ ├── src/handlers.py # 业务处理 + 后台轮询
│ ├── src/models.py # 数据库模型 (aiomysql)
│ ├── src/dg430.py # DG430 二进制协议解析
│ └── src/protocol.py # PGLC JSON 协议解析
├── edc-web/ # Flask Web 管理系统(前端)
│ ├── app/routes/ # 页面路由: 设备/测试/数据/工装/用户/日志
│ ├── app/templates/ # Jinja2 页面模板
│ └── app/static/ # CSS + JS
├── docs/ # 协议文档 + 培训手册
└── MySQL # 共享数据库 (edc)
```
---
## 2. 系统架构
### 2.1 整体架构图
```
┌──────────────────┐ 浏览器 HTTPS
│ edc-web │ ◄────────────────────── 操作人员
│ Flask (Flask-Login) │
│ 前端管理界面 │
└────────┬─────────┘
│ pymysql (同步)
┌──────────────────┐
│ MySQL │
│ 数据库: edc │
│ (共享存储) │
└────────┬─────────┘
│ aiomysql (异步)
┌──────────────────┐ UDP :4900 ┌──────────────┐ RS485/TTL ┌────────────┐
│ edc_server │ ◄──────────────► │ PGLC 联网终端 │ ◄───────────► │ DG430 工装 │
│ Python/uvloop │ SerialNet 透传 │ (DNT) │ 串口协议 │ (测试硬件) │
│ │ └──────────────┘ └──────┬─────┘
│ UDP :5500/:5505 │ │
│ TCP :5550 │ ▼
└──────────────────┘ ┌──────────────┐
│ 车检器(VD) │
│ (被测设备) │
└──────────────┘
```
### 2.2 通信链路
```
操作员浏览器 → edc-web (Flask, port 5000) → MySQL → edc_server (asyncio) → DNT → DG430 → 车检器
MySQL (同步)
```
**关键点**:
- edc_server 和 edc-web 共享同一 MySQL 数据库
- edc_server 使用 aiomysql (异步)edc-web 使用 pymysql (同步),互不冲突
- 前端通过 edc-web 的 REST API 下发指令,实际执行由 edc_server 的轮询任务完成
### 2.3 端口分配
| 端口 | 方向 | 协议 | 说明 |
|------|------|------|------|
| **5500** | 监听 | UDP | EDC 设备发现 / 心跳 |
| **5505** | 监听 | UDP | EDC 消息监听 |
| **5550** | 监听 | TCP | EDC 时间同步 / 数据上报 / 串口透传 |
| **4900** | 发送 | UDP | 向设备发送 SerialNet 透传指令 |
| **5550** | 发送 | TCP | 向设备发送 TCP 数据 |
| **5000** | 监听 | HTTP | edc-web Flask 管理界面 |
---
## 3. 硬件环境
### 3.1 DG430 地感测试工装
DG430 是执行测试的核心硬件,负责控制电机驱动模拟车辆经过地感线圈,并采集车检器的响应数据。
**接口**:
| 接口 | 连接 | 说明 |
|------|------|------|
| IN1/GND | 地感存在信号 | 检测线圈是否有车 |
| IN2/GND | 地感脉冲信号 | 检测脉冲继电器 |
| IN3/COM | 按钮 | 按下开始测试 |
| IN4/COM | 按钮 | 按下复原位置 |
| PU+/PU-/DR+/DR-/MF+/MF- | 电机驱动器 | 控制电机前进/后退 |
| +5V/GND/NO/NC | 限位开关 | 有信号电机停转 |
| 485A/485B | RS485 | 接 PGLC 联网终端 |
| GND/LP | 地感线圈 | 模拟车辆通过 |
| SW3 | 激光探头 | 检测进入/离开 |
**拨码开关**:
- DIP1=OFF, DIP2=OFF → 测试 132 系列地感
- DIP1=ON, DIP2=OFF → 测试 110 系列地感
**声音提示**:
| 声音 | 含义 |
|------|------|
| 2 声 | 工作频率/峰峰值异常 |
| 3 声 | 灵敏度异常 |
| 4 声 | 灵敏度提升异常 (132 DIP5) |
| 5 声 | 非离开脉冲 |
| 6 声 | 脉冲继电器无输入 |
### 3.2 PGLC 联网终端 (DNT)
PGLC 终端是连接 EDC 服务和 DG430 工装的**网络桥接设备**:
- 通过 **RS485/TTL 串口** 连接 DG430 工装
- 通过 **TCP/UDP 网络** 连接 EDC 服务
- 负责串口数据与网络数据的双向透传
---
## 4. EDC 服务 (edc_server)
### 4.1 功能概述
EDC 服务是整个系统的**数据中枢**,负责:
1. **设备管理**: 发现、注册、心跳检测、在线状态维护
2. **数据采集**: 接收设备上报的测试数据、原始传感数据
3. **协议解析**: 解析 DG430 二进制协议,提取测试结果
4. **指令透传**: SerialNet 透传机制,将前端指令下发给设备
5. **后台轮询**: 自动化测试调度、超时检测、状态流转
### 4.2 启动方式
```bash
cd edc_server
source .venv/bin/activate
# 配置环境变量
export EDC_MYSQL_HOST=127.0.0.1
export EDC_MYSQL_USER=dg
export EDC_MYSQL_PASSWORD=123456
export EDC_MYSQL_DB=edc
# 启动
python run.py
```
### 4.3 数据库表结构
| 表名 | 用途 | 关键字段 |
|------|------|----------|
| `dnt_info` | 联网终端信息 | serial(唯一), ip, state(在线/离线) |
| `tb_state_tst` | 设备测试状态 | dnt_id, pcnum, serialnum, ppvalue, idle_freq, enter_freq, exit_freq |
| `tb_serialnet` | 透传发送队列 | dnt_id, send_pkg, rcv_pkg, state(0未发→1已发→2已完成→3超时) |
| `tb_fixture_param` | 工装测试参数 | dnt_id(UNIQUE), dev_type, test_mode, sens_min/max, freq_min/max |
| `tb_vechicle_base_test` | 车检器基准参数 | type_num(编码), relay_exist, relay_pluse, sens_min/max 等 |
| `tb_user` | 用户账号 | username, password_hash, role(admin/operator) |
| `tb_log` | 操作日志 | user_id, action_type, target, detail, result, ip |
| `tb_collect_{DeviceID}` | 设备原始数据采集表 | dat_type, raw_data, state(0未处理/1已处理) |
### 4.4 关键流程
#### 设备注册流程
```
设备上电 → TCP 时间同步 → UDP 上报 Count_Off → EDC 检查 serial
├─ 已注册 → 更新 IP/网关 + last_login
└─ 未注册 → 插入 dnt_info + 创建 tb_collect_{DeviceID}
```
#### SerialNet 透传流程
```
前端 → edc-web → INSERT tb_serialnet (state=0)
→ serialnet_loop 轮询 → UDP 发送到设备 (state=1)
→ 设备回复 TSReport(dat_type=8 或 9)
→ parse_loop 解析 → UPDATE tb_serialnet (state=2, rcv_pkg=...)
→ 前端轮询 api/fixture/serialnet/{id} → 显示结果
```
#### 超时检测
- serialnet_loop 每轮检查 `state=1``update_time > 10s` 的记录
- 超时记录置为 `state=3`(超时)
---
## 5. EDC 管理系统 (edc-web)
### 5.1 功能概述
edc-web 是基于 Flask 的 Web 管理系统,提供图形化操作界面。
**功能模块**:
| 模块 | URL | 功能 |
|------|-----|------|
| **登录** | `/login` | 用户认证 (Flask-Login) |
| **设备管理** | `/devices` | 联网终端列表、在线状态、名称修改 |
| **测试操作** | `/test-op/<dnt_id>` | 单次测试、手动控制、自动化测试 |
| **测试数据** | `/test-data` | 历史测试数据查询、分页、导出 |
| **工装配置** | `/fixture/<dnt_id>` | DG430 V2.0 配置指令、参数管理 |
| **用户管理** | `/users` | 账号管理 (admin only) |
| **操作日志** | `/logs` | 操作记录审计 |
### 5.2 启动方式
```bash
cd edc-web
source .venv/bin/activate
# 配置环境变量(与 edc_server 相同)
export EDC_MYSQL_HOST=127.0.0.1
export EDC_MYSQL_USER=dg
export EDC_MYSQL_PASSWORD=123456
export EDC_MYSQL_DB=edc
# 启动(默认 5000 端口)
python run.py
```
**首次启动**: 自动创建默认管理员 `admin / admin123`
### 5.3 用户角色
| 角色 | 权限 |
|------|------|
| **admin** | 全部功能:设备管理、测试操作、用户管理、日志查看 |
| **operator** | 受限功能:设备管理、测试操作 |
---
## 6. 通信协议
### 6.1 DG430 串口协议
DG430 与 PGLC 终端之间的通信协议,采用**一问一答**方式。
**数据包格式** (7 字节):
```
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ STX │ ADDR │ LEN │ CMD │ DATA │ XOR │ SUM │
│ 0x7F │ 1B │ 1B │ 1B │ LEN-1│ 1B │ 1B │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘
```
#### 测试操作指令 (V1.0)
| 指令 | CMD | Hex (addr=0x01) | 说明 |
|------|-----|-----------------|------|
| 开始测试 | B0 | `7F 81 01 B0 30 32` | 启动一次测试流程 |
| 测试复原 | B1 | `7F 81 01 B1 31 33` | 恢复到初始位置 |
| 电机前进 | BA | `7F 81 01 BA 3A 3C` | 电机正转 |
| 电机后退 | BB | `7F 81 01 BB 3B 3D` | 电机反转 |
| 电机停止 | BC | `7F 81 01 BC 3C 3E` | 电机停止 |
#### 工装配置指令 (V2.0.x) NEW
| 指令 | CMD | 说明 |
|------|-----|------|
| 获取版本号 | 4A | 查询 DG430 固件和硬件版本 |
| 配置测试参数 | 4B | 下发工装参数(动态构造) |
| 查询测试参数 | 4C | 查询当前全部参数 |
| 出厂初始化 | 4D | 恢复出厂设置 |
| 设备复位 | 4E | 重启 DG430 设备 |
> ⚠️ **端序注意**: B2 上报和 0x4B/0x4C 的 2 字节字段**统一用小端** (`_le16`)。协议文档测试用例写的是大端,但实际硬件发送小端。
#### B2 状态上报
设备完成测试后自动上报状态数据包,包含:
| 字段 | 说明 | 范围 |
|------|------|------|
| State | 设备状态 | 正常/故障 |
| Mode | 工作模式 | — |
| Sens | 灵敏度 | 实际值 |
| PPValue | 峰峰值 | mV |
| IdleFreq | 开始工作频率 | kHz |
| EnterFreq | 进入工作频率 | kHz |
| EnterDist | 进入距离 | mm |
| ExitDist | 离开距离 | mm |
| EnterSpeed | 进入速度 | dm/s |
| ExitSpeed | 离开速度 | dm/s |
### 6.2 PGLC 网络接口协议
EDC 服务与 PGLC 终端之间的 JSON 协议。
#### 主要方法
| Method | 方向 | 说明 |
|--------|------|------|
| `TimeStamp` | 设备→EDC | 时间同步请求 |
| `Count_Off` | 设备→EDC | 设备注册/发现 |
| `TSReport` | 设备→EDC | 子设备数据上报 |
| `SerialNet` | EDC→设备 | 串口透传指令下发 |
| `Heartbeat` | 设备→EDC | 心跳包 |
#### SerialNet 透传格式
```json
{
"Method": "SerialNet",
"Params": {
"Addr": 1,
"SerialCmd": "7F8101B03032",
"SerialDat": "",
"WorkMode": 1
}
}
```
---
## 7. 操作指南
### 7.1 登录系统
1. 浏览器访问 `http://<服务器IP>:5000/login`
2. 默认账号: `admin` / `admin123`
3. 登录后跳转到设备管理页
### 7.2 设备管理
**查看设备列表**:
- 首页展示所有联网终端
- 显示序列号、名称、IP、在线状态
**修改设备名称**:
- 点击设备行的名称编辑图标
- 输入新名称后提交保存
**进入测试**:
- 点击设备行右侧的 **「测试」** 按钮
### 7.3 手动测试操作
在测试操作页面:
1. **单次测试**: 点击 **「开始测试」** 按钮DG430 执行一次完整测试流程
2. **手动控制** (仅 admin):
- **测试复原**: 恢复初始位置
- **电机前进/后退/停止**: 手动控制电机
3. **实时数据**: 页面自动显示设备上报的最新测试数据
### 7.4 自动化测试
1. 在测试次数输入框中填入测试次数(如 `10`
2. 点击 **「开始」** 按钮
- 进度条显示 `已完成/总数`
- 成功/失败计数实时更新
- 平均值区域持续刷新
3. 如需中途停止,点击 **「结束」** 按钮
**自动化流程**:
```
开始 → INSERT tb_serialnet (0xB0, state=0)
→ serialnet_loop 发送 UDP (state=1)
→ 等待设备 B2 上报
→ parse_loop 解析存入 tb_state_tst
→ 匹配 serialnet 记录 (state=2)
→ 前端轮询进度 → 自动发送下一次 0xB0
→ ...重复...
→ 全部完成 / 超时(fail) / 手动结束
```
**超时处理**: 单次测试超时 10 秒,超时计入失败,自动继续下一次。
### 7.5 工装配置管理 (V2.0 新增)
1. 从设备页面进入 **「工装配置」**
**工装参数设置**:
- 选择设备类型、测试模式
- 配置灵敏度范围、频率范围、峰峰值范围
- 配置 / 查询 / 初始化操作
**DG430 版本查询**:
- 点击 **「获取设备版本号」**,查看硬件/固件版本
**通信日志**:
- 每次指令发送后,下方自动显示通信日志
- `→ 发送` (绿色): 发出的指令 hex
- `← 收到` (橙色): 设备返回的 hex
- `✓ 成功` / `✗ 失败`: 解析结果
### 7.6 测试数据查询
1. 点击 **「测试信息」** 菜单
2. 功能:
- **分页浏览**: 翻页查看历史数据
- **关键字搜索**: 按设备编码、批次号搜索
- **日期筛选**: 按日期范围过滤
- **导出**: 导出测试数据
### 7.7 用户管理 (admin only)
1. 点击 **「用户管理」** 菜单
2. 功能:
- 添加新用户(用户名、密码、角色)
- 修改用户角色
- 删除用户
### 7.8 操作日志
1. 点击 **「操作日志」** 菜单
2. 记录类型:
- `login` / `logout`: 登录/登出记录
- `command`: 指令下发记录
---
## 8. 常见问题
### 8.1 设备离线
**现象**: 设备列表显示"离线"
**排查**:
1. 检查设备供电是否正常
2. 检查网络连接ping 设备 IP
3. 查看 edc_server 日志,确认是否收到心跳
4. 设备超时阈值: 默认 120 秒(`EDC_DEVICE_TIMEOUT`
### 8.2 测试超时
**现象**: 自动化测试中连续超时
**排查**:
1. 检查 DG430 工装是否正常上电
2. 检查 RS485 连接是否正确
3. 在工装配置页发送「获取设备版本号」,验证通信链路
4. 超时阈值: 10 秒
### 8.3 速度显示异常
**现象**: 速度值看起来偏大 10 倍
**原因**: DG430 协议存储的是 dm/s分米/秒),系统已自动转换为 m/s米/秒)。如果发现显示值异常,检查转换逻辑。
### 8.4 首次启动无管理员
**现象**: 无法登录
**解决方案**: edc-web 首次启动时自动创建 `admin / admin123`。如果手动清空过 `tb_user` 表,重启 edc-web 即可重新创建。
### 8.5 串口通信失败
**现象**: 工装配置指令无响应
**排查**:
1. 确认 PGLC 终端固件支持 SerialNet 透传
2. 确认 RS485 波特率: 19200TTL 波特率: 115200
3. 确认 0x4B/0x4C 指令使用了小端字节序
---
## 9. 附录
### A. DG430 指令速查
```
┌─────────────────────────────────────────────────────────────┐
│ DG430 指令速查表 │
├────────┬──────────┬─────────────────────────────────────────┤
│ CMD │ 指令 │ Hex (addr=0x01) │
├────────┼──────────┼─────────────────────────────────────────┤
│ 0xB0 │ 开始测试 │ 7F 81 01 B0 30 32 │
│ 0xB1 │ 测试复原 │ 7F 81 01 B1 31 33 │
│ 0xBA │ 电机前进 │ 7F 81 01 BA 3A 3C │
│ 0xBB │ 电机后退 │ 7F 81 01 BB 3B 3D │
│ 0xBC │ 电机停止 │ 7F 81 01 BC 3C 3E │
│ 0x4A │ 版本查询 │ 7F 81 01 4A CA CC │
│ 0x4B │ 配置参数 │ 动态构造 │
│ 0x4C │ 查询参数 │ 7F 81 01 4C CC CE │
│ 0x4D │ 出厂初始化 │ 7F 81 01 4D CD CF │
│ 0x4E │ 设备复位 │ 7F 81 01 4E CE D0 │
└────────┴──────────┴─────────────────────────────────────────┘
```
### B. 环境变量速查
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `EDC_MYSQL_HOST` | 127.0.0.1 | MySQL 地址 |
| `EDC_MYSQL_PORT` | 3306 | MySQL 端口 |
| `EDC_MYSQL_USER` | dg | 数据库用户 |
| `EDC_MYSQL_PASSWORD` | 123456 | 数据库密码 |
| `EDC_MYSQL_DB` | edc | 数据库名 |
| `EDC_UDP_PORT` | 5500 | UDP 设备发现 |
| `EDC_UDP_MSG_PORT` | 5505 | UDP 消息监听 |
| `EDC_TCP_PORT` | 5550 | TCP 数据上报 |
| `EDC_DEVICE_UDP_PORT` | 4900 | 设备端 UDP |
| `EDC_DEVICE_TCP_PORT` | 5550 | 设备端 TCP |
| `EDC_DEVICE_TIMEOUT` | 120 | 设备离线超时(秒) |
| `EDC_PARSE_POLL_INTERVAL` | 0.5 | 解析轮询间隔(秒) |
| `EDC_LOG_LEVEL` | INFO | 日志级别 |
| `EDC_WEB_PORT` | 5000 | Web 管理端口 |
### C. 数据库快速参考
```sql
-- 查看在线设备
SELECT serial, name, ip, state, last_login FROM dnt_info WHERE state=1;
-- 查看最近测试数据
SELECT s.*, d.serial, d.name
FROM tb_state_tst s
JOIN dnt_info d ON s.dnt_id = d.id
ORDER BY s.create_time DESC LIMIT 20;
-- 查看待发送指令
SELECT * FROM tb_serialnet WHERE state=0;
-- 查看操作日志
SELECT l.*, u.username
FROM tb_log l
JOIN tb_user u ON l.user_id = u.id
ORDER BY l.create_time DESC LIMIT 50;
```
### D. 修订历史
| 版本 | 日期 | 说明 | 作者 |
|------|------|------|------|
| V1.0 | 2026-05-31 | 初始版本 | wangfq |

View File

@@ -0,0 +1,834 @@
# VD 测试工装 V2.0.0 培训手册
> **版本**: V2.0.0
> **日期**: 2026-06-08
> **作者**: wangfq
> **适用对象**: 测试工程师、生产操作员、系统管理员
---
## 目录
1. [项目概述](#1-项目概述)
2. [V2.0.0 新增功能总览](#2-v200-新增功能总览)
3. [系统架构](#3-系统架构)
4. [硬件环境](#4-硬件环境)
5. [EDC 服务](#5-edc-服务-edc_server)
6. [EDC 管理系统](#6-edc-管理系统-edc-web)
7. [通信协议](#7-通信协议)
8. [操作指南](#8-操作指南)
9. [常见问题](#9-常见问题)
10. [附录](#10-附录)
---
## 1. 项目概述
### 1.1 项目简介
**VD 测试工装**vd_test_fixture是一套车检器自动化测试系统用于**批量检测车检器Vehicle Detector的核心性能指标**。
**核心能力**:
- **灵敏度测试B2**: 检测车检器对不同信号强度的响应,含峰峰值、频率、距离、速度等指标
- **波动测试B4** 🆕: 模拟车辆往复运动,动态检测车检器在工作范围内的距离/频率/速度波动
- **产品一致性测试**: 批量产品之间的性能差异分析
- **自动化测试**: 支持设定间隔时间和超时时间,自动循环执行,实时进度反馈
- **工装配置管理**: 支持 DG430 V2.0.x 协议的设备参数配置、版本查询、出厂初始化,可关联线圈参数和模拟车辆参数 🆕
- **数据可视化** 🆕: 测试数据图表视图ECharts支持多 Y 轴、缩放、图片导出
### 1.2 术语说明
| 术语 | 全称 | 说明 |
|------|------|------|
| **EDC** | Edge Data Center | 边缘数据中心,系统的核心服务 |
| **DNT** | Data Network Terminal | 联网终端PGLC连接设备与 EDC |
| **DG430** | — | 地感测试工装硬件,执行实际测试动作 |
| **VD** | Vehicle Detector | 车检器(被测设备) |
| **SerialNet** | Serial Network | 串口网络透传,通过 UDP 将指令转发到 DG430 串口 |
| **B2** | — | 灵敏度测试数据来源标识0xB2 指令上报) |
| **B4** | — | 波动测试数据来源标识0xB4 指令上报) 🆕 |
| **线圈参数** | Coil Info | 地感线圈的物理参数(形状、尺寸、电感量、圈数等) 🆕 |
| **模拟车辆** | Simulate Car | 用于模拟车辆通过的金属板参数(形状、尺寸、材质) 🆕 |
### 1.3 V2.0.0 vs V1.0 对比
| 功能 | V1.0 | V2.0.0 |
|------|:----:|:------:|
| 灵敏度测试 | ✓ | ✓ |
| 波动测试 | ✗ | ✓ **新增** |
| 测试信息查询 | 单表显示 | 三视图标签页 + ECharts 图表 |
| 工装配置 | 基本参数 | +波动参数 +线圈/模拟车辆关联 |
| 线圈参数管理 | ✗ | ✓ **新增** |
| 模拟车辆参数管理 | ✗ | ✓ **新增** |
| 时间筛选 | 仅日期范围 | 日期 + 时分秒 |
| Admin 数据删除 | ✗ | ✓ **新增** |
| 继电器状态 | 文本 | 编码值 + 解码显示 + 图表系列 |
| 自动化测试 | 固定间隔 | 可配置间隔/超时 |
| 操作日志 | 部分 | 全覆盖(创建/更新/删除) |
---
## 2. V2.0.0 新增功能总览
### 2.1 波动测试模式
V2.0.0 最重要的新增功能。在原有灵敏度测试B2基础上增加了**波动测试模式B4**。
**测试原理**: 工装控制电机驱动金属板(模拟车辆)在地感线圈上方往复运动,通过车检器在不同位置的工作频率、距离、速度等指标变化,评估车检器性能的稳定性。
**与灵敏度测试的区别**:
| 对比维度 | 灵敏度测试 (B2) | 波动测试 (B4) |
|----------|:-------------:|:------------:|
| 运动方式 | 单次进入→离开 | 多次往复运动 |
| 测试指标 | 峰峰值、进入/离开频率、距离、速度 | 工作频率、当前距离、速度、剩余次数、最近/最远距离 |
| 完成判定 | 一次进入离开 | 达到设定来回次数 |
| 继电器 | 存在+脉冲 | 实时变化 |
### 2.2 线圈参数管理
新增线圈参数管理功能,用于记录和维护测试环境中的地感线圈信息:
- 线圈编号、名称
- 电感量μH
- 形状(矩形/圆形),尺寸(长宽/半径 cm
- 圈数、电阻(Ω)
- 材质(铜线等)、备注
线圈参数在工装配置页可关联到具体工装,测试数据中自动记录关联的线圈信息,为后续数据分析提供环境参考。
### 2.3 模拟车辆参数管理
管理用于模拟车辆通过的金属板参数:
- 模拟编号、名称
- 形状(矩形/圆形),尺寸(长宽/半径 cm
- 材质(铁板、合金等)、备注
模拟车辆参数同样可关联到工装配置,测试记录时自动写入。
### 2.4 测试信息三视图 + 图表
测试信息查询页面拆分为三个标签页:
- **全部数据**: 所有测试记录汇总视图
- **灵敏度测试0xB2**: B2 专用列布局(含故障信息、继电器、进入/离开频率等)
- **波动测试0xB4**: B4 专用列布局(含工作频率、当前距离、剩余次数、最近/最远距离等)
**图表视图**: 点击「📈 图表」按钮可切换为 ECharts 交互式图表,支持:
- B2峰峰值、频率4条、距离2条、速度2条+ 继电器状态
- B4工作频率、当前距离、速度、最近/最远距离、进入/离开高度 + 继电器状态
- 四 Y 轴独立刻度
- dataZoom 滑块缩放
- 图例切换显隐
- 2x 高清图片导出
### 2.5 精确时间筛选
搜索栏时间筛选从"仅日期"升级为"日期 + 时分秒"
- 只填日期不填时间 → 行为不变date_to 自动取 23:59:59
- 日期 + 时间都填 → 精确到秒过滤
---
## 3. 系统架构
### 3.1 整体架构图
```
┌──────────────────┐ 浏览器 HTTP
│ edc-web │ ◄────────────────────── 操作人员
│ Flask (Flask-Login) │
│ 前端管理界面 │
└────────┬─────────┘
│ pymysql (同步)
┌──────────────────┐
│ MySQL │
│ 数据库: edc │
│ (共享存储) │
└────────┬─────────┘
│ aiomysql (异步)
┌──────────────────┐ UDP :4900 ┌──────────────┐ RS485/TTL ┌────────────┐
│ edc_server │ ◄──────────────► │ PGLC 联网终端 │ ◄───────────► │ DG430 工装 │
│ Python/uvloop │ SerialNet 透传 │ (DNT) │ 串口协议 │ (测试硬件) │
│ │ └──────────────┘ └──────┬─────┘
│ UDP :5500/:5505 │ │
│ TCP :5550 │ ▼
└──────────────────┘ ┌──────────────┐
│ 车检器(VD) │
│ (被测设备) │
└──────────────┘
```
### 3.2 通信链路
```
操作员浏览器 → edc-web (Flask, port 5000) → MySQL → edc_server (asyncio) → DNT → DG430 → 车检器
MySQL (共享)
```
**关键点**:
- edc_server 和 edc-web 共享同一 MySQL 数据库
- edc_server 使用 aiomysql (异步)edc-web 使用 pymysql (同步),互不冲突
- 前端通过 edc-web 的 REST API 下发指令,实际执行由 edc_server 的轮询任务完成
### 3.3 端口分配
| 端口 | 方向 | 协议 | 说明 |
|------|------|------|------|
| **5500** | 监听 | UDP | EDC 设备发现 / 心跳 |
| **5505** | 监听 | UDP | EDC 消息监听 |
| **5550** | 监听 | TCP | EDC 时间同步 / 数据上报 / 串口透传 |
| **4900** | 发送 | UDP | 向设备发送 SerialNet 透传指令 |
| **5550** | 发送 | TCP | 向设备发送 TCP 数据 |
| **5000** | 监听 | HTTP | edc-web Flask 管理界面 |
### 3.4 项目结构
```
vd_test_fixture/
├── edc_server/ # EDC 边缘数据中心(后端服务)
│ └── src/
│ ├── server.py # UDP/TCP 异步网络服务
│ ├── handlers.py # 业务处理 + parse_loop/serialnet_loop 轮询
│ ├── models.py # 数据库 DDL + aiomysql CRUD
│ ├── dg430.py # DG430 二进制协议解析 (B2/B4/4C/4B)
│ └── protocol.py # PGLC JSON 协议解析
├── edc-web/ # Flask Web 管理系统(前端)
│ └── app/
│ ├── models.py # pymysql 同步数据库操作
│ ├── auth.py # Flask-Login 认证
│ ├── routes/ # 页面路由 (fixture/test_data/test_op/devices/users/logs)
│ ├── templates/ # Jinja2 HTML 模板
│ │ ├── fixture.html # 工装配置页(含线圈/车辆关联)
│ │ ├── test_data.html # 测试信息查询页(三视图 + 图表)
│ │ ├── test_op.html # 测试操作页(自动化 + 波动数据显示)
│ │ ├── coil_info.html # 线圈参数管理页 🆕
│ │ ├── simulate_car.html # 模拟车辆管理页 🆕
│ │ └── vehicle_base_test.html # 车检器基准管理页
│ └── static/
│ ├── css/style.css
│ └── js/
│ ├── fixture.js # 工装配 JS线圈/车辆选择联动)
│ ├── test_data.js # 测试信息 JS三视图/图表/分页)
│ ├── coil_info.js # 线圈管理 JS 🆕
│ ├── simulate_car.js # 模拟车辆管理 JS 🆕
│ └── ...
└── docs/ # 协议文档 + 培训手册
```
---
## 4. 硬件环境
### 4.1 DG430 地感测试工装
DG430 是执行测试的核心硬件,负责控制电机驱动模拟车辆经过地感线圈,并采集车检器的响应数据。
**接口**:
| 接口 | 连接 | 说明 |
|------|------|------|
| IN1/GND | 地感存在信号 | 检测线圈是否有车 |
| IN2/GND | 地感脉冲信号 | 检测脉冲继电器 |
| IN3/COM | 按钮 | 按下开始测试 |
| IN4/COM | 按钮 | 按下复原位置 |
| PU+/PU-/DR+/DR-/MF+/MF- | 电机驱动器 | 控制电机前进/后退 |
| +5V/GND/NO/NC | 限位开关 | 有信号电机停转 |
| 485A/485B | RS485 | 接 PGLC 联网终端 |
| GND/LP | 地感线圈 | 模拟车辆通过 |
| SW3 | 激光探头 | 检测进入/离开 |
**拨码开关**:
- DIP1=OFF, DIP2=OFF → 测试 132 系列地感
- DIP1=ON, DIP2=OFF → 测试 110 系列地感
**声音提示**:
| 声音 | 含义 |
|------|------|
| 2 声 | 工作频率/峰峰值异常 |
| 3 声 | 灵敏度异常 |
| 4 声 | 灵敏度提升异常 (132 DIP5) |
| 5 声 | 非离开脉冲 |
| 6 声 | 脉冲继电器无输入 |
### 4.2 PGLC 联网终端 (DNT)
PGLC 终端是连接 EDC 服务和 DG430 工装的**网络桥接设备**:
- 通过 **RS485/TTL 串口** 连接 DG430 工装
- 通过 **TCP/UDP 网络** 连接 EDC 服务
- 负责串口数据与网络数据的双向透传
### 4.3 测试环境准备 🆕
V2.0.0 新增了测试环境记录能力。在实际测试中,需要准备的硬件包括:
1. **地感线圈**: 安装在地面的感应线圈,需记录形状/尺寸/电感量/圈数等参数
2. **模拟车辆(金属板)**: 安装在工装电机上的金属板,用于模拟车辆通过线圈
以上两类参数需要在 edc-web 中预先录入,并在工装配置页关联到具体设备。
---
## 5. EDC 服务 (edc_server)
### 5.1 功能概述
EDC 服务是整个系统的**数据中枢**,负责:
1. **设备管理**: 发现、注册、心跳检测、在线状态维护
2. **数据采集**: 接收设备上报的测试数据、原始传感数据
3. **协议解析**: 解析 DG430 二进制协议提取测试结果B2 + B4
4. **指令透传**: SerialNet 透传机制,将前端指令下发给设备
5. **后台轮询**: 自动化测试调度、超时检测、状态流转
### 5.2 启动方式
```bash
cd edc_server
source .venv/bin/activate
# 配置环境变量
export EDC_MYSQL_HOST=127.0.0.1
export EDC_MYSQL_USER=dg
export EDC_MYSQL_PASSWORD=123456
export EDC_MYSQL_DB=edc
# 启动
python run.py
```
> ⚠️ 启动时自动执行数据库表创建和 ALTER TABLE 迁移,无需手动建表。
### 5.3 数据库表结构
| 表名 | 用途 | 关键字段 |
|------|------|----------|
| `dnt_info` | 联网终端信息 | serial(唯一), ip, state(在线/离线) |
| `tb_state_tst` | 设备测试状态 | dnt_id, test_mode, data_source, ppvalue, idle_freq, …, coil_id, simulate_car_id 🆕 |
| `tb_serialnet` | 透传发送队列 | dnt_id, send_pkg, rcv_pkg, state(0未发→1已发→2完成→3超时) |
| `tb_fixture_param` | 工装测试参数 | dnt_id(UNIQUE), DevType, TestMode, FarTol…, coil_id, simulate_car_id 🆕 |
| `tb_coil_info` 🆕 | 线圈参数 | coil_num, name, induct, shape, length/width/radius, turns, resistance, material |
| `tb_simulate_car` 🆕 | 模拟车辆参数 | simulate_num, name, shape, length/width/radius, material |
| `tb_vechicle_base_test` | 车检器基准参数 | type_num(编码), SensMin/SensMax, FreMin/FreMax, PeakMin/PeakMax |
| `tb_user` | 用户账号 | username, password_hash, role(admin/operator) |
| `tb_log` | 操作日志 | user_id, action_type, target, detail, result, ip |
| `tb_collect_{DeviceID}` | 设备原始数据采集表 | dat_type, raw_data, state(0未处理/1已处理) |
### 5.4 关键流程
#### 设备注册流程
```
设备上电 → TCP 时间同步 → UDP 上报 Count_Off → EDC 检查 serial
├─ 已注册 → 更新 IP/网关 + last_login
└─ 未注册 → 插入 dnt_info + 创建 tb_collect_{DeviceID}
```
#### SerialNet 透传流程
```
前端 → edc-web → INSERT tb_serialnet (state=0)
→ serialnet_loop 轮询 → UDP 发送到设备 (state=1)
→ 设备回复 TSReport(dat_type=8 或 9)
→ parse_loop 解析 → UPDATE tb_serialnet (state=2, rcv_pkg=...)
→ 前端轮询 api/fixture/serialnet/{id} → 显示结果
```
#### 测试数据写入流程 🆕
```
parse_loop 解析 B2/B4 数据 → 查询 tb_fixture_param
├─ B2: 提取 coil_id, simulate_car_id → 写入 tb_state_tst
└─ B4: 提取 coil_id, simulate_car_id + DevType → 写入 tb_state_tst (含 sub_type, str_type)
```
---
## 6. EDC 管理系统 (edc-web)
### 6.1 功能概述
edc-web 是基于 Flask 的 Web 管理系统,提供图形化操作界面。
**功能模块**:
| 模块 | URL | 功能 | V2 变化 |
|------|-----|------|:------:|
| **登录** | `/login` | 用户认证 (Flask-Login) | — |
| **设备管理** | `/` | 联网终端列表、在线状态、名称修改 | — |
| **测试操作** | `/test-op/<dnt_id>` | 单次测试、手动控制、自动化测试、波动数据显示 | 🆕 新增波动测试区 |
| **测试信息** | `/test-data` | 三视图(全部/B2/B4+ ECharts 图表 + 时间精筛 + Admin 删除 | 🆕 大幅增强 |
| **工装配置** | `/fixture/<dnt_id>` | DG430 参数配置 + 波动参数 + 线圈/车辆关联 | 🆕 新增关联 |
| **线圈参数** 🆕 | `/coil-info` | 线圈参数增删改查 | 🆕 新增 |
| **模拟车辆** 🆕 | `/simulate-car` | 模拟车辆参数增删改查 | 🆕 新增 |
| **车检器基准** | `/vehicle-base-test` | 车检器测试基准参数管理 | — |
| **用户管理** | `/users` | 账号管理 (admin only) | — |
| **操作日志** | `/logs` | 操作记录审计 | 🆕 覆盖更全 |
### 6.2 启动方式
```bash
cd edc-web
source .venv/bin/activate
# 配置环境变量(与 edc_server 相同)
export EDC_MYSQL_HOST=127.0.0.1
export EDC_MYSQL_USER=dg
export EDC_MYSQL_PASSWORD=123456
export EDC_MYSQL_DB=edc
# 启动(默认 5000 端口)
python run.py
```
**首次启动**: 自动创建默认管理员 `admin / admin123`
### 6.3 用户角色
| 角色 | 权限 |
|------|------|
| **admin** | 全部功能:设备管理、测试操作、数据删除、用户管理、日志查看、参数管理 |
| **operator** | 受限功能:设备管理、测试操作、数据查看(无删除权限) |
---
## 7. 通信协议
### 7.1 DG430 串口协议
DG430 与 PGLC 终端之间的通信协议,采用**一问一答**方式。
**数据包格式** (7 字节):
```
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ STX │ ADDR │ LEN │ CMD │ DATA │ XOR │ SUM │
│ 0x7F │ 1B │ 1B │ 1B │ LEN-1│ 1B │ 1B │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘
```
#### 测试操作指令
| 指令 | CMD | Hex (addr=0x01) | 说明 |
|------|-----|-----------------|------|
| 开始测试 | B0 | `7F 81 01 B0 30 32` | 启动一次测试流程 |
| 测试复原 | B1 | `7F 81 01 B1 31 33` | 恢复到初始位置 |
| 电机前进 | BA | `7F 81 01 BA 3A 3C` | 电机正转 |
| 电机后退 | BB | `7F 81 01 BB 3B 3D` | 电机反转 |
| 电机停止 | BC | `7F 81 01 BC 3C 3E` | 电机停止 |
#### 工装配置指令 (V2.0.x)
| 指令 | CMD | 说明 |
|------|-----|------|
| 获取版本号 | 4A | 查询 DG430 固件和硬件版本 |
| 配置测试参数 | 4B | 下发工装参数(含波动参数),动态构造 |
| 查询测试参数 | 4C | 查询当前全部参数 |
| 出厂初始化 | 4D | 恢复出厂设置 |
| 设备复位 | 4E | 重启 DG430 设备 |
> ⚠️ **端序注意**: B2 上报和 0x4B/0x4C 的 2 字节字段**统一用小端** (`_le16`)。
#### B2 状态上报(灵敏度测试)
| 字段 | 说明 | 范围 |
|------|------|------|
| State | 设备状态 | 正常/故障 |
| Mode | 工作模式 | — |
| Sens | 灵敏度 | 实际值 |
| PPValue | 峰峰值 | mV |
| IdleFreq | 开始工作频率 | kHz |
| EnterFreq | 进入工作频率 | kHz |
| EnterDist | 进入距离 | mm |
| ExitDist | 离开距离 | mm |
| EnterSpeed | 进入速度 | dm/s |
| ExitSpeed | 离开速度 | dm/s |
#### B4 状态上报(波动测试)🆕
| 字段 | 说明 | 范围 |
|------|------|------|
| RemainCount | 剩余波动次数 | — |
| WorkFreq | 当前工作频率 | Hz |
| CurrDist | 当前距离 | mm |
| Speed | 当前速度 | dm/s |
| NearDist | 最近距离 | mm |
| FarDist | 最远距离 | mm |
| EnterDist | 进入高度 | mm |
| LeaveDist | 离开高度 | mm |
| RelayOut | 继电器输出 | 0x00~0x03 |
#### 0x4B 配置参数结构 (V2.0.3)
| 字段 | 字节 | 说明 |
|------|:---:|------|
| Addr | 1 | 工装设备地址 |
| DevType | 1 | 被检设备型号类型编码1=PD132, 2=DLD110 |
| TestMode | 1 | 0=灵敏度测试, 1=波动测试 |
| RestDis | 1 | 复位距离 cm |
| MinusDis | 1 | 皮距/开始距离 cm |
| SensMin/Max | 2+2 LE | 灵敏度范围 |
| FreMin/Max | 2+2 LE | 频率范围 Hz |
| PeakMin/Max | 2+2 LE | 峰峰值范围 |
| FarTol | 1 | 最远容差 cm 🆕 |
| NearTol | 1 | 最近容差 cm 🆕 |
| StepTol | 1 | 步进容差 cm 🆕 |
| BackForth | 1 | 来回次数 🆕 |
| NearStay | 2 LE | 最近停留时间 ms 🆕 |
| FarStay | 2 LE | 最远停留时间 ms 🆕 |
### 7.2 PGLC 网络接口协议
EDC 服务与 PGLC 终端之间的 JSON 协议。
| Method | 方向 | 说明 |
|--------|------|------|
| `TimeStamp` | 设备→EDC | 时间同步请求 |
| `Count_Off` | 设备→EDC | 设备注册/发现 |
| `TSReport` | 设备→EDC | 子设备数据上报 |
| `SerialNet` | EDC→设备 | 串口透传指令下发 |
| `Heartbeat` | 设备→EDC | 心跳包 |
---
## 8. 操作指南
### 8.1 登录系统
1. 浏览器访问 `http://<服务器IP>:5000/login`
2. 默认账号: `admin` / `admin123`
3. 登录后跳转到设备管理页
### 8.2 设备管理
**查看设备列表**:
- 首页展示所有联网终端
- 显示序列号、名称、IP、在线状态
**修改设备名称**:
- 点击设备行的名称编辑图标
- 输入新名称后提交保存
**进入测试**: 点击设备行右侧的「测试」按钮
**进入工装配置**: 点击「工装配」按钮 🆕
### 8.3 工装配置(重要:新设备必做)🆕
> ⚠️ **新设备首次使用,必须先完成工装配置并保存到数据库,然后才能进行测试。**
#### 8.3.1 配置前置准备
1. 进入「线圈参数管理」页面(`/coil-info`),录入测试使用的线圈参数
2. 进入「模拟车辆参数管理」页面(`/simulate-car`),录入金属板参数
3. 进入「车检器测试基准参数管理」页面(`/vehicle-base-test`),录入车检器型号基准
#### 8.3.2 工装参数配置
从设备页面点击「工装配」进入配置页:
**左侧 - 工装测试参数**:
1. 设置**工装设备地址**(默认 1
2. 选择**测试模式**:灵敏度测试(0) 或 波动测试(1)
3. 设置复位距离、皮距
4. 从下拉框选择**被检设备型号**(自动填充灵敏度/频率/峰峰值范围)
5. 如选择波动测试模式,还需配置 6 个波动参数:
- 最远容差 / 最近容差 / 步进容差cm
- 来回次数
- 最近停留时间 / 最远停留时间ms
**右侧 - 关联参数**:
6. 在「关联线圈参数」下拉框选择使用的线圈
7. 在「关联模拟车辆参数」下拉框选择使用的金属板
#### 8.3.3 保存配置
点击 **「💾 保存」** 按钮将参数保存到数据库。
> 保存会自动记录操作日志。线圈和模拟车辆关联**不需要下发透传到工装硬件**,仅记录在数据库中用于测试环境追溯。
#### 8.3.4 其他工装操作
| 按钮 | 指令 | 说明 |
|------|------|------|
| 📋 获取版本号 | 0x4A | 查询 DG430 固件/硬件版本 |
| 🔍 查询参数 | 0x4C | 从设备读取当前参数(结果自动更新表单) |
| 📤 配置参数 | 0x4B | 下发配置到 DG430 设备(同时自动保存到数据库) |
| 🏭 出厂初始化 | 0x4D | 恢复 DG430 出厂设置 |
| 🔄 设备复位 | 0x4E | 重启 DG430 设备 |
**通信日志**: 每次操作后自动显示 hex 收发记录和结果。
### 8.4 手动测试操作
在测试操作页面(点击设备行的「测试」按钮):
1. **单次测试**: 点击「开始测试」按钮DG430 执行一次完整测试流程
2. **手动控制** (仅 admin):
- 测试复原: 恢复初始位置
- 电机前进/后退/停止: 手动控制电机
3. **实时数据**: 页面自动显示设备上报的最新测试数据
### 8.5 自动化测试 🆕
V2.0.0 自动化测试支持可配置的间隔和超时:
1. 设置**间隔时间**(秒,默认 100 表示无间隔连续测试)
2. 设置**超时时间**(秒,默认 5单次测试最大等待时间
3. 填入**测试次数**
4. 点击「开始」按钮
- 进度条显示 `已完成/总数`
- 成功/失败计数实时更新
- 平均值区域持续刷新
- 波动测试数据区实时显示 B4 上报 🆕
5. 如需中途停止,点击「结束」按钮
**自动化流程**:
```
开始 → INSERT tb_serialnet (0xB0, state=0)
→ serialnet_loop 发送 UDP (state=1)
→ 等待设备 B2/B4 上报
→ parse_loop 解析存入 tb_state_tst含线圈/车辆关联)
→ 匹配 serialnet 记录 (state=2)
→ 前端轮询进度 → 等待间隔 → 自动发送下一次 0xB0
→ ...重复...
→ 全部完成 / 超时(fail) / 手动结束
```
**超时处理**: 单次测试超时计入失败,跳过间隔时间立即发送下一条。连续超时不阻塞。
### 8.6 测试数据查询 🆕
#### 8.6.1 三视图切换
进入「测试信息」页面,顶部三个标签页:
- **全部数据**: 显示所有测试记录(灵敏度+波动),通用列布局
- **灵敏度测试 (0xB2)**: 仅显示 B2 来源数据,含故障信息、继电器、进入/离开频率等专属列
- **波动测试 (0xB4)**: 仅显示 B4 来源数据,含工作频率、当前距离、剩余次数等专属列
#### 8.6.2 搜索筛选
| 筛选项 | 说明 |
|--------|------|
| 设备编码 | 模糊搜索 serial |
| 时间范围 | 日期 + 时分秒(可选),只填日期不填时间则 date_to 自动取 23:59:59 |
| 每页 | 20 / 50 / 100 条 |
「测试环境」列显示关联的线圈编号和模拟车辆编号。 🆕
#### 8.6.3 图表视图
点击「📈 图表」按钮切换为 ECharts 交互式图表:
- **B2 图表**: 峰峰值(V)、开始/进入/离开频率(Hz)、进入/离开距离(mm)、进入/离开速度(dm/s) + 继电器状态
- **B4 图表**: 工作频率(Hz)、当前距离(mm)、速度(dm/s)、最近/最远距离(mm)、进入/离开高度(mm) + 继电器状态
- **操作**: dataZoom 缩放、图例点击显隐、右键保存为 2x PNG
> 图表切换到「全部」视图时自动跳转到 B2 视图。
#### 8.6.4 导出 CSV
点击「导出 CSV」按钮下载当前筛选条件下的全部数据为 CSV 文件。
#### 8.6.5 Admin 数据删除 🆕
仅 admin 可见「🗑 删除」按钮:
1. 设置筛选条件(设备编码 / 时间范围 / 数据来源)
2. 点击「🗑 删除」
3. 确认框显示筛选条件,确认后执行删除
4. 删除操作记录到 tb_log
5. ⚠️ 无筛选条件时不删除任何数据
### 8.7 参数管理 🆕
#### 线圈参数管理 (`/coil-info`)
支持线圈参数的全生命周期管理:
- **新增**: 填写线圈编号、名称、电感量、形状、尺寸、圈数、电阻、材质、备注
- **编辑**: 点击表格行的「编辑」按钮修改
- **删除**: 点击「删除」按钮(需确认)
- **搜索**: 按编号或名称模糊搜索
- 所有操作自动记录到操作日志
#### 模拟车辆参数管理 (`/simulate-car`)
支持模拟车辆(金属板)参数的全生命周期管理:
- 模拟编号、名称、形状、尺寸、材质、备注
- 操作方式和线圈管理一致
#### 车检器测试基准管理 (`/vehicle-base-test`)
管理不同型号车检器的测试基准参数:
- 类型编码、型号名称
- 灵敏度/频率/峰峰值范围
- 备注
### 8.8 用户管理 (admin only)
1. 点击「用户管理」菜单
2. 功能: 添加新用户、修改角色、删除用户
### 8.9 操作日志
1. 点击「操作日志」菜单
2. V2.0.0 覆盖更全的操作类型:
- `login` / `logout`: 登录/登出
- `command`: 指令下发(工装配/测试操作)
- `create` / `update` / `delete`: 参数管理操作(线圈/车辆/基准/工装)
---
## 9. 常见问题
### 9.1 新设备如何开始测试?
1. 确保设备在线(设备列表显示"在线"
2. **录入线圈参数**`/coil-info` 添加测试使用的线圈
3. **录入模拟车辆参数**`/simulate-car` 添加金属板参数
4. **录入车检器基准**`/vehicle-base-test` 添加车检器型号
5. **配置工装参数** → 进入工装配页,设置参数、关联线圈和车辆,点击「💾 保存」
6. 进入测试操作页开始测试
### 9.2 波动测试和灵敏度测试有什么区别?
| 维度 | 灵敏度测试 | 波动测试 |
|------|:--------:|:------:|
| 目的 | 检测车检器灵敏度、峰峰值等静态指标 | 检测车检器在工作范围内的动态稳定性 |
| 运动方式 | 单次进入→离开 | 多次往复运动 |
| 数据标识 | data_source=B2 | data_source=B4 |
| 配置 | TestMode=0 | TestMode=1需配波动参数 |
| 查询 | 选「灵敏度测试」标签 | 选「波动测试」标签 |
### 9.3 线圈/模拟车辆关联有什么用?
线圈和模拟车辆关联**不会下发到硬件**,仅记录在数据库中。当测试记录写入 `tb_state_tst` 时,会自动带上当时的线圈和车辆信息,方便后续数据分析时了解测试环境(例如"某批次测试用了哪种线圈、哪种金属板")。
### 9.4 设备离线
**现象**: 设备列表显示"离线"
**排查**:
1. 检查设备供电是否正常
2. 检查网络连接ping 设备 IP
3. 查看 edc_server 日志,确认是否收到心跳
4. 设备超时阈值: 默认 120 秒(`EDC_DEVICE_TIMEOUT`
### 9.5 测试超时
**现象**: 自动化测试中连续超时
**排查**:
1. 检查 DG430 工装是否正常上电
2. 检查 RS485 连接是否正确
3. 在工装配页发送「获取设备版本号」(0x4A),验证通信链路
4. 可调整超时时间(默认 5 秒)
### 9.6 速度显示异常
**现象**: 速度值看起来偏大 10 倍
**原因**: DG430 协议存储的是 dm/s分米/秒),系统已自动转换为 m/s米/秒)。如果发现显示值异常,检查转换逻辑。
### 9.7 时间显示偏移 8 小时
**现象**: 测试数据时间比实际晚 8 小时
**已修复**: V2.0.0 已修复 Flask jsonify 的 "GMT" 后缀导致的时区偏移问题所有时间按服务器本地时间UTC+8显示。
---
## 10. 附录
### A. DG430 指令速查
```
┌──────────────────────────────────────────────────────────────────┐
│ DG430 指令速查表 │
├────────┬──────────┬──────────────────────────────────────────────┤
│ CMD │ 指令 │ Hex (addr=0x01) │
├────────┼──────────┼──────────────────────────────────────────────┤
│ 0xB0 │ 开始测试 │ 7F 81 01 B0 30 32 │
│ 0xB1 │ 测试复原 │ 7F 81 01 B1 31 33 │
│ 0xBA │ 电机前进 │ 7F 81 01 BA 3A 3C │
│ 0xBB │ 电机后退 │ 7F 81 01 BB 3B 3D │
│ 0xBC │ 电机停止 │ 7F 81 01 BC 3C 3E │
│ 0x4A │ 版本查询 │ 7F 81 01 4A CA CC │
│ 0x4B │ 配置参数 │ 动态构造(含波动参数) │
│ 0x4C │ 查询参数 │ 7F 81 01 4C CC CE │
│ 0x4D │ 出厂初始化 │ 7F 81 01 4D CD CF │
│ 0x4E │ 设备复位 │ 7F 81 01 4E CE D0 │
└────────┴──────────┴──────────────────────────────────────────────┘
```
### B. 数据库快速参考
```sql
-- 查看在线设备
SELECT serial, name, ip, state, last_login FROM dnt_info WHERE state=1;
-- 查看最近灵敏度测试数据
SELECT s.*, d.serial
FROM tb_state_tst s JOIN dnt_info d ON s.dnt_id = d.id
WHERE s.data_source='B2' ORDER BY s.create_time DESC LIMIT 20;
-- 查看最近波动测试数据
SELECT s.*, d.serial
FROM tb_state_tst s JOIN dnt_info d ON s.dnt_id = d.id
WHERE s.data_source='B4' ORDER BY s.create_time DESC LIMIT 20;
-- 查看测试环境(线圈+车辆关联)
SELECT t.create_time, d.serial, c.coil_num, c.name as coil_name,
sc.simulate_num, sc.name as car_name
FROM tb_state_tst t
JOIN dnt_info d ON t.dnt_id = d.id
LEFT JOIN tb_coil_info c ON t.coil_id = c.id
LEFT JOIN tb_simulate_car sc ON t.simulate_car_id = sc.id
ORDER BY t.create_time DESC LIMIT 20;
-- 查看待发送指令
SELECT * FROM tb_serialnet WHERE state=0;
-- 查看操作日志
SELECT l.*, u.username
FROM tb_log l JOIN tb_user u ON l.user_id = u.id
ORDER BY l.create_time DESC LIMIT 50;
```
### C. 环境变量速查
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `EDC_MYSQL_HOST` | 127.0.0.1 | MySQL 地址 |
| `EDC_MYSQL_PORT` | 3306 | MySQL 端口 |
| `EDC_MYSQL_USER` | dg | 数据库用户 |
| `EDC_MYSQL_PASSWORD` | 123456 | 数据库密码 |
| `EDC_MYSQL_DB` | edc | 数据库名 |
| `EDC_UDP_PORT` | 5500 | UDP 设备发现 |
| `EDC_UDP_MSG_PORT` | 5505 | UDP 消息监听 |
| `EDC_TCP_PORT` | 5550 | TCP 数据上报 |
| `EDC_DEVICE_UDP_PORT` | 4900 | 设备端 UDP |
| `EDC_DEVICE_TCP_PORT` | 5550 | 设备端 TCP |
| `EDC_DEVICE_TIMEOUT` | 120 | 设备离线超时(秒) |
| `EDC_PARSE_POLL_INTERVAL` | 0.5 | 解析轮询间隔(秒) |
| `EDC_LOG_LEVEL` | INFO | 日志级别 |
| `EDC_WEB_PORT` | 5000 | Web 管理端口 |
### D. 新设备配置检查清单
- [ ] 设备上电,确认在线状态
- [ ] `/coil-info` 录入线圈参数
- [ ] `/simulate-car` 录入模拟车辆参数
- [ ] `/vehicle-base-test` 录入车检器基准
- [ ] 工装配页:设置测试模式、被检设备型号
- [ ] 工装配页:关联线圈和模拟车辆
- [ ] 工装配页:点击「💾 保存」
- [ ] (可选)「📤 配置参数」下发到 DG430
- [ ] 进入测试操作页开始测试
### E. 修订历史
| 版本 | 日期 | 说明 | 作者 |
|------|------|------|------|
| V1.0 | 2026-05-31 | 初始版本(灵敏度测试 + 基本工装配置) | wangfq |
| V2.0.0 | 2026-06-08 | 新增波动测试、线圈/车辆管理、三视图图表、时间精筛、自动化增强 | wangfq |

View File

@@ -0,0 +1,248 @@
# 波动测试模式前端适配 实施计划
> **For Hermes:** 直接执行各 Task。
**Goal:** 将波动测试模式0xB2 的 test_mode + 0xB4 上报数据)接入测试操作页面、查询导出页面,扩展 `tb_state_tst` 表字段。
**Architecture:** 单表方案 — `tb_state_tst` 增加 `test_mode``data_source` 和 B4 专属字段B2 和 B4 记录共存同表,通过 `data_source` 区分。后端 edc_server 存储、edc-web 查询/导出均扩展。
**Tech Stack:** Python/aiomysql (edc_server) + Flask/pymysql (edc-web) + vanilla JS 前端
---
### 涉及文件
| 层 | 文件 | 变更 |
|---|---|---|
| 数据库 | `edc_server/src/models.py` | DDL + ALTER 迁移 + `insert_test_result` 扩展 + 新增 `insert_wave_data` |
| 解析 | `edc_server/src/handlers.py` | 0xB2 传 test_mode、0xB4 调 insert_wave_data |
| 查询 | `edc-web/app/models.py` | 新增查询函数、导出扩展 |
| API | `edc-web/app/routes/test_op.py` | progress 端点返回波动数据 |
| API | `edc-web/app/routes/test_data.py` | 查询/导出增加新字段 |
| 前端 | `edc-web/app/templates/test_op.html` | 增加波动数据显示区 |
| 前端 | `edc-web/app/static/js/test_op.js` | renderLatest/renderRecords 扩展 + B4 轮询 |
| 前端 | `edc-web/app/templates/test_data.html` | 表头增加列 + test_mode 筛选 |
| 前端 | `edc-web/app/static/js/test_data.js` | renderTable 扩展 |
---
### Task 1: 扩展 tb_state_tst DDL + 迁移 (edc_server)
**文件:** `edc_server/src/models.py`
**Step 1:** 修改 CREATE TABLE 增加 9 列
在现有 `tb_state_tst` DDL 中增加:
```sql
`test_mode` TINYINT DEFAULT 0 COMMENT '0 灵敏度测试, 1 波动测试',
`data_source` CHAR(2) DEFAULT 'B2' COMMENT '数据来源 B2/B4',
`remain_count` INT DEFAULT 0 COMMENT '剩余波动次数 (B4)',
`work_freq` FLOAT DEFAULT 0 COMMENT '工作频率 Hz (B4)',
`curr_dist` INT DEFAULT 0 COMMENT '当前距离 mm (B4)',
`speed` INT DEFAULT 0 COMMENT '当前速度 dm/s (B4)',
`near_dist` INT DEFAULT 0 COMMENT '波动最近距离 mm (B4)',
`far_dist` INT DEFAULT 0 COMMENT '波动最远距离 mm (B4)',
`b4_enter_dist` INT DEFAULT 0 COMMENT 'B4 进入高度 mm',
`b4_leave_dist` INT DEFAULT 0 COMMENT 'B4 离开高度 mm'
```
**Step 2:**`_create_tables` 末尾增加 ALTER TABLE 迁移逻辑(参照已有的 `tb_fixture_param` 迁移模式)
```python
# V2.0.4 迁移tb_state_tst 增加波动测试字段
for col, col_def in [
("test_mode", "TINYINT DEFAULT 0 COMMENT '0 灵敏度, 1 波动测试'"),
("data_source", "CHAR(2) DEFAULT 'B2' COMMENT 'B2/B4'"),
("remain_count", "INT DEFAULT 0 COMMENT '剩余波动次数'"),
("work_freq", "FLOAT DEFAULT 0 COMMENT '工作频率 Hz'"),
("curr_dist", "INT DEFAULT 0 COMMENT '当前距离 mm'"),
("speed", "INT DEFAULT 0 COMMENT '当前速度 dm/s'"),
("near_dist", "INT DEFAULT 0 COMMENT '波动最近距离 mm'"),
("far_dist", "INT DEFAULT 0 COMMENT '波动最远距离 mm'"),
("b4_enter_dist", "INT DEFAULT 0 COMMENT 'B4 进入高度 mm'"),
("b4_leave_dist", "INT DEFAULT 0 COMMENT 'B4 离开高度 mm'"),
]:
try:
await cur.execute(f"ALTER TABLE `tb_state_tst` ADD COLUMN `{col}` {col_def}")
except Exception:
pass
```
**Step 3:** 修改 `insert_test_result` 函数签名,增加 `test_mode``data_source` 参数
```python
async def insert_test_result(dnt_id, dpg430_addr, pcnum, serialnum,
sub_type, str_type, iffinish, fault_info,
relay_out, ppvalue, idle_freq, enter_freq,
exit_freq, enter_dist, exit_dist,
enter_speed, exit_speed,
test_mode=0, data_source='B2'):
```
INSERT 语句增加 `test_mode`, `data_source` 列。
**Step 4:** 新增 `insert_wave_data` 函数
```python
async def insert_wave_data(dnt_id, dpg430_addr, remain_count, relay_out,
work_freq, curr_dist, speed, near_dist, far_dist,
enter_dist, leave_dist):
"""插入 0xB4 波动测试上报数据到 tb_state_tst"""
```
---
### Task 2: handlers.py 适配
**文件:** `edc_server/src/handlers.py`
**Step 1:** 0xB2 处理 — `insert_test_result` 调用增加 `test_mode=status.test_mode`
```python
await insert_test_result(
...
test_mode=status.test_mode,
)
```
**Step 2:** 0xB4 处理 — 调用 `insert_wave_data` 存库
```python
from src.models import insert_wave_data # 增加导入
# 在 0xB4 分支末尾
await insert_wave_data(
dnt_id=dnt_id,
dpg430_addr=wave.addr,
remain_count=wave.remain_count,
relay_out=wave.relay_out,
work_freq=wave.work_freq,
curr_dist=wave.curr_dist,
speed=wave.speed,
near_dist=wave.near_dist,
far_dist=wave.far_dist,
enter_dist=wave.enter_dist,
leave_dist=wave.leave_dist,
)
```
---
### Task 3: edc-web models.py 查询扩展
**文件:** `edc-web/app/models.py`
**Step 1:** 新增 `get_latest_wave_data(dnt_id)` — 获取最新一条 B4 数据
**Step 2:** 新增 `get_wave_records(dnt_id, since)` — 获取本轮 B4 明细
查询条件: `data_source='B4' AND create_time >= since`
**Step 3:** 修改 `get_automation_averages` — 不涉及波动字段,保持不变
---
### Task 4: edc-web test_op.py 路由扩展
**文件:** `edc-web/app/routes/test_op.py`
`api_automation_progress` 中增加返回 B4 数据:
```python
latest_wave = get_latest_wave_data(dnt_id)
wave_records = get_wave_records(dnt_id, since) if since else []
return jsonify({
...
"latest_wave": latest_wave,
"wave_records": wave_records,
})
```
导入新增的 `get_latest_wave_data`, `get_wave_records`
---
### Task 5: edc-web test_data.py 路由扩展
**文件:** `edc-web/app/routes/test_data.py`
**Step 1:** `api_test_data` — 增加 `test_mode` 筛选参数
```python
test_mode = request.args.get("test_mode", "", type=str) # '' = 全部, '0' = 灵敏度, '1' = 波动
```
传给 `get_test_data`,在 SQL WHERE 中增加 `AND t.test_mode = %s` 条件。
**Step 2:** 修改 `get_test_data` 函数签名和 SQL在 models.py 中)
**Step 3:** CSV 导出同样支持 `test_mode` 筛选
---
### Task 6: 前端 test_op.html + test_op.js
**文件:** `edc-web/app/templates/test_op.html` + `static/js/test_op.js`
**Step 1:** HTML — 在 `latest-result` 区域增加波动数据显示 div
```html
<h3>波动测试数据</h3>
<div id="latest-wave">
<p class="placeholder">暂无波动数据...</p>
</div>
```
**Step 2:** JS — `renderLatest` 增加 test_mode 显示
在现有显示中增加:
```js
<p>测试模式<strong>${data.test_mode === 1 ? '波动测试' : '灵敏度测试'}</strong></p>
```
**Step 3:** JS — 新增 `renderLatestWave(data)` 函数
显示 B4 上报数据:剩余次数、当前距离、速度、最近/最远距离、进入/离开高度。
**Step 4:** JS — `pollProgress` 中调用 `renderLatestWave(data.latest_wave)`
**Step 5:** JS — 明细表 `renderRecords` 增加测试模式列
---
### Task 7: 前端 test_data.html + test_data.js
**文件:** `edc-web/app/templates/test_data.html` + `static/js/test_data.js`
**Step 1:** HTML — 搜索栏增加测试模式下拉筛选
```html
<label>
测试模式:
<select id="search-test-mode">
<option value="">全部</option>
<option value="0">灵敏度测试</option>
<option value="1">波动测试</option>
</select>
</label>
```
**Step 2:** HTML — 表头增加列:测试模式、数据来源、剩余次数、工作频率、当前距离、速度、最近距离、最远距离
**Step 3:** JS — `searchData``test_mode` 参数
**Step 4:** JS — `renderTable` 增加新列渲染
**Step 5:** JS — `exportCSV``test_mode` 参数
---
### Task 8: 提交推送
```bash
cd /home/wfq/projects/vd_test_fixture
git add -A
git commit -m "feat: 波动测试模式前端适配 — tb_state_tst扩展+0xB4存库+页面更新"
git push origin main
```

View File

@@ -0,0 +1,108 @@
# V2.0.4 周报 — 车检器测试工装项目
**报告周期**2026年6月1日 ~ 6月5日
**项目**vd_test_fixture车检器自动化测试工装
**作者**wangfq
---
## 一、概述
本周完成 V2.0.3 波动测试模式全链路实现、继电器状态存储重构、测试操作/信息页面多项增强,以及若干关键 Bug 修复。共提交 **24 个 commits**(主仓库 19 + edc_server 子模块 4 + edc-web 1
---
## 二、波动测试模式V2.0.3
### 2.1 协议文档
- DG430 串口协议升级至 V2.0.3
- 扩展 0x4B/0x4C 字段:新增 FarTol、NearTol、StepTol、BackForth、NearStay、FarStay6 个波动参数)
- 新增 0xB4 波动测试上报指令定义
- 第 6 章拆分为 6.1 灵敏度测试流程 + 6.2~6.4 波动测试流程
### 2.2 后端 edc_server
| 模块 | 变更 |
|------|------|
| `dg430.py` | 新增 `DG430WaveStatus` dataclass、`parse_b4_wave_status()` 解析器;扩展 `DG430FixtureParams` + `parse_4c_params()` 支持 6 个新字段 |
| `models.py` | DDL 增加 10 个新列test_mode、data_source、remain_count、work_freq、b4_enter_dist 等);新增 `insert_wave_data()` 存库函数ALTER TABLE 自动迁移逻辑 |
| `handlers.py` | parse_loop 增加 0xB4 分支波动数据路由入库0x4C 处理传递新参数字段 |
### 2.3 前端 edc-web
- **工装参数页**:表单增加 6 个波动参数输入框JS 适配新字段
- **测试操作页**:右侧新增「波动测试数据」显示区,实时展示 B4 上报数据
- **测试信息页**:拆为三视图标签页(全部/灵敏度测试 B2/波动测试 B4`data_source` 自动切换列布局
- 波动测试数据支持 Excel 导出
---
## 三、继电器输出状态重构
### 3.1 存储层
- `tb_state_tst` 新增 `relay_code TINYINT` 列,存储原始 hex 值
- `0x00` = 无输出,`0x01` = 存在信号,`0x02` = 脉冲信号,`0x03` = 存在+脉冲
- `relay_out` VARCHAR 列保留,兼容历史数据
- B2/B4 解析后直接以 int 值写入 `relay_code`
### 3.2 前端
- 新增 `decodeRelay()` 函数:整数 → 可读文本
- 测试操作页、测试信息页(表格视图)统一使用解码显示
- **图表视图**:新增第 4 Y 轴继电器输出红色三角阶梯线tooltip 自动解码
---
## 四、测试操作页面增强
| 功能 | 说明 |
|------|------|
| 自动化间隔/超时 | 新增间隔时间(秒)和超时时间(秒)参数;重写为状态机驱动,收到回复后等待间隔再发下一条,超时立即发下一条 |
| 渲染容错 | 每个 render 调用独立 try-catch避免一处报错级联导致全部数据显示失败 |
| 初始数据加载 | 页面打开时自动请求最新测试数据,无需等待启动自动化 |
| 时间格式统一 | 所有区域统一显示 `yyyy-MM-dd HH:mm:ss`,修复 Flask jsonify "GMT" 导致的 UTC+8 时区偏移 |
---
## 五、测试信息页面增强
| 功能 | 说明 |
|------|------|
| 三视图标签页 | 全部 / 灵敏度测试 (B2) / 波动测试 (B4),独立列布局 |
| ECharts 图表 | 表格/图表一键切换B2 显示 8 条线(峰峰值/频率/距离/速度B4 显示 7+1 条线(含继电器),三 Y 轴dataZoom 缩放,保存为 2x PNG |
| 分页条数 | 搜索栏增加「每页20/50/100」下拉框 |
| Admin 删除 | 仅 admin 可见,按设备编码/日期范围/数据来源筛选删除,确认框防误删,`tb_log` 留痕 |
---
## 六、Bug 修复
| 问题 | 根因 | 修复 |
|------|------|------|
| FarStay 字段长度 | 用户纠正NearStay 和 FarStay 均为 2 字节 | 全量回退 1 字节改动,恢复协议+代码 2 字节设计 |
| 数据库缺列 | 旧表缺少 V2.0.3 新增字段 | 服务启动时 ALTER TABLE ADD COLUMN IF NOT EXISTS 自动迁移 |
| 时间显示偏移 8 小时 | Flask jsonify 给本地时间加 "GMT" 后缀JS 误当 UTC 解析 | `fmtTime()` 先 strip "GMT" 再解析 |
| 测试操作三个数据区不显示 | pollProgress 共用一个 try-catch一处报错跳过后续渲染全部完成时 return 跳过最终渲染 | 独立 try-catch渲染代码移到 return 之前 |
| 自动化平均值/明细不显示 | 同上(渲染代码在 return 之后) | 同上 |
---
## 七、Git 提交统计
**主仓库**vd_test_fixture19 commits
**子模块**edc_server4 commits
**合计**23 commits
| 日期 | 主题 |
|------|------|
| 6/1 | 培训手册 V1.0、精简 requirements.txt |
| 6/2 | DG430 V2.0.3 协议文档、后端实现、前端同步、ALTER TABLE 迁移 |
| 6/3 | FarStay 字节修正、波动测试前端适配、三视图重构 |
| 6/4 | 自动化间隔/超时、协议文档补充灵敏度流程 |
| 6/5 | 时间格式化、分页条数、ECharts 图表、图表保存图片、admin 删除、继电器重构、时区修复、渲染容错、图表继电器系列 |
---
## 八、待办事项
- [ ] 重启 edc_server 使数据库迁移和新字段生效
- [ ] edc-web 重启(用户已手动停止)
- [ ] 端到端测试完整波动测试流程(参数设置 → 查询 → 执行 → B4 上报 → 前端展示)
- [ ] 验证图表功能在不同数据量下的表现

View File

@@ -0,0 +1,68 @@
# 周报 — 2026.06.09 ~ 2026.06.12
## 一、设备型号动态管理
**问题**:车检器型号名称(如 DLD110SV在代码中硬编码新增型号后测试操作页显示 `Unknown(3)``0x03`,测试信息页型号列显示 `-`
**修复**
- **后端** `edc_server`B2/B4 数据写入时,废弃硬编码 `{1:"PD132",2:"DLD110"}` 映射,改为查询 `tb_vechicle_base_test` 表获取 `type_num → dev_name`,带内存缓存避免高频 DB 查询。
- **前端** `edc-web`
- `test_op.js` 工装配置概览面板、最新测试结果区域,从 `/api/vehicle-base-test` 动态获取型号映射。
- `test_data.js` 测试信息页型号列,同样改为动态查询。
- 每 5 秒自动刷新型号缓存,工装配置页新增型号后无需手动刷新。
## 二、测试操作页实时数据改进
**问题**
1. 工装本地按键触发的测试数据上报后,网页端无法实时显示,必须依赖网页端"开始"按钮。
2. 数据轮询间隔偏长5 秒),新记录无计数提示。
**实现**
- 新增被动轮询机制,**每 3 秒**自动拉取最新测试数据,覆盖工装本地按键和网页手动指令两种触发方式。
- 自动化测试运行期间,被动轮询自动让位给 `pollProgress`500ms 高频轮询),结束后无缝接回。
- 「当前测试数据」标题旁新增 **B2 新记录条数**显示 `(N 条新记录)`,页面加载/自动化开始时自动复位。
## 三、角色权限体系
| 角色 | 权限 |
|---|---|
| `admin` | 全部功能(含用户管理、删除数据) |
| `manager` | 管理功能(用户管理除外)— 工装配置、数据删除等 |
| `analyst` | 仅测试数据查询/下载 + 修改密码 |
| `operator` | 测试操作 + 测试数据查看(不含工装配置) |
- `analyst` 角色访问受限页面时自动跳转到测试数据页并提示。
- 所有用户可自行修改密码。
## 四、设备日志管理
- 新增设备日志管理页面,记录设备 TCP 连接/断开、异常事件。
- 支持按设备编码、事件类型、时间范围筛选。
- 支持 **CSV 导出**,修复时区偏移 8 小时问题。
- 设备列表页在线/离线状态每 5 秒实时刷新。
- 后端 `device_status_monitor` 增加全表扫描,修正状态不一致问题。
## 五、UI/UX 优化
- **继电器输出格式化**:明确区分"✅有输出"/"❌无输出",前端直接显示 DB 字段。
- **工装配置概览面板**:测试操作页顶部展示当前配置参数(型号、频率范围、线圈、车辆等),可折叠。
- **测试信息页重构**
- 三视图切换(全部数据 / B2 灵敏度 / B4 波动),差异字段自动隐藏。
- 表格支持横向滚动,列宽自适应不换行。
- 故障信息列限制 12em 宽度,超长截断省略 + hover 显示全文。
- 配置页频率/峰峰值前端显示与 DB 原始值双向转换修复。
## 六、Bug 修复
| 问题 | 修复 |
|---|---|
| 浏览器缓存导致工装参数 GET 返回旧数据 | 响应头 `Cache-Control: no-store` |
| `renderLatest` 覆盖测试模式,灵敏度/波动显示回退 | 分离测试模式更新与数据渲染逻辑 |
| 测试操作页工装配置修改后不同步 | 每 5 秒刷新 + 禁用缓存 |
| 设备离线时仍可发送指令 | 在线状态检查,离线/通信不良时弹窗提示并阻止 |
| HeartBeat 大小写不匹配,交互未记录 | 统一大小写匹配 |
| 后端 `device_status_monitor` 状态不一致 | 增加 `dnt_info` 全表扫描修正 |
---
**总计提交**vd_test_fixture 24 次edc_server 8 次。

4
edc-web/.gitignore vendored
View File

@@ -1,4 +1,4 @@
.venv/
venv/
__pycache__/
*.pyc
*.pyo
.venv/

View File

@@ -20,6 +20,7 @@ def create_app() -> Flask:
from app.routes.fixture import bp as fixture_bp
from app.routes.users import bp as users_bp
from app.routes.logs import bp as logs_bp
from app.routes.device_logs import bp as device_logs_bp
app.register_blueprint(devices_bp)
app.register_blueprint(test_op_bp)
@@ -27,6 +28,7 @@ def create_app() -> Flask:
app.register_blueprint(fixture_bp)
app.register_blueprint(users_bp)
app.register_blueprint(logs_bp)
app.register_blueprint(device_logs_bp)
# 初始化默认管理员
_ensure_admin()

View File

@@ -42,11 +42,29 @@ def load_user(user_id):
def init_auth(app):
login_manager.init_app(app)
# analyst 角色:全局路由白名单拦截
ANALYST_ALLOWED = {
"auth.login", "auth.logout", "auth.change_password",
"test_data.test_data_page",
"test_data.api_test_data",
"test_data.api_chart_data",
"test_data.api_export",
"test_data.api_delete", # 自身有 inline 角色检查
}
@app.before_request
def _restrict_analyst():
if current_user.is_authenticated and current_user.role == "analyst":
ep = request.endpoint or ""
if ep not in ANALYST_ALLOWED and not ep.startswith("static"):
flash("当前角色为 analyst仅可访问测试数据")
return redirect(url_for("test_data.test_data_page"))
# ─── 装饰器 ────────────────────────────────────────────────────────
def admin_required(f):
"""要求 admin 角色"""
"""要求 admin 角色(仅 adminmanager 不可通过)"""
from functools import wraps
@wraps(f)
@login_required
@@ -57,6 +75,18 @@ def admin_required(f):
return wrapper
def privileged_required(f):
"""要求 admin 或 manager 角色"""
from functools import wraps
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
if current_user.role not in ("admin", "manager"):
return "权限不足", 403
return f(*args, **kwargs)
return wrapper
# ─── 登录 / 登出 ────────────────────────────────────────────────────
@auth_bp.route("/login", methods=["GET", "POST"])
@@ -86,3 +116,53 @@ def logout():
ip=request.remote_addr or "", result="ok")
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
"""所有用户自行修改密码"""
if request.method == "POST":
old_password = request.form.get("old_password", "")
new_password = request.form.get("new_password", "").strip()
confirm_password = request.form.get("confirm_password", "")
if not old_password or not new_password:
flash("所有字段都不能为空")
return render_template("change_password.html")
if len(new_password) < 6:
flash("新密码至少6位")
return render_template("change_password.html")
if new_password != confirm_password:
flash("两次输入的新密码不一致")
return render_template("change_password.html")
# 验证旧密码
from app.models import get_conn, get_user_by_username
from werkzeug.security import generate_password_hash
user_dict = get_user_by_username(current_user.username)
if not user_dict or not check_password_hash(user_dict["password_hash"], old_password):
flash("原密码错误")
return render_template("change_password.html")
# 更新密码
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE tb_user SET password_hash=%s WHERE id=%s",
(generate_password_hash(new_password), current_user.id),
)
conn.commit()
finally:
conn.close()
insert_log(current_user.id, current_user.username, "update",
target="self", detail="修改个人密码",
result="ok", ip=request.remote_addr or "")
flash("密码修改成功")
return redirect(url_for("devices.index"))
return render_template("change_password.html")

View File

@@ -149,10 +149,16 @@ def get_latest_test_state(dnt_id: int) -> dict | None:
conn.close()
def get_test_data(page: int = 1, per_page: int = 20,
def get_test_data(page: int = 1, per_page: int = 100,
serial: str = "", date_from: str = "",
date_to: str = "") -> tuple[list[dict], int]:
"""分页查询测试数据JOIN dnt_info返回 (records, total)"""
date_to: str = "", test_mode: str = "",
data_source: str = "",
detector_serial: str = "") -> tuple[list[dict], int]:
"""分页查询测试数据JOIN dnt_info最多返回最近 6000 条,返回 (records, total)
test_mode: ''=全部, '0'=灵敏度, '1'=波动
data_source: ''=全部, 'B2', 'B4'
"""
conn = get_conn()
try:
with conn.cursor() as cur:
@@ -161,30 +167,49 @@ def get_test_data(page: int = 1, per_page: int = 20,
if serial:
where.append("d.serial LIKE %s")
params.append(f"%{serial}%")
if detector_serial:
where.append("t.detector_serial LIKE %s")
params.append(f"%{detector_serial}%")
if date_from:
where.append("t.create_time >= %s")
params.append(date_from)
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("t.create_time <= %s")
params.append(date_to + " 23:59:59")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
if test_mode:
where.append("t.test_mode = %s")
params.append(int(test_mode))
if data_source:
where.append("t.data_source = %s")
params.append(data_source)
where_clause = " AND ".join(where) if where else "1=1"
# count
# count — 最多 6000 条
cur.execute(
f"SELECT COUNT(*) as total FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id WHERE {where_clause}",
f"SELECT COUNT(*) as total FROM ("
f"SELECT 1 FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id "
f"WHERE {where_clause} ORDER BY t.id DESC LIMIT 6000"
f") sub",
params,
)
total = cur.fetchone()["total"]
# data
# data — 子查询限 6000 后再分页
offset = (page - 1) * per_page
cur.execute(
f"SELECT t.*, d.serial FROM tb_state_tst t "
f"SELECT * FROM ("
f"SELECT t.*, d.serial, "
f"c.coil_num, c.name as coil_name, "
f"sc.simulate_num, sc.name as car_name "
f"FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id "
f"LEFT JOIN tb_coil_info c ON t.coil_id = c.id "
f"LEFT JOIN tb_simulate_car sc ON t.simulate_car_id = sc.id "
f"WHERE {where_clause} "
f"ORDER BY t.id DESC LIMIT %s OFFSET %s",
f"ORDER BY t.id DESC LIMIT 6000"
f") sub ORDER BY id DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
records = cur.fetchall()
@@ -195,8 +220,14 @@ def get_test_data(page: int = 1, per_page: int = 20,
def get_all_test_data_for_export(serial: str = "", date_from: str = "",
date_to: str = "") -> list[dict]:
"""导出全部数据"""
date_to: str = "", test_mode: str = "",
data_source: str = "",
detector_serial: str = "") -> list[dict]:
"""导出全部数据(最多最近 6000 条)
test_mode: ''=全部, '0'=灵敏度, '1'=波动
data_source: ''=全部, 'B2', 'B4'
"""
conn = get_conn()
try:
with conn.cursor() as cur:
@@ -205,18 +236,32 @@ def get_all_test_data_for_export(serial: str = "", date_from: str = "",
if serial:
where.append("d.serial LIKE %s")
params.append(f"%{serial}%")
if detector_serial:
where.append("t.detector_serial LIKE %s")
params.append(f"%{detector_serial}%")
if date_from:
where.append("t.create_time >= %s")
params.append(date_from)
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("t.create_time <= %s")
params.append(date_to + " 23:59:59")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
if test_mode:
where.append("t.test_mode = %s")
params.append(int(test_mode))
if data_source:
where.append("t.data_source = %s")
params.append(data_source)
where_clause = " AND ".join(where) if where else "1=1"
cur.execute(
f"SELECT t.*, d.serial FROM tb_state_tst t "
f"SELECT t.*, d.serial, "
f"c.coil_num, c.name as coil_name, "
f"sc.simulate_num, sc.name as car_name "
f"FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id "
f"WHERE {where_clause} ORDER BY t.id DESC",
f"LEFT JOIN tb_coil_info c ON t.coil_id = c.id "
f"LEFT JOIN tb_simulate_car sc ON t.simulate_car_id = sc.id "
f"WHERE {where_clause} ORDER BY t.id DESC LIMIT 6000",
params,
)
return cur.fetchall()
@@ -285,6 +330,37 @@ def get_automation_records(dnt_id: int, since: str) -> list[dict]:
conn.close()
def get_latest_wave_data(dnt_id: int) -> dict | None:
"""获取设备最新一条 B4 波动测试数据"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM tb_state_tst WHERE dnt_id=%s AND data_source='B4' "
"ORDER BY id DESC LIMIT 1",
(dnt_id,),
)
return cur.fetchone()
finally:
conn.close()
def get_wave_records(dnt_id: int, since: str) -> list[dict]:
"""获取本轮 B4 波动测试明细"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM tb_state_tst "
"WHERE dnt_id=%s AND data_source='B4' AND create_time >= %s "
"ORDER BY id ASC",
(dnt_id, since),
)
return cur.fetchall()
finally:
conn.close()
# ─── 用户管理 ──────────────────────────────────────────────────────
def get_user_by_username(username: str) -> dict | None:
@@ -393,12 +469,24 @@ def get_logs(page: int = 1, per_page: int = 30,
# ─── tb_fixture_param ──────────────────────────────────────────────
def get_fixture_param(dnt_id: int) -> dict | None:
"""获取设备的工装测试参数"""
"""获取设备的工装测试参数(含线圈和模拟车辆信息)"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM tb_fixture_param WHERE dnt_id=%s", (dnt_id,),
"SELECT fp.*, "
"c.coil_num, c.name as coil_name, c.shape as coil_shape, "
"c.length as coil_length, c.width as coil_width, c.radius as coil_radius, "
"c.turns as coil_turns, c.resistance as coil_resistance, "
"c.material as coil_material, "
"sc.simulate_num, sc.name as car_name, sc.shape as car_shape, "
"sc.length as car_length, sc.width as car_width, sc.radius as car_radius, "
"sc.material as car_material "
"FROM tb_fixture_param fp "
"LEFT JOIN tb_coil_info c ON fp.coil_id = c.id "
"LEFT JOIN tb_simulate_car sc ON fp.simulate_car_id = sc.id "
"WHERE fp.dnt_id=%s",
(dnt_id,),
)
return cur.fetchone()
finally:
@@ -414,9 +502,11 @@ def upsert_fixture_param(dnt_id: int, **kwargs):
"SELECT id FROM tb_fixture_param WHERE dnt_id=%s", (dnt_id,),
)
existing = cur.fetchone()
# 主线参数字段(不含 coil_id/simulate_car_id后面单独处理
fields = [
"Addr", "DevType", "TestMode", "RestDis", "MinusDis",
"SensMin", "SensMax", "FreMin", "FreMax", "PeakMin", "PeakMax",
"FarTol", "NearTol", "StepTol", "BackForth", "NearStay", "FarStay",
]
if existing:
sets = ", ".join(f"`{f}`=%s" for f in fields)
@@ -434,6 +524,17 @@ def upsert_fixture_param(dnt_id: int, **kwargs):
f"VALUES (%s, {placeholders})",
[dnt_id] + values,
)
# 单独处理线圈/模拟车辆关联(可选,不覆盖已有值)
if "coil_id" in kwargs:
cur.execute(
"UPDATE tb_fixture_param SET coil_id=%s WHERE dnt_id=%s",
(kwargs["coil_id"], dnt_id),
)
if "simulate_car_id" in kwargs:
cur.execute(
"UPDATE tb_fixture_param SET simulate_car_id=%s WHERE dnt_id=%s",
(kwargs["simulate_car_id"], dnt_id),
)
conn.commit()
finally:
conn.close()
@@ -528,3 +629,347 @@ def delete_vehicle_base_test(test_id: int):
conn.commit()
finally:
conn.close()
# ─── 线圈参数 CRUD ──────────────────────────────────────────────────
def get_coil_info_list(search: str = "") -> list[dict]:
"""获取线圈参数列表"""
conn = get_conn()
try:
with conn.cursor() as cur:
if search:
cur.execute(
"SELECT * FROM tb_coil_info WHERE coil_num LIKE %s OR name LIKE %s "
"ORDER BY id DESC",
(f"%{search}%", f"%{search}%"),
)
else:
cur.execute("SELECT * FROM tb_coil_info ORDER BY id DESC")
return cur.fetchall()
finally:
conn.close()
def get_coil_info_by_id(coil_id: int) -> dict | None:
"""获取单个线圈参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM tb_coil_info WHERE id=%s", (coil_id,))
return cur.fetchone()
finally:
conn.close()
def create_coil_info(**kwargs) -> int:
"""创建线圈参数,返回新 ID"""
conn = get_conn()
try:
with conn.cursor() as cur:
fields = [
"coil_num", "name", "induct", "shape", "length", "width",
"radius", "turns", "resistance", "material", "remark",
]
col_names = ", ".join(f"`{f}`" for f in fields)
placeholders = ", ".join(["%s"] * len(fields))
values = [kwargs.get(f, "" if f in ("coil_num", "name", "shape", "material", "remark") else 0) for f in fields]
cur.execute(
f"INSERT INTO tb_coil_info ({col_names}) VALUES ({placeholders})",
values,
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_coil_info(coil_id: int, **kwargs):
"""更新线圈参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
fields = [
"coil_num", "name", "induct", "shape", "length", "width",
"radius", "turns", "resistance", "material", "remark",
]
sets = ", ".join(f"`{f}`=%s" for f in fields)
values = [kwargs.get(f, "" if f in ("coil_num", "name", "shape", "material", "remark") else 0) for f in fields] + [coil_id]
cur.execute(
f"UPDATE tb_coil_info SET {sets} WHERE id=%s", values,
)
conn.commit()
finally:
conn.close()
def delete_coil_info(coil_id: int):
"""删除线圈参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM tb_coil_info WHERE id=%s", (coil_id,))
conn.commit()
finally:
conn.close()
# ─── 模拟车辆参数 CRUD ──────────────────────────────────────────────
def get_simulate_car_list(search: str = "") -> list[dict]:
"""获取模拟车辆参数列表"""
conn = get_conn()
try:
with conn.cursor() as cur:
if search:
cur.execute(
"SELECT * FROM tb_simulate_car WHERE simulate_num LIKE %s OR name LIKE %s "
"ORDER BY id DESC",
(f"%{search}%", f"%{search}%"),
)
else:
cur.execute("SELECT * FROM tb_simulate_car ORDER BY id DESC")
return cur.fetchall()
finally:
conn.close()
def get_simulate_car_by_id(car_id: int) -> dict | None:
"""获取单个模拟车辆参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM tb_simulate_car WHERE id=%s", (car_id,))
return cur.fetchone()
finally:
conn.close()
def create_simulate_car(**kwargs) -> int:
"""创建模拟车辆参数,返回新 ID"""
conn = get_conn()
try:
with conn.cursor() as cur:
fields = [
"simulate_num", "name", "shape", "length", "width",
"radius", "material", "remark",
]
col_names = ", ".join(f"`{f}`" for f in fields)
placeholders = ", ".join(["%s"] * len(fields))
values = [kwargs.get(f, "" if f in ("simulate_num", "name", "shape", "material", "remark") else 0) for f in fields]
cur.execute(
f"INSERT INTO tb_simulate_car ({col_names}) VALUES ({placeholders})",
values,
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_simulate_car(car_id: int, **kwargs):
"""更新模拟车辆参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
fields = [
"simulate_num", "name", "shape", "length", "width",
"radius", "material", "remark",
]
sets = ", ".join(f"`{f}`=%s" for f in fields)
values = [kwargs.get(f, "" if f in ("simulate_num", "name", "shape", "material", "remark") else 0) for f in fields] + [car_id]
cur.execute(
f"UPDATE tb_simulate_car SET {sets} WHERE id=%s", values,
)
conn.commit()
finally:
conn.close()
def delete_simulate_car(car_id: int):
"""删除模拟车辆参数"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM tb_simulate_car WHERE id=%s", (car_id,))
conn.commit()
finally:
conn.close()
# ─── 测试数据删除 ──────────────────────────────────────────────
def delete_test_data(serial: str = "", date_from: str = "",
date_to: str = "", data_source: str = "") -> int:
"""删除符合条件的测试数据,返回删除行数
必须至少提供一个条件serial / date范围 / data_source不允许无条件全删。
"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("t.dnt_id IN (SELECT id FROM dnt_info WHERE serial LIKE %s)")
params.append(f"%{serial}%")
if date_from:
where.append("t.create_time >= %s")
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("t.create_time <= %s")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
if data_source:
where.append("t.data_source = %s")
params.append(data_source)
if not where:
return 0 # 拒绝无条件全删
where_clause = " AND ".join(where)
cur.execute(
f"SELECT COUNT(*) as cnt FROM tb_state_tst t WHERE {where_clause}",
params,
)
cnt = cur.fetchone()["cnt"]
cur.execute(
f"DELETE t FROM tb_state_tst t WHERE {where_clause}", params,
)
conn.commit()
return cnt
finally:
conn.close()
# ─── tb_device_log ─────────────────────────────────────────────────
def get_device_logs(page: int = 1, per_page: int = 30,
serial: str = "", event_type: str = "",
date_from: str = "", date_to: str = "") -> tuple[list[dict], int]:
"""分页查询设备事件日志,返回 (records, total)"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("device_serial LIKE %s")
params.append(f"%{serial}%")
if event_type:
where.append("event_type = %s")
params.append(event_type)
if date_from:
where.append("create_time >= %s")
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("create_time <= %s")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
where_clause = " AND ".join(where) if where else "1=1"
cur.execute(
f"SELECT COUNT(*) as total FROM tb_device_log WHERE {where_clause}",
params,
)
total = cur.fetchone()["total"]
offset = (page - 1) * per_page
cur.execute(
f"SELECT * FROM tb_device_log WHERE {where_clause} "
f"ORDER BY id DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
return cur.fetchall(), total
finally:
conn.close()
def export_device_logs(serial: str = "", event_type: str = "",
date_from: str = "", date_to: str = "") -> list[dict]:
"""导出全部设备事件日志(不分页)"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("device_serial LIKE %s")
params.append(f"%{serial}%")
if event_type:
where.append("event_type = %s")
params.append(event_type)
if date_from:
where.append("create_time >= %s")
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("create_time <= %s")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
where_clause = " AND ".join(where) if where else "1=1"
cur.execute(
f"SELECT * FROM tb_device_log WHERE {where_clause} "
f"ORDER BY id DESC",
params,
)
return cur.fetchall()
finally:
conn.close()
def delete_device_logs(serial: str = "", event_type: str = "",
date_from: str = "", date_to: str = "") -> int:
"""删除符合条件的设备日志,返回删除行数。至少需要一个条件。"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("device_serial LIKE %s")
params.append(f"%{serial}%")
if event_type:
where.append("event_type = %s")
params.append(event_type)
if date_from:
where.append("create_time >= %s")
params.append(date_from if len(date_from) > 10 else date_from)
if date_to:
where.append("create_time <= %s")
params.append(date_to if len(date_to) > 10 else date_to + " 23:59:59")
if not where:
return 0
where_clause = " AND ".join(where)
cur.execute(
f"SELECT COUNT(*) as cnt FROM tb_device_log WHERE {where_clause}",
params,
)
cnt = cur.fetchone()["cnt"]
cur.execute(
f"DELETE FROM tb_device_log WHERE {where_clause}", params,
)
conn.commit()
return cnt
finally:
conn.close()
# ─── tb_pending_detector ───────────────────────────────────────────
def set_pending_detector_serial(dnt_id: int, detector_serial: str):
"""设置待插入的车检器序列号UPSERT"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO tb_pending_detector (dnt_id, detector_serial)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE detector_serial = VALUES(detector_serial)""",
(dnt_id, detector_serial),
)
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,95 @@
"""设备事件日志 API"""
import csv
import io
from flask import Blueprint, jsonify, render_template, request, Response
from flask_login import login_required, current_user
from app.models import get_device_logs, export_device_logs, delete_device_logs, insert_log
bp = Blueprint("device_logs", __name__)
@bp.route("/device-logs")
@login_required
def index():
"""设备日志页面"""
return render_template("device_logs.html")
@bp.route("/api/device-logs")
@login_required
def api_device_logs():
"""查询设备事件日志"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 30, type=int)
serial = request.args.get("serial", "", type=str)
event_type = request.args.get("event_type", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
records, total = get_device_logs(
page=page, per_page=per_page,
serial=serial, event_type=event_type,
date_from=date_from, date_to=date_to,
)
pages = max(1, (total + per_page - 1) // per_page)
return jsonify({"records": records, "total": total, "pages": pages})
@bp.route("/api/device-logs/export")
@login_required
def api_export():
"""导出设备事件日志为 CSV"""
serial = request.args.get("serial", "", type=str)
event_type = request.args.get("event_type", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
records = export_device_logs(
serial=serial, event_type=event_type,
date_from=date_from, date_to=date_to,
)
output = io.StringIO()
writer = csv.writer(output)
if records:
headers = list(records[0].keys())
writer.writerow(headers)
for r in records:
writer.writerow(r.values())
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=device_logs.csv"},
)
@bp.route("/api/device-logs/delete", methods=["POST"])
@login_required
def api_device_logs_delete():
"""删除设备日志admin 权限)"""
if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限"}), 403
data = request.get_json()
serial = data.get("serial", "")
event_type = data.get("event_type", "")
date_from = data.get("date_from", "")
date_to = data.get("date_to", "")
deleted = delete_device_logs(
serial=serial, event_type=event_type,
date_from=date_from, date_to=date_to,
)
insert_log(
current_user.id, current_user.username, "delete",
target="device_log",
detail=f"删除 {deleted} 条设备日志 serial={serial} type={event_type}",
result="ok",
ip=request.remote_addr or "",
)
return jsonify({"ok": True, "deleted": deleted})

View File

@@ -2,7 +2,7 @@
from flask import Blueprint, jsonify, render_template, request
from flask_login import login_required
from app.models import get_all_devices, update_device_name
from app.models import get_all_devices, update_device_name, get_device_by_id
bp = Blueprint("devices", __name__)
@@ -21,6 +21,22 @@ def api_devices():
return jsonify(devices)
@bp.route("/api/devices/<int:device_id>/status")
def api_device_status(device_id):
"""获取单个设备的在线状态"""
device = get_device_by_id(device_id)
if not device:
return jsonify({"ok": False, "error": "设备不存在"}), 404
state_names = {0: "离线", 1: "在线", 2: "通信不良"}
return jsonify({
"ok": True,
"device_id": device_id,
"state": device["state"],
"state_name": state_names.get(device["state"], "未知"),
"serial": device["serial"],
})
@bp.route("/api/devices/<int:device_id>/name", methods=["PUT"])
def api_update_name(device_id):
"""修改设备名称"""

View File

@@ -14,6 +14,16 @@ from app.models import (
delete_vehicle_base_test,
get_serialnet_by_id,
insert_log,
get_coil_info_list,
get_coil_info_by_id,
create_coil_info,
update_coil_info,
delete_coil_info,
get_simulate_car_list,
get_simulate_car_by_id,
create_simulate_car,
update_simulate_car,
delete_simulate_car,
)
bp = Blueprint("fixture", __name__)
@@ -59,13 +69,18 @@ def build_4b_packet(addr: int, dev_type: int, test_mode: int,
reset_dis: int, minus_dis: int,
sens_min: int, sens_max: int,
fre_min: int, fre_max: int,
peak_min: int, peak_max: int) -> str:
"""构造 0x4B 配置指令 hex 字符串
peak_min: int, peak_max: int,
far_tol: int = 0, near_tol: int = 0,
step_tol: int = 0, back_forth: int = 0,
near_stay: int = 0, far_stay: int = 0) -> str:
"""构造 0x4B 配置指令 hex 字符串 (V2.0.3 扩展)
格式: 7F | 81 | 12 | 4B | Addr(1) | DevType(1) | TestMode(1) |
格式: 7F | 81 | 17 | 4B | Addr(1) | DevType(1) | TestMode(1) |
ResetDis(1) | MinusDis(1) |
SensMin(2 LE) | SensMax(2 LE) | FreMin(2 LE) | FreMax(2 LE) |
PeakMin(2 LE) | PeakMax(2 LE) | XOR | SUM
PeakMin(2 LE) | PeakMax(2 LE) |
FarTol(1) | NearTol(1) | StepTol(1) | BackForth(1) |
NearStay(2 LE) | FarStay(2 LE) | XOR | SUM
"""
payload = bytes([
0x4B, # CMD
@@ -78,6 +93,14 @@ def build_4b_packet(addr: int, dev_type: int, test_mode: int,
payload += (_le16(sens_min) + _le16(sens_max) +
_le16(fre_min) + _le16(fre_max) +
_le16(peak_min) + _le16(peak_max))
# V2.0.3 波动参数
payload += bytes([
far_tol & 0xFF,
near_tol & 0xFF,
step_tol & 0xFF,
back_forth & 0xFF,
])
payload += _le16(near_stay) + _le16(far_stay)
pkt = bytes([0x7F, 0x81, len(payload)]) + payload
xor, total = _xor_sum(pkt[1:])
@@ -91,6 +114,8 @@ def build_4b_packet(addr: int, dev_type: int, test_mode: int,
@login_required
def fixture_page(dnt_id):
"""工装配置页面"""
if current_user.role not in ("admin", "manager"):
return "无权限:仅管理员可访问工装配置", 403
device = get_device_by_id(dnt_id)
if not device:
return "设备不存在", 404
@@ -110,6 +135,8 @@ def vehicle_base_test_page():
@login_required
def api_fixture_command():
"""发送工装配置指令 (0x4A/0x4B/0x4C/0x4D/0x4E)"""
if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限:仅管理员可执行工装指令"}), 403
data = request.get_json()
dnt_id = data.get("dnt_id")
cmd = data.get("cmd", "").upper()
@@ -118,7 +145,7 @@ def api_fixture_command():
target = f"{device['serial']}" if device else f"dnt_id={dnt_id}"
if cmd == "4B":
# 动态构造 0x4B 指令
# 动态构造 0x4B 指令 (V2.0.3)
params = data.get("params", {})
send_pkg = build_4b_packet(
addr=params.get("addr", 1),
@@ -132,6 +159,12 @@ def api_fixture_command():
fre_max=params.get("fre_max", 0),
peak_min=params.get("peak_min", 0),
peak_max=params.get("peak_max", 0),
far_tol=params.get("far_tol", 0),
near_tol=params.get("near_tol", 0),
step_tol=params.get("step_tol", 0),
back_forth=params.get("back_forth", 0),
near_stay=params.get("near_stay", 0),
far_stay=params.get("far_stay", 0),
)
elif cmd in FIXTURE_COMMANDS:
send_pkg = FIXTURE_COMMANDS[cmd]
@@ -184,17 +217,30 @@ def api_get_serialnet(record_id):
def api_get_fixture_param(dnt_id):
"""获取工装测试参数"""
param = get_fixture_param(dnt_id)
return jsonify(param or {})
resp = jsonify(param or {})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
@bp.route("/api/fixture/param/<int:dnt_id>", methods=["POST"])
@login_required
def api_save_fixture_param(dnt_id):
"""保存工装测试参数(仅数据库,不下发设备)"""
if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限:仅管理员可修改工装参数"}), 403
data = request.get_json()
if not data:
return jsonify({"ok": False, "error": "数据为空"}), 400
upsert_fixture_param(dnt_id, **data)
device = get_device_by_id(dnt_id)
target = f"{device['serial']}" if device else f"dnt_id={dnt_id}"
insert_log(
current_user.id, current_user.username, "update",
target=target,
detail="保存工装配置参数",
result="ok",
ip=request.remote_addr or "",
)
return jsonify({"ok": True})
@@ -256,3 +302,181 @@ def api_delete_vehicle_base_test(test_id):
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ─── 线圈参数页面 ──────────────────────────────────────────────────
@bp.route("/coil-info")
@login_required
def coil_info_page():
"""线圈参数管理页面"""
return render_template("coil_info.html")
@bp.route("/api/coil-info")
@login_required
def api_list_coil_info():
"""列出线圈参数"""
search = request.args.get("search", "")
items = get_coil_info_list(search)
return jsonify(items)
@bp.route("/api/coil-info/<int:coil_id>")
@login_required
def api_get_coil_info(coil_id):
"""获取单个线圈参数"""
item = get_coil_info_by_id(coil_id)
if not item:
return jsonify({"error": "不存在"}), 404
return jsonify(item)
@bp.route("/api/coil-info", methods=["POST"])
@login_required
def api_create_coil_info():
"""创建线圈参数"""
data = request.get_json()
if not data:
return jsonify({"ok": False, "error": "数据为空"}), 400
try:
coil_id = create_coil_info(**data)
insert_log(
current_user.id, current_user.username, "create",
target="coil_info",
detail=f"创建线圈: {data.get('coil_num','')} {data.get('name','')}",
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True, "id": coil_id})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@bp.route("/api/coil-info/<int:coil_id>", methods=["PUT"])
@login_required
def api_update_coil_info(coil_id):
"""更新线圈参数"""
data = request.get_json()
if not data:
return jsonify({"ok": False, "error": "数据为空"}), 400
try:
update_coil_info(coil_id, **data)
insert_log(
current_user.id, current_user.username, "update",
target="coil_info",
detail=f"更新线圈 id={coil_id}: {data.get('coil_num','')} {data.get('name','')}",
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@bp.route("/api/coil-info/<int:coil_id>", methods=["DELETE"])
@login_required
def api_delete_coil_info(coil_id):
"""删除线圈参数"""
try:
item = get_coil_info_by_id(coil_id)
detail = f"删除线圈 id={coil_id}"
if item:
detail += f": {item.get('coil_num','')} {item.get('name','')}"
delete_coil_info(coil_id)
insert_log(
current_user.id, current_user.username, "delete",
target="coil_info",
detail=detail,
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ─── 模拟车辆参数页面 ──────────────────────────────────────────────
@bp.route("/simulate-car")
@login_required
def simulate_car_page():
"""模拟车辆参数管理页面"""
return render_template("simulate_car.html")
@bp.route("/api/simulate-car")
@login_required
def api_list_simulate_car():
"""列出模拟车辆参数"""
search = request.args.get("search", "")
items = get_simulate_car_list(search)
return jsonify(items)
@bp.route("/api/simulate-car/<int:car_id>")
@login_required
def api_get_simulate_car(car_id):
"""获取单个模拟车辆参数"""
item = get_simulate_car_by_id(car_id)
if not item:
return jsonify({"error": "不存在"}), 404
return jsonify(item)
@bp.route("/api/simulate-car", methods=["POST"])
@login_required
def api_create_simulate_car():
"""创建模拟车辆参数"""
data = request.get_json()
if not data:
return jsonify({"ok": False, "error": "数据为空"}), 400
try:
car_id = create_simulate_car(**data)
insert_log(
current_user.id, current_user.username, "create",
target="simulate_car",
detail=f"创建模拟车辆: {data.get('simulate_num','')} {data.get('name','')}",
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True, "id": car_id})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@bp.route("/api/simulate-car/<int:car_id>", methods=["PUT"])
@login_required
def api_update_simulate_car(car_id):
"""更新模拟车辆参数"""
data = request.get_json()
if not data:
return jsonify({"ok": False, "error": "数据为空"}), 400
try:
update_simulate_car(car_id, **data)
insert_log(
current_user.id, current_user.username, "update",
target="simulate_car",
detail=f"更新模拟车辆 id={car_id}: {data.get('simulate_num','')} {data.get('name','')}",
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@bp.route("/api/simulate-car/<int:car_id>", methods=["DELETE"])
@login_required
def api_delete_simulate_car(car_id):
"""删除模拟车辆参数"""
try:
item = get_simulate_car_by_id(car_id)
detail = f"删除模拟车辆 id={car_id}"
if item:
detail += f": {item.get('simulate_num','')} {item.get('name','')}"
delete_simulate_car(car_id)
insert_log(
current_user.id, current_user.username, "delete",
target="simulate_car",
detail=detail,
result="ok", ip=request.remote_addr or "",
)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500

View File

@@ -2,20 +2,20 @@
from flask import Blueprint, jsonify, render_template, request
from flask_login import login_required
from app.auth import admin_required
from app.auth import privileged_required
from app.models import get_logs
bp = Blueprint("logs", __name__, url_prefix="/logs")
@bp.route("/")
@admin_required
@privileged_required
def logs_page():
return render_template("logs.html")
@bp.route("/api/logs")
@admin_required
@privileged_required
def api_logs():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 30, type=int)

View File

@@ -3,28 +3,34 @@
import csv
import io
from flask import Blueprint, jsonify, render_template, request, Response
from flask_login import login_required
from app.models import get_test_data, get_all_test_data_for_export
from flask_login import login_required, current_user
from app.models import get_test_data, get_all_test_data_for_export, delete_test_data, insert_log
bp = Blueprint("test_data", __name__)
@bp.route("/test-data")
@login_required
def test_data_page():
"""测试信息页"""
return render_template("test_data.html")
@bp.route("/api/test-data")
@login_required
def api_test_data():
"""分页查询测试数据"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
per_page = request.args.get("per_page", 100, type=int)
serial = request.args.get("serial", "", type=str)
detector_serial = request.args.get("detector_serial", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
test_mode = request.args.get("test_mode", "", type=str)
data_source = request.args.get("data_source", "", type=str)
records, total = get_test_data(page, per_page, serial, date_from, date_to)
records, total = get_test_data(page, per_page, serial, date_from, date_to,
test_mode, data_source, detector_serial)
return jsonify({
"records": records,
"total": total,
@@ -34,14 +40,36 @@ def api_test_data():
})
@bp.route("/api/test-data/export")
def api_export():
"""导出测试数据为 CSV"""
@bp.route("/api/test-data/chart")
@login_required
def api_chart_data():
"""返回图表所需全部数据(不分页,最多 6000 条)"""
serial = request.args.get("serial", "", type=str)
detector_serial = request.args.get("detector_serial", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
test_mode = request.args.get("test_mode", "", type=str)
data_source = request.args.get("data_source", "", type=str)
records = get_all_test_data_for_export(serial, date_from, date_to)
records = get_all_test_data_for_export(serial, date_from, date_to,
test_mode, data_source,
detector_serial)
return jsonify({"records": records, "total": len(records)})
@bp.route("/api/test-data/export")
@login_required
def api_export():
"""导出测试数据为 CSV最多 6000 条)"""
serial = request.args.get("serial", "", type=str)
detector_serial = request.args.get("detector_serial", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
test_mode = request.args.get("test_mode", "", type=str)
data_source = request.args.get("data_source", "", type=str)
records = get_all_test_data_for_export(serial, date_from, date_to,
test_mode, data_source,
detector_serial)
output = io.StringIO()
writer = csv.writer(output)
@@ -59,3 +87,39 @@ def api_export():
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=test_data.csv"},
)
@bp.route("/api/test-data/delete", methods=["POST"])
@login_required
def api_delete():
"""删除测试数据(仅 admin"""
if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限"}), 403
data = request.get_json() or {}
serial = data.get("serial", "")
date_from = data.get("date_from", "")
date_to = data.get("date_to", "")
data_source = data.get("data_source", "")
cnt = delete_test_data(serial, date_from, date_to, data_source)
detail_parts = [f"删除 {cnt} 条测试数据"]
if serial:
detail_parts.append(f"设备={serial}")
if date_from:
detail_parts.append(f"{date_from}")
if date_to:
detail_parts.append(f"{date_to}")
if data_source:
detail_parts.append(f"来源={data_source}")
insert_log(
current_user.id, current_user.username, "delete",
target="test_data",
detail=", ".join(detail_parts),
result="ok",
ip=request.remote_addr or "",
)
return jsonify({"ok": True, "deleted": cnt})

View File

@@ -9,8 +9,11 @@ from app.models import (
get_latest_test_state,
get_automation_averages,
get_automation_records,
get_latest_wave_data,
get_wave_records,
clear_serialnet_records,
insert_log,
set_pending_detector_serial,
)
bp = Blueprint("test_op", __name__)
@@ -87,10 +90,15 @@ def api_automation_start():
data = request.get_json()
dnt_id = data.get("dnt_id")
count = int(data.get("count", 1))
detector_serial = (data.get("detector_serial") or "").strip()
device = get_device_by_id(dnt_id)
target = f"{device['serial']}" if device else f"dnt_id={dnt_id}"
# 存储待插入的车检器序列号
if detector_serial:
set_pending_detector_serial(dnt_id, detector_serial)
# 清除旧记录,然后插入第一条 0xB0
clear_serialnet_records(dnt_id)
record_id = insert_serialnet(dnt_id, COMMANDS["B0"])
@@ -118,9 +126,13 @@ def api_automation_progress(dnt_id):
latest = get_latest_test_state(dnt_id)
averages = get_automation_averages(dnt_id, since if since else None)
records = get_automation_records(dnt_id, since) if since else []
latest_wave = get_latest_wave_data(dnt_id)
wave_records = get_wave_records(dnt_id, since) if since else []
return jsonify({
"stats": stats,
"latest": latest,
"averages": averages,
"records": records,
"latest_wave": latest_wave,
"wave_records": wave_records,
})

View File

@@ -23,6 +23,7 @@ tr:hover { background: #f8f9fa; }
/* === Online Status === */
.status-online { color: #27ae60; font-weight: 600; }
.status-offline { color: #bdc3c7; }
.status-poor { color: #f39c12; font-weight: 600; }
/* === Editable Name === */
.editable-name { cursor: pointer; border-bottom: 1px dashed transparent; }

View File

@@ -0,0 +1,173 @@
// 线圈参数管理
let editId = null; // null=新增, number=编辑
// ─── Toast ───────────────────────────────────
function toast(msg, isError = false) {
const el = document.getElementById("toast");
el.textContent = msg;
el.className = "msg-toast " + (isError ? "error" : "") + " show";
clearTimeout(el._timeout);
el._timeout = setTimeout(() => { el.className = "msg-toast"; }, 3000);
}
// ─── 列表加载 ────────────────────────────────
async function loadList() {
const search = document.getElementById("search-input").value;
try {
const resp = await fetch(`/api/coil-info?search=${encodeURIComponent(search)}`);
const data = await resp.json();
renderTable(data);
} catch (e) {
console.error("加载失败:", e);
}
}
function sizeLabel(item) {
if (item.shape === '圆形') return `半径${item.radius || 0}cm`;
if (item.shape === '矩形') return `${item.length || 0}×${item.width || 0}cm`;
return '-';
}
function renderTable(data) {
const tbody = document.querySelector("#coil-table tbody");
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="10" style="color:#999;text-align:center;">暂无数据,点右上角「新增」添加</td></tr>';
return;
}
tbody.innerHTML = data.map(t => `
<tr>
<td>${esc(t.coil_num)}</td>
<td>${esc(t.name)}</td>
<td>${t.induct || '-'}</td>
<td>${t.shape || '-'}</td>
<td>${sizeLabel(t)}</td>
<td>${t.turns || '-'}</td>
<td>${t.resistance || '-'}</td>
<td>${esc(t.material || '-')}</td>
<td>${esc(t.remark || '-')}</td>
<td>
<button class="btn-edit" onclick="openModal(${t.id})">编辑</button>
<button class="btn-del" onclick="deleteRecord(${t.id}, '${esc(t.coil_num || t.name)}')">删除</button>
</td>
</tr>
`).join("");
}
// ─── 弹窗 ────────────────────────────────────
function openModal(id = null) {
editId = id;
document.getElementById("modal-title").textContent = id ? "编辑线圈参数" : "新增线圈参数";
document.getElementById("edit-modal").style.display = "flex";
if (id) {
fetch(`/api/coil-info/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById("edit-coil-num").value = data.coil_num || "";
document.getElementById("edit-name").value = data.name || "";
document.getElementById("edit-induct").value = data.induct || 0;
document.getElementById("edit-shape").value = data.shape || "";
document.getElementById("edit-length").value = data.length || 0;
document.getElementById("edit-width").value = data.width || 0;
document.getElementById("edit-radius").value = data.radius || 0;
document.getElementById("edit-turns").value = data.turns || 0;
document.getElementById("edit-resistance").value = data.resistance || 0;
document.getElementById("edit-material").value = data.material || "";
document.getElementById("edit-remark").value = data.remark || "";
});
} else {
document.getElementById("edit-coil-num").value = "";
document.getElementById("edit-name").value = "";
document.getElementById("edit-induct").value = "0";
document.getElementById("edit-shape").value = "";
document.getElementById("edit-length").value = "0";
document.getElementById("edit-width").value = "0";
document.getElementById("edit-radius").value = "0";
document.getElementById("edit-turns").value = "0";
document.getElementById("edit-resistance").value = "0";
document.getElementById("edit-material").value = "";
document.getElementById("edit-remark").value = "";
}
}
function closeModal() {
document.getElementById("edit-modal").style.display = "none";
editId = null;
}
// ─── 保存 ────────────────────────────────────
async function saveRecord() {
const data = {
coil_num: document.getElementById("edit-coil-num").value.trim(),
name: document.getElementById("edit-name").value.trim(),
induct: parseFloat(document.getElementById("edit-induct").value) || 0,
shape: document.getElementById("edit-shape").value,
length: parseFloat(document.getElementById("edit-length").value) || 0,
width: parseFloat(document.getElementById("edit-width").value) || 0,
radius: parseFloat(document.getElementById("edit-radius").value) || 0,
turns: parseInt(document.getElementById("edit-turns").value) || 0,
resistance: parseFloat(document.getElementById("edit-resistance").value) || 0,
material: document.getElementById("edit-material").value.trim(),
remark: document.getElementById("edit-remark").value.trim(),
};
if (!data.coil_num && !data.name) {
toast("请输入线圈编号或名称", true);
return;
}
try {
let resp;
if (editId) {
resp = await fetch(`/api/coil-info/${editId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} else {
resp = await fetch("/api/coil-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
const result = await resp.json();
if (result.ok || resp.ok) {
toast(editId ? "更新成功" : "新增成功");
closeModal();
loadList();
} else {
toast("保存失败: " + (result.error || "未知错误"), true);
}
} catch (e) {
toast("保存失败: " + e.message, true);
}
}
// ─── 删除 ────────────────────────────────────
async function deleteRecord(id, label) {
if (!confirm(`确定要删除「${label}」吗?`)) return;
try {
const resp = await fetch(`/api/coil-info/${id}`, { method: "DELETE" });
const result = await resp.json();
if (result.ok) {
toast("删除成功");
loadList();
} else {
toast("删除失败: " + (result.error || "未知错误"), true);
}
} catch (e) {
toast("删除失败: " + e.message, true);
}
}
function esc(s) { return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
loadList();

View File

@@ -8,24 +8,55 @@ async function loadDevices() {
function renderTable(devices) {
const tbody = document.querySelector("#device-table tbody");
tbody.innerHTML = devices.map(d => `
tbody.innerHTML = devices.map(d => {
const stateLabel = getStateLabel(d.state);
const stateClass = getStateClass(d.state);
return `
<tr>
<td>${d.serial}</td>
<td class="editable-name" onclick="editName(${d.id}, '${esc(d.name)}', this)">
${d.name || '(点击编辑)'}
</td>
<td>${d.ip || '-'}</td>
<td class="${d.state === 1 ? 'status-online' : 'status-offline'}">
${d.state === 1 ? '在线' : '离线'}
<td class="${stateClass} status-cell" data-device-id="${d.id}">
${stateLabel}
</td>
<td>${d.version || '-'}</td>
<td>${d.last_login || '-'}</td>
<td>
<button class="btn-test" onclick="location.href='/test/${d.id}'">测试</button>
<button class="btn-config" onclick="location.href='/fixture/${d.id}'">配置</button>
${USER_ROLE === 'admin' || USER_ROLE === 'manager' ? `<button class="btn-config" onclick="location.href='/fixture/${d.id}'">配置</button>` : ''}
</td>
</tr>
`).join("");
</tr>`;
}).join("");
}
function getStateLabel(state) {
return {0: '离线', 1: '在线', 2: '通信不良'}[state] || '未知';
}
function getStateClass(state) {
return {0: 'status-offline', 1: 'status-online', 2: 'status-poor'}[state] || '';
}
// 异步刷新所有设备的在线状态
async function refreshDeviceStatuses() {
const cells = document.querySelectorAll(".status-cell");
for (const cell of cells) {
const deviceId = cell.dataset.deviceId;
if (!deviceId) continue;
try {
const resp = await fetch(`/api/devices/${deviceId}/status`);
const data = await resp.json();
if (data.ok) {
cell.textContent = data.state_name;
cell.className = getStateClass(data.state) + " status-cell";
cell.dataset.deviceId = deviceId;
}
} catch (e) {
// 网络错误静默跳过
}
}
}
function esc(s) { return s.replace(/'/g, "\\'").replace(/"/g, "&quot;"); }
@@ -52,3 +83,5 @@ async function editName(id, currentName, td) {
}
loadDevices();
// 每 5 秒异步刷新设备在线状态
setInterval(refreshDeviceStatuses, 5000);

View File

@@ -1,5 +1,16 @@
// 工装配置页
// ─── 频率/峰峰值转换常量 ─────────────────────
// 协议: 工作频率 f(Hz) = 10 * X, X 为 DB/设备中存储和传输的原始值
// 协议: 峰峰值 V = ((X * 3.3) / 4095) * 4, X 为 DB/设备中存储和传输的原始值(正整数)
const FREQ_SCALE = 10;
const PEAK_SCALE = 4095 / (4 * 3.3); // ≈ 310.227
function rawFreqToHz(x) { return x * FREQ_SCALE; }
function hzToRawFreq(hz) { return Math.round(hz / FREQ_SCALE); }
function rawPeakToV(x) { return parseFloat(((x * 3.3) / 4095 * 4).toFixed(2)); }
function vToRawPeak(v) { return Math.round(v * PEAK_SCALE); }
let baseTests = []; // 所有车检器基准参数
let selectedBaseTest = null;
let pollTimer4C = null; // 0x4C 参数查询轮询
@@ -9,6 +20,8 @@ let pollTimers = {}; // record_id → timer (指令响应轮询)
async function init() {
await loadBaseTests();
await loadCoilList();
await loadCarList();
await loadFixtureParam();
}
@@ -80,8 +93,8 @@ function renderBaseTestTable() {
<td>${t.type_num}</td>
<td>${esc(t.dev_name)}</td>
<td>${t.SensMin}~${t.SensMax}</td>
<td>${t.FreMin}~${t.FreMax}</td>
<td>${t.PeakMin}~${t.PeakMax}</td>
<td>${rawFreqToHz(t.FreMin)}~${rawFreqToHz(t.FreMax)}</td>
<td>${rawPeakToV(t.PeakMin)}~${rawPeakToV(t.PeakMax)}</td>
</tr>
`).join("");
}
@@ -106,10 +119,10 @@ function fillFromBaseTest(t) {
document.getElementById("param-dev-type").value = t.type_num;
document.getElementById("param-sens-min").value = t.SensMin;
document.getElementById("param-sens-max").value = t.SensMax;
document.getElementById("param-fre-min").value = t.FreMin;
document.getElementById("param-fre-max").value = t.FreMax;
document.getElementById("param-peak-min").value = t.PeakMin;
document.getElementById("param-peak-max").value = t.PeakMax;
document.getElementById("param-fre-min").value = rawFreqToHz(t.FreMin);
document.getElementById("param-fre-max").value = rawFreqToHz(t.FreMax);
document.getElementById("param-peak-min").value = rawPeakToV(t.PeakMin);
document.getElementById("param-peak-max").value = rawPeakToV(t.PeakMax);
}
function onDevTypeChange() {
@@ -119,11 +132,71 @@ function onDevTypeChange() {
else { selectedBaseTest = null; renderBaseTestTable(); }
}
// ─── 线圈列表 ────────────────────────────────
let coilList = [];
async function loadCoilList() {
try {
const resp = await fetch("/api/coil-info");
coilList = await resp.json();
populateCoilSelect();
} catch (e) { console.error("加载线圈列表失败:", e); }
}
function populateCoilSelect() {
const sel = document.getElementById("coil-select");
sel.innerHTML = '<option value="">-- 选择线圈 --</option>' +
coilList.map(c => `<option value="${c.id}">${esc(c.coil_num || c.name || `#${c.id}`)}</option>`).join("");
}
function onCoilChange() {
const id = parseInt(document.getElementById("coil-select").value);
const coil = coilList.find(c => c.id === id);
const detail = document.getElementById("coil-detail");
if (coil) {
const sizeText = coil.shape === '圆形' ? `半径${coil.radius}cm` : `${coil.length}×${coil.width}cm`;
detail.innerHTML = `${coil.shape || '-'} / ${sizeText} / ${coil.turns || 0}圈 / ${coil.resistance || 0}Ω / ${coil.material || ''}`;
} else {
detail.innerHTML = '';
}
}
// ─── 模拟车辆列表 ────────────────────────────
let carList = [];
async function loadCarList() {
try {
const resp = await fetch("/api/simulate-car");
carList = await resp.json();
populateCarSelect();
} catch (e) { console.error("加载模拟车辆列表失败:", e); }
}
function populateCarSelect() {
const sel = document.getElementById("car-select");
sel.innerHTML = '<option value="">-- 选择模拟车辆 --</option>' +
carList.map(c => `<option value="${c.id}">${esc(c.simulate_num || c.name || `#${c.id}`)}</option>`).join("");
}
function onCarChange() {
const id = parseInt(document.getElementById("car-select").value);
const car = carList.find(c => c.id === id);
const detail = document.getElementById("car-detail");
if (car) {
const sizeText = car.shape === '圆形' ? `半径${car.radius}cm` : `${car.length}×${car.width}cm`;
detail.innerHTML = `${car.shape || '-'} / ${sizeText} / ${car.material || ''}`;
} else {
detail.innerHTML = '';
}
}
// ─── 从 DB 加载/刷新/保存 ────────────────────
async function loadFixtureParam() {
try {
const resp = await fetch(`/api/fixture/param/${DNT_ID}`);
const resp = await fetch(`/api/fixture/param/${DNT_ID}?_=${Date.now()}`);
const param = await resp.json();
if (param && param.dnt_id) {
fillFormFromParam(param);
@@ -135,21 +208,38 @@ async function loadFixtureParam() {
function fillFormFromParam(param) {
document.getElementById("param-addr").value = param.Addr || 1;
document.getElementById("param-test-mode").value = param.TestMode || 0;
document.getElementById("param-reset-dis").value = param.RestDis || 0;
document.getElementById("param-minus-dis").value = param.MinusDis || 0;
document.getElementById("param-reset-dis").value = (param.RestDis || 0) * 10;
document.getElementById("param-minus-dis").value = (param.MinusDis || 0) * 10;
document.getElementById("param-dev-type").value = param.DevType || 0;
document.getElementById("param-sens-min").value = param.SensMin || 0;
document.getElementById("param-sens-max").value = param.SensMax || 0;
document.getElementById("param-fre-min").value = param.FreMin || 0;
document.getElementById("param-fre-max").value = param.FreMax || 0;
document.getElementById("param-peak-min").value = param.PeakMin || 0;
document.getElementById("param-peak-max").value = param.PeakMax || 0;
document.getElementById("param-fre-min").value = rawFreqToHz(param.FreMin || 0);
document.getElementById("param-fre-max").value = rawFreqToHz(param.FreMax || 0);
document.getElementById("param-peak-min").value = rawPeakToV(param.PeakMin || 0);
document.getElementById("param-peak-max").value = rawPeakToV(param.PeakMax || 0);
document.getElementById("param-far-tol").value = param.FarTol || 0;
document.getElementById("param-near-tol").value = param.NearTol || 0;
document.getElementById("param-step-tol").value = param.StepTol || 0;
document.getElementById("param-back-forth").value = param.BackForth || 0;
document.getElementById("param-near-stay").value = param.NearStay || 0;
document.getElementById("param-far-stay").value = param.FarStay || 0;
const matched = baseTests.find(t => t.type_num === param.DevType);
if (matched) { selectedBaseTest = matched; renderBaseTestTable(); }
// 设置线圈和模拟车辆选中
if (param.coil_id) {
document.getElementById("coil-select").value = param.coil_id;
onCoilChange();
}
if (param.simulate_car_id) {
document.getElementById("car-select").value = param.simulate_car_id;
onCarChange();
}
// 更新当前关联标签
updateCurrentLabels(param);
}
async function refreshParams() {
const param = await (await fetch(`/api/fixture/param/${DNT_ID}`)).json();
const param = await (await fetch(`/api/fixture/param/${DNT_ID}?_=${Date.now()}`)).json();
if (param && param.dnt_id) {
fillFormFromParam(param);
commLog('info', null, '已刷新:从数据库加载工装参数');
@@ -162,41 +252,74 @@ async function refreshParams() {
async function saveToDb() {
const data = getFormParams();
const coilId = parseInt(document.getElementById("coil-select").value) || null;
const carId = parseInt(document.getElementById("car-select").value) || null;
const body = {
Addr: data.addr, DevType: data.dev_type, TestMode: data.test_mode,
RestDis: data.reset_dis, MinusDis: data.minus_dis,
SensMin: data.sens_min, SensMax: data.sens_max,
FreMin: data.fre_min, FreMax: data.fre_max,
PeakMin: data.peak_min, PeakMax: data.peak_max,
FarTol: data.far_tol, NearTol: data.near_tol,
StepTol: data.step_tol, BackForth: data.back_forth,
NearStay: data.near_stay, FarStay: data.far_stay,
};
if (coilId) body.coil_id = coilId;
if (carId) body.simulate_car_id = carId;
try {
const resp = await fetch(`/api/fixture/param/${DNT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Addr: data.addr, DevType: data.dev_type, TestMode: data.test_mode,
RestDis: data.reset_dis, MinusDis: data.minus_dis,
SensMin: data.sens_min, SensMax: data.sens_max,
FreMin: data.fre_min, FreMax: data.fre_max,
PeakMin: data.peak_min, PeakMax: data.peak_max,
}),
body: JSON.stringify(body),
});
const result = await resp.json();
if (result.ok) {
commLog('info', null, '参数已保存到数据库');
toast("已保存到数据库");
updateCurrentLabels();
} else {
toast("保存失败: " + (result.error || ""), true);
}
} catch (e) { toast("保存失败: " + e.message, true); }
}
/** 更新页面上的当前线圈/车辆标签 */
function updateCurrentLabels(param) {
const coilLabel = document.getElementById("current-coil-label");
const carLabel = document.getElementById("current-car-label");
if (param) {
coilLabel.textContent = param.coil_name || param.coil_num || '未设置';
carLabel.textContent = param.car_name || param.simulate_num || '未设置';
} else {
// 从 DOM 读取当前选择
const coilId = parseInt(document.getElementById("coil-select").value);
const carId = parseInt(document.getElementById("car-select").value);
const coil = coilId ? coilList.find(c => c.id === coilId) : null;
const car = carId ? carList.find(c => c.id === carId) : null;
coilLabel.textContent = coil ? (coil.coil_num || coil.name) : '未设置';
carLabel.textContent = car ? (car.simulate_num || car.name) : '未设置';
}
}
function getFormParams() {
return {
addr: parseInt(document.getElementById("param-addr").value) || 1,
dev_type: parseInt(document.getElementById("param-dev-type").value) || 0,
test_mode: parseInt(document.getElementById("param-test-mode").value) || 0,
reset_dis: parseInt(document.getElementById("param-reset-dis").value) || 0,
minus_dis: parseInt(document.getElementById("param-minus-dis").value) || 0,
reset_dis: Math.round((parseInt(document.getElementById("param-reset-dis").value) || 0) / 10),
minus_dis: Math.round((parseInt(document.getElementById("param-minus-dis").value) || 0) / 10),
sens_min: parseInt(document.getElementById("param-sens-min").value) || 0,
sens_max: parseInt(document.getElementById("param-sens-max").value) || 0,
fre_min: parseInt(document.getElementById("param-fre-min").value) || 0,
fre_max: parseInt(document.getElementById("param-fre-max").value) || 0,
peak_min: parseInt(document.getElementById("param-peak-min").value) || 0,
peak_max: parseInt(document.getElementById("param-peak-max").value) || 0,
fre_min: hzToRawFreq(parseFloat(document.getElementById("param-fre-min").value) || 0),
fre_max: hzToRawFreq(parseFloat(document.getElementById("param-fre-max").value) || 0),
peak_min: vToRawPeak(parseFloat(document.getElementById("param-peak-min").value) || 0),
peak_max: vToRawPeak(parseFloat(document.getElementById("param-peak-max").value) || 0),
far_tol: parseInt(document.getElementById("param-far-tol").value) || 0,
near_tol: parseInt(document.getElementById("param-near-tol").value) || 0,
step_tol: parseInt(document.getElementById("param-step-tol").value) || 0,
back_forth: parseInt(document.getElementById("param-back-forth").value) || 0,
near_stay: parseInt(document.getElementById("param-near-stay").value) || 0,
far_stay: parseInt(document.getElementById("param-far-stay").value) || 0,
};
}
@@ -237,7 +360,7 @@ function startRespPolling(recordId, cmd) {
if (cmd === '4C') {
// 参数已在后端 upsert直接从 DB 加载
setTimeout(async () => {
const p = await (await fetch(`/api/fixture/param/${DNT_ID}`)).json();
const p = await (await fetch(`/api/fixture/param/${DNT_ID}?_=${Date.now()}`)).json();
if (p && p.dnt_id) fillFormFromParam(p);
}, 500);
}
@@ -309,16 +432,24 @@ async function sendConfig() {
startRespPolling(data.record_id, "4B");
// 同时保存到数据库
const coilId = parseInt(document.getElementById("coil-select").value) || null;
const carId = parseInt(document.getElementById("car-select").value) || null;
const saveBody = {
Addr: params.addr, DevType: params.dev_type, TestMode: params.test_mode,
RestDis: params.reset_dis, MinusDis: params.minus_dis,
SensMin: params.sens_min, SensMax: params.sens_max,
FreMin: params.fre_min, FreMax: params.fre_max,
PeakMin: params.peak_min, PeakMax: params.peak_max,
FarTol: params.far_tol, NearTol: params.near_tol,
StepTol: params.step_tol, BackForth: params.back_forth,
NearStay: params.near_stay, FarStay: params.far_stay,
};
if (coilId) saveBody.coil_id = coilId;
if (carId) saveBody.simulate_car_id = carId;
await fetch(`/api/fixture/param/${DNT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Addr: params.addr, DevType: params.dev_type, TestMode: params.test_mode,
RestDis: params.reset_dis, MinusDis: params.minus_dis,
SensMin: params.sens_min, SensMax: params.sens_max,
FreMin: params.fre_min, FreMax: params.fre_max,
PeakMin: params.peak_min, PeakMax: params.peak_max,
}),
body: JSON.stringify(saveBody),
});
} else {
toast(`失败: ${data.error}`, true);

View File

@@ -0,0 +1,151 @@
// 模拟车辆参数管理
let editId = null;
function toast(msg, isError = false) {
const el = document.getElementById("toast");
el.textContent = msg;
el.className = "msg-toast " + (isError ? "error" : "") + " show";
clearTimeout(el._timeout);
el._timeout = setTimeout(() => { el.className = "msg-toast"; }, 3000);
}
async function loadList() {
const search = document.getElementById("search-input").value;
try {
const resp = await fetch(`/api/simulate-car?search=${encodeURIComponent(search)}`);
const data = await resp.json();
renderTable(data);
} catch (e) {
console.error("加载失败:", e);
}
}
function sizeLabel(item) {
if (item.shape === '圆形') return `半径${item.radius || 0}cm`;
if (item.shape === '矩形') return `${item.length || 0}×${item.width || 0}cm`;
return '-';
}
function renderTable(data) {
const tbody = document.querySelector("#car-table tbody");
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="7" style="color:#999;text-align:center;">暂无数据,点右上角「新增」添加</td></tr>';
return;
}
tbody.innerHTML = data.map(t => `
<tr>
<td>${esc(t.simulate_num)}</td>
<td>${esc(t.name)}</td>
<td>${t.shape || '-'}</td>
<td>${sizeLabel(t)}</td>
<td>${esc(t.material || '-')}</td>
<td>${esc(t.remark || '-')}</td>
<td>
<button class="btn-edit" onclick="openModal(${t.id})">编辑</button>
<button class="btn-del" onclick="deleteRecord(${t.id}, '${esc(t.simulate_num || t.name)}')">删除</button>
</td>
</tr>
`).join("");
}
function openModal(id = null) {
editId = id;
document.getElementById("modal-title").textContent = id ? "编辑模拟车辆参数" : "新增模拟车辆参数";
document.getElementById("edit-modal").style.display = "flex";
if (id) {
fetch(`/api/simulate-car/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById("edit-simulate-num").value = data.simulate_num || "";
document.getElementById("edit-name").value = data.name || "";
document.getElementById("edit-shape").value = data.shape || "";
document.getElementById("edit-length").value = data.length || 0;
document.getElementById("edit-width").value = data.width || 0;
document.getElementById("edit-radius").value = data.radius || 0;
document.getElementById("edit-material").value = data.material || "";
document.getElementById("edit-remark").value = data.remark || "";
});
} else {
document.getElementById("edit-simulate-num").value = "";
document.getElementById("edit-name").value = "";
document.getElementById("edit-shape").value = "";
document.getElementById("edit-length").value = "0";
document.getElementById("edit-width").value = "0";
document.getElementById("edit-radius").value = "0";
document.getElementById("edit-material").value = "";
document.getElementById("edit-remark").value = "";
}
}
function closeModal() {
document.getElementById("edit-modal").style.display = "none";
editId = null;
}
async function saveRecord() {
const data = {
simulate_num: document.getElementById("edit-simulate-num").value.trim(),
name: document.getElementById("edit-name").value.trim(),
shape: document.getElementById("edit-shape").value,
length: parseFloat(document.getElementById("edit-length").value) || 0,
width: parseFloat(document.getElementById("edit-width").value) || 0,
radius: parseFloat(document.getElementById("edit-radius").value) || 0,
material: document.getElementById("edit-material").value.trim(),
remark: document.getElementById("edit-remark").value.trim(),
};
if (!data.simulate_num && !data.name) {
toast("请输入模拟编号或名称", true);
return;
}
try {
let resp;
if (editId) {
resp = await fetch(`/api/simulate-car/${editId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} else {
resp = await fetch("/api/simulate-car", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
const result = await resp.json();
if (result.ok || resp.ok) {
toast(editId ? "更新成功" : "新增成功");
closeModal();
loadList();
} else {
toast("保存失败: " + (result.error || "未知错误"), true);
}
} catch (e) {
toast("保存失败: " + e.message, true);
}
}
async function deleteRecord(id, label) {
if (!confirm(`确定要删除「${label}」吗?`)) return;
try {
const resp = await fetch(`/api/simulate-car/${id}`, { method: "DELETE" });
const result = await resp.json();
if (result.ok) {
toast("删除成功");
loadList();
} else {
toast("删除失败: " + (result.error || "未知错误"), true);
}
} catch (e) {
toast("删除失败: " + e.message, true);
}
}
function esc(s) { return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
loadList();

View File

@@ -1,23 +1,212 @@
// 测试信息页
// 测试信息页 — 三视图 (全部 / B2 / B4)
// ─── 型号名称缓存 ─────────────────────────────────
let devTypeNameCache = {};
async function initDevTypeNames() {
try {
const resp = await fetch('/api/vehicle-base-test');
const tests = await resp.json();
devTypeNameCache = {};
tests.forEach(t => {
if (t.type_num != null && t.dev_name) {
devTypeNameCache[t.type_num] = t.dev_name;
}
});
} catch (e) { console.error('加载型号名称失败:', e); }
}
function getDevTypeName(subType) {
if (subType == null || subType === 0) return '-';
return devTypeNameCache[subType] || `Unknown(${subType})`;
}
// ─── 视图定义 ───────────────────────────────────
const VIEWS = {
all: {
label: '全部数据',
data_source: '', // '' = 不过滤
cols: [
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
{ key: 'serial', title: '设备编码', render: r => (r.serial || '').slice(-6) },
{ key: 'detector_serial', title: '车检器序列号', render: r => r.detector_serial || '-' },
{ key: 'model', title: '型号', render: r => getDevTypeName(r.sub_type) },
{ key: 'data_source', title: '来源' },
{ key: 'iffinish', title: '完成', render: r => r.data_source === 'B4' ? '-' : (r.iffinish === '1' ? '是' : '否') },
{ key: 'fault_info', title: '故障信息', render: r => r.data_source === 'B4' ? '-' : `<span style="display:inline-block;max-width:12em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.fault_info || '')}">${escHtml(r.fault_info || '-')}</span>` },
{ key: 'relay_out', title: '继电器', render: r => fmtRelay(r.relay_out) },
{ key: 'ppvalue', title: '峰峰值(V)', render: r => r.data_source === 'B4' ? '-' : (r.ppvalue != null ? r.ppvalue.toFixed(2) : '-') },
{ key: 'idle_freq', title: '开始频率(Hz)', render: r => r.data_source === 'B4' ? '-' : (r.idle_freq || '-') },
{ key: 'enter_freq', title: '触发频率(Hz)', render: r => r.data_source === 'B4' ? '-' : (r.enter_freq || '-') },
{ key: 'exit_freq', title: '释放频率(Hz)', render: r => r.data_source === 'B4' ? '-' : (r.exit_freq || '-') },
{ key: 'enter_dist', title: '触发距离(mm)', render: r => {
const v = r.data_source === 'B4' ? r.b4_enter_dist : r.enter_dist;
return v != null ? v + ' ' : '-';
}},
{ key: 'exit_dist', title: '释放距离(mm)', render: r => {
const v = r.data_source === 'B4' ? r.b4_leave_dist : r.exit_dist;
return v != null ? v + ' ' : '-';
}},
{ key: 'enter_speed', title: '触发速度(dm/s)', render: r => r.data_source === 'B4' ? '-' : toSpeed(r.enter_speed) },
{ key: 'exit_speed', title: '释放速度(dm/s)', render: r => r.data_source === 'B4' ? '-' : toSpeed(r.exit_speed) },
{ key: 'remain_count', title: '剩余次数', render: r => r.data_source === 'B2' ? '-' : (r.remain_count ?? '-') },
{ key: 'work_freq', title: '工作频率(Hz)', render: r => r.data_source === 'B2' ? '-' : (r.work_freq ?? '-') },
{ key: 'curr_dist', title: '当前距离(mm)', render: r => r.data_source === 'B2' ? '-' : (r.curr_dist != null ? r.curr_dist + ' ' : '-') },
{ key: 'speed', title: '速度(dm/s)', render: r => r.data_source === 'B2' ? '-' : (r.speed ?? '-') },
{ key: 'near_dist', title: '最近距离(mm)', render: r => r.data_source === 'B2' ? '-' : (r.near_dist != null ? r.near_dist + ' ' : '-') },
{ key: 'far_dist', title: '最远距离(mm)', render: r => r.data_source === 'B2' ? '-' : (r.far_dist != null ? r.far_dist + ' ' : '-') },
{ key: 'env', title: '测试环境', render: r => envLabel(r) },
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
],
},
b2: {
label: '灵敏度测试',
data_source: 'B2',
cols: [
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
{ key: 'serial', title: '设备编码', render: r => (r.serial || '').slice(-6) },
{ key: 'detector_serial', title: '车检器序列号', render: r => r.detector_serial || '-' },
{ key: 'model', title: '型号', render: r => getDevTypeName(r.sub_type) },
{ key: 'iffinish', title: '完成', render: r => r.iffinish === '1' ? '是' : '否' },
{ key: 'fault_info', title: '故障信息', render: r => `<span style="display:inline-block;max-width:12em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.fault_info || '')}">${escHtml(r.fault_info || '-')}</span>` },
{ key: 'relay_out', title: '继电器', render: r => fmtRelay(r.relay_out) },
{ key: 'ppvalue', title: '峰峰值(V)', render: r => r.ppvalue?.toFixed(2) || '-' },
{ key: 'idle_freq', title: '开始频率(Hz)' },
{ key: 'enter_freq', title: '触发频率(Hz)' },
{ key: 'exit_freq', title: '释放频率(Hz)' },
{ key: 'enter_dist', title: '触发距离(mm)' },
{ key: 'exit_dist', title: '释放距离(mm)' },
{ 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: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
],
},
b4: {
label: '波动测试',
data_source: 'B4',
cols: [
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
{ key: 'serial', title: '设备编码', render: r => (r.serial || '').slice(-6) },
{ key: 'detector_serial', title: '车检器序列号', render: r => r.detector_serial || '-' },
{ 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 => fmtRelay(r.relay_out) },
{ key: 'env', title: '测试环境', render: r => envLabel(r) },
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
],
},
};
// ─── 状态 ───────────────────────────────────────
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);
}
let currentPage = 1;
let totalPages = 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 escHtml(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 构建测试环境标签 (线圈 + 模拟车辆) */
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 fmtRelay(s) {
if (!s) return '-';
return s
.replace(/继电器有输出/g, '<span style="color:#22c55e;font-weight:600">✅有输出</span>')
.replace(/继电器无输出/g, '<span style="color:#ef4444;font-weight:600">❌无输出</span>');
}
// ─── 视图切换 ────────────────────────────────────
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 = document.getElementById("search-date-from").value;
const dateTo = document.getElementById("search-date-to").value;
const detectorSerial = document.getElementById("search-detector-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({ page, per_page: 20 });
const perPage = parseInt(document.getElementById("per-page").value) || 100;
const params = new URLSearchParams({ page, per_page: perPage });
if (serial) params.set("serial", serial);
if (detectorSerial) params.set("detector_serial", detectorSerial);
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}`);
@@ -30,35 +219,39 @@ async function searchData(page = 1) {
}
}
// ─── 渲染表头 ────────────────────────────────────
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="17" style="text-align:center;color:#999;">暂无数据</td></tr>';
tbody.innerHTML = `<tr><td colspan="${nCols}" style="text-align:center;color:#999;">暂无数据</td></tr>`;
return;
}
tbody.innerHTML = records.map(r => `
<tr>
<td>${r.id}</td>
<td>${r.serial || '-'}</td>
<td>${r.dpg430_addr}</td>
<td>${r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-'}</td>
<td>${r.str_type || '-'}</td>
<td>${r.iffinish === '1' ? '是' : '否'}</td>
<td>${r.fault_info || '无'}</td>
<td>${r.relay_out || '无'}</td>
<td>${r.ppvalue?.toFixed(2) || '-'}</td>
<td>${r.idle_freq || '-'}</td>
<td>${r.enter_freq || '-'}</td>
<td>${r.exit_freq || '-'}</td>
<td>${r.enter_dist || '-'}</td>
<td>${r.exit_dist || '-'}</td>
<td>${toSpeed(r.enter_speed)}</td>
<td>${toSpeed(r.exit_speed)}</td>
<td>${r.create_time || '-'}</td>
</tr>
`).join("");
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 = "";
@@ -74,18 +267,266 @@ function renderPagination() {
div.innerHTML = html;
}
// ─── 导出 ────────────────────────────────────────
function exportCSV() {
const serial = document.getElementById("search-serial").value;
const dateFrom = document.getElementById("search-date-from").value;
const dateTo = document.getElementById("search-date-to").value;
const detectorSerial = document.getElementById("search-detector-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 (detectorSerial) params.set("detector_serial", detectorSerial);
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}`;
}
// 初始加载
searchData(1);
// ─── 图表 ────────────────────────────────────────
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 detectorSerial = document.getElementById('search-detector-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 (detectorSerial) params.set('detector_serial', detectorSerial);
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();
// 先加载型号名称再查询数据,确保型号列正确渲染
initDevTypeNames().then(() => 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);
}
}

View File

@@ -1,4 +1,4 @@
// 测试操作页
// 测试操作页 — 间隔/超时机制
let autoRunning = false;
let autoTotal = 0;
@@ -7,13 +7,35 @@ let autoFailed = 0;
let autoRemaining = 0;
let autoStartTime = "";
let localSinceStr = "";
let currentTestMode = null; // 0=灵敏度, 1=波动, null=未加载
let currentDeviceState = null; // 当前设备状态 (0=离线 1=在线 2=通信不良 null=未加载)
let devTypeNameCache = {}; // type_num → dev_name 映射(从 tb_vechicle_base_test
let pollInterval = null;
let timeoutTimers = {}; // record_id → timer
const TIMEOUT_MS = 10000;
let nextCmdTimer = null; // 间隔等待定时器
let timeoutTimer = null; // 超时定时器
let timeoutAt = 0; // 超时时刻 (Date.now() + timeoutMs)
let lastDoneCount = 0; // 上一轮 done 数,检测新响应
let cmdSentAt = 0; // 最近一次发送 0xB0 时间
let intervalMs = 10000; // 默认 10s
let timeoutMs = 5000; // 默认 5s
// ─── 设备在线检查 ──────────────────────────────
function checkDeviceOnline() {
if (currentDeviceState !== 1) {
const stateName = currentDeviceState === 2 ? '通信不良' :
currentDeviceState === 0 ? '离线' : '未知';
alert(`设备当前状态为「${stateName}」,无法发送指令`);
return false;
}
return true;
}
// ─── 手动指令 ─────────────────────────────────
async function sendCmd(cmd) {
if (!checkDeviceOnline()) return;
try {
const resp = await fetch("/api/command", {
method: "POST",
@@ -40,17 +62,29 @@ async function toggleAuto() {
}
async function startAuto() {
const count = parseInt(document.getElementById("test-count").value) || 10;
if (!checkDeviceOnline()) return;
const count = parseInt(document.getElementById("test-count").value) || 1;
if (count < 1) return;
// 读取车检器序列号
const detectorSerial = document.getElementById("detector-serial").value.trim();
// 读取参数
intervalMs = (parseFloat(document.getElementById("interval-sec").value) || 3) * 1000;
timeoutMs = (parseFloat(document.getElementById("timeout-sec").value) || 10) * 1000;
if (timeoutMs < 1000) timeoutMs = 1000;
// 重置
autoRunning = true;
autoTotal = count;
autoDone = 0;
autoFailed = 0;
autoRemaining = count;
autoStartTime = new Date().toISOString(); // 记录开始时间 (UTC)
// MySQL 存的是本地时间,需要转本地格式传给后端过滤
lastDoneCount = 0;
newB2Count = 0;
updateRecordCount();
autoStartTime = new Date().toISOString();
const now = new Date();
localSinceStr = now.getFullYear() + "-" +
String(now.getMonth() + 1).padStart(2, "0") + "-" +
@@ -67,13 +101,14 @@ async function startAuto() {
// 清空显示
resetAverages();
document.getElementById("latest-result").innerHTML = '<p class="placeholder">等待测试...</p>';
document.getElementById("latest-wave").innerHTML = '<p class="placeholder">暂无波动数据...</p>';
document.getElementById("progress-bar").style.width = "0%";
document.getElementById("progress-text").textContent = "0/" + count + " (0 失败)";
document.getElementById("stat-done").textContent = "0";
document.getElementById("stat-failed").textContent = "0";
document.getElementById("stat-remaining").textContent = count;
document.getElementById("auto-status").textContent = "";
// 清空测试明细
document.querySelector("#records-table tbody").innerHTML = "";
document.getElementById("records-empty").style.display = "block";
@@ -81,17 +116,19 @@ async function startAuto() {
btn.textContent = "结束";
btn.className = "btn-stop";
// 插入第一条 0xB0
// 清除旧记录 + 插入第一条
try {
const resp = await fetch("/api/automation/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, count }),
body: JSON.stringify({ dnt_id: DNT_ID, count, detector_serial: detectorSerial }),
});
const data = await resp.json();
if (data.ok) {
// 启动超时计时器
startTimeout(data.first_record_id);
cmdSentAt = Date.now();
timeoutAt = cmdSentAt + timeoutMs;
armTimeout();
setStatus(`已发送,超时 ${timeoutMs/1000}s...`);
}
} catch (e) {
console.error("启动失败:", e);
@@ -99,26 +136,87 @@ async function startAuto() {
}
// 启动轮询
pollInterval = setInterval(pollProgress, 1000);
pollInterval = setInterval(pollProgress, 500);
}
function stopAuto() {
autoRunning = false;
clearInterval(pollInterval);
pollInterval = null;
// 记录结束时间
clearTimeout(nextCmdTimer);
nextCmdTimer = null;
clearTimeout(timeoutTimer);
timeoutTimer = null;
document.getElementById("auto-status").textContent = "";
document.getElementById("time-end").textContent = new Date().toLocaleString();
// 清除所有超时计时器
for (const id in timeoutTimers) {
clearTimeout(timeoutTimers[id]);
}
timeoutTimers = {};
const btn = document.getElementById("btn-auto");
btn.textContent = "开始";
btn.className = "btn-start";
// 自动化结束 → 焦点回到车检器序列号输入框(全选),方便下一个车检器测试
setTimeout(focusDetectorSerial, 100);
}
// ─── 发送下一条 0xB0 ────────────────────────────
async function sendNextCmd() {
if (!autoRunning) return;
if (autoRemaining <= 0) {
// 检查是否还有等待完成的
if (autoDone + autoFailed >= autoTotal) {
stopAuto();
return;
}
}
try {
const r = await fetch("/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, cmd: "B0" }),
});
const rd = await r.json();
if (rd.ok) {
cmdSentAt = Date.now();
timeoutAt = cmdSentAt + timeoutMs;
armTimeout();
setStatus(`已发送,超时 ${timeoutMs/1000}s...`);
}
} catch (e) {
console.error("发送下一条失败:", e);
}
}
// ─── 超时定时器 ─────────────────────────────────
function armTimeout() {
clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(onTimeout, timeoutMs + 500); // +500ms 容差
}
function onTimeout() {
if (!autoRunning) return;
// 超时:即使没收到回复也计数失败,立即发下一条
autoFailed++;
autoRemaining = autoTotal - autoDone - autoFailed;
if (autoRemaining < 0) autoRemaining = 0;
updateUI();
setStatus(`超时!立即发下一条...`);
console.log(`超时 (${timeoutMs}ms),失败数: ${autoFailed}`);
clearTimeout(nextCmdTimer);
nextCmdTimer = null;
if (autoRemaining > 0) {
sendNextCmd();
} else {
stopAuto();
}
}
// ─── 轮询 ──────────────────────────────────────
async function pollProgress() {
if (!autoRunning) return;
@@ -127,63 +225,295 @@ async function pollProgress() {
const data = await resp.json();
const stats = data.stats;
// 更新计数
autoDone = stats.done || 0;
autoFailed = stats.failed || 0;
autoRemaining = autoTotal - autoDone - autoFailed;
if (autoRemaining < 0) autoRemaining = 0;
updateUI();
// 显示最新结果
if (data.latest) {
// ── 先渲染数据(放在所有 return 之前,避免完成时跳过渲染)──
try { if (data.latest) {
if (data.latest.id !== lastLatestId) {
lastLatestId = data.latest.id;
if (data.latest.data_source === "B2") { newB2Count++; updateRecordCount(); }
}
renderLatest(data.latest);
}
} } catch (e) { console.error("renderLatest:", e); }
try { if (data.averages) renderAverages(data.averages); } catch (e) { console.error("renderAverages:", e); }
try { if (data.latest_wave) { renderLatestWave(data.latest_wave); lastWaveId = data.latest_wave.id; } } catch (e) { console.error("renderLatestWave:", e); }
try { if (data.records && data.records.length) renderRecords(data.records); } catch (e) { console.error("renderRecords:", e); }
// 显示平均值
if (data.averages) {
renderAverages(data.averages);
}
// 更新计数
const newDone = stats.done || 0;
const newFailed = stats.failed || 0;
// 显示本轮测试明细
if (data.records) {
renderRecords(data.records);
}
if (newDone > lastDoneCount) {
// 收到新回复 → 清除超时,开始间隔等待
const delta = newDone - lastDoneCount;
lastDoneCount = newDone;
autoDone = newDone;
autoFailed = newFailed;
autoRemaining = autoTotal - autoDone - autoFailed;
if (autoRemaining < 0) autoRemaining = 0;
// 自动插入下一条 0xB0
if (autoRemaining > 0) {
// 检查是否还有 pending 的记录,没有则插入新的
if (stats.pending === 0 && stats.sent === 0) {
try {
const r = await fetch("/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, cmd: "B0" }),
});
const rd = await r.json();
if (rd.ok) {
startTimeout(rd.record_id);
}
} catch (e) {
console.error("插入下一条失败:", e);
}
clearTimeout(timeoutTimer);
timeoutTimer = null;
clearTimeout(nextCmdTimer);
nextCmdTimer = null;
updateUI();
if (autoRemaining > 0) {
const wait = (intervalMs / 1000).toFixed(0);
setStatus(`收到 ${delta} 条回复,等待 ${wait}s...`);
nextCmdTimer = setTimeout(() => {
setStatus("发送中...");
sendNextCmd();
}, intervalMs);
} else {
setStatus("全部完成");
stopAuto();
return;
}
} else {
// 全部完成
stopAuto();
// 没有新回复,更新超时计数
autoFailed = newFailed;
autoRemaining = autoTotal - autoDone - autoFailed;
if (autoRemaining < 0) autoRemaining = 0;
// 检查是否全部完成
if (autoRemaining <= 0 && autoDone + autoFailed >= autoTotal) {
stopAuto();
return;
}
}
} catch (e) {
console.error("轮询失败:", e);
}
}
function startTimeout(recordId) {
timeoutTimers[recordId] = setTimeout(async () => {
// 超时:检查 record 的状态,如果还是 1 → 视为失败
// 后端串行轮询会自动处理超时标记为 state=3
// 前端稍后通过 pollProgress 更新计数
console.log(`记录 ${recordId} 可能已超时`);
}, TIMEOUT_MS);
// ─── 页面加载时获取初始数据 ──────────────────────
async function loadDeviceTypeNames() {
try {
const resp = await fetch(`/api/vehicle-base-test`);
const tests = await resp.json();
devTypeNameCache = {};
tests.forEach(t => {
if (t.type_num != null && t.dev_name) {
devTypeNameCache[t.type_num] = t.dev_name;
}
});
} catch (e) { console.error("加载型号名称失败:", e); }
}
async function loadTestMode() {
try {
const resp = await fetch(`/api/fixture/param/${DNT_ID}?_=${Date.now()}`);
const param = await resp.json();
if (param && param.dnt_id) {
updateTestModeUI(param.TestMode);
renderConfigOverview(param);
} else {
// 没有工装参数时,尝试从最新测试数据获取
const r2 = await fetch(`/api/automation/${DNT_ID}/progress`);
const d2 = await r2.json();
if (d2.latest && d2.latest.test_mode !== undefined) {
updateTestModeUI(d2.latest.test_mode);
}
}
} catch (e) { /* 静默 */ }
}
function updateTestModeUI(mode) {
currentTestMode = mode;
const waveSection = document.getElementById("wave-section");
if (mode === 1) {
waveSection.style.display = '';
} else {
waveSection.style.display = 'none';
}
}
function renderConfigOverview(param) {
const panel = document.getElementById("config-overview");
if (!panel) return;
panel.style.display = '';
// 测试模式
const modeEl = document.getElementById("cfg-test-mode");
if (param.TestMode === 1) {
modeEl.innerHTML = '<strong style="color:#e67e22;">波动测试</strong>';
} else {
modeEl.innerHTML = '<strong style="color:#2980b9;">灵敏度测试</strong>';
}
// 型号
document.getElementById("cfg-dev-type").textContent =
devTypeNameCache[param.DevType] || `0x${(param.DevType || 0).toString(16)}`;
// 距离 (DB cm → 显示 mm)
document.getElementById("cfg-reset-dis").textContent = param.RestDis != null ? param.RestDis * 10 : '-';
document.getElementById("cfg-minus-dis").textContent = param.MinusDis != null ? param.MinusDis * 10 : '-';
// 触发和释放范围 (SensMin ~ SensMax)
document.getElementById("cfg-sens-range").textContent =
(param.SensMin != null && param.SensMax != null) ? `${param.SensMin} ~ ${param.SensMax}` : '-';
// 频率范围 (配置值 ×10 = 实际 Hz)
if (param.FreMin != null && param.FreMax != null) {
document.getElementById("cfg-fre-range").textContent = `${param.FreMin * 10} ~ ${param.FreMax * 10}`;
} else {
document.getElementById("cfg-fre-range").textContent = '-';
}
// 线圈信息
const coil = [param.coil_num, param.coil_name].filter(Boolean).join(' ');
document.getElementById("cfg-coil").textContent = coil || '-';
// 模拟车辆信息
const car = [param.simulate_num, param.car_name].filter(Boolean).join(' ');
document.getElementById("cfg-car").textContent = car || '-';
// 波动参数
const waveParams = document.getElementById("cfg-wave-params");
if (param.TestMode === 1) {
waveParams.style.display = '';
document.getElementById("cfg-near-tol").textContent = param.NearTol ?? '-';
document.getElementById("cfg-far-tol").textContent = param.FarTol ?? '-';
document.getElementById("cfg-step-tol").textContent = param.StepTol ?? '-';
document.getElementById("cfg-back-forth").textContent = param.BackForth ?? '-';
document.getElementById("cfg-near-stay").textContent = param.NearStay ?? '-';
document.getElementById("cfg-far-stay").textContent = param.FarStay ?? '-';
} else {
waveParams.style.display = 'none';
}
}
function toggleConfig() {
const body = document.getElementById("config-body");
const toggle = document.getElementById("config-toggle");
if (body.style.display === 'none') {
body.style.display = '';
toggle.textContent = '收起 ▲';
} else {
body.style.display = 'none';
toggle.textContent = '展开 ▼';
}
}
async function loadInitialData() {
await loadDeviceTypeNames();
await loadTestMode();
refreshDeviceStatus();
newB2Count = 0;
updateRecordCount();
try {
const resp = await fetch(`/api/automation/${DNT_ID}/progress`);
const data = await resp.json();
if (data.latest) { renderLatest(data.latest); lastLatestId = data.latest.id; }
if (data.latest_wave) { renderLatestWave(data.latest_wave); lastWaveId = data.latest_wave.id; }
} catch (e) {
// 初始加载静默失败
}
// 页面加载后焦点落到车检器序列号输入框(全选)
setTimeout(focusDetectorSerial, 200);
}
loadInitialData();
// ─── 回车键触发"开始"按钮 ─────────────────────
/** 将焦点移到车检器序列号输入框并全选已有文本 */
function focusDetectorSerial() {
const input = document.getElementById("detector-serial");
if (input) {
input.focus();
input.select();
}
}
document.addEventListener("DOMContentLoaded", function() {
const detectorInput = document.getElementById("detector-serial");
if (detectorInput) {
detectorInput.addEventListener("keydown", function(e) {
if (e.key === "Enter") {
e.preventDefault();
const btn = document.getElementById("btn-auto");
if (btn && !autoRunning) {
btn.click();
}
}
});
}
});
// ─── 设备状态异步刷新 ──────────────────────────
async function refreshDeviceStatus() {
try {
const resp = await fetch(`/api/devices/${DNT_ID}/status`);
const data = await resp.json();
if (data.ok) {
currentDeviceState = data.state;
const el = document.getElementById("device-status-text");
if (el) {
el.textContent = data.state_name;
if (data.state === 1) {
el.className = "status-online";
} else if (data.state === 2) {
el.className = "status-poor";
} else {
el.className = "status-offline";
}
}
}
} catch (e) {
// 静默失败
}
}
// ─── 被动轮询:实时显示上报数据(工装本地按键 / 网页手动指令触发)───
let lastLatestId = 0; // 最新测试数据 ID用于判断是否有新数据
let lastWaveId = 0; // 最新波动数据 ID
let newB2Count = 0; // 本轮新收到的 B2(灵敏度测试) 记录条数
function updateRecordCount() {
const el = document.getElementById("new-record-count");
if (el) el.textContent = newB2Count > 0 ? `(${newB2Count} 条新记录)` : "";
}
async function refreshLatestData() {
// 自动化运行中由 pollProgress 负责渲染,避免冲突
if (autoRunning) return;
try {
const resp = await fetch(`/api/automation/${DNT_ID}/progress`);
const data = await resp.json();
if (data.latest && data.latest.id !== lastLatestId) {
lastLatestId = data.latest.id;
renderLatest(data.latest);
// 仅 B2(灵敏度测试) 记录计数
if (data.latest.data_source === "B2") {
newB2Count++;
updateRecordCount();
}
}
if (data.latest_wave && data.latest_wave.id !== lastWaveId) {
lastWaveId = data.latest_wave.id;
renderLatestWave(data.latest_wave);
}
} catch (e) { /* 静默失败 */ }
}
// 最新测试数据每 3 秒轮询
setInterval(refreshLatestData, 3000);
// 每 5 秒刷新设备状态 + 测试模式 + 型号名称缓存(工装页修改后能及时同步)
async function refreshAll() {
await loadDeviceTypeNames();
await loadTestMode();
refreshDeviceStatus();
}
setInterval(refreshAll, 5000);
// ─── UI ────────────────────────────────────────
function setStatus(msg) {
document.getElementById("auto-status").textContent = msg;
}
function updateUI() {
@@ -203,23 +533,65 @@ function toSpeed(v) {
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 fmtRelay(s) {
if (!s) return '-';
return s
.replace(/继电器有输出/g, '<span style="color:#22c55e;font-weight:600">✅有输出</span>')
.replace(/继电器无输出/g, '<span style="color:#ef4444;font-weight:600">❌无输出</span>');
}
// ─── 显示最新结果 ──────────────────────────────
function renderLatest(data) {
const div = document.getElementById("latest-result");
// 优先使用 str_type为空时从缓存查找
let typeName = data.str_type;
if (!typeName && data.sub_type != null) {
typeName = devTypeNameCache[data.sub_type] || `Unknown(${data.sub_type})`;
}
div.innerHTML = `
<p>设备型号:<strong>${data.str_type || '-'}</strong></p>
<p>车检器序列号:<strong>${data.detector_serial || '-'}</strong></p>
<p>设备型号:<strong>${typeName || '-'}</strong></p>
<p>测试模式:<strong>${data.test_mode === 1 ? '波动测试' : '灵敏度测试'}</strong></p>
<p>峰峰值:${data.ppvalue?.toFixed(2) || '-'} V</p>
<p>开始工作频率:${data.idle_freq || '-'} Hz</p>
<p>进入工作频率:${data.enter_freq || '-'} Hz</p>
<p>离开工作频率:${data.exit_freq || '-'} Hz</p>
<p>进入距离:${data.enter_dist || '-'} mm</p>
<p>离开距离:${data.exit_dist || '-'} mm</p>
<p>进入速度:${toSpeed(data.enter_speed)} m/s</p>
<p>离开速度:${toSpeed(data.exit_speed)} m/s</p>
<p>开始频率:${data.idle_freq || '-'} Hz</p>
<p>触发频率:${data.enter_freq || '-'} Hz</p>
<p>释放频率:${data.exit_freq || '-'} Hz</p>
<p>触发距离:${data.enter_dist || '-'} mm</p>
<p>释放距离:${data.exit_dist || '-'} mm</p>
<p>触发速度:${toSpeed(data.enter_speed)} m/s</p>
<p>释放速度:${toSpeed(data.exit_speed)} m/s</p>
<p>是否完成:${data.iffinish === '1' ? '是' : '否'}</p>
<p>故障信息:${data.fault_info || '无'}</p>
<p>时间${data.create_time || '-'}</p>
<p>继电器${fmtRelay(data.relay_out) || decodeRelay(data.relay_code)}</p>
<p>时间:${fmtTime(data.create_time)}</p>
`;
}
@@ -242,6 +614,28 @@ function resetAverages() {
});
}
// ─── 显示波动测试数据 ──────────────────────────
function renderLatestWave(data) {
const div = document.getElementById("latest-wave");
if (!data || !data.work_freq) {
div.innerHTML = '<p class="placeholder">暂无波动数据...</p>';
return;
}
div.innerHTML = `
<p>剩余次数:<strong>${data.remain_count || 0}</strong></p>
<p>工作频率:${data.work_freq || '-'} Hz</p>
<p>当前距离:${data.curr_dist || '-'} mm</p>
<p>当前速度:${toSpeed(data.speed)} m/s</p>
<p>最近距离:${data.near_dist || '-'} mm</p>
<p>最远距离:${data.far_dist || '-'} mm</p>
<p>触发高度 (B4)${data.b4_enter_dist || '-'} mm</p>
<p>释放高度 (B4)${data.b4_leave_dist || '-'} mm</p>
<p>继电器:${fmtRelay(data.relay_out) || decodeRelay(data.relay_code)}</p>
<p>时间:${fmtTime(data.create_time)}</p>
`;
}
// ─── 显示本轮测试明细 ──────────────────────────
function renderRecords(records) {
@@ -260,12 +654,13 @@ function renderRecords(records) {
<td style="color:${r.sn_state === 2 ? '#27ae60' : r.sn_state === 3 ? '#e74c3c' : '#888'}">
${r.sn_state === 2 ? 'OK' : r.sn_state === 3 ? '超时' : '?'}
</td>
<td>${r.test_mode === 1 ? '波动' : '灵敏度'}</td>
<td>${r.ppvalue?.toFixed(2) || '-'}</td>
<td>${r.idle_freq || '-'}</td>
<td>${r.enter_dist || '-'}</td>
<td>${r.exit_dist || '-'}</td>
<td>${toSpeed(r.enter_speed)}</td>
<td>${r.create_time || '-'}</td>
<td>${fmtTime(r.create_time)}</td>
</tr>
`).join("");
}

View File

@@ -1,5 +1,16 @@
// 车检器测试基准参数管理
// ─── 频率/峰峰值转换常量 ─────────────────────
// 协议: 工作频率 f(Hz) = 10 * X, X 为 DB/设备中存储和传输的原始值
// 协议: 峰峰值 V = ((X * 3.3) / 4095) * 4, X 为 DB/设备中存储和传输的原始值(正整数)
const FREQ_SCALE = 10;
const PEAK_SCALE = 4095 / (4 * 3.3); // ≈ 310.227
function rawFreqToHz(x) { return x * FREQ_SCALE; }
function hzToRawFreq(hz) { return Math.round(hz / FREQ_SCALE); }
function rawPeakToV(x) { return parseFloat(((x * 3.3) / 4095 * 4).toFixed(2)); }
function vToRawPeak(v) { return Math.round(v * PEAK_SCALE); }
let editId = null; // null=新增, number=编辑
// ─── Toast ───────────────────────────────────
@@ -36,8 +47,8 @@ function renderTable(data) {
<td>${t.type_num}</td>
<td>${esc(t.dev_name)}</td>
<td>${t.SensMin} ~ ${t.SensMax}</td>
<td>${t.FreMin} ~ ${t.FreMax}</td>
<td>${t.PeakMin} ~ ${t.PeakMax}</td>
<td>${rawFreqToHz(t.FreMin)} ~ ${rawFreqToHz(t.FreMax)}</td>
<td>${rawPeakToV(t.PeakMin)} ~ ${rawPeakToV(t.PeakMax)}</td>
<td>${esc(t.remark || '-')}</td>
<td>
<button class="btn-edit" onclick="openModal(${t.id})">编辑</button>
@@ -63,10 +74,10 @@ function openModal(id = null) {
document.getElementById("edit-dev-name").value = data.dev_name;
document.getElementById("edit-sens-min").value = data.SensMin;
document.getElementById("edit-sens-max").value = data.SensMax;
document.getElementById("edit-fre-min").value = data.FreMin;
document.getElementById("edit-fre-max").value = data.FreMax;
document.getElementById("edit-peak-min").value = data.PeakMin;
document.getElementById("edit-peak-max").value = data.PeakMax;
document.getElementById("edit-fre-min").value = rawFreqToHz(data.FreMin);
document.getElementById("edit-fre-max").value = rawFreqToHz(data.FreMax);
document.getElementById("edit-peak-min").value = rawPeakToV(data.PeakMin);
document.getElementById("edit-peak-max").value = rawPeakToV(data.PeakMax);
document.getElementById("edit-remark").value = data.remark || "";
});
} else {
@@ -96,10 +107,10 @@ async function saveRecord() {
dev_name: document.getElementById("edit-dev-name").value.trim(),
SensMin: parseInt(document.getElementById("edit-sens-min").value) || 0,
SensMax: parseInt(document.getElementById("edit-sens-max").value) || 0,
FreMin: parseInt(document.getElementById("edit-fre-min").value) || 0,
FreMax: parseInt(document.getElementById("edit-fre-max").value) || 0,
PeakMin: parseInt(document.getElementById("edit-peak-min").value) || 0,
PeakMax: parseInt(document.getElementById("edit-peak-max").value) || 0,
FreMin: hzToRawFreq(parseFloat(document.getElementById("edit-fre-min").value) || 0),
FreMax: hzToRawFreq(parseFloat(document.getElementById("edit-fre-max").value) || 0),
PeakMin: vToRawPeak(parseFloat(document.getElementById("edit-peak-min").value) || 0),
PeakMax: vToRawPeak(parseFloat(document.getElementById("edit-peak-max").value) || 0),
remark: document.getElementById("edit-remark").value.trim(),
};

View File

@@ -8,15 +8,21 @@
</head>
<body>
<nav class="top-menu">
{% if current_user.is_authenticated and current_user.role != 'analyst' %}
<a href="/" class="{% if request.path == '/' %}active{% endif %}">设备</a>
{% endif %}
<a href="/test-data" class="{% if request.path == '/test-data' %}active{% endif %}">测试信息</a>
{% if current_user.is_authenticated and current_user.role == 'admin' %}
{% if current_user.is_authenticated and current_user.role in ('admin', 'manager') %}
<a href="/device-logs" class="{% if request.path == '/device-logs' %}active{% endif %}">设备日志</a>
<a href="/logs/" class="{% if request.path == '/logs/' %}active{% endif %}">操作日志</a>
{% endif %}
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<a href="/users/" class="{% if request.path == '/users/' %}active{% endif %}">用户管理</a>
{% endif %}
<span class="user-info">
{% if current_user.is_authenticated %}
{{ current_user.username }} ({{ current_user.role }})
<a href="/change-password">修改密码</a>
<a href="/logout">退出</a>
{% endif %}
</span>

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}修改密码 - EDC 工装管理系统{% endblock %}
{% block content %}
<div style="max-width:400px;margin:40px auto;">
<h2>修改密码</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div style="background:#fef3e2;color:#b45309;padding:10px;border-radius:6px;margin-bottom:16px;">
{% for msg in messages %}{{ msg }}{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" style="display:flex;flex-direction:column;gap:16px;">
<div>
<label>当前密码</label>
<input type="password" name="old_password" required
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div>
<label>新密码至少6位</label>
<input type="password" name="new_password" required minlength="6"
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div>
<label>确认新密码</label>
<input type="password" name="confirm_password" required minlength="6"
style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;">
</div>
<div style="display:flex;justify-content:space-between;">
<a href="/" style="line-height:36px;">← 返回</a>
<button type="submit" class="btn-search" style="padding:8px 24px;">确认修改</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}线圈参数管理 - EDC 工装管理系统{% endblock %}
{% block content %}
<div class="test-header">
<a href="/">← 返回设备列表</a>
<h2>线圈参数管理</h2>
</div>
<div class="fixture-card">
<div class="vbt-header">
<div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="search-input" placeholder="搜索编号/名称..."
style="padding:6px 10px; border:1px solid #ddd; border-radius:4px; font-size:13px; width:200px;"
oninput="loadList()">
</div>
<button class="btn-add" onclick="openModal()">+ 新增</button>
</div>
<table id="coil-table">
<thead>
<tr>
<th>线圈编号</th>
<th>名称</th>
<th>电感量</th>
<th>形状</th>
<th>尺寸 (cm)</th>
<th>圈数</th>
<th>电阻 (Ω)</th>
<th>材质</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- 编辑弹窗 -->
<div id="edit-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeModal()">
<div class="modal-box">
<h3 id="modal-title">新增线圈参数</h3>
<div class="modal-form">
<div class="form-group">
<label>线圈编号 *</label>
<input type="text" id="edit-coil-num">
</div>
<div class="form-group">
<label>名称</label>
<input type="text" id="edit-name">
</div>
<div class="form-group">
<label>电感量</label>
<input type="number" id="edit-induct" step="0.01" value="0">
</div>
<div class="form-group">
<label>形状</label>
<select id="edit-shape">
<option value="">-- 请选择 --</option>
<option value="矩形">矩形</option>
<option value="圆形">圆形</option>
</select>
</div>
<div class="form-group">
<label>长度 (cm矩形有效)</label>
<input type="number" id="edit-length" step="0.1" value="0">
</div>
<div class="form-group">
<label>宽度 (cm矩形有效)</label>
<input type="number" id="edit-width" step="0.1" value="0">
</div>
<div class="form-group">
<label>半径 (cm圆形有效)</label>
<input type="number" id="edit-radius" step="0.1" value="0">
</div>
<div class="form-group">
<label>圈数</label>
<input type="number" id="edit-turns" value="0">
</div>
<div class="form-group">
<label>电阻 (Ω)</label>
<input type="number" id="edit-resistance" step="0.01" value="0">
</div>
<div class="form-group">
<label>材质</label>
<input type="text" id="edit-material" placeholder="如铜线">
</div>
<div class="form-group full">
<label>备注</label>
<textarea id="edit-remark" rows="2" style="resize:vertical;"></textarea>
</div>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">取消</button>
<button class="btn-save" onclick="saveRecord()">保存</button>
</div>
</div>
</div>
<div id="toast" class="msg-toast"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/coil_info.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}设备日志 - EDC 工装管理系统{% endblock %}
{% block content %}
<h2>设备事件日志</h2>
<div class="search-bar">
<label>设备序列号:<input type="text" id="search-serial" placeholder="筛选设备..."></label>
<label>事件类型:
<select id="search-event">
<option value="">全部</option>
<option value="login">登录</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="poor">通信不良</option>
<option value="tcp_connect">TCP连接</option>
<option value="tcp_disconnect">TCP断开</option>
</select>
</label>
<label>
时间范围:
<input type="date" id="search-date-from">
<input type="time" id="search-time-from" step="1" style="width:110px;" title="起始时间(时:分:秒)">
<input type="date" id="search-date-to">
<input type="time" id="search-time-to" step="1" style="width:110px;" title="截止时间(时:分:秒)">
</label>
<button onclick="searchLogs(1)" class="btn-search">查询</button>
<button onclick="exportCSV()" class="btn-export">导出 CSV</button>
{% if current_user.role in ('admin', 'manager') %}
<button onclick="confirmDeleteLogs()" class="btn-delete">🗑 删除</button>
{% endif %}
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>设备序列号</th>
<th>设备IP</th>
<th>事件类型</th>
<th>事件内容</th>
<th>时间</th>
</tr>
</thead>
<tbody id="log-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1, totalPages = 1;
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; // 完整 datetime
}
async function searchLogs(page = 1) {
currentPage = page;
const serial = document.getElementById("search-serial").value;
const event_type = document.getElementById("search-event").value;
const date_from = getDatetime("search-date-from", "search-time-from");
const date_to = getDatetime("search-date-to", "search-time-to");
const params = new URLSearchParams({page, per_page: 30});
if (serial) params.set("serial", serial);
if (event_type) params.set("event_type", event_type);
if (date_from) params.set("date_from", date_from);
if (date_to) params.set("date_to", date_to);
const resp = await fetch(`/api/device-logs?${params}`);
const data = await resp.json();
renderTable(data.records);
totalPages = data.pages;
renderPagination();
}
function renderTable(records) {
const tbody = document.getElementById("log-tbody");
if (!records.length) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#999;">暂无记录</td></tr>';
return;
}
tbody.innerHTML = records.map(r => {
let typeStyle = '';
if (r.event_type === 'online' || r.event_type === 'login') typeStyle = 'color:#27ae60;font-weight:bold;';
else if (r.event_type === 'offline') typeStyle = 'color:#e74c3c;font-weight:bold;';
else if (r.event_type === 'poor') typeStyle = 'color:#f39c12;font-weight:bold;';
else if (r.event_type === 'tcp_disconnect') typeStyle = 'color:#e74c3c;';
else if (r.event_type === 'tcp_connect') typeStyle = 'color:#3498db;';
return `
<tr>
<td>${r.id}</td>
<td>${escHtml(r.device_serial || '-')}</td>
<td>${r.device_ip || '-'}</td>
<td style="${typeStyle}">${eventLabel(r.event_type)}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.event_content || '')}">${r.event_content || '-'}</td>
<td>${fmtTime(r.create_time)}</td>
</tr>`;
}).join("");
}
function eventLabel(t) {
const m = {login: '登录', online: '在线', offline: '离线', poor: '通信不良',
tcp_connect: 'TCP连接', tcp_disconnect: 'TCP断开'};
return m[t] || t;
}
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}`;
}
function renderPagination() {
const div = document.getElementById("pagination");
let html = `<button onclick="searchLogs(${currentPage-1})" ${currentPage<=1?'disabled':''}>上一页</button>`;
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="searchLogs(${i})" class="${i===currentPage?'active':''}">${i}</button>`;
}
html += `<button onclick="searchLogs(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}>下一页</button>`;
div.innerHTML = html;
}
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ─── 导出 CSV ────────────────────────────────────
function exportCSV() {
const serial = document.getElementById("search-serial").value;
const event_type = document.getElementById("search-event").value;
const date_from = getDatetime("search-date-from", "search-time-from");
const date_to = getDatetime("search-date-to", "search-time-to");
const params = new URLSearchParams();
if (serial) params.set("serial", serial);
if (event_type) params.set("event_type", event_type);
if (date_from) params.set("date_from", date_from);
if (date_to) params.set("date_to", date_to);
// 直接打开下载链接
window.location.href = `/api/device-logs/export?${params}`;
}
// ─── 删除 ────────────────────────────────────────
async function confirmDeleteLogs() {
const serial = document.getElementById("search-serial").value;
const event_type = document.getElementById("search-event").value;
const date_from = getDatetime("search-date-from", "search-time-from");
const date_to = getDatetime("search-date-to", "search-time-to");
if (!serial && !event_type && !date_from && !date_to) {
alert("请至少输入设备序列号、选择事件类型或指定时间范围作为删除条件");
return;
}
const msg = `确认删除设备日志?\n条件: serial=${serial || '(无)'} type=${event_type || '(无)'} time=${date_from||'(无)'}~${date_to||'(无)'}\n此操作不可撤销!`;
if (!confirm(msg)) return;
const resp = await fetch("/api/device-logs/delete", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({serial, event_type, date_from, date_to}),
});
const data = await resp.json();
if (data.ok) {
alert(`已删除 ${data.deleted} 条记录`);
searchLogs(1);
} else {
alert("删除失败: " + (data.error || "未知错误"));
}
}
searchLogs(1);
</script>
{% endblock %}

View File

@@ -20,5 +20,8 @@
{% endblock %}
{% block scripts %}
<script>
const USER_ROLE = "{{ current_user.role }}";
</script>
<script src="{{ url_for('static', filename='js/devices.js') }}"></script>
{% endblock %}

View File

@@ -27,15 +27,15 @@
<label>测试模式</label>
<select id="param-test-mode">
<option value="0">0 - 灵敏度测试模式</option>
<option value="1">1 - 模拟过车模式</option>
<option value="1">1 - 波动测试模式</option>
</select>
</div>
<div class="form-group">
<label>复位距离 (cm)</label>
<label>复位距离 (包含了皮距,mm)</label>
<input type="number" id="param-reset-dis" value="0" min="0">
</div>
<div class="form-group">
<label>皮距/开始距离 (cm)</label>
<label>皮距/开始距离 (mm)</label>
<input type="number" id="param-minus-dis" value="0" min="0">
</div>
<div class="form-group">
@@ -45,11 +45,11 @@
</select>
</div>
<div class="form-group">
<label>灵敏度最小值</label>
<label>触发距离最小值(mm)</label>
<input type="number" id="param-sens-min" value="0">
</div>
<div class="form-group">
<label>灵敏度最大值</label>
<label>释放距离最大值(mm)</label>
<input type="number" id="param-sens-max" value="0">
</div>
<div class="form-group">
@@ -61,13 +61,39 @@
<input type="number" id="param-fre-max" value="0">
</div>
<div class="form-group">
<label>峰峰值最小值</label>
<label>峰峰值最小值 (V)</label>
<input type="number" id="param-peak-min" value="0">
</div>
<div class="form-group">
<label>峰峰值最大值</label>
<label>峰峰值最大值 (V)</label>
<input type="number" id="param-peak-max" value="0">
</div>
<div class="form-group"><hr style="border-color:#eee; margin:2px 0;"></div>
<h4 style="margin:8px 0 4px 0; color:#e67e22; font-size:13px;">⚡ 波动测试参数 (TestMode=1 时生效)</h4>
<div class="form-group">
<label>最远容差 (mm)</label>
<input type="number" id="param-far-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>最近容差 (mm)</label>
<input type="number" id="param-near-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>步进容差 (mm)</label>
<input type="number" id="param-step-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>来回次数</label>
<input type="number" id="param-back-forth" value="0" min="0" max="255">
</div>
<div class="form-group">
<label>最近停留时间 (ms)</label>
<input type="number" id="param-near-stay" value="0" min="0" max="65535">
</div>
<div class="form-group">
<label>最远停留时间 (ms)</label>
<input type="number" id="param-far-stay" value="0" min="0" max="65535">
</div>
</div>
<div class="fixture-actions">
@@ -107,13 +133,49 @@
<table>
<thead>
<tr>
<th>编码</th><th>名称</th><th>灵敏度</th><th>频率(Hz)</th><th>峰峰值</th>
<th>编码</th><th>名称</th><th>触发和释放范围(mm)</th><th>频率(Hz)</th><th>峰峰值(V)</th>
</tr>
</thead>
<tbody id="ref-table-body"></tbody>
</table>
</div>
</div>
<!-- 线圈参数选择区 -->
<div class="fixture-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<h3 style="margin:0;">关联线圈参数</h3>
<button class="btn-config" style="padding:4px 12px; font-size:12px;"
onclick="location.href='/coil-info'">管理</button>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:12px; color:#666;">当前线圈:</label>
<span id="current-coil-label" style="font-weight:600; font-size:13px;">未设置</span>
</div>
<select id="coil-select" style="width:100%; padding:4px; border:1px solid #ddd; border-radius:4px; font-size:12px;"
onchange="onCoilChange()">
<option value="">-- 选择线圈 --</option>
</select>
<div id="coil-detail" style="font-size:12px; color:#888; margin-top:4px;"></div>
</div>
<!-- 模拟车辆参数选择区 -->
<div class="fixture-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<h3 style="margin:0;">关联模拟车辆参数</h3>
<button class="btn-config" style="padding:4px 12px; font-size:12px;"
onclick="location.href='/simulate-car'">管理</button>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:12px; color:#666;">当前车辆:</label>
<span id="current-car-label" style="font-weight:600; font-size:13px;">未设置</span>
</div>
<select id="car-select" style="width:100%; padding:4px; border:1px solid #ddd; border-radius:4px; font-size:12px;"
onchange="onCarChange()">
<option value="">-- 选择模拟车辆 --</option>
</select>
<div id="car-detail" style="font-size:12px; color:#888; margin-top:4px;"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}模拟车辆参数管理 - EDC 工装管理系统{% endblock %}
{% block content %}
<div class="test-header">
<a href="/">← 返回设备列表</a>
<h2>模拟车辆参数管理</h2>
</div>
<div class="fixture-card">
<div class="vbt-header">
<div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="search-input" placeholder="搜索编号/名称..."
style="padding:6px 10px; border:1px solid #ddd; border-radius:4px; font-size:13px; width:200px;"
oninput="loadList()">
</div>
<button class="btn-add" onclick="openModal()">+ 新增</button>
</div>
<table id="car-table">
<thead>
<tr>
<th>模拟编号</th>
<th>名称</th>
<th>形状</th>
<th>尺寸 (cm)</th>
<th>材质</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- 编辑弹窗 -->
<div id="edit-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeModal()">
<div class="modal-box">
<h3 id="modal-title">新增模拟车辆参数</h3>
<div class="modal-form">
<div class="form-group">
<label>模拟编号 *</label>
<input type="text" id="edit-simulate-num">
</div>
<div class="form-group">
<label>名称</label>
<input type="text" id="edit-name">
</div>
<div class="form-group">
<label>形状</label>
<select id="edit-shape">
<option value="">-- 请选择 --</option>
<option value="矩形">矩形</option>
<option value="圆形">圆形</option>
</select>
</div>
<div class="form-group">
<label>长度 (cm矩形有效)</label>
<input type="number" id="edit-length" step="0.1" value="0">
</div>
<div class="form-group">
<label>宽度 (cm矩形有效)</label>
<input type="number" id="edit-width" step="0.1" value="0">
</div>
<div class="form-group">
<label>半径 (cm圆形有效)</label>
<input type="number" id="edit-radius" step="0.1" value="0">
</div>
<div class="form-group">
<label>材质</label>
<input type="text" id="edit-material" placeholder="如铁板、合金">
</div>
<div class="form-group full">
<label>备注</label>
<textarea id="edit-remark" rows="2" style="resize:vertical;"></textarea>
</div>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">取消</button>
<button class="btn-save" onclick="saveRecord()">保存</button>
</div>
</div>
</div>
<div id="toast" class="msg-toast"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/simulate_car.js') }}"></script>
{% endblock %}

View File

@@ -4,49 +4,108 @@
{% block content %}
<h2>测试信息</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div style="background:#fef3e2;color:#b45309;padding:10px;border-radius:6px;margin-bottom:16px;">
{% for msg in messages %}{{ msg }}{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="view-tabs">
<button id="tab-all" class="tab-btn active" onclick="switchView('all')">全部数据</button>
<button id="tab-b2" class="tab-btn" onclick="switchView('b2')">灵敏度测试 (0xB2)</button>
<button id="tab-b4" class="tab-btn" onclick="switchView('b4')">波动测试 (0xB4)</button>
</div>
<div class="search-bar">
<label>
设备编码:
<input type="text" id="search-serial" placeholder="输入设备编码搜索...">
<input type="text" id="search-serial" placeholder="设备编码...">
</label>
<label>
日期范围
车检器序列号
<input type="text" id="search-detector-serial" placeholder="车检器序列号...">
</label>
<label>
时间范围:
<input type="date" id="search-date-from">
<input type="time" id="search-time-from" step="1" style="width:110px;" title="起始时间(时:分:秒)">
<input type="date" id="search-date-to">
<input type="time" id="search-time-to" step="1" style="width:110px;" title="截止时间(时:分:秒)">
</label>
<button onclick="searchData(1)" class="btn-search">搜索</button>
<button onclick="exportCSV()" class="btn-export">导出 CSV</button>
<label style="margin-left:16px;">
每页:
<select id="per-page" onchange="searchData(1)" style="width:70px;">
<option value="20">20</option>
<option value="50">50</option>
<option value="100" selected>100</option>
</select>
</label>
<button id="btn-chart" class="btn-chart" onclick="toggleChart()">📈 图表</button>
{% if current_user.role in ('admin', 'manager') %}
<button id="btn-delete" class="btn-delete" onclick="confirmDelete()">🗑 删除</button>
{% endif %}
</div>
<div id="chart-container" style="display:none; width:100%; height:500px; margin-bottom:16px;"></div>
<div style="overflow-x:auto; max-width:100%; -webkit-overflow-scrolling:touch;">
<table id="test-data-table">
<thead>
<tr>
<th>ID</th>
<th>设备编码</th>
<th>DG430地址</th>
<th>设备型号</th>
<th>类型</th>
<th>是否完成</th>
<th>故障信息</th>
<th>继电器</th>
<th>峰峰值(V)</th>
<th>开始频率(Hz)</th>
<th>进入频率(Hz)</th>
<th>离开频率(Hz)</th>
<th>进入距离(mm)</th>
<th>离开距离(mm)</th>
<th>进入速度(m/s)</th>
<th>离开速度(m/s)</th>
<th>时间</th>
</tr>
</thead>
<thead></thead>
<tbody></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
{% endblock %}
{% block scripts %}
<style>
.view-tabs { margin-bottom: 16px; display: flex; gap: 8px; }
.tab-btn {
padding: 8px 20px;
border: 1px solid #ccc;
background: #f5f5f5;
cursor: pointer;
border-radius: 4px 4px 0 0;
font-size: 14px;
transition: all .2s;
}
.tab-btn.active {
background: #2c3e50;
color: #fff;
border-color: #2c3e50;
}
.tab-btn:hover:not(.active) { background: #e0e0e0; }
.btn-chart {
margin-left: 16px;
padding: 6px 14px;
border: 1px solid #27ae60;
background: #fff;
color: #27ae60;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
}
.btn-chart.active { background: #27ae60; color: #fff; }
.btn-delete {
margin-left: 8px;
padding: 6px 14px;
border: 1px solid #e74c3c;
background: #fff;
color: #e74c3c;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
}
.btn-delete:hover { background: #e74c3c; color: #fff; }
#test-data-table th,
#test-data-table td { white-space: nowrap; }
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="{{ url_for('static', filename='js/test_data.js') }}"></script>
{% endblock %}

View File

@@ -6,6 +6,42 @@
<div class="test-header">
<a href="/">← 返回设备列表</a>
<h2>测试操作 — {{ device.serial }} ({{ device.name or '未命名' }})</h2>
<div id="device-status-bar" style="margin-top:4px;font-size:14px;">
设备状态:<span id="device-status-text" class="{% if device.state == 1 %}status-online{% elif device.state == 2 %}status-poor{% else %}status-offline{% endif %}">加载中…</span>
</div>
<div id="test-mode-indicator" style="margin-top:4px;font-size:14px;color:#888;display:none;">加载中…</div>
<div id="config-overview" style="margin-top:8px;background:#f8f9fa;border:1px solid #e0e0e0;border-radius:6px;padding:10px 14px;font-size:13px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<strong style="color:#555;">工装配置概览</strong>
<span id="config-toggle" style="cursor:pointer;color:#888;font-size:12px;user-select:none;" onclick="toggleConfig()">收起 ▲</span>
</div>
<div id="config-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;">
<div>测试模式:<span id="cfg-test-mode">-</span></div>
<div>车检器型号:<span id="cfg-dev-type">-</span></div>
<div>复位距离:<span id="cfg-reset-dis">-</span> mm</div>
<div>皮距:<span id="cfg-minus-dis">-</span> mm</div>
<div>触发和释放范围:<span id="cfg-sens-range">-</span> mm</div>
<div>频率范围:<span id="cfg-fre-range">-</span> Hz</div>
</div>
<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #ddd;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;">
<div>线圈:<span id="cfg-coil">-</span></div>
<div>模拟车辆:<span id="cfg-car">-</span></div>
</div>
</div>
<div id="cfg-wave-params" style="display:none;margin-top:6px;padding-top:6px;border-top:1px dashed #ddd;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px 16px;">
<div>最近容差:<span id="cfg-near-tol">-</span> mm</div>
<div>最远容差:<span id="cfg-far-tol">-</span> mm</div>
<div>步进容差:<span id="cfg-step-tol">-</span> mm</div>
<div>来回次数:<span id="cfg-back-forth">-</span></div>
<div>最近停留:<span id="cfg-near-stay">-</span> ms</div>
<div>最远停留:<span id="cfg-far-stay">-</span> ms</div>
</div>
</div>
</div>
</div>
</div>
<div class="test-layout">
@@ -22,9 +58,22 @@
<h3>自动化测试</h3>
<div class="automation">
<label>
车检器序列号:
<input type="text" id="detector-serial" placeholder="选填" style="width:180px;">
</label>
<br style="margin-bottom:8px;">
<label>
测试次数:
<input type="number" id="test-count" value="10" min="1" max="9999">
<input type="number" id="test-count" value="1" min="1" max="9999">
</label>
<label style="margin-left:16px;">
间隔时间(秒)
<input type="number" id="interval-sec" value="5" min="0" max="300" style="width:60px;">
</label>
<label style="margin-left:16px;">
超时时间(秒)
<input type="number" id="timeout-sec" value="4" min="1" max="600" style="width:60px;">
</label>
<button id="btn-auto" class="btn-start" onclick="toggleAuto()">开始</button>
<div class="progress-container">
@@ -36,6 +85,7 @@
<span>失败:<strong id="stat-failed">0</strong></span>
<span>剩余:<strong id="stat-remaining">0</strong></span>
</div>
<div class="auto-status" id="auto-status" style="font-size:12px;color:#888;margin-top:4px;"></div>
<div class="auto-time" id="auto-time" style="display:none;margin-top:8px;font-size:12px;color:#888;">
开始:<span id="time-start">-</span> &nbsp; 结束:<span id="time-end">-</span>
</div>
@@ -44,20 +94,27 @@
<!-- 右侧:测试信息显示区 -->
<div class="test-info">
<h3>当前测试数据</h3>
<h3>当前测试数据 <span id="new-record-count" style="font-size:12px;color:#888;font-weight:normal;margin-left:8px;"></span></h3>
<div id="latest-result">
<p class="placeholder">等待设备上报...</p>
</div>
<div id="wave-section">
<h3>波动测试数据</h3>
<div id="latest-wave">
<p class="placeholder">暂无波动数据...</p>
</div>
</div>
<h3>自动化平均值</h3>
<table id="avg-table">
<tr><td>平均峰峰值</td><td id="avg-ppvalue">-</td><td>V</td></tr>
<tr><td>平均开始工作频率</td><td id="avg-idle-freq">-</td><td>Hz</td></tr>
<tr><td>平均进入工作频率</td><td id="avg-enter-freq">-</td><td>Hz</td></tr>
<tr><td>平均进入距离</td><td id="avg-enter-dist">-</td><td>mm</td></tr>
<tr><td>平均离开距离</td><td id="avg-exit-dist">-</td><td>mm</td></tr>
<tr><td>平均进入速度</td><td id="avg-enter-speed">-</td><td>m/s</td></tr>
<tr><td>平均离开速度</td><td id="avg-exit-speed">-</td><td>m/s</td></tr>
<tr><td>平均开始频率</td><td id="avg-idle-freq">-</td><td>Hz</td></tr>
<tr><td>平均触发频率</td><td id="avg-enter-freq">-</td><td>Hz</td></tr>
<tr><td>平均触发距离</td><td id="avg-enter-dist">-</td><td>mm</td></tr>
<tr><td>平均释放距离</td><td id="avg-exit-dist">-</td><td>mm</td></tr>
<tr><td>平均触发速度</td><td id="avg-enter-speed">-</td><td>m/s</td></tr>
<tr><td>平均释放速度</td><td id="avg-exit-speed">-</td><td>m/s</td></tr>
</table>
<h3 style="margin-top:20px;">本轮测试明细</h3>
@@ -65,7 +122,7 @@
<table id="records-table" style="font-size:11px;">
<thead>
<tr>
<th>#</th><th>串口状态</th><th>峰峰值(V)</th><th>开始频率</th><th>进入距离</th><th>离开距离</th><th>速度(m/s)</th><th>时间</th>
<th>#</th><th>串口状态</th><th>模式</th><th>峰峰值(V)</th><th>开始频率</th><th>触发距离</th><th>释放距离</th><th>速度(m/s)</th><th>时间</th>
</tr>
</thead>
<tbody></tbody>

View File

@@ -12,6 +12,8 @@
<label>角色:
<select id="new-role" style="margin:0 8px;">
<option value="operator">operator</option>
<option value="analyst">analyst</option>
<option value="manager">manager</option>
<option value="admin">admin</option>
</select>
</label>
@@ -51,6 +53,8 @@ async function loadUsers() {
<td>
<select onchange="updateUser(${u.id}, this, 'role')" data-field="role">
<option value="operator" ${u.role==='operator'?'selected':''}>operator</option>
<option value="analyst" ${u.role==='analyst'?'selected':''}>analyst</option>
<option value="manager" ${u.role==='manager'?'selected':''}>manager</option>
<option value="admin" ${u.role==='admin'?'selected':''}>admin</option>
</select>
<select onchange="updateUser(${u.id}, this, 'is_active')" data-field="is_active">

View File

@@ -22,9 +22,9 @@
<tr>
<th>类型编码</th>
<th>型号/名称</th>
<th>灵敏度范围</th>
<th>触发和释放距离范围(mm)</th>
<th>频率范围 (Hz)</th>
<th>峰峰值范围</th>
<th>峰峰值范围(V)</th>
<th>备注</th>
<th>操作</th>
</tr>
@@ -47,11 +47,11 @@
<input type="text" id="edit-dev-name">
</div>
<div class="form-group">
<label>灵敏度最小值</label>
<label>触发距离最小值(mm)</label>
<input type="number" id="edit-sens-min" value="0">
</div>
<div class="form-group">
<label>灵敏度最大值</label>
<label>释放距离最大值(mm)</label>
<input type="number" id="edit-sens-max" value="0">
</div>
<div class="form-group">
@@ -63,11 +63,11 @@
<input type="number" id="edit-fre-max" value="0">
</div>
<div class="form-group">
<label>峰峰值最小值</label>
<label>峰峰值最小值(V)</label>
<input type="number" id="edit-peak-min" value="0">
</div>
<div class="form-group">
<label>峰峰值最大值</label>
<label>峰峰值最大值(V)</label>
<input type="number" id="edit-peak-max" value="0">
</div>
<div class="form-group full">

View File

@@ -1,2 +1,3 @@
flask>=3.0
pymysql>=1.1
flask>=3.1.0
flask-login>=0.6.0
pymysql>=1.2.0