diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js index 101748a..c2404c1 100644 --- a/ControlPanel/electron-main.js +++ b/ControlPanel/electron-main.js @@ -6,6 +6,7 @@ const { callServer, downloadFromUrl, downloadToPath } = require("./server-client const { signLicense } = require("./license-manager"); const { plotCsv } = require("./plot-csv"); const { plotJson } = require("./plot-json"); +const { createModelHandlers } = require("./model-handlers"); const VOLUME_FIELDS = [ "q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs" @@ -198,6 +199,7 @@ function toDataUrl(filePath) { } function registerHandlers() { + const modelHandlers = createModelHandlers({ callServer }); ipcMain.handle("app:get-defaults", () => ({ apiUrl: API_URL, deviceId: process.env.REINLOOP_DEVICE_ID || "", @@ -331,35 +333,19 @@ function registerHandlers() { return result; }); - ipcMain.handle("model:list", (_event, request) => - callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials)); + ipcMain.handle("model:list", (_event, request) => modelHandlers.list(request)); 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:upload", (_event, request) => modelHandlers.upload(request)); 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("model:delete", (_event, request) => modelHandlers.delete(request)); ipcMain.handle("identification:list", (_event, request) => callServer({ diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index 24a655d..c1510af 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -172,7 +172,7 @@
-
文件名上传时间大小操作
+
服务器文件名原始文件名上传时间大小操作

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

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 () => {