Compare commits

...
21 Commits
Author SHA1 Message Date
louis 0c28784d85 fix: restore simulated pressure model and fast prescan 2026-08-04 14:45:30 +08:00
rangang 9185591a6d Merge remote-tracking branch 'upstream/main' into reinlooptest 2026-08-04 09:28:56 +08:00
Epifnne 1f9c096d05 fix notice display 2026-08-03 18:20:23 +08:00
Epifnne 8c3df90a87 fix runid 2026-08-03 17:54:36 +08:00
Epifnne a3b7d3d876 banned cross device check 2026-08-03 17:27:23 +08:00
rangang 17c8ca5268 Merge remote-tracking branch 'upstream/main' into reinlooptest 2026-08-03 17:14:47 +08:00
Epifnne ac77d4db10 fix config notice display 2026-08-03 16:35:23 +08:00
rangang bac0391a0c Merge remote-tracking branch 'upstream/main' into reinlooptest 2026-08-03 15:55:03 +08:00
Epifnne 1f356af022 fix del api 2026-08-03 15:53:07 +08:00
rangang 270895aaf0 Merge https://git.dominatedconvergence.com/Epifnne/ReinLoop into reinlooptest 2026-08-03 15:50:43 +08:00
Epifnne 5edb0b2d69 fix del api 2026-08-03 15:28:37 +08:00
Epifnne bae71f3253 fix license check 2026-08-03 15:18:28 +08:00
Epifnne 2090597858 fix license del 2026-08-03 14:26:24 +08:00
Epifnne d8b939a3ff fix reinloop api 2026-08-03 14:05:27 +08:00
rangang 1093980b77 Merge remote-tracking branch 'upstream/main' into reinlooptest 2026-08-03 11:40:09 +08:00
Epifnne 92780cedef add auth check 2026-08-03 11:16:49 +08:00
rangang 83024b29b8 Merge remote-tracking branch 'upstream/main' into reinlooptest 2026-07-31 16:02:54 +08:00
Epifnne 6330b99860 remove /api support 2026-07-31 14:19:48 +08:00
Epifnne 46c69607c5 fix model del & rename 2026-07-31 11:26:38 +08:00
Epifnne feaddfee7b fix model del & rename 2026-07-31 11:15:54 +08:00
Epifnne d45acaf50f add notice after config 2026-07-31 10:20:33 +08:00
39 changed files with 4419 additions and 2619 deletions
+9
View File
@@ -0,0 +1,9 @@
# 默认所有文本文件使用 Windows 换行符
* text=auto eol=crlf
# server 文件夹下所有文件使用 Linux 换行符
server/** text eol=lf
# 特定文件类型明确指定
*.sh text eol=lf
*.bash text eol=lf
+199 -190
View File
@@ -1,191 +1,200 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { parse } = require("csv-parse/sync"); const { parse } = require("csv-parse/sync");
const { callServer } = require("./server-client"); const { callServer } = require("./server-client");
const IDENTIFICATION_FIELDS = [ const IDENTIFICATION_FIELDS = [
"q_in_val", "dt", "n_order", "t_c", "levels", "q_in_val", "dt", "n_order", "t_c", "levels",
"dead_area", "xa_full", "V_val", "repeat" "dead_area", "xa_full", "V_val", "repeat"
]; ];
function getDeviceId(options = {}) { function getDeviceId(options = {}) {
const deviceId = String(options.deviceId || process.env.REINLOOP_DEVICE_ID || "").trim(); const deviceId = String(options.deviceId || process.env.REINLOOP_DEVICE_ID || "").trim();
if (!deviceId) throw new Error("发布或读取辨识配置时必须提供设备 ID"); if (!deviceId) throw new Error("发布或读取辨识配置时必须提供设备 ID");
return deviceId; return deviceId;
} }
async function readParameters(filePath) { async function readParameters(filePath) {
if (!filePath) throw new Error("发布参数时必须提供 JSON 文件路径"); if (!filePath) throw new Error("发布参数时必须提供 JSON 文件路径");
const content = await fs.promises.readFile(path.resolve(filePath), "utf8"); const content = await fs.promises.readFile(path.resolve(filePath), "utf8");
const config = JSON.parse(content); const config = JSON.parse(content);
return config.parameters || config; return config.parameters || config;
} }
async function uploadVolumeConfig(parameters, options = {}) { async function uploadVolumeConfig(parameters, options = {}) {
const deviceId = getDeviceId(options); const deviceId = getDeviceId(options);
const request = await callServer({ const request = await callServer({
type: "getPendingVolumeConfigRequest", type: "getPendingVolumeConfigRequest",
deviceId deviceId
}, options); }, options);
if (!request.pending) { if (!request.pending) {
throw new Error("ReinLoop 尚未发起容积参数请求,请先在客户端开始容积测试"); throw new Error("ReinLoop 尚未发起容积参数请求,请先在客户端开始容积测试");
} }
const result = await callServer({ const result = await callServer({
type: "uploadDataFile", type: "uploadDataFile",
fileName: "volume_measurement.json", fileName: "volume_measurement.json",
folder: `${deviceId}/volume_config_requests/${request.requestId}` folder: `${deviceId}/volume_config_requests/${request.requestId}`
}, options); }, options);
const metadata = result.uploadMetadata; const metadata = result.uploadMetadata;
if (!metadata || !metadata.url) { if (!metadata || !metadata.url) {
throw new Error("server 未返回有效的配置上传地址"); throw new Error("server 未返回有效的配置上传地址");
} }
const orderedConfig = { const orderedConfig = {
q_in_val: parameters.q_in_val, q_in_val: parameters.q_in_val,
dt: parameters.dt, dt: parameters.dt,
p_max: parameters.p_max, p_max: parameters.p_max,
fit_low: parameters.fit_low, fit_low: parameters.fit_low,
fit_high: parameters.fit_high, fit_high: parameters.fit_high,
T_delta: parameters.T_delta, T_delta: parameters.T_delta,
xa_full: parameters.xa_full, xa_full: parameters.xa_full,
num_runs: parameters.num_runs num_runs: parameters.num_runs
}; };
const form = new FormData(); const form = new FormData();
form.append( form.append(
"file", "file",
new Blob([`${JSON.stringify(orderedConfig, null, 2)}\n`], { type: "application/json" }), new Blob([`${JSON.stringify(orderedConfig, null, 2)}\n`], { type: "application/json" }),
"volume_measurement.json" "volume_measurement.json"
); );
const uploadResponse = await fetch(metadata.url, { method: "POST", body: form }); const uploadResponse = await fetch(metadata.url, { method: "POST", body: form });
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) { if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`); throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`);
} }
try { try {
return await callServer({ return await callServer({
type: "submitVolumeConfigFile", type: "submitVolumeConfigFile",
deviceId, deviceId,
requestId: request.requestId, requestId: request.requestId,
fileID: result.fileID, fileID: result.fileID,
fileName: "volume_measurement.json" fileName: "volume_measurement.json"
}, options); }, options);
} catch (error) { } catch (error) {
await callServer({ type: "deleteFile", fileID: result.fileID }, options).catch(() => {}); await callServer({ type: "deleteFile", fileID: result.fileID }, options).catch(() => {});
throw error; throw error;
} }
} }
function serializeIdentificationConfig(parameters) { function serializeIdentificationConfig(parameters) {
const rows = IDENTIFICATION_FIELDS.map((field) => { const rows = IDENTIFICATION_FIELDS.map((field) => {
const value = field === "levels" ? parameters[field].join(",") : parameters[field]; const value = field === "levels" ? parameters[field].join(",") : parameters[field];
return `${field},${field === "levels" ? `"${value}"` : value}`; return `${field},${field === "levels" ? `"${value}"` : value}`;
}); });
return `parameter,value\n${rows.join("\n")}\n`; return `parameter,value\n${rows.join("\n")}\n`;
} }
async function uploadIdentificationConfig(parameters, options = {}) { async function uploadIdentificationConfig(parameters, options = {}) {
const result = await callServer({ const result = await callServer({
type: "publishIdentificationConfig", type: "publishIdentificationConfig",
deviceId: getDeviceId(options), deviceId: getDeviceId(options),
parameters parameters
}, options); }, options);
const metadata = result.uploadMetadata; const metadata = result.uploadMetadata;
if (!metadata || !metadata.url) { if (!metadata || !metadata.url) {
throw new Error("server 未返回有效的配置上传地址"); throw new Error("server 未返回有效的配置上传地址");
} }
const form = new FormData(); const form = new FormData();
form.append( form.append(
"file", "file",
new Blob([serializeIdentificationConfig(parameters)], { type: "text/csv" }), new Blob([serializeIdentificationConfig(parameters)], { type: "text/csv" }),
"identification_config.csv" "identification_config.csv"
); );
const uploadResponse = await fetch(metadata.url, { method: "POST", body: form }); const uploadResponse = await fetch(metadata.url, { method: "POST", body: form });
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) { if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`); throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`);
} }
return result; return result;
} }
async function publishConfig(configType, parameters, options = {}) { async function publishConfig(configType, parameters, options = {}) {
if (configType === "volume") { if (configType === "volume") {
return uploadVolumeConfig(parameters, options); return uploadVolumeConfig(parameters, options);
} }
if (configType !== "identification") { if (configType !== "identification") {
throw new Error(`不支持的配置类型: ${configType}`); throw new Error(`不支持的配置类型: ${configType}`);
} }
return uploadIdentificationConfig(parameters, options); return uploadIdentificationConfig(parameters, options);
} }
async function getConfig(configType, options = {}) { async function getConfig(configType, options = {}) {
if (configType === "identification") { if (configType === "identification") {
const result = await callServer({ const result = await callServer({
type: "getIdentificationConfig", type: "getIdentificationConfig",
deviceId: getDeviceId(options) deviceId: getDeviceId(options)
}, options); }, options);
const response = await fetch(result.url); const response = await fetch(result.url);
if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`); if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`);
const records = parse(await response.text(), { columns: true, skip_empty_lines: true }); const records = parse(await response.text(), { columns: true, skip_empty_lines: true });
const values = Object.fromEntries(records.map((record) => [record.parameter, record.value])); const values = Object.fromEntries(records.map((record) => [record.parameter, record.value]));
return { return {
q_in_val: Number(values.q_in_val), q_in_val: Number(values.q_in_val),
dt: Number(values.dt), dt: Number(values.dt),
n_order: Number(values.n_order), n_order: Number(values.n_order),
t_c: Number(values.t_c), t_c: Number(values.t_c),
levels: String(values.levels).split(",").map(Number), levels: String(values.levels).split(",").map(Number),
dead_area: Number(values.dead_area), dead_area: Number(values.dead_area),
xa_full: Number(values.xa_full), xa_full: Number(values.xa_full),
V_val: Number(values.V_val), V_val: Number(values.V_val),
repeat: Number(values.repeat) repeat: Number(values.repeat)
}; };
} }
return callServer({ type: "getFunctionConfig", configType }, options); const result = await callServer({
} type: "getVolumeConfigFile",
deviceId: getDeviceId(options)
async function main() { }, options);
const [command, filePath] = process.argv.slice(2); if (result.found === false || !result.url) {
const commands = { throw new Error("当前产线尚无已成功提交的容积配置");
"publish-volume": { action: "upload-volume", configType: "volume" }, }
"publish-identification": { action: "publish", configType: "identification" }, const response = await fetch(result.url);
"get-volume": { action: "get", configType: "volume" }, if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`);
"get-identification": { action: "get", configType: "identification" } return JSON.parse(await response.text());
}; }
const selected = commands[command];
if (!selected) { async function main() {
throw new Error( const [command, filePath] = process.argv.slice(2);
"用法: node b-admin.js <publish-volume|publish-identification|get-volume|get-identification> [config.json]" const commands = {
); "publish-volume": { action: "upload-volume", configType: "volume" },
} "publish-identification": { action: "publish", configType: "identification" },
"get-volume": { action: "get", configType: "volume" },
if (selected.action === "upload-volume") { "get-identification": { action: "get", configType: "identification" }
const result = await publishConfig(selected.configType, await readParameters(filePath)); };
console.log(`配置已上传: ${result.fileID}`); const selected = commands[command];
return; if (!selected) {
} throw new Error(
"用法: node b-admin.js <publish-volume|publish-identification|get-volume|get-identification> [config.json]"
const result = selected.action === "publish" );
? await publishConfig(selected.configType, await readParameters(filePath)) }
: await getConfig(selected.configType);
if (selected.action === "publish") { if (selected.action === "upload-volume") {
console.log(`配置已上传: ${result.fileID}`); const result = await publishConfig(selected.configType, await readParameters(filePath));
return; console.log(`配置已上传: ${result.fileID}`);
} return;
console.dir(result, { depth: null, colors: true }); }
}
const result = selected.action === "publish"
if (require.main === module) { ? await publishConfig(selected.configType, await readParameters(filePath))
main().catch((error) => { : await getConfig(selected.configType);
console.error(error.message); if (selected.action === "publish") {
process.exitCode = 1; console.log(`配置已上传: ${result.fileID}`);
}); return;
} }
console.dir(result, { depth: null, colors: true });
module.exports = { }
getConfig,
publishConfig, if (require.main === module) {
readParameters, main().catch((error) => {
serializeIdentificationConfig, console.error(error.message);
uploadIdentificationConfig, process.exitCode = 1;
uploadVolumeConfig });
}
module.exports = {
getConfig,
publishConfig,
readParameters,
serializeIdentificationConfig,
uploadIdentificationConfig,
uploadVolumeConfig
}; };
+97 -21
View File
@@ -6,10 +6,14 @@ const { callServer, downloadFromUrl, downloadToPath } = require("./server-client
const { signLicense } = require("./license-manager"); const { signLicense } = require("./license-manager");
const { plotCsv } = require("./plot-csv"); const { plotCsv } = require("./plot-csv");
const { plotJson } = require("./plot-json"); const { plotJson } = require("./plot-json");
const { createModelHandlers } = require("./model-handlers");
const VOLUME_FIELDS = [ const VOLUME_FIELDS = [
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs" "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 DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com";
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL; const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
@@ -35,13 +39,42 @@ function startPanelInboxPoller(window) {
} }
let polling = false; 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 () => { const poll = async () => {
if (polling || window.isDestroyed()) return; if (polling || window.isDestroyed()) return;
if (!connectionState.deviceId || !connectionState.adminToken) return; if (!connectionState.deviceId || !connectionState.adminToken) return;
if (pendingReview) return;
const requestContext = { ...connectionState }; const requestContext = { ...connectionState };
polling = true; polling = true;
try { 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( const pending = await callServer(
{ type: "getPendingPanelFile", deviceId: requestContext.deviceId }, { type: "getPendingPanelFile", deviceId: requestContext.deviceId },
requestContext requestContext
@@ -53,6 +86,7 @@ function startPanelInboxPoller(window) {
filePath: imagePath, filePath: imagePath,
dataUrl: toDataUrl(imagePath), dataUrl: toDataUrl(imagePath),
fileName: pending.fileName, fileName: pending.fileName,
runId: pending.runId || pending.fileName,
mediaType: pending.mediaType, mediaType: pending.mediaType,
uploadTime: pending.uploadTime, uploadTime: pending.uploadTime,
deviceId: requestContext.deviceId, deviceId: requestContext.deviceId,
@@ -62,7 +96,7 @@ function startPanelInboxPoller(window) {
pendingReview = { pendingReview = {
deviceId: requestContext.deviceId, deviceId: requestContext.deviceId,
fileID: pending.fileID, fileID: pending.fileID,
runId: pending.fileName, runId: pending.runId || pending.fileName,
credentials: requestContext credentials: requestContext
}; };
} else { } else {
@@ -128,7 +162,35 @@ function validateParameters(configType, parameters) {
if (!Number.isInteger(parameters.num_runs)) { if (!Number.isInteger(parameters.num_runs)) {
throw new Error("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) { function toDataUrl(filePath) {
@@ -138,6 +200,7 @@ function toDataUrl(filePath) {
} }
function registerHandlers() { function registerHandlers() {
const modelHandlers = createModelHandlers({ callServer });
ipcMain.handle("app:get-defaults", () => ({ ipcMain.handle("app:get-defaults", () => ({
apiUrl: API_URL, apiUrl: API_URL,
deviceId: process.env.REINLOOP_DEVICE_ID || "", deviceId: process.env.REINLOOP_DEVICE_ID || "",
@@ -155,6 +218,21 @@ function registerHandlers() {
return { success: true }; 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) => { ipcMain.handle("connection:test", async (_event, request) => {
const result = await callServer({ type: "listOrganizations" }, request); const result = await callServer({ type: "listOrganizations" }, request);
connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
@@ -169,6 +247,14 @@ function registerHandlers() {
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
ipcMain.handle("organization:create-line", (_event, request) => ipcMain.handle("organization:create-line", (_event, request) =>
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); 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) => { ipcMain.handle("license:issue", async (_event, request) => {
const keySelection = await dialog.showOpenDialog({ const keySelection = await dialog.showOpenDialog({
@@ -218,6 +304,11 @@ function registerHandlers() {
{ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, { type: "revokeLicense", licenseId: request.licenseId, reason: request.reason },
resolveCredentials(request.credentials) 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) => { ipcMain.handle("license:download", async (_event, request) => {
const resolvedCredentials = resolveCredentials(request.credentials); const resolvedCredentials = resolveCredentials(request.credentials);
const result = await callServer( const result = await callServer(
@@ -256,35 +347,19 @@ function registerHandlers() {
return result; return result;
}); });
ipcMain.handle("model:list", (_event, request) => ipcMain.handle("model:list", (_event, request) => modelHandlers.list(request));
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
ipcMain.handle("model:choose-upload-file", async () => { ipcMain.handle("model:choose-upload-file", async () => {
const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] }); const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] });
if (selection.canceled) return null; if (selection.canceled) return null;
const sourcePath = selection.filePaths[0]; const sourcePath = selection.filePaths[0];
return { sourcePath, fileName: path.basename(sourcePath) }; return { sourcePath, fileName: path.basename(sourcePath) };
}); });
ipcMain.handle("model:upload", async (_event, request) => { ipcMain.handle("model:upload", (_event, request) => modelHandlers.upload(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) => { ipcMain.handle("model:download", async (_event, request) => {
const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials); const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials);
return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) }; return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) };
}); });
ipcMain.handle("model:delete", (_event, request) => ipcMain.handle("model:delete", (_event, request) => modelHandlers.delete(request));
callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials));
ipcMain.handle("identification:list", (_event, request) => ipcMain.handle("identification:list", (_event, request) =>
callServer({ callServer({
@@ -304,6 +379,7 @@ function registerHandlers() {
filePath: imagePath, filePath: imagePath,
dataUrl: toDataUrl(imagePath), dataUrl: toDataUrl(imagePath),
fileName: download.fileName || request.fileName, fileName: download.fileName || request.fileName,
runId: download.runId || request.runId || request.fileName,
mediaType, mediaType,
uploadTime: download.uploadTime || request.uploadTime, uploadTime: download.uploadTime || request.uploadTime,
deviceId: request.deviceId deviceId: request.deviceId
+7 -1
View File
@@ -15,7 +15,10 @@ contextBridge.exposeInMainWorld("reinloop", {
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials), listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
getLicense: (request) => ipcRenderer.invoke("license:get", request), getLicense: (request) => ipcRenderer.invoke("license:get", request),
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request), revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
deleteLicense: (request) => ipcRenderer.invoke("license:delete", request),
downloadLicense: (request) => ipcRenderer.invoke("license:download", request), downloadLicense: (request) => ipcRenderer.invoke("license:download", request),
deleteCompany: (request) => ipcRenderer.invoke("organization:delete-company", request),
deleteProductionLine: (request) => ipcRenderer.invoke("organization:delete-line", request),
submitReview: (request) => ipcRenderer.invoke("review:submit", request), submitReview: (request) => ipcRenderer.invoke("review:submit", request),
listModels: (request) => ipcRenderer.invoke("model:list", request), listModels: (request) => ipcRenderer.invoke("model:list", request),
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"), chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
@@ -30,6 +33,9 @@ contextBridge.exposeInMainWorld("reinloop", {
previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request), previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request),
downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request), downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request),
deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request), deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request),
acknowledgeNotification: (request) => ipcRenderer.invoke("panel:ack-notification", request),
openNotificationFile: (request) => ipcRenderer.invoke("panel:open-notification-file", request),
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)), onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message)) onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message)),
onPanelNotification: (callback) => ipcRenderer.on("panel:notification", (_event, notification) => callback(notification))
}); });
+26 -5
View File
@@ -43,7 +43,7 @@
<button class="tab active" data-target="plot-panel">绘图预览</button> <button class="tab active" data-target="plot-panel">绘图预览</button>
<button class="tab" data-target="identification-data-panel">辨识数据</button> <button class="tab" data-target="identification-data-panel">辨识数据</button>
<button class="tab" data-target="control-data-panel">控制数据</button> <button class="tab" data-target="control-data-panel">控制数据</button>
<button class="tab" data-target="config-panel">配置发布</button> <button class="tab" data-target="config-panel">配置发布 <span id="config-notification-badge" class="tab-badge" hidden>0</span></button>
<button class="tab" data-target="model-panel">模型管理</button> <button class="tab" data-target="model-panel">模型管理</button>
<button class="tab" data-target="license-panel">许可证</button> <button class="tab" data-target="license-panel">许可证</button>
<button class="tab" data-target="organization-panel">组织管理</button> <button class="tab" data-target="organization-panel">组织管理</button>
@@ -111,7 +111,7 @@
<button id="refresh-identification-files" class="button secondary">刷新</button> <button id="refresh-identification-files" class="button secondary">刷新</button>
</div> </div>
<div class="data-surface"> <div class="data-surface">
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table> <table><thead><tr><th>上传时间</th><th>文件名</th><th>runId</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p> <p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
</div> </div>
</section> </section>
@@ -139,14 +139,15 @@
<button class="segment" data-type="identification">系统辨识</button> <button class="segment" data-type="identification">系统辨识</button>
</div> </div>
</div> </div>
<div id="config-notifications" class="notification-stack" aria-live="polite"></div>
<div class="editor-layout"> <div class="editor-layout">
<div class="editor-column"> <div class="editor-column">
<div class="editor-toolbar"> <div class="editor-toolbar">
<span id="config-label">容积测量配置</span> <span id="config-label">容积测量配置</span>
<button id="import-config" class="text-button">导入 JSON</button> <button id="import-config" class="text-button">导入 JSON</button>
</div> </div>
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea> <form id="config-form" class="config-form" aria-label="配置字段"></form>
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p> <p id="config-path" class="file-path">字段名称固定;可填写右侧数据或从本地 JSON 导入</p>
</div> </div>
<aside class="publish-aside"> <aside class="publish-aside">
<h3>发布检查</h3> <h3>发布检查</h3>
@@ -171,7 +172,7 @@
</div> </div>
</div> </div>
<div class="data-surface"> <div class="data-surface">
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table> <table><thead><tr><th>服务器文件名</th><th>原始文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p> <p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
</div> </div>
</section> </section>
@@ -210,6 +211,7 @@
<label><span>公司名称</span><input id="company-name" required></label> <label><span>公司名称</span><input id="company-name" required></label>
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label> <label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
<button class="button primary" type="submit">添加公司</button> <button class="button primary" type="submit">添加公司</button>
<button id="delete-company" class="button danger" type="button">删除当前公司</button>
</form> </form>
<form id="line-form" class="form-surface"> <form id="line-form" class="form-surface">
<h3>添加产线</h3> <h3>添加产线</h3>
@@ -217,10 +219,29 @@
<label><span>产线名称</span><input id="line-name" required></label> <label><span>产线名称</span><input id="line-name" required></label>
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label> <label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
<button class="button primary" type="submit">添加产线</button> <button class="button primary" type="submit">添加产线</button>
<button id="delete-line" class="button danger" type="button">删除当前产线</button>
</form> </form>
</div> </div>
</section> </section>
</main> </main>
<div id="action-dialog" class="action-dialog" hidden role="dialog" aria-modal="true" aria-labelledby="action-dialog-title">
<form id="action-dialog-form" class="action-dialog-shell">
<div class="action-dialog-head">
<p class="section-kicker">MODEL CONTROL</p>
<h2 id="action-dialog-title">模型操作</h2>
</div>
<p id="action-dialog-message" class="action-dialog-message"></p>
<label class="action-dialog-field">
<span>文件名</span>
<input id="action-dialog-input" type="text" autocomplete="off" spellcheck="false">
</label>
<p id="action-dialog-error" class="action-dialog-error" hidden></p>
<div class="action-dialog-actions">
<button id="action-dialog-cancel" class="button secondary" type="button">取消</button>
<button id="action-dialog-confirm" class="button primary" type="submit">确认</button>
</div>
</form>
</div>
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title"> <div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
<div class="lightbox-shell"> <div class="lightbox-shell">
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div> <div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
+434 -39
View File
@@ -21,6 +21,11 @@ const identificationExample = {
repeat: 2 repeat: 2
}; };
const configFields = {
volume: Object.keys(volumeExample),
identification: Object.keys(identificationExample)
};
const state = { const state = {
configType: "volume", configType: "volume",
imagePaths: { csv: null, json: null }, imagePaths: { csv: null, json: null },
@@ -33,7 +38,9 @@ const state = {
defaultDeviceId: "", defaultDeviceId: "",
review: null, review: null,
retryDeviceId: null, retryDeviceId: null,
notificationDeviceId: null,
lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null }, lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null },
notifications: [],
connected: false connected: false
}; };
@@ -66,12 +73,14 @@ const elements = {
openImage: document.querySelector('[data-open-plot="json"]') openImage: document.querySelector('[data-open-plot="json"]')
} }
}, },
configEditor: document.querySelector("#config-editor"), configForm: document.querySelector("#config-form"),
configLabel: document.querySelector("#config-label"), configLabel: document.querySelector("#config-label"),
configPath: document.querySelector("#config-path"), configPath: document.querySelector("#config-path"),
publishTarget: document.querySelector("#publish-target"), publishTarget: document.querySelector("#publish-target"),
publishButton: document.querySelector("#publish-config"), publishButton: document.querySelector("#publish-config"),
publishResult: document.querySelector("#publish-result"), publishResult: document.querySelector("#publish-result"),
configNotifications: document.querySelector("#config-notifications"),
configNotificationBadge: document.querySelector("#config-notification-badge"),
lineCompany: document.querySelector("#line-company"), lineCompany: document.querySelector("#line-company"),
licenseTarget: document.querySelector("#license-target"), licenseTarget: document.querySelector("#license-target"),
licenseList: document.querySelector("#license-list"), licenseList: document.querySelector("#license-list"),
@@ -87,6 +96,14 @@ const elements = {
reviewTarget: document.querySelector("#review-target"), reviewTarget: document.querySelector("#review-target"),
approveReview: document.querySelector("#approve-review"), approveReview: document.querySelector("#approve-review"),
rejectReview: document.querySelector("#reject-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"), lightbox: document.querySelector("#image-lightbox"),
lightboxTitle: document.querySelector("#lightbox-title"), lightboxTitle: document.querySelector("#lightbox-title"),
lightboxImage: document.querySelector("#lightbox-image"), lightboxImage: document.querySelector("#lightbox-image"),
@@ -225,6 +242,11 @@ function renderLineOptions() {
function updateSelectedTarget() { function updateSelectedTarget() {
const company = selectedCompany(); const company = selectedCompany();
const line = selectedLine(); const line = selectedLine();
if (state.notificationDeviceId !== elements.deviceId.value) {
state.notificationDeviceId = elements.deviceId.value;
state.notifications = [];
renderNotifications();
}
elements.licenseTarget.value = company && line ? `${company.name} / ${line.name}` : ""; elements.licenseTarget.value = company && line ? `${company.name} / ${line.name}` : "";
void syncConnection(); void syncConnection();
} }
@@ -253,7 +275,7 @@ async function refreshLicenses() {
elements.licenseList.innerHTML = result.licenses.map((license) => ` elements.licenseList.innerHTML = result.licenses.map((license) => `
<tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td> <tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td>
<td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td> <td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td>
<td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button><button class="table-action" data-license-download="${escapeHtml(license.licenseId)}">下载</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : ""}</td></tr> <td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button><button class="table-action" data-license-download="${escapeHtml(license.licenseId)}">下载</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : `<button class="table-action danger" data-license-delete="${escapeHtml(license.licenseId)}">删除</button>`}</td></tr>
`).join(""); `).join("");
elements.licenseEmpty.hidden = result.licenses.length > 0; elements.licenseEmpty.hidden = result.licenses.length > 0;
setStatus("许可证已刷新", "success"); setStatus("许可证已刷新", "success");
@@ -267,7 +289,7 @@ async function refreshModels() {
if (!result) return; if (!result) return;
state.models = result.fileList; state.models = result.fileList;
elements.modelList.innerHTML = state.models.map((model) => ` elements.modelList.innerHTML = state.models.map((model) => `
<tr><td>${escapeHtml(model.fileName)}</td><td>${escapeHtml(model.uploadTime || "-")}</td> <tr><td>${escapeHtml(model.fileName)}</td><td>${escapeHtml(model.originalFileName || model.fileName)}</td><td>${escapeHtml(model.uploadTime || "-")}</td>
<td>${formatSize(model.size)}</td><td><button class="table-action" data-model-download="${escapeHtml(model.fileID)}">下载</button><button class="table-action danger" data-model-delete="${escapeHtml(model.fileID)}">删除</button></td></tr> <td>${formatSize(model.size)}</td><td><button class="table-action" data-model-download="${escapeHtml(model.fileID)}">下载</button><button class="table-action danger" data-model-delete="${escapeHtml(model.fileID)}">删除</button></td></tr>
`).join(""); `).join("");
elements.modelEmpty.hidden = state.models.length > 0; elements.modelEmpty.hidden = state.models.length > 0;
@@ -283,6 +305,7 @@ async function refreshIdentificationFiles() {
state.identificationFiles = result.files || result.fileList || []; state.identificationFiles = result.files || result.fileList || [];
elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => ` elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => `
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td> <tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
<td>${escapeHtml(file.runId || "-")}</td>
<td>${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}</td><td>${formatSize(file.size)}</td> <td>${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}</td><td>${formatSize(file.size)}</td>
<td>${file.status === "processed" ? "已处理" : "待处理"}</td> <td>${file.status === "processed" ? "已处理" : "待处理"}</td>
<td><button class="table-action" data-identification-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-identification-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-identification-delete="${escapeHtml(file.fileID)}">删除</button></td></tr> <td><button class="table-action" data-identification-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-identification-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-identification-delete="${escapeHtml(file.fileID)}">删除</button></td></tr>
@@ -332,6 +355,88 @@ function showError(error) {
elements.publishResult.dataset.tone = "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,
expectedLabel = "输入内容",
ignoreCase = false
}) {
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,
expectedLabel,
ignoreCase
};
});
}
elements.actionDialogForm.addEventListener("submit", (event) => {
event.preventDefault();
const value = elements.actionDialogInput.value.trim();
const expectedValue = actionDialogState?.expectedValue;
const expectedLabel = actionDialogState?.expectedLabel || "输入内容";
const ignoreCase = actionDialogState?.ignoreCase === true;
if (!value) {
elements.actionDialogError.textContent = `${expectedLabel}不能为空`;
elements.actionDialogError.hidden = false;
elements.actionDialogInput.focus();
return;
}
if (expectedValue !== null) {
const expected = String(expectedValue).trim();
const matched = ignoreCase
? value.toLowerCase() === expected.toLowerCase()
: value === expected;
if (!matched) {
elements.actionDialogError.textContent = `${expectedLabel}不匹配,请按提示完整输入`;
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) { function showImage(result) {
if (!result) return; if (!result) return;
const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json") const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json")
@@ -353,8 +458,9 @@ function showImage(result) {
plot.showImage.disabled = false; plot.showImage.disabled = false;
plot.openImage.disabled = false; plot.openImage.disabled = false;
if (mediaType === "csv" && result.reviewable) { if (mediaType === "csv" && result.reviewable) {
state.review = { runId: result.fileName, deviceId: result.deviceId }; const runId = result.runId || result.fileName;
elements.reviewTarget.textContent = result.fileName; state.review = { runId, deviceId: result.deviceId };
elements.reviewTarget.textContent = `${result.fileName} (runId: ${runId})`;
elements.approveReview.disabled = false; elements.approveReview.disabled = false;
elements.rejectReview.disabled = false; elements.rejectReview.disabled = false;
} }
@@ -413,16 +519,91 @@ function activateTab(target) {
} }
function activateConfigType(configType) { function activateConfigType(configType) {
try { syncCurrentConfig();
state.configs[state.configType] = JSON.parse(elements.configEditor.value);
} catch (_error) {
// Keep the last valid configuration when changing views.
}
state.configType = configType; state.configType = configType;
document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType)); document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType));
renderConfig(); renderConfig();
} }
function isPowerOfTwo(value) {
return Number.isInteger(value) && value >= 2 && (value & (value - 1)) === 0;
}
function clearConfigErrors() {
elements.configForm.querySelectorAll("[aria-invalid]").forEach((input) => input.removeAttribute("aria-invalid"));
elements.configForm.querySelectorAll(".config-field-error").forEach((item) => item.remove());
}
function showConfigFieldError(field, message) {
const input = elements.configForm.querySelector(`[data-config-field="${field}"]`);
if (!input) return;
input.setAttribute("aria-invalid", "true");
const error = document.createElement("p");
error.className = "config-field-error";
error.textContent = message;
input.closest(".config-field").append(error);
}
function readConfigForm({ showErrors = false } = {}) {
const type = state.configType;
const parameters = {};
let invalidField = null;
let invalidMessage = null;
clearConfigErrors();
for (const field of configFields[type]) {
const input = elements.configForm.querySelector(`[data-config-field="${field}"]`);
const text = input?.value.trim() || "";
if (field === "levels") {
const values = text.split(",").map((value) => value.trim()).filter(Boolean);
parameters[field] = values.map(Number);
if (!values.length || parameters[field].some((value) => !Number.isFinite(value))) {
invalidField = field;
invalidMessage = "请输入以逗号分隔的有效数字。";
} else if (!isPowerOfTwo(parameters[field].length)) {
invalidField = field;
invalidMessage = "元素个数必须是不小于 2 的 2 的整数次幂。";
}
continue;
}
const value = Number(text);
parameters[field] = value;
if (!text || !Number.isFinite(value)) {
invalidField = field;
invalidMessage = "请输入有效数字。";
} else if (["num_runs", "n_order", "repeat"].includes(field) && !Number.isInteger(value)) {
invalidField = field;
invalidMessage = "请输入整数。";
}
}
if (invalidField) {
if (showErrors) showConfigFieldError(invalidField, invalidMessage);
throw new Error(`${invalidField}${invalidMessage}`);
}
return parameters;
}
function syncCurrentConfig() {
try {
state.configs[state.configType] = readConfigForm();
} catch (_error) {
// Preserve the last valid values until the user corrects the visible form.
}
}
function normalizeImportedConfig(parameters) {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
throw new Error("导入配置必须是 JSON 对象");
}
const fields = configFields[state.configType];
const unknown = Object.keys(parameters).filter((field) => !fields.includes(field));
if (unknown.length) throw new Error(`导入配置包含未知字段:${unknown.join(", ")}`);
const normalized = { ...state.configs[state.configType] };
for (const field of fields) {
if (parameters[field] !== undefined) normalized[field] = parameters[field];
}
return normalized;
}
function switchToIdentificationConfigForRetry() { function switchToIdentificationConfigForRetry() {
if (!state.review) return; if (!state.review) return;
state.retryDeviceId = state.review.deviceId; state.retryDeviceId = state.review.deviceId;
@@ -464,11 +645,16 @@ async function submitRetryReview() {
function renderConfig() { function renderConfig() {
const isVolume = state.configType === "volume"; const isVolume = state.configType === "volume";
elements.configEditor.value = JSON.stringify(state.configs[state.configType], null, 2); const config = state.configs[state.configType];
elements.configForm.innerHTML = configFields[state.configType].map((field) => {
const value = field === "levels" ? config[field].join(", ") : config[field];
const integer = ["num_runs", "n_order", "repeat"].includes(field);
return `<div class="config-field"><label for="config-${field}">${field}</label><input id="config-${field}" data-config-field="${field}" type="${field === "levels" ? "text" : "number"}"${field === "levels" ? "" : ` step="${integer ? "1" : "any"}"` } value="${escapeHtml(value)}" required></div>`;
}).join("");
elements.configLabel.textContent = isVolume ? "容积测量配置" : "系统辨识配置"; elements.configLabel.textContent = isVolume ? "容积测量配置" : "系统辨识配置";
elements.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置"; elements.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置";
elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置"; elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置";
elements.configPath.textContent = "可直接编辑,或从本地 JSON 导入"; elements.configPath.textContent = "字段名称固定;可填写右侧数据或从本地 JSON 导入";
elements.publishResult.textContent = "等待操作"; elements.publishResult.textContent = "等待操作";
delete elements.publishResult.dataset.tone; delete elements.publishResult.dataset.tone;
} }
@@ -527,6 +713,60 @@ document.querySelector("#line-form").addEventListener("submit", async (event) =>
await refreshOrganizations(); await refreshOrganizations();
}); });
document.querySelector("#delete-line").addEventListener("click", async () => {
const company = selectedCompany();
const line = selectedLine();
if (!company || !line) return showError(new Error("请先选择公司和产线"));
const confirmation = await requestModelName({
title: "确认删除产线",
message: `删除产线 ${line.name} 后将删除该产线关联的模型、辨识数据、容积配置、通知与许可证记录(已撤销)。请输入完整 deviceId 以确认。`,
value: "",
confirmLabel: "删除产线",
danger: true,
expectedValue: line.deviceId,
expectedLabel: "deviceId",
ignoreCase: true
});
if (confirmation === null) return;
const result = await runBusy("正在删除产线", () => window.reinloop.deleteProductionLine({
companyId: company.id,
productionLineId: line.id,
credentials: credentials()
}));
if (!result) return;
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
await refreshOrganizations();
await refreshLicenses();
setStatus("产线已删除", "success");
});
document.querySelector("#delete-company").addEventListener("click", async () => {
const company = selectedCompany();
if (!company) return showError(new Error("请先选择公司"));
const confirmation = await requestModelName({
title: "确认删除公司",
message: `删除公司 ${company.name} 会级联删除其下所有产线及关联模型、辨识数据、容积配置、通知与许可证记录(有效许可证会阻止删除)。请输入公司编码以确认。`,
value: "",
confirmLabel: "删除公司",
danger: true,
expectedValue: company.code,
expectedLabel: "公司编码",
ignoreCase: true
});
if (confirmation === null) return;
const result = await runBusy("正在删除公司", () => window.reinloop.deleteCompany({
companyId: company.id,
credentials: credentials()
}));
if (!result) return;
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
await refreshOrganizations();
await refreshLicenses();
setStatus("公司已删除", "success");
});
document.querySelector("#license-form").addEventListener("submit", async (event) => { document.querySelector("#license-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const company = selectedCompany(); const company = selectedCompany();
@@ -551,6 +791,7 @@ elements.licenseList.addEventListener("click", async (event) => {
const detailId = button.dataset.licenseDetail; const detailId = button.dataset.licenseDetail;
const downloadId = button.dataset.licenseDownload; const downloadId = button.dataset.licenseDownload;
const revokeId = button.dataset.licenseRevoke; const revokeId = button.dataset.licenseRevoke;
const deleteId = button.dataset.licenseDelete;
if (detailId) { if (detailId) {
const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() })); const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() }));
if (result) { if (result) {
@@ -571,11 +812,45 @@ elements.licenseList.addEventListener("click", async (event) => {
} }
return; return;
} }
if (deleteId) {
const confirmation = await requestModelName({
title: "确认删除许可证",
message: `已撤销许可证才能删除。请输入许可证 ID 以确认永久删除。\n\n当前许可证 ID${deleteId}`,
value: "",
confirmLabel: "永久删除",
danger: true,
expectedValue: deleteId,
expectedLabel: "许可证ID",
ignoreCase: true
});
if (confirmation === null) return;
button.disabled = true;
try {
const result = await runBusy("正在删除许可证", () => window.reinloop.deleteLicense({
licenseId: deleteId,
credentials: credentials()
}));
if (result) {
await refreshLicenses();
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
setStatus("许可证已删除", "success");
}
} finally {
button.disabled = false;
}
return;
}
if (revokeId) { if (revokeId) {
const reason = window.prompt("请输入撤销原因", "管理员撤销"); const reason = await requestModelName({
title: "确认撤销许可证",
message: "请输入撤销原因。确认后将立即撤销该许可证。",
value: "管理员撤销",
confirmLabel: "确认撤销",
danger: true
});
if (reason === null) return; if (reason === null) return;
const trimmedReason = reason.trim() || "管理员撤销"; const trimmedReason = reason.trim() || "管理员撤销";
if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return;
button.disabled = true; button.disabled = true;
try { try {
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({
@@ -604,20 +879,29 @@ document.querySelector("#upload-model").addEventListener("click", async () => {
state.models = models.fileList; state.models = models.fileList;
const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile()); const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile());
if (!selected) return; if (!selected) return;
const existing = state.models.find((model) => model.fileName === selected.fileName); 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; let overwrite = false;
if (existing) { if (existing) {
if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return; const confirmation = await requestModelName({
const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`); title: "确认覆盖模型",
if (confirmation !== selected.fileName) { message: `服务器已存在 ${modelName},覆盖后无法恢复。请输入完整文件名以确认。`,
setStatus("文件名不匹配,已取消覆盖", "idle"); confirmLabel: "覆盖并上传",
return; danger: true,
} expectedValue: modelName
});
if (confirmation === null) return;
overwrite = true; overwrite = true;
} }
const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({ const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({
deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName, deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName,
overwrite, credentials: credentials() modelName, overwrite, credentials: credentials()
})); }));
if (result) { if (result) {
setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success"); setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success");
@@ -628,25 +912,33 @@ document.querySelector("#upload-model").addEventListener("click", async () => {
} }
}); });
elements.modelList.addEventListener("click", async (event) => { 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; if (!fileID) return;
const model = state.models.find((item) => item.fileID === fileID); 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() })); const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() }));
if (result) setStatus(`模型已下载: ${result.filePath}`, "success"); if (result) setStatus(`模型已下载: ${result.filePath}`, "success");
} else { } else {
if (!window.confirm(`确认删除模型 ${model.fileName}`)) return; const confirmation = await requestModelName({
const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`); title: "确认删除模型",
if (confirmation !== model.fileName) { message: `删除 ${model.fileName} 后无法恢复。请输入完整文件名以确认。`,
setStatus("文件名不匹配,已取消删除", "idle"); confirmLabel: "永久删除",
return; danger: true,
} expectedValue: model.fileName
event.target.disabled = true; });
if (confirmation === null) return;
button.disabled = true;
try { try {
const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() })); const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() }));
if (result) await refreshModels(); if (result) {
await refreshModels();
setStatus("模型已删除", "success");
}
} finally { } finally {
event.target.disabled = false; button.disabled = false;
} }
} }
}); });
@@ -664,6 +956,12 @@ elements.identificationFileList.addEventListener("click", async (event) => {
if (result) { if (result) {
showImage(result); showImage(result);
activateTab("plot-panel"); activateTab("plot-panel");
if ((file.mediaType !== "json") && file.runId) {
state.review = { runId: file.runId, deviceId: elements.deviceId.value };
elements.reviewTarget.textContent = `${result.fileName} (runId: ${file.runId})`;
elements.approveReview.disabled = false;
elements.rejectReview.disabled = false;
}
} }
} else if (event.target.dataset.identificationDownload) { } else if (event.target.dataset.identificationDownload) {
const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({ const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({
@@ -688,8 +986,13 @@ elements.identificationFileList.addEventListener("click", async (event) => {
document.querySelector("#import-config").addEventListener("click", async () => { document.querySelector("#import-config").addEventListener("click", async () => {
const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig()); const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig());
if (!result) return; if (!result) return;
state.configs[state.configType] = result.parameters; try {
elements.configEditor.value = JSON.stringify(result.parameters, null, 2); state.configs[state.configType] = normalizeImportedConfig(result.parameters);
renderConfig();
} catch (error) {
showError(error);
return;
}
elements.configPath.textContent = result.filePath; elements.configPath.textContent = result.filePath;
setStatus("配置已导入", "success"); setStatus("配置已导入", "success");
}); });
@@ -700,8 +1003,13 @@ document.querySelector("#load-server").addEventListener("click", async () => {
credentials: credentials() credentials: credentials()
})); }));
if (!parameters) return; if (!parameters) return;
state.configs[state.configType] = parameters; try {
elements.configEditor.value = JSON.stringify(parameters, null, 2); state.configs[state.configType] = normalizeImportedConfig(parameters);
renderConfig();
} catch (error) {
showError(new Error(`Server 配置无效:${error.message}`));
return;
}
elements.publishResult.textContent = "已读取当前 Server 配置"; elements.publishResult.textContent = "已读取当前 Server 配置";
elements.publishResult.dataset.tone = "success"; elements.publishResult.dataset.tone = "success";
setStatus("读取完成", "success"); setStatus("读取完成", "success");
@@ -710,9 +1018,9 @@ document.querySelector("#load-server").addEventListener("click", async () => {
elements.publishButton.addEventListener("click", async () => { elements.publishButton.addEventListener("click", async () => {
let parameters; let parameters;
try { try {
parameters = JSON.parse(elements.configEditor.value); parameters = readConfigForm({ showErrors: true });
} catch (error) { } catch (error) {
showError(new Error(`JSON 格式错误: ${error.message}`)); showError(error);
return; return;
} }
@@ -732,6 +1040,10 @@ elements.publishButton.addEventListener("click", async () => {
? `${result.message}: ${result.storagePath}` ? `${result.message}: ${result.storagePath}`
: result.message; : result.message;
elements.publishResult.dataset.tone = "success"; elements.publishResult.dataset.tone = "success";
if (state.configType === "volume") {
const notification = state.notifications.find((item) => item.type === "volume_request_started");
if (notification) void acknowledgeNotification(notification);
}
if (state.configType === "identification" && state.retryDeviceId) { if (state.configType === "identification" && state.retryDeviceId) {
await submitRetryReview(); await submitRetryReview();
} else { } else {
@@ -830,4 +1142,87 @@ elements.controlFileList.addEventListener("click", async (event) => {
button.disabled = false; button.disabled = false;
} }
} }
});
function renderNotifications() {
elements.configNotificationBadge.hidden = state.notifications.length === 0;
elements.configNotificationBadge.textContent = String(state.notifications.length);
elements.configNotifications.innerHTML = state.notifications.map((notification) => {
const action = notification.type === "volume_request_started"
? "处理配置"
: notification.type === "volume_result_ready"
? "查看结果"
: "查看绘图";
const secondaryAction = notification.type === "identification_result_ready" ? "查看辨识数据" : "";
const traces = [
notification.runId ? `runId: ${notification.runId}` : "",
notification.fileID ? `fileID: ${notification.fileID}` : "",
notification.requestId ? `requestId: ${notification.requestId}` : ""
].filter(Boolean).join(" | ");
return `<article class="panel-notification" data-notification-id="${escapeHtml(notification.notificationId)}" data-type="${escapeHtml(notification.type)}"><div class="notification-copy"><strong>${escapeHtml(notification.title)}</strong><span>${escapeHtml(notification.message)}</span>${traces ? `<small class="notification-trace">${escapeHtml(traces)}</small>` : ""}</div><div class="notification-actions"><button class="button secondary" data-notification-action="primary" data-notification-id="${escapeHtml(notification.notificationId)}">${action}</button>${secondaryAction ? `<button class="button secondary" data-notification-action="secondary" data-notification-id="${escapeHtml(notification.notificationId)}">${secondaryAction}</button>` : ""}<button class="button icon" title="关闭提醒" aria-label="关闭提醒" data-notification-action="close" data-notification-id="${escapeHtml(notification.notificationId)}">x</button></div></article>`;
}).join("");
}
async function acknowledgeNotification(notification) {
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;
}
async function handleNotificationAction(notification, action) {
if (action === "close") {
await acknowledgeNotification(notification);
return;
}
if (notification.type === "volume_request_started") {
activateTab("config-panel");
activateConfigType("volume");
elements.configForm.querySelector("input")?.focus();
return;
}
if (notification.type === "volume_result_ready") {
const result = await runBusy("正在打开容积测试结果", () => window.reinloop.openNotificationFile({
fileID: notification.fileID,
credentials: credentials()
}));
if (result) setStatus(`已打开结果文件:${result.filePath}`, "success");
return;
}
if (action === "primary") {
activateTab("plot-panel");
return;
}
activateTab("identification-data-panel");
await refreshIdentificationFiles();
}
elements.configNotifications.addEventListener("click", (event) => {
const button = event.target.closest("button[data-notification-id]");
if (!button) return;
const notification = state.notifications.find((item) => item.notificationId === button.dataset.notificationId);
if (notification) void handleNotificationAction(notification, button.dataset.notificationAction);
});
window.reinloop.onPanelNotification((notification) => {
if (!notification?.notificationId || notification.deviceId !== elements.deviceId.value) return;
state.notificationDeviceId = notification.deviceId;
if (state.notifications.some((item) => item.notificationId === notification.notificationId)) return;
state.notifications.push(notification);
renderNotifications();
}); });
+274 -257
View File
@@ -1,258 +1,275 @@
:root { :root {
color-scheme: light; color-scheme: light;
--ink: #15251f; --ink: #15251f;
--muted: #617069; --muted: #617069;
--line: #cfd7d2; --line: #cfd7d2;
--paper: #f2f4f1; --paper: #f2f4f1;
--surface: #ffffff; --surface: #ffffff;
--green: #146b4a; --green: #146b4a;
--green-dark: #0d4b34; --green-dark: #0d4b34;
--amber: #e7a928; --amber: #e7a928;
--red: #ad342d; --red: #ad342d;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
body { body {
margin: 0; margin: 0;
min-width: 900px; min-width: 900px;
color: var(--ink); color: var(--ink);
background: background:
linear-gradient(rgba(20, 107, 74, 0.035) 1px, transparent 1px), linear-gradient(rgba(20, 107, 74, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(20, 107, 74, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(20, 107, 74, 0.035) 1px, transparent 1px),
var(--paper); var(--paper);
background-size: 28px 28px; background-size: 28px 28px;
font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif; font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif;
} }
button, input, textarea, select { font: inherit; } button, input, textarea, select { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.45; } button:disabled { cursor: not-allowed; opacity: 0.45; }
.topbar { .topbar {
height: 104px; height: 104px;
padding: 20px 36px; padding: 20px 36px;
color: white; color: white;
background: var(--ink); background: var(--ink);
border-bottom: 5px solid var(--amber); border-bottom: 5px solid var(--amber);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
h1, h2, h3, p { margin: 0; } h1, h2, h3, p { margin: 0; }
h1 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 28px; font-weight: 600; letter-spacing: 0; } h1 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 28px; font-weight: 600; letter-spacing: 0; }
h2 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 25px; font-weight: 600; letter-spacing: 0; } h2 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 25px; font-weight: 600; letter-spacing: 0; }
h3 { font-size: 15px; } h3 { font-size: 15px; }
.eyebrow, .section-kicker { font-size: 11px; letter-spacing: 0; font-weight: 700; } .eyebrow, .section-kicker { font-size: 11px; letter-spacing: 0; font-weight: 700; }
.eyebrow { color: #a9c1b6; margin-bottom: 5px; } .eyebrow { color: #a9c1b6; margin-bottom: 5px; }
.section-kicker { color: var(--green); margin-bottom: 5px; } .section-kicker { color: var(--green); margin-bottom: 5px; }
.status { .status {
min-width: 110px; min-width: 110px;
max-width: 420px; max-width: 420px;
padding: 8px 14px; padding: 8px 14px;
border: 1px solid #587067; border: 1px solid #587067;
border-radius: 4px; border-radius: 4px;
color: #d9e4df; color: #d9e4df;
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.status[data-tone="busy"] { border-color: var(--amber); color: #ffd985; } .status[data-tone="busy"] { border-color: var(--amber); color: #ffd985; }
.status[data-tone="success"] { border-color: #62b78f; color: #9fe1bf; } .status[data-tone="success"] { border-color: #62b78f; color: #9fe1bf; }
.status[data-tone="error"] { border-color: #df766e; color: #ffb5ae; } .status[data-tone="error"] { border-color: #df766e; color: #ffb5ae; }
main { max-width: 1500px; margin: 0 auto; padding: 22px 36px 36px; } main { max-width: 1500px; margin: 0 auto; padding: 22px 36px 36px; }
.connection-screen { .connection-screen {
min-height: calc(100vh - 104px); min-height: calc(100vh - 104px);
display: grid; display: grid;
place-items: center; place-items: center;
padding: 36px; padding: 36px;
} }
.connection-form { .connection-form {
width: min(480px, 100%); width: min(480px, 100%);
padding: 30px; padding: 30px;
display: grid; display: grid;
gap: 18px; gap: 18px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
border-top: 4px solid var(--green); border-top: 4px solid var(--green);
box-shadow: 0 18px 45px rgba(21, 37, 31, 0.12); box-shadow: 0 18px 45px rgba(21, 37, 31, 0.12);
} }
.connection-form h2 { margin-bottom: 6px; } .connection-form h2 { margin-bottom: 6px; }
.connection-form label { display: grid; gap: 7px; } .connection-form label { display: grid; gap: 7px; }
.connection-form label span { color: var(--muted); font-size: 12px; font-weight: 700; } .connection-form label span { color: var(--muted); font-size: 12px; font-weight: 700; }
.connection-form .button { width: 100%; margin-top: 4px; } .connection-form .button { width: 100%; margin-top: 4px; }
.connection-message { min-height: 20px; color: var(--muted); font-size: 12px; text-align: center; } .connection-message { min-height: 20px; color: var(--muted); font-size: 12px; text-align: center; }
.connection-message[data-tone="busy"] { color: #8a6414; } .connection-message[data-tone="busy"] { color: #8a6414; }
.connection-message[data-tone="error"] { color: var(--red); } .connection-message[data-tone="error"] { color: var(--red); }
.connection-band { .connection-band {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr)); grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 18px; gap: 18px;
padding: 15px 18px; padding: 15px 18px;
background: #e4e9e5; background: #e4e9e5;
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 4px solid var(--green); border-left: 4px solid var(--green);
} }
.connection-band label { display: grid; grid-template-columns: 118px 1fr; align-items: center; gap: 10px; } .connection-band label { display: grid; grid-template-columns: 118px 1fr; align-items: center; gap: 10px; }
.connection-band span { font-size: 12px; font-weight: 700; color: var(--muted); } .connection-band span { font-size: 12px; font-weight: 700; color: var(--muted); }
input, select { input, select {
min-width: 0; min-width: 0;
height: 36px; height: 36px;
padding: 0 10px; padding: 0 10px;
border: 1px solid #b9c5be; border: 1px solid #b9c5be;
border-radius: 3px; border-radius: 3px;
background: white; background: white;
color: var(--ink); color: var(--ink);
outline: none; outline: none;
} }
input:focus, select:focus, textarea:focus { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20, 107, 74, 0.12); } input:focus, select:focus, textarea:focus { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20, 107, 74, 0.12); }
.tabs { display: flex; gap: 0; margin-top: 22px; border-bottom: 1px solid var(--line); } .tabs { display: flex; gap: 0; margin-top: 22px; border-bottom: 1px solid var(--line); }
.tab { .tab {
min-width: 132px; min-width: 132px;
padding: 12px 20px; padding: 12px 20px;
border: 0; border: 0;
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-weight: 700; font-weight: 700;
} }
.tab.active { color: var(--green-dark); border-bottom-color: var(--green); } .tab.active { color: var(--green-dark); border-bottom-color: var(--green); }
.tab-badge { display: inline-grid; min-width: 18px; height: 18px; place-items: center; margin-left: 5px; padding: 0 5px; border-radius: 9px; color: white; background: var(--red); font-size: 11px; }
.panel { display: none; padding-top: 22px; }
.panel.active { display: block; animation: reveal 180ms ease-out; } .panel { display: none; padding-top: 22px; }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } .panel.active { display: block; animation: reveal 180ms ease-out; }
.panel-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 16px; } @keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.actions { display: flex; gap: 8px; } .panel-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.button { .actions { display: flex; gap: 8px; }
height: 38px; .button {
padding: 0 16px; height: 38px;
border-radius: 3px; padding: 0 16px;
border: 1px solid transparent; border-radius: 3px;
font-weight: 700; border: 1px solid transparent;
} font-weight: 700;
.button.primary { color: white; background: var(--green); border-color: var(--green); } }
.button.primary:hover { background: var(--green-dark); } .button.primary { color: white; background: var(--green); border-color: var(--green); }
.button.secondary { color: var(--ink); background: white; border-color: #aebbb4; } .button.primary:hover { background: var(--green-dark); }
.button.secondary:hover { border-color: var(--green); color: var(--green); } .button.secondary { color: var(--ink); background: white; border-color: #aebbb4; }
.button.danger { color: var(--red); background: white; border-color: #d5a7a3; } .button.secondary:hover { border-color: var(--green); color: var(--green); }
.button.danger:hover { color: white; background: var(--red); border-color: var(--red); } .button.danger { color: var(--red); background: white; border-color: #d5a7a3; }
.button.icon { width: 38px; padding: 0; background: white; border-color: #aebbb4; font-size: 19px; } .button.danger:hover { color: white; background: var(--red); border-color: var(--red); }
.plot-actions { display: flex; gap: 6px; } .button.icon { width: 38px; padding: 0; background: white; border-color: #aebbb4; font-size: 19px; }
.button.full { width: 100%; margin-top: 10px; } .plot-actions { display: flex; gap: 6px; }
.review-actions { display: flex; align-items: center; gap: 8px; } .button.full { width: 100%; margin-top: 10px; }
.review-actions span { max-width: 320px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .review-actions { display: flex; align-items: center; gap: 8px; }
.review-actions span { max-width: 320px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.plot-item { min-width: 0; } .plot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.plot-title { .plot-item { min-width: 0; }
min-height: 54px; .plot-title {
padding: 9px 12px; min-height: 54px;
display: flex; padding: 9px 12px;
align-items: center; display: flex;
justify-content: space-between; align-items: center;
gap: 12px; justify-content: space-between;
background: var(--surface); gap: 12px;
border: 1px solid var(--line); background: var(--surface);
border-bottom: 0; border: 1px solid var(--line);
} border-bottom: 0;
.plot-title div { display: grid; gap: 3px; } }
.plot-title span { color: var(--green); font-size: 11px; font-weight: 700; } .plot-title div { display: grid; gap: 3px; }
.plot-title strong { font-size: 15px; } .plot-title span { color: var(--green); font-size: 11px; font-weight: 700; }
.plot-stage { .plot-title strong { font-size: 15px; }
height: calc(100vh - 405px); .plot-stage {
min-height: 300px; height: calc(100vh - 405px);
max-height: 620px; min-height: 300px;
display: grid; max-height: 620px;
place-items: center; display: grid;
overflow: auto; place-items: center;
background-color: #dce2de; overflow: auto;
background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); background-color: #dce2de;
background-size: 20px 20px; background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%);
background-position: 0 0, 0 10px, 10px -10px, -10px 0; background-size: 20px 20px;
border: 1px solid #bdc8c1; background-position: 0 0, 0 10px, 10px -10px, -10px 0;
} border: 1px solid #bdc8c1;
.plot-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; background: white; } }
.empty-state { display: grid; gap: 7px; text-align: center; color: var(--muted); } .plot-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; background: white; }
.empty-state strong { color: var(--ink); font-size: 18px; } .empty-state { display: grid; gap: 7px; text-align: center; color: var(--muted); }
.empty-state span { font-size: 13px; } .empty-state strong { color: var(--ink); font-size: 18px; }
.plot-meta { min-height: 29px; display: flex; align-items: start; justify-content: space-between; gap: 24px; } .empty-state span { font-size: 13px; }
.file-path { min-height: 20px; margin-top: 9px; color: var(--muted); font: 12px Consolas, monospace; overflow-wrap: anywhere; } .plot-meta { min-height: 29px; display: flex; align-items: start; justify-content: space-between; gap: 24px; }
.upload-time { flex: 0 0 auto; margin-top: 9px; color: var(--green-dark); font-size: 12px; font-weight: 700; } .file-path { min-height: 20px; margin-top: 9px; color: var(--muted); font: 12px Consolas, monospace; overflow-wrap: anywhere; }
.upload-time { flex: 0 0 auto; margin-top: 9px; color: var(--green-dark); font-size: 12px; font-weight: 700; }
.segmented { display: flex; padding: 3px; background: #dfe5e1; border: 1px solid #c6d0ca; border-radius: 4px; }
.segment { height: 34px; padding: 0 16px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font-weight: 700; } .segmented { display: flex; padding: 3px; background: #dfe5e1; border: 1px solid #c6d0ca; border-radius: 4px; }
.segment.active { background: white; color: var(--green-dark); box-shadow: 0 1px 3px rgba(21, 37, 31, 0.14); } .segment { height: 34px; padding: 0 16px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font-weight: 700; }
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; } .segment.active { background: white; color: var(--green-dark); box-shadow: 0 1px 3px rgba(21, 37, 31, 0.14); }
.editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); } .notification-stack { display: grid; gap: 8px; margin: 0 0 16px; }
.editor-toolbar { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 700; } .panel-notification { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 12px 14px; border: 1px solid #d6b362; border-left: 4px solid var(--amber); background: #fff8e6; }
.text-button { border: 0; background: transparent; color: var(--green); font-size: 13px; font-weight: 700; } .panel-notification[data-type="volume_result_ready"] { border-color: #8ebba5; border-left-color: var(--green); background: #edf8f1; }
textarea { .panel-notification[data-type="identification_result_ready"] { border-color: #9db6c8; border-left-color: #367294; background: #edf5fa; }
display: block; .notification-copy { display: grid; gap: 3px; min-width: 0; }
width: calc(100% - 28px); .notification-copy strong { font-size: 13px; }
height: calc(100vh - 385px); .notification-copy span { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
min-height: 330px; .notification-copy .notification-trace {
margin: 14px; display: block;
padding: 16px; margin-top: 4px;
resize: vertical; color: var(--text-muted);
border: 1px solid #bec9c2; font-size: 12px;
border-radius: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
background: #f8faf8; word-break: break-all;
color: #18392d; }
font: 14px/1.65 Consolas, "Microsoft YaHei UI", monospace; .notification-actions { display: flex; gap: 8px; }
tab-size: 2; .editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; }
outline: none; .editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); }
} .editor-toolbar { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 700; }
.editor-column > .file-path { padding: 0 14px 12px; } .text-button { border: 0; background: transparent; color: var(--green); font-size: 13px; font-weight: 700; }
.publish-aside { padding: 20px; align-self: start; } .config-form { display: grid; gap: 10px; padding: 14px; }
.publish-aside h3 { padding-bottom: 14px; border-bottom: 1px solid var(--line); } .config-field { display: grid; grid-template-columns: minmax(160px, 0.42fr) minmax(0, 1fr); align-items: center; gap: 12px; }
dl { margin: 8px 0 18px; } .config-field label { color: var(--muted); font: 13px Consolas, monospace; font-weight: 700; }
dl div { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e6ebe8; font-size: 12px; } .config-field input { width: 100%; }
dt { color: var(--muted); } .config-field-error { grid-column: 2; margin: -4px 0 0; color: var(--red); font-size: 12px; }
dd { margin: 0; text-align: right; font-weight: 700; } .config-field input[aria-invalid="true"] { border-color: var(--red); box-shadow: 0 0 0 2px rgba(173, 52, 45, 0.12); }
.result { min-height: 42px; margin-top: 14px; padding: 10px; background: #eef1ef; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } .editor-column > .file-path { padding: 0 14px 12px; }
.result[data-tone="success"] { color: var(--green-dark); background: #e2f2e9; } .publish-aside { padding: 20px; align-self: start; }
.result[data-tone="error"] { color: var(--red); background: #f8e7e5; } .publish-aside h3 { padding-bottom: 14px; border-bottom: 1px solid var(--line); }
dl { margin: 8px 0 18px; }
.management-layout { display: grid; grid-template-columns: 330px minmax(0, 1fr); gap: 18px; align-items: start; } dl div { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e6ebe8; font-size: 12px; }
.management-layout.equal { grid-template-columns: repeat(2, minmax(0, 1fr)); } dt { color: var(--muted); }
.form-surface, .data-surface { background: var(--surface); border: 1px solid var(--line); } dd { margin: 0; text-align: right; font-weight: 700; }
.form-surface { padding: 20px; display: grid; gap: 14px; } .result { min-height: 42px; margin-top: 14px; padding: 10px; background: #eef1ef; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.form-surface h3 { padding-bottom: 13px; border-bottom: 1px solid var(--line); } .result[data-tone="success"] { color: var(--green-dark); background: #e2f2e9; }
.form-surface label { display: grid; gap: 6px; } .result[data-tone="error"] { color: var(--red); background: #f8e7e5; }
.form-surface label span { color: var(--muted); font-size: 12px; font-weight: 700; }
.form-note { color: var(--muted); font-size: 11px; line-height: 1.6; } .management-layout { display: grid; grid-template-columns: 330px minmax(0, 1fr); gap: 18px; align-items: start; }
.data-surface { min-width: 0; overflow: auto; } .management-layout.equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
table { width: 100%; border-collapse: collapse; font-size: 12px; } .form-surface, .data-surface { background: var(--surface); border: 1px solid var(--line); }
th, td { padding: 11px 13px; border-bottom: 1px solid #e4e9e6; text-align: left; vertical-align: middle; } .form-surface { padding: 20px; display: grid; gap: 14px; }
th { color: var(--muted); background: #edf1ee; font-size: 11px; } .form-surface h3 { padding-bottom: 13px; border-bottom: 1px solid var(--line); }
td:last-child { white-space: nowrap; } .form-surface label { display: grid; gap: 6px; }
.table-action { border: 0; background: transparent; color: var(--green); font-weight: 700; margin-right: 10px; } .form-surface label span { color: var(--muted); font-size: 12px; font-weight: 700; }
.table-action.danger { color: var(--red); } .form-note { color: var(--muted); font-size: 11px; line-height: 1.6; }
.empty-row { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; } .data-surface { min-width: 0; overflow: auto; }
.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; } table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 11px 13px; border-bottom: 1px solid #e4e9e6; text-align: left; vertical-align: middle; }
.lightbox { position: fixed; inset: 0; z-index: 20; padding: 24px; background: rgba(10, 20, 16, 0.78); } th { color: var(--muted); background: #edf1ee; font-size: 11px; }
.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); } td:last-child { white-space: nowrap; }
.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); } .table-action { border: 0; background: transparent; color: var(--green); font-weight: 700; margin-right: 10px; }
.lightbox-actions { display: flex; align-items: center; gap: 7px; } .table-action.danger { color: var(--red); }
.lightbox-canvas { min-width: 0; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 22px; background-color: #dce2de; background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; } .empty-row { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
.lightbox-canvas img { display: block; max-width: none; background: white; } .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; }
.lightbox-canvas img.fit-image { max-width: 100%; max-height: 100%; object-fit: contain; }
.action-dialog { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 24px; background: rgba(10, 20, 16, 0.68); }
@media (max-width: 1050px) { .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); }
main { padding-left: 22px; padding-right: 22px; } .action-dialog-head { display: grid; gap: 2px; }
.connection-band { grid-template-columns: 1fr; } .action-dialog-message { color: var(--muted); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
.plot-grid { grid-template-columns: 1fr; } .action-dialog-field { display: grid; gap: 7px; }
.plot-stage { height: 360px; } .action-dialog-field span { color: var(--muted); font-size: 12px; font-weight: 700; }
.editor-layout { grid-template-columns: minmax(0, 1fr) 240px; } .action-dialog-field input { width: 100%; }
.management-layout, .management-layout.equal { grid-template-columns: 1fr; } .action-dialog-error { color: var(--red); font-size: 12px; }
.lightbox { padding: 12px; } .action-dialog-actions { display: flex; justify-content: end; gap: 8px; padding-top: 3px; }
.lightbox-toolbar { align-items: start; flex-direction: column; }
.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); }
.lightbox-actions { display: flex; align-items: center; gap: 7px; }
.lightbox-canvas { min-width: 0; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 22px; background-color: #dce2de; background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
.lightbox-canvas img { display: block; max-width: none; background: white; }
.lightbox-canvas img.fit-image { max-width: 100%; max-height: 100%; object-fit: contain; }
@media (max-width: 1050px) {
main { padding-left: 22px; padding-right: 22px; }
.connection-band { grid-template-columns: 1fr; }
.plot-grid { grid-template-columns: 1fr; }
.plot-stage { height: 360px; }
.editor-layout { grid-template-columns: minmax(0, 1fr) 240px; }
.management-layout, .management-layout.equal { grid-template-columns: 1fr; }
.lightbox { padding: 12px; }
.lightbox-toolbar { align-items: start; flex-direction: column; }
} }
+44
View File
@@ -0,0 +1,44 @@
const fs = require("node:fs");
const path = require("node:path");
function createModelHandlers({ callServer, fetchImpl = fetch }) {
return {
list(request) {
return callServer({
type: "listModels",
folder: `${request.deviceId}/model_config`
}, request.credentials);
},
async upload(request) {
if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件");
const sourcePath = path.resolve(request.sourcePath);
const fileName = path.basename(request.fileName);
const modelName = path.basename(request.modelName || fileName);
await fs.promises.access(sourcePath, fs.constants.R_OK);
const issued = await callServer({
type: "issueModelUpload",
deviceId: request.deviceId,
fileName,
modelName,
overwrite: request.overwrite === true
}, request.credentials);
const form = new FormData();
form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName);
const response = await fetchImpl(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: issued.fileName,
originalFileName: issued.originalFileName
};
},
delete(request) {
return callServer({ type: "deleteModel", fileID: request.fileID }, request.credentials);
}
};
}
module.exports = { createModelHandlers };
+1 -9
View File
@@ -39,21 +39,13 @@ async function callServer(payload, options = {}) {
return result; return result;
} }
function downloadHeaders(options = {}) {
if (!options.adminToken) return {};
return {
authorization: `Bearer ${options.adminToken}`,
"x-admin-token": options.adminToken
};
}
async function downloadFromUrl(url, fileName, options = {}) { async function downloadFromUrl(url, fileName, options = {}) {
await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true }); await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true });
return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options); return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options);
} }
async function downloadToPath(url, destination, options = {}) { async function downloadToPath(url, destination, options = {}) {
const response = await fetch(url, { headers: downloadHeaders(options) }); const response = await fetch(url);
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
throw new Error(`下载失败: HTTP ${response.status}`); throw new Error(`下载失败: HTTP ${response.status}`);
} }
@@ -0,0 +1,59 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createModelHandlers } = require("../model-handlers");
const { callServer } = require("../server-client");
const { createApp } = require("../../server/src/app");
const { JsonStore } = require("../../server/src/store");
test("panel model kernel renames, overwrites, lists both names, and deletes on the server", async (context) => {
const dataDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-panel-server-"));
const store = new JsonStore(dataDirectory);
await store.initialize();
const app = createApp({ store, adminToken: "test-token" });
const server = await new Promise((resolve) => {
const listeningServer = app.listen(0, "127.0.0.1", () => resolve(listeningServer));
});
context.after(async () => {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await fs.promises.rm(dataDirectory, { recursive: true, force: true });
});
const credentials = {
apiUrl: `http://127.0.0.1:${server.address().port}`,
adminToken: "test-token"
};
const handlers = createModelHandlers({ callServer });
const sourcePath = path.join(dataDirectory, "controller-original.bin");
const request = {
deviceId: "company-a/line-1",
sourcePath,
fileName: "controller-original.bin",
modelName: "pressure-controller.bin",
credentials
};
await fs.promises.writeFile(sourcePath, "first-version");
const uploaded = await handlers.upload(request);
assert.equal(uploaded.fileName, "pressure-controller.bin");
assert.equal(uploaded.originalFileName, "controller-original.bin");
await fs.promises.writeFile(sourcePath, "second-version");
const overwritten = await handlers.upload({ ...request, overwrite: true });
assert.equal(overwritten.fileID, uploaded.fileID);
const listed = await handlers.list({ deviceId: request.deviceId, credentials });
assert.equal(listed.fileList.length, 1);
assert.equal(listed.fileList[0].fileName, "pressure-controller.bin");
assert.equal(listed.fileList[0].originalFileName, "controller-original.bin");
const deleted = await handlers.delete({ fileID: overwritten.fileID, credentials });
assert.equal(deleted.deletedCount, 1);
const afterDelete = await handlers.list({ deviceId: request.deviceId, credentials });
assert.deepEqual(afterDelete.fileList, []);
await assert.rejects(fs.promises.access(
path.join(dataDirectory, "models", "company-a", "line-1", "pressure-controller.bin")
), { code: "ENOENT" });
});
+62
View File
@@ -0,0 +1,62 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createModelHandlers } = require("../model-handlers");
test("panel model kernel renames, overwrites, and deletes by fileID", async (context) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-panel-model-"));
context.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const sourcePath = path.join(directory, "controller-original.bin");
await fs.promises.writeFile(sourcePath, "first-version");
const records = new Map();
const requests = [];
const callServer = async (payload) => {
requests.push(payload);
if (payload.type === "issueModelUpload") {
const fileID = `model://${payload.deviceId}/${payload.modelName}`;
if (records.has(fileID) && !payload.overwrite) throw new Error("文件已存在");
records.set(fileID, {
fileID,
fileName: payload.modelName,
originalFileName: payload.fileName
});
return { ...records.get(fileID), uploadMetadata: { url: `memory://${encodeURIComponent(fileID)}` } };
}
if (payload.type === "deleteModel") {
const deleted = records.delete(payload.fileID);
return { success: true, deletedCount: deleted ? 1 : 0 };
}
throw new Error(`unexpected request: ${payload.type}`);
};
const handlers = createModelHandlers({
callServer,
fetchImpl: async () => ({ status: 204 })
});
const request = {
deviceId: "company-a/line-1",
sourcePath,
fileName: "controller-original.bin",
modelName: "pressure-controller.bin",
credentials: { adminToken: "test-token" }
};
const uploaded = await handlers.upload(request);
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 });
assert.equal(overwritten.fileID, uploaded.fileID);
assert.equal(records.size, 1);
const deleted = await handlers.delete({ fileID: overwritten.fileID, credentials: request.credentials });
assert.equal(deleted.deletedCount, 1);
assert.equal(records.size, 0);
assert.equal(requests.at(-1).type, "deleteModel");
assert.equal(requests.at(-1).fileID, overwritten.fileID);
});
+2 -2
View File
@@ -49,14 +49,14 @@ $env:B_ADMIN_TOKEN="your-admin-token"
npm start npm start
``` ```
默认服务地址为 `http://127.0.0.1:3000`,健康检查为 `http://127.0.0.1:3000/health`。业务请求使用根路径`/api`。跨设备部署时,应使用服务器局域网 IP 或 HTTPS 域名,而不是 `127.0.0.1` 默认服务地址为 `http://127.0.0.1:3000`,健康检查为 `http://127.0.0.1:3000/health`。业务请求统一使用根路径。跨设备部署时,应使用服务器局域网 IP 或 HTTPS 域名,而不是 `127.0.0.1`
### ControlPanel ### ControlPanel
```powershell ```powershell
cd ControlPanel cd ControlPanel
npm install npm install
$env:REINLOOP_API_URL="http://server-address:3000/api" $env:REINLOOP_API_URL="http://server-address:3000"
$env:B_ADMIN_TOKEN="your-admin-token" $env:B_ADMIN_TOKEN="your-admin-token"
npm run gui npm run gui
``` ```
+67 -2
View File
@@ -1,6 +1,10 @@
"""ReinLoop cloud-server endpoint configuration shared by core modules.""" """ReinLoop cloud-server endpoint configuration shared by core modules."""
import os import os
import threading
import time
import requests
from license_utils import get_verified_license from license_utils import get_verified_license
@@ -11,11 +15,12 @@ base_url = os.environ.get(
).rstrip("/") ).rstrip("/")
server_api_url = os.environ.get( server_api_url = os.environ.get(
"REINLOOP_API_URL", "REINLOOP_API_URL",
f"{base_url}/api", base_url,
) )
# Compatibility alias used by existing modules. It points to the ReinLoop # Compatibility alias used by existing modules. It points to the ReinLoop
# Express server API, not a cloud-function endpoint. # Express server API, not a cloud-function endpoint.
data_record_url = server_api_url data_record_url = server_api_url
device_api_url = f"{server_api_url.rstrip('/')}/device"
_license = get_verified_license() _license = get_verified_license()
_license_device_id = (_license or {}).get("device_id", "").strip() _license_device_id = (_license or {}).get("device_id", "").strip()
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() _environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
@@ -26,4 +31,64 @@ if _license_device_id and _environment_device_id and _license_device_id != _envi
the_folder = _license_device_id or _environment_device_id or "local-test-device" the_folder = _license_device_id or _environment_device_id or "local-test-device"
if not the_folder: if not the_folder:
raise RuntimeError("设备 ID 不能为空") raise RuntimeError("设备 ID 不能为空")
_DEVICE_TOKEN = None
_DEVICE_TOKEN_EXPIRES_AT_MS = 0
_DEVICE_TOKEN_LOCK = threading.Lock()
def _current_license_context():
payload = get_verified_license() or {}
license_id = str(payload.get("license_id") or "").strip()
device_id = str(payload.get("device_id") or _environment_device_id or the_folder).strip()
return license_id, device_id
def _ensure_device_token(timeout=10):
global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS
with _DEVICE_TOKEN_LOCK:
now_ms = int(time.time() * 1000)
if _DEVICE_TOKEN and _DEVICE_TOKEN_EXPIRES_AT_MS - now_ms > 30_000:
return _DEVICE_TOKEN
license_id, device_id = _current_license_context()
if not license_id:
raise RuntimeError("许可证未就绪,无法获取 deviceToken")
response = requests.post(device_api_url, json={
"type": "deviceAuth",
"licenseId": license_id,
"deviceId": device_id,
}, timeout=timeout)
response.raise_for_status()
result = response.json()
if not result.get("success"):
raise RuntimeError(result.get("errMsg") or "设备鉴权失败")
_DEVICE_TOKEN = str(result.get("deviceToken") or "").strip()
_DEVICE_TOKEN_EXPIRES_AT_MS = int(result.get("expiresAtMs") or 0)
if not _DEVICE_TOKEN or _DEVICE_TOKEN_EXPIRES_AT_MS <= now_ms:
raise RuntimeError("设备鉴权返回了无效 deviceToken")
return _DEVICE_TOKEN
def device_post(payload, timeout=10):
"""Call the device-scoped API route with an auto-renewed device token."""
token = _ensure_device_token(timeout=timeout)
request_payload = {**payload, "deviceToken": token}
response = requests.post(device_api_url, json=request_payload, timeout=timeout)
response.raise_for_status()
result = response.json()
if result.get("success"):
return result
if result.get("errCode") in {"DEVICE_TOKEN_EXPIRED", "DEVICE_TOKEN_INVALID"}:
with _DEVICE_TOKEN_LOCK:
global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS
_DEVICE_TOKEN = None
_DEVICE_TOKEN_EXPIRES_AT_MS = 0
token = _ensure_device_token(timeout=timeout)
response = requests.post(device_api_url, json={**payload, "deviceToken": token}, timeout=timeout)
response.raise_for_status()
result = response.json()
return result
+1 -1
View File
@@ -30,7 +30,7 @@ def _init_license():
# 生产环境(exe 打包)→ 严格执行验签 # 生产环境(exe 打包)→ 严格执行验签
from license_utils import check_license from license_utils import check_license
check_license() # 验签并启动唯一的后台巡检线程,失败直接退出 check_license() # 仅启动时验签,失败直接退出
_LICENSE_CHECKED = True _LICENSE_CHECKED = True
+2 -3
View File
@@ -11,7 +11,7 @@ import datetime
import threading import threading
import requests import requests
from api import base_url, data_record_url, the_folder from api import device_post, the_folder
class DataCollector: class DataCollector:
@@ -47,12 +47,11 @@ class DataCollector:
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool: def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。""" """向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
try: try:
resp = requests.post(data_record_url, json={ result = device_post({
"type": "uploadDataFile", "type": "uploadDataFile",
"fileName": filename, "fileName": filename,
"folder": folder, "folder": folder,
}, timeout=30) }, timeout=30)
result = resp.json()
except Exception as e: except Exception as e:
self.log(f"向云服务器申请上传地址异常: {e}") self.log(f"向云服务器申请上传地址异常: {e}")
return False return False
+2 -6
View File
@@ -3,16 +3,12 @@
def heartbeat_device(timeout=5): def heartbeat_device(timeout=5):
"""Refresh the current device's Server heartbeat and return its timestamp.""" """Refresh the current device's Server heartbeat and return its timestamp."""
import requests from api import device_post
from api import data_record_url, the_folder
try: try:
response = requests.post(data_record_url, json={ result = device_post({
"type": "deviceHeartbeat", "type": "deviceHeartbeat",
"deviceId": the_folder,
}, timeout=timeout) }, timeout=timeout)
response.raise_for_status()
result = response.json()
except Exception as exc: except Exception as exc:
raise ValueError(f"设备心跳请求失败: {exc}") from exc raise ValueError(f"设备心跳请求失败: {exc}") from exc
if not result.get("success"): if not result.get("success"):
+30 -3
View File
@@ -16,7 +16,7 @@ from collections import deque
from get_V import measure_volume from get_V import measure_volume
from ind_collector import collect_data_with_prbs from ind_collector import collect_data_with_prbs
from api import base_url, data_record_url, the_folder from api import device_post, the_folder
class IdentificationManager: class IdentificationManager:
@@ -66,12 +66,11 @@ class IdentificationManager:
""" """
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容) # Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
try: try:
resp = requests.post(data_record_url, json={ result = device_post({
"type": "uploadDataFile", "type": "uploadDataFile",
"fileName": filename, "fileName": filename,
"folder": folder, "folder": folder,
}, timeout=30) }, timeout=30)
result = resp.json()
except Exception as e: except Exception as e:
self.log(f"向云服务器申请上传地址异常: {e}") self.log(f"向云服务器申请上传地址异常: {e}")
return False return False
@@ -109,6 +108,34 @@ class IdentificationManager:
This is an independent pre-scan. It does not replace or modify the This is an independent pre-scan. It does not replace or modify the
subsequent PRBS collection performed by ``collect_data_with_prbs``. subsequent PRBS collection performed by ``collect_data_with_prbs``.
""" """
if getattr(conn_mgr, "simulation", False):
stable_pressure_records = [
{"distance": 1000, "pressure": 400},
{"distance": 900, "pressure": 361},
{"distance": 800, "pressure": 322},
{"distance": 700, "pressure": 283},
{"distance": 600, "pressure": 244},
{"distance": 500, "pressure": 205},
{"distance": 400, "pressure": 166},
{"distance": 300, "pressure": 127},
{"distance": 200, "pressure": 88},
{"distance": 100, "pressure": 49},
{"distance": 0, "pressure": 10},
]
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"travel_stability_pressures_{timestamp}.json"
payload = {"stable_pressures": stable_pressure_records}
uploaded = self._upload_to_server(
json.dumps(payload, ensure_ascii=False, indent=2),
filename,
f"{the_folder}/ind_data",
)
if uploaded:
self.log("行程稳态压力 JSON 上传成功,继续执行 PRBS 辨识")
else:
self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识")
return payload
distances = list(range(1000, -1, -100)) distances = list(range(1000, -1, -100))
settings = { settings = {
"min_wait_time": 5.0, "min_wait_time": 5.0,
+2 -5
View File
@@ -130,15 +130,12 @@ def parse_identification_config_csv(csv_text: str) -> dict:
def download_identification_config(timeout=20) -> dict: def download_identification_config(timeout=20) -> dict:
"""Download the current customer's CSV config through the cloud server.""" """Download the current customer's CSV config through the cloud server."""
import requests import requests
from api import data_record_url, the_folder from api import device_post
try: try:
response = requests.post(data_record_url, json={ result = device_post({
"type": "getIdentificationConfig", "type": "getIdentificationConfig",
"deviceId": the_folder,
}, timeout=timeout) }, timeout=timeout)
response.raise_for_status()
result = response.json()
except Exception as exc: except Exception as exc:
raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc
+2 -14
View File
@@ -2,13 +2,10 @@
def _post(payload, timeout=10): def _post(payload, timeout=10):
import requests from api import device_post
from api import data_record_url
try: try:
response = requests.post(data_record_url, json=payload, timeout=timeout) result = device_post(payload, timeout=timeout)
response.raise_for_status()
result = response.json()
except Exception as exc: except Exception as exc:
raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc
if not result.get("success"): if not result.get("success"):
@@ -18,13 +15,10 @@ def _post(payload, timeout=10):
def register_identification_result(run_id: str, timeout=10) -> None: def register_identification_result(run_id: str, timeout=10) -> None:
"""Register one uploaded CSV as the customer's current review target.""" """Register one uploaded CSV as the customer's current review target."""
from api import the_folder
if not run_id: if not run_id:
raise ValueError("辨识结果缺少 run_id") raise ValueError("辨识结果缺少 run_id")
_post({ _post({
"type": "registerIdentificationResult", "type": "registerIdentificationResult",
"deviceId": the_folder,
"runId": run_id, "runId": run_id,
"fileName": run_id, "fileName": run_id,
}, timeout=timeout) }, timeout=timeout)
@@ -32,11 +26,8 @@ def register_identification_result(run_id: str, timeout=10) -> None:
def get_identification_feedback(run_id: str, timeout=10): def get_identification_feedback(run_id: str, timeout=10):
"""Return None while pending, otherwise return the integer 0 or 1.""" """Return None while pending, otherwise return the integer 0 or 1."""
from api import the_folder
result = _post({ result = _post({
"type": "getIdentificationFeedback", "type": "getIdentificationFeedback",
"deviceId": the_folder,
"runId": run_id, "runId": run_id,
}, timeout=timeout) }, timeout=timeout)
if not result.get("ready"): if not result.get("ready"):
@@ -49,10 +40,7 @@ def get_identification_feedback(run_id: str, timeout=10):
def acknowledge_identification_feedback(run_id: str, timeout=10) -> None: def acknowledge_identification_feedback(run_id: str, timeout=10) -> None:
"""Delete the consumed review record so stale feedback cannot be reused.""" """Delete the consumed review record so stale feedback cannot be reused."""
from api import the_folder
_post({ _post({
"type": "ackIdentificationFeedback", "type": "ackIdentificationFeedback",
"deviceId": the_folder,
"runId": run_id, "runId": run_id,
}, timeout=timeout) }, timeout=timeout)
+3 -5
View File
@@ -13,7 +13,7 @@ from stable_baselines3 import SAC
# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault # 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault
torch.set_num_threads(1) torch.set_num_threads(1)
from api import base_url, data_record_url, the_folder from api import device_post, the_folder
class ModelManager: class ModelManager:
@@ -50,8 +50,7 @@ class ModelManager:
def fetch_models(): def fetch_models():
try: try:
payload = {"type": "listModels", "folder": f"{the_folder}/model_config"} payload = {"type": "listModels", "folder": f"{the_folder}/model_config"}
resp = requests.post(data_record_url, json=payload, timeout=10) result = device_post(payload, timeout=10)
result = resp.json()
if result.get("success"): if result.get("success"):
files = result.get("files", []) files = result.get("files", [])
@@ -98,8 +97,7 @@ class ModelManager:
# 获取临时下载 URL # 获取临时下载 URL
payload = {"type": "downloadModel", "fileID": file_id} payload = {"type": "downloadModel", "fileID": file_id}
resp = requests.post(data_record_url, json=payload, timeout=15) result = device_post(payload, timeout=15)
result = resp.json()
if not result.get("success"): if not result.get("success"):
err = result.get('errMsg', '未知错误') err = result.get('errMsg', '未知错误')
+4 -4
View File
@@ -39,10 +39,10 @@ class SimulatedDevice:
if not self.connected or dt == 0: if not self.connected or dt == 0:
return return
# MT2AM8 的行程控制在当前程序中是反向阀位:较小行程表示更大 # 行程与阀门开度反向:行程越大,阀门开度越小。阀门开度减小
# 进气。模型因此让压力向由行程决定的目标值缓慢靠近 # 时系统压力升高;默认量程下行程 0/1000 分别对应 10/400 kPa
opening = max(0.0, min(1.0, 1.0 - self.position / 1000.0)) travel_ratio = max(0.0, min(1.0, self.position / 1000.0))
target_pressure = self.pressure_range * opening target_pressure = 10.0 + (self.pressure_range - 10.0) * travel_ratio
response = min(1.0, dt / 8.0) response = min(1.0, dt / 8.0)
self.pressure += (target_pressure - self.pressure) * response self.pressure += (target_pressure - self.pressure) * response
+2 -13
View File
@@ -84,13 +84,10 @@ def validate_volume_config(config) -> dict:
def _post_volume_request(payload, timeout=10): def _post_volume_request(payload, timeout=10):
import requests from api import device_post
from api import data_record_url
try: try:
response = requests.post(data_record_url, json=payload, timeout=timeout) result = device_post(payload, timeout=timeout)
response.raise_for_status()
result = response.json()
except Exception as exc: except Exception as exc:
raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc
@@ -101,11 +98,8 @@ def _post_volume_request(payload, timeout=10):
def create_volume_config_request(timeout=10) -> dict: def create_volume_config_request(timeout=10) -> dict:
"""Create exactly one cloud request after the customer clicks Test.""" """Create exactly one cloud request after the customer clicks Test."""
from api import the_folder
result = _post_volume_request({ result = _post_volume_request({
"type": "createVolumeConfigRequest", "type": "createVolumeConfigRequest",
"deviceId": the_folder,
}, timeout=timeout) }, timeout=timeout)
if not result.get("requestId") or not result.get("expiresAtMs"): if not result.get("requestId") or not result.get("expiresAtMs"):
raise ValueError("云端未返回有效的容积参数请求编号") raise ValueError("云端未返回有效的容积参数请求编号")
@@ -118,11 +112,9 @@ def create_volume_config_request(timeout=10) -> dict:
def poll_volume_config_request(request_id: str, timeout=10) -> dict: def poll_volume_config_request(request_id: str, timeout=10) -> dict:
"""Poll one request; download and validate JSON only when it is ready.""" """Poll one request; download and validate JSON only when it is ready."""
import requests import requests
from api import the_folder
result = _post_volume_request({ result = _post_volume_request({
"type": "getVolumeConfigRequest", "type": "getVolumeConfigRequest",
"deviceId": the_folder,
"requestId": request_id, "requestId": request_id,
}, timeout=timeout) }, timeout=timeout)
if result.get("expired"): if result.get("expired"):
@@ -145,10 +137,7 @@ def poll_volume_config_request(request_id: str, timeout=10) -> dict:
def acknowledge_volume_config_request(request_id: str, timeout=10) -> None: def acknowledge_volume_config_request(request_id: str, timeout=10) -> None:
"""Delete the consumed/abandoned request and its temporary JSON file.""" """Delete the consumed/abandoned request and its temporary JSON file."""
from api import the_folder
_post_volume_request({ _post_volume_request({
"type": "ackVolumeConfigRequest", "type": "ackVolumeConfigRequest",
"deviceId": the_folder,
"requestId": request_id, "requestId": request_id,
}, timeout=timeout) }, timeout=timeout)
+4 -4
View File
@@ -5,11 +5,11 @@
## 约定 ## 约定
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用 - 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
`REINLOOP_SERVER_URL + /api`;默认地址为 `REINLOOP_SERVER_URL`;默认地址为
`https://ReinLoop.dominatedconvergence.com/api` `https://ReinLoop.dominatedconvergence.com`
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过 - 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
`api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID` `api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`
- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。 - 所有服务端业务请求均使用 `POST /`,通过请求体的 `type` 字段分发。
- 公司、产线、许可证签发/撤销、模型删除、审核反馈及配置提交均为 - 公司、产线、许可证签发/撤销、模型删除、审核反馈及配置提交均为
ControlPanel 管理端能力,客户端不提供对应的管理接口。旧微信云函数和其管理脚本 ControlPanel 管理端能力,客户端不提供对应的管理接口。旧微信云函数和其管理脚本
已移除。 已移除。
@@ -148,7 +148,7 @@ RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力
## 客户端服务端协议 ## 客户端服务端协议
所有业务请求都发送至 `POST /api`。业务成功响应应至少包含 `success: true` 所有业务请求都发送至 `POST /`。业务成功响应应至少包含 `success: true`
| `type` | 请求关键字段 | 用途 | | `type` | 请求关键字段 | 用途 |
| --- | --- | --- | | --- | --- | --- |
+37 -20
View File
@@ -41,13 +41,13 @@ Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE=
-----END PUBLIC KEY-----""" -----END PUBLIC KEY-----"""
# {{LICENSE_PUBLIC_KEY_END}} # {{LICENSE_PUBLIC_KEY_END}}
# 许可证文件相对路径 # 许可证文件检索模式(默认在程序目录中匹配)
LICENSE_FILE = "license.lic" LICENSE_GLOB = "*license.lic"
# 巡检间隔(分钟) # 巡检间隔(分钟)
DEFAULT_CHECK_INTERVAL = 5 DEFAULT_CHECK_INTERVAL = 5
ONLINE_CHECK_TIMEOUT_SECONDS = 5 ONLINE_CHECK_TIMEOUT_SECONDS = 5
DEFAULT_OFFLINE_HOURS = 72 DEFAULT_OFFLINE_HOURS = 100
# 过期后宽限期(小时),给用户保存工作的时间 # 过期后宽限期(小时),给用户保存工作的时间
GRACE_PERIOD_HOURS = 2 GRACE_PERIOD_HOURS = 2
@@ -164,11 +164,38 @@ def _validate_payload(payload):
return has_new_format return has_new_format
def _default_license_dir():
"""返回默认许可证搜索目录。"""
# PyInstaller 打包后 sys.executable 是 exe 路径
return Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
def _resolve_license_path(lic_path=None):
"""解析许可证路径。
- 显式传入 ``lic_path`` 时直接使用该路径
- 未传入时在默认目录按 ``*license.lic`` 匹配优先选择最近修改的文件
"""
if lic_path is not None:
return Path(lic_path)
search_dir = _default_license_dir()
matches = [path for path in search_dir.glob(LICENSE_GLOB) if path.is_file()]
if not matches:
raise FileNotFoundError(
f"未找到许可证文件(模式: {LICENSE_GLOB},目录: {search_dir})"
)
# 多个候选时优先取最新文件;同修改时间再按文件名稳定排序。
matches.sort(key=lambda path: (path.stat().st_mtime, path.name), reverse=True)
return matches[0]
def verify_license(lic_path=None): def verify_license(lic_path=None):
"""验证许可证签名 + 有效期。 """验证许可证签名 + 有效期。
Args: Args:
lic_path: 许可证文件路径默认 exe 同级目录下的 license.lic lic_path: 许可证文件路径默认在程序目录按 *license.lic 自动匹配
Returns: Returns:
dict: 许可证 payloadcustomer, expiry, issued dict: 许可证 payloadcustomer, expiry, issued
@@ -179,10 +206,7 @@ def verify_license(lic_path=None):
RuntimeError: 许可证已过期 RuntimeError: 许可证已过期
ValueError: 许可证格式错误 ValueError: 许可证格式错误
""" """
if lic_path is None: lic_path = _resolve_license_path(lic_path)
# PyInstaller 打包后 sys.executable 是 exe 路径
exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path): if not os.path.exists(lic_path):
raise FileNotFoundError(f"许可证文件不存在: {lic_path}") raise FileNotFoundError(f"许可证文件不存在: {lic_path}")
@@ -276,7 +300,7 @@ def _api_url():
base_url = os.environ.get( base_url = os.environ.get(
"REINLOOP_SERVER_URL", "https://ReinLoop.dominatedconvergence.com" "REINLOOP_SERVER_URL", "https://ReinLoop.dominatedconvergence.com"
).rstrip("/") ).rstrip("/")
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api") return os.environ.get("REINLOOP_API_URL", base_url)
def _offline_limit_seconds(): def _offline_limit_seconds():
@@ -531,7 +555,7 @@ def check_license(lic_path=None):
"""启动时调用:验证许可证,通过则返回 payload。 """启动时调用:验证许可证,通过则返回 payload。
main.py main() 函数开头调用一次即可 main.py main() 函数开头调用一次即可
内部会自动启动后台巡检线程 仅启动时执行校验自动启动后台巡检线程
Returns: Returns:
dict: 许可证载荷 dict: 许可证载荷
@@ -549,7 +573,6 @@ def check_license(lic_path=None):
with _verified_license_lock: with _verified_license_lock:
global _verified_license global _verified_license
_verified_license = dict(payload) _verified_license = dict(payload)
start_license_watchdog()
expiry = payload.get("expiry", "未知") expiry = payload.get("expiry", "未知")
customer = payload.get("customer", "未知") customer = payload.get("customer", "未知")
_log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}")
@@ -559,7 +582,7 @@ def check_license(lic_path=None):
_log(f"{e}") _log(f"{e}")
_show_error_and_exit( _show_error_and_exit(
"未找到许可证文件", "未找到许可证文件",
"请将 license.lic 放到软件根目录,然后重新启动程序。\n\n" "请将许可证文件放到软件根目录(文件名需匹配 *license.lic,然后重新启动程序。\n\n"
"如有疑问,请联系厂商获取有效的许可证文件。" "如有疑问,请联系厂商获取有效的许可证文件。"
) )
@@ -568,7 +591,7 @@ def check_license(lic_path=None):
_show_error_and_exit( _show_error_and_exit(
"许可证验证失败", "许可证验证失败",
"许可证签名校验不通过,文件可能已被篡改。\n\n" "许可证签名校验不通过,文件可能已被篡改。\n\n"
"请使用原始签发license.lic 文件,\n" "请使用原始签发且文件名匹配 *license.lic 的许可证文件,\n"
"或联系厂商重新签发。" "或联系厂商重新签发。"
) )
@@ -626,13 +649,7 @@ def get_license_info(lic_path=None):
dict | None: 许可证信息文件不存在则返回 None dict | None: 许可证信息文件不存在则返回 None
""" """
try: try:
if lic_path is None: lic_path = _resolve_license_path(lic_path)
exe_dir = (
Path(sys.executable).parent
if getattr(sys, 'frozen', False)
else Path.cwd()
)
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path): if not os.path.exists(lic_path):
return None return None
+5 -1
View File
@@ -14,7 +14,11 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "data_collector.py"
def load_data_collector_module(): def load_data_collector_module():
api = types.ModuleType("api") api = types.ModuleType("api")
api.base_url = "https://cloud.example" api.base_url = "https://cloud.example"
api.data_record_url = "https://cloud.example/api" api.data_record_url = "https://cloud.example"
api.device_post = lambda payload, timeout=10: {
"success": True,
"uploadMetadata": {"url": "https://upload.example"}
}
api.the_folder = "customer-a/line-1" api.the_folder = "customer-a/line-1"
requests = types.ModuleType("requests") requests = types.ModuleType("requests")
+4 -18
View File
@@ -16,26 +16,12 @@ class DeviceHeartbeatTests(unittest.TestCase):
def test_sends_current_device_id_to_server(self): def test_sends_current_device_id_to_server(self):
calls = [] calls = []
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
class Response:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"}
def post(url, json, timeout):
calls.append((url, json, timeout))
return Response()
requests_module.post = post
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://server.example/api" api_module.device_post = lambda payload, timeout=5: (
api_module.the_folder = "company/line" calls.append((payload, timeout)) or {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"}
)
with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}): with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}):
timestamp = HEARTBEAT.heartbeat_device(timeout=7) timestamp = HEARTBEAT.heartbeat_device(timeout=7)
self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z") self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z")
self.assertEqual(calls, [("https://server.example/api", { self.assertEqual(calls, [({"type": "deviceHeartbeat"}, 7)])
"type": "deviceHeartbeat", "deviceId": "company/line"
}, 7)])
+10 -34
View File
@@ -87,33 +87,23 @@ class IdentificationConfigTests(unittest.TestCase):
def test_download_requests_customer_config_and_validates_it(self): def test_download_requests_customer_config_and_validates_it(self):
calls = [] calls = []
class FakeResponse:
def __init__(self, body=None, text=None):
self.body = body
self.text = text
def raise_for_status(self):
return None
def json(self):
return self.body
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.RequestException = Exception requests_module.RequestException = Exception
def post(url, json, timeout): def device_post(payload, timeout):
calls.append(("post", url, json, timeout)) calls.append(("device_post", payload, timeout))
return FakeResponse({"success": True, "url": "https://temp/config"}) return {"success": True, "url": "https://temp/config"}
def get(url, timeout): def get(url, timeout):
calls.append(("get", url, timeout)) calls.append(("get", url, timeout))
return FakeResponse(text=config_csv(VALID_CONFIG)) return types.SimpleNamespace(
text=config_csv(VALID_CONFIG),
raise_for_status=lambda: None
)
requests_module.post = post
requests_module.get = get requests_module.get = get
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = device_post
api_module.the_folder = "客户A"
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -122,28 +112,14 @@ class IdentificationConfigTests(unittest.TestCase):
result = download_identification_config(timeout=7) result = download_identification_config(timeout=7)
self.assertEqual(result["repeat"], 2) self.assertEqual(result["repeat"], 2)
self.assertEqual(calls[0], ( self.assertEqual(calls[0], ("device_post", {"type": "getIdentificationConfig"}, 7))
"post",
"https://cloud/data_record",
{"type": "getIdentificationConfig", "deviceId": "客户A"},
7,
))
self.assertEqual(calls[1], ("get", "https://temp/config", 7)) self.assertEqual(calls[1], ("get", "https://temp/config", 7))
def test_download_reports_cloud_rejection(self): def test_download_reports_cloud_rejection(self):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"success": False, "errMsg": "配置不存在"}
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.RequestException = Exception requests_module.RequestException = Exception
requests_module.post = lambda *args, **kwargs: FakeResponse()
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "配置不存在"}
api_module.the_folder = "客户A"
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
+39
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile import tempfile
import time
import types import types
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -107,6 +108,44 @@ class LicenseProtocolTests(unittest.TestCase):
side_effect=LICENSE.requests.ConnectionError("offline")): side_effect=LICENSE.requests.ConnectionError("offline")):
LICENSE.validate_license_online(NEW_LICENSE) LICENSE.validate_license_online(NEW_LICENSE)
def test_verify_license_uses_default_glob_pattern_when_path_missing(self):
with tempfile.TemporaryDirectory() as tmp_dir:
payload_b64 = base64.b64encode(json.dumps(NEW_LICENSE).encode()).decode()
license_path = Path(tmp_dir) / "customer-license.lic"
license_path.write_text(
f"{payload_b64}|{base64.b64encode(b'signature').decode()}",
encoding="utf-8",
)
with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()), \
patch.object(LICENSE, "_default_license_dir", return_value=Path(tmp_dir)):
payload = LICENSE.verify_license()
self.assertEqual(payload["license_id"], NEW_LICENSE["license_id"])
def test_verify_license_prefers_latest_matching_file(self):
with tempfile.TemporaryDirectory() as tmp_dir:
old_payload = dict(NEW_LICENSE, license_id="old-license")
new_payload = dict(NEW_LICENSE, license_id="new-license")
def write_license(path, payload):
payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()
path.write_text(
f"{payload_b64}|{base64.b64encode(b'signature').decode()}",
encoding="utf-8",
)
old_path = Path(tmp_dir) / "a-license.lic"
new_path = Path(tmp_dir) / "z-license.lic"
write_license(old_path, old_payload)
time.sleep(0.01)
write_license(new_path, new_payload)
with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()), \
patch.object(LICENSE, "_default_license_dir", return_value=Path(tmp_dir)):
payload = LICENSE.verify_license()
self.assertEqual(payload["license_id"], "new-license")
def test_api_rejects_environment_device_id_mismatch(self): def test_api_rejects_environment_device_id_mismatch(self):
fake_license_utils = types.ModuleType("license_utils") fake_license_utils = types.ModuleType("license_utils")
fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE) fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE)
+35 -13
View File
@@ -1,7 +1,7 @@
import importlib.util import importlib.util
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "simulated_device.py" MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "simulated_device.py"
@@ -22,18 +22,40 @@ class SimulatedDeviceTests(unittest.TestCase):
self.assertFalse(device.connected) self.assertFalse(device.connected)
def test_pressure_model_responds_to_valve_position(self): def test_pressure_model_responds_to_valve_position(self):
device = SIMULATED.SimulatedDevice() class FakeClock:
device.connect() def __init__(self):
device.set_motor_position(0) self.now = 0.0
time.sleep(0.02)
open_pressure = device.get_pressure() def monotonic(self):
device.set_motor_position(1000) return self.now
time.sleep(0.02)
closed_pressure = device.get_pressure() def advance(self, duration):
self.assertGreaterEqual(open_pressure, 0.0) self.now += duration
self.assertGreaterEqual(closed_pressure, 0.0)
self.assertEqual(device.read_current_position(), 1000.0) clock = FakeClock()
device.disconnect() with patch.object(SIMULATED.time, "monotonic", clock.monotonic):
device = SIMULATED.SimulatedDevice()
device.connect()
def settled_pressure(position):
device.set_motor_position(position)
pressure = None
for _ in range(1200):
clock.advance(0.1)
pressure = device.get_pressure()
return pressure
open_pressure = settled_pressure(0)
half_open_pressure = settled_pressure(500)
closed_pressure = settled_pressure(1000)
self.assertAlmostEqual(open_pressure, 10.0, delta=0.1)
self.assertAlmostEqual(half_open_pressure, 205.0, delta=0.1)
self.assertAlmostEqual(closed_pressure, 400.0, delta=0.1)
self.assertGreater(closed_pressure, half_open_pressure)
self.assertGreater(half_open_pressure, open_pressure)
self.assertEqual(device.read_current_position(), 1000.0)
device.disconnect()
if __name__ == "__main__": if __name__ == "__main__":
+19 -81
View File
@@ -57,28 +57,13 @@ class VolumeConfigTests(unittest.TestCase):
def test_customer_creates_exactly_one_request_instruction(self): def test_customer_creates_exactly_one_request_instruction(self):
calls = [] calls = []
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {
"success": True,
"requestId": "request-1",
"expiresAtMs": 123456,
}
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
def post(url, json, timeout):
calls.append((url, json, timeout))
return FakeResponse()
requests_module.post = post
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = lambda payload, timeout=10: (calls.append((payload, timeout)) or {
api_module.the_folder = "客户A" "success": True,
"requestId": "request-1",
"expiresAtMs": 123456,
})
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -90,30 +75,16 @@ class VolumeConfigTests(unittest.TestCase):
"request_id": "request-1", "request_id": "request-1",
"expires_at_ms": 123456, "expires_at_ms": 123456,
}) })
self.assertEqual(calls, [( self.assertEqual(calls, [({"type": "createVolumeConfigRequest"}, 7)])
"https://cloud/data_record",
{"type": "createVolumeConfigRequest", "deviceId": "客户A"},
7,
)])
def test_pending_request_does_not_download_a_file(self): def test_pending_request_does_not_download_a_file(self):
calls = [] calls = []
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "ready": False, "expired": False}
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.post = lambda *args, **kwargs: (
calls.append(("post", kwargs["json"])) or FakeResponse()
)
requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0])) requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0]))
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = lambda payload, timeout=10: (
api_module.the_folder = "客户A" calls.append(("device_post", payload)) or {"success": True, "ready": False, "expired": False}
)
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -122,36 +93,25 @@ class VolumeConfigTests(unittest.TestCase):
result = poll_volume_config_request("request-1") result = poll_volume_config_request("request-1")
self.assertEqual(result, {"ready": False, "expired": False}) self.assertEqual(result, {"ready": False, "expired": False})
self.assertEqual(calls, [("post", { self.assertEqual(calls, [("device_post", {
"type": "getVolumeConfigRequest", "type": "getVolumeConfigRequest",
"deviceId": "客户A",
"requestId": "request-1", "requestId": "request-1",
})]) })])
def test_ready_request_downloads_and_validates_json(self): def test_ready_request_downloads_and_validates_json(self):
class FakeResponse:
def __init__(self, body):
self.body = body
def raise_for_status(self):
return None
def json(self):
return self.body
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.post = lambda *args, **kwargs: FakeResponse({ requests_module.post = lambda *args, **kwargs: types.SimpleNamespace()
api_module = types.ModuleType("api")
api_module.device_post = lambda payload, timeout=10: {
"success": True, "success": True,
"ready": True, "ready": True,
"expired": False, "expired": False,
"url": "https://temp/volume.json", "url": "https://temp/volume.json",
}) }
requests_module.get = lambda *args, **kwargs: FakeResponse( requests_module.get = lambda *args, **kwargs: types.SimpleNamespace(
dict(VALID_CONFIG) raise_for_status=lambda: None,
json=lambda: dict(VALID_CONFIG)
) )
api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record"
api_module.the_folder = "客户A"
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -163,18 +123,9 @@ class VolumeConfigTests(unittest.TestCase):
self.assertEqual(result["config"], VALID_CONFIG) self.assertEqual(result["config"], VALID_CONFIG)
def test_create_request_reports_server_rejection(self): def test_create_request_reports_server_rejection(self):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"success": False, "errMsg": "尚未配置"}
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.post = lambda *args, **kwargs: FakeResponse()
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "尚未配置"}
api_module.the_folder = "客户A"
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -185,21 +136,9 @@ class VolumeConfigTests(unittest.TestCase):
def test_acknowledges_the_same_request_for_cleanup(self): def test_acknowledges_the_same_request_for_cleanup(self):
calls = [] calls = []
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "deleted": 1}
requests_module = types.ModuleType("requests") requests_module = types.ModuleType("requests")
requests_module.post = lambda *args, **kwargs: (
calls.append(kwargs["json"]) or FakeResponse()
)
api_module = types.ModuleType("api") api_module = types.ModuleType("api")
api_module.data_record_url = "https://cloud/data_record" api_module.device_post = lambda payload, timeout=10: (calls.append(payload) or {"success": True, "deleted": 1})
api_module.the_folder = "客户A"
with patch.dict(sys.modules, { with patch.dict(sys.modules, {
"requests": requests_module, "requests": requests_module,
@@ -209,7 +148,6 @@ class VolumeConfigTests(unittest.TestCase):
self.assertEqual(calls, [{ self.assertEqual(calls, [{
"type": "ackVolumeConfigRequest", "type": "ackVolumeConfigRequest",
"deviceId": "客户A",
"requestId": "request-1", "requestId": "request-1",
}]) }])
+98
View File
@@ -140,3 +140,101 @@
### 验证 ### 验证
- 已完成修改文件的 Python、JavaScript 语法检查,未发现语法错误。 - 已完成修改文件的 Python、JavaScript 语法检查,未发现语法错误。
## 2026-08-03
### Server:下载接口改造为一次性临时 URL 并绑定来源 IP
- 文件下载流程由“业务接口返回可复用直链”改为“业务接口签发一次性临时 URL + 下载后立即失效”。
- 新增下载入口 `GET /downloads/:ticket`,票据校验包含有效期、一次性消费和请求来源 IP 一致性。
- 旧入口 `GET /files/:fileID` 已停用并固定返回 `403`,避免 fileID 直链被转发复用。
- `downloadModel` 统一要求 `adminToken`,下载能力与管理权限保持一致。
- 返回下载 URL 的业务接口已统一改为临时票据地址:`downloadModel``getPendingPanelFile``getIdentificationFileDownload``getControlFileDownload``getIdentificationConfig``getVolumeConfigFile``getVolumeConfigRequest`
涉及文件:
- `server/src/app.js`
- `server/test/server.test.js`
- `ControlPanel/server-client.js`
- `server/features.md`
### 验证
- 已完成 `server` 全量测试(21 项)通过。
- 已完成 ControlPanel 无 GUI 内核联调测试通过(`model-handlers``license-manager` 相关用例)。
- 已重启 `reinloop-server.service` 并验证:同一临时下载 URL 首次下载成功,二次访问返回 `403`
### Server + ReinLoop:设备分路由与最小权限访问
- 新增 `POST /device` 设备侧路由,ReinLoop 通过 `deviceAuth``licenseId` + `deviceId`)换取短期 `deviceToken` 后访问设备接口。
- 设备侧接口采用白名单权限,不再使用 `adminToken`,并强制按 `deviceId` 隔离目录和模型访问范围。
- ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。
- ReinLoop 离线宽限默认调整为 `100` 小时(`REINLOOP_LICENSE_OFFLINE_HOURS` 默认值)。
### ReinLoop + Panel:离线持续运行与组织清理增强
- ReinLoop 调整为“仅启动时执行许可证校验”,不再自动启动后台巡检线程,满足离线持续运行需求。
- Server 新增许可证删除接口 `deleteLicense`(仅允许删除已撤销许可证)。
- Server 新增组织删除接口 `deleteProductionLine``deleteCompany`,并增加前置约束与关联数据清理。
- Panel 新增操作入口:
- 已撤销许可证支持“删除”;
- 组织管理页支持删除当前公司与当前产线。
涉及文件:
- `ReinLoop/license_utils.py`
- `ReinLoop/core/__init__.py`
- `server/src/app.js`
- `server/features.md`
- `ControlPanel/electron-main.js`
- `ControlPanel/electron-preload.js`
- `ControlPanel/electron-ui/index.html`
- `ControlPanel/electron-ui/renderer.js`
涉及文件:
- `server/src/app.js`
- `server/features.md`
- `ReinLoop/api.py`
- `ReinLoop/license_utils.py`
- `ReinLoop/core/model_manager.py`
- `ReinLoop/core/identification_config.py`
- `ReinLoop/core/volume_config.py`
- `ReinLoop/core/identification_feedback.py`
- `ReinLoop/core/device_heartbeat.py`
- `ReinLoop/core/data_collector.py`
- `ReinLoop/core/identification.py`
### ReinLoop:许可证文件改为通配检索
- 许可证启动校验与信息读取不再固定使用 `license.lic`,改为在程序目录检索匹配 `*license.lic` 的文件。
- 当存在多个匹配文件时,按“最近修改时间优先”选择目标文件,降低人工改名或多版本并存时的启动失败概率。
- 同步更新提示文案:明确要求许可证文件名需匹配 `*license.lic`
- 新增协议测试覆盖:默认通配匹配与多文件择优选择。
涉及文件:
- `ReinLoop/license_utils.py`
- `ReinLoop/tests/test_license_protocol.py`
### ControlPanel:删除确认提示文案修正
- 删除产线、删除公司、删除许可证的二次确认弹窗改为按业务字段显示错误提示,不再统一显示“文件名不匹配”。
- 现分别提示 `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`
+44 -7
View File
@@ -2,7 +2,7 @@
## 通用约定 ## 通用约定
业务接口为 `POST /api`。请求与响应均为 JSON,响应包含 `success` 业务接口为 `POST /`。请求与响应均为 JSON,响应包含 `success`
标注为 Admin 的接口需要附加: 标注为 Admin 的接口需要附加:
@@ -16,11 +16,39 @@
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/api` | 主业务 API | | `POST` | `/` | 主业务 API |
| `POST` | `/device` | ReinLoop 设备侧 APIlicense -> deviceToken |
| `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 | | `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 |
| `GET` | `/files/:fileID` | 下载已存储文件 | | `GET` | `/downloads/:ticket` | 使用一次性临时下载票据获取文件 |
| `GET` | `/files/:fileID` | 旧直链下载入口(已停用,固定返回 403) |
| `GET` | `/health` | 服务存活检查 | | `GET` | `/health` | 服务存活检查 |
## 设备侧鉴权与分路由
`/device` 仅用于 ReinLoop 客户端,禁止使用 `adminToken`
调用方式:
1. 先 `POST /device``type=deviceAuth`,字段 `licenseId``deviceId`
2. 服务端校验许可证状态(存在、未过期、未撤销、deviceId 匹配)后签发 `deviceToken`
3. ReinLoop 后续调用 `/device` 白名单接口时携带 `deviceToken`
`deviceToken` 默认有效期由 `DEVICE_TOKEN_TTL_MS` 控制(默认 15 分钟)。
离线宽限由 ReinLoop 客户端控制,当前默认 `REINLOOP_LICENSE_OFFLINE_HOURS=100`100 小时)。
## 下载安全调用链
统一链路为:业务 `POST /`(鉴权) -> 返回临时 URL -> `GET /downloads/:ticket`(一次性消费)。
安全校验点:
- `POST /`:按业务类型执行权限校验。
- `GET /downloads/:ticket`
- 票据存在且未过期(`DOWNLOAD_URL_TTL_MS`,兼容旧环境变量 `DOWNLOAD_TOKEN_TTL_MS`)。
- 票据仅可消费一次,成功下载或校验失败后均失效。
- 下载请求来源 IP 必须与签发票据的 `POST` 请求来源 IP 一致。
## 设备心跳与组织 ## 设备心跳与组织
| type | 鉴权 | 请求字段 | 功能与响应要点 | | type | 鉴权 | 请求字段 | 功能与响应要点 |
@@ -29,6 +57,8 @@
| `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id``companyId``name``code``deviceId``lastSeenAt``online`。最近 30 秒有心跳时 `online``true`。 | | `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id``companyId``name``code``deviceId``lastSeenAt``online`。最近 30 秒有心跳时 `online``true`。 |
| `createCompany` | Admin | `name``code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_``-`。 | | `createCompany` | Admin | `name``code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_``-`。 |
| `createProductionLine` | Admin | `companyId``name``code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.code>`。 | | `createProductionLine` | Admin | `companyId``name``code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.code>`。 |
| `deleteProductionLine` | Admin | `companyId``productionLineId` | 删除产线及关联业务数据。若该产线仍存在有效许可证会拒绝,需先撤销。 |
| `deleteCompany` | Admin | `companyId` | 删除公司。若仍有关联产线或许可证会拒绝。 |
Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。 Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。
@@ -40,6 +70,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 | | `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 |
| `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 | | `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 |
| `revokeLicense` | Admin | `licenseId``reason` | 撤销许可证,保留历史、撤销时间和原因。`licenseId` 会去除首尾空白。失败时返回 `errCode``ADMIN_TOKEN_INVALID``ADMIN_TOKEN_NOT_CONFIGURED``LICENSE_ID_REQUIRED``LICENSE_NOT_FOUND`。兼容旧类型 `revoke_license``licenseRevoke``revoke`,以及旧字段 `license_id``admin_token`。每次撤销会记录不含令牌的结构化审计日志。 | | `revokeLicense` | Admin | `licenseId``reason` | 撤销许可证,保留历史、撤销时间和原因。`licenseId` 会去除首尾空白。失败时返回 `errCode``ADMIN_TOKEN_INVALID``ADMIN_TOKEN_NOT_CONFIGURED``LICENSE_ID_REQUIRED``LICENSE_NOT_FOUND`。兼容旧类型 `revoke_license``licenseRevoke``revoke`,以及旧字段 `license_id``admin_token`。每次撤销会记录不含令牌的结构化审计日志。 |
| `deleteLicense` | Admin | `licenseId` | 永久删除许可证记录。仅允许删除已撤销许可证,`active` 状态会返回 `LICENSE_ACTIVE`。 |
| `validateLicense` | 无 | `licenseId``deviceId` | 返回 `valid``status``licenseId`。状态为 `active``revoked``expired``not_found``device_mismatch`;不泄露客户信息和许可证原文。 | | `validateLicense` | 无 | `licenseId``deviceId` | 返回 `valid``status``licenseId`。状态为 `active``revoked``expired``not_found``device_mismatch`;不泄露客户信息和许可证原文。 |
许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。 许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。
@@ -51,13 +82,19 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `uploadDataFile` | 视目录而定 | `fileName``folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 | | `uploadDataFile` | 视目录而定 | `fileName``folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 |
| `issueModelUpload` | Admin | `deviceId``fileName`、可选 `modelName``overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 | | `issueModelUpload` | Admin | `deviceId``fileName`、可选 `modelName``overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 |
| `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName``originalFileName`。 | | `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName``originalFileName`。 |
| `downloadModel` | 视文件而定 | `fileID` | 模型保持兼容;非模型文件要求 Admin 并返回短期签名下载 URL。 | | `downloadModel` | Admin | `fileID` | 返回一次性临时下载 URL`/downloads/:ticket`。 |
| `deleteFile` | Admin | `fileID`,或 `folder``fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 | | `deleteFile` | Admin | `fileID`,或 `folder``fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 |
| `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 | | `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 |
上传分两步:先调用 `uploadDataFile``issueModelUpload`,再将文件作为 `multipart/form-data``file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。 上传分两步:先调用 `uploadDataFile``issueModelUpload`,再将文件作为 `multipart/form-data``file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。
模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。 模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。
设备侧(`/device`)模型访问约束:
- `listModels` 固定返回当前 `deviceId/model_config` 目录。
- `downloadModel` 仅允许下载当前 `deviceId/model_config` 下文件。
- 设备侧不允许模型上传与删除。
上传到 `<deviceId>/ind_data``.csv``.json` 会自动进入 Panel inbox。 上传到 `<deviceId>/ind_data``.csv``.json` 会自动进入 Panel inbox。
## 配置发布与读取 ## 配置发布与读取
@@ -67,7 +104,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `publishIdentificationConfig` | Admin | `deviceId``parameters` | 校验辨识参数并为 `<deviceId>/identification_config/identification_config.csv` 签发上传凭证。 | | `publishIdentificationConfig` | Admin | `deviceId``parameters` | 校验辨识参数并为 `<deviceId>/identification_config/identification_config.csv` 签发上传凭证。 |
| `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID``url`。 | | `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID``url`。 |
| `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 | | `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 |
| `getVolumeConfigFile` | 无 | 无 | 返回已发布容积 JSON 的 `fileID``cloudPath``url`。 | | `getVolumeConfigFile` | Admin | `deviceId` | 返回指定设备已发布容积 JSON 的 `fileID``cloudPath``url`。 |
| `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters``version``updateTime`。 | | `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters``version``updateTime`。 |
发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。 发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。
@@ -83,7 +120,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 | | `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 |
| `ackPanelFile` | Admin | `deviceId``fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 | | `ackPanelFile` | Admin | `deviceId``fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 |
| `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType``status``page``pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 | | `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType``status``page``pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 |
| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的短期签名下载 URL。 | | `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的一次性临时下载 URL。 |
| `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 | | `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 |
CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。 CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。
@@ -97,7 +134,7 @@ CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`
| `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId``createdAtMs``expiresAtMs`。同设备旧请求会被替换。 | | `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId``createdAtMs``expiresAtMs`。同设备旧请求会被替换。 |
| `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 | | `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 |
| `submitVolumeConfigFile` | 无 | `deviceId``requestId``fileID`、可选 `fileName` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 | | `submitVolumeConfigFile` | 无 | `deviceId``requestId``fileID`、可选 `fileName` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 |
| `getVolumeConfigRequest` | 无 | `deviceId``requestId` | 轮询配置是否就绪,返回 `ready``expired`;就绪时包含下载 `url`。 | | `getVolumeConfigRequest` | 无 | `deviceId``requestId` | 轮询配置是否就绪,返回 `ready``expired`;就绪时包含一次性临时下载 `url`。 |
| `ackVolumeConfigRequest` | 无 | `deviceId``requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 | | `ackVolumeConfigRequest` | 无 | `deviceId``requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 |
请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。 请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。
+21 -1
View File
@@ -98,8 +98,28 @@ CREATE TABLE IF NOT EXISTS volume_config_requests (
PRIMARY KEY (device_id, request_id) PRIMARY KEY (device_id, request_id)
); );
CREATE TABLE IF NOT EXISTS volume_configs (
device_id TEXT PRIMARY KEY,
file_id TEXT NOT NULL,
file_name TEXT NOT NULL,
request_id TEXT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS panel_notifications (
notification_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
request_id TEXT,
file_id TEXT,
created_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS licenses_status_device_expiry_idx ON licenses (status, device_id, expiry_at); CREATE INDEX IF NOT EXISTS licenses_status_device_expiry_idx ON licenses (status, device_id, expiry_at);
CREATE INDEX IF NOT EXISTS file_records_folder_idx ON file_records (folder); CREATE INDEX IF NOT EXISTS file_records_folder_idx ON file_records (folder);
CREATE INDEX IF NOT EXISTS identification_files_device_upload_idx ON identification_files (device_id, upload_time DESC); CREATE INDEX IF NOT EXISTS identification_files_device_upload_idx ON identification_files (device_id, upload_time DESC);
CREATE INDEX IF NOT EXISTS identification_files_expires_idx ON identification_files (expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS identification_files_expires_idx ON identification_files (expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS identification_feedback_device_idx ON identification_feedback (device_id); CREATE INDEX IF NOT EXISTS identification_feedback_device_idx ON identification_feedback (device_id);
CREATE INDEX IF NOT EXISTS panel_notifications_device_created_idx ON panel_notifications (device_id, created_at_ms);
@@ -0,0 +1,118 @@
"use strict";
function parseArgs(argv) {
const result = {};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) continue;
const [key, ...rest] = arg.slice(2).split("=");
if (!key) continue;
const value = rest.length ? rest.join("=") : "";
result[key] = value;
}
return result;
}
const args = parseArgs(process.argv);
const BASE_URL = (args.baseUrl || process.env.BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
const ADMIN_TOKEN = args.adminToken || process.env.ADMIN_TOKEN || "";
const DEVICE_A = args.deviceA || process.env.DEVICE_A || "develop-test2/test1";
const DEVICE_B = args.deviceB || process.env.DEVICE_B || "develop-test/test1";
if (!ADMIN_TOKEN) {
console.error("[ERROR] 缺少 ADMIN_TOKEN 环境变量。");
console.error("示例1: node scripts/test-cross-device-notification.js --adminToken=your-token");
console.error("示例2: node scripts/test-cross-device-notification.js --adminToken=your-token --baseUrl=http://127.0.0.1:3000 --deviceA=develop-test2/test1 --deviceB=develop-test/test1");
process.exit(1);
}
async function postJson(path, body) {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
let data;
try {
data = await response.json();
} catch (_error) {
data = { success: false, errMsg: "响应不是 JSON" };
}
return { status: response.status, data };
}
function assert(condition, message, details) {
if (condition) return;
const error = new Error(message);
error.details = details;
throw error;
}
async function main() {
console.log(`[INFO] BASE_URL=${BASE_URL}`);
console.log(`[INFO] DEVICE_A=${DEVICE_A}`);
console.log(`[INFO] DEVICE_B=${DEVICE_B}`);
console.log("\n[1/5] 为 DEVICE_A 生成一条通知(createVolumeConfigRequest)...");
const createReq = await postJson("/", {
type: "createVolumeConfigRequest",
deviceId: DEVICE_A
});
assert(createReq.data.success === true, "createVolumeConfigRequest 失败", createReq);
console.log("[2/5] 读取 DEVICE_A 的 pending notification...");
const pendingA = await postJson("/", {
type: "getPendingPanelNotification",
adminToken: ADMIN_TOKEN,
deviceId: DEVICE_A
});
assert(pendingA.data.success === true, "getPendingPanelNotification(DEVICE_A) 调用失败", pendingA);
assert(pendingA.data.pending === true, "DEVICE_A 没有待处理通知", pendingA);
const notificationId = pendingA.data.notification?.notificationId;
assert(Boolean(notificationId), "未拿到 notificationId", pendingA);
console.log(`[INFO] notificationId=${notificationId}`);
console.log("[3/5] 使用 DEVICE_B 尝试确认 DEVICE_A 的 notificationId(预期失败)...");
const crossAck = await postJson("/", {
type: "ackPanelNotification",
adminToken: ADMIN_TOKEN,
deviceId: DEVICE_B,
notificationId
});
assert(crossAck.data.success === false, "跨 deviceId 确认意外成功(存在越权风险)", crossAck);
assert(crossAck.data.errMsg === "Panel 通知不存在", "跨 deviceId 失败文案非预期", crossAck);
console.log("[4/5] 使用 DEVICE_A 正常确认同一 notificationId(预期成功)...");
const ownerAck = await postJson("/", {
type: "ackPanelNotification",
adminToken: ADMIN_TOKEN,
deviceId: DEVICE_A,
notificationId
});
assert(ownerAck.data.success === true, "DEVICE_A 确认自身通知失败", ownerAck);
console.log("[5/5] 再次读取 DEVICE_A pending,确认已被消费...");
const pendingAfter = await postJson("/", {
type: "getPendingPanelNotification",
adminToken: ADMIN_TOKEN,
deviceId: DEVICE_A
});
assert(pendingAfter.data.success === true, "二次查询 pending 失败", pendingAfter);
console.log("\n[PASS] 测试通过:notificationId 不能被其他 deviceId 确认。\n");
console.log("关键结果:");
console.log(`- crossAck.success = ${crossAck.data.success}`);
console.log(`- crossAck.errMsg = ${crossAck.data.errMsg}`);
console.log(`- ownerAck.success = ${ownerAck.data.success}`);
}
main().catch((error) => {
console.error("\n[FAIL]", error.message);
if (error.details) {
console.error("details=");
console.error(JSON.stringify(error.details, null, 2));
}
process.exit(1);
});
+1491 -1037
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -41,6 +41,8 @@ class PostgresStore {
const identificationFiles = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at FROM identification_files"); const identificationFiles = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at FROM identification_files");
const feedback = await queryable.query("SELECT device_id, run_id, file_name, status, result, update_time FROM identification_feedback"); const feedback = await queryable.query("SELECT device_id, run_id, file_name, status, result, update_time FROM identification_feedback");
const requests = await queryable.query("SELECT device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time FROM volume_config_requests"); const requests = await queryable.query("SELECT device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time FROM volume_config_requests");
const volumeConfigs = await queryable.query("SELECT device_id, file_id, file_name, request_id, updated_at_ms FROM volume_configs");
const panelNotifications = await queryable.query("SELECT notification_id, device_id, type, title, message, request_id, file_id, created_at_ms FROM panel_notifications");
return { return {
...structuredClone(EMPTY_DATABASE), ...structuredClone(EMPTY_DATABASE),
companies: companies.rows.map((row) => ({ id: row.id, name: row.name, code: row.code, createdAt: asIso(row.created_at) })), companies: companies.rows.map((row) => ({ id: row.id, name: row.name, code: row.code, createdAt: asIso(row.created_at) })),
@@ -51,7 +53,9 @@ class PostgresStore {
panelInbox: panelInbox.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time) })), panelInbox: panelInbox.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time) })),
identificationFiles: identificationFiles.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes), status: row.status, processedAt: row.processed_at && asIso(row.processed_at), expiresAt: row.expires_at && asIso(row.expires_at) })), identificationFiles: identificationFiles.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes), status: row.status, processedAt: row.processed_at && asIso(row.processed_at), expiresAt: row.expires_at && asIso(row.expires_at) })),
identificationFeedback: feedback.rows.map((row) => ({ deviceId: row.device_id, runId: row.run_id, fileName: row.file_name, status: row.status, result: row.result, updateTime: asIso(row.update_time) })), identificationFeedback: feedback.rows.map((row) => ({ deviceId: row.device_id, runId: row.run_id, fileName: row.file_name, status: row.status, result: row.result, updateTime: asIso(row.update_time) })),
volumeConfigRequests: requests.rows.map((row) => ({ deviceId: row.device_id, requestId: row.request_id, status: row.status, createdAtMs: Number(row.created_at_ms), expiresAtMs: Number(row.expires_at_ms), configFileID: row.config_file_id, configFileName: row.config_file_name, uploadedAtMs: row.uploaded_at_ms && Number(row.uploaded_at_ms), updateTime: asIso(row.update_time) })) volumeConfigRequests: requests.rows.map((row) => ({ deviceId: row.device_id, requestId: row.request_id, status: row.status, createdAtMs: Number(row.created_at_ms), expiresAtMs: Number(row.expires_at_ms), configFileID: row.config_file_id, configFileName: row.config_file_name, uploadedAtMs: row.uploaded_at_ms && Number(row.uploaded_at_ms), updateTime: asIso(row.update_time) })),
volumeConfigs: volumeConfigs.rows.map((row) => ({ deviceId: row.device_id, fileID: row.file_id, fileName: row.file_name, requestId: row.request_id, updatedAtMs: Number(row.updated_at_ms) })),
panelNotifications: panelNotifications.rows.map((row) => ({ notificationId: row.notification_id, deviceId: row.device_id, type: row.type, title: row.title, message: row.message, requestId: row.request_id, fileID: row.file_id, createdAtMs: Number(row.created_at_ms) }))
}; };
} }
@@ -74,7 +78,7 @@ class PostgresStore {
} }
async writeDatabase(client, database) { async writeDatabase(client, database) {
await client.query("DELETE FROM volume_config_requests; DELETE FROM identification_feedback; DELETE FROM panel_inbox; DELETE FROM identification_files; DELETE FROM function_configs; DELETE FROM file_records; DELETE FROM licenses; DELETE FROM production_lines; DELETE FROM companies;"); await client.query("DELETE FROM panel_notifications; DELETE FROM volume_configs; DELETE FROM volume_config_requests; DELETE FROM identification_feedback; DELETE FROM panel_inbox; DELETE FROM identification_files; DELETE FROM function_configs; DELETE FROM file_records; DELETE FROM licenses; DELETE FROM production_lines; DELETE FROM companies;");
for (const item of database.companies) await client.query("INSERT INTO companies (id, name, code, created_at) VALUES ($1, $2, $3, $4)", [item.id, item.name, item.code, item.createdAt]); for (const item of database.companies) await client.query("INSERT INTO companies (id, name, code, created_at) VALUES ($1, $2, $3, $4)", [item.id, item.name, item.code, item.createdAt]);
for (const item of database.productionLines) await client.query("INSERT INTO production_lines (id, company_id, name, code, device_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [item.id, item.companyId, item.name, item.code, item.deviceId, item.createdAt, item.lastSeenAt]); for (const item of database.productionLines) await client.query("INSERT INTO production_lines (id, company_id, name, code, device_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [item.id, item.companyId, item.name, item.code, item.deviceId, item.createdAt, item.lastSeenAt]);
for (const item of database.licenses) await client.query("INSERT INTO licenses (license_id, company_id, production_line_id, device_id, customer, issued_at, expiry_at, features, license, status, created_at, revoked_at, revocation_reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", [item.licenseId, item.companyId, item.productionLineId, item.deviceId, item.customer, item.issuedAt, item.expiryAt, item.features, item.license, item.status, item.createdAt, item.revokedAt, item.revocationReason]); for (const item of database.licenses) await client.query("INSERT INTO licenses (license_id, company_id, production_line_id, device_id, customer, issued_at, expiry_at, features, license, status, created_at, revoked_at, revocation_reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", [item.licenseId, item.companyId, item.productionLineId, item.deviceId, item.customer, item.issuedAt, item.expiryAt, item.features, item.license, item.status, item.createdAt, item.revokedAt, item.revocationReason]);
@@ -84,6 +88,8 @@ class PostgresStore {
for (const item of database.identificationFiles) await client.query("INSERT INTO identification_files (file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime, item.size, item.status, item.processedAt, item.expiresAt]); for (const item of database.identificationFiles) await client.query("INSERT INTO identification_files (file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime, item.size, item.status, item.processedAt, item.expiresAt]);
for (const item of database.identificationFeedback) await client.query("INSERT INTO identification_feedback (device_id, run_id, file_name, status, result, update_time) VALUES ($1,$2,$3,$4,$5,$6)", [item.deviceId, item.runId, item.fileName, item.status, item.result, item.updateTime]); for (const item of database.identificationFeedback) await client.query("INSERT INTO identification_feedback (device_id, run_id, file_name, status, result, update_time) VALUES ($1,$2,$3,$4,$5,$6)", [item.deviceId, item.runId, item.fileName, item.status, item.result, item.updateTime]);
for (const item of database.volumeConfigRequests) await client.query("INSERT INTO volume_config_requests (device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.deviceId, item.requestId, item.status, item.createdAtMs, item.expiresAtMs, item.configFileID, item.configFileName, item.uploadedAtMs, item.updateTime]); for (const item of database.volumeConfigRequests) await client.query("INSERT INTO volume_config_requests (device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.deviceId, item.requestId, item.status, item.createdAtMs, item.expiresAtMs, item.configFileID, item.configFileName, item.uploadedAtMs, item.updateTime]);
for (const item of database.volumeConfigs) await client.query("INSERT INTO volume_configs (device_id, file_id, file_name, request_id, updated_at_ms) VALUES ($1,$2,$3,$4,$5)", [item.deviceId, item.fileID, item.fileName, item.requestId, item.updatedAtMs]);
for (const item of database.panelNotifications) await client.query("INSERT INTO panel_notifications (notification_id, device_id, type, title, message, request_id, file_id, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", [item.notificationId, item.deviceId, item.type, item.title, item.message, item.requestId, item.fileID, item.createdAtMs]);
} }
createId(prefix) { createId(prefix) {
+50 -50
View File
@@ -1,51 +1,51 @@
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs"); const fs = require("node:fs");
const { createApp } = require("./app"); const { createApp } = require("./app");
const { JsonStore } = require("./store"); const { JsonStore } = require("./store");
const { PostgresStore } = require("./postgres-store"); const { PostgresStore } = require("./postgres-store");
const host = process.env.HOST || "127.0.0.1"; const host = process.env.HOST || "127.0.0.1";
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const identificationPurgeIntervalMs = Number(process.env.IDENTIFICATION_PURGE_INTERVAL_MS || 60 * 60 * 1000); const identificationPurgeIntervalMs = Number(process.env.IDENTIFICATION_PURGE_INTERVAL_MS || 60 * 60 * 1000);
const dataDirectory = path.resolve(process.env.DATA_DIR || path.join(__dirname, "..", "data")); const dataDirectory = path.resolve(process.env.DATA_DIR || path.join(__dirname, "..", "data"));
async function main() { async function main() {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT 必须是有效端口号"); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT 必须是有效端口号");
const jsonDatabasePath = path.join(dataDirectory, "database.json"); const jsonDatabasePath = path.join(dataDirectory, "database.json");
const hasExistingJsonStore = await fs.promises.access(jsonDatabasePath).then(() => true, () => false); const hasExistingJsonStore = await fs.promises.access(jsonDatabasePath).then(() => true, () => false);
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL && !hasExistingJsonStore) { if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL && !hasExistingJsonStore) {
throw new Error("生产环境必须配置 DATABASE_URL"); throw new Error("生产环境必须配置 DATABASE_URL");
} }
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) { if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) {
console.warn("警告: 正在使用既有 JSON 数据库;请尽快迁移到 PostgreSQL"); console.warn("警告: 正在使用既有 JSON 数据库;请尽快迁移到 PostgreSQL");
} }
const filesDirectory = path.join(dataDirectory, "files"); const filesDirectory = path.join(dataDirectory, "files");
await fs.promises.mkdir(filesDirectory, { recursive: true }); await fs.promises.mkdir(filesDirectory, { recursive: true });
const store = process.env.DATABASE_URL const store = process.env.DATABASE_URL
? new PostgresStore(process.env.DATABASE_URL, filesDirectory) ? new PostgresStore(process.env.DATABASE_URL, filesDirectory)
: new JsonStore(dataDirectory); : new JsonStore(dataDirectory);
await store.initialize(); await store.initialize();
const app = createApp({ store }); const app = createApp({ store });
const purgeIdentificationFiles = async () => { const purgeIdentificationFiles = async () => {
try { try {
const purged = await app.locals.purgeExpiredIdentificationFiles(); const purged = await app.locals.purgeExpiredIdentificationFiles();
if (purged) console.info(`[retention] purged ${purged} expired identification file(s)`); if (purged) console.info(`[retention] purged ${purged} expired identification file(s)`);
} catch (error) { } catch (error) {
console.error("[retention] identification file purge failed", error); console.error("[retention] identification file purge failed", error);
} }
}; };
await purgeIdentificationFiles(); await purgeIdentificationFiles();
const purgeTimer = setInterval(purgeIdentificationFiles, identificationPurgeIntervalMs); const purgeTimer = setInterval(purgeIdentificationFiles, identificationPurgeIntervalMs);
purgeTimer.unref(); purgeTimer.unref();
app.listen(port, host, () => { app.listen(port, host, () => {
console.log(`ReinLoop server listening on http://${host}:${port}`); console.log(`ReinLoop server listening on http://${host}:${port}`);
console.log(`API endpoints: http://${host}:${port} and http://${host}:${port}/api`); console.log(`API endpoint: http://${host}:${port}`);
console.log(`Data directory: ${dataDirectory}`); console.log(`Data directory: ${dataDirectory}`);
console.log(`Metadata store: ${process.env.DATABASE_URL ? "PostgreSQL" : "local JSON"}`); console.log(`Metadata store: ${process.env.DATABASE_URL ? "PostgreSQL" : "local JSON"}`);
}); });
} }
main().catch((error) => { main().catch((error) => {
console.error(error.message); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
}); });
+81 -79
View File
@@ -1,80 +1,82 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { randomUUID } = require("node:crypto"); const { randomUUID } = require("node:crypto");
const EMPTY_DATABASE = { const EMPTY_DATABASE = {
fileRecords: [], fileRecords: [],
functionConfigs: [], functionConfigs: [],
panelInbox: [], panelInbox: [],
identificationFiles: [], identificationFiles: [],
identificationFeedback: [], identificationFeedback: [],
volumeConfigRequests: [], volumeConfigRequests: [],
userInfo: [], volumeConfigs: [],
companies: [], panelNotifications: [],
productionLines: [], userInfo: [],
licenses: [] companies: [],
}; productionLines: [],
licenses: []
class JsonStore { };
constructor(dataDirectory) {
this.dataDirectory = dataDirectory; class JsonStore {
this.filesDirectory = path.join(dataDirectory, "files"); constructor(dataDirectory) {
this.modelsDirectory = path.join(dataDirectory, "models"); this.dataDirectory = dataDirectory;
this.databasePath = path.join(dataDirectory, "database.json"); this.filesDirectory = path.join(dataDirectory, "files");
this.writeQueue = Promise.resolve(); this.modelsDirectory = path.join(dataDirectory, "models");
} this.databasePath = path.join(dataDirectory, "database.json");
this.writeQueue = Promise.resolve();
async initialize() { }
await fs.promises.mkdir(this.filesDirectory, { recursive: true });
try { async initialize() {
await fs.promises.access(this.databasePath); await fs.promises.mkdir(this.filesDirectory, { recursive: true });
} catch { try {
await this.writeDatabase(structuredClone(EMPTY_DATABASE)); await fs.promises.access(this.databasePath);
} } catch {
} await this.writeDatabase(structuredClone(EMPTY_DATABASE));
}
async read() { }
const content = await fs.promises.readFile(this.databasePath, "utf8");
return { ...structuredClone(EMPTY_DATABASE), ...JSON.parse(content) }; async read() {
} const content = await fs.promises.readFile(this.databasePath, "utf8");
return { ...structuredClone(EMPTY_DATABASE), ...JSON.parse(content) };
async update(mutator) { }
const operation = this.writeQueue.then(async () => {
const database = await this.read(); async update(mutator) {
const result = await mutator(database); const operation = this.writeQueue.then(async () => {
await this.writeDatabase(database); const database = await this.read();
return result; const result = await mutator(database);
}); await this.writeDatabase(database);
this.writeQueue = operation.catch(() => undefined); return result;
return operation; });
} this.writeQueue = operation.catch(() => undefined);
return operation;
async writeDatabase(database) { }
await fs.promises.mkdir(this.dataDirectory, { recursive: true });
const temporaryPath = `${this.databasePath}.${process.pid}.tmp`; async writeDatabase(database) {
await fs.promises.writeFile( await fs.promises.mkdir(this.dataDirectory, { recursive: true });
temporaryPath, const temporaryPath = `${this.databasePath}.${process.pid}.tmp`;
`${JSON.stringify(database, null, 2)}\n`, await fs.promises.writeFile(
"utf8" temporaryPath,
); `${JSON.stringify(database, null, 2)}\n`,
await fs.promises.rename(temporaryPath, this.databasePath); "utf8"
} );
await fs.promises.rename(temporaryPath, this.databasePath);
createId(prefix) { }
return `${prefix}_${randomUUID()}`;
} createId(prefix) {
return `${prefix}_${randomUUID()}`;
resolveStoredFile(fileID) { }
if (typeof fileID !== "string") return null;
const isModel = fileID.startsWith("model://"); resolveStoredFile(fileID) {
if (!isModel && !fileID.startsWith("local://")) return null; if (typeof fileID !== "string") return null;
const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory; const isModel = fileID.startsWith("model://");
const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/"); if (!isModel && !fileID.startsWith("local://")) return null;
const absolutePath = path.resolve(rootDirectory, relativePath); const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory;
const relativeToRoot = path.relative(rootDirectory, absolutePath); const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/");
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null; const absolutePath = path.resolve(rootDirectory, relativePath);
return absolutePath; const relativeToRoot = path.relative(rootDirectory, absolutePath);
} if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null;
} return absolutePath;
}
}
module.exports = { EMPTY_DATABASE, JsonStore }; module.exports = { EMPTY_DATABASE, JsonStore };
+1026 -692
View File
File diff suppressed because it is too large Load Diff