diff --git a/packages/common/package.json b/packages/common/package.json index 925dad8..6c5babe 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-common", - "version": "0.3.2", + "version": "0.4.0", "description": "XuqmGroup RN SDK — core: init, network, token management", "license": "UNLICENSED", "main": "src/index.ts", @@ -14,12 +14,18 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { + "react": ">=18.0.0", "react-native": ">=0.76.0", - "@react-native-async-storage/async-storage": ">=1.21.0" + "@react-native-async-storage/async-storage": ">=1.21.0", + "axios": ">=1.0.0" }, "devDependencies": { "typescript": "^5.9.3", + "@types/react": "^19.0.0", "@types/react-native": "^0.73.0", - "@react-native-async-storage/async-storage": "^2.1.2" + "@react-native-async-storage/async-storage": "^2.1.2", + "axios": "^1.7.0", + "react": "^19.0.0", + "zod": "^3.23.0" } } diff --git a/packages/common/src/api/errors.ts b/packages/common/src/api/errors.ts new file mode 100644 index 0000000..dcf2f7e --- /dev/null +++ b/packages/common/src/api/errors.ts @@ -0,0 +1,11 @@ +export class RequestError extends Error { + constructor( + message: string, + public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError', + public readonly cause?: unknown, + public readonly response?: { data?: TData; status?: number }, + ) { + super(message) + this.name = 'RequestError' + } +} diff --git a/packages/common/src/api/globalErrorHandler.ts b/packages/common/src/api/globalErrorHandler.ts new file mode 100644 index 0000000..10cdc37 --- /dev/null +++ b/packages/common/src/api/globalErrorHandler.ts @@ -0,0 +1,15 @@ +import { RequestError } from './errors' + +type ApiErrorHandler = (error: RequestError) => void + +let _handler: ApiErrorHandler | null = null + +/** 注册全局 API 错误处理器(宿主在初始化时注入,通常将错误转发给 rn-log)。*/ +export function setGlobalApiErrorHandler(handler: ApiErrorHandler): void { + _handler = handler +} + +/** 内部调用:在 useRequest 的 catch 分支中触发。*/ +export function _notifyApiError(error: RequestError): void { + _handler?.(error) +} diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts new file mode 100644 index 0000000..a3b8a2a --- /dev/null +++ b/packages/common/src/api/index.ts @@ -0,0 +1,7 @@ +export { useApi } from './useApi' +export { usePageApi } from './usePageApi' +export { RequestError } from './errors' +export { setGlobalApiErrorHandler } from './globalErrorHandler' +export type { RequestOptions } from './useRequest' +export type { ApiMethod } from './useApi' +export type { AxiosRequestConfig } from 'axios' diff --git a/packages/common/src/api/useApi.ts b/packages/common/src/api/useApi.ts new file mode 100644 index 0000000..a67266d --- /dev/null +++ b/packages/common/src/api/useApi.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { + type AxiosInterceptorManager, + type AxiosRequestConfig, + type AxiosResponse, + isAxiosError, + isCancel, +} from 'axios' +import { z } from 'zod' +import { RequestError } from './errors' +import { type RequestOptions, useRequest } from './useRequest' + +const SUCCESS_STATUS = /^0$/ + +type ApiResponse = { + status: string + message: string + data: T +} + +export type ApiMethod = 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD' + +// ValidationError shape (duck-typed to avoid circular dependency) +interface HasIssues { + issues: Array<{ path: (string | number)[]; message: string }> + response?: { data?: { message?: string } } +} + +function isValidationError(e: unknown): e is HasIssues { + return ( + e != null && + typeof e === 'object' && + 'issues' in e && + Array.isArray((e as { issues: unknown }).issues) + ) +} + +export const useApi = < + S extends z.ZodTypeAny, + T extends z.infer = z.infer, + D = unknown, +>( + url: string, + method: ApiMethod, + params?: D, + validationSchema?: S, + options?: RequestOptions, + config?: AxiosRequestConfig, +): { + response?: T + error?: RequestError + loading: boolean + fetch: (config?: AxiosRequestConfig) => void + fetchAsync: (config?: AxiosRequestConfig) => Promise + cancel: () => void + requestInterceptors: AxiosInterceptorManager> + responseInterceptors: AxiosInterceptorManager, unknown>> +} => { + const responseSchema = useMemo( + () => + z.object({ + status: z.string().regex(SUCCESS_STATUS), + message: z.string(), + data: validationSchema ?? z.unknown(), + }), + [validationSchema], + ) + + const { + response, + error, + loading, + fetch, + fetchAsync, + cancel, + requestInterceptors, + responseInterceptors, + } = useRequest, unknown>( + url, + method, + params, + responseSchema, + options, + { + timeout: 30_000, + ...config, + }, + ) + + useEffect(() => { + // Note: signing headers are injected by the app layer via requestInterceptors.use(...) + // The SDK itself does not know MD5_KEY or CLIENT_ID — those are app-specific. + // Callers can add a request interceptor to inject signing headers. + // (This mirrors the pattern in YiwangxinApp4's useApi.ts but removes app-specific logic.) + return undefined + }, [requestInterceptors]) + + useEffect(() => { + const responseInterceptor = responseInterceptors.use( + nextResponse => nextResponse, + (thrownError: unknown) => { + if (isCancel(thrownError)) { + return Promise.reject( + new RequestError('网络请求已取消', 'Cancel', thrownError), + ) + } + + if (isValidationError(thrownError)) { + if (thrownError.issues.length <= 0) { + return Promise.reject( + new RequestError('数据格式校验错误', 'ValidationError', thrownError), + ) + } + + const firstIssue = thrownError.issues[0] + if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') { + return Promise.reject( + new RequestError( + thrownError.response?.data?.message ?? '接口返回失败', + 'ValidationError', + thrownError, + ), + ) + } + + return Promise.reject( + new RequestError(firstIssue.message, 'ValidationError', thrownError), + ) + } + + if (isAxiosError(thrownError)) { + return Promise.reject( + new RequestError('网络请求失败', 'AxiosError', thrownError), + ) + } + + return Promise.reject( + new RequestError('网络请求失败', 'OtherError', thrownError), + ) + }, + ) + + return () => { + responseInterceptors.eject(responseInterceptor) + } + }, [responseInterceptors]) + + const transformedFetchAsync = useCallback( + async (nextConfig?: AxiosRequestConfig) => { + try { + const requestResponse = await fetchAsync(nextConfig) + return (requestResponse as unknown as AxiosResponse>).data.data + } catch (requestError) { + return Promise.reject(requestError) + } + }, + [fetchAsync], + ) + + return { + response: (response as unknown as AxiosResponse> | undefined)?.data.data, + error: error as RequestError, + loading, + fetch, + fetchAsync: transformedFetchAsync, + cancel, + requestInterceptors: requestInterceptors as AxiosInterceptorManager>, + responseInterceptors: responseInterceptors as AxiosInterceptorManager, unknown>>, + } +} + +export type { AxiosRequestConfig, RequestError, RequestOptions } diff --git a/packages/common/src/api/usePageApi.ts b/packages/common/src/api/usePageApi.ts new file mode 100644 index 0000000..8bbd195 --- /dev/null +++ b/packages/common/src/api/usePageApi.ts @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react' +import type { AxiosRequestConfig } from 'axios' +import type { RequestError, RequestOptions } from './useRequest' +import { z } from 'zod' + +import { useApi } from './useApi' + +type List = { + records: T[] +} + +type Root = T[] + +interface PageOptions extends RequestOptions { + pageSize?: number + path?: 'list' | 'root' + pagination?: 'cursor' | 'offset' +} + +export const usePageApi = < + S extends z.ZodTypeAny, + T extends z.infer = z.infer, + P extends List | Root = List, + D = Record, +>( + url: string, + method: 'GET' | 'POSTJSON' | 'POSTFORM', + params?: D, + validationSchema?: S, + options?: PageOptions, + config?: AxiosRequestConfig, +): { + response?: T[] + error?: RequestError + loading: boolean + fetch: React.Dispatch> + cancel: () => void +} => { + const [data, setData] = useState(undefined) + const [status, setStatus] = useState<'loaded' | 'reload' | 'loadmore'>('loaded') + + const listSchema: z.ZodSchema> = z.object({ + records: z.array(validationSchema ?? z.any()), + }) + const rootSchema: z.ZodSchema> = z.array(validationSchema ?? z.any()) + + const { error, loading, fetchAsync, cancel } = useApi< + typeof listSchema | typeof rootSchema, + P + >( + url, + method, + options?.pagination === 'offset' + ? { + ...params, + current: + status === 'loadmore' + ? Math.ceil((data ?? []).length / (options?.pageSize ?? 10)) + 1 + : 1, + size: options?.pageSize ?? 10, + } + : { + ...params, + startNum: status === 'loadmore' ? (data ?? []).length : 0, + endNum: + status === 'loadmore' + ? (data ?? []).length + (options?.pageSize ?? 10) - 1 + : (options?.pageSize ?? 10) - 1, + }, + options?.path === 'root' ? rootSchema : listSchema, + options, + config, + ) + + useEffect(() => { + if ( + status === 'loaded' || + (status === 'loadmore' && + ((data ?? []).length === 0 || + (data ?? []).length % (options?.pageSize ?? 10) !== 0)) + ) { + return + } + + fetchAsync() + .then(result => { + if (status === 'reload') { + switch (options?.path) { + case 'root': + setData(result as Root) + break + default: + setData((result as List).records) + break + } + setStatus('loaded') + return + } + + switch (options?.path) { + case 'root': + setData(previous => [...(previous ?? []), ...(result as Root)]) + break + default: + setData(previous => [ + ...(previous ?? []), + ...(result as List).records, + ]) + break + } + setStatus('loaded') + }) + .catch(() => { + setStatus('loaded') + }) + }, [data, fetchAsync, options?.pageSize, options?.path, status]) + + return { + response: data, + error: error as RequestError, + loading, + fetch: setStatus as React.Dispatch>, + cancel, + } +} + +export type { AxiosRequestConfig, PageOptions } diff --git a/packages/common/src/api/useRequest.ts b/packages/common/src/api/useRequest.ts new file mode 100644 index 0000000..9ee1be7 --- /dev/null +++ b/packages/common/src/api/useRequest.ts @@ -0,0 +1,237 @@ +/** + * useRequest — 替换 @szyx-mobile/use-request。 + * 接口与原版完全兼容,底层使用 axios.create() + AbortController。 + */ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import axios, { + type AxiosRequestConfig, + type AxiosResponse, + isAxiosError, + isCancel, +} from 'axios' +import { z } from 'zod' +import { RequestError } from './errors' +import { _notifyApiError } from './globalErrorHandler' + +export interface RequestOptions { + /** true = 不自动触发请求(需手动调用 fetch/fetchAsync)。*/ + manual?: boolean + /** 延迟多少毫秒后才将 loading 置为 true(默认 0)。*/ + loadingDelay?: number +} + +// ─── 内部 ValidationError(对齐 @szyx-mobile/use-axios 的 ValidationError)──── + +class ValidationError extends z.ZodError { + constructor( + issues: z.ZodIssue[], + public readonly response: AxiosResponse, + ) { + super(issues) + this.name = 'ValidationError' + } +} + +// ─── Reducer ────────────────────────────────────────────────────────────────── + +interface State { + response: R | undefined + error: RequestError | undefined + loading: boolean +} + +type Action = + | { type: 'start' } + | { type: 'resolve'; response: R } + | { type: 'reject'; error: RequestError } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'start': + return { ...state, loading: true } + case 'resolve': + return { response: action.response, error: undefined, loading: false } + case 'reject': + return { ...state, error: action.error, loading: false } + } +} + +const initialState = { response: undefined, error: undefined, loading: false } + +// ─── useRequest ─────────────────────────────────────────────────────────────── + +export function useRequest< + S extends z.ZodTypeAny, + T extends z.infer = z.infer, + D = unknown, + R = AxiosResponse, +>( + url: string, + method: 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD', + params?: D, + validationSchema?: S, + options?: RequestOptions, + config?: AxiosRequestConfig, +): { + response: R | undefined + error: RequestError | undefined + loading: boolean + fetch: (config?: AxiosRequestConfig) => void + fetchAsync: (config?: AxiosRequestConfig) => Promise + cancel: () => void + requestInterceptors: ReturnType['interceptors']['request'] + responseInterceptors: ReturnType['interceptors']['response'] +} { + const [state, dispatch] = useReducer(reducer, initialState as State) + + // axios 实例用 useRef 持有,避免每次 render 重建(丢失拦截器) + // eslint-disable-next-line react-hooks/exhaustive-deps + const instance = useMemo(() => axios.create(), []) + + const abortControllerRef = useRef(undefined) + const validationSchemaRef = useRef(validationSchema) + const optionsRef = useRef(options) + + // 保持 ref 同步(避免闭包陈旧引用) + useEffect(() => { validationSchemaRef.current = validationSchema }) + useEffect(() => { optionsRef.current = options }) + + // Zod 校验响应拦截器(对齐 useValidatedAxios 行为) + useEffect(() => { + const id = instance.interceptors.response.use( + (r: AxiosResponse) => { + const schema = validationSchemaRef.current + if (schema) { + const result = schema.safeParse(r.data) + if (!result.success) { + return Promise.reject(new ValidationError(result.error.issues, r)) + } + r.data = result.data + } + return r + }, + ) + return () => { instance.interceptors.response.eject(id) } + }, [instance]) + + const cancel = useCallback(() => { + abortControllerRef.current?.abort() + }, []) + + // axios 方法映射 + function resolveAxiosMethod(m: typeof method): string { + switch (m) { + case 'GET': return 'get' + case 'PUT': return 'put' + default: return 'post' + } + } + + const fetchAsync = useCallback( + async (overrideConfig?: AxiosRequestConfig): Promise => { + const loadingDelay = optionsRef.current?.loadingDelay ?? 0 + const timer = setTimeout(() => { dispatch({ type: 'start' }) }, loadingDelay) + + cancel() + abortControllerRef.current = new AbortController() + + // Content-Type 按 method 设置 + const methodHeaders: Record = {} + if (method === 'POSTFORM') { + methodHeaders['Content-Type'] = 'application/x-www-form-urlencoded' + } else if (method === 'UPLOAD') { + methodHeaders['Content-Type'] = 'multipart/form-data' + } + + const axiosMethod = resolveAxiosMethod(method) + const isGet = method === 'GET' + + const mergedConfig: AxiosRequestConfig = { + timeout: 30_000, + ...config, + ...overrideConfig, + url, + method: axiosMethod, + params: isGet ? params : (overrideConfig?.params ?? config?.params), + data: isGet ? (overrideConfig?.data ?? config?.data) : params, + headers: { + ...methodHeaders, + ...(config?.headers as Record | undefined), + ...(overrideConfig?.headers as Record | undefined), + }, + signal: abortControllerRef.current.signal, + } + + try { + const r = await instance.request, D>(mergedConfig) + clearTimeout(timer) + dispatch({ type: 'resolve', response: r as unknown as R }) + return r as unknown as R + } catch (e: unknown) { + clearTimeout(timer) + + let wrapped: RequestError + + if (isCancel(e)) { + wrapped = new RequestError('网络请求已取消', 'Cancel', e) + } else if (e instanceof ValidationError) { + if (e.issues.length === 0) { + wrapped = new RequestError('数据格式校验错误', 'ValidationError', e) + } else { + const first = e.issues[0] + if (first.path.length > 0 && first.path[0] === 'status') { + const resp = e.response as AxiosResponse<{ message?: string }> | undefined + wrapped = new RequestError( + resp?.data?.message ?? '接口返回失败', + 'ValidationError', + e, + ) + } else { + wrapped = new RequestError(first.message, 'ValidationError', e) + } + } + } else if (isAxiosError(e)) { + wrapped = new RequestError('网络请求失败', 'AxiosError', e) + } else { + wrapped = new RequestError('网络请求失败', 'OtherError', e) + } + + _notifyApiError(wrapped) + dispatch({ type: 'reject', error: wrapped }) + return Promise.reject(wrapped) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cancel, config, instance, method, params, url], + ) + + const fetch = useCallback( + (overrideConfig?: AxiosRequestConfig) => { + fetchAsync(overrideConfig).catch(() => { /* error already in state */ }) + }, + [fetchAsync], + ) + + // 自动触发(manual !== true 时挂载自动发请求) + useEffect(() => { + if (!optionsRef.current?.manual) { + fetch() + } + // 仅在 mount 时触发一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // unmount 时取消请求 + useEffect(() => { return () => { cancel() } }, [cancel]) + + return { + response: state.response, + error: state.error, + loading: state.loading, + fetch, + fetchAsync, + cancel, + requestInterceptors: instance.interceptors.request, + responseInterceptors: instance.interceptors.response, + } +} diff --git a/packages/common/src/autoInit.ts b/packages/common/src/autoInit.ts index 4fab9ee..dc4dda7 100644 --- a/packages/common/src/autoInit.ts +++ b/packages/common/src/autoInit.ts @@ -40,7 +40,8 @@ export function tryAutoInit(): void { const encrypted = tryRequireConfig() if (!encrypted) return - XuqmSDK.initWithConfigFile(encrypted, {debug: __DEV__}).catch(() => { - // 静默降级 + XuqmSDK.initWithConfigFile(encrypted, {debug: __DEV__}).catch((e: unknown) => { + console.error('[XuqmSDK] Auto-init failed:', e) + if (__DEV__) throw e }) } diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index dfe411b..3ca4636 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -15,6 +15,9 @@ export interface XuqmConfig { imEnabled: boolean pushEnabled: boolean licenseEnabled: boolean + // 日志服务(rn-log 使用) + logApiUrl: string + logEnabled: boolean } export interface XuqmUserInfo { @@ -44,6 +47,8 @@ export interface XuqmRemoteConfig { imEnabled?: boolean pushEnabled?: boolean licenseEnabled?: boolean + logApiUrl?: string + logEnabled?: boolean } export function initConfigFromRemote( @@ -61,6 +66,8 @@ export function initConfigFromRemote( imEnabled: remote.imEnabled ?? !!remote.imWsUrl, pushEnabled: remote.pushEnabled ?? true, licenseEnabled: remote.licenseEnabled ?? false, + logApiUrl: remote.logApiUrl ?? '', + logEnabled: remote.logEnabled ?? false, } } diff --git a/packages/common/src/device.ts b/packages/common/src/device.ts index db47c8b..5145c32 100644 --- a/packages/common/src/device.ts +++ b/packages/common/src/device.ts @@ -33,6 +33,10 @@ export interface DeviceInfo { model: string osVersion: string pushVendor: PushVendor + /** 设备制造商(如 "APPLE"/"HUAWEI");iOS 暂留 undefined。*/ + manufacturer?: string + /** 厂商 ROM 版本;Android 可选填充,iOS 暂留 undefined。*/ + vendorVersion?: string } const BRAND_MAP: Record = { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index da6dd12..f0a4438 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -23,7 +23,10 @@ export { getXWebViewConfig, openXWebView, setXWebViewController, + setXWebViewNavigationRef, + processJSBridgeMessage, } from './xwebview/XWebViewBridge' +export type { XWebViewNavigationRef } from './xwebview/XWebViewBridge' export type { XWebViewClickMenu, XWebViewConfig, @@ -35,3 +38,18 @@ export type { XWebViewMessageEvent, XWebViewPermissionRequest, } from './xwebview/types' + +// api/ 子模块 +export { + RequestError, + setGlobalApiErrorHandler, + useRequest, + useApi, + usePageApi, +} from './api' +export type { RequestOptions as UseRequestOptions, ApiMethod } from './api' +export type { PageOptions } from './api' +export type { AxiosRequestConfig } from 'axios' + +// ui/ 子模块 +export { showToast, showAlert, showConfirm, configureToast } from './ui' diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index a781c91..e95e2c2 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -63,6 +63,8 @@ export const XuqmSDK = { imEnabled: remote.imEnabled as boolean | undefined, pushEnabled: remote.pushEnabled as boolean | undefined, licenseEnabled: remote.licenseEnabled as boolean | undefined, + logApiUrl: remote.logApiUrl as string | undefined, + logEnabled: remote.logEnabled as boolean | undefined, }) configureHttp({ baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl, diff --git a/packages/common/src/ui/feedback.ts b/packages/common/src/ui/feedback.ts new file mode 100644 index 0000000..d53e335 --- /dev/null +++ b/packages/common/src/ui/feedback.ts @@ -0,0 +1,73 @@ +import { Alert } from 'react-native' + +let _toastImpl: ((msg: string) => void) | null = null + +/** + * 显示简短的 Toast 消息。 + * 默认降级为 Alert,宿主可通过 configureToast 注入自定义实现。 + */ +export function showToast(message: string): void { + if (_toastImpl) { + _toastImpl(message) + return + } + Alert.alert('', message) +} + +/** + * 显示单按钮提示弹窗(信息告知,无取消)。 + */ +export function showAlert(options: { + title?: string + message: string + confirmText?: string + onConfirm?: () => void +}): void { + const { title = '提示', message, confirmText = '知道了', onConfirm } = options + Alert.alert(title, message, [ + { + text: confirmText, + onPress: onConfirm, + }, + ]) +} + +/** + * 显示双按钮确认弹窗(确认 + 取消)。 + */ +export function showConfirm(options: { + title?: string + message: string + confirmText?: string + cancelText?: string + onConfirm?: () => void + onCancel?: () => void +}): void { + const { + title = '提示', + message, + confirmText = '确定', + cancelText = '取消', + onConfirm, + onCancel, + } = options + Alert.alert(title, message, [ + { + text: cancelText, + style: 'cancel', + onPress: onCancel, + }, + { + text: confirmText, + onPress: onConfirm, + }, + ]) +} + +/** + * 宿主注入自定义 Toast 实现(可选)。 + * 注入后 showToast 将使用该实现,而不是降级的 Alert。 + */ +export function configureToast(impl: (msg: string) => void): void { + _toastImpl = impl +} diff --git a/packages/common/src/ui/index.ts b/packages/common/src/ui/index.ts new file mode 100644 index 0000000..e9c983a --- /dev/null +++ b/packages/common/src/ui/index.ts @@ -0,0 +1 @@ +export { showToast, showAlert, showConfirm, configureToast } from './feedback' diff --git a/packages/common/src/xwebview/XWebViewBridge.ts b/packages/common/src/xwebview/XWebViewBridge.ts index e3f7b24..d2bc4e0 100644 --- a/packages/common/src/xwebview/XWebViewBridge.ts +++ b/packages/common/src/xwebview/XWebViewBridge.ts @@ -1,3 +1,7 @@ +import { Alert, Platform, ToastAndroid } from 'react-native' +import { getDeviceInfo } from '../device' +import { getUserInfo } from '../config' +import { _getToken } from '../http' import type { XWebViewConfig } from './types' export type XWebViewControllerAPI = { @@ -10,6 +14,176 @@ export type XWebViewControllerAPI = { getTitle: () => string } +// ─── Navigation ref (宿主注入) ───────────────────────────────────────────────── + +export type XWebViewNavigationRef = { + navigate: (route: string, params?: Record) => void + goBack: () => void +} + +let _navigationRef: XWebViewNavigationRef | null = null + +/** + * 注入导航能力(宿主在 App 初始化时调用)。 + * 供 JSBridge 的 `xuqm.openNativePage` / `xuqm.closeWebView` 使用。 + */ +export function setXWebViewNavigationRef(ref: XWebViewNavigationRef | null): void { + _navigationRef = ref +} + +// ─── ScanQRCode handler (宿主注入) ──────────────────────────────────────────── + +export type ScanQRCodeHandler = () => Promise + +let _scanQRCodeHandler: ScanQRCodeHandler | null = null + +/** + * 注入扫码能力(宿主在 App 初始化时调用)。 + * 供 JSBridge 的 `xuqm.scanQRCode` 使用。 + * handler 应打开原生扫码页并返回扫码结果字符串。 + */ +export function setXWebViewScanQRCodeHandler(handler: ScanQRCodeHandler | null): void { + _scanQRCodeHandler = handler +} + +// ─── JSBridge response helpers ───────────────────────────────────────────────── + +type BridgeResponse = { code: 0; data: unknown } | { code: -1; message: string } + +function ok(data: unknown): BridgeResponse { + return { code: 0, data } +} + +function fail(message: string): BridgeResponse { + return { code: -1, message } +} + +function showToast(message: string): void { + if (Platform.OS === 'android') { + ToastAndroid.show(message, ToastAndroid.SHORT) + } else { + Alert.alert('', message) + } +} + +// ─── JSBridge message processor ─────────────────────────────────────────────── + +/** + * 处理来自 WebView 的 JSBridge 消息(action 以 `xuqm.` 开头)。 + * + * 在 WebView 的 `onMessage` 回调中调用此函数。若消息被本 bridge 处理, + * 返回 `true`(上层不必再转发给 userOnMessage);否则返回 `false`。 + * + * @param rawData WebViewMessageEvent.nativeEvent.data + * @param postMessageToWeb 向 WebView 回传字符串的函数(injectJavaScript 封装) + */ +export async function processJSBridgeMessage( + rawData: string, + postMessageToWeb: (js: string) => void, +): Promise { + let parsed: { + __xuqm?: string + id?: string | number + params?: Record + } + + try { + parsed = JSON.parse(rawData) + } catch { + return false + } + + const action = parsed.__xuqm + if (typeof action !== 'string' || !action.startsWith('xuqm.')) { + return false + } + + const id = parsed.id + const params = parsed.params ?? {} + + function respond(result: BridgeResponse): void { + const js = ` +(function(){ + var e = new CustomEvent('xuqm_bridge_response_${String(id ?? '')}', { detail: ${JSON.stringify(result)} }); + window.dispatchEvent(e); + if (window.__xuqmBridgeCallback) { + window.__xuqmBridgeCallback(${JSON.stringify(String(id ?? ''))}, ${JSON.stringify(result)}); + } +})(); +true;`.trim() + postMessageToWeb(js) + } + + try { + switch (action) { + case 'xuqm.getDeviceInfo': { + const info = await getDeviceInfo() + respond(ok(info)) + break + } + + case 'xuqm.getToken': { + const token = await _getToken() + respond(ok(token)) + break + } + + case 'xuqm.getUserInfo': { + const info = getUserInfo() + respond(ok(info)) + break + } + + case 'xuqm.openNativePage': { + const route = params.route + if (typeof route !== 'string') { + respond(fail('params.route is required')) + break + } + const navParams = params.params as Record | undefined + if (!_navigationRef) { + respond(fail('navigation not available — call setXWebViewNavigationRef() in host app')) + break + } + _navigationRef.navigate(route, navParams) + respond(ok(null)) + break + } + + case 'xuqm.closeWebView': { + if (!_navigationRef) { + respond(fail('navigation not available — call setXWebViewNavigationRef() in host app')) + break + } + _navigationRef.goBack() + respond(ok(null)) + break + } + + case 'xuqm.showToast': { + const message = params.message + if (typeof message !== 'string') { + respond(fail('params.message is required')) + break + } + showToast(message) + respond(ok(null)) + break + } + + default: + // Unknown xuqm.* action — not handled by built-in bridge + return false + } + } catch (err) { + respond(fail(err instanceof Error ? err.message : String(err))) + } + + return true +} + +// ─── Config + Controller ─────────────────────────────────────────────────────── + let _config: XWebViewConfig = {} let _controller: XWebViewControllerAPI | null = null