const workspaces = new Map(); const PERSISTENCE_POLICIES = new Set(["none", "local", "server"]); let appStale = false; export const DEFAULT_API_TIMEOUT_MS = 15000; export const DEFAULT_WORKSPACE_ID = "device"; export function isAppStale() { return appStale; } export function markAppStale() { if (appStale) return; appStale = true; document.getElementById("app-version-notice")?.removeAttribute("hidden"); announce("控制程序已更新,请刷新页面后再操作设备", true); document.dispatchEvent(new CustomEvent("matrix:app-stale")); } function ensureWriteAllowed(path, options) { const method = String(options.method || "GET").toUpperCase(); const isWrite = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; if (appStale && isWrite && !path.startsWith("/api/preview/")) { const error = new Error("页面版本已过期,请刷新后再提交此操作"); error.status = 409; error.code = "APP_STALE"; throw error; } } export function registerWorkspace(definition) { const required = ["id", "title", "elementId", "order", "persistence"]; for (const key of required) { if (definition[key] === undefined || definition[key] === null || definition[key] === "") { throw new Error(`工作区缺少 ${key}`); } } if (workspaces.has(definition.id)) { throw new Error(`工作区 ${definition.id} 重复注册`); } if (!PERSISTENCE_POLICIES.has(definition.persistence)) { throw new Error(`工作区 ${definition.id} 的持久化策略无效`); } const capabilities = definition.capabilities || []; if (!Array.isArray(capabilities) || capabilities.some((item) => typeof item !== "string")) { throw new Error(`工作区 ${definition.id} 的能力声明无效`); } workspaces.set(definition.id, Object.freeze({ ...definition, capabilities: Object.freeze([...new Set(capabilities)]), })); } export function getWorkspaces() { return Array.from(workspaces.values()).sort((left, right) => ( left.order - right.order || left.title.localeCompare(right.title, "zh-CN") )); } async function fetchWithTimeout(path, options) { const { timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options; const controller = new AbortController(); let timedOut = false; const forwardAbort = () => controller.abort(externalSignal?.reason); if (externalSignal?.aborted) forwardAbort(); else externalSignal?.addEventListener("abort", forwardAbort, { once: true }); const timer = window.setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); try { return await fetch(path, { ...fetchOptions, signal: controller.signal }); } catch (error) { if (timedOut) { const timeoutError = new Error("请求超时,请检查核桃派网络或服务状态后重试"); timeoutError.code = "REQUEST_TIMEOUT"; throw timeoutError; } throw error; } finally { window.clearTimeout(timer); externalSignal?.removeEventListener("abort", forwardAbort); } } export async function apiJson(path, options = {}) { ensureWriteAllowed(path, options); const response = await fetchWithTimeout(path, { ...options, cache: "no-store", headers: { "Content-Type": "application/json", ...(options.headers || {}) }, }); const data = await response.json(); if (!response.ok) { const error = new Error(data.detail || response.statusText); error.status = response.status; error.detail = data.detail; throw error; } return data; } export async function apiBlob(path, options = {}) { ensureWriteAllowed(path, options); const response = await fetchWithTimeout(path, { ...options, cache: "no-store", headers: { "Content-Type": "application/json", ...(options.headers || {}) }, }); if (!response.ok) { let message = response.statusText; try { const data = await response.json(); message = data.detail || message; } catch (_error) { // Keep the HTTP status text when the error body is not JSON. } const error = new Error(message); error.status = response.status; throw error; } return response.blob(); } export async function apiFile(path, file, options = {}) { ensureWriteAllowed(path, options); const response = await fetchWithTimeout(path, { ...options, method: options.method || "POST", body: file, cache: "no-store", headers: { "Content-Type": "application/octet-stream", ...(options.headers || {}) }, }); let data = null; try { data = await response.json(); } catch (_error) { data = null; } if (!response.ok) { const error = new Error(data?.detail || response.statusText); error.status = response.status; error.detail = data?.detail; throw error; } return data; } export function announce(text, isError = false) { const message = document.getElementById("last-message"); message.textContent = text; message.classList.toggle("message-error", isError); message.setAttribute("role", isError ? "alert" : "status"); } export function createLocalState(namespace, version) { const key = `matrixController:${namespace}:v${version}`; return { load(fallback = null) { try { const raw = localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); } catch (_error) { return fallback; } }, save(value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (_error) { announce("浏览器无法保存当前编辑草稿", true); } }, }; } export function setActiveButton(buttons, activeButton) { buttons.forEach((button) => { const active = button === activeButton; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); } export function debounce(callback, wait) { let timer = null; const wrapped = (...args) => { window.clearTimeout(timer); timer = window.setTimeout(() => callback(...args), wait); }; wrapped.flush = (...args) => { window.clearTimeout(timer); timer = null; callback(...args); }; return wrapped; }