199 lines
6.0 KiB
JavaScript
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;
|
|
}
|