初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user