- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
285 lines
8.4 KiB
TypeScript
285 lines
8.4 KiB
TypeScript
import Taro from '@tarojs/taro'
|
||
import { BaseUrl, TenantId } from '@/config/app'
|
||
import { clearStorageByLoginUser } from '@/utils/server'
|
||
|
||
export interface RequestConfig {
|
||
url: string
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||
data?: any
|
||
header?: Record<string, string>
|
||
timeout?: number
|
||
retry?: number
|
||
showLoading?: boolean
|
||
showError?: boolean
|
||
returnRaw?: boolean
|
||
}
|
||
|
||
interface ApiResponse<T = any> {
|
||
code: number
|
||
message?: string
|
||
data?: T
|
||
}
|
||
|
||
export enum ErrorType {
|
||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
|
||
BUSINESS_ERROR = 'BUSINESS_ERROR',
|
||
AUTH_ERROR = 'AUTH_ERROR',
|
||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||
}
|
||
|
||
export class RequestError extends Error {
|
||
public type: ErrorType
|
||
public code?: number
|
||
public data?: any
|
||
|
||
constructor(message: string, type: ErrorType, code?: number, data?: any) {
|
||
super(message)
|
||
this.name = 'RequestError'
|
||
this.type = type
|
||
this.code = code
|
||
this.data = data
|
||
}
|
||
}
|
||
|
||
const DEFAULT_CONFIG: Required<Pick<RequestConfig, 'timeout' | 'retry' | 'showLoading' | 'showError' | 'returnRaw'>> = {
|
||
timeout: 10000,
|
||
retry: 2,
|
||
showLoading: false,
|
||
showError: true,
|
||
returnRaw: true,
|
||
}
|
||
|
||
const baseUrl = BaseUrl
|
||
|
||
const requestInterceptor = (config: RequestConfig): RequestConfig => {
|
||
const token = Taro.getStorageSync('access_token')
|
||
const tenantId = Taro.getStorageSync('TenantId') || TenantId
|
||
|
||
const defaultHeaders: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
TenantId: String(tenantId),
|
||
}
|
||
|
||
if (token) {
|
||
defaultHeaders.Authorization = token
|
||
}
|
||
|
||
config.header = { ...defaultHeaders, ...config.header }
|
||
|
||
if (config.showLoading) {
|
||
Taro.showLoading({ title: '加载中...' })
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
// Redis 临时故障关键词 - 这类 401 是服务端问题,不应清除本地 token
|
||
const REDIS_ERROR_KEYWORDS = ['RedisSystemException', 'RedisException', 'Operation timed out', 'Redis']
|
||
|
||
const isRedisError = (message?: string): boolean => {
|
||
if (!message) return false
|
||
return REDIS_ERROR_KEYWORDS.some((keyword) => message.includes(keyword))
|
||
}
|
||
|
||
// 防抖:避免同一时间多个接口都触发跳登录页
|
||
let authErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const handleAuthError = (message?: string) => {
|
||
// Redis 临时故障导致的 401,不清除 token,静默处理
|
||
if (isRedisError(message)) {
|
||
console.warn('[Auth] Redis 临时故障导致 401,忽略处理:', message)
|
||
return
|
||
}
|
||
|
||
clearStorageByLoginUser()
|
||
|
||
// 防抖:多个接口同时 401 只弹一次 Toast
|
||
if (authErrorTimer) return
|
||
authErrorTimer = setTimeout(() => {
|
||
authErrorTimer = null
|
||
}, 3000)
|
||
|
||
Taro.showToast({
|
||
title: '登录已过期,请重新登录',
|
||
icon: 'none',
|
||
duration: 2000,
|
||
})
|
||
}
|
||
|
||
const responseInterceptor = <T>(response: any, config: RequestConfig): T => {
|
||
if (config.showLoading) {
|
||
Taro.hideLoading()
|
||
}
|
||
|
||
const { statusCode, data } = response
|
||
|
||
if (statusCode !== 200) {
|
||
throw new RequestError(`HTTP错误: ${statusCode}`, ErrorType.NETWORK_ERROR, statusCode, data)
|
||
}
|
||
|
||
if (data === null || data === undefined) {
|
||
throw new RequestError('响应数据为空', ErrorType.NETWORK_ERROR, statusCode, data)
|
||
}
|
||
|
||
if (typeof data === 'object' && data !== null && 'code' in data) {
|
||
const apiResponse = data as ApiResponse<T>
|
||
|
||
if (apiResponse.code === 0 || apiResponse.code === 200) {
|
||
return (config.returnRaw ? data : apiResponse.data) as T
|
||
}
|
||
|
||
if (apiResponse.code === 401 || apiResponse.code === 403) {
|
||
handleAuthError(apiResponse.message)
|
||
throw new RequestError(apiResponse.message || '认证失败', ErrorType.AUTH_ERROR, apiResponse.code, apiResponse.data)
|
||
}
|
||
|
||
throw new RequestError(apiResponse.message || '请求失败', ErrorType.BUSINESS_ERROR, apiResponse.code, apiResponse.data)
|
||
}
|
||
|
||
return data as T
|
||
}
|
||
|
||
const handleError = (error: RequestError, config: RequestConfig) => {
|
||
if (config.showLoading) {
|
||
Taro.hideLoading()
|
||
}
|
||
|
||
if (!config.showError) return
|
||
|
||
const titleMap: Record<ErrorType, string> = {
|
||
[ErrorType.NETWORK_ERROR]: '网络连接失败',
|
||
[ErrorType.TIMEOUT_ERROR]: '请求超时',
|
||
[ErrorType.BUSINESS_ERROR]: error.message || '操作失败',
|
||
[ErrorType.AUTH_ERROR]: '认证失败',
|
||
[ErrorType.UNKNOWN_ERROR]: '未知错误',
|
||
}
|
||
|
||
Taro.showToast({
|
||
title: titleMap[error.type] || '请求失败',
|
||
icon: 'none',
|
||
duration: 2000,
|
||
})
|
||
}
|
||
|
||
const executeRequest = <T>(config: RequestConfig): Promise<T> =>
|
||
new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
reject(new RequestError('请求超时', ErrorType.TIMEOUT_ERROR))
|
||
}, config.timeout || DEFAULT_CONFIG.timeout)
|
||
|
||
Taro.request({
|
||
url: config.url,
|
||
method: config.method || 'GET',
|
||
data: config.data || {},
|
||
header: config.header || {},
|
||
success: (res) => {
|
||
clearTimeout(timer)
|
||
try {
|
||
resolve(responseInterceptor<T>(res, config))
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
clearTimeout(timer)
|
||
reject(new RequestError(err.errMsg || '网络请求失败', ErrorType.NETWORK_ERROR, undefined, err))
|
||
},
|
||
})
|
||
})
|
||
|
||
const retryRequest = async <T>(config: RequestConfig, retryCount = 0): Promise<T> => {
|
||
try {
|
||
return await executeRequest<T>(config)
|
||
} catch (error) {
|
||
const requestError = error as RequestError
|
||
if (requestError.type === ErrorType.AUTH_ERROR || requestError.type === ErrorType.BUSINESS_ERROR) {
|
||
throw error
|
||
}
|
||
if (retryCount < (config.retry || DEFAULT_CONFIG.retry)) {
|
||
await new Promise((resolve) => setTimeout(resolve, 1000 * (retryCount + 1)))
|
||
return retryRequest<T>(config, retryCount + 1)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
export async function request<T>(options: RequestConfig): Promise<T> {
|
||
try {
|
||
const config = requestInterceptor({ ...DEFAULT_CONFIG, ...options })
|
||
return await retryRequest<T>(config)
|
||
} catch (error) {
|
||
const requestError = error as RequestError
|
||
handleError(requestError, options)
|
||
throw requestError
|
||
}
|
||
}
|
||
|
||
const buildUrl = (url: string): string => {
|
||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||
return url
|
||
}
|
||
return `${baseUrl}${url}`
|
||
}
|
||
|
||
const buildQueryString = (params: Record<string, any>): string => {
|
||
const queryString = Object.keys(params)
|
||
.filter((key) => params[key] !== undefined && params[key] !== null)
|
||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||
.join('&')
|
||
return queryString ? `?${queryString}` : ''
|
||
}
|
||
|
||
export function get<T>(url: string, params?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
const fullUrl = buildUrl(url) + (params ? buildQueryString(params) : '')
|
||
return request<T>({ url: fullUrl, method: 'GET', returnRaw: true, ...config })
|
||
}
|
||
|
||
export function post<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'POST', data, returnRaw: true, ...config })
|
||
}
|
||
|
||
export function put<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'PUT', data, returnRaw: true, ...config })
|
||
}
|
||
|
||
export function patch<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'PATCH', data, returnRaw: true, ...config })
|
||
}
|
||
|
||
export function del<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'DELETE', data, returnRaw: true, ...config })
|
||
}
|
||
|
||
export function getData<T>(url: string, params?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
const fullUrl = buildUrl(url) + (params ? buildQueryString(params) : '')
|
||
return request<T>({ url: fullUrl, method: 'GET', returnRaw: false, ...config })
|
||
}
|
||
|
||
export function postData<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'POST', data, returnRaw: false, ...config })
|
||
}
|
||
|
||
export function putData<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'PUT', data, returnRaw: false, ...config })
|
||
}
|
||
|
||
export function delData<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
|
||
return request<T>({ url: buildUrl(url), method: 'DELETE', data, returnRaw: false, ...config })
|
||
}
|
||
|
||
export default {
|
||
request,
|
||
get,
|
||
post,
|
||
put,
|
||
patch,
|
||
del,
|
||
getData,
|
||
postData,
|
||
putData,
|
||
delData,
|
||
ErrorType,
|
||
RequestError,
|
||
}
|