update panel
This commit is contained in:
@@ -242,7 +242,13 @@ Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID
|
|||||||
Panel 当前依赖以下新增 Server type:
|
Panel 当前依赖以下新增 Server type:
|
||||||
|
|
||||||
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
||||||
`listLicenses`、`getLicense`、`revokeLicense`。既有模型和反馈接口继续使用。
|
`listLicenses`、`getLicense`、`revokeLicense`、`listControlFiles`、
|
||||||
|
`getControlFileDownload`、`deleteControlFile`。既有模型和反馈接口继续使用。
|
||||||
|
|
||||||
|
控制数据接口约定:`listControlFiles` 按 `deviceId` 分页返回控制结束后上传到
|
||||||
|
`<deviceId>/data_record/` 的文件元数据;`getControlFileDownload` 按 `fileID` 返回带时效的
|
||||||
|
下载 URL;`deleteControlFile` 按 `fileID` 删除文件和元数据。三者均应要求 B 端管理令牌,且
|
||||||
|
服务端必须验证文件归属控制数据目录,避免使用该接口操作其他业务文件。
|
||||||
|
|
||||||
## 9. 已知依赖风险
|
## 9. 已知依赖风险
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { plotJson } = require("./plot-json");
|
|||||||
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 DEFAULT_API_URL = "http://ReinLoop.dominatedconvergence.com/api";
|
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);
|
||||||
const connectionState = {
|
const connectionState = {
|
||||||
@@ -204,6 +204,20 @@ function registerHandlers() {
|
|||||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||||
ipcMain.handle("license:revoke", (_event, request) =>
|
ipcMain.handle("license:revoke", (_event, request) =>
|
||||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||||
|
ipcMain.handle("license:download", async (_event, request) => {
|
||||||
|
const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials);
|
||||||
|
if (typeof result.license?.license !== "string" || !result.license.license) {
|
||||||
|
throw new Error("Server 未返回许可证原文,无法下载");
|
||||||
|
}
|
||||||
|
const selection = await dialog.showSaveDialog({
|
||||||
|
title: "保存许可证",
|
||||||
|
defaultPath: `${request.licenseId}.lic`,
|
||||||
|
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||||
|
});
|
||||||
|
if (selection.canceled) return null;
|
||||||
|
await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 });
|
||||||
|
return { filePath: selection.filePath };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("review:submit", async (_event, request) => {
|
ipcMain.handle("review:submit", async (_event, request) => {
|
||||||
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
||||||
@@ -292,6 +306,51 @@ function registerHandlers() {
|
|||||||
ipcMain.handle("identification:delete", (_event, request) =>
|
ipcMain.handle("identification:delete", (_event, request) =>
|
||||||
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
||||||
|
|
||||||
|
ipcMain.handle("control-data:list", (_event, request) =>
|
||||||
|
callServer({
|
||||||
|
type: "listControlFiles",
|
||||||
|
deviceId: request.deviceId,
|
||||||
|
page: request.page,
|
||||||
|
pageSize: request.pageSize
|
||||||
|
}, request.credentials));
|
||||||
|
ipcMain.handle("control-data:preview", async (_event, request) => {
|
||||||
|
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
|
const fileName = download.fileName || request.fileName;
|
||||||
|
const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials);
|
||||||
|
const extension = path.extname(fileName).toLowerCase();
|
||||||
|
let content = null;
|
||||||
|
if (extension === ".json") {
|
||||||
|
const text = await fs.promises.readFile(sourcePath, "utf8");
|
||||||
|
try {
|
||||||
|
content = JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
} catch (_error) {
|
||||||
|
content = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filePath: sourcePath,
|
||||||
|
fileName,
|
||||||
|
uploadTime: download.uploadTime || request.uploadTime,
|
||||||
|
size: download.size ?? request.size,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ipcMain.handle("control-data:download", async (_event, request) => {
|
||||||
|
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
|
const fileName = download.fileName || request.fileName;
|
||||||
|
const extension = path.extname(fileName).replace(/^\./, "") || "bin";
|
||||||
|
const selection = await dialog.showSaveDialog({
|
||||||
|
title: "保存控制原始数据",
|
||||||
|
defaultPath: fileName,
|
||||||
|
filters: [{ name: `${extension.toUpperCase()} 文件`, extensions: [extension] }]
|
||||||
|
});
|
||||||
|
if (selection.canceled) return null;
|
||||||
|
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||||
|
return { filePath: selection.filePath };
|
||||||
|
});
|
||||||
|
ipcMain.handle("control-data:delete", (_event, request) =>
|
||||||
|
callServer({ type: "deleteControlFile", fileID: request.fileID }, request.credentials));
|
||||||
|
|
||||||
ipcMain.handle("config:choose", async () => {
|
ipcMain.handle("config:choose", async () => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
title: "导入配置 JSON",
|
title: "导入配置 JSON",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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),
|
||||||
|
downloadLicense: (request) => ipcRenderer.invoke("license:download", 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"),
|
||||||
@@ -25,6 +26,10 @@ contextBridge.exposeInMainWorld("reinloop", {
|
|||||||
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
||||||
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
||||||
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
||||||
|
listControlFiles: (request) => ipcRenderer.invoke("control-data:list", request),
|
||||||
|
previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request),
|
||||||
|
downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request),
|
||||||
|
deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", 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))
|
||||||
});
|
});
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<form id="connection-form" class="connection-form">
|
<form id="connection-form" class="connection-form">
|
||||||
<p class="section-kicker">SERVER ACCESS</p>
|
<p class="section-kicker">SERVER ACCESS</p>
|
||||||
<h2 id="connection-title">连接管理服务</h2>
|
<h2 id="connection-title">连接管理服务</h2>
|
||||||
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com/api" required></label>
|
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com" required></label>
|
||||||
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
||||||
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
||||||
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
<nav class="tabs" aria-label="功能切换">
|
<nav class="tabs" aria-label="功能切换">
|
||||||
<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="config-panel">配置发布</button>
|
<button class="tab" data-target="config-panel">配置发布</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>
|
||||||
@@ -115,6 +116,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="control-data-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="section-kicker">CONTROL DATA ARCHIVE</p><h2>控制数据暂存</h2></div>
|
||||||
|
<button id="refresh-control-files" class="button secondary">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-surface">
|
||||||
|
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>操作</th></tr></thead><tbody id="control-file-list"></tbody></table>
|
||||||
|
<p id="control-file-empty" class="empty-row">选择公司和产线后刷新控制数据</p>
|
||||||
|
<pre id="control-file-detail" class="detail-view" hidden></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="config-panel" class="panel">
|
<section id="config-panel" class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const state = {
|
|||||||
imagePaths: { csv: null, json: null },
|
imagePaths: { csv: null, json: null },
|
||||||
imageDataUrls: { csv: null, json: null },
|
imageDataUrls: { csv: null, json: null },
|
||||||
identificationFiles: [],
|
identificationFiles: [],
|
||||||
|
controlFiles: [],
|
||||||
configs: { volume: volumeExample, identification: identificationExample },
|
configs: { volume: volumeExample, identification: identificationExample },
|
||||||
companies: [],
|
companies: [],
|
||||||
models: [],
|
models: [],
|
||||||
@@ -80,6 +81,9 @@ const elements = {
|
|||||||
modelEmpty: document.querySelector("#model-empty"),
|
modelEmpty: document.querySelector("#model-empty"),
|
||||||
identificationFileList: document.querySelector("#identification-file-list"),
|
identificationFileList: document.querySelector("#identification-file-list"),
|
||||||
identificationFileEmpty: document.querySelector("#identification-file-empty"),
|
identificationFileEmpty: document.querySelector("#identification-file-empty"),
|
||||||
|
controlFileList: document.querySelector("#control-file-list"),
|
||||||
|
controlFileEmpty: document.querySelector("#control-file-empty"),
|
||||||
|
controlFileDetail: document.querySelector("#control-file-detail"),
|
||||||
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"),
|
||||||
@@ -127,6 +131,13 @@ function formatSize(value) {
|
|||||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function controlFileType(fileName) {
|
||||||
|
const extension = String(fileName || "").split(".").pop().toLowerCase();
|
||||||
|
if (extension === "pkl") return "控制 Episode 分片";
|
||||||
|
if (extension === "json") return "控制数据清单";
|
||||||
|
return extension ? `${extension.toUpperCase()} 文件` : "控制数据";
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
const node = document.createElement("span");
|
const node = document.createElement("span");
|
||||||
node.textContent = String(value ?? "");
|
node.textContent = String(value ?? "");
|
||||||
@@ -242,7 +253,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>${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>` : ""}</td></tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
elements.licenseEmpty.hidden = result.licenses.length > 0;
|
elements.licenseEmpty.hidden = result.licenses.length > 0;
|
||||||
setStatus("许可证已刷新", "success");
|
setStatus("许可证已刷新", "success");
|
||||||
@@ -280,6 +291,38 @@ async function refreshIdentificationFiles() {
|
|||||||
setStatus("辨识数据已刷新", "success");
|
setStatus("辨识数据已刷新", "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshControlFiles() {
|
||||||
|
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
|
||||||
|
const result = await runBusy("正在读取控制暂存数据", () => window.reinloop.listControlFiles({
|
||||||
|
deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (!result) return;
|
||||||
|
state.controlFiles = result.files || result.fileList || [];
|
||||||
|
elements.controlFileList.innerHTML = state.controlFiles.map((file) => `
|
||||||
|
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
|
||||||
|
<td>${escapeHtml(controlFileType(file.fileName))}</td><td>${formatSize(file.size)}</td>
|
||||||
|
<td><button class="table-action" data-control-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-control-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-control-delete="${escapeHtml(file.fileID)}">删除</button></td></tr>
|
||||||
|
`).join("");
|
||||||
|
elements.controlFileEmpty.hidden = state.controlFiles.length > 0;
|
||||||
|
elements.controlFileDetail.hidden = true;
|
||||||
|
elements.controlFileDetail.textContent = "";
|
||||||
|
setStatus("控制数据已刷新", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showControlFilePreview(result) {
|
||||||
|
const metadata = [
|
||||||
|
`文件名:${result.fileName}`,
|
||||||
|
`上传时间:${result.uploadTime || "-"}`,
|
||||||
|
`大小:${formatSize(result.size)}`,
|
||||||
|
`本地缓存:${result.filePath}`
|
||||||
|
];
|
||||||
|
const detail = result.content === null
|
||||||
|
? `${metadata.join("\n")}\n\n此文件为二进制控制 Episode 分片(.pkl),请下载后使用 ReinLoop/Python 分析。`
|
||||||
|
: `${metadata.join("\n")}\n\n${result.content}`;
|
||||||
|
elements.controlFileDetail.textContent = detail;
|
||||||
|
elements.controlFileDetail.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function showError(error) {
|
function showError(error) {
|
||||||
const message = (error?.message || String(error))
|
const message = (error?.message || String(error))
|
||||||
.replace(/^Error invoking remote method '[^']+': Error: /, "");
|
.replace(/^Error invoking remote method '[^']+': Error: /, "");
|
||||||
@@ -503,20 +546,48 @@ document.querySelector("#license-form").addEventListener("submit", async (event)
|
|||||||
});
|
});
|
||||||
document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses);
|
document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses);
|
||||||
elements.licenseList.addEventListener("click", async (event) => {
|
elements.licenseList.addEventListener("click", async (event) => {
|
||||||
const detailId = event.target.dataset.licenseDetail;
|
const button = event.target.closest("button");
|
||||||
const revokeId = event.target.dataset.licenseRevoke;
|
if (!button) return;
|
||||||
|
const detailId = button.dataset.licenseDetail;
|
||||||
|
const downloadId = button.dataset.licenseDownload;
|
||||||
|
const revokeId = button.dataset.licenseRevoke;
|
||||||
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) {
|
||||||
elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2);
|
elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2);
|
||||||
elements.licenseDetail.hidden = false;
|
elements.licenseDetail.hidden = false;
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (downloadId) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runBusy("正在下载许可证", () => window.reinloop.downloadLicense({
|
||||||
|
licenseId: downloadId, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (result) setStatus(`许可证已保存: ${result.filePath}`, "success");
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (revokeId) {
|
if (revokeId) {
|
||||||
const reason = window.prompt("请输入撤销原因", "管理员撤销");
|
const reason = window.prompt("请输入撤销原因", "管理员撤销");
|
||||||
if (reason === null) return;
|
if (reason === null) return;
|
||||||
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ licenseId: revokeId, reason, credentials: credentials() }));
|
const trimmedReason = reason.trim() || "管理员撤销";
|
||||||
if (result) await refreshLicenses();
|
if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({
|
||||||
|
licenseId: revokeId, reason: trimmedReason, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (result) {
|
||||||
|
await refreshLicenses();
|
||||||
|
setStatus("许可证已撤销", "success");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -714,3 +785,49 @@ document.addEventListener("keydown", (event) => {
|
|||||||
if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25);
|
if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25);
|
||||||
if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8);
|
if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#refresh-control-files").addEventListener("click", refreshControlFiles);
|
||||||
|
elements.controlFileList.addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("button");
|
||||||
|
if (!button) return;
|
||||||
|
const fileID = button.dataset.controlPreview || button.dataset.controlDownload || button.dataset.controlDelete;
|
||||||
|
if (!fileID) return;
|
||||||
|
const file = state.controlFiles.find((item) => item.fileID === fileID);
|
||||||
|
if (!file) return;
|
||||||
|
if (button.dataset.controlPreview) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runBusy("正在下载控制数据预览", () => window.reinloop.previewControlFile({
|
||||||
|
...file, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (result) {
|
||||||
|
showControlFilePreview(result);
|
||||||
|
setStatus("控制数据预览已载入", "success");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
} else if (button.dataset.controlDownload) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runBusy("正在保存控制原始数据", () => window.reinloop.downloadControlFile({
|
||||||
|
...file, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (result) setStatus(`已保存到: ${result.filePath}`, "success");
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!window.confirm(`确认永久删除 ${file.fileName}?`)) return;
|
||||||
|
if (!window.confirm("删除后无法恢复,确认继续?")) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runBusy("正在删除控制数据", () => window.reinloop.deleteControlFile({
|
||||||
|
fileID, credentials: credentials()
|
||||||
|
}));
|
||||||
|
if (result) await refreshControlFiles();
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -179,6 +179,7 @@ base64(JSON)|base64(signature)
|
|||||||
|
|
||||||
- 刷新许可证列表
|
- 刷新许可证列表
|
||||||
- 查看许可证详情
|
- 查看许可证详情
|
||||||
|
- 下载已签发许可证
|
||||||
- 撤销有效许可证
|
- 撤销有效许可证
|
||||||
- 填写撤销原因
|
- 填写撤销原因
|
||||||
- 区分有效和已撤销状态
|
- 区分有效和已撤销状态
|
||||||
@@ -192,6 +193,33 @@ getLicense
|
|||||||
revokeLicense
|
revokeLicense
|
||||||
```
|
```
|
||||||
|
|
||||||
|
下载已签发许可证复用 `getLicense` 返回的许可证原文;Panel 在本地选择保存位置后写入 `.lic` 文件。
|
||||||
|
|
||||||
|
## 5.1 控制数据暂存
|
||||||
|
|
||||||
|
“控制数据”页面按当前产线显示 ReinLoop 在控制结束后上传到
|
||||||
|
`<deviceId>/data_record/` 的 Episode 分片和清单文件。页面支持:
|
||||||
|
|
||||||
|
- 刷新控制数据列表
|
||||||
|
- 查看 JSON 清单内容;`.pkl` 分片显示文件元数据和本地缓存位置
|
||||||
|
- 下载任意控制原始文件
|
||||||
|
- 删除指定控制数据文件
|
||||||
|
|
||||||
|
控制 Episode 分片采用 Python pickle 格式,Panel 不在渲染进程反序列化该二进制数据;
|
||||||
|
需要详细分析时,应下载后使用 ReinLoop/Python 读取。JSON manifest 可直接在页面中查看。
|
||||||
|
|
||||||
|
Panel 需要 Server 提供以下 Admin 接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
listControlFiles deviceId, page, pageSize -> files, total, page, pageSize
|
||||||
|
getControlFileDownload fileID -> fileID, fileName, uploadTime, size, url
|
||||||
|
deleteControlFile fileID -> deletedCount
|
||||||
|
```
|
||||||
|
|
||||||
|
每条 `files` 记录至少包含 `fileID`、`fileName`、`uploadTime` 和 `size`。下载接口必须返回
|
||||||
|
可下载原始文件的短期签名 URL;删除接口必须同时删除文件本体和元数据,并仅允许删除该设备的
|
||||||
|
控制数据目录中的文件。
|
||||||
|
|
||||||
## 6. 模型管理
|
## 6. 模型管理
|
||||||
|
|
||||||
“模型管理”页面按当前产线操作:
|
“模型管理”页面按当前产线操作:
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ pressure_control_gui/
|
|||||||
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
|
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
|
||||||
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client)
|
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client)
|
||||||
│ ├── model_manager.py # RL 模型管理
|
│ ├── model_manager.py # RL 模型管理
|
||||||
│ ├── data_collector.py # 数据采集与云端上传
|
│ ├── data_collector.py # 数据采集与云服务器上传
|
||||||
│ └── identification.py # 系统辨识与容积测量管理
|
│ └── identification.py # 系统辨识与容积测量管理
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
|
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
|
||||||
|
|||||||
+6
-3
@@ -1,4 +1,4 @@
|
|||||||
"""ReinLoop server endpoint configuration shared by core modules."""
|
"""ReinLoop cloud-server endpoint configuration shared by core modules."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,12 +7,15 @@ from license_utils import get_verified_license
|
|||||||
|
|
||||||
base_url = os.environ.get(
|
base_url = os.environ.get(
|
||||||
"REINLOOP_SERVER_URL",
|
"REINLOOP_SERVER_URL",
|
||||||
"http://ReinLoop.dominatedconvergence.com",
|
"https://ReinLoop.dominatedconvergence.com",
|
||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
data_record_url = os.environ.get(
|
server_api_url = os.environ.get(
|
||||||
"REINLOOP_API_URL",
|
"REINLOOP_API_URL",
|
||||||
f"{base_url}/api",
|
f"{base_url}/api",
|
||||||
)
|
)
|
||||||
|
# Compatibility alias used by existing modules. It points to the ReinLoop
|
||||||
|
# Express server API, not a cloud-function endpoint.
|
||||||
|
data_record_url = server_api_url
|
||||||
_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()
|
||||||
|
|||||||
@@ -22,17 +22,30 @@ class DataCollector:
|
|||||||
self.current_episode = None # 当前正在记录的 Episode
|
self.current_episode = None # 当前正在记录的 Episode
|
||||||
self.last_target_record = None
|
self.last_target_record = None
|
||||||
self._on_log = None
|
self._on_log = None
|
||||||
|
self._on_upload_complete = None
|
||||||
|
|
||||||
def set_log_callback(self, callback):
|
def set_log_callback(self, callback):
|
||||||
"""设置日志回调"""
|
"""设置日志回调"""
|
||||||
self._on_log = callback
|
self._on_log = callback
|
||||||
|
|
||||||
|
def set_upload_complete_callback(self, callback):
|
||||||
|
"""设置控制数据上传完成回调。
|
||||||
|
|
||||||
|
callback(success, manifest, error) 会在后台上传线程中调用。成功时
|
||||||
|
manifest 是已上传的清单字典;失败时 error 为可展示的错误信息。
|
||||||
|
"""
|
||||||
|
self._on_upload_complete = callback
|
||||||
|
|
||||||
def log(self, message):
|
def log(self, message):
|
||||||
if self._on_log:
|
if self._on_log:
|
||||||
self._on_log(message)
|
self._on_log(message)
|
||||||
|
|
||||||
def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
def _notify_upload_complete(self, success, manifest=None, error=None):
|
||||||
"""通过云函数获取直传凭证,再将数据直传到腾讯云 COS。"""
|
if self._on_upload_complete:
|
||||||
|
self._on_upload_complete(success, manifest, error)
|
||||||
|
|
||||||
|
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
||||||
|
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
|
||||||
try:
|
try:
|
||||||
resp = requests.post(data_record_url, json={
|
resp = requests.post(data_record_url, json={
|
||||||
"type": "uploadDataFile",
|
"type": "uploadDataFile",
|
||||||
@@ -41,35 +54,29 @@ class DataCollector:
|
|||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"向云函数申请凭证异常: {e}")
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
meta = result.get("uploadMetadata")
|
meta = result.get("uploadMetadata")
|
||||||
if not meta or "url" not in meta or "authorization" not in meta:
|
if not meta or "url" not in meta:
|
||||||
self.log("云端未返回有效的上传元数据")
|
self.log("云服务器未返回有效的上传地址")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
form_data = {
|
|
||||||
"key": meta["cosFileId"],
|
|
||||||
"Signature": meta["authorization"],
|
|
||||||
"x-cos-security-token": meta["token"],
|
|
||||||
"x-cos-meta-fileid": meta["fileId"],
|
|
||||||
}
|
|
||||||
files = {"file": (filename, io.BytesIO(data_bytes))}
|
files = {"file": (filename, io.BytesIO(data_bytes))}
|
||||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
upload_resp = requests.post(meta["url"], files=files, timeout=60)
|
||||||
|
|
||||||
if cos_resp.status_code in [200, 204]:
|
if upload_resp.status_code in [200, 204]:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"COS 直传异常: {e}")
|
self.log(f"云服务器上传异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
@@ -136,8 +143,12 @@ class DataCollector:
|
|||||||
if not self.episode_data_raw:
|
if not self.episode_data_raw:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 上传在线程中继续执行,因此必须持有本轮数据快照。否则 finally
|
||||||
|
# 清空缓存后,异步线程生成的 manifest 会错误地显示 0 个 Episode。
|
||||||
|
episodes = list(self.episode_data_raw)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
|
||||||
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
||||||
|
|
||||||
# 拆成每片尽量不超过 5MB 的 episode 分组
|
# 拆成每片尽量不超过 5MB 的 episode 分组
|
||||||
@@ -145,55 +156,79 @@ class DataCollector:
|
|||||||
|
|
||||||
chunks = [] # [(chunk_index, episodes_subset)]
|
chunks = [] # [(chunk_index, episodes_subset)]
|
||||||
current_chunk = []
|
current_chunk = []
|
||||||
for ep in self.episode_data_raw:
|
for ep in episodes:
|
||||||
current_chunk.append(ep)
|
current_chunk.append(ep)
|
||||||
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
||||||
# 当前片已满,回退一个 episode 后保存
|
# 当前片已满,回退一个 episode 后保存
|
||||||
current_chunk.pop()
|
current_chunk.pop()
|
||||||
|
# 单个 Episode 也可能超过 5 MB;此时仍上传该 Episode,
|
||||||
|
# 而不是产生一个无内容的空分片。
|
||||||
|
if current_chunk:
|
||||||
chunks.append(current_chunk)
|
chunks.append(current_chunk)
|
||||||
current_chunk = [ep]
|
current_chunk = [ep]
|
||||||
if current_chunk:
|
if current_chunk:
|
||||||
chunks.append(current_chunk)
|
chunks.append(current_chunk)
|
||||||
|
|
||||||
total_chunks = len(chunks)
|
total_chunks = len(chunks)
|
||||||
self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode,"
|
self.log(f"控制数据共 {len(episodes)} 个 Episode,"
|
||||||
f"拆为 {total_chunks} 个分片上传")
|
f"拆为 {total_chunks} 个分片上传")
|
||||||
|
|
||||||
def upload_all():
|
def upload_all():
|
||||||
part_files = []
|
part_files = []
|
||||||
|
part_metadata = []
|
||||||
for idx, chunk_eps in enumerate(chunks):
|
for idx, chunk_eps in enumerate(chunks):
|
||||||
data_bytes = pickle.dumps(chunk_eps)
|
data_bytes = pickle.dumps(chunk_eps)
|
||||||
size_kb = len(data_bytes) / 1024
|
size_kb = len(data_bytes) / 1024
|
||||||
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
||||||
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
||||||
if self._upload_to_cos(data_bytes, part_filename, base_folder):
|
if self._upload_to_server(data_bytes, part_filename, base_folder):
|
||||||
part_files.append(part_filename)
|
part_files.append(part_filename)
|
||||||
|
part_metadata.append({
|
||||||
|
"file_name": part_filename,
|
||||||
|
"episode_count": len(chunk_eps),
|
||||||
|
"size_bytes": len(data_bytes),
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
self.log(f" 分片 {idx + 1} 上传失败")
|
self.log(f" 分片 {idx + 1} 上传失败")
|
||||||
|
|
||||||
# 上传 manifest
|
# 上传 manifest
|
||||||
manifest = {
|
manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data_type": "control_episode",
|
||||||
|
"run_id": timestamp,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
"folder": base_folder,
|
||||||
"total_chunks": total_chunks,
|
"total_chunks": total_chunks,
|
||||||
"uploaded_chunks": len(part_files),
|
"uploaded_chunks": len(part_files),
|
||||||
"part_files": part_files,
|
"part_files": part_files,
|
||||||
"total_episodes": len(self.episode_data_raw),
|
"parts": part_metadata,
|
||||||
|
"total_episodes": len(episodes),
|
||||||
"flow": flow,
|
"flow": flow,
|
||||||
"volume": vol,
|
"volume": vol,
|
||||||
}
|
}
|
||||||
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
||||||
manifest_bytes = manifest_str.encode('utf-8')
|
manifest_bytes = manifest_str.encode('utf-8')
|
||||||
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
||||||
self._upload_to_cos(manifest_bytes, manifest_filename, base_folder)
|
manifest_uploaded = self._upload_to_server(
|
||||||
|
manifest_bytes, manifest_filename, base_folder
|
||||||
|
)
|
||||||
|
|
||||||
if len(part_files) == total_chunks:
|
if len(part_files) == total_chunks and manifest_uploaded:
|
||||||
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
||||||
|
self._notify_upload_complete(True, manifest, None)
|
||||||
else:
|
else:
|
||||||
self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})")
|
error = (
|
||||||
|
"控制数据清单上传失败"
|
||||||
|
if not manifest_uploaded
|
||||||
|
else f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})"
|
||||||
|
)
|
||||||
|
self.log(error)
|
||||||
|
self._notify_upload_complete(False, manifest, error)
|
||||||
|
|
||||||
threading.Thread(target=upload_all, daemon=True).start()
|
threading.Thread(target=upload_all, daemon=True).start()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"保存收集数据时发生错误: {e}")
|
self.log(f"保存收集数据时发生错误: {e}")
|
||||||
|
self._notify_upload_complete(False, None, str(e))
|
||||||
finally:
|
finally:
|
||||||
self.episode_data_raw = []
|
self.episode_data_raw = []
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ class IdentificationManager:
|
|||||||
)
|
)
|
||||||
return self._identifying or thread_alive
|
return self._identifying or thread_alive
|
||||||
|
|
||||||
def _upload_to_cos(self, content, filename: str, folder: str) -> bool:
|
def _upload_to_server(self, content, filename: str, folder: str) -> bool:
|
||||||
"""通过云函数获取直传凭证,再将文本或字节数据直传到 COS。
|
"""向 ReinLoop 云服务器申请上传地址并上传文本或字节数据。
|
||||||
|
|
||||||
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
||||||
"""
|
"""
|
||||||
# Step 1: 向云函数申请直传凭证(不传文件内容)
|
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
|
||||||
try:
|
try:
|
||||||
resp = requests.post(data_record_url, json={
|
resp = requests.post(data_record_url, json={
|
||||||
"type": "uploadDataFile",
|
"type": "uploadDataFile",
|
||||||
@@ -73,40 +73,34 @@ class IdentificationManager:
|
|||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"向云函数申请凭证异常: {e}")
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
meta = result.get("uploadMetadata")
|
meta = result.get("uploadMetadata")
|
||||||
if not meta or "url" not in meta or "authorization" not in meta:
|
if not meta or "url" not in meta:
|
||||||
self.log("云端未返回有效的上传元数据")
|
self.log("云服务器未返回有效的上传地址")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Step 2: 直传到 COS
|
# Step 2: multipart 上传到云服务器提供的一次性地址
|
||||||
try:
|
try:
|
||||||
form_data = {
|
|
||||||
"key": meta["cosFileId"],
|
|
||||||
"Signature": meta["authorization"],
|
|
||||||
"x-cos-security-token": meta["token"],
|
|
||||||
"x-cos-meta-fileid": meta["fileId"],
|
|
||||||
}
|
|
||||||
content_bytes = (
|
content_bytes = (
|
||||||
content if isinstance(content, bytes)
|
content if isinstance(content, bytes)
|
||||||
else str(content).encode("utf-8")
|
else str(content).encode("utf-8")
|
||||||
)
|
)
|
||||||
files = {"file": (filename, io.BytesIO(content_bytes))}
|
files = {"file": (filename, io.BytesIO(content_bytes))}
|
||||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
upload_resp = requests.post(meta["url"], files=files, timeout=60)
|
||||||
|
|
||||||
if cos_resp.status_code in [200, 204]:
|
if upload_resp.status_code in [200, 204]:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"COS 直传异常: {e}")
|
self.log(f"云服务器上传异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _run_initial_travel_scan(self, conn_mgr):
|
def _run_initial_travel_scan(self, conn_mgr):
|
||||||
@@ -226,7 +220,7 @@ class IdentificationManager:
|
|||||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
filename = f"travel_stability_pressures_{timestamp}.json"
|
filename = f"travel_stability_pressures_{timestamp}.json"
|
||||||
payload = {"stable_pressures": stable_pressure_records}
|
payload = {"stable_pressures": stable_pressure_records}
|
||||||
uploaded = self._upload_to_cos(
|
uploaded = self._upload_to_server(
|
||||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
filename,
|
filename,
|
||||||
f"{the_folder}/ind_data",
|
f"{the_folder}/ind_data",
|
||||||
@@ -309,7 +303,7 @@ class IdentificationManager:
|
|||||||
self.log(error)
|
self.log(error)
|
||||||
if self._on_identification_upload:
|
if self._on_identification_upload:
|
||||||
self._on_identification_upload(False, None, error)
|
self._on_identification_upload(False, None, error)
|
||||||
elif self._upload_to_cos(
|
elif self._upload_to_server(
|
||||||
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
||||||
self.log("辨识数据上传成功")
|
self.log("辨识数据上传成功")
|
||||||
if self._on_identification_upload:
|
if self._on_identification_upload:
|
||||||
@@ -458,7 +452,7 @@ class IdentificationManager:
|
|||||||
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
||||||
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
||||||
|
|
||||||
if self._upload_to_cos(json_str, filename, f"{the_folder}/V_config"):
|
if self._upload_to_server(json_str, filename, f"{the_folder}/V_config"):
|
||||||
self.log("体积测量数据上传成功")
|
self.log("体积测量数据上传成功")
|
||||||
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ 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 function."""
|
"""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 data_record_url, the_folder
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ def download_identification_config(timeout=20) -> dict:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
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"):
|
||||||
raise ValueError(result.get("errMsg", "云端未返回辨识配置"))
|
raise ValueError(result.get("errMsg", "云服务器未返回辨识配置"))
|
||||||
try:
|
try:
|
||||||
config_response = requests.get(result["url"], timeout=timeout)
|
config_response = requests.get(result["url"], timeout=timeout)
|
||||||
config_response.raise_for_status()
|
config_response.raise_for_status()
|
||||||
|
|||||||
+21
-3
@@ -5,7 +5,8 @@
|
|||||||
## 约定
|
## 约定
|
||||||
|
|
||||||
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
||||||
`REINLOOP_SERVER_URL + /api`。
|
`REINLOOP_SERVER_URL + /api`;默认地址为
|
||||||
|
`https://ReinLoop.dominatedconvergence.com/api`。
|
||||||
- 新许可证的设备标识为 `<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 /api`,通过请求体的 `type` 字段分发。
|
||||||
@@ -67,11 +68,28 @@ RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
|
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
|
||||||
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
|
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
|
||||||
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/...`。 |
|
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/data_<flow>SLM_<volume>L`。清单含 `data_type: "control_episode"`、`run_id`、分片元数据和总 Episode 数。 |
|
||||||
| 上传凭证与直传 | `DataCollector._upload_to_cos(data_bytes, filename, folder)` | 内部接口;先请求上传凭证,再将对象直传。 |
|
| 申请上传地址并上传 | `DataCollector._upload_to_server(data_bytes, filename, folder)` | 内部接口;先向 ReinLoop 云服务器申请一次性上传地址,再以 multipart 上传文件。 |
|
||||||
|
| 上传结果通知 | `DataCollector.set_upload_complete_callback(callback)` | 注册 `callback(success, manifest, error)`;在后台上传线程完成时调用。 |
|
||||||
|
|
||||||
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
|
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
|
||||||
|
|
||||||
|
### 控制数据服务端约定
|
||||||
|
|
||||||
|
控制数据上传目录固定以 `<deviceId>/data_record/` 为前缀;每次停止控制会上传
|
||||||
|
若干 `.pkl` 分片和一个同名时间戳的 `_manifest.json`。服务端在接收二步上传的文件后,
|
||||||
|
应保留文件元数据,并向管理端提供以下仅管理员可调用的接口:
|
||||||
|
|
||||||
|
| `type` | 请求字段 | 成功响应 | 服务端行为 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `listControlFiles` | `deviceId`、可选 `page`、`pageSize` | `files`、`total`、`page`、`pageSize` | 仅返回 `folder` 以 `<deviceId>/data_record/` 开头的记录;每条至少有 `fileID`、`fileName`、`folder`、`uploadTime`、`size`。 |
|
||||||
|
| `getControlFileDownload` | `fileID` | `fileID`、`fileName`、`url` | 仅允许下载控制数据目录内的文件,并返回短期签名下载 URL。 |
|
||||||
|
| `deleteControlFile` | `fileID` | `deletedCount` | 仅允许删除控制数据目录内的文件;同时删除文件本体及对应元数据。 |
|
||||||
|
|
||||||
|
上述三个接口必须校验管理端令牌,并根据 `fileID` 对应记录的目录验证设备边界,不能仅信任
|
||||||
|
调用方传入的设备标识。Panel 可直接展示 JSON manifest;`.pkl` 为 Python pickle 二进制,
|
||||||
|
应仅供下载,不应在管理端进程中反序列化。
|
||||||
|
|
||||||
## 系统辨识
|
## 系统辨识
|
||||||
|
|
||||||
| 功能 | 接口 | 返回或行为 |
|
| 功能 | 接口 | 返回或行为 |
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ def _is_new_license(payload):
|
|||||||
|
|
||||||
def _api_url():
|
def _api_url():
|
||||||
base_url = os.environ.get(
|
base_url = os.environ.get(
|
||||||
"REINLOOP_SERVER_URL", "http://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", f"{base_url}/api")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "data_collector.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_data_collector_module():
|
||||||
|
api = types.ModuleType("api")
|
||||||
|
api.base_url = "https://cloud.example"
|
||||||
|
api.data_record_url = "https://cloud.example/api"
|
||||||
|
api.the_folder = "customer-a/line-1"
|
||||||
|
requests = types.ModuleType("requests")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"data_collector_under_test", MODULE_PATH
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
with patch.dict(sys.modules, {"api": api, "requests": requests}):
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DATA_COLLECTOR = load_data_collector_module()
|
||||||
|
|
||||||
|
|
||||||
|
class ImmediateThread:
|
||||||
|
def __init__(self, target, daemon):
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.target()
|
||||||
|
|
||||||
|
|
||||||
|
class DataCollectorUploadTests(unittest.TestCase):
|
||||||
|
def test_uploads_control_manifest_with_complete_metadata(self):
|
||||||
|
collector = DATA_COLLECTOR.DataCollector()
|
||||||
|
collector.record_step(0, 10.0, 20.0, 30.0, 1.0, 0.2, 0.0, 50.0, 5.0)
|
||||||
|
collector.record_step(1, 11.0, 20.0, 31.0, 1.0, 0.2, 0.0, 50.0, 5.0)
|
||||||
|
|
||||||
|
uploads = []
|
||||||
|
completions = []
|
||||||
|
collector._upload_to_server = lambda data, name, folder: (
|
||||||
|
uploads.append((data, name, folder)) or True
|
||||||
|
)
|
||||||
|
collector.set_upload_complete_callback(
|
||||||
|
lambda success, manifest, error: completions.append(
|
||||||
|
(success, manifest, error)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
|
||||||
|
collector.finalize_and_upload(50.0, 5.0)
|
||||||
|
|
||||||
|
self.assertEqual(len(uploads), 2)
|
||||||
|
part_data, part_name, folder = uploads[0]
|
||||||
|
manifest_data, manifest_name, manifest_folder = uploads[1]
|
||||||
|
self.assertTrue(part_name.endswith(".pkl"))
|
||||||
|
self.assertTrue(manifest_name.endswith("_manifest.json"))
|
||||||
|
self.assertEqual(folder, "customer-a/line-1/data_record/data_50.0SLM_5.0L")
|
||||||
|
self.assertEqual(manifest_folder, folder)
|
||||||
|
self.assertEqual(len(pickle.loads(part_data)), 1)
|
||||||
|
|
||||||
|
manifest = json.loads(manifest_data)
|
||||||
|
self.assertEqual(manifest["schema_version"], 1)
|
||||||
|
self.assertEqual(manifest["data_type"], "control_episode")
|
||||||
|
self.assertEqual(manifest["total_episodes"], 1)
|
||||||
|
self.assertEqual(manifest["uploaded_chunks"], 1)
|
||||||
|
self.assertEqual(manifest["part_files"], [part_name])
|
||||||
|
self.assertEqual(manifest["parts"][0]["file_name"], part_name)
|
||||||
|
self.assertEqual(completions, [(True, manifest, None)])
|
||||||
|
self.assertEqual(collector.episode_data_raw, [])
|
||||||
|
|
||||||
|
def test_does_not_create_an_empty_chunk_for_oversized_episode(self):
|
||||||
|
collector = DATA_COLLECTOR.DataCollector()
|
||||||
|
collector.episode_data_raw = [{"payload": "x" * (5 * 1024 * 1024)}]
|
||||||
|
uploads = []
|
||||||
|
collector._upload_to_server = lambda data, name, folder: (
|
||||||
|
uploads.append((data, name, folder)) or True
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
|
||||||
|
collector.finalize_and_upload(1.0, 1.0)
|
||||||
|
|
||||||
|
part_data = uploads[0][0]
|
||||||
|
self.assertEqual(len(pickle.loads(part_data)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -72,7 +72,7 @@ class InitialTravelScanTests(unittest.TestCase):
|
|||||||
captured.update(body=body, filename=filename, folder=folder)
|
captured.update(body=body, filename=filename, folder=folder)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
manager._upload_to_cos = capture_upload
|
manager._upload_to_server = capture_upload
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
|
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
|
||||||
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
|
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
|
||||||
@@ -104,7 +104,7 @@ class InitialTravelScanTests(unittest.TestCase):
|
|||||||
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
|
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
|
||||||
csv_filename = "identification_data_test.csv"
|
csv_filename = "identification_data_test.csv"
|
||||||
|
|
||||||
manager._upload_to_cos = lambda content, filename, folder: (
|
manager._upload_to_server = lambda content, filename, folder: (
|
||||||
uploaded.update(
|
uploaded.update(
|
||||||
content=content, filename=filename, folder=folder
|
content=content, filename=filename, folder=folder
|
||||||
) or True
|
) or True
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
1. 控制数据(ReinLoop 里的 core/data_collecter)和辨识数据一样,上传服务器后,需要服务器向panel提供删除下载查看功能。
|
||||||
|
2. 控制台许可证撤销界面点了没有反应,需要实现撤销和下载功能。
|
||||||
|
|
||||||
|
修改reinloop和panel,并给出服务器端需要实现的接口及功能。
|
||||||
|
|
||||||
|
## 服务端访问约定
|
||||||
|
|
||||||
|
不再调用微信云函数或云存储接口。ReinLoop 与 ControlPanel 统一访问云服务器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://ReinLoop.dominatedconvergence.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
- 所有业务接口均使用 `POST /api`,请求与响应均为 JSON。
|
||||||
|
- 通过请求体中的 `type` 字段区分业务功能。
|
||||||
|
- 管理端接口必须携带 `adminToken`,由服务端校验 `B_ADMIN_TOKEN`;ReinLoop 客户端上传控制数据时不携带管理令牌。
|
||||||
|
- 所有响应必须包含 `success: true|false`;失败时必须提供可展示的 `errMsg`。
|
||||||
|
- 设备标识 `deviceId` 固定为 `<company-code>/<line-code>`,服务端必须校验其格式,禁止路径遍历。
|
||||||
|
- 服务端需要设置 `PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com`,确保上传地址和下载地址均为可从客户端访问的 HTTPS URL。
|
||||||
|
|
||||||
|
### 通用文件上传接口:`uploadDataFile`
|
||||||
|
|
||||||
|
ReinLoop 的控制数据、辨识数据及配置文件均通过此两步协议上传:
|
||||||
|
|
||||||
|
1. 客户端调用业务接口申请一次性上传地址:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "uploadDataFile",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
|
||||||
|
"folder": "<deviceId>/data_record/data_50SLM_5L"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 服务端返回 `uploadMetadata.url` 后,客户端以 `multipart/form-data` 向该 URL 提交 `file` 字段。上传成功应返回 HTTP `204` 或 `200`。
|
||||||
|
|
||||||
|
服务端需要在上传完成时保存文件本体和 `fileRecords` 元数据(包括 `fileID`、`fileName`、`folder`、`uploadTime`、`size`)。控制数据目录必须以 `<deviceId>/data_record/` 为前缀。
|
||||||
|
|
||||||
|
## 服务器端接口需求(控制数据)
|
||||||
|
|
||||||
|
控制数据由 `ReinLoop/core/data_collector.py` 上传到
|
||||||
|
`<deviceId>/data_record/`,包括控制 Episode 的 `.pkl` 分片和对应的 JSON manifest。
|
||||||
|
以下接口均为管理端接口,要求请求体携带有效的 `adminToken`;响应统一包含
|
||||||
|
`success: true|false`,失败时返回 `errMsg`。
|
||||||
|
|
||||||
|
### `listControlFiles`
|
||||||
|
|
||||||
|
按设备分页查询控制数据文件,供 Panel 的“控制数据”列表使用。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "listControlFiles",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"deviceId": "<company-code>/<line-code>",
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
|
||||||
|
"uploadTime": "2026-07-30T04:00:00.000Z",
|
||||||
|
"size": 123456
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端只可返回指定 `deviceId` 的 `data_record` 目录及其子目录中的文件;按上传时间倒序排列,
|
||||||
|
`pageSize` 建议限制在 $1\dots100$。
|
||||||
|
|
||||||
|
### `getControlFileDownload`
|
||||||
|
|
||||||
|
按 `fileID` 获取控制数据原始文件的短期下载地址,供 Panel 查看 JSON manifest 或保存 `.pkl` / `.json` 文件。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "getControlFileDownload",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_manifest.json",
|
||||||
|
"uploadTime": "2026-07-30T04:00:00.000Z",
|
||||||
|
"size": 1024,
|
||||||
|
"url": "https://server.example/files/...?..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` 必须是绑定该文件且会过期的签名 URL,不能根据任意路径直接下载。服务端须校验文件存在,
|
||||||
|
且该文件必须属于控制数据目录。
|
||||||
|
|
||||||
|
### `deleteControlFile`
|
||||||
|
|
||||||
|
永久删除指定控制数据文件,供 Panel 的二次确认删除操作使用。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "deleteControlFile",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"deletedCount": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端须同时删除文件本体及 `fileRecords` 中的元数据;必须校验 `fileID` 属于控制数据目录,
|
||||||
|
禁止借此接口删除模型、辨识数据、配置或许可证相关文件。文件不存在时返回明确错误,不应将删除操作视为成功。
|
||||||
|
|
||||||
|
## 许可证接口补充
|
||||||
|
|
||||||
|
### `getLicense`
|
||||||
|
|
||||||
|
Panel 的许可证“下载”复用既有 `getLicense` 接口。请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "getLicense",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"licenseId": "<license-uuid>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应中的 `license` 对象必须包含原始许可证文本字段 `license`;Panel 将该字段保存为 `.lic` 文件。
|
||||||
|
服务端不得将私钥或其他许可证的内容一并返回。
|
||||||
|
|
||||||
|
### `revokeLicense`
|
||||||
|
|
||||||
|
Panel 的许可证撤销使用既有 `revokeLicense` 接口:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "revokeLicense",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"licenseId": "<license-uuid>",
|
||||||
|
"reason": "管理员撤销原因"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应应返回 `success: true` 及更新后的许可证对象,其中 `status` 为 `revoked`。服务端必须保留
|
||||||
|
`revokedAt` 与 `revocationReason` 审计信息;许可证在线校验接口 `validateLicense` 随后应返回
|
||||||
|
`valid: false`、`status: "revoked"`,使 ReinLoop 客户端在下一次许可证巡检时生效。
|
||||||
|
|
||||||
|
### Panel 对应功能
|
||||||
|
|
||||||
|
- “控制数据”页面:调用 `listControlFiles` 刷新列表;JSON manifest 可请求下载后直接预览,`.pkl` 仅提供下载;删除前需二次确认。
|
||||||
|
- “许可证”页面:调用 `getLicense` 下载 `.lic`;调用 `revokeLicense` 撤销,并在成功后刷新许可证列表。
|
||||||
|
- Panel 不得自行拼接服务器文件路径、下载 URL 或绕过上述 Admin 接口访问文件。
|
||||||
Reference in New Issue
Block a user