From ac77d4db10be70723b6667a4ae6e1c5e301adc46 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 16:35:23 +0800 Subject: [PATCH] fix config notice display --- ControlPanel/electron-ui/renderer.js | 26 ++-- changelog.md | 13 ++ server/src/app.js | 206 ++++++++++++++++----------- server/test/server.test.js | 89 ++++++++++-- 4 files changed, 236 insertions(+), 98 deletions(-) diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index 434bee3..4245554 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -717,7 +717,7 @@ document.querySelector("#delete-line").addEventListener("click", async () => { if (!company || !line) return showError(new Error("请先选择公司和产线")); const confirmation = await requestModelName({ title: "确认删除产线", - message: `删除产线 ${line.name} 后将清理关联业务数据。请输入完整 deviceId 以确认。`, + message: `删除产线 ${line.name} 后将删除该产线关联的模型、辨识数据、容积配置、通知与许可证记录(已撤销)。请输入完整 deviceId 以确认。`, value: "", confirmLabel: "删除产线", danger: true, @@ -744,7 +744,7 @@ document.querySelector("#delete-company").addEventListener("click", async () => if (!company) return showError(new Error("请先选择公司")); const confirmation = await requestModelName({ title: "确认删除公司", - message: `删除公司 ${company.name} 前必须先删除其产线与许可证。请输入公司编码以确认。`, + message: `删除公司 ${company.name} 会级联删除其下所有产线及关联模型、辨识数据、容积配置、通知与许可证记录(有效许可证会阻止删除)。请输入公司编码以确认。`, value: "", confirmLabel: "删除公司", danger: true, @@ -1151,14 +1151,24 @@ function renderNotifications() { } async function acknowledgeNotification(notification) { - const result = await runBusy("正在确认提醒", () => window.reinloop.acknowledgeNotification({ - deviceId: notification.deviceId, - notificationId: notification.notificationId, - credentials: credentials() - })); - if (!result) return false; + setStatus("正在确认提醒", "busy"); + try { + await window.reinloop.acknowledgeNotification({ + deviceId: notification.deviceId, + notificationId: notification.notificationId, + credentials: credentials() + }); + } catch (error) { + const message = String(error?.message || error) + .replace(/^Error invoking remote method '[^']+': Error: /, ""); + if (!message.includes("Panel 通知不存在")) { + showError(error); + return false; + } + } state.notifications = state.notifications.filter((item) => item.notificationId !== notification.notificationId); renderNotifications(); + setStatus("提醒已关闭", "success"); return true; } diff --git a/changelog.md b/changelog.md index e785def..6370322 100644 --- a/changelog.md +++ b/changelog.md @@ -222,6 +222,19 @@ - 删除产线、删除公司、删除许可证的二次确认弹窗改为按业务字段显示错误提示,不再统一显示“文件名不匹配”。 - 现分别提示 `deviceId`、公司编码、许可证 ID 的必填和匹配错误,减少误解与误操作。 +### Server + ControlPanel:删除逻辑防死数据增强 + +- `deleteProductionLine` 重构为统一的产线清理流程:删除产线时会同时清理关联文件元数据、通知、辨识反馈、容积配置请求/结果、许可证记录,并增加磁盘目录兜底删除,减少孤儿目录残留。 +- `deleteCompany` 改为级联删除:在不存在有效许可证时,自动删除该公司下全部产线及其关联数据,再删除公司本体;若仍有有效许可证则阻止删除并返回 `ACTIVE_LICENSES_PRESENT`。 +- Panel 删除提示文案升级:明确告知“删除公司/产线会删除关联模型、辨识数据、容积配置、通知与许可证记录”。 +- 删除许可证确认弹窗补充“当前许可证 ID”显示,且确认比较支持忽略大小写,降低输入误判。 + +涉及文件: + +- `server/src/app.js` +- `server/test/server.test.js` +- `ControlPanel/electron-ui/renderer.js` + 涉及文件: - `ControlPanel/electron-ui/renderer.js` diff --git a/server/src/app.js b/server/src/app.js index 289b463..df7ab4a 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -393,6 +393,74 @@ function createApp({ return removed; } + async function removeProductionLineData(database, { companyId, productionLineId, failOnActiveLicense = true }) { + const line = database.productionLines.find((item) => item.id === productionLineId && item.companyId === companyId); + if (!line) return { success: false, errMsg: "产线不存在" }; + + const activeLicenses = database.licenses.filter((item) => + item.companyId === companyId && item.productionLineId === productionLineId && item.status === "active" + ); + if (failOnActiveLicense && activeLicenses.length) { + return { + success: false, + errMsg: `请先撤销该产线的 ${activeLicenses.length} 个有效许可证后再删除产线`, + errCode: "ACTIVE_LICENSES_PRESENT" + }; + } + + const deviceId = line.deviceId; + const relatedFileIDs = database.fileRecords + .filter((record) => { + if (record.fileID.startsWith(`model://${deviceId}/`)) return true; + const prefix = `${deviceId}/`; + return record.folder === deviceId || record.folder.startsWith(prefix); + }) + .map((record) => record.fileID); + + let deletedFileCount = 0; + for (const fileID of new Set(relatedFileIDs)) { + deletedFileCount += await removeFile(database, fileID); + } + + const beforeFeedback = database.identificationFeedback.length; + database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId); + const deletedFeedback = beforeFeedback - database.identificationFeedback.length; + + const beforeRequests = database.volumeConfigRequests.length; + database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId); + const deletedRequests = beforeRequests - database.volumeConfigRequests.length; + + const beforeConfigs = database.volumeConfigs.length; + database.volumeConfigs = database.volumeConfigs.filter((item) => item.deviceId !== deviceId); + const deletedConfigs = beforeConfigs - database.volumeConfigs.length; + + const beforeNotifications = database.panelNotifications.length; + database.panelNotifications = database.panelNotifications.filter((item) => item.deviceId !== deviceId); + const deletedNotifications = beforeNotifications - database.panelNotifications.length; + + const deletedLicenses = database.licenses.filter((item) => + item.companyId === companyId && item.productionLineId === productionLineId + ).length; + database.licenses = database.licenses.filter((item) => + !(item.companyId === companyId && item.productionLineId === productionLineId) + ); + + const deletedDirectories = await removeDeviceDirectories(deviceId); + database.productionLines = database.productionLines.filter((item) => item.id !== productionLineId); + + return { + success: true, + deletedProductionLineId: productionLineId, + deletedFiles: deletedFileCount, + deletedDirectories, + deletedLicenses, + deletedFeedback, + deletedVolumeRequests: deletedRequests, + deletedVolumeConfigs: deletedConfigs, + deletedNotifications + }; + } + function backfillIdentificationFiles(database) { for (const record of database.fileRecords) { if (!record.folder.endsWith("/ind_data") || ![".csv", ".json"].includes(path.extname(record.fileName).toLowerCase())) continue; @@ -520,71 +588,11 @@ function createApp({ return store.update(async (database) => { const company = database.companies.find((item) => item.id === companyId); if (!company) return { success: false, errMsg: "公司不存在" }; - const line = database.productionLines.find((item) => item.id === productionLineId && item.companyId === companyId); - if (!line) return { success: false, errMsg: "产线不存在" }; - - const activeLicenses = database.licenses.filter((item) => - item.companyId === companyId && item.productionLineId === productionLineId && item.status === "active" - ); - if (activeLicenses.length) { - return { - success: false, - errMsg: `请先撤销该产线的 ${activeLicenses.length} 个有效许可证后再删除产线`, - errCode: "ACTIVE_LICENSES_PRESENT" - }; - } - - const deviceId = line.deviceId; - const relatedFileIDs = database.fileRecords - .filter((record) => { - if (record.fileID.startsWith(`model://${deviceId}/`)) return true; - const prefix = `${deviceId}/`; - return record.folder === deviceId || record.folder.startsWith(prefix); - }) - .map((record) => record.fileID); - - let deletedFileCount = 0; - for (const fileID of new Set(relatedFileIDs)) { - deletedFileCount += await removeFile(database, fileID); - } - - const beforeFeedback = database.identificationFeedback.length; - database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId); - const deletedFeedback = beforeFeedback - database.identificationFeedback.length; - - const beforeRequests = database.volumeConfigRequests.length; - database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId); - const deletedRequests = beforeRequests - database.volumeConfigRequests.length; - - const beforeConfigs = database.volumeConfigs.length; - database.volumeConfigs = database.volumeConfigs.filter((item) => item.deviceId !== deviceId); - const deletedConfigs = beforeConfigs - database.volumeConfigs.length; - - const beforeNotifications = database.panelNotifications.length; - database.panelNotifications = database.panelNotifications.filter((item) => item.deviceId !== deviceId); - const deletedNotifications = beforeNotifications - database.panelNotifications.length; - - const revokedLicenses = database.licenses.filter((item) => - item.companyId === companyId && item.productionLineId === productionLineId - ).length; - database.licenses = database.licenses.filter((item) => - !(item.companyId === companyId && item.productionLineId === productionLineId) - ); - - const deletedDirectories = await removeDeviceDirectories(deviceId); - database.productionLines = database.productionLines.filter((item) => item.id !== productionLineId); - - return { - success: true, - deletedProductionLineId: productionLineId, - deletedFiles: deletedFileCount, - deletedDirectories, - deletedLicenses: revokedLicenses, - deletedFeedback, - deletedVolumeRequests: deletedRequests, - deletedVolumeConfigs: deletedConfigs, - deletedNotifications - }; + return removeProductionLineData(database, { + companyId, + productionLineId, + failOnActiveLicense: true + }); }); } case "deleteCompany": { @@ -592,27 +600,59 @@ function createApp({ if (authError) return { success: false, errMsg: authError }; const companyId = String(event.companyId || "").trim(); if (!companyId) return { success: false, errMsg: "缺少 companyId" }; - return store.update((database) => { + return store.update(async (database) => { const company = database.companies.find((item) => item.id === companyId); if (!company) return { success: false, errMsg: "公司不存在" }; + + const activeLicenses = database.licenses.filter((item) => + item.companyId === companyId && item.status === "active" + ); + if (activeLicenses.length) { + return { + success: false, + errMsg: `请先撤销该公司的 ${activeLicenses.length} 个有效许可证后再删除公司`, + errCode: "ACTIVE_LICENSES_PRESENT" + }; + } + const lines = database.productionLines.filter((item) => item.companyId === companyId); - if (lines.length) { - return { - success: false, - errMsg: `请先删除该公司的 ${lines.length} 条产线后再删除公司`, - errCode: "PRODUCTION_LINES_PRESENT" - }; + + const summary = { + deletedProductionLines: 0, + deletedFiles: 0, + deletedDirectories: 0, + deletedLicenses: 0, + deletedFeedback: 0, + deletedVolumeRequests: 0, + deletedVolumeConfigs: 0, + deletedNotifications: 0 + }; + + for (const line of lines) { + const lineDeleted = await removeProductionLineData(database, { + companyId, + productionLineId: line.id, + failOnActiveLicense: false + }); + if (!lineDeleted.success) return lineDeleted; + summary.deletedProductionLines += 1; + summary.deletedFiles += lineDeleted.deletedFiles; + summary.deletedDirectories += lineDeleted.deletedDirectories; + summary.deletedLicenses += lineDeleted.deletedLicenses; + summary.deletedFeedback += lineDeleted.deletedFeedback; + summary.deletedVolumeRequests += lineDeleted.deletedVolumeRequests; + summary.deletedVolumeConfigs += lineDeleted.deletedVolumeConfigs; + summary.deletedNotifications += lineDeleted.deletedNotifications; } - const licenses = database.licenses.filter((item) => item.companyId === companyId); - if (licenses.length) { - return { - success: false, - errMsg: `请先删除该公司的 ${licenses.length} 个许可证后再删除公司`, - errCode: "LICENSES_PRESENT" - }; + + const orphanCompanyLicenses = database.licenses.filter((item) => item.companyId === companyId).length; + if (orphanCompanyLicenses > 0) { + summary.deletedLicenses += orphanCompanyLicenses; + database.licenses = database.licenses.filter((item) => item.companyId !== companyId); } + database.companies = database.companies.filter((item) => item.id !== companyId); - return { success: true, deletedCompanyId: companyId }; + return { success: true, deletedCompanyId: companyId, ...summary }; }); } case "createLicense": { @@ -896,11 +936,13 @@ function createApp({ const exists = database.panelNotifications.some((item) => item.deviceId === deviceId && item.notificationId === notificationId ); - if (!exists) return { success: false, errMsg: "Panel 通知不存在" }; + if (!exists) { + return { success: true, notificationId, deleted: 0, idempotent: true }; + } database.panelNotifications = database.panelNotifications.filter((item) => !(item.deviceId === deviceId && item.notificationId === notificationId) ); - return { success: true, notificationId }; + return { success: true, notificationId, deleted: 1 }; }); } case "ackPanelFile": { diff --git a/server/test/server.test.js b/server/test/server.test.js index 0ddc9c9..019ca3f 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -546,6 +546,13 @@ test("panel notifications and volume configuration stay isolated by device", asy type: "ackPanelNotification", deviceId, notificationId: notification.notification.notificationId, adminToken: "test-token" })).success, true); + const duplicatedAck = await post({ + type: "ackPanelNotification", deviceId, notificationId: notification.notification.notificationId, + adminToken: "test-token" + }); + assert.equal(duplicatedAck.success, true); + assert.equal(duplicatedAck.idempotent, true); + assert.equal(duplicatedAck.deleted, 0); async function uploadResult(folderName, fileName, content) { const result = await post({ type: "uploadDataFile", fileName, folder: `${deviceId}/${folderName}` }); @@ -908,7 +915,7 @@ test("deleteProductionLine blocks active licenses and removes revoked data", asy assert.deepEqual(models.fileList, []); }); -test("deleteCompany requires no child lines and no remaining licenses", async () => { +test("deleteCompany cascades revoked data cleanup", async () => { const suffix = crypto.randomUUID().slice(0, 8); const company = await post({ type: "createCompany", name: `删除公司-${suffix}`, code: `del-co-${suffix}`, @@ -919,21 +926,87 @@ test("deleteCompany requires no child lines and no remaining licenses", async () name: "子产线", code: "line-1", adminToken: "test-token" }); - const blocked = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" }); - assert.equal(blocked.success, false); - assert.equal(blocked.errCode, "PRODUCTION_LINES_PRESENT"); + const modelIssued = await post({ + type: "issueModelUpload", deviceId: line.productionLine.deviceId, + fileName: "company-cascade.bin", adminToken: "test-token" + }); + const modelForm = new FormData(); + modelForm.append("file", new Blob(["cascade-model"]), "company-cascade.bin"); + assert.equal((await fetch(modelIssued.uploadMetadata.url, { method: "POST", body: modelForm })).status, 204); - const lineDeleted = await post({ - type: "deleteProductionLine", + const licenseId = crypto.randomUUID(); + const payload = { + license_id: licenseId, + company_id: company.company.id, + production_line_id: line.productionLine.id, + customer: company.company.name, + device_id: line.productionLine.deviceId, + issued: "2026-08-01 10:00", + expiry: "2028-08-01 10:00", + features: "*" + }; + await post({ + type: "createLicense", licenseId, companyId: company.company.id, productionLineId: line.productionLine.id, + customer: payload.customer, + issued: payload.issued, + expiry: payload.expiry, + features: payload.features, + license: signLicense(payload), adminToken: "test-token" }); - assert.equal(lineDeleted.success, true); + await post({ type: "revokeLicense", licenseId, reason: "公司删除", adminToken: "test-token" }); const deleted = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" }); - assert.deepEqual(deleted, { success: true, deletedCompanyId: company.company.id }); + assert.equal(deleted.success, true); + assert.equal(deleted.deletedCompanyId, company.company.id); + assert.ok(deleted.deletedProductionLines >= 1); + assert.ok(deleted.deletedLicenses >= 1); + assert.ok(deleted.deletedFiles >= 1); const organizations = await post({ type: "listOrganizations", adminToken: "test-token" }); assert.equal(organizations.companies.some((item) => item.id === company.company.id), false); + + const models = await post({ type: "listModels", folder: `${line.productionLine.deviceId}/model_config` }); + assert.deepEqual(models.fileList, []); +}); + +test("deleteCompany blocks when active licenses exist", async () => { + const suffix = crypto.randomUUID().slice(0, 8); + const company = await post({ + type: "createCompany", name: `删除公司阻断-${suffix}`, code: `del-co-block-${suffix}`, + adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "阻断产线", code: "line-1", adminToken: "test-token" + }); + + const licenseId = crypto.randomUUID(); + const payload = { + license_id: licenseId, + company_id: company.company.id, + production_line_id: line.productionLine.id, + customer: company.company.name, + device_id: line.productionLine.deviceId, + issued: "2026-08-01 10:00", + expiry: "2028-08-01 10:00", + features: "*" + }; + await post({ + type: "createLicense", licenseId, + companyId: company.company.id, + productionLineId: line.productionLine.id, + customer: payload.customer, + issued: payload.issued, + expiry: payload.expiry, + features: payload.features, + license: signLicense(payload), + adminToken: "test-token" + }); + + const blocked = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" }); + assert.equal(blocked.success, false); + assert.equal(blocked.errCode, "ACTIVE_LICENSES_PRESENT"); }); \ No newline at end of file