diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js
index e1aa8c4..80ee4c7 100644
--- a/ControlPanel/electron-ui/renderer.js
+++ b/ControlPanel/electron-ui/renderer.js
@@ -96,6 +96,14 @@ const elements = {
reviewTarget: document.querySelector("#review-target"),
approveReview: document.querySelector("#approve-review"),
rejectReview: document.querySelector("#reject-review"),
+ actionDialog: document.querySelector("#action-dialog"),
+ actionDialogForm: document.querySelector("#action-dialog-form"),
+ actionDialogTitle: document.querySelector("#action-dialog-title"),
+ actionDialogMessage: document.querySelector("#action-dialog-message"),
+ actionDialogInput: document.querySelector("#action-dialog-input"),
+ actionDialogError: document.querySelector("#action-dialog-error"),
+ actionDialogCancel: document.querySelector("#action-dialog-cancel"),
+ actionDialogConfirm: document.querySelector("#action-dialog-confirm"),
lightbox: document.querySelector("#image-lightbox"),
lightboxTitle: document.querySelector("#lightbox-title"),
lightboxImage: document.querySelector("#lightbox-image"),
@@ -346,6 +354,65 @@ function showError(error) {
elements.publishResult.dataset.tone = "error";
}
+let actionDialogState = null;
+
+function closeActionDialog(value) {
+ if (!actionDialogState) return;
+ const { resolve, previousFocus } = actionDialogState;
+ actionDialogState = null;
+ elements.actionDialog.hidden = true;
+ elements.actionDialogForm.reset();
+ elements.actionDialogError.hidden = true;
+ previousFocus?.focus();
+ resolve(value);
+}
+
+function requestModelName({ title, message, value = "", confirmLabel = "确认", danger = false, expectedValue = null }) {
+ if (actionDialogState) closeActionDialog(null);
+ elements.actionDialogTitle.textContent = title;
+ elements.actionDialogMessage.textContent = message;
+ elements.actionDialogInput.value = value;
+ elements.actionDialogConfirm.textContent = confirmLabel;
+ elements.actionDialogConfirm.classList.toggle("primary", !danger);
+ elements.actionDialogConfirm.classList.toggle("danger", danger);
+ elements.actionDialogError.hidden = true;
+ elements.actionDialog.hidden = false;
+ const previousFocus = document.activeElement;
+ window.requestAnimationFrame(() => {
+ elements.actionDialogInput.focus();
+ elements.actionDialogInput.select();
+ });
+ return new Promise((resolve) => {
+ actionDialogState = { resolve, previousFocus, expectedValue };
+ });
+}
+
+elements.actionDialogForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const value = elements.actionDialogInput.value.trim();
+ const expectedValue = actionDialogState?.expectedValue;
+ if (!value) {
+ elements.actionDialogError.textContent = "文件名不能为空";
+ elements.actionDialogError.hidden = false;
+ elements.actionDialogInput.focus();
+ return;
+ }
+ if (expectedValue !== null && value !== expectedValue) {
+ elements.actionDialogError.textContent = "文件名不匹配,请输入完整文件名";
+ elements.actionDialogError.hidden = false;
+ elements.actionDialogInput.focus();
+ return;
+ }
+ closeActionDialog(value);
+});
+elements.actionDialogCancel.addEventListener("click", () => closeActionDialog(null));
+elements.actionDialog.addEventListener("click", (event) => {
+ if (event.target === elements.actionDialog) closeActionDialog(null);
+});
+document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && !elements.actionDialog.hidden) closeActionDialog(null);
+});
+
function showImage(result) {
if (!result) return;
const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json")
@@ -698,19 +765,24 @@ document.querySelector("#upload-model").addEventListener("click", async () => {
state.models = models.fileList;
const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile());
if (!selected) return;
- const enteredModelName = window.prompt("请输入模型在服务器上的文件名:", selected.fileName);
- if (enteredModelName === null) return;
- const modelName = enteredModelName.trim();
- if (!modelName) return showError(new Error("服务器文件名不能为空"));
+ const modelName = await requestModelName({
+ title: "设置服务器文件名",
+ message: `已选择 ${selected.fileName}。可在上传前修改模型在服务器上的文件名。`,
+ value: selected.fileName,
+ confirmLabel: "继续上传"
+ });
+ if (modelName === null) return;
const existing = state.models.find((model) => model.fileName === modelName);
let overwrite = false;
if (existing) {
- if (!window.confirm(`服务器已存在模型 ${modelName},覆盖后无法恢复。是否继续?`)) return;
- const confirmation = window.prompt(`请输入服务器文件名以确认覆盖:${modelName}`);
- if (confirmation !== modelName) {
- setStatus("文件名不匹配,已取消覆盖", "idle");
- return;
- }
+ const confirmation = await requestModelName({
+ title: "确认覆盖模型",
+ message: `服务器已存在 ${modelName},覆盖后无法恢复。请输入完整文件名以确认。`,
+ confirmLabel: "覆盖并上传",
+ danger: true,
+ expectedValue: modelName
+ });
+ if (confirmation === null) return;
overwrite = true;
}
const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({
@@ -726,25 +798,33 @@ document.querySelector("#upload-model").addEventListener("click", async () => {
}
});
elements.modelList.addEventListener("click", async (event) => {
- const fileID = event.target.dataset.modelDownload || event.target.dataset.modelDelete;
+ const button = event.target.closest("button");
+ if (!button || !elements.modelList.contains(button)) return;
+ const fileID = button.dataset.modelDownload || button.dataset.modelDelete;
if (!fileID) return;
const model = state.models.find((item) => item.fileID === fileID);
- if (event.target.dataset.modelDownload) {
+ if (!model) return showError(new Error("模型记录已变化,请刷新列表后重试"));
+ if (button.dataset.modelDownload) {
const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() }));
if (result) setStatus(`模型已下载: ${result.filePath}`, "success");
} else {
- if (!window.confirm(`确认删除模型 ${model.fileName}?`)) return;
- const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`);
- if (confirmation !== model.fileName) {
- setStatus("文件名不匹配,已取消删除", "idle");
- return;
- }
- event.target.disabled = true;
+ const confirmation = await requestModelName({
+ title: "确认删除模型",
+ message: `删除 ${model.fileName} 后无法恢复。请输入完整文件名以确认。`,
+ confirmLabel: "永久删除",
+ danger: true,
+ expectedValue: model.fileName
+ });
+ if (confirmation === null) return;
+ button.disabled = true;
try {
const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() }));
- if (result) await refreshModels();
+ if (result) {
+ await refreshModels();
+ setStatus("模型已删除", "success");
+ }
} finally {
- event.target.disabled = false;
+ button.disabled = false;
}
}
});
diff --git a/ControlPanel/electron-ui/styles.css b/ControlPanel/electron-ui/styles.css
index b1ac353..dfcc0d5 100644
--- a/ControlPanel/electron-ui/styles.css
+++ b/ControlPanel/electron-ui/styles.css
@@ -237,6 +237,16 @@ td:last-child { white-space: nowrap; }
.empty-row { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
.detail-view { margin: 0; padding: 16px; max-height: 260px; overflow: auto; background: #18251f; color: #d9e9df; font: 12px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
+.action-dialog { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 24px; background: rgba(10, 20, 16, 0.68); }
+.action-dialog-shell { width: min(520px, 100%); padding: 24px; display: grid; gap: 17px; background: var(--surface); border: 1px solid #9aa9a1; border-top: 4px solid var(--green); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); }
+.action-dialog-head { display: grid; gap: 2px; }
+.action-dialog-message { color: var(--muted); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
+.action-dialog-field { display: grid; gap: 7px; }
+.action-dialog-field span { color: var(--muted); font-size: 12px; font-weight: 700; }
+.action-dialog-field input { width: 100%; }
+.action-dialog-error { color: var(--red); font-size: 12px; }
+.action-dialog-actions { display: flex; justify-content: end; gap: 8px; padding-top: 3px; }
+
.lightbox { position: fixed; inset: 0; z-index: 20; padding: 24px; background: rgba(10, 20, 16, 0.78); }
.lightbox-shell { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); background: var(--surface); border: 1px solid #9aa9a1; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); }
.lightbox-toolbar { min-height: 58px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); }
diff --git a/ControlPanel/model-handlers.js b/ControlPanel/model-handlers.js
index b0108a4..72d4dd9 100644
--- a/ControlPanel/model-handlers.js
+++ b/ControlPanel/model-handlers.js
@@ -17,9 +17,9 @@ function createModelHandlers({ callServer, fetchImpl = fetch }) {
const modelName = path.basename(request.modelName || fileName);
await fs.promises.access(sourcePath, fs.constants.R_OK);
const issued = await callServer({
- type: "uploadDataFile",
+ type: "issueModelUpload",
+ deviceId: request.deviceId,
fileName,
- folder: `${request.deviceId}/model_config`,
modelName,
overwrite: request.overwrite === true
}, request.credentials);
diff --git a/ControlPanel/test/model-handlers.test.js b/ControlPanel/test/model-handlers.test.js
index 0ea9991..e4f6e39 100644
--- a/ControlPanel/test/model-handlers.test.js
+++ b/ControlPanel/test/model-handlers.test.js
@@ -15,8 +15,8 @@ test("panel model kernel renames, overwrites, and deletes by fileID", async (con
const requests = [];
const callServer = async (payload) => {
requests.push(payload);
- if (payload.type === "uploadDataFile") {
- const fileID = `model://${payload.folder.replace(/\/model_config$/, "")}/${payload.modelName}`;
+ if (payload.type === "issueModelUpload") {
+ const fileID = `model://${payload.deviceId}/${payload.modelName}`;
if (records.has(fileID) && !payload.overwrite) throw new Error("文件已存在");
records.set(fileID, {
fileID,
@@ -47,6 +47,7 @@ test("panel model kernel renames, overwrites, and deletes by fileID", async (con
assert.equal(uploaded.fileName, "pressure-controller.bin");
assert.equal(uploaded.originalFileName, "controller-original.bin");
assert.equal(records.size, 1);
+ assert.equal(requests[0].deviceId, request.deviceId);
await fs.promises.writeFile(sourcePath, "second-version");
const overwritten = await handlers.upload({ ...request, overwrite: true });