228 lines
8.2 KiB
JavaScript
228 lines
8.2 KiB
JavaScript
/**
|
|
* AGV 播放页外部 API:标准化请求/响应格式。
|
|
*
|
|
* 嵌入 iframe 时父页面:
|
|
* await iframe.contentWindow.AgvPlaybackApi.getCatalog()
|
|
* await iframe.contentWindow.AgvPlaybackApi.request({ version: 1, method: "playAction", params: { modelId: "英招", actionId: "dongzuo1" } })
|
|
*
|
|
* 跨窗口 postMessage(父 → 播放页):
|
|
* iframe.contentWindow.postMessage({ version: 1, method: "playAction", params: { modelId: "英招", actionId: "dongzuo1" }, id: "req-1" }, "*")
|
|
* 播放页回复:{ version: 1, id: "req-1", ok: true, data: { ... } }
|
|
*/
|
|
|
|
export const API_VERSION = 1;
|
|
|
|
export const METHODS = {
|
|
PLAY_ACTION: "playAction",
|
|
STOP_ACTION: "stopAction",
|
|
CLEAR_ACTIONS: "clearActions",
|
|
};
|
|
|
|
const ALLOWED_METHODS = new Set(Object.values(METHODS));
|
|
|
|
const ERROR = {
|
|
INVALID_REQUEST: "INVALID_REQUEST",
|
|
UNKNOWN_METHOD: "UNKNOWN_METHOD",
|
|
INTERNAL_ERROR: "INTERNAL_ERROR",
|
|
};
|
|
|
|
function isPlainObject(v) {
|
|
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
}
|
|
|
|
function asString(v) {
|
|
return typeof v === "string" ? v.trim() : "";
|
|
}
|
|
|
|
function asStringList(v) {
|
|
if (Array.isArray(v)) return v.map(asString).filter(Boolean);
|
|
if (typeof v === "string") return v.split(/[,+\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
return [];
|
|
}
|
|
|
|
async function ensureModel(controller, params) {
|
|
const modelId = asString(params.modelId ?? params.model);
|
|
if (!modelId) return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "params.modelId 必填" } };
|
|
const cur = controller.getState();
|
|
if (cur.modelId === modelId && cur.ready) return { ok: true };
|
|
await controller.loadModel(modelId);
|
|
return { ok: true };
|
|
}
|
|
|
|
/** @typedef {{ version: number, id?: string, method: string, params?: object }} AgvApiRequest */
|
|
/** @typedef {{ version: number, id?: string, ok: boolean, data?: object|null, error?: { code: string, message: string }|null }} AgvApiResponse */
|
|
|
|
/**
|
|
* 校验并规范化入站请求。
|
|
* @param {unknown} raw
|
|
* @returns {{ ok: true, request: AgvApiRequest } | { ok: false, error: { code: string, message: string } } }
|
|
*/
|
|
export function parseRequest(raw) {
|
|
if (!isPlainObject(raw)) {
|
|
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "请求体须为 JSON 对象" } };
|
|
}
|
|
const version = Number(raw.version);
|
|
if (version !== API_VERSION) {
|
|
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: `不支持的 version,当前为 ${API_VERSION}` } };
|
|
}
|
|
const method = asString(raw.method);
|
|
if (!method) {
|
|
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "缺少 method" } };
|
|
}
|
|
if (!ALLOWED_METHODS.has(method)) {
|
|
return { ok: false, error: { code: ERROR.UNKNOWN_METHOD, message: "仅支持 playAction、stopAction、clearActions" } };
|
|
}
|
|
const params = isPlainObject(raw.params) ? raw.params : {};
|
|
const id = raw.id != null ? String(raw.id) : undefined;
|
|
return { ok: true, request: { version, method, params, id } };
|
|
}
|
|
|
|
/**
|
|
* @param {string|undefined} id
|
|
* @param {boolean} ok
|
|
* @param {object|null} [data]
|
|
* @param {{ code: string, message: string }|null} [error]
|
|
* @returns {AgvApiResponse}
|
|
*/
|
|
export function formatResponse(id, ok, data = null, error = null) {
|
|
return { version: API_VERSION, id, ok, data, error };
|
|
}
|
|
|
|
/**
|
|
* @param {object} controller
|
|
* @param {{ onStateChange?: (state: object) => void, allowedOrigins?: string[]|null }} [options]
|
|
*/
|
|
export function createPlaybackApi(controller, options = {}) {
|
|
const allowedOrigins = options.allowedOrigins ?? null;
|
|
let messageBound = false;
|
|
|
|
function emitState() {
|
|
try {
|
|
const state = controller.getState();
|
|
options.onStateChange?.(state);
|
|
dispatchEvent(new CustomEvent("agv-playback", { detail: { type: "state", state } }));
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
|
|
async function dispatch(req) {
|
|
const { method, params = {} } = req;
|
|
try {
|
|
switch (method) {
|
|
case METHODS.PLAY_ACTION: {
|
|
const ensured = await ensureModel(controller, params);
|
|
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
|
|
const ids = asStringList(params.actionIds ?? params.actions).length
|
|
? asStringList(params.actionIds ?? params.actions)
|
|
: [asString(params.actionId ?? params.action)];
|
|
const played = [];
|
|
for (const id of ids) {
|
|
if (!id) continue;
|
|
const r = controller.playAction(id);
|
|
if (!r.ok) return formatResponse(req.id, false, null, r.error);
|
|
played.push(id);
|
|
}
|
|
if (!played.length) {
|
|
return formatResponse(req.id, false, null, { code: ERROR.INVALID_REQUEST, message: "params.actionId 必填" });
|
|
}
|
|
emitState();
|
|
return formatResponse(req.id, true, {
|
|
modelId: controller.getState().modelId,
|
|
activeActions: controller.getState().activeActions,
|
|
played,
|
|
});
|
|
}
|
|
case METHODS.STOP_ACTION: {
|
|
const ensured = await ensureModel(controller, params);
|
|
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
|
|
const ids = asStringList(params.actionIds ?? params.actions).length
|
|
? asStringList(params.actionIds ?? params.actions)
|
|
: [asString(params.actionId ?? params.action)];
|
|
const stopped = [];
|
|
for (const id of ids) {
|
|
if (!id) continue;
|
|
const r = controller.stopAction(id);
|
|
if (!r.ok) return formatResponse(req.id, false, null, r.error);
|
|
stopped.push(id);
|
|
}
|
|
if (!stopped.length) {
|
|
return formatResponse(req.id, false, null, { code: ERROR.INVALID_REQUEST, message: "params.actionId 必填" });
|
|
}
|
|
emitState();
|
|
return formatResponse(req.id, true, {
|
|
modelId: controller.getState().modelId,
|
|
activeActions: controller.getState().activeActions,
|
|
stopped,
|
|
});
|
|
}
|
|
case METHODS.CLEAR_ACTIONS: {
|
|
const ensured = await ensureModel(controller, params);
|
|
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
|
|
controller.clearActions();
|
|
emitState();
|
|
return formatResponse(req.id, true, { modelId: controller.getState().modelId, activeActions: [] });
|
|
}
|
|
default:
|
|
return formatResponse(req.id, false, null, { code: ERROR.UNKNOWN_METHOD, message: "仅支持 playAction、stopAction、clearActions" });
|
|
}
|
|
} catch (e) {
|
|
return formatResponse(req.id, false, null, {
|
|
code: ERROR.INTERNAL_ERROR,
|
|
message: e?.message || String(e),
|
|
});
|
|
}
|
|
}
|
|
|
|
function isOriginAllowed(origin) {
|
|
if (!allowedOrigins || allowedOrigins.includes("*")) return true;
|
|
return allowedOrigins.includes(origin);
|
|
}
|
|
|
|
function onMessage(event) {
|
|
if (!isOriginAllowed(event.origin)) return;
|
|
const parsed = parseRequest(event.data);
|
|
if (!parsed.ok) {
|
|
const res = formatResponse(undefined, false, null, parsed.error);
|
|
event.source?.postMessage?.(res, event.origin);
|
|
return;
|
|
}
|
|
if (!parsed.request.method) return;
|
|
Promise.resolve(dispatch(parsed.request)).then((res) => {
|
|
if (event.source && typeof event.source.postMessage === "function") {
|
|
event.source.postMessage(res, event.origin);
|
|
}
|
|
});
|
|
}
|
|
|
|
function attach(target = window) {
|
|
const api = {
|
|
version: API_VERSION,
|
|
METHODS,
|
|
request: (raw) => {
|
|
const parsed = parseRequest({ version: API_VERSION, ...raw });
|
|
if (!parsed.ok) return Promise.resolve(formatResponse(raw?.id, false, null, parsed.error));
|
|
return Promise.resolve(dispatch(parsed.request));
|
|
},
|
|
getCatalog: () => fetch("/api/catalog").then((r) => r.json()),
|
|
getState: () => controller.getState(),
|
|
parseRequest,
|
|
formatResponse,
|
|
};
|
|
target.AgvPlaybackApi = api;
|
|
if (!messageBound) {
|
|
addEventListener("message", onMessage);
|
|
messageBound = true;
|
|
}
|
|
return api;
|
|
}
|
|
|
|
function detach() {
|
|
if (messageBound) {
|
|
removeEventListener("message", onMessage);
|
|
messageBound = false;
|
|
}
|
|
delete window.AgvPlaybackApi;
|
|
}
|
|
|
|
return { attach, detach, dispatch, parseRequest, formatResponse };
|
|
}
|