Files
matrix-screen-controller/核桃派软件源代码/app/static/core.js
T

199 lines
6.0 KiB
JavaScript

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;
}