From d45acaf50f75cc225882d35ebc4b2ab6751669ac Mon Sep 17 00:00:00 2001 From: Epifnne Date: Fri, 31 Jul 2026 10:20:33 +0800 Subject: [PATCH 1/4] 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 导入

-
文件名上传时间大小操作
+
服务器文件名原始文件名上传时间大小操作

选择公司和产线后刷新模型列表

diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index c0566c5..e1aa8c4 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -281,7 +281,7 @@ async function refreshModels() { if (!result) return; state.models = result.fileList; elements.modelList.innerHTML = state.models.map((model) => ` - ${escapeHtml(model.fileName)}${escapeHtml(model.uploadTime || "-")} + ${escapeHtml(model.fileName)}${escapeHtml(model.originalFileName || model.fileName)}${escapeHtml(model.uploadTime || "-")} ${formatSize(model.size)} `).join(""); elements.modelEmpty.hidden = state.models.length > 0; @@ -698,12 +698,16 @@ document.querySelector("#upload-model").addEventListener("click", async () => { state.models = models.fileList; const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile()); if (!selected) return; - const existing = state.models.find((model) => model.fileName === selected.fileName); + const enteredModelName = window.prompt("请输入模型在服务器上的文件名:", selected.fileName); + if (enteredModelName === null) return; + const modelName = enteredModelName.trim(); + if (!modelName) return showError(new Error("服务器文件名不能为空")); + const existing = state.models.find((model) => model.fileName === modelName); let overwrite = false; if (existing) { - if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return; - const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`); - if (confirmation !== selected.fileName) { + if (!window.confirm(`服务器已存在模型 ${modelName},覆盖后无法恢复。是否继续?`)) return; + const confirmation = window.prompt(`请输入服务器文件名以确认覆盖:${modelName}`); + if (confirmation !== modelName) { setStatus("文件名不匹配,已取消覆盖", "idle"); return; } @@ -711,7 +715,7 @@ document.querySelector("#upload-model").addEventListener("click", async () => { } const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({ deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName, - overwrite, credentials: credentials() + modelName, overwrite, credentials: credentials() })); if (result) { setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success"); diff --git a/ControlPanel/model-handlers.js b/ControlPanel/model-handlers.js new file mode 100644 index 0000000..b0108a4 --- /dev/null +++ b/ControlPanel/model-handlers.js @@ -0,0 +1,44 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +function createModelHandlers({ callServer, fetchImpl = fetch }) { + return { + list(request) { + return callServer({ + type: "listModels", + folder: `${request.deviceId}/model_config` + }, request.credentials); + }, + + async upload(request) { + if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件"); + const sourcePath = path.resolve(request.sourcePath); + const fileName = path.basename(request.fileName); + const modelName = path.basename(request.modelName || fileName); + await fs.promises.access(sourcePath, fs.constants.R_OK); + const issued = await callServer({ + type: "uploadDataFile", + fileName, + folder: `${request.deviceId}/model_config`, + modelName, + overwrite: request.overwrite === true + }, request.credentials); + const form = new FormData(); + form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName); + const response = await fetchImpl(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: issued.fileName, + originalFileName: issued.originalFileName + }; + }, + + delete(request) { + return callServer({ type: "deleteModel", fileID: request.fileID }, request.credentials); + } + }; +} + +module.exports = { createModelHandlers }; \ No newline at end of file diff --git a/ControlPanel/test/model-handlers.integration.test.js b/ControlPanel/test/model-handlers.integration.test.js new file mode 100644 index 0000000..7c1a4fb --- /dev/null +++ b/ControlPanel/test/model-handlers.integration.test.js @@ -0,0 +1,59 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { createModelHandlers } = require("../model-handlers"); +const { callServer } = require("../server-client"); +const { createApp } = require("../../server/src/app"); +const { JsonStore } = require("../../server/src/store"); + +test("panel model kernel renames, overwrites, lists both names, and deletes on the server", async (context) => { + const dataDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-panel-server-")); + const store = new JsonStore(dataDirectory); + await store.initialize(); + const app = createApp({ store, adminToken: "test-token" }); + const server = await new Promise((resolve) => { + const listeningServer = app.listen(0, "127.0.0.1", () => resolve(listeningServer)); + }); + context.after(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await fs.promises.rm(dataDirectory, { recursive: true, force: true }); + }); + + const credentials = { + apiUrl: `http://127.0.0.1:${server.address().port}/api`, + adminToken: "test-token" + }; + const handlers = createModelHandlers({ callServer }); + const sourcePath = path.join(dataDirectory, "controller-original.bin"); + const request = { + deviceId: "company-a/line-1", + sourcePath, + fileName: "controller-original.bin", + modelName: "pressure-controller.bin", + credentials + }; + + await fs.promises.writeFile(sourcePath, "first-version"); + const uploaded = await handlers.upload(request); + assert.equal(uploaded.fileName, "pressure-controller.bin"); + assert.equal(uploaded.originalFileName, "controller-original.bin"); + + await fs.promises.writeFile(sourcePath, "second-version"); + const overwritten = await handlers.upload({ ...request, overwrite: true }); + assert.equal(overwritten.fileID, uploaded.fileID); + + const listed = await handlers.list({ deviceId: request.deviceId, credentials }); + assert.equal(listed.fileList.length, 1); + assert.equal(listed.fileList[0].fileName, "pressure-controller.bin"); + assert.equal(listed.fileList[0].originalFileName, "controller-original.bin"); + + const deleted = await handlers.delete({ fileID: overwritten.fileID, credentials }); + assert.equal(deleted.deletedCount, 1); + const afterDelete = await handlers.list({ deviceId: request.deviceId, credentials }); + assert.deepEqual(afterDelete.fileList, []); + await assert.rejects(fs.promises.access( + path.join(dataDirectory, "models", "company-a", "line-1", "pressure-controller.bin") + ), { code: "ENOENT" }); +}); \ No newline at end of file diff --git a/ControlPanel/test/model-handlers.test.js b/ControlPanel/test/model-handlers.test.js new file mode 100644 index 0000000..0ea9991 --- /dev/null +++ b/ControlPanel/test/model-handlers.test.js @@ -0,0 +1,61 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { createModelHandlers } = require("../model-handlers"); + +test("panel model kernel renames, overwrites, and deletes by fileID", async (context) => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-panel-model-")); + context.after(() => fs.promises.rm(directory, { recursive: true, force: true })); + const sourcePath = path.join(directory, "controller-original.bin"); + await fs.promises.writeFile(sourcePath, "first-version"); + + const records = new Map(); + const requests = []; + const callServer = async (payload) => { + requests.push(payload); + if (payload.type === "uploadDataFile") { + const fileID = `model://${payload.folder.replace(/\/model_config$/, "")}/${payload.modelName}`; + if (records.has(fileID) && !payload.overwrite) throw new Error("文件已存在"); + records.set(fileID, { + fileID, + fileName: payload.modelName, + originalFileName: payload.fileName + }); + return { ...records.get(fileID), uploadMetadata: { url: `memory://${encodeURIComponent(fileID)}` } }; + } + if (payload.type === "deleteModel") { + const deleted = records.delete(payload.fileID); + return { success: true, deletedCount: deleted ? 1 : 0 }; + } + throw new Error(`unexpected request: ${payload.type}`); + }; + const handlers = createModelHandlers({ + callServer, + fetchImpl: async () => ({ status: 204 }) + }); + const request = { + deviceId: "company-a/line-1", + sourcePath, + fileName: "controller-original.bin", + modelName: "pressure-controller.bin", + credentials: { adminToken: "test-token" } + }; + + const uploaded = await handlers.upload(request); + assert.equal(uploaded.fileName, "pressure-controller.bin"); + assert.equal(uploaded.originalFileName, "controller-original.bin"); + assert.equal(records.size, 1); + + await fs.promises.writeFile(sourcePath, "second-version"); + const overwritten = await handlers.upload({ ...request, overwrite: true }); + assert.equal(overwritten.fileID, uploaded.fileID); + assert.equal(records.size, 1); + + const deleted = await handlers.delete({ fileID: overwritten.fileID, credentials: request.credentials }); + assert.equal(deleted.deletedCount, 1); + assert.equal(records.size, 0); + assert.equal(requests.at(-1).type, "deleteModel"); + assert.equal(requests.at(-1).fileID, overwritten.fileID); +}); \ No newline at end of file diff --git a/server/src/app.js b/server/src/app.js index 780d376..c5581db 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -611,8 +611,9 @@ function createApp({ } if (!records.length) return { success: false, errMsg: "数据库中未找到对应记录" }; let deletedCount = 0; - for (const record of records) deletedCount += await removeFile(database, record.fileID); - return { success: true, deletedFileID: records.map((record) => record.fileID).join(", "), deletedCount }; + const fileIDs = [...new Set(records.map((record) => record.fileID))]; + for (const fileID of fileIDs) deletedCount += await removeFile(database, fileID); + return { success: true, deletedFileID: fileIDs.join(", "), deletedCount }; }); } case "publishIdentificationConfig": { diff --git a/server/test/server.test.js b/server/test/server.test.js index 6f5d0de..5000abb 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -244,6 +244,19 @@ test("model upload requires explicit overwrite for an existing file", async () = await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "overwrite-line", "controller.bin"), "utf8"), "second-version" ); + + const deleted = await post({ + type: "deleteModel", fileID: replacement.fileID, adminToken: "test-token" + }); + assert.equal(deleted.success, true); + assert.equal(deleted.deletedCount, 1); + const listed = await post({ + type: "listModels", folder: "company-a/overwrite-line/model_config" + }); + assert.deepEqual(listed.fileList, []); + await assert.rejects(fs.promises.access( + path.join(dataDirectory, "models", "company-a", "overwrite-line", "controller.bin") + ), { code: "ENOENT" }); }); test("panel consumes uploaded device files from an inbox without scanning folders", async () => { From 46c69607c55f4e70dc8a8e89f9c34686f0931191 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Fri, 31 Jul 2026 11:26:38 +0800 Subject: [PATCH 3/4] fix model del & rename --- ControlPanel/electron-ui/index.html | 18 ++++ ControlPanel/electron-ui/renderer.js | 122 +++++++++++++++++++---- ControlPanel/electron-ui/styles.css | 10 ++ ControlPanel/model-handlers.js | 4 +- ControlPanel/test/model-handlers.test.js | 5 +- 5 files changed, 134 insertions(+), 25 deletions(-) diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index c1510af..856d879 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -222,6 +222,24 @@ +