update server

This commit is contained in:
2026-07-30 11:12:31 +08:00
commit 4312cb878c
99 changed files with 24034 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
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();
});