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 state = { configType: "volume", imagePaths: { csv: null, json: null }, imageDataUrls: { csv: null, json: null }, identificationFiles: [], configs: { volume: volumeExample, identification: identificationExample }, companies: [], models: [], defaultDeviceId: "", review: null, retryDeviceId: null, lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null }, 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"]') } }, configEditor: document.querySelector("#config-editor"), 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"), 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"), 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 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(); 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.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"); } 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) { try { state.configs[state.configType] = JSON.parse(elements.configEditor.value); } catch (_error) { // Keep the last valid configuration when changing views. } state.configType = configType; document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType)); renderConfig(); } 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"; elements.configEditor.value = JSON.stringify(state.configs[state.configType], null, 2); 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 detailId = event.target.dataset.licenseDetail; const revokeId = event.target.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; } } if (revokeId) { const reason = window.prompt("请输入撤销原因", "管理员撤销"); if (reason === null) return; const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ licenseId: revokeId, reason, credentials: credentials() })); if (result) await refreshLicenses(); } }); 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 existing = state.models.find((model) => model.fileName === selected.fileName); let overwrite = false; if (existing) { if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return; const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`); if (confirmation !== selected.fileName) { setStatus("文件名不匹配,已取消覆盖", "idle"); return; } overwrite = true; } const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({ deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName, 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; state.configs[state.configType] = result.parameters; elements.configEditor.value = JSON.stringify(result.parameters, null, 2); 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; state.configs[state.configType] = parameters; elements.configEditor.value = JSON.stringify(parameters, null, 2); elements.publishResult.textContent = "已读取当前 Server 配置"; elements.publishResult.dataset.tone = "success"; setStatus("读取完成", "success"); }); elements.publishButton.addEventListener("click", async () => { let parameters; try { parameters = JSON.parse(elements.configEditor.value); } catch (error) { showError(new Error(`JSON 格式错误: ${error.message}`)); 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 === "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); });