update server
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
# 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. 已知依赖风险
|
||||
|
||||
依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的
|
||||
`npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。
|
||||
@@ -0,0 +1,191 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { parse } = require("csv-parse/sync");
|
||||
const { callServer } = require("./server-client");
|
||||
|
||||
const IDENTIFICATION_FIELDS = [
|
||||
"q_in_val", "dt", "n_order", "t_c", "levels",
|
||||
"dead_area", "xa_full", "V_val", "repeat"
|
||||
];
|
||||
|
||||
function getDeviceId(options = {}) {
|
||||
const deviceId = String(options.deviceId || process.env.REINLOOP_DEVICE_ID || "").trim();
|
||||
if (!deviceId) throw new Error("发布或读取辨识配置时必须提供设备 ID");
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
async function readParameters(filePath) {
|
||||
if (!filePath) throw new Error("发布参数时必须提供 JSON 文件路径");
|
||||
const content = await fs.promises.readFile(path.resolve(filePath), "utf8");
|
||||
const config = JSON.parse(content);
|
||||
return config.parameters || config;
|
||||
}
|
||||
|
||||
async function uploadVolumeConfig(parameters, options = {}) {
|
||||
const deviceId = getDeviceId(options);
|
||||
const request = await callServer({
|
||||
type: "getPendingVolumeConfigRequest",
|
||||
deviceId
|
||||
}, options);
|
||||
if (!request.pending) {
|
||||
throw new Error("ReinLoop 尚未发起容积参数请求,请先在客户端开始容积测试");
|
||||
}
|
||||
|
||||
const result = await callServer({
|
||||
type: "uploadDataFile",
|
||||
fileName: "volume_measurement.json",
|
||||
folder: `${deviceId}/volume_config_requests/${request.requestId}`
|
||||
}, options);
|
||||
const metadata = result.uploadMetadata;
|
||||
if (!metadata || !metadata.url) {
|
||||
throw new Error("server 未返回有效的配置上传地址");
|
||||
}
|
||||
|
||||
const orderedConfig = {
|
||||
q_in_val: parameters.q_in_val,
|
||||
dt: parameters.dt,
|
||||
p_max: parameters.p_max,
|
||||
fit_low: parameters.fit_low,
|
||||
fit_high: parameters.fit_high,
|
||||
T_delta: parameters.T_delta,
|
||||
xa_full: parameters.xa_full,
|
||||
num_runs: parameters.num_runs
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new Blob([`${JSON.stringify(orderedConfig, null, 2)}\n`], { type: "application/json" }),
|
||||
"volume_measurement.json"
|
||||
);
|
||||
|
||||
const uploadResponse = await fetch(metadata.url, { method: "POST", body: form });
|
||||
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
||||
throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`);
|
||||
}
|
||||
try {
|
||||
return await callServer({
|
||||
type: "submitVolumeConfigFile",
|
||||
deviceId,
|
||||
requestId: request.requestId,
|
||||
fileID: result.fileID,
|
||||
fileName: "volume_measurement.json"
|
||||
}, options);
|
||||
} catch (error) {
|
||||
await callServer({ type: "deleteFile", fileID: result.fileID }, options).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeIdentificationConfig(parameters) {
|
||||
const rows = IDENTIFICATION_FIELDS.map((field) => {
|
||||
const value = field === "levels" ? parameters[field].join(",") : parameters[field];
|
||||
return `${field},${field === "levels" ? `"${value}"` : value}`;
|
||||
});
|
||||
return `parameter,value\n${rows.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function uploadIdentificationConfig(parameters, options = {}) {
|
||||
const result = await callServer({
|
||||
type: "publishIdentificationConfig",
|
||||
deviceId: getDeviceId(options),
|
||||
parameters
|
||||
}, options);
|
||||
const metadata = result.uploadMetadata;
|
||||
if (!metadata || !metadata.url) {
|
||||
throw new Error("server 未返回有效的配置上传地址");
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new Blob([serializeIdentificationConfig(parameters)], { type: "text/csv" }),
|
||||
"identification_config.csv"
|
||||
);
|
||||
const uploadResponse = await fetch(metadata.url, { method: "POST", body: form });
|
||||
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
||||
throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function publishConfig(configType, parameters, options = {}) {
|
||||
if (configType === "volume") {
|
||||
return uploadVolumeConfig(parameters, options);
|
||||
}
|
||||
if (configType !== "identification") {
|
||||
throw new Error(`不支持的配置类型: ${configType}`);
|
||||
}
|
||||
return uploadIdentificationConfig(parameters, options);
|
||||
}
|
||||
|
||||
async function getConfig(configType, options = {}) {
|
||||
if (configType === "identification") {
|
||||
const result = await callServer({
|
||||
type: "getIdentificationConfig",
|
||||
deviceId: getDeviceId(options)
|
||||
}, options);
|
||||
const response = await fetch(result.url);
|
||||
if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`);
|
||||
const records = parse(await response.text(), { columns: true, skip_empty_lines: true });
|
||||
const values = Object.fromEntries(records.map((record) => [record.parameter, record.value]));
|
||||
return {
|
||||
q_in_val: Number(values.q_in_val),
|
||||
dt: Number(values.dt),
|
||||
n_order: Number(values.n_order),
|
||||
t_c: Number(values.t_c),
|
||||
levels: String(values.levels).split(",").map(Number),
|
||||
dead_area: Number(values.dead_area),
|
||||
xa_full: Number(values.xa_full),
|
||||
V_val: Number(values.V_val),
|
||||
repeat: Number(values.repeat)
|
||||
};
|
||||
}
|
||||
return callServer({ type: "getFunctionConfig", configType }, options);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, filePath] = process.argv.slice(2);
|
||||
const commands = {
|
||||
"publish-volume": { action: "upload-volume", configType: "volume" },
|
||||
"publish-identification": { action: "publish", configType: "identification" },
|
||||
"get-volume": { action: "get", configType: "volume" },
|
||||
"get-identification": { action: "get", configType: "identification" }
|
||||
};
|
||||
const selected = commands[command];
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
"用法: node b-admin.js <publish-volume|publish-identification|get-volume|get-identification> [config.json]"
|
||||
);
|
||||
}
|
||||
|
||||
if (selected.action === "upload-volume") {
|
||||
const result = await publishConfig(selected.configType, await readParameters(filePath));
|
||||
console.log(`配置已上传: ${result.fileID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = selected.action === "publish"
|
||||
? await publishConfig(selected.configType, await readParameters(filePath))
|
||||
: await getConfig(selected.configType);
|
||||
if (selected.action === "publish") {
|
||||
console.log(`配置已上传: ${result.fileID}`);
|
||||
return;
|
||||
}
|
||||
console.dir(result, { depth: null, colors: true });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfig,
|
||||
publishConfig,
|
||||
readParameters,
|
||||
serializeIdentificationConfig,
|
||||
uploadIdentificationConfig,
|
||||
uploadVolumeConfig
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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))
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
<!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>
|
||||
</html>
|
||||
@@ -0,0 +1,716 @@
|
||||
const volumeExample = {
|
||||
q_in_val: 91,
|
||||
dt: 0.1,
|
||||
p_max: 200,
|
||||
fit_low: 50,
|
||||
fit_high: 200,
|
||||
T_delta: 30,
|
||||
xa_full: 1000,
|
||||
num_runs: 6
|
||||
};
|
||||
|
||||
const identificationExample = {
|
||||
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
|
||||
};
|
||||
|
||||
const state = {
|
||||
configType: "volume",
|
||||
imagePaths: { csv: null, json: null },
|
||||
imageDataUrls: { csv: null, json: null },
|
||||
identificationFiles: [],
|
||||
configs: { volume: volumeExample, identification: identificationExample },
|
||||
companies: [],
|
||||
models: [],
|
||||
defaultDeviceId: "",
|
||||
review: null,
|
||||
retryDeviceId: null,
|
||||
lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null },
|
||||
connected: false
|
||||
};
|
||||
|
||||
const elements = {
|
||||
status: document.querySelector("#status"),
|
||||
connectionScreen: document.querySelector("#connection-screen"),
|
||||
connectionForm: document.querySelector("#connection-form"),
|
||||
connectionMessage: document.querySelector("#connection-message"),
|
||||
connectButton: document.querySelector("#connect-button"),
|
||||
workspace: document.querySelector("#workspace"),
|
||||
apiUrl: document.querySelector("#api-url"),
|
||||
adminToken: document.querySelector("#admin-token"),
|
||||
companySelect: document.querySelector("#company-select"),
|
||||
deviceId: document.querySelector("#device-id"),
|
||||
plots: {
|
||||
csv: {
|
||||
image: document.querySelector("#csv-plot-image"),
|
||||
empty: document.querySelector("#empty-csv-plot"),
|
||||
path: document.querySelector("#csv-image-path"),
|
||||
uploadTime: document.querySelector("#csv-upload-time"),
|
||||
showImage: document.querySelector("#show-csv-image"),
|
||||
openImage: document.querySelector('[data-open-plot="csv"]')
|
||||
},
|
||||
json: {
|
||||
image: document.querySelector("#json-plot-image"),
|
||||
empty: document.querySelector("#empty-json-plot"),
|
||||
path: document.querySelector("#json-image-path"),
|
||||
uploadTime: document.querySelector("#json-upload-time"),
|
||||
showImage: document.querySelector("#show-json-image"),
|
||||
openImage: document.querySelector('[data-open-plot="json"]')
|
||||
}
|
||||
},
|
||||
configEditor: document.querySelector("#config-editor"),
|
||||
configLabel: document.querySelector("#config-label"),
|
||||
configPath: document.querySelector("#config-path"),
|
||||
publishTarget: document.querySelector("#publish-target"),
|
||||
publishButton: document.querySelector("#publish-config"),
|
||||
publishResult: document.querySelector("#publish-result"),
|
||||
lineCompany: document.querySelector("#line-company"),
|
||||
licenseTarget: document.querySelector("#license-target"),
|
||||
licenseList: document.querySelector("#license-list"),
|
||||
licenseEmpty: document.querySelector("#license-empty"),
|
||||
licenseDetail: document.querySelector("#license-detail"),
|
||||
modelList: document.querySelector("#model-list"),
|
||||
modelEmpty: document.querySelector("#model-empty"),
|
||||
identificationFileList: document.querySelector("#identification-file-list"),
|
||||
identificationFileEmpty: document.querySelector("#identification-file-empty"),
|
||||
reviewTarget: document.querySelector("#review-target"),
|
||||
approveReview: document.querySelector("#approve-review"),
|
||||
rejectReview: document.querySelector("#reject-review"),
|
||||
lightbox: document.querySelector("#image-lightbox"),
|
||||
lightboxTitle: document.querySelector("#lightbox-title"),
|
||||
lightboxImage: document.querySelector("#lightbox-image"),
|
||||
lightboxCanvas: document.querySelector("#lightbox-canvas"),
|
||||
zoomIn: document.querySelector("#zoom-in"),
|
||||
zoomOut: document.querySelector("#zoom-out"),
|
||||
zoomFit: document.querySelector("#zoom-fit"),
|
||||
zoomReset: document.querySelector("#zoom-reset"),
|
||||
closeLightbox: document.querySelector("#close-lightbox")
|
||||
};
|
||||
|
||||
function setStatus(message, tone = "idle") {
|
||||
elements.status.textContent = message;
|
||||
elements.status.dataset.tone = tone;
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
return {
|
||||
apiUrl: elements.apiUrl.value.trim(),
|
||||
adminToken: elements.adminToken.value,
|
||||
deviceId: elements.deviceId.value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function selectedCompany() {
|
||||
return state.companies.find((company) => company.id === elements.companySelect.value);
|
||||
}
|
||||
|
||||
function selectedLine() {
|
||||
const company = selectedCompany();
|
||||
return company?.productionLines.find((line) => line.deviceId === elements.deviceId.value);
|
||||
}
|
||||
|
||||
function formatLicenseDate(value) {
|
||||
return value.replace("T", " ");
|
||||
}
|
||||
|
||||
function formatSize(value) {
|
||||
if (!Number.isFinite(value)) return "-";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement("span");
|
||||
node.textContent = String(value ?? "");
|
||||
return node.innerHTML;
|
||||
}
|
||||
|
||||
async function syncConnection() {
|
||||
await window.reinloop.setConnection(credentials());
|
||||
}
|
||||
|
||||
function showConnectionError(error) {
|
||||
const message = (error?.message || String(error))
|
||||
.replace(/^Error invoking remote method '[^']+': Error: /, "");
|
||||
elements.connectionMessage.textContent = message;
|
||||
elements.connectionMessage.dataset.tone = "error";
|
||||
setStatus("连接失败", "error");
|
||||
}
|
||||
|
||||
async function connectWorkspace() {
|
||||
const apiUrl = elements.apiUrl.value.trim();
|
||||
if (!apiUrl) return showConnectionError(new Error("请输入 Server API URL"));
|
||||
elements.connectButton.disabled = true;
|
||||
elements.connectionMessage.textContent = "正在校验 Server 与 Admin Token";
|
||||
elements.connectionMessage.dataset.tone = "busy";
|
||||
setStatus("正在连接", "busy");
|
||||
try {
|
||||
const result = await window.reinloop.testConnection({
|
||||
apiUrl,
|
||||
adminToken: elements.adminToken.value
|
||||
});
|
||||
state.companies = result.companies;
|
||||
renderOrganizationOptions();
|
||||
elements.connectionScreen.hidden = true;
|
||||
elements.workspace.hidden = false;
|
||||
state.connected = true;
|
||||
delete elements.connectionMessage.dataset.tone;
|
||||
setStatus("连接成功", "success");
|
||||
} catch (error) {
|
||||
showConnectionError(error);
|
||||
} finally {
|
||||
elements.connectButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrganizationOptions() {
|
||||
const previousCompany = elements.companySelect.value;
|
||||
const companyOptions = state.companies.map((company) => {
|
||||
const lines = company.productionLines || [];
|
||||
const onlineCount = lines.filter((line) => line.online === true).length;
|
||||
const hasKnownStatus = lines.some((line) => typeof line.online === "boolean");
|
||||
const statusSummary = !lines.length
|
||||
? " · 暂无产线"
|
||||
: hasKnownStatus
|
||||
? `${onlineCount > 0 ? " · ● 在线" : " · ○ 离线"} (${onlineCount}/${lines.length})`
|
||||
: " · ◇ 状态未知";
|
||||
return `<option value="${escapeHtml(company.id)}">${escapeHtml(company.name)} (${escapeHtml(company.code)})${escapeHtml(statusSummary)}</option>`;
|
||||
}).join("");
|
||||
elements.companySelect.innerHTML = `<option value="">请选择公司</option>${companyOptions}`;
|
||||
elements.lineCompany.innerHTML = `<option value="">请选择公司</option>${companyOptions}`;
|
||||
if (state.companies.some((company) => company.id === previousCompany)) {
|
||||
elements.companySelect.value = previousCompany;
|
||||
} else if (state.defaultDeviceId) {
|
||||
const defaultCompany = state.companies.find((company) =>
|
||||
company.productionLines.some((line) => line.deviceId === state.defaultDeviceId));
|
||||
if (defaultCompany) elements.companySelect.value = defaultCompany.id;
|
||||
}
|
||||
renderLineOptions();
|
||||
}
|
||||
|
||||
function renderLineOptions() {
|
||||
const company = selectedCompany();
|
||||
const previousDevice = elements.deviceId.value;
|
||||
const lines = company?.productionLines || [];
|
||||
elements.deviceId.innerHTML = `<option value="">请选择产线</option>${lines.map((line) =>
|
||||
`<option value="${escapeHtml(line.deviceId)}">${escapeHtml(line.online === true ? "● 在线" : line.online === false ? "○ 离线" : "◇ 状态未知")} · ${escapeHtml(line.name)} (${escapeHtml(line.code)})</option>`
|
||||
).join("")}`;
|
||||
if (lines.some((line) => line.deviceId === previousDevice)) {
|
||||
elements.deviceId.value = previousDevice;
|
||||
} else if (lines.some((line) => line.deviceId === state.defaultDeviceId)) {
|
||||
elements.deviceId.value = state.defaultDeviceId;
|
||||
}
|
||||
updateSelectedTarget();
|
||||
}
|
||||
|
||||
function updateSelectedTarget() {
|
||||
const company = selectedCompany();
|
||||
const line = selectedLine();
|
||||
elements.licenseTarget.value = company && line ? `${company.name} / ${line.name}` : "";
|
||||
void syncConnection();
|
||||
}
|
||||
|
||||
async function refreshOrganizations(silent = false) {
|
||||
if (silent) {
|
||||
try {
|
||||
const result = await window.reinloop.listOrganizations(credentials());
|
||||
state.companies = result.companies;
|
||||
renderOrganizationOptions();
|
||||
} catch (_error) {
|
||||
// Keep the last known status; explicit operations still report errors.
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await runBusy("正在读取组织", () => window.reinloop.listOrganizations(credentials()));
|
||||
if (!result) return;
|
||||
state.companies = result.companies;
|
||||
renderOrganizationOptions();
|
||||
setStatus("组织已刷新", "success");
|
||||
}
|
||||
|
||||
async function refreshLicenses() {
|
||||
const result = await runBusy("正在读取许可证", () => window.reinloop.listLicenses(credentials()));
|
||||
if (!result) return;
|
||||
elements.licenseList.innerHTML = result.licenses.map((license) => `
|
||||
<tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td>
|
||||
<td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td>
|
||||
<td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : ""}</td></tr>
|
||||
`).join("");
|
||||
elements.licenseEmpty.hidden = result.licenses.length > 0;
|
||||
setStatus("许可证已刷新", "success");
|
||||
}
|
||||
|
||||
async function refreshModels() {
|
||||
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
|
||||
const result = await runBusy("正在读取模型", () => window.reinloop.listModels({
|
||||
deviceId: elements.deviceId.value, credentials: credentials()
|
||||
}));
|
||||
if (!result) return;
|
||||
state.models = result.fileList;
|
||||
elements.modelList.innerHTML = state.models.map((model) => `
|
||||
<tr><td>${escapeHtml(model.fileName)}</td><td>${escapeHtml(model.uploadTime || "-")}</td>
|
||||
<td>${formatSize(model.size)}</td><td><button class="table-action" data-model-download="${escapeHtml(model.fileID)}">下载</button><button class="table-action danger" data-model-delete="${escapeHtml(model.fileID)}">删除</button></td></tr>
|
||||
`).join("");
|
||||
elements.modelEmpty.hidden = state.models.length > 0;
|
||||
setStatus("模型已刷新", "success");
|
||||
}
|
||||
|
||||
async function refreshIdentificationFiles() {
|
||||
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
|
||||
const result = await runBusy("正在读取辨识暂存数据", () => window.reinloop.listIdentificationFiles({
|
||||
deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials()
|
||||
}));
|
||||
if (!result) return;
|
||||
state.identificationFiles = result.files || result.fileList || [];
|
||||
elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => `
|
||||
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
|
||||
<td>${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}</td><td>${formatSize(file.size)}</td>
|
||||
<td>${file.status === "processed" ? "已处理" : "待处理"}</td>
|
||||
<td><button class="table-action" data-identification-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-identification-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-identification-delete="${escapeHtml(file.fileID)}">删除</button></td></tr>
|
||||
`).join("");
|
||||
elements.identificationFileEmpty.hidden = state.identificationFiles.length > 0;
|
||||
setStatus("辨识数据已刷新", "success");
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
const message = (error?.message || String(error))
|
||||
.replace(/^Error invoking remote method '[^']+': Error: /, "");
|
||||
setStatus(message, "error");
|
||||
elements.status.title = message;
|
||||
elements.publishResult.textContent = message;
|
||||
elements.publishResult.dataset.tone = "error";
|
||||
}
|
||||
|
||||
function showImage(result) {
|
||||
if (!result) return;
|
||||
const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json")
|
||||
? "json"
|
||||
: "csv";
|
||||
const plot = elements.plots[mediaType];
|
||||
state.imagePaths[mediaType] = result.filePath;
|
||||
state.imageDataUrls[mediaType] = result.dataUrl;
|
||||
plot.image.src = result.dataUrl;
|
||||
plot.image.hidden = false;
|
||||
plot.empty.hidden = true;
|
||||
plot.path.textContent = result.filePath;
|
||||
if (result.uploadTime) {
|
||||
const uploadedAt = new Date(result.uploadTime);
|
||||
plot.uploadTime.textContent = `上传时间:${uploadedAt.toLocaleString("zh-CN", { hour12: false })}`;
|
||||
} else {
|
||||
plot.uploadTime.textContent = "已接收";
|
||||
}
|
||||
plot.showImage.disabled = false;
|
||||
plot.openImage.disabled = false;
|
||||
if (mediaType === "csv" && result.reviewable) {
|
||||
state.review = { runId: result.fileName, deviceId: result.deviceId };
|
||||
elements.reviewTarget.textContent = result.fileName;
|
||||
elements.approveReview.disabled = false;
|
||||
elements.rejectReview.disabled = false;
|
||||
}
|
||||
setStatus("图片已载入", "success");
|
||||
}
|
||||
|
||||
function updateLightboxImage() {
|
||||
const image = elements.lightboxImage;
|
||||
if (state.lightbox.fit) {
|
||||
image.classList.add("fit-image");
|
||||
image.style.width = "";
|
||||
return;
|
||||
}
|
||||
image.classList.remove("fit-image");
|
||||
if (image.complete && image.naturalWidth) image.style.width = `${Math.round(image.naturalWidth * state.lightbox.scale)}px`;
|
||||
}
|
||||
|
||||
function openLightbox(mediaType) {
|
||||
const source = state.imageDataUrls[mediaType];
|
||||
if (!source) return;
|
||||
state.lightbox.mediaType = mediaType;
|
||||
state.lightbox.scale = 1;
|
||||
state.lightbox.fit = true;
|
||||
state.lightbox.previousFocus = document.activeElement;
|
||||
elements.lightboxTitle.textContent = mediaType === "json" ? "行程稳态压力图" : "辨识 CSV 图";
|
||||
elements.lightboxImage.src = source;
|
||||
elements.lightboxImage.onload = updateLightboxImage;
|
||||
elements.lightbox.hidden = false;
|
||||
elements.closeLightbox.focus();
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
if (elements.lightbox.hidden) return;
|
||||
elements.lightbox.hidden = true;
|
||||
elements.lightboxImage.removeAttribute("src");
|
||||
state.lightbox.previousFocus?.focus();
|
||||
state.lightbox.previousFocus = null;
|
||||
}
|
||||
|
||||
function zoomLightbox(direction) {
|
||||
state.lightbox.fit = false;
|
||||
state.lightbox.scale = Math.min(4, Math.max(0.25, state.lightbox.scale * direction));
|
||||
updateLightboxImage();
|
||||
}
|
||||
|
||||
function finishReview(result) {
|
||||
state.review = null;
|
||||
elements.reviewTarget.textContent = result === 1 ? "已提交:通过" : "已提交:未通过";
|
||||
elements.approveReview.disabled = true;
|
||||
elements.rejectReview.disabled = true;
|
||||
}
|
||||
|
||||
function activateTab(target) {
|
||||
document.querySelectorAll(".tab").forEach((item) => item.classList.toggle("active", item.dataset.target === target));
|
||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === target));
|
||||
}
|
||||
|
||||
function activateConfigType(configType) {
|
||||
try {
|
||||
state.configs[state.configType] = JSON.parse(elements.configEditor.value);
|
||||
} catch (_error) {
|
||||
// Keep the last valid configuration when changing views.
|
||||
}
|
||||
state.configType = configType;
|
||||
document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType));
|
||||
renderConfig();
|
||||
}
|
||||
|
||||
function switchToIdentificationConfigForRetry() {
|
||||
if (!state.review) return;
|
||||
state.retryDeviceId = state.review.deviceId;
|
||||
activateTab("config-panel");
|
||||
activateConfigType("identification");
|
||||
elements.publishResult.textContent = `本轮辨识未通过。请检查配置后发布到设备 ${state.retryDeviceId},发布成功后将自动要求重测。`;
|
||||
elements.publishResult.dataset.tone = "error";
|
||||
setStatus("请重新发布辨识配置", "busy");
|
||||
}
|
||||
|
||||
async function submitReview(result) {
|
||||
if (!state.review) return;
|
||||
if (result === 0) return switchToIdentificationConfigForRetry();
|
||||
const review = state.review;
|
||||
const submitted = await runBusy("正在提交辨识结论", () => window.reinloop.submitReview({
|
||||
deviceId: review.deviceId,
|
||||
runId: review.runId,
|
||||
result,
|
||||
credentials: credentials()
|
||||
}));
|
||||
if (submitted) {
|
||||
finishReview(result);
|
||||
state.retryDeviceId = null;
|
||||
setStatus("辨识结果已通过", "success");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRetryReview() {
|
||||
if (!state.review || !state.retryDeviceId) return;
|
||||
const review = state.review;
|
||||
const submitted = await runBusy("正在提交未通过结论", () => window.reinloop.submitReview({
|
||||
deviceId: review.deviceId, runId: review.runId, result: 0, credentials: credentials()
|
||||
}));
|
||||
if (!submitted) return;
|
||||
finishReview(0);
|
||||
state.retryDeviceId = null;
|
||||
setStatus("新配置已发布,已要求设备重测", "success");
|
||||
}
|
||||
|
||||
function renderConfig() {
|
||||
const isVolume = state.configType === "volume";
|
||||
elements.configEditor.value = JSON.stringify(state.configs[state.configType], null, 2);
|
||||
elements.configLabel.textContent = isVolume ? "容积测量配置" : "系统辨识配置";
|
||||
elements.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置";
|
||||
elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置";
|
||||
elements.configPath.textContent = "可直接编辑,或从本地 JSON 导入";
|
||||
elements.publishResult.textContent = "等待操作";
|
||||
delete elements.publishResult.dataset.tone;
|
||||
}
|
||||
|
||||
async function runBusy(label, action) {
|
||||
setStatus(label, "busy");
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.target)));
|
||||
|
||||
document.querySelectorAll(".segment").forEach((button) => button.addEventListener("click", () => activateConfigType(button.dataset.type)));
|
||||
|
||||
Object.entries(elements.plots).forEach(([mediaType, plot]) => {
|
||||
plot.showImage.addEventListener("click", () => window.reinloop.showInFolder(state.imagePaths[mediaType]));
|
||||
plot.openImage.addEventListener("click", () => openLightbox(mediaType));
|
||||
});
|
||||
|
||||
elements.approveReview.addEventListener("click", () => void submitReview(1));
|
||||
elements.rejectReview.addEventListener("click", () => void submitReview(0));
|
||||
|
||||
elements.companySelect.addEventListener("change", renderLineOptions);
|
||||
elements.deviceId.addEventListener("change", updateSelectedTarget);
|
||||
elements.connectionForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void connectWorkspace();
|
||||
});
|
||||
|
||||
document.querySelector("#refresh-organizations").addEventListener("click", refreshOrganizations);
|
||||
document.querySelector("#company-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const result = await runBusy("正在添加公司", () => window.reinloop.createCompany({
|
||||
name: document.querySelector("#company-name").value,
|
||||
code: document.querySelector("#company-code").value,
|
||||
credentials: credentials()
|
||||
}));
|
||||
if (!result) return;
|
||||
event.target.reset();
|
||||
await refreshOrganizations();
|
||||
});
|
||||
document.querySelector("#line-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const result = await runBusy("正在添加产线", () => window.reinloop.createProductionLine({
|
||||
companyId: elements.lineCompany.value,
|
||||
name: document.querySelector("#line-name").value,
|
||||
code: document.querySelector("#line-code").value,
|
||||
credentials: credentials()
|
||||
}));
|
||||
if (!result) return;
|
||||
event.target.reset();
|
||||
await refreshOrganizations();
|
||||
});
|
||||
|
||||
document.querySelector("#license-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const company = selectedCompany();
|
||||
const line = selectedLine();
|
||||
if (!company || !line) return showError(new Error("请先选择公司和产线"));
|
||||
const result = await runBusy("正在签发许可证", () => window.reinloop.issueLicense({
|
||||
companyId: company.id, companyCode: company.code, customer: company.name,
|
||||
productionLineId: line.id, lineCode: line.code, deviceId: line.deviceId,
|
||||
issued: formatLicenseDate(document.querySelector("#license-issued").value),
|
||||
expiry: formatLicenseDate(document.querySelector("#license-expiry").value),
|
||||
features: document.querySelector("#license-features").value,
|
||||
credentials: credentials()
|
||||
}));
|
||||
if (!result) return;
|
||||
setStatus(`许可证已保存: ${result.filePath}`, "success");
|
||||
await refreshLicenses();
|
||||
});
|
||||
document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses);
|
||||
elements.licenseList.addEventListener("click", async (event) => {
|
||||
const detailId = event.target.dataset.licenseDetail;
|
||||
const revokeId = event.target.dataset.licenseRevoke;
|
||||
if (detailId) {
|
||||
const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() }));
|
||||
if (result) {
|
||||
elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2);
|
||||
elements.licenseDetail.hidden = false;
|
||||
}
|
||||
}
|
||||
if (revokeId) {
|
||||
const reason = window.prompt("请输入撤销原因", "管理员撤销");
|
||||
if (reason === null) return;
|
||||
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ licenseId: revokeId, reason, credentials: credentials() }));
|
||||
if (result) await refreshLicenses();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#refresh-models").addEventListener("click", refreshModels);
|
||||
document.querySelector("#upload-model").addEventListener("click", async () => {
|
||||
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
|
||||
const button = document.querySelector("#upload-model");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const models = await runBusy("正在读取模型", () => window.reinloop.listModels({
|
||||
deviceId: elements.deviceId.value, credentials: credentials()
|
||||
}));
|
||||
if (!models) return;
|
||||
state.models = models.fileList;
|
||||
const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile());
|
||||
if (!selected) return;
|
||||
const existing = state.models.find((model) => model.fileName === selected.fileName);
|
||||
let overwrite = false;
|
||||
if (existing) {
|
||||
if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return;
|
||||
const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`);
|
||||
if (confirmation !== selected.fileName) {
|
||||
setStatus("文件名不匹配,已取消覆盖", "idle");
|
||||
return;
|
||||
}
|
||||
overwrite = true;
|
||||
}
|
||||
const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({
|
||||
deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName,
|
||||
overwrite, credentials: credentials()
|
||||
}));
|
||||
if (result) {
|
||||
setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success");
|
||||
await refreshModels();
|
||||
}
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
elements.modelList.addEventListener("click", async (event) => {
|
||||
const fileID = event.target.dataset.modelDownload || event.target.dataset.modelDelete;
|
||||
if (!fileID) return;
|
||||
const model = state.models.find((item) => item.fileID === fileID);
|
||||
if (event.target.dataset.modelDownload) {
|
||||
const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() }));
|
||||
if (result) setStatus(`模型已下载: ${result.filePath}`, "success");
|
||||
} else {
|
||||
if (!window.confirm(`确认删除模型 ${model.fileName}?`)) return;
|
||||
const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`);
|
||||
if (confirmation !== model.fileName) {
|
||||
setStatus("文件名不匹配,已取消删除", "idle");
|
||||
return;
|
||||
}
|
||||
event.target.disabled = true;
|
||||
try {
|
||||
const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() }));
|
||||
if (result) await refreshModels();
|
||||
} finally {
|
||||
event.target.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#refresh-identification-files").addEventListener("click", refreshIdentificationFiles);
|
||||
elements.identificationFileList.addEventListener("click", async (event) => {
|
||||
const fileID = event.target.dataset.identificationPreview || event.target.dataset.identificationDownload || event.target.dataset.identificationDelete;
|
||||
if (!fileID) return;
|
||||
const file = state.identificationFiles.find((item) => item.fileID === fileID);
|
||||
if (!file) return;
|
||||
if (event.target.dataset.identificationPreview) {
|
||||
const result = await runBusy("正在下载并生成预览", () => window.reinloop.previewIdentificationFile({
|
||||
...file, deviceId: elements.deviceId.value, credentials: credentials()
|
||||
}));
|
||||
if (result) {
|
||||
showImage(result);
|
||||
activateTab("plot-panel");
|
||||
}
|
||||
} else if (event.target.dataset.identificationDownload) {
|
||||
const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({
|
||||
...file, credentials: credentials()
|
||||
}));
|
||||
if (result) setStatus(`已保存到: ${result.filePath}`, "success");
|
||||
} else {
|
||||
if (!window.confirm(`确认永久删除 ${file.fileName}?`)) return;
|
||||
if (!window.confirm("删除后无法恢复,确认继续?")) return;
|
||||
event.target.disabled = true;
|
||||
try {
|
||||
const result = await runBusy("正在删除辨识数据", () => window.reinloop.deleteIdentificationFile({
|
||||
fileID, credentials: credentials()
|
||||
}));
|
||||
if (result) await refreshIdentificationFiles();
|
||||
} finally {
|
||||
event.target.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#import-config").addEventListener("click", async () => {
|
||||
const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig());
|
||||
if (!result) return;
|
||||
state.configs[state.configType] = result.parameters;
|
||||
elements.configEditor.value = JSON.stringify(result.parameters, null, 2);
|
||||
elements.configPath.textContent = result.filePath;
|
||||
setStatus("配置已导入", "success");
|
||||
});
|
||||
|
||||
document.querySelector("#load-server").addEventListener("click", async () => {
|
||||
const parameters = await runBusy("正在读取 Server 配置", () => window.reinloop.getConfig({
|
||||
configType: state.configType,
|
||||
credentials: credentials()
|
||||
}));
|
||||
if (!parameters) return;
|
||||
state.configs[state.configType] = parameters;
|
||||
elements.configEditor.value = JSON.stringify(parameters, null, 2);
|
||||
elements.publishResult.textContent = "已读取当前 Server 配置";
|
||||
elements.publishResult.dataset.tone = "success";
|
||||
setStatus("读取完成", "success");
|
||||
});
|
||||
|
||||
elements.publishButton.addEventListener("click", async () => {
|
||||
let parameters;
|
||||
try {
|
||||
parameters = JSON.parse(elements.configEditor.value);
|
||||
} catch (error) {
|
||||
showError(new Error(`JSON 格式错误: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runBusy("正在发布配置", () => window.reinloop.publishConfig({
|
||||
configType: state.configType,
|
||||
parameters,
|
||||
credentials: {
|
||||
...credentials(),
|
||||
deviceId: state.configType === "identification" && state.retryDeviceId
|
||||
? state.retryDeviceId
|
||||
: credentials().deviceId
|
||||
}
|
||||
}));
|
||||
if (!result) return;
|
||||
state.configs[state.configType] = parameters;
|
||||
elements.publishResult.textContent = result.storagePath
|
||||
? `${result.message}: ${result.storagePath}`
|
||||
: result.message;
|
||||
elements.publishResult.dataset.tone = "success";
|
||||
if (state.configType === "identification" && state.retryDeviceId) {
|
||||
await submitRetryReview();
|
||||
} else {
|
||||
setStatus("发布完成", "success");
|
||||
}
|
||||
});
|
||||
|
||||
window.reinloop.getDefaults().then((defaults) => {
|
||||
elements.apiUrl.value = defaults.apiUrl;
|
||||
state.defaultDeviceId = defaults.deviceId;
|
||||
elements.adminToken.placeholder = defaults.hasAdminToken
|
||||
? "已使用环境变量中的 Token"
|
||||
: "请输入 Admin Token";
|
||||
});
|
||||
|
||||
window.reinloop.onCsvUpdated((result) => {
|
||||
showImage(result);
|
||||
if (state.connected) void refreshIdentificationFiles();
|
||||
setStatus(`已接收 ${result.fileName}`, "success");
|
||||
});
|
||||
|
||||
window.reinloop.onCsvWatchError((message) => {
|
||||
setStatus("数据接收异常", "error");
|
||||
const pendingPlot = Object.values(elements.plots).find((plot) => plot.image.hidden);
|
||||
if (pendingPlot) pendingPlot.path.textContent = message;
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (state.connected) void refreshOrganizations(true);
|
||||
}, 10000);
|
||||
|
||||
renderConfig();
|
||||
|
||||
elements.closeLightbox.addEventListener("click", closeLightbox);
|
||||
elements.zoomIn.addEventListener("click", () => zoomLightbox(1.25));
|
||||
elements.zoomOut.addEventListener("click", () => zoomLightbox(0.8));
|
||||
elements.zoomFit.addEventListener("click", () => {
|
||||
state.lightbox.fit = true;
|
||||
updateLightboxImage();
|
||||
});
|
||||
elements.zoomReset.addEventListener("click", () => {
|
||||
state.lightbox.fit = false;
|
||||
state.lightbox.scale = 1;
|
||||
updateLightboxImage();
|
||||
});
|
||||
elements.lightbox.addEventListener("click", (event) => {
|
||||
if (event.target === elements.lightbox) closeLightbox();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeLightbox();
|
||||
if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25);
|
||||
if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #15251f;
|
||||
--muted: #617069;
|
||||
--line: #cfd7d2;
|
||||
--paper: #f2f4f1;
|
||||
--surface: #ffffff;
|
||||
--green: #146b4a;
|
||||
--green-dark: #0d4b34;
|
||||
--amber: #e7a928;
|
||||
--red: #ad342d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 900px;
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(rgba(20, 107, 74, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(20, 107, 74, 0.035) 1px, transparent 1px),
|
||||
var(--paper);
|
||||
background-size: 28px 28px;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
|
||||
.topbar {
|
||||
height: 104px;
|
||||
padding: 20px 36px;
|
||||
color: white;
|
||||
background: var(--ink);
|
||||
border-bottom: 5px solid var(--amber);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 28px; font-weight: 600; letter-spacing: 0; }
|
||||
h2 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 25px; font-weight: 600; letter-spacing: 0; }
|
||||
h3 { font-size: 15px; }
|
||||
.eyebrow, .section-kicker { font-size: 11px; letter-spacing: 0; font-weight: 700; }
|
||||
.eyebrow { color: #a9c1b6; margin-bottom: 5px; }
|
||||
.section-kicker { color: var(--green); margin-bottom: 5px; }
|
||||
|
||||
.status {
|
||||
min-width: 110px;
|
||||
max-width: 420px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #587067;
|
||||
border-radius: 4px;
|
||||
color: #d9e4df;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status[data-tone="busy"] { border-color: var(--amber); color: #ffd985; }
|
||||
.status[data-tone="success"] { border-color: #62b78f; color: #9fe1bf; }
|
||||
.status[data-tone="error"] { border-color: #df766e; color: #ffb5ae; }
|
||||
|
||||
main { max-width: 1500px; margin: 0 auto; padding: 22px 36px 36px; }
|
||||
.connection-screen {
|
||||
min-height: calc(100vh - 104px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 36px;
|
||||
}
|
||||
.connection-form {
|
||||
width: min(480px, 100%);
|
||||
padding: 30px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 4px solid var(--green);
|
||||
box-shadow: 0 18px 45px rgba(21, 37, 31, 0.12);
|
||||
}
|
||||
.connection-form h2 { margin-bottom: 6px; }
|
||||
.connection-form label { display: grid; gap: 7px; }
|
||||
.connection-form label span { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.connection-form .button { width: 100%; margin-top: 4px; }
|
||||
.connection-message { min-height: 20px; color: var(--muted); font-size: 12px; text-align: center; }
|
||||
.connection-message[data-tone="busy"] { color: #8a6414; }
|
||||
.connection-message[data-tone="error"] { color: var(--red); }
|
||||
.connection-band {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
padding: 15px 18px;
|
||||
background: #e4e9e5;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 4px solid var(--green);
|
||||
}
|
||||
.connection-band label { display: grid; grid-template-columns: 118px 1fr; align-items: center; gap: 10px; }
|
||||
.connection-band span { font-size: 12px; font-weight: 700; color: var(--muted); }
|
||||
input, select {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #b9c5be;
|
||||
border-radius: 3px;
|
||||
background: white;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20, 107, 74, 0.12); }
|
||||
|
||||
.tabs { display: flex; gap: 0; margin-top: 22px; border-bottom: 1px solid var(--line); }
|
||||
.tab {
|
||||
min-width: 132px;
|
||||
padding: 12px 20px;
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tab.active { color: var(--green-dark); border-bottom-color: var(--green); }
|
||||
|
||||
.panel { display: none; padding-top: 22px; }
|
||||
.panel.active { display: block; animation: reveal 180ms ease-out; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.panel-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.button {
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
.button.primary { color: white; background: var(--green); border-color: var(--green); }
|
||||
.button.primary:hover { background: var(--green-dark); }
|
||||
.button.secondary { color: var(--ink); background: white; border-color: #aebbb4; }
|
||||
.button.secondary:hover { border-color: var(--green); color: var(--green); }
|
||||
.button.danger { color: var(--red); background: white; border-color: #d5a7a3; }
|
||||
.button.danger:hover { color: white; background: var(--red); border-color: var(--red); }
|
||||
.button.icon { width: 38px; padding: 0; background: white; border-color: #aebbb4; font-size: 19px; }
|
||||
.plot-actions { display: flex; gap: 6px; }
|
||||
.button.full { width: 100%; margin-top: 10px; }
|
||||
.review-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.review-actions span { max-width: 320px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.plot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.plot-item { min-width: 0; }
|
||||
.plot-title {
|
||||
min-height: 54px;
|
||||
padding: 9px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.plot-title div { display: grid; gap: 3px; }
|
||||
.plot-title span { color: var(--green); font-size: 11px; font-weight: 700; }
|
||||
.plot-title strong { font-size: 15px; }
|
||||
.plot-stage {
|
||||
height: calc(100vh - 405px);
|
||||
min-height: 300px;
|
||||
max-height: 620px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
background-color: #dce2de;
|
||||
background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
border: 1px solid #bdc8c1;
|
||||
}
|
||||
.plot-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; background: white; }
|
||||
.empty-state { display: grid; gap: 7px; text-align: center; color: var(--muted); }
|
||||
.empty-state strong { color: var(--ink); font-size: 18px; }
|
||||
.empty-state span { font-size: 13px; }
|
||||
.plot-meta { min-height: 29px; display: flex; align-items: start; justify-content: space-between; gap: 24px; }
|
||||
.file-path { min-height: 20px; margin-top: 9px; color: var(--muted); font: 12px Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.upload-time { flex: 0 0 auto; margin-top: 9px; color: var(--green-dark); font-size: 12px; font-weight: 700; }
|
||||
|
||||
.segmented { display: flex; padding: 3px; background: #dfe5e1; border: 1px solid #c6d0ca; border-radius: 4px; }
|
||||
.segment { height: 34px; padding: 0 16px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font-weight: 700; }
|
||||
.segment.active { background: white; color: var(--green-dark); box-shadow: 0 1px 3px rgba(21, 37, 31, 0.14); }
|
||||
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; }
|
||||
.editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); }
|
||||
.editor-toolbar { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 700; }
|
||||
.text-button { border: 0; background: transparent; color: var(--green); font-size: 13px; font-weight: 700; }
|
||||
textarea {
|
||||
display: block;
|
||||
width: calc(100% - 28px);
|
||||
height: calc(100vh - 385px);
|
||||
min-height: 330px;
|
||||
margin: 14px;
|
||||
padding: 16px;
|
||||
resize: vertical;
|
||||
border: 1px solid #bec9c2;
|
||||
border-radius: 3px;
|
||||
background: #f8faf8;
|
||||
color: #18392d;
|
||||
font: 14px/1.65 Consolas, "Microsoft YaHei UI", monospace;
|
||||
tab-size: 2;
|
||||
outline: none;
|
||||
}
|
||||
.editor-column > .file-path { padding: 0 14px 12px; }
|
||||
.publish-aside { padding: 20px; align-self: start; }
|
||||
.publish-aside h3 { padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
dl { margin: 8px 0 18px; }
|
||||
dl div { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e6ebe8; font-size: 12px; }
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; text-align: right; font-weight: 700; }
|
||||
.result { min-height: 42px; margin-top: 14px; padding: 10px; background: #eef1ef; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.result[data-tone="success"] { color: var(--green-dark); background: #e2f2e9; }
|
||||
.result[data-tone="error"] { color: var(--red); background: #f8e7e5; }
|
||||
|
||||
.management-layout { display: grid; grid-template-columns: 330px minmax(0, 1fr); gap: 18px; align-items: start; }
|
||||
.management-layout.equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-surface, .data-surface { background: var(--surface); border: 1px solid var(--line); }
|
||||
.form-surface { padding: 20px; display: grid; gap: 14px; }
|
||||
.form-surface h3 { padding-bottom: 13px; border-bottom: 1px solid var(--line); }
|
||||
.form-surface label { display: grid; gap: 6px; }
|
||||
.form-surface label span { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.form-note { color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.data-surface { min-width: 0; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { padding: 11px 13px; border-bottom: 1px solid #e4e9e6; text-align: left; vertical-align: middle; }
|
||||
th { color: var(--muted); background: #edf1ee; font-size: 11px; }
|
||||
td:last-child { white-space: nowrap; }
|
||||
.table-action { border: 0; background: transparent; color: var(--green); font-weight: 700; margin-right: 10px; }
|
||||
.table-action.danger { color: var(--red); }
|
||||
.empty-row { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
|
||||
.detail-view { margin: 0; padding: 16px; max-height: 260px; overflow: auto; background: #18251f; color: #d9e9df; font: 12px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.lightbox { position: fixed; inset: 0; z-index: 20; padding: 24px; background: rgba(10, 20, 16, 0.78); }
|
||||
.lightbox-shell { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); background: var(--surface); border: 1px solid #9aa9a1; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); }
|
||||
.lightbox-toolbar { min-height: 58px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); }
|
||||
.lightbox-actions { display: flex; align-items: center; gap: 7px; }
|
||||
.lightbox-canvas { min-width: 0; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 22px; background-color: #dce2de; background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
|
||||
.lightbox-canvas img { display: block; max-width: none; background: white; }
|
||||
.lightbox-canvas img.fit-image { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
main { padding-left: 22px; padding-right: 22px; }
|
||||
.connection-band { grid-template-columns: 1fr; }
|
||||
.plot-grid { grid-template-columns: 1fr; }
|
||||
.plot-stage { height: 360px; }
|
||||
.editor-layout { grid-template-columns: minmax(0, 1fr) 240px; }
|
||||
.management-layout, .management-layout.equal { grid-template-columns: 1fr; }
|
||||
.lightbox { padding: 12px; }
|
||||
.lightbox-toolbar { align-items: start; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
# ReinLoop ControlPanel 功能总览
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
ControlPanel 是 ReinLoop 的 B 端管理工作台,用于管理公司与产线、签发许可证、管理模型、发布参数配置,并接收和审核 ReinLoop 上传的辨识数据。
|
||||
|
||||
桌面端基于 Electron,业务请求统一发送到 ReinLoop Server。渲染页面不直接访问文件系统、私钥或管理凭据,敏感操作通过 Electron 主进程完成。
|
||||
|
||||
## 2. 连接与鉴权
|
||||
|
||||
应用启动后首先显示连接页面,只包含:
|
||||
|
||||
- Server API URL
|
||||
- Admin Token
|
||||
- “连接并校验”按钮
|
||||
|
||||
点击连接后,主进程调用需要管理权限的 `listOrganizations` 接口,同时验证:
|
||||
|
||||
- Server 地址是否可访问
|
||||
- API 路径是否正确
|
||||
- Admin Token 是否有效
|
||||
|
||||
只有校验成功才显示后续业务工作台。连接失败时业务区域保持隐藏,并显示 Server 返回的错误信息。
|
||||
|
||||
连接信息仅保存在当前应用进程中:
|
||||
|
||||
- Token 不写入浏览器存储。
|
||||
- Token 不写入本地配置文件。
|
||||
- Token 只由 Electron 主进程附加到 Server 请求。
|
||||
- 本次应用会话不提供更改连接入口;需要切换 Server 或 Token 时重新启动应用。
|
||||
|
||||
支持通过环境变量提供默认值:
|
||||
|
||||
```text
|
||||
REINLOOP_API_URL
|
||||
B_ADMIN_TOKEN
|
||||
REINLOOP_DEVICE_ID
|
||||
POLL_INTERVAL_MS
|
||||
DOWNLOAD_DIR
|
||||
```
|
||||
|
||||
## 3. 公司与产线选择
|
||||
|
||||
连接成功后,工作台顶部显示公司和产线两个选择菜单。
|
||||
|
||||
设备业务标识由 Server 生成,格式固定为:
|
||||
|
||||
```text
|
||||
<company_code>/<production_line_code>
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
sample-co/line-1
|
||||
```
|
||||
|
||||
配置、模型、辨识数据、审核反馈和容积请求都使用同一个 `deviceId`,避免不同公司或产线的数据混用。
|
||||
|
||||
### 在线状态
|
||||
|
||||
公司菜单显示在线产线汇总,例如:
|
||||
|
||||
```text
|
||||
示例公司 (sample-co) · 在线 2/3
|
||||
```
|
||||
|
||||
产线菜单显示具体状态:
|
||||
|
||||
```text
|
||||
● 在线 · 一号产线 (line-1)
|
||||
○ 离线 · 二号产线 (line-2)
|
||||
◇ 状态未知 · 三号产线 (line-3)
|
||||
```
|
||||
|
||||
Panel 每 10 秒调用一次 `listOrganizations` 静默刷新状态。
|
||||
|
||||
Server 已实现设备心跳:
|
||||
|
||||
- ReinLoop 调用 `deviceHeartbeat` 更新产线的 `lastSeenAt`。
|
||||
- Server 以最近 30 秒是否收到心跳计算 `online`。
|
||||
- `listOrganizations` 返回每条产线的 `online` 和 `lastSeenAt`。
|
||||
- 旧数据缺少状态字段时,Panel 显示“状态未知”,不会误报在线或离线。
|
||||
|
||||
## 4. 组织管理
|
||||
|
||||
“组织管理”页面支持:
|
||||
|
||||
- 添加公司
|
||||
- 为指定公司添加产线
|
||||
- 刷新公司与产线列表
|
||||
|
||||
公司编码和产线编码只允许:
|
||||
|
||||
- 小写英文字母
|
||||
- 数字
|
||||
- 下划线
|
||||
- 连字符
|
||||
|
||||
编码长度为 2 到 64 位,并且必须以字母或数字开头。
|
||||
|
||||
公司编码由 Server 保证全局唯一;产线编码在同一公司内唯一。
|
||||
|
||||
相关 Server 接口:
|
||||
|
||||
```text
|
||||
listOrganizations
|
||||
createCompany
|
||||
createProductionLine
|
||||
```
|
||||
|
||||
## 5. 许可证签发与管理
|
||||
|
||||
“许可证”页面根据当前选择的公司和产线签发许可证。
|
||||
|
||||
签发字段包括:
|
||||
|
||||
- 公司
|
||||
- 产线
|
||||
- 组合设备 ID
|
||||
- 签发时间
|
||||
- 到期时间
|
||||
- 功能范围
|
||||
- 唯一许可证 ID
|
||||
|
||||
许可证载荷示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"license_id": "UUID",
|
||||
"customer": "示例公司",
|
||||
"company_id": "company_UUID",
|
||||
"production_line_id": "line_UUID",
|
||||
"device_id": "sample-co/line-1",
|
||||
"issued": "2026-07-25 12:00",
|
||||
"expiry": "2027-07-25 12:00",
|
||||
"features": "*"
|
||||
}
|
||||
```
|
||||
|
||||
### 签名格式
|
||||
|
||||
Panel 使用 RSA-PSS SHA-256 签名,与 ReinLoop 的 Python 验签逻辑兼容。
|
||||
|
||||
许可证文件格式:
|
||||
|
||||
```text
|
||||
base64(JSON)|base64(signature)
|
||||
```
|
||||
|
||||
签发核心会拒绝:
|
||||
|
||||
- 空公司或产线标识
|
||||
- 非法组合设备 ID
|
||||
- 无效日期格式
|
||||
- 到期时间不晚于签发时间
|
||||
- 空功能字段
|
||||
|
||||
### 私钥安全
|
||||
|
||||
- 每次签发由操作者选择外部 PEM 私钥。
|
||||
- 私钥只在 Electron 主进程内读取。
|
||||
- 私钥不会进入渲染页面。
|
||||
- 私钥不会上传 Server。
|
||||
- 私钥路径和内容不会由 Panel 持久化。
|
||||
- 私钥不会打进安装包。
|
||||
|
||||
### 本地与 Server 一致性
|
||||
|
||||
签发流程为:
|
||||
|
||||
1. 选择私钥。
|
||||
2. 选择本地许可证保存位置。
|
||||
3. 生成并写入本地许可证。
|
||||
4. 将签发结果登记到 Server。
|
||||
5. Server 登记失败时删除本次本地文件,避免出现半完成状态。
|
||||
|
||||
许可证管理支持:
|
||||
|
||||
- 刷新许可证列表
|
||||
- 查看许可证详情
|
||||
- 撤销有效许可证
|
||||
- 填写撤销原因
|
||||
- 区分有效和已撤销状态
|
||||
|
||||
相关 Server 接口:
|
||||
|
||||
```text
|
||||
createLicense
|
||||
listLicenses
|
||||
getLicense
|
||||
revokeLicense
|
||||
```
|
||||
|
||||
## 6. 模型管理
|
||||
|
||||
“模型管理”页面按当前产线操作:
|
||||
|
||||
```text
|
||||
<deviceId>/model_config
|
||||
```
|
||||
|
||||
支持:
|
||||
|
||||
- 刷新模型列表
|
||||
- 查看文件名、上传时间和大小
|
||||
- 从本地选择并上传模型
|
||||
- 同名模型覆盖前要求确认并输入完整文件名
|
||||
- 下载模型到本地 `downloads` 目录
|
||||
- 按 `fileID` 精确删除模型;删除前要求两次确认并输入完整文件名
|
||||
|
||||
上传使用 Server 的两步协议:
|
||||
|
||||
1. 调用 `uploadDataFile` 获取上传地址与凭证。
|
||||
2. 使用 multipart 表单上传文件内容。
|
||||
|
||||
相关 Server 接口:
|
||||
|
||||
```text
|
||||
listModels
|
||||
uploadDataFile
|
||||
downloadModel
|
||||
deleteFile
|
||||
```
|
||||
|
||||
## 7. 参数配置发布
|
||||
|
||||
“配置发布”页面支持两类参数:
|
||||
|
||||
- 容积测量配置
|
||||
- 系统辨识配置
|
||||
|
||||
用户可以:
|
||||
|
||||
- 直接编辑 JSON
|
||||
- 从本地导入 JSON
|
||||
- 从 Server 读取当前配置
|
||||
- 发布新配置
|
||||
|
||||
### 容积测量参数
|
||||
|
||||
严格包含 8 个字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 91,
|
||||
"dt": 0.1,
|
||||
"p_max": 200,
|
||||
"fit_low": 50,
|
||||
"fit_high": 200,
|
||||
"T_delta": 30,
|
||||
"xa_full": 1000,
|
||||
"num_runs": 6
|
||||
}
|
||||
```
|
||||
|
||||
Panel 在发布前检查:
|
||||
|
||||
- 配置必须是 JSON 对象
|
||||
- 字段不能缺失
|
||||
- 不能包含多余字段
|
||||
- 数值必须有限
|
||||
- `num_runs` 必须是整数
|
||||
|
||||
容积配置只会响应 ReinLoop 当前有效的一次性请求;没有待处理请求时 Server 拒绝提交。
|
||||
|
||||
### 系统辨识参数
|
||||
|
||||
严格包含 9 个字段:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
Panel 将其序列化为 `parameter,value` 两列 CSV,并发布到当前产线:
|
||||
|
||||
```text
|
||||
<deviceId>/identification_config/identification_config.csv
|
||||
```
|
||||
|
||||
## 8. 辨识数据接收与绘图
|
||||
|
||||
Panel 按当前产线轮询 Server 收件箱,不扫描 Server 文件目录。
|
||||
|
||||
默认轮询间隔为 1 秒,可通过 `POLL_INTERVAL_MS` 修改,最小允许值为 500 毫秒。
|
||||
|
||||
支持两类数据:
|
||||
|
||||
### 辨识 CSV
|
||||
|
||||
收到 CSV 后:
|
||||
|
||||
1. 下载到本地。
|
||||
2. 生成阀门开度与压力组合时序图。
|
||||
3. 在工作台中显示图片。
|
||||
4. 等待人工提交“通过”或“未通过”。
|
||||
|
||||
### 行程稳定压力 JSON
|
||||
|
||||
收到 JSON 后:
|
||||
|
||||
1. 下载到本地。
|
||||
2. 将行程与稳定压力绘制为固定 0-1000 行程范围的数值折线图,并显示各点坐标。
|
||||
3. 在工作台中显示图片。
|
||||
4. 绘图成功后确认已处理;文件保留在 Server 暂存区,可继续查看和下载。
|
||||
|
||||
绘图结果支持在系统文件管理器中定位,也支持在应用内放大、缩小、适应窗口和原始比例查看。
|
||||
|
||||
## 9. 辨识数据暂存
|
||||
|
||||
“辨识数据”页面按当前产线列出 Server 暂存的 CSV 和行程 JSON,支持:
|
||||
|
||||
- 查看并生成对应图像预览
|
||||
- 将原始 CSV 或 JSON 保存到用户选择的位置
|
||||
- 管理员二次确认后删除暂存数据
|
||||
- 查看待处理和已处理状态
|
||||
|
||||
文件下载请求携带 Admin Token;Server 可使用该令牌校验下载访问,或返回短期授权下载 URL。
|
||||
|
||||
## 10. 辨识人工审核
|
||||
|
||||
绘图页面提供:
|
||||
|
||||
- “通过”按钮
|
||||
- “未通过”按钮
|
||||
|
||||
### 通过
|
||||
|
||||
提交数字 `1`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "setIdentificationFeedback",
|
||||
"deviceId": "sample-co/line-1",
|
||||
"runId": "辨识 CSV 文件名",
|
||||
"result": 1
|
||||
}
|
||||
```
|
||||
|
||||
ReinLoop 获取结果后结束当前辨识流程。
|
||||
|
||||
### 未通过
|
||||
|
||||
提交数字 `0` 前,Panel 强制要求:
|
||||
|
||||
1. 选择一份新的系统辨识参数 JSON。
|
||||
2. 成功发布新的辨识 CSV 配置。
|
||||
3. 再提交未通过结果。
|
||||
|
||||
ReinLoop 获取数字 `0` 后重新下载配置并执行下一轮辨识。
|
||||
|
||||
### 消息可靠性
|
||||
|
||||
CSV 不会在绘图后立即从 Server 删除。
|
||||
|
||||
只有以下操作都成功后才确认消息:
|
||||
|
||||
1. 人工审核结论提交成功。
|
||||
2. Server 接受 `0/1` 反馈。
|
||||
3. Panel 调用 `ackPanelFile` 成功。
|
||||
|
||||
如果应用在审核前退出,CSV 仍保留在 Server,重新启动并选择同一产线后可以再次获取。
|
||||
|
||||
Panel 同一时间只处理一个待审核 CSV,避免多个审核结果串线。
|
||||
|
||||
## 10. Electron 安全边界
|
||||
|
||||
BrowserWindow 使用:
|
||||
|
||||
```text
|
||||
contextIsolation: true
|
||||
nodeIntegration: false
|
||||
sandbox: true
|
||||
```
|
||||
|
||||
渲染页面只能通过 preload 暴露的有限 IPC 调用主进程。
|
||||
|
||||
以下能力仅存在于主进程:
|
||||
|
||||
- Server Token 请求
|
||||
- 私钥读取和许可证签名
|
||||
- 本地文件选择与保存
|
||||
- 模型上传与下载
|
||||
- 绘图文件读取
|
||||
- 在文件管理器中定位文件
|
||||
|
||||
页面配置了 Content Security Policy,只允许加载应用自身脚本、样式和 data URL 图片。
|
||||
|
||||
## 11. 命令行兼容工具
|
||||
|
||||
除 Electron GUI 外,仍保留原有命令行能力:
|
||||
|
||||
```text
|
||||
b-admin.js
|
||||
poll-panel-inbox.js
|
||||
```
|
||||
|
||||
支持:
|
||||
|
||||
- 发布容积配置
|
||||
- 发布系统辨识配置
|
||||
- 读取配置
|
||||
- 轮询待处理数据
|
||||
- 命令行人工审核
|
||||
|
||||
Electron GUI 是主要管理入口,命令行工具用于调试和兼容既有流程。
|
||||
|
||||
## 12. 运行与打包
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
cd ControlPanel
|
||||
npm install
|
||||
```
|
||||
|
||||
启动 Electron:
|
||||
|
||||
```bash
|
||||
npm run gui
|
||||
```
|
||||
|
||||
运行语法检查:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
构建 Windows 安装版和便携版:
|
||||
|
||||
```bash
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
输出目录:
|
||||
|
||||
```text
|
||||
Build/
|
||||
```
|
||||
|
||||
构建内容包含:
|
||||
|
||||
- Electron 主进程和 preload
|
||||
- 页面文件
|
||||
- Server 客户端
|
||||
- 配置管理模块
|
||||
- 许可证签发模块
|
||||
- CSV/JSON 绘图模块
|
||||
- `canvas` 原生依赖
|
||||
|
||||
`canvas` 会从 ASAR 中解包,以便 Windows 原生模块正常加载。
|
||||
|
||||
## 13. 当前验证状态
|
||||
|
||||
已验证:
|
||||
|
||||
- 所有 Panel JavaScript 文件通过 `node --check`。
|
||||
- 许可证 RSA-PSS 签名可由对应公钥验证。
|
||||
- 非法身份、设备 ID 和时间范围会被拒绝。
|
||||
- 许可证签发单元测试通过。
|
||||
- 连接门禁首屏只显示 Server URL、Token 和校验按钮。
|
||||
- 未通过连接校验时业务区域保持隐藏。
|
||||
- 页面文件没有编辑器诊断错误。
|
||||
|
||||
当前 Linux 工作区中的完整绘图测试受原生 `canvas` 环境限制:已有 `canvas.node` 不是当前 Linux 可加载格式,源码重建又缺少系统 `pangocairo` 开发库。该限制不影响 JavaScript 语法和许可证测试,但 Windows 发布前仍需在目标构建环境执行完整绘图和打包验证。
|
||||
|
||||
## 14. 主要 Server 接口依赖
|
||||
|
||||
连接与组织:
|
||||
|
||||
```text
|
||||
listOrganizations
|
||||
createCompany
|
||||
createProductionLine
|
||||
```
|
||||
|
||||
许可证:
|
||||
|
||||
```text
|
||||
createLicense
|
||||
listLicenses
|
||||
getLicense
|
||||
revokeLicense
|
||||
```
|
||||
|
||||
模型与文件:
|
||||
|
||||
```text
|
||||
uploadDataFile
|
||||
listModels
|
||||
downloadModel
|
||||
deleteFile
|
||||
```
|
||||
|
||||
配置:
|
||||
|
||||
```text
|
||||
getPendingVolumeConfigRequest
|
||||
submitVolumeConfigFile
|
||||
publishIdentificationConfig
|
||||
getIdentificationConfig
|
||||
getFunctionConfig
|
||||
```
|
||||
|
||||
辨识与收件箱:
|
||||
|
||||
```text
|
||||
getPendingPanelFile
|
||||
ackPanelFile
|
||||
setIdentificationFeedback
|
||||
```
|
||||
|
||||
设备在线状态由 ReinLoop 调用:
|
||||
|
||||
```text
|
||||
deviceHeartbeat
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) throw new Error(`${fieldName}不能为空`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, fieldName) {
|
||||
const timestamp = requiredText(value, fieldName);
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(timestamp);
|
||||
if (!match) throw new Error(`${fieldName}格式必须为 YYYY-MM-DD HH:MM`);
|
||||
const [, year, month, day, hour, minute] = match.map(Number);
|
||||
const parsed = new Date(year, month - 1, day, hour, minute);
|
||||
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 ||
|
||||
parsed.getDate() !== day || parsed.getHours() !== hour || parsed.getMinutes() !== minute) {
|
||||
throw new Error(`${fieldName}不是有效日期时间`);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function signLicense(payload, privateKeyPath) {
|
||||
const licenseId = payload.license_id || crypto.randomUUID();
|
||||
const deviceId = requiredText(payload.device_id, "device_id");
|
||||
const deviceParts = deviceId.split("/");
|
||||
if (deviceParts.length !== 2 || deviceParts.some((part) => !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(part))) {
|
||||
throw new Error("device_id 必须是 company-code/line-code 格式");
|
||||
}
|
||||
const issued = normalizeTimestamp(payload.issued, "签发时间");
|
||||
const expiry = normalizeTimestamp(payload.expiry, "到期时间");
|
||||
if (expiry <= issued) throw new Error("到期时间必须晚于签发时间");
|
||||
const normalized = {
|
||||
license_id: licenseId,
|
||||
customer: requiredText(payload.customer, "customer"),
|
||||
company_id: requiredText(payload.company_id, "company_id"),
|
||||
production_line_id: requiredText(payload.production_line_id, "production_line_id"),
|
||||
device_id: deviceId,
|
||||
issued,
|
||||
expiry,
|
||||
features: requiredText(payload.features || "*", "features")
|
||||
};
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(normalized)).toString("base64");
|
||||
const privateKey = fs.readFileSync(privateKeyPath);
|
||||
const signature = crypto.sign("sha256", Buffer.from(payloadBase64), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX_SIGN
|
||||
});
|
||||
return {
|
||||
payload: normalized,
|
||||
content: `${payloadBase64}|${signature.toString("base64")}`
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { signLicense };
|
||||
Generated
+3998
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "reinloop-control-panel",
|
||||
"version": "1.0.1",
|
||||
"description": "ReinLoop B 端数据绘图与配置发布工具",
|
||||
"author": "ReinLoop",
|
||||
"private": true,
|
||||
"main": "electron-main.js",
|
||||
"scripts": {
|
||||
"start": "node poll-panel-inbox.js",
|
||||
"gui": "electron .",
|
||||
"pack:win": "npm run pack:installer && npm run pack:portable",
|
||||
"pack:installer": "electron-builder --win nsis -c.artifactName=ReinLoop-BConsole-Setup-${version}-${arch}.${ext}",
|
||||
"pack:portable": "electron-builder --win portable -c.artifactName=ReinLoop-BConsole-Portable-${version}-${arch}.${ext}",
|
||||
"pack:mac": "electron-builder --mac dmg zip --x64 --arm64 -c.artifactName=ReinLoop-BConsole-${version}-mac-${arch}.${ext}",
|
||||
"check": "node --check poll-panel-inbox.js && node --check server-client.js && node --check b-admin.js && node --check plot-json.js && node --check license-manager.js && node --check electron-main.js && node --check electron-preload.js && node --check electron-ui/renderer.js",
|
||||
"test": "node --test",
|
||||
"publish:volume": "node b-admin.js publish-volume",
|
||||
"publish:identification": "node b-admin.js publish-identification"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas": "^3.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-node-canvas": "^5.0.0",
|
||||
"csv-parse": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^37.2.6",
|
||||
"electron-builder": "24.13.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.reinloop.bconsole",
|
||||
"productName": "ReinLoop B端工作台",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"node_modules/canvas/**/*"
|
||||
],
|
||||
"files": [
|
||||
"electron-main.js",
|
||||
"electron-preload.js",
|
||||
"electron-ui/**/*",
|
||||
"b-admin.js",
|
||||
"license-manager.js",
|
||||
"server-client.js",
|
||||
"plot-csv.js",
|
||||
"plot-json.js",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"directories": {
|
||||
"output": "../Build"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.productivity"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"canvas@3.2.3": true,
|
||||
"electron@37.10.3": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { parse } = require("csv-parse/sync");
|
||||
const { ChartJSNodeCanvas } = require("chartjs-node-canvas");
|
||||
const { createCanvas, loadImage, registerFont } = require("canvas");
|
||||
|
||||
const CHART_WIDTH = 1400;
|
||||
const CHART_HEIGHT = 540;
|
||||
const CHINESE_FONT_PATH = "C:\\Windows\\Fonts\\msyh.ttc";
|
||||
|
||||
if (fs.existsSync(CHINESE_FONT_PATH)) {
|
||||
registerFont(CHINESE_FONT_PATH, { family: "Microsoft YaHei" });
|
||||
}
|
||||
|
||||
const chartCanvas = new ChartJSNodeCanvas({
|
||||
width: CHART_WIDTH,
|
||||
height: CHART_HEIGHT,
|
||||
backgroundColour: "white"
|
||||
});
|
||||
|
||||
async function renderTimeSeries(points, options) {
|
||||
return chartCanvas.renderToBuffer({
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
data: points.map((row) => ({ x: row.t, y: row[options.column] })),
|
||||
borderColor: options.color,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
stepped: options.stepped,
|
||||
tension: 0,
|
||||
fill: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
animation: false,
|
||||
parsing: false,
|
||||
layout: { padding: { top: 14, right: 34, bottom: 8, left: 24 } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: {
|
||||
display: true,
|
||||
text: options.title,
|
||||
color: "#222222",
|
||||
font: { family: "Microsoft YaHei", size: 21, weight: "normal" },
|
||||
padding: { bottom: 10 }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "linear",
|
||||
min: options.xMin,
|
||||
max: options.xMax,
|
||||
grid: { color: "#d8d8d8", lineWidth: 1 },
|
||||
border: { color: "#333333", width: 1.5 },
|
||||
ticks: { color: "#333333", font: { family: "Microsoft YaHei", size: 14 } },
|
||||
title: {
|
||||
display: true,
|
||||
text: "时间 (s)",
|
||||
color: "#333333",
|
||||
font: { family: "Microsoft YaHei", size: 17 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: options.yMax,
|
||||
grid: { color: "#d8d8d8", lineWidth: 1 },
|
||||
border: { color: "#333333", width: 1.5 },
|
||||
ticks: { color: "#333333", font: { family: "Microsoft YaHei", size: 14 } },
|
||||
title: {
|
||||
display: true,
|
||||
text: options.yLabel,
|
||||
color: "#333333",
|
||||
font: { family: "Microsoft YaHei", size: 17 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCombinedPlot(points, outputPath) {
|
||||
const minimumTime = Math.min(...points.map((row) => row.t));
|
||||
const maximumTime = Math.max(...points.map((row) => row.t));
|
||||
const timeSpan = Math.max(maximumTime - minimumTime, 1);
|
||||
const xMin = Math.min(0, minimumTime);
|
||||
const xMax = Math.ceil((maximumTime + timeSpan * 0.05) / 10) * 10;
|
||||
const maximumPressure = Math.max(...points.map((row) => row.p));
|
||||
const pressureStep = maximumPressure <= 100 ? 20 : 50;
|
||||
const pressureMax = Math.ceil((maximumPressure * 1.1) / pressureStep) * pressureStep;
|
||||
|
||||
const upperImage = await renderTimeSeries(points, {
|
||||
column: "u",
|
||||
color: "#304ffe",
|
||||
stepped: true,
|
||||
title: "阀门开度随时间变化",
|
||||
yLabel: "阀门开度 u (%)",
|
||||
xMin,
|
||||
xMax,
|
||||
yMax: 100
|
||||
});
|
||||
const lowerImage = await renderTimeSeries(points, {
|
||||
column: "p",
|
||||
color: "#f5222d",
|
||||
stepped: false,
|
||||
title: "压力随时间变化",
|
||||
yLabel: "压力 p (kPa)",
|
||||
xMin,
|
||||
xMax,
|
||||
yMax: pressureMax
|
||||
});
|
||||
|
||||
const canvas = createCanvas(CHART_WIDTH, CHART_HEIGHT * 2);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "white";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(await loadImage(upperImage), 0, 0);
|
||||
context.drawImage(await loadImage(lowerImage), 0, CHART_HEIGHT);
|
||||
|
||||
await fs.promises.writeFile(outputPath, canvas.toBuffer("image/png"));
|
||||
console.log(`[${new Date().toISOString()}] 已生成时序图: ${outputPath}`);
|
||||
}
|
||||
|
||||
async function plotCsv(csvPath) {
|
||||
const content = await fs.promises.readFile(csvPath, "utf8");
|
||||
const rows = parse(content, {
|
||||
bom: true,
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true
|
||||
});
|
||||
const requiredColumns = ["t", "u", "p"];
|
||||
const headers = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
const missingColumns = requiredColumns.filter((column) => !headers.includes(column));
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error(`CSV 缺少列: ${missingColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
const points = rows
|
||||
.map((row) => ({ t: Number(row.t), u: Number(row.u), p: Number(row.p) }))
|
||||
.filter((row) => Number.isFinite(row.t) && Number.isFinite(row.u) && Number.isFinite(row.p));
|
||||
if (points.length === 0) {
|
||||
throw new Error("CSV 中没有可绘制的 t、u、p 数值行");
|
||||
}
|
||||
|
||||
const parsedPath = path.parse(csvPath);
|
||||
const outputPath = path.join(parsedPath.dir, `${parsedPath.name}-u-p-t.png`);
|
||||
await renderCombinedPlot(points, outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
module.exports = { plotCsv };
|
||||
@@ -0,0 +1,135 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { ChartJSNodeCanvas } = require("chartjs-node-canvas");
|
||||
|
||||
const chartCanvas = new ChartJSNodeCanvas({
|
||||
width: 1400,
|
||||
height: 720,
|
||||
backgroundColour: "white"
|
||||
});
|
||||
|
||||
function normalizeJsonSeries(data) {
|
||||
if (Array.isArray(data)) {
|
||||
if (data.every(Number.isFinite)) {
|
||||
return { labels: data.map((_, index) => index), series: [{ label: "value", data }] };
|
||||
}
|
||||
if (data.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
||||
const numericFields = [...new Set(data.flatMap(Object.keys))]
|
||||
.filter((field) => data.some((item) => Number.isFinite(item[field])));
|
||||
const xField = ["t", "time", "x", "timestamp", "index", "distance"]
|
||||
.find((field) => numericFields.includes(field));
|
||||
const valueFields = numericFields.filter((field) => field !== xField);
|
||||
const isTravelStability = xField === "distance" && valueFields.length === 1 && valueFields[0] === "pressure";
|
||||
return {
|
||||
labels: data.map((item, index) => xField ? item[xField] : index),
|
||||
series: valueFields.map((field) => ({
|
||||
label: field,
|
||||
data: isTravelStability
|
||||
? data.filter((item) => Number.isFinite(item.distance) && Number.isFinite(item[field]))
|
||||
.map((item) => ({ x: item.distance, y: item[field] }))
|
||||
: data.map((item) => Number.isFinite(item[field]) ? item[field] : null)
|
||||
})),
|
||||
chartKind: isTravelStability ? "travel-stability" : "line",
|
||||
xLabel: xField === "distance" ? "行程" : "采样点 / 时间",
|
||||
yLabel: isTravelStability
|
||||
? "稳态压力 (kPa)"
|
||||
: "数值"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const arrays = Object.entries(data).filter(([, value]) => Array.isArray(value));
|
||||
if (arrays.length === 1) return normalizeJsonSeries(arrays[0][1]);
|
||||
|
||||
const numericArrays = arrays.filter(([, values]) =>
|
||||
values.length > 0 && values.every(Number.isFinite)
|
||||
);
|
||||
if (numericArrays.length > 0) {
|
||||
const xEntry = numericArrays.find(([field]) =>
|
||||
["t", "time", "x", "timestamp", "index"].includes(field)
|
||||
);
|
||||
const seriesEntries = numericArrays.filter(([field]) => !xEntry || field !== xEntry[0]);
|
||||
const pointCount = Math.max(...numericArrays.map(([, values]) => values.length));
|
||||
return {
|
||||
labels: xEntry ? xEntry[1] : Array.from({ length: pointCount }, (_, index) => index),
|
||||
series: seriesEntries.map(([label, values]) => ({ label, data: values }))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("JSON 必须包含数字数组、数值对象数组或多个数值数组字段");
|
||||
}
|
||||
|
||||
const travelPointLabels = {
|
||||
id: "travelPointLabels",
|
||||
afterDatasetsDraw(chart) {
|
||||
if (chart.options.plugins.travelPointLabels !== true) return;
|
||||
const { ctx } = chart;
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#15251f";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "bottom";
|
||||
for (const meta of chart.getSortedVisibleDatasetMetas()) {
|
||||
meta.data.forEach((element, index) => {
|
||||
const point = chart.data.datasets[meta.index].data[index];
|
||||
ctx.fillText(`(${point.x}, ${Number(point.y).toFixed(2)})`, element.x + 7, element.y - 7);
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
function buildChartConfiguration(normalized, title) {
|
||||
const isTravelStability = normalized.chartKind === "travel-stability";
|
||||
const colors = ["#d62828", "#0077b6", "#2a9d8f", "#f77f00", "#6a4c93", "#495057"];
|
||||
return {
|
||||
type: isTravelStability ? "scatter" : "line",
|
||||
data: {
|
||||
labels: isTravelStability ? undefined : normalized.labels,
|
||||
datasets: normalized.series.map((item, index) => ({
|
||||
...item,
|
||||
borderColor: colors[index % colors.length],
|
||||
borderWidth: 2,
|
||||
pointRadius: isTravelStability ? 4 : 0,
|
||||
pointHoverRadius: isTravelStability ? 7 : 3,
|
||||
showLine: isTravelStability,
|
||||
tension: 0,
|
||||
fill: false
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
animation: false,
|
||||
layout: isTravelStability ? { padding: { top: 24, right: 92 } } : undefined,
|
||||
plugins: {
|
||||
title: { display: true, text: title },
|
||||
legend: { display: true },
|
||||
travelPointLabels: isTravelStability
|
||||
},
|
||||
scales: {
|
||||
x: isTravelStability
|
||||
? { type: "linear", min: 0, max: 1000, title: { display: true, text: normalized.xLabel } }
|
||||
: { title: { display: true, text: normalized.xLabel } },
|
||||
y: { title: { display: true, text: normalized.yLabel } }
|
||||
}
|
||||
},
|
||||
plugins: isTravelStability ? [travelPointLabels] : []
|
||||
};
|
||||
}
|
||||
|
||||
async function plotJson(jsonPath) {
|
||||
const content = await fs.promises.readFile(jsonPath, "utf8");
|
||||
const normalized = normalizeJsonSeries(JSON.parse(content));
|
||||
if (normalized.series.length === 0) throw new Error("JSON 数组中没有可绘制的数值字段");
|
||||
const image = await chartCanvas.renderToBuffer(buildChartConfiguration(normalized, path.basename(jsonPath)));
|
||||
|
||||
const parsedPath = path.parse(jsonPath);
|
||||
const outputPath = path.join(parsedPath.dir, `${parsedPath.name}-line.png`);
|
||||
await fs.promises.writeFile(outputPath, image);
|
||||
console.log(`[${new Date().toISOString()}] 已生成 JSON 折线图: ${outputPath}`);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
module.exports = { buildChartConfiguration, normalizeJsonSeries, plotJson };
|
||||
@@ -0,0 +1,113 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline/promises");
|
||||
const { callServer, downloadFromUrl } = require("./server-client");
|
||||
const { publishConfig } = require("./b-admin");
|
||||
const { plotCsv } = require("./plot-csv");
|
||||
const { plotJson } = require("./plot-json");
|
||||
|
||||
const API_URL = process.env.REINLOOP_API_URL;
|
||||
const B_ADMIN_TOKEN = process.env.B_ADMIN_TOKEN;
|
||||
const DEVICE_ID = process.env.REINLOOP_DEVICE_ID;
|
||||
const REVIEW_MODE = process.env.REVIEW_MODE || "manual";
|
||||
const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 3000);
|
||||
|
||||
let polling = false;
|
||||
|
||||
async function reviewIdentification(message, imagePath) {
|
||||
if (REVIEW_MODE !== "manual") {
|
||||
console.log(`已生成 ${imagePath};REVIEW_MODE=${REVIEW_MODE},跳过人工评审`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
console.log(`请查看辨识图像: ${imagePath}`);
|
||||
let decision;
|
||||
while (decision !== 0 && decision !== 1) {
|
||||
const answer = (await prompt.question("参数是否通过?输入 1=通过,0=不通过: ")).trim();
|
||||
if (answer === "0" || answer === "1") decision = Number(answer);
|
||||
}
|
||||
|
||||
if (decision === 0) {
|
||||
const configPath = (await prompt.question("请输入新的函数2参数 JSON 路径: ")).trim();
|
||||
const config = JSON.parse(await fs.promises.readFile(path.resolve(configPath), "utf8"));
|
||||
await publishConfig("identification", config.parameters || config, {
|
||||
apiUrl: API_URL,
|
||||
adminToken: B_ADMIN_TOKEN,
|
||||
deviceId: DEVICE_ID
|
||||
});
|
||||
}
|
||||
|
||||
const result = await callServer({
|
||||
type: "setIdentificationFeedback",
|
||||
deviceId: DEVICE_ID,
|
||||
runId: message.fileName,
|
||||
result: decision
|
||||
});
|
||||
console.log(`辨识结果已提交: ${result.result === 1 ? "通过" : "不通过"}`);
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingMessage() {
|
||||
const message = await callServer({ type: "getPendingPanelFile", deviceId: DEVICE_ID });
|
||||
if (!message.pending) return false;
|
||||
|
||||
const sourcePath = await downloadFromUrl(message.url, message.fileName);
|
||||
if (message.mediaType === "json") {
|
||||
await plotJson(sourcePath);
|
||||
} else {
|
||||
const imagePath = await plotCsv(sourcePath);
|
||||
await reviewIdentification(message, imagePath);
|
||||
}
|
||||
|
||||
await callServer({
|
||||
type: "ackPanelFile",
|
||||
deviceId: DEVICE_ID,
|
||||
fileID: message.fileID
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
while (await processPendingMessage()) {
|
||||
// Drain messages already queued on the server before waiting again.
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${new Date().toISOString()}] 消息处理失败: ${error.message}`);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
if (!API_URL) throw new Error("缺少环境变量 REINLOOP_API_URL");
|
||||
if (!DEVICE_ID) throw new Error("缺少环境变量 REINLOOP_DEVICE_ID");
|
||||
if (REVIEW_MODE === "manual" && !B_ADMIN_TOKEN) {
|
||||
throw new Error("人工评审模式缺少环境变量 B_ADMIN_TOKEN");
|
||||
}
|
||||
if (!Number.isFinite(POLL_INTERVAL_MS) || POLL_INTERVAL_MS < 1000) {
|
||||
throw new Error("POLL_INTERVAL_MS 必须是大于或等于 1000 的数字");
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
validateConfig();
|
||||
console.log(`开始获取设备 ${DEVICE_ID} 的待处理消息,轮询间隔 ${POLL_INTERVAL_MS}ms`);
|
||||
void poll();
|
||||
setInterval(poll, POLL_INTERVAL_MS);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { processPendingMessage };
|
||||
@@ -0,0 +1,69 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Readable } = require("node:stream");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
const DOWNLOAD_DIR = path.resolve(process.env.DOWNLOAD_DIR || "downloads");
|
||||
|
||||
function getServerCredentials(options = {}) {
|
||||
const apiUrl = options.apiUrl || process.env.REINLOOP_API_URL;
|
||||
const adminToken = options.adminToken || process.env.B_ADMIN_TOKEN;
|
||||
if (!apiUrl) throw new Error("缺少 server API URL");
|
||||
if (!adminToken) throw new Error("缺少 Admin Token");
|
||||
return { apiUrl, adminToken };
|
||||
}
|
||||
|
||||
async function callServer(payload, options = {}) {
|
||||
const { apiUrl, adminToken } = getServerCredentials(options);
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, adminToken })
|
||||
});
|
||||
if (!response.ok) throw new Error(`server 请求失败: HTTP ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) throw new Error(result.errMsg || "server 返回失败");
|
||||
return result;
|
||||
}
|
||||
|
||||
function downloadHeaders(options = {}) {
|
||||
if (!options.adminToken) return {};
|
||||
return {
|
||||
authorization: `Bearer ${options.adminToken}`,
|
||||
"x-admin-token": options.adminToken
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromUrl(url, fileName, options = {}) {
|
||||
await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true });
|
||||
return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options);
|
||||
}
|
||||
|
||||
async function downloadToPath(url, destination, options = {}) {
|
||||
const response = await fetch(url, { headers: downloadHeaders(options) });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`下载失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
|
||||
const temporary = `${destination}.downloading`;
|
||||
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(temporary));
|
||||
await fs.promises.rename(temporary, destination);
|
||||
} catch (error) {
|
||||
await fs.promises.rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] 已下载: ${destination}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
async function downloadFile(fileID, fileName, options = {}) {
|
||||
const { url } = await callServer({ type: "downloadModel", fileID }, options);
|
||||
return downloadFromUrl(url, fileName, options);
|
||||
}
|
||||
|
||||
module.exports = { callServer, downloadFile, downloadFromUrl, downloadToPath, getServerCredentials };
|
||||
@@ -0,0 +1,60 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { after, before, test } = require("node:test");
|
||||
|
||||
const { signLicense } = require("../license-manager");
|
||||
|
||||
let privateKeyPath;
|
||||
let publicKey;
|
||||
let temporaryDirectory;
|
||||
|
||||
before(async () => {
|
||||
temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-license-"));
|
||||
const pair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
privateKeyPath = path.join(temporaryDirectory, "license_private.pem");
|
||||
publicKey = pair.publicKey;
|
||||
await fs.promises.writeFile(privateKeyPath, pair.privateKey.export({
|
||||
type: "pkcs8",
|
||||
format: "pem"
|
||||
}));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fs.promises.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function validPayload(overrides = {}) {
|
||||
return {
|
||||
customer: "示例公司",
|
||||
company_id: "company-1",
|
||||
production_line_id: "line-1",
|
||||
device_id: "sample-co/line-1",
|
||||
issued: "2026-07-25 12:00",
|
||||
expiry: "2027-07-25 12:00",
|
||||
features: "*",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("signLicense creates a Python-compatible RSA-PSS license", () => {
|
||||
const signed = signLicense(validPayload(), privateKeyPath);
|
||||
const [payloadBase64, signatureBase64] = signed.content.split("|");
|
||||
const verified = crypto.verify("sha256", Buffer.from(payloadBase64), {
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO
|
||||
}, Buffer.from(signatureBase64, "base64"));
|
||||
|
||||
assert.equal(verified, true);
|
||||
assert.deepEqual(JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8")), signed.payload);
|
||||
assert.match(signed.payload.license_id, /^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
test("signLicense rejects invalid identity and date ranges", () => {
|
||||
assert.throws(() => signLicense(validPayload({ device_id: "line-only" }), privateKeyPath), /device_id/);
|
||||
assert.throws(() => signLicense(validPayload({ company_id: "" }), privateKeyPath), /company_id/);
|
||||
assert.throws(() => signLicense(validPayload({ expiry: "2026-07-24 12:00" }), privateKeyPath), /到期时间/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
|
||||
const { buildChartConfiguration, normalizeJsonSeries } = require("../plot-json");
|
||||
|
||||
test("travel stability data plots pressure against motor distance", () => {
|
||||
const normalized = normalizeJsonSeries({
|
||||
stable_pressures: [
|
||||
{ distance: 1000, pressure: 12.3 },
|
||||
{ distance: 900, pressure: 15.6 },
|
||||
{ distance: 800, pressure: 18.1 }
|
||||
]
|
||||
});
|
||||
|
||||
assert.deepEqual(normalized.labels, [1000, 900, 800]);
|
||||
assert.deepEqual(normalized.series, [{
|
||||
label: "pressure",
|
||||
data: [
|
||||
{ x: 1000, y: 12.3 },
|
||||
{ x: 900, y: 15.6 },
|
||||
{ x: 800, y: 18.1 }
|
||||
]
|
||||
}]);
|
||||
assert.equal(normalized.chartKind, "travel-stability");
|
||||
assert.equal(normalized.xLabel, "行程");
|
||||
assert.equal(normalized.yLabel, "稳态压力 (kPa)");
|
||||
|
||||
const chart = buildChartConfiguration(normalized, "travel.json");
|
||||
assert.equal(chart.type, "scatter");
|
||||
assert.equal(chart.options.scales.x.type, "linear");
|
||||
assert.equal(chart.options.scales.x.min, 0);
|
||||
assert.equal(chart.options.scales.x.max, 1000);
|
||||
assert.equal(chart.options.plugins.travelPointLabels, true);
|
||||
assert.equal(chart.data.datasets[0].pointRadius, 4);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
Reference in New Issue
Block a user