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 ``; }).join(""); elements.companySelect.innerHTML = `${companyOptions}`; elements.lineCompany.innerHTML = `${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 = `${lines.map((line) => `` ).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) => ` ${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)} ${escapeHtml(license.expiry)}${license.status === "active" ? "有效" : "已撤销"} ${license.status === "active" ? `` : ""} `).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) => ` ${escapeHtml(model.fileName)}${escapeHtml(model.originalFileName || model.fileName)}${escapeHtml(model.uploadTime || "-")} ${formatSize(model.size)} `).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) => ` ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} ${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}${formatSize(file.size)} ${file.status === "processed" ? "已处理" : "待处理"} `).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) => ` ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} ${escapeHtml(controlFileType(file.fileName))}${formatSize(file.size)} `).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 }) { 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 }; }); } elements.actionDialogForm.addEventListener("submit", (event) => { event.preventDefault(); const value = elements.actionDialogInput.value.trim(); const expectedValue = actionDialogState?.expectedValue; if (!value) { elements.actionDialogError.textContent = "文件名不能为空"; elements.actionDialogError.hidden = false; elements.actionDialogInput.focus(); return; } if (expectedValue !== null && value !== expectedValue) { elements.actionDialogError.textContent = "文件名不匹配,请输入完整文件名"; 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 `
`; }).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 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 `
${escapeHtml(notification.title)}${escapeHtml(notification.message)}
${secondaryAction ? `` : ""}
`; }).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(); });