新增 WMS 搬运规则与任务管理
支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user