fix model del & rename

This commit is contained in:
2026-07-31 11:15:54 +08:00
parent d45acaf50f
commit feaddfee7b
8 changed files with 196 additions and 28 deletions
+5 -19
View File
@@ -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({
+1 -1
View File
@@ -172,7 +172,7 @@
</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>
<table><thead><tr><th>服务器文件名</th><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>
+10 -6
View File
@@ -281,7 +281,7 @@ async function refreshModels() {
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>
<tr><td>${escapeHtml(model.fileName)}</td><td>${escapeHtml(model.originalFileName || 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;
@@ -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");
+44
View File
@@ -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 };
@@ -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" });
});
+61
View File
@@ -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);
});