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