/** * 敏行 AJAX 支持的 HTTP 请求类型。 * * 该枚举范围来自 MXCommon.ajax 文档;未包含 PATCH, * 调用方如需 PATCH 应在业务请求中提前处理。 */ export type MXAjaxType = 'GET' | 'POST' | 'PUT' | 'DELETE' /** * MXCommon.ajax 入参。 * * 这里只保留敏行 AJAX 文档中明确支持的公共字段: * - type: 请求方式,默认 GET * - url: 请求地址 * - dataType: 返回数据格式 * - async: 是否异步请求,默认 true * - data: 请求数据,兼容实际宿主能力透传 * - headers: 请求头,兼容实际宿主能力透传 * - success/error/complete: 宿主回调 */ export interface MXAjaxParams { /** 请求方式,未传时按敏行文档默认 GET。 */ type?: MXAjaxType /** 请求地址,可以是完整 URL,也可以由调用方传入业务接口路径。 */ url: string /** 返回数据格式,例如 text/json,具体支持范围以敏行宿主为准。 */ dataType?: string /** 是否异步请求,未传时默认 true。 */ async?: boolean /** 请求数据,原样透传给敏行宿主。 */ data?: unknown /** 请求头,原样透传给敏行宿主。 */ headers?: Record /** 请求成功回调,收到的是宿主原始返回值。 */ success?: (data: unknown) => void /** 请求失败回调,收到的是宿主原始错误值。 */ error?: (data: unknown) => void /** 请求完成回调,无论成功或失败都由宿主触发。 */ complete?: () => void } /** * Promise 化后的 AJAX 返回结构。 * * 敏行宿主以回调返回原始数据;封装层会尝试 JSON.parse, * 成功则返回解析后的对象,失败则保留原始数据。 */ export interface MXAjaxResponse { data: T } /** * 获取 APP token 成功回调。 */ export type MXAppTokenSuccess = (token: string) => void /** * 获取 APP token 失败回调。 */ export type MXAppTokenError = (error: unknown) => void /** * MXCommon.getEncryptString 入参。 */ interface MXGetEncryptStringOptions { onSuccess: MXAppTokenSuccess onError?: MXAppTokenError } /** * MXCommon.openNoPermissionPage 入参。 */ export interface MXOpenNoPermissionPageOptions { /** 联系人姓名。 */ name: string /** 联系人电话,字段名沿用敏行宿主定义。 */ phoneNumb: string /** 权限申请流程指引页地址。 */ guidePage?: string /** 无权限页面自定义说明。 */ customContent: string /** 原生页面打开成功回调。 */ onSuccess?: () => void } /** * MXCommon 宿主能力。 */ interface MXCommonHost { ajax?: (options: MXAjaxParams) => void getEncryptString?: (options: MXGetEncryptStringOptions) => void openNoPermissionPage?: (options: MXOpenNoPermissionPageOptions) => void } /** * NXCommon 宿主能力。 * * 当前只用于 AJAX 兜底,不用于获取 APP token。 */ interface NXCommonHost { ajax?: (options: MXAjaxParams) => void } export type MXWebuiSideSlot = { default?: boolean hidden?: boolean disable?: boolean content?: string | 'default' action?: (() => void) | 'default' | 'custom' | 'native' | 'both' preventDefault?: boolean } export type MXWebuiTitleSlot = { default?: boolean hidden?: boolean title?: string subtitle?: string } export type MXWebuiInteractiveSlot = { default?: boolean hidden?: boolean title: string[] action?: (idx: number) => void active?: number } export type MXWebuiHeaderSlot = { left?: MXWebuiSideSlot left_1?: MXWebuiSideSlot left_2?: MXWebuiSideSlot middle?: MXWebuiTitleSlot | MXWebuiInteractiveSlot right_1?: MXWebuiSideSlot right_2?: MXWebuiSideSlot } export type MXWebuiCustomHeaderOptions = { slot: MXWebuiHeaderSlot onSuccess?: () => void onFail?: (error: unknown) => void } interface MXWebuiHost { hideWebViewTitle?: () => void showWebViewTitle?: (title?: string) => void setWebViewTitle?: (title: string) => void setNavBgColor?: (bgColor: string) => void closeWindow?: () => void setCustomHeader?: (options: MXWebuiCustomHeaderOptions) => void updateCustomHeader?: (options: MXWebuiCustomHeaderOptions) => void } const DEFAULT_NATIVE_TIMEOUT = 5000 const NATIVE_POLL_INTERVAL = 100 declare global { /** 敏行旧宿主命名空间。 */ const MXCommon: MXCommonHost | undefined /** 敏行新宿主命名空间,部分客户端使用 NXCommon。 */ const NXCommon: NXCommonHost | undefined /** 敏行原生 UI 宿主命名空间。 */ const MXWebui: MXWebuiHost | undefined interface Window { /** 敏行旧宿主命名空间。 */ MXCommon?: MXCommonHost /** 敏行新宿主命名空间,部分客户端使用 NXCommon。 */ NXCommon?: NXCommonHost /** 敏行原生 UI 宿主命名空间。 */ MXWebui?: MXWebuiHost } } /** * 获取当前可用的敏行公共宿主对象。 * * 优先使用 MXCommon;如果客户端只注入 NXCommon,则回退到 NXCommon。 * 使用 typeof 判断是为了避免全局变量未注入时产生运行时错误。 */ const getHostCommon = () => { return getMXCommon() || getNXCommon() } const getMXCommon = () => (typeof MXCommon !== 'undefined' ? MXCommon : undefined) const getNXCommon = () => (typeof NXCommon !== 'undefined' ? NXCommon : undefined) const getMXWebui = () => (typeof MXWebui !== 'undefined' ? MXWebui : undefined) const getNativeSnapshot = () => ({ 是否存在MXCommon: typeof MXCommon !== 'undefined', 是否存在NXCommon: typeof NXCommon !== 'undefined', 是否存在MXWebui: typeof MXWebui !== 'undefined', 是否存在MXCommonAjax: typeof MXCommon !== 'undefined' && typeof MXCommon.ajax === 'function', 是否存在NXCommonAjax: typeof NXCommon !== 'undefined' && typeof NXCommon.ajax === 'function', 是否存在隐藏原生标题栏方法: typeof MXWebui !== 'undefined' && typeof MXWebui.hideWebViewTitle === 'function', 是否存在获取Token方法: typeof MXCommon !== 'undefined' && typeof MXCommon.getEncryptString === 'function', }) /** * 安全解析宿主返回的数据。 * * 敏行 AJAX 示例中 dataType 常使用 text,业务响应可能是 JSON 字符串。 * 这里仅在字符串非空且 JSON.parse 成功时转换;否则保持原值,避免吞掉 * 文本响应或宿主返回的非 JSON 数据。 */ const safeJsonParse = (data: unknown) => { if (typeof data !== 'string') return data const text = data.trim() if (!text) return data try { return JSON.parse(text) } catch { return data } } /** * 发起敏行 AJAX 请求。 * * 该方法做三件事: * 1. 判断当前环境是否存在 MXCommon.ajax / NXCommon.ajax。 * 2. 补齐敏行文档默认值,例如 type=GET、async=true。 * 3. 将宿主回调包装成 Promise,同时保留调用方传入的回调。 * * @param options 敏行 AJAX 请求参数 * @returns Promise 化后的请求结果 */ export function ajax(options: MXAjaxParams): Promise> { return new Promise((resolve, reject) => { const hostCommon = getHostCommon() // 非敏行宿主环境无法调用 MXCommon.ajax,直接进入失败链路。 if (!hostCommon?.ajax) { const error = new Error('当前环境不支持 MXCommon.ajax') options.error?.(error) options.complete?.() reject(error) return } // 透传调用方参数,并补齐敏行文档中的默认请求配置。 const requestOptions: MXAjaxParams = { ...options, type: options.type || 'GET', async: options.async ?? true, success(data) { console.log('[敏行AJAX] 原生请求成功返回', { url: requestOptions.url, data: safeJsonParse(data), }) // 先保留原始回调行为,再 resolve Promise,避免破坏旧调用方。 options.success?.(data) resolve({ data: safeJsonParse(data) as T }) }, error(data) { const errorData = safeJsonParse(data) console.error('[敏行AJAX] 原生请求失败返回', { url: requestOptions.url, data: errorData, }) // error 同样保留调用方回调,再 reject 给 await/catch 使用。 options.error?.(errorData) reject(errorData) }, complete() { options.complete?.() }, } // 每次调用敏行 AJAX 前打印完整关键参数,方便原生联调定位问题。 console.log('[敏行AJAX] 发起原生请求', { type: requestOptions.type, url: requestOptions.url, dataType: requestOptions.dataType, async: requestOptions.async, data: requestOptions.data, headers: requestOptions.headers, hasSuccessCallback: Boolean(options.success), hasErrorCallback: Boolean(options.error), hasCompleteCallback: Boolean(options.complete), }) hostCommon.ajax(requestOptions) }) } /** * 获取敏行宿主提供的 APP token。 * * 宿主文档方法名为 MXCommon.getEncryptString({ onSuccess, onError }), * 这里只做 Promise 化薄封装。 * * @returns Promise 化后的 APP token 字符串 */ export function getAppToken(): Promise { return new Promise((resolve, reject) => { if (typeof MXCommon === 'undefined' || typeof MXCommon.getEncryptString !== 'function') { const error = new Error('当前环境不支持 MXCommon.getEncryptString') console.error('[敏行Token] 获取失败:方法不存在', error) reject(error) return } const onSuccess: MXAppTokenSuccess = (token) => { console.log('[敏行Token] 获取成功', { token, 是否返回Token: Boolean(token), Token长度: token?.length || 0, }) resolve(token) } const onError: MXAppTokenError = (error) => { console.error('[敏行Token] 获取失败', error) reject(error) } MXCommon.getEncryptString({ onSuccess, onError, }) }) } /** * MXCommon.getEncryptString 的同名封装。 * * 保留宿主方法名,方便调用方按敏行文档搜索和使用。 */ export const getEncryptString = getAppToken /** * 打开敏行原生无权限页面。 * * 非敏行环境或旧客户端未提供该能力时返回 false,由调用方展示网页兜底提示。 */ export function openNoPermissionPage(options: MXOpenNoPermissionPageOptions) { const host = getMXCommon() if (typeof host?.openNoPermissionPage !== 'function') { console.error('[敏行权限页] 打开失败:方法不存在') return false } try { host.openNoPermissionPage(options) return true } catch (error) { console.error('[敏行权限页] 打开失败', error) return false } } function callMXWebui(method: keyof MXWebuiHost, ...args: unknown[]) { const host = getMXWebui() const api = host?.[method] if (typeof api !== 'function') { return false } try { ;(api as (...params: unknown[]) => unknown)(...args) return true } catch (error) { console.error(`[敏行UI] ${String(method)} 调用失败`, error) return false } } export function hideWebViewTitle() { return callMXWebui('hideWebViewTitle') } export function showWebViewTitle(title?: string) { return typeof title === 'string' ? callMXWebui('showWebViewTitle', title) : callMXWebui('showWebViewTitle') } export function setWebViewTitle(title: string) { return callMXWebui('setWebViewTitle', title) } export function setNavBgColor(bgColor: string) { return callMXWebui('setNavBgColor', bgColor) } export function closeWindow() { return callMXWebui('closeWindow') } export function setCustomHeader(options: MXWebuiCustomHeaderOptions) { return callMXWebui('setCustomHeader', options) } export function updateCustomHeader(options: MXWebuiCustomHeaderOptions) { return callMXWebui('updateCustomHeader', options) } /** * 判断当前页面是否运行在敏行宿主环境中。 * * 只要宿主注入了 MXCommon 或 NXCommon,就认为是原生环境。 */ export const isNativeApp = (): boolean => typeof MXCommon !== 'undefined' || typeof NXCommon !== 'undefined' || typeof MXWebui !== 'undefined' /** * 等待敏行宿主完成注入后再判断是否为 APP 环境。 * * 部分客户端会在 deviceready 后才注入 MXCommon/NXCommon。如果页面启动时 * 立即同步判断,容易误判为浏览器环境。这里先做一次即时判断;如果还没有 * 宿主对象,则等待 deviceready,超过 timeout 后按浏览器环境处理。 */ export function waitNativeReady(timeout = DEFAULT_NATIVE_TIMEOUT): Promise { return waitForNativeCapability('原生环境检测', isNativeApp, timeout) } function waitForNativeCapability( label: string, hasCapability: () => boolean, timeout: number, ): Promise { const initialReady = hasCapability() console.log('[敏行环境] 开始检测', { 检测目标: label, 超时时间: timeout, 初始是否就绪: initialReady, ...getNativeSnapshot(), }) if (initialReady) { console.log('[敏行环境] 启动时已就绪', { 检测目标: label, ...getNativeSnapshot(), }) return Promise.resolve(true) } return new Promise((resolve) => { let settled = false const finish = (value: boolean, reason: string) => { if (settled) return settled = true document.removeEventListener('deviceready', onDeviceReady, false) clearTimeout(timer) clearInterval(interval) console.log('[敏行环境] 检测完成', { 检测目标: label, 是否就绪: value, 完成原因: reason, ...getNativeSnapshot(), 结果说明: value ? '敏行能力已就绪' : '敏行能力未就绪', }) resolve(value) } const onDeviceReady = () => { console.log('[敏行环境] 收到 deviceready 事件', { 检测目标: label, ...getNativeSnapshot(), }) if (hasCapability()) { finish(true, 'deviceready') } } const interval = setInterval(() => { if (hasCapability()) { finish(true, 'polling') } }, NATIVE_POLL_INTERVAL) const timer = setTimeout(() => finish(false, 'timeout'), timeout) document.addEventListener('deviceready', onDeviceReady, false) }) }