update panel
This commit is contained in:
+390
-331
@@ -1,332 +1,391 @@
|
||||
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 VOLUME_FIELDS = [
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
||||
];
|
||||
const DEFAULT_API_URL = "http://ReinLoop.dominatedconvergence.com/api";
|
||||
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 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;
|
||||
const poll = async () => {
|
||||
if (polling || window.isDestroyed()) return;
|
||||
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
||||
if (pendingReview) return;
|
||||
const requestContext = { ...connectionState };
|
||||
polling = true;
|
||||
try {
|
||||
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,
|
||||
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.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 必须是整数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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("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("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" }, credentials));
|
||||
ipcMain.handle("license:get", (_event, request) =>
|
||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||
ipcMain.handle("license:revoke", (_event, request) =>
|
||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||
|
||||
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) =>
|
||||
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
||||
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: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("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,
|
||||
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("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();
|
||||
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 VOLUME_FIELDS = [
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
||||
];
|
||||
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 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;
|
||||
const poll = async () => {
|
||||
if (polling || window.isDestroyed()) return;
|
||||
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
||||
if (pendingReview) return;
|
||||
const requestContext = { ...connectionState };
|
||||
polling = true;
|
||||
try {
|
||||
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,
|
||||
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.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 必须是整数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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("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("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" }, credentials));
|
||||
ipcMain.handle("license:get", (_event, request) =>
|
||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||
ipcMain.handle("license:revoke", (_event, request) =>
|
||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||
ipcMain.handle("license:download", async (_event, request) => {
|
||||
const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials);
|
||||
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) =>
|
||||
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
||||
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: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("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,
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user