update panel
This commit is contained in:
+255
-249
@@ -1,250 +1,256 @@
|
||||
# B 端需求拆解与实现说明
|
||||
|
||||
## 1. 需求翻译
|
||||
|
||||
系统包含两个流程:
|
||||
|
||||
1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。
|
||||
2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。
|
||||
|
||||
辨识结果约定:
|
||||
|
||||
- `1`:参数通过,A 端结束本次辨识。
|
||||
- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。
|
||||
|
||||
## 2. 已实现内容
|
||||
|
||||
### Server 中转接口
|
||||
|
||||
- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。
|
||||
- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。
|
||||
- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。
|
||||
- `getIdentificationConfig`:A 端按设备读取函数 2 配置。
|
||||
- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。
|
||||
- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。
|
||||
- `ackPanelFile`:确认处理完成并删除 server 暂存文件。
|
||||
- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。
|
||||
|
||||
server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback`
|
||||
保存当前设备待消费的辨识反馈。
|
||||
|
||||
### B 端本地程序
|
||||
|
||||
- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。
|
||||
- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。
|
||||
- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。
|
||||
- 新 CSV 到达后自动下载并生成上下组合时序图。
|
||||
- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。
|
||||
- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。
|
||||
|
||||
## 3. 参数契约
|
||||
|
||||
函数 1,对应 `start_volume_measurement`:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 91,
|
||||
"dt": 0.1,
|
||||
"xa_full": 1000,
|
||||
"p_max": 200,
|
||||
"fit_low": 50,
|
||||
"fit_high": 200,
|
||||
"T_delta": 30,
|
||||
"num_runs": 6
|
||||
}
|
||||
```
|
||||
|
||||
函数 2,对应 `start_identification`:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 91,
|
||||
"dt": 0.1,
|
||||
"n_order": 8,
|
||||
"t_c": 2.5,
|
||||
"levels": [10, 20, 30, 40, 50, 60, 70, 80],
|
||||
"dead_area": 0,
|
||||
"xa_full": 1000,
|
||||
"V_val": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
```
|
||||
|
||||
示例数值仅用于联调,正式值需要算法或产品确认。
|
||||
|
||||
## 4. A 端调用契约
|
||||
|
||||
函数 1 使用一次性请求,不监听或扫描文件路径:
|
||||
|
||||
```json
|
||||
{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" }
|
||||
{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||
{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||
```
|
||||
|
||||
B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。
|
||||
|
||||
读取函数 2 参数:
|
||||
|
||||
```json
|
||||
{ "type": "getIdentificationConfig", "deviceId": "设备 ID" }
|
||||
```
|
||||
|
||||
查询某个辨识 CSV 的结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "getIdentificationFeedback",
|
||||
"deviceId": "设备 ID",
|
||||
"runId": "CSV 文件名"
|
||||
}
|
||||
```
|
||||
|
||||
未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。
|
||||
当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。
|
||||
|
||||
## 5. Server 部署
|
||||
|
||||
1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。
|
||||
2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。
|
||||
3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。
|
||||
4. CSV 处理完成后由 server 自动删除暂存文件。
|
||||
|
||||
## 6. B 端运行
|
||||
|
||||
PowerShell 环境变量:
|
||||
|
||||
```powershell
|
||||
$env:REINLOOP_API_URL="http://服务器地址:3000/api"
|
||||
$env:B_ADMIN_TOKEN="与 server 相同的管理令牌"
|
||||
$env:REINLOOP_DEVICE_ID="设备 ID"
|
||||
$env:POLL_INTERVAL_MS="1000"
|
||||
```
|
||||
|
||||
响应 A 端当前待处理的函数 1 参数请求:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js publish-volume .\volume-config.example.json
|
||||
```
|
||||
|
||||
发布函数 2 参数:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js publish-identification .\identification-config.example.json
|
||||
```
|
||||
|
||||
读取当前函数 2 参数:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js get-identification
|
||||
```
|
||||
|
||||
启动监听与评审:
|
||||
|
||||
```powershell
|
||||
npm start
|
||||
```
|
||||
|
||||
### Electron 图形界面
|
||||
|
||||
首次使用安装依赖:
|
||||
|
||||
```powershell
|
||||
cd ControlPanel
|
||||
npm install
|
||||
```
|
||||
|
||||
启动桌面应用:
|
||||
|
||||
```powershell
|
||||
npm run gui
|
||||
```
|
||||
|
||||
图形界面提供以下功能:
|
||||
|
||||
- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。
|
||||
- 打开并预览已有 PNG/JPG 绘图结果。
|
||||
- 导入或直接编辑容积测量、系统辨识 JSON 配置。
|
||||
- 读取当前系统辨识配置并发布新配置。
|
||||
- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。
|
||||
- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。
|
||||
|
||||
Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置
|
||||
`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求,
|
||||
不会保存到浏览器存储或配置文件。
|
||||
|
||||
### 打包 Windows EXE
|
||||
|
||||
在 `ControlPanel` 目录执行:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
构建结果输出到仓库根目录的 `Build` 文件夹:
|
||||
|
||||
- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。
|
||||
- 便携版 EXE:无需安装,可直接运行。
|
||||
- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。
|
||||
|
||||
应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除
|
||||
`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能
|
||||
显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。
|
||||
|
||||
## 7. 仍需产品/A 端确认
|
||||
|
||||
- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。
|
||||
- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。
|
||||
- 两套参数示例中的正式默认值、单位和合法范围尚未定义。
|
||||
- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。
|
||||
- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。
|
||||
- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。
|
||||
|
||||
## 8. 公司、产线、许可证与模型管理
|
||||
|
||||
Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的
|
||||
`deviceId` 固定为 `<company_code>/<production_line_code>`,配置发布、模型目录、
|
||||
绘图收件箱和辨识反馈均使用同一个值。
|
||||
|
||||
应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击
|
||||
“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查
|
||||
网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。
|
||||
连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接
|
||||
入口,需要切换 Server 或 Token 时重新启动应用。
|
||||
|
||||
公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后
|
||||
每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段,
|
||||
可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示
|
||||
“状态未知”,不会误报在线。
|
||||
|
||||
组织管理页支持:
|
||||
|
||||
- 添加公司,编码只允许小写字母、数字、下划线和连字符。
|
||||
- 在公司下添加产线;同一公司的产线编码必须唯一。
|
||||
- 刷新组织后,顶部公司和产线菜单同步更新。
|
||||
|
||||
许可证页支持:
|
||||
|
||||
- 根据当前公司和产线签发许可证。
|
||||
- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。
|
||||
- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。
|
||||
- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。
|
||||
- 查看已签发许可证详情和撤销许可证。
|
||||
|
||||
模型管理页按当前产线列出 `<deviceId>/model_config`,支持上传、下载和删除。
|
||||
|
||||
绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功
|
||||
发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论
|
||||
提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在
|
||||
绘图成功后直接确认。
|
||||
|
||||
Panel 当前依赖以下新增 Server type:
|
||||
|
||||
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
||||
`listLicenses`、`getLicense`、`revokeLicense`。既有模型和反馈接口继续使用。
|
||||
|
||||
## 9. 已知依赖风险
|
||||
|
||||
依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的
|
||||
# B 端需求拆解与实现说明
|
||||
|
||||
## 1. 需求翻译
|
||||
|
||||
系统包含两个流程:
|
||||
|
||||
1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。
|
||||
2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。
|
||||
|
||||
辨识结果约定:
|
||||
|
||||
- `1`:参数通过,A 端结束本次辨识。
|
||||
- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。
|
||||
|
||||
## 2. 已实现内容
|
||||
|
||||
### Server 中转接口
|
||||
|
||||
- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。
|
||||
- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。
|
||||
- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。
|
||||
- `getIdentificationConfig`:A 端按设备读取函数 2 配置。
|
||||
- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。
|
||||
- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。
|
||||
- `ackPanelFile`:确认处理完成并删除 server 暂存文件。
|
||||
- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。
|
||||
|
||||
server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback`
|
||||
保存当前设备待消费的辨识反馈。
|
||||
|
||||
### B 端本地程序
|
||||
|
||||
- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。
|
||||
- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。
|
||||
- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。
|
||||
- 新 CSV 到达后自动下载并生成上下组合时序图。
|
||||
- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。
|
||||
- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。
|
||||
|
||||
## 3. 参数契约
|
||||
|
||||
函数 1,对应 `start_volume_measurement`:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 91,
|
||||
"dt": 0.1,
|
||||
"xa_full": 1000,
|
||||
"p_max": 200,
|
||||
"fit_low": 50,
|
||||
"fit_high": 200,
|
||||
"T_delta": 30,
|
||||
"num_runs": 6
|
||||
}
|
||||
```
|
||||
|
||||
函数 2,对应 `start_identification`:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 91,
|
||||
"dt": 0.1,
|
||||
"n_order": 8,
|
||||
"t_c": 2.5,
|
||||
"levels": [10, 20, 30, 40, 50, 60, 70, 80],
|
||||
"dead_area": 0,
|
||||
"xa_full": 1000,
|
||||
"V_val": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
```
|
||||
|
||||
示例数值仅用于联调,正式值需要算法或产品确认。
|
||||
|
||||
## 4. A 端调用契约
|
||||
|
||||
函数 1 使用一次性请求,不监听或扫描文件路径:
|
||||
|
||||
```json
|
||||
{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" }
|
||||
{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||
{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||
```
|
||||
|
||||
B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。
|
||||
|
||||
读取函数 2 参数:
|
||||
|
||||
```json
|
||||
{ "type": "getIdentificationConfig", "deviceId": "设备 ID" }
|
||||
```
|
||||
|
||||
查询某个辨识 CSV 的结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "getIdentificationFeedback",
|
||||
"deviceId": "设备 ID",
|
||||
"runId": "CSV 文件名"
|
||||
}
|
||||
```
|
||||
|
||||
未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。
|
||||
当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。
|
||||
|
||||
## 5. Server 部署
|
||||
|
||||
1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。
|
||||
2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。
|
||||
3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。
|
||||
4. CSV 处理完成后由 server 自动删除暂存文件。
|
||||
|
||||
## 6. B 端运行
|
||||
|
||||
PowerShell 环境变量:
|
||||
|
||||
```powershell
|
||||
$env:REINLOOP_API_URL="http://服务器地址:3000/api"
|
||||
$env:B_ADMIN_TOKEN="与 server 相同的管理令牌"
|
||||
$env:REINLOOP_DEVICE_ID="设备 ID"
|
||||
$env:POLL_INTERVAL_MS="1000"
|
||||
```
|
||||
|
||||
响应 A 端当前待处理的函数 1 参数请求:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js publish-volume .\volume-config.example.json
|
||||
```
|
||||
|
||||
发布函数 2 参数:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js publish-identification .\identification-config.example.json
|
||||
```
|
||||
|
||||
读取当前函数 2 参数:
|
||||
|
||||
```powershell
|
||||
node .\b-admin.js get-identification
|
||||
```
|
||||
|
||||
启动监听与评审:
|
||||
|
||||
```powershell
|
||||
npm start
|
||||
```
|
||||
|
||||
### Electron 图形界面
|
||||
|
||||
首次使用安装依赖:
|
||||
|
||||
```powershell
|
||||
cd ControlPanel
|
||||
npm install
|
||||
```
|
||||
|
||||
启动桌面应用:
|
||||
|
||||
```powershell
|
||||
npm run gui
|
||||
```
|
||||
|
||||
图形界面提供以下功能:
|
||||
|
||||
- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。
|
||||
- 打开并预览已有 PNG/JPG 绘图结果。
|
||||
- 导入或直接编辑容积测量、系统辨识 JSON 配置。
|
||||
- 读取当前系统辨识配置并发布新配置。
|
||||
- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。
|
||||
- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。
|
||||
|
||||
Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置
|
||||
`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求,
|
||||
不会保存到浏览器存储或配置文件。
|
||||
|
||||
### 打包 Windows EXE
|
||||
|
||||
在 `ControlPanel` 目录执行:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
构建结果输出到仓库根目录的 `Build` 文件夹:
|
||||
|
||||
- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。
|
||||
- 便携版 EXE:无需安装,可直接运行。
|
||||
- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。
|
||||
|
||||
应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除
|
||||
`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能
|
||||
显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。
|
||||
|
||||
## 7. 仍需产品/A 端确认
|
||||
|
||||
- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。
|
||||
- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。
|
||||
- 两套参数示例中的正式默认值、单位和合法范围尚未定义。
|
||||
- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。
|
||||
- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。
|
||||
- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。
|
||||
|
||||
## 8. 公司、产线、许可证与模型管理
|
||||
|
||||
Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的
|
||||
`deviceId` 固定为 `<company_code>/<production_line_code>`,配置发布、模型目录、
|
||||
绘图收件箱和辨识反馈均使用同一个值。
|
||||
|
||||
应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击
|
||||
“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查
|
||||
网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。
|
||||
连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接
|
||||
入口,需要切换 Server 或 Token 时重新启动应用。
|
||||
|
||||
公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后
|
||||
每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段,
|
||||
可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示
|
||||
“状态未知”,不会误报在线。
|
||||
|
||||
组织管理页支持:
|
||||
|
||||
- 添加公司,编码只允许小写字母、数字、下划线和连字符。
|
||||
- 在公司下添加产线;同一公司的产线编码必须唯一。
|
||||
- 刷新组织后,顶部公司和产线菜单同步更新。
|
||||
|
||||
许可证页支持:
|
||||
|
||||
- 根据当前公司和产线签发许可证。
|
||||
- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。
|
||||
- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。
|
||||
- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。
|
||||
- 查看已签发许可证详情和撤销许可证。
|
||||
|
||||
模型管理页按当前产线列出 `<deviceId>/model_config`,支持上传、下载和删除。
|
||||
|
||||
绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功
|
||||
发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论
|
||||
提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在
|
||||
绘图成功后直接确认。
|
||||
|
||||
Panel 当前依赖以下新增 Server type:
|
||||
|
||||
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
||||
`listLicenses`、`getLicense`、`revokeLicense`、`listControlFiles`、
|
||||
`getControlFileDownload`、`deleteControlFile`。既有模型和反馈接口继续使用。
|
||||
|
||||
控制数据接口约定:`listControlFiles` 按 `deviceId` 分页返回控制结束后上传到
|
||||
`<deviceId>/data_record/` 的文件元数据;`getControlFileDownload` 按 `fileID` 返回带时效的
|
||||
下载 URL;`deleteControlFile` 按 `fileID` 删除文件和元数据。三者均应要求 B 端管理令牌,且
|
||||
服务端必须验证文件归属控制数据目录,避免使用该接口操作其他业务文件。
|
||||
|
||||
## 9. 已知依赖风险
|
||||
|
||||
依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的
|
||||
`npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。
|
||||
+390
-331
@@ -1,332 +1,391 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
|
||||
const { getConfig, publishConfig } = require("./b-admin");
|
||||
const { callServer, downloadFromUrl, downloadToPath } = require("./server-client");
|
||||
const { signLicense } = require("./license-manager");
|
||||
const { plotCsv } = require("./plot-csv");
|
||||
const { plotJson } = require("./plot-json");
|
||||
|
||||
const VOLUME_FIELDS = [
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
||||
];
|
||||
const DEFAULT_API_URL = "http://ReinLoop.dominatedconvergence.com/api";
|
||||
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
|
||||
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
|
||||
const connectionState = {
|
||||
apiUrl: API_URL,
|
||||
adminToken: process.env.B_ADMIN_TOKEN || "",
|
||||
deviceId: process.env.REINLOOP_DEVICE_ID || ""
|
||||
};
|
||||
let pendingReview = null;
|
||||
|
||||
function startPanelInboxPoller(window) {
|
||||
if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) {
|
||||
window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let polling = false;
|
||||
const poll = async () => {
|
||||
if (polling || window.isDestroyed()) return;
|
||||
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
||||
if (pendingReview) return;
|
||||
const requestContext = { ...connectionState };
|
||||
polling = true;
|
||||
try {
|
||||
const pending = await callServer(
|
||||
{ type: "getPendingPanelFile", deviceId: requestContext.deviceId },
|
||||
requestContext
|
||||
);
|
||||
if (!pending.pending) return;
|
||||
const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext);
|
||||
const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||
window.webContents.send("csv:updated", {
|
||||
filePath: imagePath,
|
||||
dataUrl: toDataUrl(imagePath),
|
||||
fileName: pending.fileName,
|
||||
mediaType: pending.mediaType,
|
||||
uploadTime: pending.uploadTime,
|
||||
deviceId: requestContext.deviceId,
|
||||
reviewable: pending.mediaType === "csv"
|
||||
});
|
||||
if (pending.mediaType === "csv") {
|
||||
pendingReview = {
|
||||
deviceId: requestContext.deviceId,
|
||||
fileID: pending.fileID,
|
||||
runId: pending.fileName,
|
||||
credentials: requestContext
|
||||
};
|
||||
} else {
|
||||
await callServer({
|
||||
type: "ackPanelFile",
|
||||
deviceId: requestContext.deviceId,
|
||||
fileID: pending.fileID
|
||||
}, requestContext);
|
||||
}
|
||||
} catch (error) {
|
||||
window.webContents.send("csv:watch-error", error.message);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
width: 1240,
|
||||
height: 820,
|
||||
minWidth: 960,
|
||||
minHeight: 680,
|
||||
backgroundColor: "#f2f4f1",
|
||||
title: "ReinLoop B 端工作台",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "electron-preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
|
||||
window.removeMenu();
|
||||
void window.loadFile(path.join(__dirname, "electron-ui", "index.html"));
|
||||
window.webContents.once("did-finish-load", () => {
|
||||
const stopPoller = startPanelInboxPoller(window);
|
||||
window.once("closed", stopPoller);
|
||||
});
|
||||
}
|
||||
|
||||
function validateParameters(configType, parameters) {
|
||||
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
||||
throw new Error("配置必须是 JSON 对象");
|
||||
}
|
||||
|
||||
if (configType === "volume") {
|
||||
const fields = Object.keys(parameters);
|
||||
const missing = VOLUME_FIELDS.filter((field) => !(field in parameters));
|
||||
const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field));
|
||||
if (missing.length || extra.length) {
|
||||
throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`);
|
||||
}
|
||||
for (const field of VOLUME_FIELDS) {
|
||||
if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) {
|
||||
throw new Error(`${field} 必须是有效数字`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(parameters.num_runs)) {
|
||||
throw new Error("num_runs 必须是整数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDataUrl(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png";
|
||||
return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
ipcMain.handle("app:get-defaults", () => ({
|
||||
apiUrl: API_URL,
|
||||
deviceId: process.env.REINLOOP_DEVICE_ID || "",
|
||||
hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN)
|
||||
}));
|
||||
|
||||
ipcMain.handle("image:show-in-folder", async (_event, filePath) => {
|
||||
if (filePath) shell.showItemInFolder(path.resolve(filePath));
|
||||
});
|
||||
|
||||
ipcMain.handle("connection:set", (_event, request) => {
|
||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||
connectionState.deviceId = String(request.deviceId || "").trim();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("connection:test", async (_event, request) => {
|
||||
const result = await callServer({ type: "listOrganizations" }, request);
|
||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||
connectionState.deviceId = "";
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("organization:list", (_event, credentials) =>
|
||||
callServer({ type: "listOrganizations" }, credentials));
|
||||
ipcMain.handle("organization:create-company", (_event, request) =>
|
||||
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
|
||||
ipcMain.handle("organization:create-line", (_event, request) =>
|
||||
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials));
|
||||
|
||||
ipcMain.handle("license:issue", async (_event, request) => {
|
||||
const keySelection = await dialog.showOpenDialog({
|
||||
title: "选择许可证 RSA 私钥",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }]
|
||||
});
|
||||
if (keySelection.canceled) return null;
|
||||
const saveSelection = await dialog.showSaveDialog({
|
||||
title: "保存签发的许可证",
|
||||
defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`,
|
||||
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||
});
|
||||
if (saveSelection.canceled) return null;
|
||||
const signed = signLicense({
|
||||
customer: request.customer,
|
||||
company_id: request.companyId,
|
||||
production_line_id: request.productionLineId,
|
||||
device_id: request.deviceId,
|
||||
issued: request.issued,
|
||||
expiry: request.expiry,
|
||||
features: request.features
|
||||
}, keySelection.filePaths[0]);
|
||||
await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
const result = await callServer({
|
||||
type: "createLicense", licenseId: signed.payload.license_id,
|
||||
companyId: request.companyId, productionLineId: request.productionLineId,
|
||||
customer: request.customer, issued: request.issued, expiry: request.expiry,
|
||||
features: request.features, license: signed.content
|
||||
}, request.credentials);
|
||||
return { ...result, filePath: saveSelection.filePath };
|
||||
} catch (error) {
|
||||
await fs.promises.rm(saveSelection.filePath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("license:list", (_event, credentials) =>
|
||||
callServer({ type: "listLicenses" }, credentials));
|
||||
ipcMain.handle("license:get", (_event, request) =>
|
||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||
ipcMain.handle("license:revoke", (_event, request) =>
|
||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||
|
||||
ipcMain.handle("review:submit", async (_event, request) => {
|
||||
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
||||
throw new Error("待审核记录已变化,请等待 Panel 重新载入数据");
|
||||
}
|
||||
const result = await callServer({
|
||||
type: "setIdentificationFeedback",
|
||||
deviceId: request.deviceId,
|
||||
runId: request.runId,
|
||||
result: request.result
|
||||
}, request.credentials);
|
||||
await callServer({
|
||||
type: "ackPanelFile",
|
||||
deviceId: pendingReview.deviceId,
|
||||
fileID: pendingReview.fileID
|
||||
}, pendingReview.credentials);
|
||||
pendingReview = null;
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("model:list", (_event, request) =>
|
||||
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
||||
ipcMain.handle("model:choose-upload-file", async () => {
|
||||
const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] });
|
||||
if (selection.canceled) return null;
|
||||
const sourcePath = selection.filePaths[0];
|
||||
return { sourcePath, fileName: path.basename(sourcePath) };
|
||||
});
|
||||
ipcMain.handle("model:upload", async (_event, request) => {
|
||||
if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件");
|
||||
const sourcePath = path.resolve(request.sourcePath);
|
||||
const fileName = path.basename(request.fileName);
|
||||
await fs.promises.access(sourcePath, fs.constants.R_OK);
|
||||
const issued = await callServer({
|
||||
type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`,
|
||||
overwrite: request.overwrite === true
|
||||
}, request.credentials);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName);
|
||||
const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form });
|
||||
if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`);
|
||||
return { success: true, fileID: issued.fileID, fileName };
|
||||
});
|
||||
ipcMain.handle("model:download", async (_event, request) => {
|
||||
const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials);
|
||||
return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) };
|
||||
});
|
||||
ipcMain.handle("model:delete", (_event, request) =>
|
||||
callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials));
|
||||
|
||||
ipcMain.handle("identification:list", (_event, request) =>
|
||||
callServer({
|
||||
type: "listIdentificationFiles",
|
||||
deviceId: request.deviceId,
|
||||
mediaType: request.mediaType,
|
||||
status: request.status,
|
||||
page: request.page,
|
||||
pageSize: request.pageSize
|
||||
}, request.credentials));
|
||||
ipcMain.handle("identification:preview", async (_event, request) => {
|
||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials);
|
||||
const mediaType = download.mediaType || request.mediaType;
|
||||
const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||
return {
|
||||
filePath: imagePath,
|
||||
dataUrl: toDataUrl(imagePath),
|
||||
fileName: download.fileName || request.fileName,
|
||||
mediaType,
|
||||
uploadTime: download.uploadTime || request.uploadTime,
|
||||
deviceId: request.deviceId
|
||||
};
|
||||
});
|
||||
ipcMain.handle("identification:download", async (_event, request) => {
|
||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const fileName = download.fileName || request.fileName;
|
||||
const selection = await dialog.showSaveDialog({
|
||||
title: "保存辨识原始数据",
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }]
|
||||
});
|
||||
if (selection.canceled) return null;
|
||||
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||
return { filePath: selection.filePath };
|
||||
});
|
||||
ipcMain.handle("identification:delete", (_event, request) =>
|
||||
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
||||
|
||||
ipcMain.handle("config:choose", async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "导入配置 JSON",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "JSON 配置", extensions: ["json"] }]
|
||||
});
|
||||
if (result.canceled) return null;
|
||||
const filePath = result.filePaths[0];
|
||||
const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf8"));
|
||||
return { filePath, parameters: parsed.parameters || parsed };
|
||||
});
|
||||
|
||||
ipcMain.handle("config:publish", async (_event, request) => {
|
||||
validateParameters(request.configType, request.parameters);
|
||||
const result = await publishConfig(request.configType, request.parameters, request.credentials);
|
||||
return {
|
||||
storagePath: result.fileID || null,
|
||||
message: request.configType === "volume" ? "容积配置已提交给请求设备" : "辨识配置已发布"
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("config:get", async (_event, request) => {
|
||||
const result = await getConfig(request.configType, request.credentials);
|
||||
return result.parameters || result.config?.parameters || result.config || result;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerHandlers();
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
|
||||
const { getConfig, publishConfig } = require("./b-admin");
|
||||
const { callServer, downloadFromUrl, downloadToPath } = require("./server-client");
|
||||
const { signLicense } = require("./license-manager");
|
||||
const { plotCsv } = require("./plot-csv");
|
||||
const { plotJson } = require("./plot-json");
|
||||
|
||||
const VOLUME_FIELDS = [
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
||||
];
|
||||
const DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com";
|
||||
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
|
||||
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
|
||||
const connectionState = {
|
||||
apiUrl: API_URL,
|
||||
adminToken: process.env.B_ADMIN_TOKEN || "",
|
||||
deviceId: process.env.REINLOOP_DEVICE_ID || ""
|
||||
};
|
||||
let pendingReview = null;
|
||||
|
||||
function startPanelInboxPoller(window) {
|
||||
if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) {
|
||||
window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let polling = false;
|
||||
const poll = async () => {
|
||||
if (polling || window.isDestroyed()) return;
|
||||
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
||||
if (pendingReview) return;
|
||||
const requestContext = { ...connectionState };
|
||||
polling = true;
|
||||
try {
|
||||
const pending = await callServer(
|
||||
{ type: "getPendingPanelFile", deviceId: requestContext.deviceId },
|
||||
requestContext
|
||||
);
|
||||
if (!pending.pending) return;
|
||||
const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext);
|
||||
const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||
window.webContents.send("csv:updated", {
|
||||
filePath: imagePath,
|
||||
dataUrl: toDataUrl(imagePath),
|
||||
fileName: pending.fileName,
|
||||
mediaType: pending.mediaType,
|
||||
uploadTime: pending.uploadTime,
|
||||
deviceId: requestContext.deviceId,
|
||||
reviewable: pending.mediaType === "csv"
|
||||
});
|
||||
if (pending.mediaType === "csv") {
|
||||
pendingReview = {
|
||||
deviceId: requestContext.deviceId,
|
||||
fileID: pending.fileID,
|
||||
runId: pending.fileName,
|
||||
credentials: requestContext
|
||||
};
|
||||
} else {
|
||||
await callServer({
|
||||
type: "ackPanelFile",
|
||||
deviceId: requestContext.deviceId,
|
||||
fileID: pending.fileID
|
||||
}, requestContext);
|
||||
}
|
||||
} catch (error) {
|
||||
window.webContents.send("csv:watch-error", error.message);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
width: 1240,
|
||||
height: 820,
|
||||
minWidth: 960,
|
||||
minHeight: 680,
|
||||
backgroundColor: "#f2f4f1",
|
||||
title: "ReinLoop B 端工作台",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "electron-preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
|
||||
window.removeMenu();
|
||||
void window.loadFile(path.join(__dirname, "electron-ui", "index.html"));
|
||||
window.webContents.once("did-finish-load", () => {
|
||||
const stopPoller = startPanelInboxPoller(window);
|
||||
window.once("closed", stopPoller);
|
||||
});
|
||||
}
|
||||
|
||||
function validateParameters(configType, parameters) {
|
||||
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
||||
throw new Error("配置必须是 JSON 对象");
|
||||
}
|
||||
|
||||
if (configType === "volume") {
|
||||
const fields = Object.keys(parameters);
|
||||
const missing = VOLUME_FIELDS.filter((field) => !(field in parameters));
|
||||
const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field));
|
||||
if (missing.length || extra.length) {
|
||||
throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`);
|
||||
}
|
||||
for (const field of VOLUME_FIELDS) {
|
||||
if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) {
|
||||
throw new Error(`${field} 必须是有效数字`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(parameters.num_runs)) {
|
||||
throw new Error("num_runs 必须是整数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDataUrl(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png";
|
||||
return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
ipcMain.handle("app:get-defaults", () => ({
|
||||
apiUrl: API_URL,
|
||||
deviceId: process.env.REINLOOP_DEVICE_ID || "",
|
||||
hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN)
|
||||
}));
|
||||
|
||||
ipcMain.handle("image:show-in-folder", async (_event, filePath) => {
|
||||
if (filePath) shell.showItemInFolder(path.resolve(filePath));
|
||||
});
|
||||
|
||||
ipcMain.handle("connection:set", (_event, request) => {
|
||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||
connectionState.deviceId = String(request.deviceId || "").trim();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("connection:test", async (_event, request) => {
|
||||
const result = await callServer({ type: "listOrganizations" }, request);
|
||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||
connectionState.deviceId = "";
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("organization:list", (_event, credentials) =>
|
||||
callServer({ type: "listOrganizations" }, credentials));
|
||||
ipcMain.handle("organization:create-company", (_event, request) =>
|
||||
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
|
||||
ipcMain.handle("organization:create-line", (_event, request) =>
|
||||
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials));
|
||||
|
||||
ipcMain.handle("license:issue", async (_event, request) => {
|
||||
const keySelection = await dialog.showOpenDialog({
|
||||
title: "选择许可证 RSA 私钥",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }]
|
||||
});
|
||||
if (keySelection.canceled) return null;
|
||||
const saveSelection = await dialog.showSaveDialog({
|
||||
title: "保存签发的许可证",
|
||||
defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`,
|
||||
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||
});
|
||||
if (saveSelection.canceled) return null;
|
||||
const signed = signLicense({
|
||||
customer: request.customer,
|
||||
company_id: request.companyId,
|
||||
production_line_id: request.productionLineId,
|
||||
device_id: request.deviceId,
|
||||
issued: request.issued,
|
||||
expiry: request.expiry,
|
||||
features: request.features
|
||||
}, keySelection.filePaths[0]);
|
||||
await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
const result = await callServer({
|
||||
type: "createLicense", licenseId: signed.payload.license_id,
|
||||
companyId: request.companyId, productionLineId: request.productionLineId,
|
||||
customer: request.customer, issued: request.issued, expiry: request.expiry,
|
||||
features: request.features, license: signed.content
|
||||
}, request.credentials);
|
||||
return { ...result, filePath: saveSelection.filePath };
|
||||
} catch (error) {
|
||||
await fs.promises.rm(saveSelection.filePath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("license:list", (_event, credentials) =>
|
||||
callServer({ type: "listLicenses" }, credentials));
|
||||
ipcMain.handle("license:get", (_event, request) =>
|
||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||
ipcMain.handle("license:revoke", (_event, request) =>
|
||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||
ipcMain.handle("license:download", async (_event, request) => {
|
||||
const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials);
|
||||
if (typeof result.license?.license !== "string" || !result.license.license) {
|
||||
throw new Error("Server 未返回许可证原文,无法下载");
|
||||
}
|
||||
const selection = await dialog.showSaveDialog({
|
||||
title: "保存许可证",
|
||||
defaultPath: `${request.licenseId}.lic`,
|
||||
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||
});
|
||||
if (selection.canceled) return null;
|
||||
await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 });
|
||||
return { filePath: selection.filePath };
|
||||
});
|
||||
|
||||
ipcMain.handle("review:submit", async (_event, request) => {
|
||||
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
||||
throw new Error("待审核记录已变化,请等待 Panel 重新载入数据");
|
||||
}
|
||||
const result = await callServer({
|
||||
type: "setIdentificationFeedback",
|
||||
deviceId: request.deviceId,
|
||||
runId: request.runId,
|
||||
result: request.result
|
||||
}, request.credentials);
|
||||
await callServer({
|
||||
type: "ackPanelFile",
|
||||
deviceId: pendingReview.deviceId,
|
||||
fileID: pendingReview.fileID
|
||||
}, pendingReview.credentials);
|
||||
pendingReview = null;
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("model:list", (_event, request) =>
|
||||
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
||||
ipcMain.handle("model:choose-upload-file", async () => {
|
||||
const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] });
|
||||
if (selection.canceled) return null;
|
||||
const sourcePath = selection.filePaths[0];
|
||||
return { sourcePath, fileName: path.basename(sourcePath) };
|
||||
});
|
||||
ipcMain.handle("model:upload", async (_event, request) => {
|
||||
if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件");
|
||||
const sourcePath = path.resolve(request.sourcePath);
|
||||
const fileName = path.basename(request.fileName);
|
||||
await fs.promises.access(sourcePath, fs.constants.R_OK);
|
||||
const issued = await callServer({
|
||||
type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`,
|
||||
overwrite: request.overwrite === true
|
||||
}, request.credentials);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName);
|
||||
const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form });
|
||||
if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`);
|
||||
return { success: true, fileID: issued.fileID, fileName };
|
||||
});
|
||||
ipcMain.handle("model:download", async (_event, request) => {
|
||||
const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials);
|
||||
return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) };
|
||||
});
|
||||
ipcMain.handle("model:delete", (_event, request) =>
|
||||
callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials));
|
||||
|
||||
ipcMain.handle("identification:list", (_event, request) =>
|
||||
callServer({
|
||||
type: "listIdentificationFiles",
|
||||
deviceId: request.deviceId,
|
||||
mediaType: request.mediaType,
|
||||
status: request.status,
|
||||
page: request.page,
|
||||
pageSize: request.pageSize
|
||||
}, request.credentials));
|
||||
ipcMain.handle("identification:preview", async (_event, request) => {
|
||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials);
|
||||
const mediaType = download.mediaType || request.mediaType;
|
||||
const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||
return {
|
||||
filePath: imagePath,
|
||||
dataUrl: toDataUrl(imagePath),
|
||||
fileName: download.fileName || request.fileName,
|
||||
mediaType,
|
||||
uploadTime: download.uploadTime || request.uploadTime,
|
||||
deviceId: request.deviceId
|
||||
};
|
||||
});
|
||||
ipcMain.handle("identification:download", async (_event, request) => {
|
||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const fileName = download.fileName || request.fileName;
|
||||
const selection = await dialog.showSaveDialog({
|
||||
title: "保存辨识原始数据",
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }]
|
||||
});
|
||||
if (selection.canceled) return null;
|
||||
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||
return { filePath: selection.filePath };
|
||||
});
|
||||
ipcMain.handle("identification:delete", (_event, request) =>
|
||||
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
||||
|
||||
ipcMain.handle("control-data:list", (_event, request) =>
|
||||
callServer({
|
||||
type: "listControlFiles",
|
||||
deviceId: request.deviceId,
|
||||
page: request.page,
|
||||
pageSize: request.pageSize
|
||||
}, request.credentials));
|
||||
ipcMain.handle("control-data:preview", async (_event, request) => {
|
||||
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const fileName = download.fileName || request.fileName;
|
||||
const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials);
|
||||
const extension = path.extname(fileName).toLowerCase();
|
||||
let content = null;
|
||||
if (extension === ".json") {
|
||||
const text = await fs.promises.readFile(sourcePath, "utf8");
|
||||
try {
|
||||
content = JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch (_error) {
|
||||
content = text;
|
||||
}
|
||||
}
|
||||
return {
|
||||
filePath: sourcePath,
|
||||
fileName,
|
||||
uploadTime: download.uploadTime || request.uploadTime,
|
||||
size: download.size ?? request.size,
|
||||
content
|
||||
};
|
||||
});
|
||||
ipcMain.handle("control-data:download", async (_event, request) => {
|
||||
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||
const fileName = download.fileName || request.fileName;
|
||||
const extension = path.extname(fileName).replace(/^\./, "") || "bin";
|
||||
const selection = await dialog.showSaveDialog({
|
||||
title: "保存控制原始数据",
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: `${extension.toUpperCase()} 文件`, extensions: [extension] }]
|
||||
});
|
||||
if (selection.canceled) return null;
|
||||
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||
return { filePath: selection.filePath };
|
||||
});
|
||||
ipcMain.handle("control-data:delete", (_event, request) =>
|
||||
callServer({ type: "deleteControlFile", fileID: request.fileID }, request.credentials));
|
||||
|
||||
ipcMain.handle("config:choose", async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "导入配置 JSON",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "JSON 配置", extensions: ["json"] }]
|
||||
});
|
||||
if (result.canceled) return null;
|
||||
const filePath = result.filePaths[0];
|
||||
const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf8"));
|
||||
return { filePath, parameters: parsed.parameters || parsed };
|
||||
});
|
||||
|
||||
ipcMain.handle("config:publish", async (_event, request) => {
|
||||
validateParameters(request.configType, request.parameters);
|
||||
const result = await publishConfig(request.configType, request.parameters, request.credentials);
|
||||
return {
|
||||
storagePath: result.fileID || null,
|
||||
message: request.configType === "volume" ? "容积配置已提交给请求设备" : "辨识配置已发布"
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("config:get", async (_event, request) => {
|
||||
const result = await getConfig(request.configType, request.credentials);
|
||||
return result.parameters || result.config?.parameters || result.config || result;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerHandlers();
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
@@ -1,30 +1,35 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("reinloop", {
|
||||
getDefaults: () => ipcRenderer.invoke("app:get-defaults"),
|
||||
showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath),
|
||||
chooseConfig: () => ipcRenderer.invoke("config:choose"),
|
||||
publishConfig: (request) => ipcRenderer.invoke("config:publish", request),
|
||||
getConfig: (request) => ipcRenderer.invoke("config:get", request),
|
||||
setConnection: (request) => ipcRenderer.invoke("connection:set", request),
|
||||
testConnection: (request) => ipcRenderer.invoke("connection:test", request),
|
||||
listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials),
|
||||
createCompany: (request) => ipcRenderer.invoke("organization:create-company", request),
|
||||
createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request),
|
||||
issueLicense: (request) => ipcRenderer.invoke("license:issue", request),
|
||||
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
|
||||
getLicense: (request) => ipcRenderer.invoke("license:get", request),
|
||||
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
|
||||
submitReview: (request) => ipcRenderer.invoke("review:submit", request),
|
||||
listModels: (request) => ipcRenderer.invoke("model:list", request),
|
||||
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
|
||||
uploadModel: (request) => ipcRenderer.invoke("model:upload", request),
|
||||
downloadModel: (request) => ipcRenderer.invoke("model:download", request),
|
||||
deleteModel: (request) => ipcRenderer.invoke("model:delete", request),
|
||||
listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request),
|
||||
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
||||
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
||||
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
||||
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
|
||||
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message))
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("reinloop", {
|
||||
getDefaults: () => ipcRenderer.invoke("app:get-defaults"),
|
||||
showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath),
|
||||
chooseConfig: () => ipcRenderer.invoke("config:choose"),
|
||||
publishConfig: (request) => ipcRenderer.invoke("config:publish", request),
|
||||
getConfig: (request) => ipcRenderer.invoke("config:get", request),
|
||||
setConnection: (request) => ipcRenderer.invoke("connection:set", request),
|
||||
testConnection: (request) => ipcRenderer.invoke("connection:test", request),
|
||||
listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials),
|
||||
createCompany: (request) => ipcRenderer.invoke("organization:create-company", request),
|
||||
createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request),
|
||||
issueLicense: (request) => ipcRenderer.invoke("license:issue", request),
|
||||
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
|
||||
getLicense: (request) => ipcRenderer.invoke("license:get", request),
|
||||
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
|
||||
downloadLicense: (request) => ipcRenderer.invoke("license:download", request),
|
||||
submitReview: (request) => ipcRenderer.invoke("review:submit", request),
|
||||
listModels: (request) => ipcRenderer.invoke("model:list", request),
|
||||
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
|
||||
uploadModel: (request) => ipcRenderer.invoke("model:upload", request),
|
||||
downloadModel: (request) => ipcRenderer.invoke("model:download", request),
|
||||
deleteModel: (request) => ipcRenderer.invoke("model:delete", request),
|
||||
listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request),
|
||||
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
||||
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
||||
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
||||
listControlFiles: (request) => ipcRenderer.invoke("control-data:list", request),
|
||||
previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request),
|
||||
downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request),
|
||||
deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request),
|
||||
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
|
||||
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message))
|
||||
});
|
||||
+231
-218
@@ -1,219 +1,232 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'">
|
||||
<title>ReinLoop B 端工作台</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">REINLOOP / B CONSOLE</p>
|
||||
<h1>数据评审工作台</h1>
|
||||
</div>
|
||||
<div id="status" class="status" data-tone="idle">未连接</div>
|
||||
</header>
|
||||
|
||||
<section id="connection-screen" class="connection-screen" aria-labelledby="connection-title">
|
||||
<form id="connection-form" class="connection-form">
|
||||
<p class="section-kicker">SERVER ACCESS</p>
|
||||
<h2 id="connection-title">连接管理服务</h2>
|
||||
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com/api" required></label>
|
||||
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
||||
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
||||
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<main id="workspace" hidden>
|
||||
<section class="connection-band" aria-label="当前业务目标">
|
||||
<label>
|
||||
<span>公司</span>
|
||||
<select id="company-select" aria-label="选择公司"><option value="">请先刷新组织</option></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>产线</span>
|
||||
<select id="device-id" aria-label="选择产线"><option value="">请选择产线</option></select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<nav class="tabs" aria-label="功能切换">
|
||||
<button class="tab active" data-target="plot-panel">绘图预览</button>
|
||||
<button class="tab" data-target="identification-data-panel">辨识数据</button>
|
||||
<button class="tab" data-target="config-panel">配置发布</button>
|
||||
<button class="tab" data-target="model-panel">模型管理</button>
|
||||
<button class="tab" data-target="license-panel">许可证</button>
|
||||
<button class="tab" data-target="organization-panel">组织管理</button>
|
||||
</nav>
|
||||
|
||||
<section id="plot-panel" class="panel active">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">IDENTIFICATION REVIEW</p>
|
||||
<h2>数据曲线</h2>
|
||||
</div>
|
||||
<div class="review-actions">
|
||||
<span id="review-target">等待辨识结果</span>
|
||||
<button id="reject-review" class="button danger" disabled>未通过</button>
|
||||
<button id="approve-review" class="button primary" disabled>通过</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plot-grid">
|
||||
<article class="plot-item">
|
||||
<div class="plot-title">
|
||||
<div>
|
||||
<span>辨识 CSV</span>
|
||||
<strong>阀门开度与压力</strong>
|
||||
</div>
|
||||
<div class="plot-actions"><button class="button icon" data-open-plot="csv" title="放大图像" disabled aria-label="放大辨识图">+</button><button id="show-csv-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示辨识图">↗</button></div>
|
||||
</div>
|
||||
<div class="plot-stage">
|
||||
<div id="empty-csv-plot" class="empty-state">
|
||||
<strong>尚未载入辨识曲线</strong>
|
||||
<span>等待 ReinLoop 上传 CSV</span>
|
||||
</div>
|
||||
<img id="csv-plot-image" alt="辨识 CSV 绘图预览" hidden>
|
||||
</div>
|
||||
<div class="plot-meta">
|
||||
<p id="csv-image-path" class="file-path">未接收 CSV</p>
|
||||
<p id="csv-upload-time" class="upload-time">等待数据</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="plot-item">
|
||||
<div class="plot-title">
|
||||
<div>
|
||||
<span>行程 JSON</span>
|
||||
<strong>行程与稳态压力</strong>
|
||||
</div>
|
||||
<div class="plot-actions"><button class="button icon" data-open-plot="json" title="放大图像" disabled aria-label="放大行程图">+</button><button id="show-json-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示行程图">↗</button></div>
|
||||
</div>
|
||||
<div class="plot-stage">
|
||||
<div id="empty-json-plot" class="empty-state">
|
||||
<strong>尚未载入行程曲线</strong>
|
||||
<span>等待 ReinLoop 上传行程 JSON</span>
|
||||
</div>
|
||||
<img id="json-plot-image" alt="行程稳态压力绘图预览" hidden>
|
||||
</div>
|
||||
<div class="plot-meta">
|
||||
<p id="json-image-path" class="file-path">未接收行程 JSON</p>
|
||||
<p id="json-upload-time" class="upload-time">等待数据</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="identification-data-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">IDENTIFICATION ARCHIVE</p><h2>辨识数据暂存</h2></div>
|
||||
<button id="refresh-identification-files" class="button secondary">刷新</button>
|
||||
</div>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
|
||||
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="config-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">FUNCTION PARAMETERS</p>
|
||||
<h2>配置数据</h2>
|
||||
</div>
|
||||
<div class="segmented" aria-label="配置类型">
|
||||
<button class="segment active" data-type="volume">容积测量</button>
|
||||
<button class="segment" data-type="identification">系统辨识</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-layout">
|
||||
<div class="editor-column">
|
||||
<div class="editor-toolbar">
|
||||
<span id="config-label">容积测量配置</span>
|
||||
<button id="import-config" class="text-button">导入 JSON</button>
|
||||
</div>
|
||||
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea>
|
||||
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p>
|
||||
</div>
|
||||
<aside class="publish-aside">
|
||||
<h3>发布检查</h3>
|
||||
<dl>
|
||||
<div><dt>目标</dt><dd id="publish-target">Server 配置文件</dd></div>
|
||||
<div><dt>格式</dt><dd>JSON</dd></div>
|
||||
<div><dt>鉴权</dt><dd>Admin Token</dd></div>
|
||||
</dl>
|
||||
<button id="load-server" class="button secondary full">读取 Server 配置</button>
|
||||
<button id="publish-config" class="button primary full">上传容积配置</button>
|
||||
<p id="publish-result" class="result">等待操作</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="model-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">MODEL CONTROL</p><h2>产线模型</h2></div>
|
||||
<div class="actions">
|
||||
<button id="refresh-models" class="button secondary">刷新</button>
|
||||
<button id="upload-model" class="button primary">上传模型</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
|
||||
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="license-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">LICENSE REGISTRY</p><h2>许可证签发与管理</h2></div>
|
||||
<button id="refresh-licenses" class="button secondary">刷新列表</button>
|
||||
</div>
|
||||
<div class="management-layout">
|
||||
<form id="license-form" class="form-surface">
|
||||
<h3>签发许可证</h3>
|
||||
<label><span>当前目标</span><input id="license-target" readonly placeholder="请先选择公司和产线"></label>
|
||||
<label><span>签发时间</span><input id="license-issued" type="datetime-local" required></label>
|
||||
<label><span>到期时间</span><input id="license-expiry" type="datetime-local" required></label>
|
||||
<label><span>功能</span><input id="license-features" value="*" required></label>
|
||||
<button class="button primary" type="submit">选择私钥并签发</button>
|
||||
<p class="form-note">许可证保存到本地后同步登记到 Server;私钥不会上传或保存。</p>
|
||||
</form>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>公司 / 产线</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="license-list"></tbody></table>
|
||||
<p id="license-empty" class="empty-row">尚未读取许可证</p>
|
||||
<pre id="license-detail" class="detail-view" hidden></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="organization-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">ORGANIZATION</p><h2>公司与产线</h2></div>
|
||||
<button id="refresh-organizations" class="button secondary">刷新组织</button>
|
||||
</div>
|
||||
<div class="management-layout equal">
|
||||
<form id="company-form" class="form-surface">
|
||||
<h3>添加公司</h3>
|
||||
<label><span>公司名称</span><input id="company-name" required></label>
|
||||
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||
<button class="button primary" type="submit">添加公司</button>
|
||||
</form>
|
||||
<form id="line-form" class="form-surface">
|
||||
<h3>添加产线</h3>
|
||||
<label><span>所属公司</span><select id="line-company" required><option value="">请选择公司</option></select></label>
|
||||
<label><span>产线名称</span><input id="line-name" required></label>
|
||||
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||
<button class="button primary" type="submit">添加产线</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
|
||||
<div class="lightbox-shell">
|
||||
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
|
||||
<div id="lightbox-canvas" class="lightbox-canvas"><img id="lightbox-image" alt="放大的图像预览"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'">
|
||||
<title>ReinLoop B 端工作台</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">REINLOOP / B CONSOLE</p>
|
||||
<h1>数据评审工作台</h1>
|
||||
</div>
|
||||
<div id="status" class="status" data-tone="idle">未连接</div>
|
||||
</header>
|
||||
|
||||
<section id="connection-screen" class="connection-screen" aria-labelledby="connection-title">
|
||||
<form id="connection-form" class="connection-form">
|
||||
<p class="section-kicker">SERVER ACCESS</p>
|
||||
<h2 id="connection-title">连接管理服务</h2>
|
||||
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com" required></label>
|
||||
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
||||
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
||||
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<main id="workspace" hidden>
|
||||
<section class="connection-band" aria-label="当前业务目标">
|
||||
<label>
|
||||
<span>公司</span>
|
||||
<select id="company-select" aria-label="选择公司"><option value="">请先刷新组织</option></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>产线</span>
|
||||
<select id="device-id" aria-label="选择产线"><option value="">请选择产线</option></select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<nav class="tabs" aria-label="功能切换">
|
||||
<button class="tab active" data-target="plot-panel">绘图预览</button>
|
||||
<button class="tab" data-target="identification-data-panel">辨识数据</button>
|
||||
<button class="tab" data-target="control-data-panel">控制数据</button>
|
||||
<button class="tab" data-target="config-panel">配置发布</button>
|
||||
<button class="tab" data-target="model-panel">模型管理</button>
|
||||
<button class="tab" data-target="license-panel">许可证</button>
|
||||
<button class="tab" data-target="organization-panel">组织管理</button>
|
||||
</nav>
|
||||
|
||||
<section id="plot-panel" class="panel active">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">IDENTIFICATION REVIEW</p>
|
||||
<h2>数据曲线</h2>
|
||||
</div>
|
||||
<div class="review-actions">
|
||||
<span id="review-target">等待辨识结果</span>
|
||||
<button id="reject-review" class="button danger" disabled>未通过</button>
|
||||
<button id="approve-review" class="button primary" disabled>通过</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plot-grid">
|
||||
<article class="plot-item">
|
||||
<div class="plot-title">
|
||||
<div>
|
||||
<span>辨识 CSV</span>
|
||||
<strong>阀门开度与压力</strong>
|
||||
</div>
|
||||
<div class="plot-actions"><button class="button icon" data-open-plot="csv" title="放大图像" disabled aria-label="放大辨识图">+</button><button id="show-csv-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示辨识图">↗</button></div>
|
||||
</div>
|
||||
<div class="plot-stage">
|
||||
<div id="empty-csv-plot" class="empty-state">
|
||||
<strong>尚未载入辨识曲线</strong>
|
||||
<span>等待 ReinLoop 上传 CSV</span>
|
||||
</div>
|
||||
<img id="csv-plot-image" alt="辨识 CSV 绘图预览" hidden>
|
||||
</div>
|
||||
<div class="plot-meta">
|
||||
<p id="csv-image-path" class="file-path">未接收 CSV</p>
|
||||
<p id="csv-upload-time" class="upload-time">等待数据</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="plot-item">
|
||||
<div class="plot-title">
|
||||
<div>
|
||||
<span>行程 JSON</span>
|
||||
<strong>行程与稳态压力</strong>
|
||||
</div>
|
||||
<div class="plot-actions"><button class="button icon" data-open-plot="json" title="放大图像" disabled aria-label="放大行程图">+</button><button id="show-json-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示行程图">↗</button></div>
|
||||
</div>
|
||||
<div class="plot-stage">
|
||||
<div id="empty-json-plot" class="empty-state">
|
||||
<strong>尚未载入行程曲线</strong>
|
||||
<span>等待 ReinLoop 上传行程 JSON</span>
|
||||
</div>
|
||||
<img id="json-plot-image" alt="行程稳态压力绘图预览" hidden>
|
||||
</div>
|
||||
<div class="plot-meta">
|
||||
<p id="json-image-path" class="file-path">未接收行程 JSON</p>
|
||||
<p id="json-upload-time" class="upload-time">等待数据</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="identification-data-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">IDENTIFICATION ARCHIVE</p><h2>辨识数据暂存</h2></div>
|
||||
<button id="refresh-identification-files" class="button secondary">刷新</button>
|
||||
</div>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
|
||||
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="control-data-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">CONTROL DATA ARCHIVE</p><h2>控制数据暂存</h2></div>
|
||||
<button id="refresh-control-files" class="button secondary">刷新</button>
|
||||
</div>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>操作</th></tr></thead><tbody id="control-file-list"></tbody></table>
|
||||
<p id="control-file-empty" class="empty-row">选择公司和产线后刷新控制数据</p>
|
||||
<pre id="control-file-detail" class="detail-view" hidden></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="config-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">FUNCTION PARAMETERS</p>
|
||||
<h2>配置数据</h2>
|
||||
</div>
|
||||
<div class="segmented" aria-label="配置类型">
|
||||
<button class="segment active" data-type="volume">容积测量</button>
|
||||
<button class="segment" data-type="identification">系统辨识</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-layout">
|
||||
<div class="editor-column">
|
||||
<div class="editor-toolbar">
|
||||
<span id="config-label">容积测量配置</span>
|
||||
<button id="import-config" class="text-button">导入 JSON</button>
|
||||
</div>
|
||||
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea>
|
||||
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p>
|
||||
</div>
|
||||
<aside class="publish-aside">
|
||||
<h3>发布检查</h3>
|
||||
<dl>
|
||||
<div><dt>目标</dt><dd id="publish-target">Server 配置文件</dd></div>
|
||||
<div><dt>格式</dt><dd>JSON</dd></div>
|
||||
<div><dt>鉴权</dt><dd>Admin Token</dd></div>
|
||||
</dl>
|
||||
<button id="load-server" class="button secondary full">读取 Server 配置</button>
|
||||
<button id="publish-config" class="button primary full">上传容积配置</button>
|
||||
<p id="publish-result" class="result">等待操作</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="model-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">MODEL CONTROL</p><h2>产线模型</h2></div>
|
||||
<div class="actions">
|
||||
<button id="refresh-models" class="button secondary">刷新</button>
|
||||
<button id="upload-model" class="button primary">上传模型</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
|
||||
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="license-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">LICENSE REGISTRY</p><h2>许可证签发与管理</h2></div>
|
||||
<button id="refresh-licenses" class="button secondary">刷新列表</button>
|
||||
</div>
|
||||
<div class="management-layout">
|
||||
<form id="license-form" class="form-surface">
|
||||
<h3>签发许可证</h3>
|
||||
<label><span>当前目标</span><input id="license-target" readonly placeholder="请先选择公司和产线"></label>
|
||||
<label><span>签发时间</span><input id="license-issued" type="datetime-local" required></label>
|
||||
<label><span>到期时间</span><input id="license-expiry" type="datetime-local" required></label>
|
||||
<label><span>功能</span><input id="license-features" value="*" required></label>
|
||||
<button class="button primary" type="submit">选择私钥并签发</button>
|
||||
<p class="form-note">许可证保存到本地后同步登记到 Server;私钥不会上传或保存。</p>
|
||||
</form>
|
||||
<div class="data-surface">
|
||||
<table><thead><tr><th>公司 / 产线</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="license-list"></tbody></table>
|
||||
<p id="license-empty" class="empty-row">尚未读取许可证</p>
|
||||
<pre id="license-detail" class="detail-view" hidden></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="organization-panel" class="panel">
|
||||
<div class="panel-head">
|
||||
<div><p class="section-kicker">ORGANIZATION</p><h2>公司与产线</h2></div>
|
||||
<button id="refresh-organizations" class="button secondary">刷新组织</button>
|
||||
</div>
|
||||
<div class="management-layout equal">
|
||||
<form id="company-form" class="form-surface">
|
||||
<h3>添加公司</h3>
|
||||
<label><span>公司名称</span><input id="company-name" required></label>
|
||||
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||
<button class="button primary" type="submit">添加公司</button>
|
||||
</form>
|
||||
<form id="line-form" class="form-surface">
|
||||
<h3>添加产线</h3>
|
||||
<label><span>所属公司</span><select id="line-company" required><option value="">请选择公司</option></select></label>
|
||||
<label><span>产线名称</span><input id="line-name" required></label>
|
||||
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||
<button class="button primary" type="submit">添加产线</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
|
||||
<div class="lightbox-shell">
|
||||
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
|
||||
<div id="lightbox-canvas" class="lightbox-canvas"><img id="lightbox-image" alt="放大的图像预览"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,7 @@ base64(JSON)|base64(signature)
|
||||
|
||||
- 刷新许可证列表
|
||||
- 查看许可证详情
|
||||
- 下载已签发许可证
|
||||
- 撤销有效许可证
|
||||
- 填写撤销原因
|
||||
- 区分有效和已撤销状态
|
||||
@@ -192,6 +193,33 @@ getLicense
|
||||
revokeLicense
|
||||
```
|
||||
|
||||
下载已签发许可证复用 `getLicense` 返回的许可证原文;Panel 在本地选择保存位置后写入 `.lic` 文件。
|
||||
|
||||
## 5.1 控制数据暂存
|
||||
|
||||
“控制数据”页面按当前产线显示 ReinLoop 在控制结束后上传到
|
||||
`<deviceId>/data_record/` 的 Episode 分片和清单文件。页面支持:
|
||||
|
||||
- 刷新控制数据列表
|
||||
- 查看 JSON 清单内容;`.pkl` 分片显示文件元数据和本地缓存位置
|
||||
- 下载任意控制原始文件
|
||||
- 删除指定控制数据文件
|
||||
|
||||
控制 Episode 分片采用 Python pickle 格式,Panel 不在渲染进程反序列化该二进制数据;
|
||||
需要详细分析时,应下载后使用 ReinLoop/Python 读取。JSON manifest 可直接在页面中查看。
|
||||
|
||||
Panel 需要 Server 提供以下 Admin 接口:
|
||||
|
||||
```text
|
||||
listControlFiles deviceId, page, pageSize -> files, total, page, pageSize
|
||||
getControlFileDownload fileID -> fileID, fileName, uploadTime, size, url
|
||||
deleteControlFile fileID -> deletedCount
|
||||
```
|
||||
|
||||
每条 `files` 记录至少包含 `fileID`、`fileName`、`uploadTime` 和 `size`。下载接口必须返回
|
||||
可下载原始文件的短期签名 URL;删除接口必须同时删除文件本体和元数据,并仅允许删除该设备的
|
||||
控制数据目录中的文件。
|
||||
|
||||
## 6. 模型管理
|
||||
|
||||
“模型管理”页面按当前产线操作:
|
||||
|
||||
Reference in New Issue
Block a user