const fs = require("node:fs"); const path = require("node:path"); const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); const { getConfig, publishConfig } = require("./b-admin"); 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" ]; const IDENTIFICATION_FIELDS = [ "q_in_val", "dt", "n_order", "t_c", "levels", "dead_area", "xa_full", "V_val", "repeat" ]; const DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com"; const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL; const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); const connectionState = { apiUrl: API_URL, adminToken: process.env.B_ADMIN_TOKEN || "", deviceId: process.env.REINLOOP_DEVICE_ID || "" }; let pendingReview = null; function resolveCredentials(credentials = {}) { return { apiUrl: String(credentials.apiUrl || connectionState.apiUrl || API_URL).trim(), adminToken: String(credentials.adminToken || connectionState.adminToken || process.env.B_ADMIN_TOKEN || ""), deviceId: String(credentials.deviceId || connectionState.deviceId || "").trim() }; } function startPanelInboxPoller(window) { if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) { window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500"); return () => {}; } let polling = false; let notificationDeviceId = null; const deliveredNotificationIds = new Set(); let notificationFailureReported = false; async function pollNotifications(requestContext) { if (notificationDeviceId !== requestContext.deviceId) { notificationDeviceId = requestContext.deviceId; deliveredNotificationIds.clear(); } const pending = await callServer( { type: "getPendingPanelNotification", deviceId: requestContext.deviceId }, requestContext ); notificationFailureReported = false; const notification = pending.notification; if (!pending.pending || !notification?.notificationId) return; if (deliveredNotificationIds.has(notification.notificationId)) return; deliveredNotificationIds.add(notification.notificationId); window.webContents.send("panel:notification", notification); } const poll = async () => { if (polling || window.isDestroyed()) return; if (!connectionState.deviceId || !connectionState.adminToken) return; const requestContext = { ...connectionState }; polling = true; try { try { await pollNotifications(requestContext); } catch (error) { if (!notificationFailureReported) { console.warn(`[${new Date().toISOString()}] Panel 通知轮询不可用: ${error.message}`); notificationFailureReported = true; } } if (pendingReview) return; const pending = await callServer( { type: "getPendingPanelFile", deviceId: requestContext.deviceId }, requestContext ); if (!pending.pending) return; const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext); const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); window.webContents.send("csv:updated", { filePath: imagePath, dataUrl: toDataUrl(imagePath), fileName: pending.fileName, runId: pending.runId || pending.fileName, mediaType: pending.mediaType, uploadTime: pending.uploadTime, deviceId: requestContext.deviceId, reviewable: pending.mediaType === "csv" }); if (pending.mediaType === "csv") { pendingReview = { deviceId: requestContext.deviceId, fileID: pending.fileID, runId: pending.runId || pending.fileName, credentials: requestContext }; } else { await callServer({ type: "ackPanelFile", deviceId: requestContext.deviceId, fileID: pending.fileID }, requestContext); } } catch (error) { window.webContents.send("csv:watch-error", error.message); } finally { polling = false; } }; void poll(); const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS); return () => clearInterval(timer); } function createWindow() { const window = new BrowserWindow({ width: 1240, height: 820, minWidth: 960, minHeight: 680, backgroundColor: "#f2f4f1", title: "ReinLoop B 端工作台", webPreferences: { preload: path.join(__dirname, "electron-preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true } }); window.removeMenu(); void window.loadFile(path.join(__dirname, "electron-ui", "index.html")); window.webContents.once("did-finish-load", () => { const stopPoller = startPanelInboxPoller(window); window.once("closed", stopPoller); }); } function validateParameters(configType, parameters) { if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { throw new Error("配置必须是 JSON 对象"); } if (configType === "volume") { const fields = Object.keys(parameters); const missing = VOLUME_FIELDS.filter((field) => !(field in parameters)); const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field)); if (missing.length || extra.length) { throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`); } for (const field of VOLUME_FIELDS) { if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) { throw new Error(`${field} 必须是有效数字`); } } if (!Number.isInteger(parameters.num_runs)) { throw new Error("num_runs 必须是整数"); } return; } if (configType === "identification") { const fields = Object.keys(parameters); const missing = IDENTIFICATION_FIELDS.filter((field) => !(field in parameters)); const extra = fields.filter((field) => !IDENTIFICATION_FIELDS.includes(field)); if (missing.length || extra.length) { throw new Error(`系统辨识配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`); } for (const field of IDENTIFICATION_FIELDS) { if (field === "levels") continue; if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) { throw new Error(`${field} 必须是有效数字`); } } if (!Array.isArray(parameters.levels) || parameters.levels.some((value) => typeof value !== "number" || !Number.isFinite(value))) { throw new Error("levels 必须是有效数字数组"); } if (parameters.levels.length < 2 || (parameters.levels.length & (parameters.levels.length - 1)) !== 0) { throw new Error("levels 元素个数必须是不小于 2 的 2 的整数次幂"); } if (!Number.isInteger(parameters.n_order) || !Number.isInteger(parameters.repeat)) { throw new Error("n_order 和 repeat 必须是整数"); } return; } throw new Error(`不支持的配置类型: ${configType}`); } function toDataUrl(filePath) { const extension = path.extname(filePath).toLowerCase(); const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png"; return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`; } function registerHandlers() { const modelHandlers = createModelHandlers({ callServer }); ipcMain.handle("app:get-defaults", () => ({ apiUrl: API_URL, deviceId: process.env.REINLOOP_DEVICE_ID || "", hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN) })); ipcMain.handle("image:show-in-folder", async (_event, filePath) => { if (filePath) shell.showItemInFolder(path.resolve(filePath)); }); ipcMain.handle("connection:set", (_event, request) => { connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); connectionState.deviceId = String(request.deviceId || "").trim(); return { success: true }; }); ipcMain.handle("panel:ack-notification", (_event, request) => callServer({ type: "ackPanelNotification", deviceId: request.deviceId, notificationId: request.notificationId }, request.credentials)); ipcMain.handle("panel:open-notification-file", async (_event, request) => { if (!request.fileID) throw new Error("该提醒未关联结果文件"); const download = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials); const sourcePath = await downloadFromUrl(download.url, download.fileName || "volume_result.json", request.credentials); const error = await shell.openPath(sourcePath); if (error) throw new Error(`无法打开结果文件: ${error}`); return { filePath: sourcePath }; }); ipcMain.handle("connection:test", async (_event, request) => { const result = await callServer({ type: "listOrganizations" }, request); connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); connectionState.deviceId = ""; return result; }); ipcMain.handle("organization:list", (_event, credentials) => callServer({ type: "listOrganizations" }, credentials)); ipcMain.handle("organization:create-company", (_event, request) => callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); ipcMain.handle("organization:create-line", (_event, request) => callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); ipcMain.handle("organization:delete-company", (_event, request) => callServer({ type: "deleteCompany", companyId: request.companyId }, request.credentials)); ipcMain.handle("organization:delete-line", (_event, request) => callServer({ type: "deleteProductionLine", companyId: request.companyId, productionLineId: request.productionLineId }, request.credentials)); ipcMain.handle("license:issue", async (_event, request) => { const keySelection = await dialog.showOpenDialog({ title: "选择许可证 RSA 私钥", properties: ["openFile"], filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }] }); if (keySelection.canceled) return null; const saveSelection = await dialog.showSaveDialog({ title: "保存签发的许可证", defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`, filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }] }); if (saveSelection.canceled) return null; const signed = signLicense({ customer: request.customer, company_id: request.companyId, production_line_id: request.productionLineId, device_id: request.deviceId, issued: request.issued, expiry: request.expiry, features: request.features }, keySelection.filePaths[0]); await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 }); try { const result = await callServer({ type: "createLicense", licenseId: signed.payload.license_id, companyId: request.companyId, productionLineId: request.productionLineId, customer: request.customer, issued: request.issued, expiry: request.expiry, features: request.features, license: signed.content }, request.credentials); return { ...result, filePath: saveSelection.filePath }; } catch (error) { await fs.promises.rm(saveSelection.filePath, { force: true }); throw error; } }); ipcMain.handle("license:list", (_event, credentials) => callServer({ type: "listLicenses" }, resolveCredentials(credentials))); ipcMain.handle("license:get", (_event, request) => callServer( { type: "getLicense", licenseId: request.licenseId }, resolveCredentials(request.credentials) )); ipcMain.handle("license:revoke", (_event, request) => callServer( { type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, resolveCredentials(request.credentials) )); ipcMain.handle("license:delete", (_event, request) => callServer( { type: "deleteLicense", licenseId: request.licenseId }, resolveCredentials(request.credentials) )); ipcMain.handle("license:download", async (_event, request) => { const resolvedCredentials = resolveCredentials(request.credentials); const result = await callServer( { type: "getLicense", licenseId: request.licenseId }, resolvedCredentials ); if (typeof result.license?.license !== "string" || !result.license.license) { throw new Error("Server 未返回许可证原文,无法下载"); } const selection = await dialog.showSaveDialog({ title: "保存许可证", defaultPath: `${request.licenseId}.lic`, filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }] }); if (selection.canceled) return null; await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 }); return { filePath: selection.filePath }; }); ipcMain.handle("review:submit", async (_event, request) => { if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) { throw new Error("待审核记录已变化,请等待 Panel 重新载入数据"); } const result = await callServer({ type: "setIdentificationFeedback", deviceId: request.deviceId, runId: request.runId, result: request.result }, request.credentials); await callServer({ type: "ackPanelFile", deviceId: pendingReview.deviceId, fileID: pendingReview.fileID }, pendingReview.credentials); pendingReview = null; return result; }); 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", (_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) => modelHandlers.delete(request)); ipcMain.handle("identification:list", (_event, request) => callServer({ type: "listIdentificationFiles", deviceId: request.deviceId, mediaType: request.mediaType, status: request.status, page: request.page, pageSize: request.pageSize }, request.credentials)); ipcMain.handle("identification:preview", async (_event, request) => { const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials); const mediaType = download.mediaType || request.mediaType; const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); return { filePath: imagePath, dataUrl: toDataUrl(imagePath), fileName: download.fileName || request.fileName, runId: download.runId || request.runId || request.fileName, mediaType, uploadTime: download.uploadTime || request.uploadTime, deviceId: request.deviceId }; }); ipcMain.handle("identification:download", async (_event, request) => { const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); const fileName = download.fileName || request.fileName; const selection = await dialog.showSaveDialog({ title: "保存辨识原始数据", defaultPath: fileName, filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }] }); if (selection.canceled) return null; await downloadToPath(download.url, selection.filePath, request.credentials); return { filePath: selection.filePath }; }); ipcMain.handle("identification:delete", (_event, request) => callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials)); ipcMain.handle("control-data:list", (_event, request) => callServer({ type: "listControlFiles", deviceId: request.deviceId, page: request.page, pageSize: request.pageSize }, request.credentials)); ipcMain.handle("control-data:preview", async (_event, request) => { const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials); const fileName = download.fileName || request.fileName; const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials); const extension = path.extname(fileName).toLowerCase(); let content = null; if (extension === ".json") { const text = await fs.promises.readFile(sourcePath, "utf8"); try { content = JSON.stringify(JSON.parse(text), null, 2); } catch (_error) { content = text; } } return { filePath: sourcePath, fileName, uploadTime: download.uploadTime || request.uploadTime, size: download.size ?? request.size, content }; }); ipcMain.handle("control-data:download", async (_event, request) => { const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials); const fileName = download.fileName || request.fileName; const extension = path.extname(fileName).replace(/^\./, "") || "bin"; const selection = await dialog.showSaveDialog({ title: "保存控制原始数据", defaultPath: fileName, filters: [{ name: `${extension.toUpperCase()} 文件`, extensions: [extension] }] }); if (selection.canceled) return null; await downloadToPath(download.url, selection.filePath, request.credentials); return { filePath: selection.filePath }; }); ipcMain.handle("control-data:delete", (_event, request) => callServer({ type: "deleteControlFile", fileID: request.fileID }, request.credentials)); ipcMain.handle("config:choose", async () => { const result = await dialog.showOpenDialog({ title: "导入配置 JSON", properties: ["openFile"], filters: [{ name: "JSON 配置", extensions: ["json"] }] }); if (result.canceled) return null; const filePath = result.filePaths[0]; const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf8")); return { filePath, parameters: parsed.parameters || parsed }; }); ipcMain.handle("config:publish", async (_event, request) => { validateParameters(request.configType, request.parameters); const result = await publishConfig(request.configType, request.parameters, request.credentials); return { storagePath: result.fileID || null, message: request.configType === "volume" ? "容积配置已提交给请求设备" : "辨识配置已发布" }; }); ipcMain.handle("config:get", async (_event, request) => { const result = await getConfig(request.configType, request.credentials); return result.parameters || result.config?.parameters || result.config || result; }); } app.whenReady().then(() => { registerHandlers(); createWindow(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });