feat(address): 新增收货地址与售后申请功能

- 新增地址编辑页面,支持地址智能识别、地图选点、地区选择和默认地址设置
- 实现地址列表页面,支持地址查看、删除、设为默认及选择返回结算页
- 新增售后申请页面,支持退款类型选择、商品选择、原因填写、图片上传和提交审核
- 修复 passport 分包配置,移除不存在分包并补充缺失声明,避免 Taro 编译报错
- 新增地址类型定义,增强前端地址数据结构类型安全
- 优化页面交互体验,完善表单校验及错误提示逻辑
- 统一代码格式与命名规范,保持代码风格一致性
This commit is contained in:
2026-07-16 01:18:42 +08:00
commit 9df90c1176
658 changed files with 85984 additions and 0 deletions

284
src/utils/request.ts Normal file
View File

@@ -0,0 +1,284 @@
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) {
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,
}