feat(rn-common): v0.4.0 — api/ui 子模块 + config 扩展 + autoInit 修复
- 新增 api/ 子模块:useRequest、useApi、usePageApi(替换 @szyx-mobile) - 新增 ui/ 子模块:showToast、showAlert、showConfirm、configureToast - 新增 globalErrorHandler:setGlobalApiErrorHandler - config.ts 新增 logApiUrl、logEnabled 字段 - device.ts 新增 manufacturer、vendorVersion 可选字段 - autoInit.ts 修复静默降级,__DEV__ 模式下 re-throw - index.ts 补全 api/ui 导出 - package.json 版本升至 0.4.0,新增 axios/react/zod 依赖
这个提交包含在:
父节点
9870a0a368
当前提交
97d4d9498a
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
export class RequestError<TData = unknown> 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'
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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'
|
||||
172
packages/common/src/api/useApi.ts
普通文件
172
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<T> = {
|
||||
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<S> = z.infer<S>,
|
||||
D = unknown,
|
||||
>(
|
||||
url: string,
|
||||
method: ApiMethod,
|
||||
params?: D,
|
||||
validationSchema?: S,
|
||||
options?: RequestOptions,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): {
|
||||
response?: T
|
||||
error?: RequestError<unknown>
|
||||
loading: boolean
|
||||
fetch: (config?: AxiosRequestConfig<D>) => void
|
||||
fetchAsync: (config?: AxiosRequestConfig<D>) => Promise<T>
|
||||
cancel: () => void
|
||||
requestInterceptors: AxiosInterceptorManager<AxiosRequestConfig<unknown>>
|
||||
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, 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<typeof responseSchema, ApiResponse<T>, 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<D>) => {
|
||||
try {
|
||||
const requestResponse = await fetchAsync(nextConfig)
|
||||
return (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data.data
|
||||
} catch (requestError) {
|
||||
return Promise.reject(requestError)
|
||||
}
|
||||
},
|
||||
[fetchAsync],
|
||||
)
|
||||
|
||||
return {
|
||||
response: (response as unknown as AxiosResponse<ApiResponse<T>> | undefined)?.data.data,
|
||||
error: error as RequestError<unknown>,
|
||||
loading,
|
||||
fetch,
|
||||
fetchAsync: transformedFetchAsync,
|
||||
cancel,
|
||||
requestInterceptors: requestInterceptors as AxiosInterceptorManager<AxiosRequestConfig<unknown>>,
|
||||
responseInterceptors: responseInterceptors as AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AxiosRequestConfig, RequestError, RequestOptions }
|
||||
@ -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<T> = {
|
||||
records: T[]
|
||||
}
|
||||
|
||||
type Root<T> = T[]
|
||||
|
||||
interface PageOptions extends RequestOptions {
|
||||
pageSize?: number
|
||||
path?: 'list' | 'root'
|
||||
pagination?: 'cursor' | 'offset'
|
||||
}
|
||||
|
||||
export const usePageApi = <
|
||||
S extends z.ZodTypeAny,
|
||||
T extends z.infer<S> = z.infer<S>,
|
||||
P extends List<T> | Root<T> = List<T>,
|
||||
D = Record<string, unknown>,
|
||||
>(
|
||||
url: string,
|
||||
method: 'GET' | 'POSTJSON' | 'POSTFORM',
|
||||
params?: D,
|
||||
validationSchema?: S,
|
||||
options?: PageOptions,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): {
|
||||
response?: T[]
|
||||
error?: RequestError<unknown>
|
||||
loading: boolean
|
||||
fetch: React.Dispatch<React.SetStateAction<'reload' | 'loadmore'>>
|
||||
cancel: () => void
|
||||
} => {
|
||||
const [data, setData] = useState<T[] | undefined>(undefined)
|
||||
const [status, setStatus] = useState<'loaded' | 'reload' | 'loadmore'>('loaded')
|
||||
|
||||
const listSchema: z.ZodSchema<List<T>> = z.object({
|
||||
records: z.array(validationSchema ?? z.any()),
|
||||
})
|
||||
const rootSchema: z.ZodSchema<Root<T>> = 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<T>)
|
||||
break
|
||||
default:
|
||||
setData((result as List<T>).records)
|
||||
break
|
||||
}
|
||||
setStatus('loaded')
|
||||
return
|
||||
}
|
||||
|
||||
switch (options?.path) {
|
||||
case 'root':
|
||||
setData(previous => [...(previous ?? []), ...(result as Root<T>)])
|
||||
break
|
||||
default:
|
||||
setData(previous => [
|
||||
...(previous ?? []),
|
||||
...(result as List<T>).records,
|
||||
])
|
||||
break
|
||||
}
|
||||
setStatus('loaded')
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('loaded')
|
||||
})
|
||||
}, [data, fetchAsync, options?.pageSize, options?.path, status])
|
||||
|
||||
return {
|
||||
response: data,
|
||||
error: error as RequestError<unknown>,
|
||||
loading,
|
||||
fetch: setStatus as React.Dispatch<React.SetStateAction<'reload' | 'loadmore'>>,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AxiosRequestConfig, PageOptions }
|
||||
@ -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<T = unknown, D = unknown> extends z.ZodError {
|
||||
constructor(
|
||||
issues: z.ZodIssue[],
|
||||
public readonly response: AxiosResponse<T, D>,
|
||||
) {
|
||||
super(issues)
|
||||
this.name = 'ValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface State<R> {
|
||||
response: R | undefined
|
||||
error: RequestError | undefined
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
type Action<R> =
|
||||
| { type: 'start' }
|
||||
| { type: 'resolve'; response: R }
|
||||
| { type: 'reject'; error: RequestError }
|
||||
|
||||
function reducer<R>(state: State<R>, action: Action<R>): State<R> {
|
||||
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<S> = z.infer<S>,
|
||||
D = unknown,
|
||||
R = AxiosResponse<T, D>,
|
||||
>(
|
||||
url: string,
|
||||
method: 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD',
|
||||
params?: D,
|
||||
validationSchema?: S,
|
||||
options?: RequestOptions,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): {
|
||||
response: R | undefined
|
||||
error: RequestError | undefined
|
||||
loading: boolean
|
||||
fetch: (config?: AxiosRequestConfig<D>) => void
|
||||
fetchAsync: (config?: AxiosRequestConfig<D>) => Promise<R>
|
||||
cancel: () => void
|
||||
requestInterceptors: ReturnType<typeof axios.create>['interceptors']['request']
|
||||
responseInterceptors: ReturnType<typeof axios.create>['interceptors']['response']
|
||||
} {
|
||||
const [state, dispatch] = useReducer(reducer<R>, initialState as State<R>)
|
||||
|
||||
// axios 实例用 useRef 持有,避免每次 render 重建(丢失拦截器)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const instance = useMemo(() => axios.create(), [])
|
||||
|
||||
const abortControllerRef = useRef<AbortController | undefined>(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<D>): Promise<R> => {
|
||||
const loadingDelay = optionsRef.current?.loadingDelay ?? 0
|
||||
const timer = setTimeout(() => { dispatch({ type: 'start' }) }, loadingDelay)
|
||||
|
||||
cancel()
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// Content-Type 按 method 设置
|
||||
const methodHeaders: Record<string, string> = {}
|
||||
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<D> = {
|
||||
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<string, string> | undefined),
|
||||
...(overrideConfig?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
signal: abortControllerRef.current.signal,
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await instance.request<T, AxiosResponse<T, D>, 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<D>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, PushVendor> = {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { showToast, showAlert, showConfirm, configureToast } from './feedback'
|
||||
@ -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<string, unknown>) => 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<string>
|
||||
|
||||
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<boolean> {
|
||||
let parsed: {
|
||||
__xuqm?: string
|
||||
id?: string | number
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown> | 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
|
||||
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户