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