Files
ReinLoopTest/ControlPanel/electron-ui/renderer.js
T
2026-07-31 11:15:54 +08:00

1013 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const volumeExample = {
q_in_val: 91,
dt: 0.1,
p_max: 200,
fit_low: 50,
fit_high: 200,
T_delta: 30,
xa_full: 1000,
num_runs: 6
};
const identificationExample = {
q_in_val: 91,
dt: 0.1,
n_order: 8,
t_c: 2.5,
levels: [10, 20, 30, 40, 50, 60, 70, 80],
dead_area: 0,
xa_full: 1000,
V_val: 1,
repeat: 2
};
const configFields = {
volume: Object.keys(volumeExample),
identification: Object.keys(identificationExample)
};
const state = {
configType: "volume",
imagePaths: { csv: null, json: null },
imageDataUrls: { csv: null, json: null },
identificationFiles: [],
controlFiles: [],
configs: { volume: volumeExample, identification: identificationExample },
companies: [],
models: [],
defaultDeviceId: "",
review: null,
retryDeviceId: null,
notificationDeviceId: null,
lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null },
notifications: [],
connected: false
};
const elements = {
status: document.querySelector("#status"),
connectionScreen: document.querySelector("#connection-screen"),
connectionForm: document.querySelector("#connection-form"),
connectionMessage: document.querySelector("#connection-message"),
connectButton: document.querySelector("#connect-button"),
workspace: document.querySelector("#workspace"),
apiUrl: document.querySelector("#api-url"),
adminToken: document.querySelector("#admin-token"),
companySelect: document.querySelector("#company-select"),
deviceId: document.querySelector("#device-id"),
plots: {
csv: {
image: document.querySelector("#csv-plot-image"),
empty: document.querySelector("#empty-csv-plot"),
path: document.querySelector("#csv-image-path"),
uploadTime: document.querySelector("#csv-upload-time"),
showImage: document.querySelector("#show-csv-image"),
openImage: document.querySelector('[data-open-plot="csv"]')
},
json: {
image: document.querySelector("#json-plot-image"),
empty: document.querySelector("#empty-json-plot"),
path: document.querySelector("#json-image-path"),
uploadTime: document.querySelector("#json-upload-time"),
showImage: document.querySelector("#show-json-image"),
openImage: document.querySelector('[data-open-plot="json"]')
}
},
configForm: document.querySelector("#config-form"),
configLabel: document.querySelector("#config-label"),
configPath: document.querySelector("#config-path"),
publishTarget: document.querySelector("#publish-target"),
publishButton: document.querySelector("#publish-config"),
publishResult: document.querySelector("#publish-result"),
configNotifications: document.querySelector("#config-notifications"),
configNotificationBadge: document.querySelector("#config-notification-badge"),
lineCompany: document.querySelector("#line-company"),
licenseTarget: document.querySelector("#license-target"),
licenseList: document.querySelector("#license-list"),
licenseEmpty: document.querySelector("#license-empty"),
licenseDetail: document.querySelector("#license-detail"),
modelList: document.querySelector("#model-list"),
modelEmpty: document.querySelector("#model-empty"),
identificationFileList: document.querySelector("#identification-file-list"),
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"),
approveReview: document.querySelector("#approve-review"),
rejectReview: document.querySelector("#reject-review"),
lightbox: document.querySelector("#image-lightbox"),
lightboxTitle: document.querySelector("#lightbox-title"),
lightboxImage: document.querySelector("#lightbox-image"),
lightboxCanvas: document.querySelector("#lightbox-canvas"),
zoomIn: document.querySelector("#zoom-in"),
zoomOut: document.querySelector("#zoom-out"),
zoomFit: document.querySelector("#zoom-fit"),
zoomReset: document.querySelector("#zoom-reset"),
closeLightbox: document.querySelector("#close-lightbox")
};
function setStatus(message, tone = "idle") {
elements.status.textContent = message;
elements.status.dataset.tone = tone;
}
function credentials() {
return {
apiUrl: elements.apiUrl.value.trim(),
adminToken: elements.adminToken.value,
deviceId: elements.deviceId.value.trim()
};
}
function selectedCompany() {
return state.companies.find((company) => company.id === elements.companySelect.value);
}
function selectedLine() {
const company = selectedCompany();
return company?.productionLines.find((line) => line.deviceId === elements.deviceId.value);
}
function formatLicenseDate(value) {
return value.replace("T", " ");
}
function formatSize(value) {
if (!Number.isFinite(value)) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
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) {
const node = document.createElement("span");
node.textContent = String(value ?? "");
return node.innerHTML;
}
async function syncConnection() {
await window.reinloop.setConnection(credentials());
}
function showConnectionError(error) {
const message = (error?.message || String(error))
.replace(/^Error invoking remote method '[^']+': Error: /, "");
elements.connectionMessage.textContent = message;
elements.connectionMessage.dataset.tone = "error";
setStatus("连接失败", "error");
}
async function connectWorkspace() {
const apiUrl = elements.apiUrl.value.trim();
if (!apiUrl) return showConnectionError(new Error("请输入 Server API URL"));
elements.connectButton.disabled = true;
elements.connectionMessage.textContent = "正在校验 Server 与 Admin Token";
elements.connectionMessage.dataset.tone = "busy";
setStatus("正在连接", "busy");
try {
const result = await window.reinloop.testConnection({
apiUrl,
adminToken: elements.adminToken.value
});
state.companies = result.companies;
renderOrganizationOptions();
elements.connectionScreen.hidden = true;
elements.workspace.hidden = false;
state.connected = true;
delete elements.connectionMessage.dataset.tone;
setStatus("连接成功", "success");
} catch (error) {
showConnectionError(error);
} finally {
elements.connectButton.disabled = false;
}
}
function renderOrganizationOptions() {
const previousCompany = elements.companySelect.value;
const companyOptions = state.companies.map((company) => {
const lines = company.productionLines || [];
const onlineCount = lines.filter((line) => line.online === true).length;
const hasKnownStatus = lines.some((line) => typeof line.online === "boolean");
const statusSummary = !lines.length
? " · 暂无产线"
: hasKnownStatus
? `${onlineCount > 0 ? " · ● 在线" : " · ○ 离线"} (${onlineCount}/${lines.length})`
: " · ◇ 状态未知";
return `<option value="${escapeHtml(company.id)}">${escapeHtml(company.name)} (${escapeHtml(company.code)})${escapeHtml(statusSummary)}</option>`;
}).join("");
elements.companySelect.innerHTML = `<option value="">请选择公司</option>${companyOptions}`;
elements.lineCompany.innerHTML = `<option value="">请选择公司</option>${companyOptions}`;
if (state.companies.some((company) => company.id === previousCompany)) {
elements.companySelect.value = previousCompany;
} else if (state.defaultDeviceId) {
const defaultCompany = state.companies.find((company) =>
company.productionLines.some((line) => line.deviceId === state.defaultDeviceId));
if (defaultCompany) elements.companySelect.value = defaultCompany.id;
}
renderLineOptions();
}
function renderLineOptions() {
const company = selectedCompany();
const previousDevice = elements.deviceId.value;
const lines = company?.productionLines || [];
elements.deviceId.innerHTML = `<option value="">请选择产线</option>${lines.map((line) =>
`<option value="${escapeHtml(line.deviceId)}">${escapeHtml(line.online === true ? "● 在线" : line.online === false ? "○ 离线" : "◇ 状态未知")} · ${escapeHtml(line.name)} (${escapeHtml(line.code)})</option>`
).join("")}`;
if (lines.some((line) => line.deviceId === previousDevice)) {
elements.deviceId.value = previousDevice;
} else if (lines.some((line) => line.deviceId === state.defaultDeviceId)) {
elements.deviceId.value = state.defaultDeviceId;
}
updateSelectedTarget();
}
function updateSelectedTarget() {
const company = selectedCompany();
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}` : "";
void syncConnection();
}
async function refreshOrganizations(silent = false) {
if (silent) {
try {
const result = await window.reinloop.listOrganizations(credentials());
state.companies = result.companies;
renderOrganizationOptions();
} catch (_error) {
// Keep the last known status; explicit operations still report errors.
}
return;
}
const result = await runBusy("正在读取组织", () => window.reinloop.listOrganizations(credentials()));
if (!result) return;
state.companies = result.companies;
renderOrganizationOptions();
setStatus("组织已刷新", "success");
}
async function refreshLicenses() {
const result = await runBusy("正在读取许可证", () => window.reinloop.listLicenses(credentials()));
if (!result) return;
elements.licenseList.innerHTML = result.licenses.map((license) => `
<tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</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>
`).join("");
elements.licenseEmpty.hidden = result.licenses.length > 0;
setStatus("许可证已刷新", "success");
}
async function refreshModels() {
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
const result = await runBusy("正在读取模型", () => window.reinloop.listModels({
deviceId: elements.deviceId.value, credentials: credentials()
}));
if (!result) return;
state.models = result.fileList;
elements.modelList.innerHTML = state.models.map((model) => `
<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>
`).join("");
elements.modelEmpty.hidden = state.models.length > 0;
setStatus("模型已刷新", "success");
}
async function refreshIdentificationFiles() {
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
const result = await runBusy("正在读取辨识暂存数据", () => window.reinloop.listIdentificationFiles({
deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials()
}));
if (!result) return;
state.identificationFiles = result.files || result.fileList || [];
elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => `
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
<td>${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}</td><td>${formatSize(file.size)}</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>
`).join("");
elements.identificationFileEmpty.hidden = state.identificationFiles.length > 0;
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) {
const message = (error?.message || String(error))
.replace(/^Error invoking remote method '[^']+': Error: /, "");
setStatus(message, "error");
elements.status.title = message;
elements.publishResult.textContent = message;
elements.publishResult.dataset.tone = "error";
}
function showImage(result) {
if (!result) return;
const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json")
? "json"
: "csv";
const plot = elements.plots[mediaType];
state.imagePaths[mediaType] = result.filePath;
state.imageDataUrls[mediaType] = result.dataUrl;
plot.image.src = result.dataUrl;
plot.image.hidden = false;
plot.empty.hidden = true;
plot.path.textContent = result.filePath;
if (result.uploadTime) {
const uploadedAt = new Date(result.uploadTime);
plot.uploadTime.textContent = `上传时间:${uploadedAt.toLocaleString("zh-CN", { hour12: false })}`;
} else {
plot.uploadTime.textContent = "已接收";
}
plot.showImage.disabled = false;
plot.openImage.disabled = false;
if (mediaType === "csv" && result.reviewable) {
state.review = { runId: result.fileName, deviceId: result.deviceId };
elements.reviewTarget.textContent = result.fileName;
elements.approveReview.disabled = false;
elements.rejectReview.disabled = false;
}
setStatus("图片已载入", "success");
}
function updateLightboxImage() {
const image = elements.lightboxImage;
if (state.lightbox.fit) {
image.classList.add("fit-image");
image.style.width = "";
return;
}
image.classList.remove("fit-image");
if (image.complete && image.naturalWidth) image.style.width = `${Math.round(image.naturalWidth * state.lightbox.scale)}px`;
}
function openLightbox(mediaType) {
const source = state.imageDataUrls[mediaType];
if (!source) return;
state.lightbox.mediaType = mediaType;
state.lightbox.scale = 1;
state.lightbox.fit = true;
state.lightbox.previousFocus = document.activeElement;
elements.lightboxTitle.textContent = mediaType === "json" ? "行程稳态压力图" : "辨识 CSV 图";
elements.lightboxImage.src = source;
elements.lightboxImage.onload = updateLightboxImage;
elements.lightbox.hidden = false;
elements.closeLightbox.focus();
}
function closeLightbox() {
if (elements.lightbox.hidden) return;
elements.lightbox.hidden = true;
elements.lightboxImage.removeAttribute("src");
state.lightbox.previousFocus?.focus();
state.lightbox.previousFocus = null;
}
function zoomLightbox(direction) {
state.lightbox.fit = false;
state.lightbox.scale = Math.min(4, Math.max(0.25, state.lightbox.scale * direction));
updateLightboxImage();
}
function finishReview(result) {
state.review = null;
elements.reviewTarget.textContent = result === 1 ? "已提交:通过" : "已提交:未通过";
elements.approveReview.disabled = true;
elements.rejectReview.disabled = true;
}
function activateTab(target) {
document.querySelectorAll(".tab").forEach((item) => item.classList.toggle("active", item.dataset.target === target));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === target));
}
function activateConfigType(configType) {
syncCurrentConfig();
state.configType = configType;
document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType));
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() {
if (!state.review) return;
state.retryDeviceId = state.review.deviceId;
activateTab("config-panel");
activateConfigType("identification");
elements.publishResult.textContent = `本轮辨识未通过。请检查配置后发布到设备 ${state.retryDeviceId},发布成功后将自动要求重测。`;
elements.publishResult.dataset.tone = "error";
setStatus("请重新发布辨识配置", "busy");
}
async function submitReview(result) {
if (!state.review) return;
if (result === 0) return switchToIdentificationConfigForRetry();
const review = state.review;
const submitted = await runBusy("正在提交辨识结论", () => window.reinloop.submitReview({
deviceId: review.deviceId,
runId: review.runId,
result,
credentials: credentials()
}));
if (submitted) {
finishReview(result);
state.retryDeviceId = null;
setStatus("辨识结果已通过", "success");
}
}
async function submitRetryReview() {
if (!state.review || !state.retryDeviceId) return;
const review = state.review;
const submitted = await runBusy("正在提交未通过结论", () => window.reinloop.submitReview({
deviceId: review.deviceId, runId: review.runId, result: 0, credentials: credentials()
}));
if (!submitted) return;
finishReview(0);
state.retryDeviceId = null;
setStatus("新配置已发布,已要求设备重测", "success");
}
function renderConfig() {
const isVolume = state.configType === "volume";
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.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置";
elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置";
elements.configPath.textContent = "字段名称固定;可填写右侧数据或从本地 JSON 导入";
elements.publishResult.textContent = "等待操作";
delete elements.publishResult.dataset.tone;
}
async function runBusy(label, action) {
setStatus(label, "busy");
try {
return await action();
} catch (error) {
showError(error);
return null;
}
}
document.querySelectorAll(".tab").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.target)));
document.querySelectorAll(".segment").forEach((button) => button.addEventListener("click", () => activateConfigType(button.dataset.type)));
Object.entries(elements.plots).forEach(([mediaType, plot]) => {
plot.showImage.addEventListener("click", () => window.reinloop.showInFolder(state.imagePaths[mediaType]));
plot.openImage.addEventListener("click", () => openLightbox(mediaType));
});
elements.approveReview.addEventListener("click", () => void submitReview(1));
elements.rejectReview.addEventListener("click", () => void submitReview(0));
elements.companySelect.addEventListener("change", renderLineOptions);
elements.deviceId.addEventListener("change", updateSelectedTarget);
elements.connectionForm.addEventListener("submit", (event) => {
event.preventDefault();
void connectWorkspace();
});
document.querySelector("#refresh-organizations").addEventListener("click", refreshOrganizations);
document.querySelector("#company-form").addEventListener("submit", async (event) => {
event.preventDefault();
const result = await runBusy("正在添加公司", () => window.reinloop.createCompany({
name: document.querySelector("#company-name").value,
code: document.querySelector("#company-code").value,
credentials: credentials()
}));
if (!result) return;
event.target.reset();
await refreshOrganizations();
});
document.querySelector("#line-form").addEventListener("submit", async (event) => {
event.preventDefault();
const result = await runBusy("正在添加产线", () => window.reinloop.createProductionLine({
companyId: elements.lineCompany.value,
name: document.querySelector("#line-name").value,
code: document.querySelector("#line-code").value,
credentials: credentials()
}));
if (!result) return;
event.target.reset();
await refreshOrganizations();
});
document.querySelector("#license-form").addEventListener("submit", async (event) => {
event.preventDefault();
const company = selectedCompany();
const line = selectedLine();
if (!company || !line) return showError(new Error("请先选择公司和产线"));
const result = await runBusy("正在签发许可证", () => window.reinloop.issueLicense({
companyId: company.id, companyCode: company.code, customer: company.name,
productionLineId: line.id, lineCode: line.code, deviceId: line.deviceId,
issued: formatLicenseDate(document.querySelector("#license-issued").value),
expiry: formatLicenseDate(document.querySelector("#license-expiry").value),
features: document.querySelector("#license-features").value,
credentials: credentials()
}));
if (!result) return;
setStatus(`许可证已保存: ${result.filePath}`, "success");
await refreshLicenses();
});
document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses);
elements.licenseList.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
const detailId = button.dataset.licenseDetail;
const downloadId = button.dataset.licenseDownload;
const revokeId = button.dataset.licenseRevoke;
if (detailId) {
const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() }));
if (result) {
elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2);
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) {
const reason = window.prompt("请输入撤销原因", "管理员撤销");
if (reason === null) return;
const trimmedReason = reason.trim() || "管理员撤销";
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;
}
}
});
document.querySelector("#refresh-models").addEventListener("click", refreshModels);
document.querySelector("#upload-model").addEventListener("click", async () => {
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
const button = document.querySelector("#upload-model");
button.disabled = true;
try {
const models = await runBusy("正在读取模型", () => window.reinloop.listModels({
deviceId: elements.deviceId.value, credentials: credentials()
}));
if (!models) return;
state.models = models.fileList;
const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile());
if (!selected) return;
const enteredModelName = window.prompt("请输入模型在服务器上的文件名:", selected.fileName);
if (enteredModelName === null) return;
const modelName = enteredModelName.trim();
if (!modelName) return showError(new Error("服务器文件名不能为空"));
const existing = state.models.find((model) => model.fileName === modelName);
let overwrite = false;
if (existing) {
if (!window.confirm(`服务器已存在模型 ${modelName},覆盖后无法恢复。是否继续?`)) return;
const confirmation = window.prompt(`请输入服务器文件名以确认覆盖:${modelName}`);
if (confirmation !== modelName) {
setStatus("文件名不匹配,已取消覆盖", "idle");
return;
}
overwrite = true;
}
const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({
deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName,
modelName, overwrite, credentials: credentials()
}));
if (result) {
setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success");
await refreshModels();
}
} finally {
button.disabled = false;
}
});
elements.modelList.addEventListener("click", async (event) => {
const fileID = event.target.dataset.modelDownload || event.target.dataset.modelDelete;
if (!fileID) return;
const model = state.models.find((item) => item.fileID === fileID);
if (event.target.dataset.modelDownload) {
const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() }));
if (result) setStatus(`模型已下载: ${result.filePath}`, "success");
} else {
if (!window.confirm(`确认删除模型 ${model.fileName}`)) return;
const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`);
if (confirmation !== model.fileName) {
setStatus("文件名不匹配,已取消删除", "idle");
return;
}
event.target.disabled = true;
try {
const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() }));
if (result) await refreshModels();
} finally {
event.target.disabled = false;
}
}
});
document.querySelector("#refresh-identification-files").addEventListener("click", refreshIdentificationFiles);
elements.identificationFileList.addEventListener("click", async (event) => {
const fileID = event.target.dataset.identificationPreview || event.target.dataset.identificationDownload || event.target.dataset.identificationDelete;
if (!fileID) return;
const file = state.identificationFiles.find((item) => item.fileID === fileID);
if (!file) return;
if (event.target.dataset.identificationPreview) {
const result = await runBusy("正在下载并生成预览", () => window.reinloop.previewIdentificationFile({
...file, deviceId: elements.deviceId.value, credentials: credentials()
}));
if (result) {
showImage(result);
activateTab("plot-panel");
}
} else if (event.target.dataset.identificationDownload) {
const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({
...file, credentials: credentials()
}));
if (result) setStatus(`已保存到: ${result.filePath}`, "success");
} else {
if (!window.confirm(`确认永久删除 ${file.fileName}`)) return;
if (!window.confirm("删除后无法恢复,确认继续?")) return;
event.target.disabled = true;
try {
const result = await runBusy("正在删除辨识数据", () => window.reinloop.deleteIdentificationFile({
fileID, credentials: credentials()
}));
if (result) await refreshIdentificationFiles();
} finally {
event.target.disabled = false;
}
}
});
document.querySelector("#import-config").addEventListener("click", async () => {
const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig());
if (!result) return;
try {
state.configs[state.configType] = normalizeImportedConfig(result.parameters);
renderConfig();
} catch (error) {
showError(error);
return;
}
elements.configPath.textContent = result.filePath;
setStatus("配置已导入", "success");
});
document.querySelector("#load-server").addEventListener("click", async () => {
const parameters = await runBusy("正在读取 Server 配置", () => window.reinloop.getConfig({
configType: state.configType,
credentials: credentials()
}));
if (!parameters) return;
try {
state.configs[state.configType] = normalizeImportedConfig(parameters);
renderConfig();
} catch (error) {
showError(new Error(`Server 配置无效:${error.message}`));
return;
}
elements.publishResult.textContent = "已读取当前 Server 配置";
elements.publishResult.dataset.tone = "success";
setStatus("读取完成", "success");
});
elements.publishButton.addEventListener("click", async () => {
let parameters;
try {
parameters = readConfigForm({ showErrors: true });
} catch (error) {
showError(error);
return;
}
const result = await runBusy("正在发布配置", () => window.reinloop.publishConfig({
configType: state.configType,
parameters,
credentials: {
...credentials(),
deviceId: state.configType === "identification" && state.retryDeviceId
? state.retryDeviceId
: credentials().deviceId
}
}));
if (!result) return;
state.configs[state.configType] = parameters;
elements.publishResult.textContent = result.storagePath
? `${result.message}: ${result.storagePath}`
: result.message;
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) {
await submitRetryReview();
} else {
setStatus("发布完成", "success");
}
});
window.reinloop.getDefaults().then((defaults) => {
elements.apiUrl.value = defaults.apiUrl;
state.defaultDeviceId = defaults.deviceId;
elements.adminToken.placeholder = defaults.hasAdminToken
? "已使用环境变量中的 Token"
: "请输入 Admin Token";
});
window.reinloop.onCsvUpdated((result) => {
showImage(result);
if (state.connected) void refreshIdentificationFiles();
setStatus(`已接收 ${result.fileName}`, "success");
});
window.reinloop.onCsvWatchError((message) => {
setStatus("数据接收异常", "error");
const pendingPlot = Object.values(elements.plots).find((plot) => plot.image.hidden);
if (pendingPlot) pendingPlot.path.textContent = message;
});
setInterval(() => {
if (state.connected) void refreshOrganizations(true);
}, 10000);
renderConfig();
elements.closeLightbox.addEventListener("click", closeLightbox);
elements.zoomIn.addEventListener("click", () => zoomLightbox(1.25));
elements.zoomOut.addEventListener("click", () => zoomLightbox(0.8));
elements.zoomFit.addEventListener("click", () => {
state.lightbox.fit = true;
updateLightboxImage();
});
elements.zoomReset.addEventListener("click", () => {
state.lightbox.fit = false;
state.lightbox.scale = 1;
updateLightboxImage();
});
elements.lightbox.addEventListener("click", (event) => {
if (event.target === elements.lightbox) closeLightbox();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeLightbox();
if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25);
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;
}
}
});
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" ? "查看辨识数据" : "";
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></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) {
const result = await runBusy("正在确认提醒", () => window.reinloop.acknowledgeNotification({
deviceId: notification.deviceId,
notificationId: notification.notificationId,
credentials: credentials()
}));
if (!result) return false;
state.notifications = state.notifications.filter((item) => item.notificationId !== notification.notificationId);
renderNotifications();
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();
});