Files
ReinLoopTest/ControlPanel/electron-ui/renderer.js
T
2026-08-03 16:35:23 +08:00

1215 lines
49 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"),
actionDialog: document.querySelector("#action-dialog"),
actionDialogForm: document.querySelector("#action-dialog-form"),
actionDialogTitle: document.querySelector("#action-dialog-title"),
actionDialogMessage: document.querySelector("#action-dialog-message"),
actionDialogInput: document.querySelector("#action-dialog-input"),
actionDialogError: document.querySelector("#action-dialog-error"),
actionDialogCancel: document.querySelector("#action-dialog-cancel"),
actionDialogConfirm: document.querySelector("#action-dialog-confirm"),
lightbox: document.querySelector("#image-lightbox"),
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>` : `<button class="table-action danger" data-license-delete="${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";
}
let actionDialogState = null;
function closeActionDialog(value) {
if (!actionDialogState) return;
const { resolve, previousFocus } = actionDialogState;
actionDialogState = null;
elements.actionDialog.hidden = true;
elements.actionDialogForm.reset();
elements.actionDialogError.hidden = true;
previousFocus?.focus();
resolve(value);
}
function requestModelName({
title,
message,
value = "",
confirmLabel = "确认",
danger = false,
expectedValue = null,
expectedLabel = "输入内容",
ignoreCase = false
}) {
if (actionDialogState) closeActionDialog(null);
elements.actionDialogTitle.textContent = title;
elements.actionDialogMessage.textContent = message;
elements.actionDialogInput.value = value;
elements.actionDialogConfirm.textContent = confirmLabel;
elements.actionDialogConfirm.classList.toggle("primary", !danger);
elements.actionDialogConfirm.classList.toggle("danger", danger);
elements.actionDialogError.hidden = true;
elements.actionDialog.hidden = false;
const previousFocus = document.activeElement;
window.requestAnimationFrame(() => {
elements.actionDialogInput.focus();
elements.actionDialogInput.select();
});
return new Promise((resolve) => {
actionDialogState = {
resolve,
previousFocus,
expectedValue,
expectedLabel,
ignoreCase
};
});
}
elements.actionDialogForm.addEventListener("submit", (event) => {
event.preventDefault();
const value = elements.actionDialogInput.value.trim();
const expectedValue = actionDialogState?.expectedValue;
const expectedLabel = actionDialogState?.expectedLabel || "输入内容";
const ignoreCase = actionDialogState?.ignoreCase === true;
if (!value) {
elements.actionDialogError.textContent = `${expectedLabel}不能为空`;
elements.actionDialogError.hidden = false;
elements.actionDialogInput.focus();
return;
}
if (expectedValue !== null) {
const expected = String(expectedValue).trim();
const matched = ignoreCase
? value.toLowerCase() === expected.toLowerCase()
: value === expected;
if (!matched) {
elements.actionDialogError.textContent = `${expectedLabel}不匹配,请按提示完整输入`;
elements.actionDialogError.hidden = false;
elements.actionDialogInput.focus();
return;
}
}
closeActionDialog(value);
});
elements.actionDialogCancel.addEventListener("click", () => closeActionDialog(null));
elements.actionDialog.addEventListener("click", (event) => {
if (event.target === elements.actionDialog) closeActionDialog(null);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !elements.actionDialog.hidden) closeActionDialog(null);
});
function showImage(result) {
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("#delete-line").addEventListener("click", async () => {
const company = selectedCompany();
const line = selectedLine();
if (!company || !line) return showError(new Error("请先选择公司和产线"));
const confirmation = await requestModelName({
title: "确认删除产线",
message: `删除产线 ${line.name} 后将删除该产线关联的模型、辨识数据、容积配置、通知与许可证记录(已撤销)。请输入完整 deviceId 以确认。`,
value: "",
confirmLabel: "删除产线",
danger: true,
expectedValue: line.deviceId,
expectedLabel: "deviceId",
ignoreCase: true
});
if (confirmation === null) return;
const result = await runBusy("正在删除产线", () => window.reinloop.deleteProductionLine({
companyId: company.id,
productionLineId: line.id,
credentials: credentials()
}));
if (!result) return;
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
await refreshOrganizations();
await refreshLicenses();
setStatus("产线已删除", "success");
});
document.querySelector("#delete-company").addEventListener("click", async () => {
const company = selectedCompany();
if (!company) return showError(new Error("请先选择公司"));
const confirmation = await requestModelName({
title: "确认删除公司",
message: `删除公司 ${company.name} 会级联删除其下所有产线及关联模型、辨识数据、容积配置、通知与许可证记录(有效许可证会阻止删除)。请输入公司编码以确认。`,
value: "",
confirmLabel: "删除公司",
danger: true,
expectedValue: company.code,
expectedLabel: "公司编码",
ignoreCase: true
});
if (confirmation === null) return;
const result = await runBusy("正在删除公司", () => window.reinloop.deleteCompany({
companyId: company.id,
credentials: credentials()
}));
if (!result) return;
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
await refreshOrganizations();
await refreshLicenses();
setStatus("公司已删除", "success");
});
document.querySelector("#license-form").addEventListener("submit", async (event) => {
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;
const deleteId = button.dataset.licenseDelete;
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 (deleteId) {
const confirmation = await requestModelName({
title: "确认删除许可证",
message: `已撤销许可证才能删除。请输入许可证 ID 以确认永久删除。\n\n当前许可证 ID:${deleteId}`,
value: "",
confirmLabel: "永久删除",
danger: true,
expectedValue: deleteId,
expectedLabel: "许可证ID",
ignoreCase: true
});
if (confirmation === null) return;
button.disabled = true;
try {
const result = await runBusy("正在删除许可证", () => window.reinloop.deleteLicense({
licenseId: deleteId,
credentials: credentials()
}));
if (result) {
await refreshLicenses();
elements.licenseDetail.hidden = true;
elements.licenseDetail.textContent = "";
setStatus("许可证已删除", "success");
}
} finally {
button.disabled = false;
}
return;
}
if (revokeId) {
const reason = await requestModelName({
title: "确认撤销许可证",
message: "请输入撤销原因。确认后将立即撤销该许可证。",
value: "管理员撤销",
confirmLabel: "确认撤销",
danger: true
});
if (reason === null) return;
const trimmedReason = reason.trim() || "管理员撤销";
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 modelName = await requestModelName({
title: "设置服务器文件名",
message: `已选择 ${selected.fileName}。可在上传前修改模型在服务器上的文件名。`,
value: selected.fileName,
confirmLabel: "继续上传"
});
if (modelName === null) return;
const existing = state.models.find((model) => model.fileName === modelName);
let overwrite = false;
if (existing) {
const confirmation = await requestModelName({
title: "确认覆盖模型",
message: `服务器已存在 ${modelName},覆盖后无法恢复。请输入完整文件名以确认。`,
confirmLabel: "覆盖并上传",
danger: true,
expectedValue: modelName
});
if (confirmation === null) 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 button = event.target.closest("button");
if (!button || !elements.modelList.contains(button)) return;
const fileID = button.dataset.modelDownload || button.dataset.modelDelete;
if (!fileID) return;
const model = state.models.find((item) => item.fileID === fileID);
if (!model) return showError(new Error("模型记录已变化,请刷新列表后重试"));
if (button.dataset.modelDownload) {
const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() }));
if (result) setStatus(`模型已下载: ${result.filePath}`, "success");
} else {
const confirmation = await requestModelName({
title: "确认删除模型",
message: `删除 ${model.fileName} 后无法恢复。请输入完整文件名以确认。`,
confirmLabel: "永久删除",
danger: true,
expectedValue: model.fileName
});
if (confirmation === null) return;
button.disabled = true;
try {
const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() }));
if (result) {
await refreshModels();
setStatus("模型已删除", "success");
}
} finally {
button.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) {
setStatus("正在确认提醒", "busy");
try {
await window.reinloop.acknowledgeNotification({
deviceId: notification.deviceId,
notificationId: notification.notificationId,
credentials: credentials()
});
} catch (error) {
const message = String(error?.message || error)
.replace(/^Error invoking remote method '[^']+': Error: /, "");
if (!message.includes("Panel 通知不存在")) {
showError(error);
return false;
}
}
state.notifications = state.notifications.filter((item) => item.notificationId !== notification.notificationId);
renderNotifications();
setStatus("提醒已关闭", "success");
return true;
}
async function handleNotificationAction(notification, action) {
if (action === "close") {
await acknowledgeNotification(notification);
return;
}
if (notification.type === "volume_request_started") {
activateTab("config-panel");
activateConfigType("volume");
elements.configForm.querySelector("input")?.focus();
return;
}
if (notification.type === "volume_result_ready") {
const result = await runBusy("正在打开容积测试结果", () => window.reinloop.openNotificationFile({
fileID: notification.fileID,
credentials: credentials()
}));
if (result) setStatus(`已打开结果文件:${result.filePath}`, "success");
return;
}
if (action === "primary") {
activateTab("plot-panel");
return;
}
activateTab("identification-data-panel");
await refreshIdentificationFiles();
}
elements.configNotifications.addEventListener("click", (event) => {
const button = event.target.closest("button[data-notification-id]");
if (!button) return;
const notification = state.notifications.find((item) => item.notificationId === button.dataset.notificationId);
if (notification) void handleNotificationAction(notification, button.dataset.notificationAction);
});
window.reinloop.onPanelNotification((notification) => {
if (!notification?.notificationId || notification.deviceId !== elements.deviceId.value) return;
state.notificationDeviceId = notification.deviceId;
if (state.notifications.some((item) => item.notificationId === notification.notificationId)) return;
state.notifications.push(notification);
renderNotifications();
});