From d45acaf50f75cc225882d35ebc4b2ab6751669ac Mon Sep 17 00:00:00 2001 From: Epifnne Date: Fri, 31 Jul 2026 10:20:33 +0800 Subject: [PATCH] add notice after config --- .gitattributes | 9 + ControlPanel/b-admin.js | 389 ++-- ControlPanel/electron-main.js | 77 +- ControlPanel/electron-preload.js | 5 +- ControlPanel/electron-ui/index.html | 7 +- ControlPanel/electron-ui/renderer.js | 204 +- ControlPanel/electron-ui/styles.css | 513 +++-- server/migrations/001_normalized_schema.sql | 22 +- server/src/app.js | 2159 ++++++++++--------- server/src/postgres-store.js | 10 +- server/src/store.js | 160 +- server/test/server.test.js | 1443 +++++++------ 12 files changed, 2721 insertions(+), 2277 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..71ff692 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 默认所有文本文件使用 Windows 换行符 +* text=auto eol=crlf + +# server 文件夹下所有文件使用 Linux 换行符 +server/** text eol=lf + +# 特定文件类型明确指定 +*.sh text eol=lf +*.bash text eol=lf \ No newline at end of file diff --git a/ControlPanel/b-admin.js b/ControlPanel/b-admin.js index 765137e..c8c7fb0 100644 --- a/ControlPanel/b-admin.js +++ b/ControlPanel/b-admin.js @@ -1,191 +1,200 @@ -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 [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 +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) + }; + } + const result = await callServer({ + type: "getVolumeConfigFile", + deviceId: getDeviceId(options) + }, options); + if (result.found === false || !result.url) { + throw new Error("当前产线尚无已成功提交的容积配置"); + } + const response = await fetch(result.url); + if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`); + return JSON.parse(await response.text()); +} + +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 [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 }; \ No newline at end of file diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js index 9c079ea..101748a 100644 --- a/ControlPanel/electron-main.js +++ b/ControlPanel/electron-main.js @@ -10,6 +10,9 @@ 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 IDENTIFICATION_FIELDS = [ + "q_in_val", "dt", "n_order", "t_c", "levels", "dead_area", "xa_full", "V_val", "repeat" +]; const DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com"; const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL; const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); @@ -35,13 +38,42 @@ function startPanelInboxPoller(window) { } let polling = false; + let notificationDeviceId = null; + const deliveredNotificationIds = new Set(); + let notificationFailureReported = false; + + async function pollNotifications(requestContext) { + if (notificationDeviceId !== requestContext.deviceId) { + notificationDeviceId = requestContext.deviceId; + deliveredNotificationIds.clear(); + } + const pending = await callServer( + { type: "getPendingPanelNotification", deviceId: requestContext.deviceId }, + requestContext + ); + notificationFailureReported = false; + const notification = pending.notification; + if (!pending.pending || !notification?.notificationId) return; + if (deliveredNotificationIds.has(notification.notificationId)) return; + deliveredNotificationIds.add(notification.notificationId); + window.webContents.send("panel:notification", notification); + } + const poll = async () => { if (polling || window.isDestroyed()) return; if (!connectionState.deviceId || !connectionState.adminToken) return; - if (pendingReview) return; const requestContext = { ...connectionState }; polling = true; try { + try { + await pollNotifications(requestContext); + } catch (error) { + if (!notificationFailureReported) { + console.warn(`[${new Date().toISOString()}] Panel 通知轮询不可用: ${error.message}`); + notificationFailureReported = true; + } + } + if (pendingReview) return; const pending = await callServer( { type: "getPendingPanelFile", deviceId: requestContext.deviceId }, requestContext @@ -128,7 +160,35 @@ function validateParameters(configType, parameters) { if (!Number.isInteger(parameters.num_runs)) { throw new Error("num_runs 必须是整数"); } + return; } + + if (configType === "identification") { + const fields = Object.keys(parameters); + const missing = IDENTIFICATION_FIELDS.filter((field) => !(field in parameters)); + const extra = fields.filter((field) => !IDENTIFICATION_FIELDS.includes(field)); + if (missing.length || extra.length) { + throw new Error(`系统辨识配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`); + } + for (const field of IDENTIFICATION_FIELDS) { + if (field === "levels") continue; + if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) { + throw new Error(`${field} 必须是有效数字`); + } + } + if (!Array.isArray(parameters.levels) || parameters.levels.some((value) => typeof value !== "number" || !Number.isFinite(value))) { + throw new Error("levels 必须是有效数字数组"); + } + if (parameters.levels.length < 2 || (parameters.levels.length & (parameters.levels.length - 1)) !== 0) { + throw new Error("levels 元素个数必须是不小于 2 的 2 的整数次幂"); + } + if (!Number.isInteger(parameters.n_order) || !Number.isInteger(parameters.repeat)) { + throw new Error("n_order 和 repeat 必须是整数"); + } + return; + } + + throw new Error(`不支持的配置类型: ${configType}`); } function toDataUrl(filePath) { @@ -155,6 +215,21 @@ function registerHandlers() { return { success: true }; }); + ipcMain.handle("panel:ack-notification", (_event, request) => + callServer({ + type: "ackPanelNotification", + deviceId: request.deviceId, + notificationId: request.notificationId + }, request.credentials)); + ipcMain.handle("panel:open-notification-file", async (_event, request) => { + if (!request.fileID) throw new Error("该提醒未关联结果文件"); + const download = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials); + const sourcePath = await downloadFromUrl(download.url, download.fileName || "volume_result.json", request.credentials); + const error = await shell.openPath(sourcePath); + if (error) throw new Error(`无法打开结果文件: ${error}`); + return { filePath: sourcePath }; + }); + ipcMain.handle("connection:test", async (_event, request) => { const result = await callServer({ type: "listOrganizations" }, request); connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); diff --git a/ControlPanel/electron-preload.js b/ControlPanel/electron-preload.js index d89a11e..af6982b 100644 --- a/ControlPanel/electron-preload.js +++ b/ControlPanel/electron-preload.js @@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld("reinloop", { previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request), downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request), deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request), + acknowledgeNotification: (request) => ipcRenderer.invoke("panel:ack-notification", request), + openNotificationFile: (request) => ipcRenderer.invoke("panel:open-notification-file", request), onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)), - onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message)) + onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message)), + onPanelNotification: (callback) => ipcRenderer.on("panel:notification", (_event, notification) => callback(notification)) }); \ No newline at end of file diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index b8a67e2..24a655d 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -43,7 +43,7 @@ - + @@ -139,14 +139,15 @@ +
容积测量配置
- -

可直接编辑,或从本地 JSON 导入

+
+

字段名称固定;可填写右侧数据或从本地 JSON 导入