Compare commits

...

33 Commits

Author SHA1 Message Date
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
34 changed files with 2937 additions and 136 deletions

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 |

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

@@ -168,10 +168,10 @@ def get_test_data(page: int = 1, per_page: int = 20,
params.append(f"%{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))
@@ -192,8 +192,13 @@ def get_test_data(page: int = 1, per_page: int = 20,
# data
offset = (page - 1) * per_page
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"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",
params + [per_page, offset],
@@ -223,10 +228,10 @@ def get_all_test_data_for_export(serial: str = "", date_from: str = "",
params.append(f"%{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))
@@ -236,8 +241,13 @@ def get_all_test_data_for_export(serial: str = "", date_from: str = "",
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"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",
params,
)
@@ -446,12 +456,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:
@@ -467,6 +489,7 @@ 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",
@@ -488,6 +511,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()
@@ -584,6 +618,172 @@ def delete_vehicle_base_test(test_id: int):
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 = "",
@@ -602,10 +802,10 @@ def delete_test_data(serial: str = "", date_from: str = "",
params.append(f"%{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 data_source:
where.append("t.data_source = %s")
params.append(data_source)
@@ -627,3 +827,118 @@ def delete_test_data(serial: str = "", date_from: str = "",
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()

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__)
@@ -104,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
@@ -123,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()
@@ -203,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})
@@ -275,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

@@ -10,12 +10,14 @@ 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)
@@ -38,6 +40,7 @@ def api_test_data():
@bp.route("/api/test-data/chart")
@login_required
def api_chart_data():
"""返回图表所需全部数据(不分页)"""
serial = request.args.get("serial", "", type=str)
@@ -51,6 +54,7 @@ def api_chart_data():
return jsonify({"records": records, "total": len(records)})
@bp.route("/api/test-data/export")
@login_required
def api_export():
"""导出测试数据为 CSV"""
serial = request.args.get("serial", "", type=str)
@@ -84,7 +88,7 @@ def api_export():
@login_required
def api_delete():
"""删除测试数据(仅 admin"""
if current_user.role != "admin":
if current_user.role not in ("admin", "manager"):
return jsonify({"ok": False, "error": "无权限"}), 403
data = request.get_json() or {}

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,15 +208,15 @@ 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;
@@ -152,10 +225,21 @@ function fillFormFromParam(param) {
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, '已刷新:从数据库加载工装参数');
@@ -168,44 +252,68 @@ 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,
FarTol: data.far_tol, NearTol: data.near_tol,
StepTol: data.step_tol, BackForth: data.back_forth,
NearStay: data.near_stay, FarStay: data.far_stay,
}),
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,
@@ -252,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);
}
@@ -324,19 +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,
FarTol: params.far_tol, NearTol: params.near_tol,
StepTol: params.step_tol, BackForth: params.back_forth,
NearStay: params.near_stay, FarStay: params.far_stay,
}),
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,5 +1,26 @@
// 测试信息页 — 三视图 (全部 / 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 = {
@@ -9,18 +30,34 @@ const VIEWS = {
cols: [
{ key: 'id', title: 'ID' },
{ key: 'serial', title: '设备编码' },
{ key: 'dpg430_addr', title: '地址' },
{ key: 'model', title: '型号', render: r => r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-' },
{ key: 'str_type', title: '类型' },
{ key: 'model', title: '型号', render: r => getDevTypeName(r.sub_type) },
{ key: 'data_source', title: '来源' },
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
{ key: 'ppvalue', title: '峰峰值(V)', render: r => r.ppvalue?.toFixed(2) || '-' },
{ key: 'idle_freq', title: '开始频率' },
{ key: 'enter_dist', title: '进入距离' },
{ key: 'exit_dist', title: '离开距离' },
{ key: 'remain_count', title: '剩余次数' },
{ key: 'curr_dist', title: '当前距离' },
{ key: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
{ 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: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
],
},
b2: {
@@ -29,21 +66,20 @@ const VIEWS = {
cols: [
{ key: 'id', title: 'ID' },
{ key: 'serial', title: '设备编码' },
{ key: 'dpg430_addr', title: '地址' },
{ key: 'model', title: '型号', render: r => r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-' },
{ key: 'str_type', title: '类型' },
{ key: 'model', title: '型号', render: r => getDevTypeName(r.sub_type) },
{ key: 'test_mode', title: '测试模式', render: r => r.test_mode === 1 ? '波动' : '灵敏度' },
{ key: 'iffinish', title: '完成', render: r => r.iffinish === '1' ? '是' : '否' },
{ key: 'fault_info', title: '故障信息' },
{ key: 'relay_out', title: '继电器', render: r => decodeRelay(r.relay_code) },
{ key: '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: '开始频率' },
{ key: 'enter_freq', title: '进入频率' },
{ key: 'exit_freq', title: '离开频率' },
{ key: 'enter_dist', title: '进入距离' },
{ key: 'exit_dist', title: '离开距离' },
{ 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: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
],
},
@@ -53,16 +89,16 @@ const VIEWS = {
cols: [
{ key: 'id', title: 'ID' },
{ key: 'serial', title: '设备编码' },
{ key: 'dpg430_addr', title: '地址' },
{ key: 'remain_count', title: '剩余次数' },
{ key: 'work_freq', title: '工作频率(Hz)' },
{ key: 'curr_dist', title: '当前距离(mm)' },
{ key: 'speed', title: '速度(dm/s)' },
{ key: 'near_dist', title: '最近距离(mm)' },
{ key: 'far_dist', title: '最远距离(mm)' },
{ key: 'b4_enter_dist', title: '进入高度(mm)' },
{ key: 'b4_leave_dist', title: '离开高度(mm)' },
{ key: 'relay_out', title: '继电器', render: r => decodeRelay(r.relay_code) },
{ key: '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: 'create_time', title: '时间', render: r => fmtTime(r.create_time) },
],
},
@@ -106,6 +142,29 @@ function decodeRelay(v) {
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) {
@@ -119,11 +178,20 @@ function switchView(view) {
// ─── 查询 ────────────────────────────────────────
/** 合并日期和时间输入框,返回 "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 dateFrom = getDatetime("search-date-from", "search-time-from");
const dateTo = getDatetime("search-date-to", "search-time-to");
const v = VIEWS[currentView];
const perPage = parseInt(document.getElementById("per-page").value) || 20;
@@ -200,8 +268,8 @@ function renderPagination() {
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 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();
@@ -293,8 +361,8 @@ async function loadChart() {
if (!container || container.style.display === 'none') return;
const serial = document.getElementById('search-serial').value;
const dateFrom = document.getElementById('search-date-from').value;
const dateTo = document.getElementById('search-date-to').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
@@ -382,7 +450,7 @@ async function loadChart() {
{ type: 'value', name: '距离(mm)', nameTextStyle: { fontSize: 11 } },
{ type: 'value', name: '速度(dm/s)',nameTextStyle: { fontSize: 11 },
offset: 80 },
{ type: 'value', name: '继电器输出', nameTextStyle: { fontSize: 11 },
{ type: 'value', name: '继电器', nameTextStyle: { fontSize: 11 },
min: -0.5, max: 3.5, interval: 1,
offset: 160,
axisLabel: {
@@ -410,14 +478,15 @@ async function loadChart() {
// ─── 初始加载 ────────────────────────────────────
renderHead();
searchData(1);
// 先加载型号名称再查询数据,确保型号列正确渲染
initDevTypeNames().then(() => searchData(1));
// ─── 删除admin─────────────────────────────────
function confirmDelete() {
const serial = document.getElementById('search-serial').value;
const dateFrom = document.getElementById('search-date-from').value;
const dateTo = document.getElementById('search-date-to').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 || '';

View File

@@ -7,6 +7,9 @@ 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 nextCmdTimer = null; // 间隔等待定时器
@@ -17,9 +20,22 @@ 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",
@@ -46,6 +62,7 @@ async function toggleAuto() {
}
async function startAuto() {
if (!checkDeviceOnline()) return;
const count = parseInt(document.getElementById("test-count").value) || 10;
if (count < 1) return;
@@ -256,7 +273,119 @@ async function pollProgress() {
}
// ─── 页面加载时获取初始数据 ──────────────────────
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();
try {
const resp = await fetch(`/api/automation/${DNT_ID}/progress`);
const data = await resp.json();
@@ -268,6 +397,39 @@ async function loadInitialData() {
}
loadInitialData();
// ─── 设备状态异步刷新 ──────────────────────────
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) {
// 静默失败
}
}
// 每 5 秒刷新设备状态 + 测试模式 + 型号名称缓存(工装页修改后能及时同步)
async function refreshAll() {
await loadDeviceTypeNames();
await loadTestMode();
refreshDeviceStatus();
}
setInterval(refreshAll, 5000);
// ─── UI ────────────────────────────────────────
function setStatus(msg) {
@@ -318,12 +480,24 @@ function decodeRelay(v) {
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>${typeName || '-'}</strong></p>
<p>测试模式:<strong>${data.test_mode === 1 ? '波动测试' : '灵敏度测试'}</strong></p>
<p>峰峰值:${data.ppvalue?.toFixed(2) || '-'} V</p>
<p>开始工作频率:${data.idle_freq || '-'} Hz</p>
@@ -335,7 +509,7 @@ function renderLatest(data) {
<p>离开速度:${toSpeed(data.exit_speed)} m/s</p>
<p>是否完成:${data.iffinish === '1' ? '是' : '否'}</p>
<p>故障信息:${data.fault_info || '无'}</p>
<p>继电器:${decodeRelay(data.relay_code)}</p>
<p>继电器:${fmtRelay(data.relay_out) || decodeRelay(data.relay_code)}</p>
<p>时间:${fmtTime(data.create_time)}</p>
`;
}
@@ -376,7 +550,7 @@ function renderLatestWave(data) {
<p>最远距离:${data.far_dist || '-'} mm</p>
<p>进入高度 (B4)${data.b4_enter_dist || '-'} mm</p>
<p>离开高度 (B4)${data.b4_leave_dist || '-'} mm</p>
<p>继电器:${decodeRelay(data.relay_code)}</p>
<p>继电器:${fmtRelay(data.relay_out) || decodeRelay(data.relay_code)}</p>
<p>时间:${fmtTime(data.create_time)}</p>
`;
}

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

@@ -31,11 +31,11 @@
</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,26 +61,26 @@
<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>最远容差 (cm)</label>
<input type="number" id="param-far-tol" value="0" min="0" max="255">
<label>最远容差 (mm)</label>
<input type="number" id="param-far-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>最近容差 (cm)</label>
<input type="number" id="param-near-tol" value="0" min="0" max="255">
<label>最近容差 (mm)</label>
<input type="number" id="param-near-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>步进容差 (cm)</label>
<input type="number" id="param-step-tol" value="0" min="0" max="255">
<label>步进容差 (mm)</label>
<input type="number" id="param-step-tol" value="0" min="0" max="2550">
</div>
<div class="form-group">
<label>来回次数</label>
@@ -133,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,6 +4,14 @@
{% 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>
@@ -16,10 +24,12 @@
<input type="text" id="search-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>
@@ -32,17 +42,19 @@
</select>
</label>
<button id="btn-chart" class="btn-chart" onclick="toggleChart()">📈 图表</button>
{% if current_user.role == 'admin' %}
{% 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></thead>
<tbody></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
{% endblock %}
@@ -87,6 +99,8 @@
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>

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">
@@ -58,10 +94,12 @@
<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">

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">