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",
|
"name": "@xuqm/rn-common",
|
||||||
"version": "0.3.2",
|
"version": "0.4.0",
|
||||||
"description": "XuqmGroup RN SDK — core: init, network, token management",
|
"description": "XuqmGroup RN SDK — core: init, network, token management",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@ -14,12 +14,18 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
"react-native": ">=0.76.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": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-native": "^0.73.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()
|
const encrypted = tryRequireConfig()
|
||||||
if (!encrypted) return
|
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
|
imEnabled: boolean
|
||||||
pushEnabled: boolean
|
pushEnabled: boolean
|
||||||
licenseEnabled: boolean
|
licenseEnabled: boolean
|
||||||
|
// 日志服务(rn-log 使用)
|
||||||
|
logApiUrl: string
|
||||||
|
logEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XuqmUserInfo {
|
export interface XuqmUserInfo {
|
||||||
@ -44,6 +47,8 @@ export interface XuqmRemoteConfig {
|
|||||||
imEnabled?: boolean
|
imEnabled?: boolean
|
||||||
pushEnabled?: boolean
|
pushEnabled?: boolean
|
||||||
licenseEnabled?: boolean
|
licenseEnabled?: boolean
|
||||||
|
logApiUrl?: string
|
||||||
|
logEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initConfigFromRemote(
|
export function initConfigFromRemote(
|
||||||
@ -61,6 +66,8 @@ export function initConfigFromRemote(
|
|||||||
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
||||||
pushEnabled: remote.pushEnabled ?? true,
|
pushEnabled: remote.pushEnabled ?? true,
|
||||||
licenseEnabled: remote.licenseEnabled ?? false,
|
licenseEnabled: remote.licenseEnabled ?? false,
|
||||||
|
logApiUrl: remote.logApiUrl ?? '',
|
||||||
|
logEnabled: remote.logEnabled ?? false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,10 @@ export interface DeviceInfo {
|
|||||||
model: string
|
model: string
|
||||||
osVersion: string
|
osVersion: string
|
||||||
pushVendor: PushVendor
|
pushVendor: PushVendor
|
||||||
|
/** 设备制造商(如 "APPLE"/"HUAWEI");iOS 暂留 undefined。*/
|
||||||
|
manufacturer?: string
|
||||||
|
/** 厂商 ROM 版本;Android 可选填充,iOS 暂留 undefined。*/
|
||||||
|
vendorVersion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const BRAND_MAP: Record<string, PushVendor> = {
|
const BRAND_MAP: Record<string, PushVendor> = {
|
||||||
|
|||||||
@ -23,7 +23,10 @@ export {
|
|||||||
getXWebViewConfig,
|
getXWebViewConfig,
|
||||||
openXWebView,
|
openXWebView,
|
||||||
setXWebViewController,
|
setXWebViewController,
|
||||||
|
setXWebViewNavigationRef,
|
||||||
|
processJSBridgeMessage,
|
||||||
} from './xwebview/XWebViewBridge'
|
} from './xwebview/XWebViewBridge'
|
||||||
|
export type { XWebViewNavigationRef } from './xwebview/XWebViewBridge'
|
||||||
export type {
|
export type {
|
||||||
XWebViewClickMenu,
|
XWebViewClickMenu,
|
||||||
XWebViewConfig,
|
XWebViewConfig,
|
||||||
@ -35,3 +38,18 @@ export type {
|
|||||||
XWebViewMessageEvent,
|
XWebViewMessageEvent,
|
||||||
XWebViewPermissionRequest,
|
XWebViewPermissionRequest,
|
||||||
} from './xwebview/types'
|
} 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,
|
imEnabled: remote.imEnabled as boolean | undefined,
|
||||||
pushEnabled: remote.pushEnabled as boolean | undefined,
|
pushEnabled: remote.pushEnabled as boolean | undefined,
|
||||||
licenseEnabled: remote.licenseEnabled as boolean | undefined,
|
licenseEnabled: remote.licenseEnabled as boolean | undefined,
|
||||||
|
logApiUrl: remote.logApiUrl as string | undefined,
|
||||||
|
logEnabled: remote.logEnabled as boolean | undefined,
|
||||||
})
|
})
|
||||||
configureHttp({
|
configureHttp({
|
||||||
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
|
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'
|
import type { XWebViewConfig } from './types'
|
||||||
|
|
||||||
export type XWebViewControllerAPI = {
|
export type XWebViewControllerAPI = {
|
||||||
@ -10,6 +14,176 @@ export type XWebViewControllerAPI = {
|
|||||||
getTitle: () => string
|
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 _config: XWebViewConfig = {}
|
||||||
let _controller: XWebViewControllerAPI | null = null
|
let _controller: XWebViewControllerAPI | null = null
|
||||||
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户