feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

36
src_bak/utils/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import Taro from '@tarojs/taro'
import { clearStorageByLoginUser } from '@/utils/server'
/**
* 登录态 / 游客态 标识。
* - member: 已登录
* - guest : 游客(未登录或登录态过期)
*/
export type UserMode = 'member' | 'guest'
export function isLoggedIn(): boolean {
return !!Taro.getStorageSync('access_token') && !!Taro.getStorageSync('UserId')
}
export function getUserMode(): UserMode {
return isLoggedIn() ? 'member' : 'guest'
}
export function isGuest(): boolean {
return !isLoggedIn()
}
export function goToRegister(options?: { redirect?: string }) {
const redirect = options?.redirect ? `?redirect=${encodeURIComponent(options.redirect)}` : ''
Taro.navigateTo({ url: `/passport/login${redirect}` })
}
export function ensureLoggedIn(redirect?: string): boolean {
if (isLoggedIn()) return true
goToRegister({ redirect })
return false
}
export function logout() {
clearStorageByLoginUser()
}

35
src_bak/utils/common.ts Normal file
View File

@@ -0,0 +1,35 @@
import Taro from '@tarojs/taro'
export function wxParse(htmlText: string) {
return htmlText
.replace(/\<img/gi, '<img style="max-width:100%;height:auto;margin:0;padding:0;display:block;"')
.replace(
/style\s*?=\s*?(['"])(?!.*?text-align)[\s\S]*?\1/ig,
'style="max-width:100%;height:auto;margin:0;padding:0;display:block;"'
)
}
export function copyText(text: string) {
Taro.setClipboardData({
data: text,
success() {
Taro.showToast({
title: '复制成功',
icon: 'success',
duration: 2000,
})
},
fail() {
Taro.showToast({
title: '复制失败',
icon: 'none',
duration: 2000,
})
},
})
}
export default function navTo(url: string) {
const normalizedUrl = url.startsWith('/') ? url : `/${url}`
Taro.navigateTo({ url: normalizedUrl })
}

131
src_bak/utils/geofence.ts Normal file
View File

@@ -0,0 +1,131 @@
export type LngLat = { lng: number; lat: number }
function normalizeLngLat(a: number, b: number): LngLat | null {
if (!Number.isFinite(a) || !Number.isFinite(b)) return null
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180
if (looksLikeLngLat) return { lng: a, lat: b }
if (looksLikeLatLng) return { lng: b, lat: a }
return null
}
function parsePointLike(v: any): LngLat | null {
if (!v) return null
if (Array.isArray(v) && v.length >= 2) {
return normalizeLngLat(Number(v[0]), Number(v[1]))
}
if (typeof v === 'object') {
const a = v.lng ?? v.lon ?? v.longitude ?? v.x
const b = v.lat ?? v.latitude ?? v.y
if (a !== undefined && b !== undefined) {
return normalizeLngLat(Number(a), Number(b))
}
}
if (typeof v === 'string') {
return parseLngLatFromText(v)
}
return null
}
export function parseLngLatFromText(raw: string | undefined): LngLat | null {
const text = (raw || '').trim()
if (!text) return null
const parts = text.split(/[,\s]+/).filter(Boolean)
if (parts.length < 2) return null
const a = parts[0]
const b = parts[1]
if (!a || !b) return null
return normalizeLngLat(parseFloat(a), parseFloat(b))
}
/**
* Parse fence "points" into a polygon point list.
*/
export function parseFencePoints(pointsRaw: string | undefined): LngLat[] {
const text = (pointsRaw || '').trim()
if (!text) return []
if (text.startsWith('[') || text.startsWith('{')) {
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) {
const list = parsed.map(parsePointLike).filter(Boolean) as LngLat[]
if (list.length) return list
if (Array.isArray(parsed[0])) {
const inner = (parsed[0] as any[]).map(parsePointLike).filter(Boolean) as LngLat[]
if (inner.length) return inner
}
}
} catch (_e) {
// fall through
}
}
const segments = text.split(/[;|\n\r]+/).map(s => s.trim()).filter(Boolean)
if (segments.length > 1) {
const list = segments.map(seg => {
const nums = seg.match(/-?\d+(\.\d+)?/g) || []
if (nums.length < 2) return null
const a = nums[0]
const b = nums[1]
if (!a || !b) return null
return normalizeLngLat(parseFloat(a), parseFloat(b))
}).filter(Boolean) as LngLat[]
if (list.length) return list
}
const nums = text.match(/-?\d+(\.\d+)?/g) || []
if (nums.length >= 6 && nums.length % 2 === 0) {
const list: LngLat[] = []
for (let i = 0; i < nums.length; i += 2) {
const a = nums[i]
const b = nums[i + 1]
if (!a || !b) continue
const p = normalizeLngLat(parseFloat(a), parseFloat(b))
if (p) list.push(p)
}
if (list.length) return list
}
return []
}
function pointOnSegment(p: LngLat, a: LngLat, b: LngLat, eps = 1e-9): boolean {
const cross = (b.lat - a.lat) * (p.lng - a.lng) - (b.lng - a.lng) * (p.lat - a.lat)
if (Math.abs(cross) > eps) return false
const dot = (p.lng - a.lng) * (b.lng - a.lng) + (p.lat - a.lat) * (b.lat - a.lat)
if (dot < -eps) return false
const lenSq = (b.lng - a.lng) ** 2 + (b.lat - a.lat) ** 2
return dot <= lenSq + eps
}
export function pointInPolygon(p: LngLat, polygon: LngLat[]): boolean {
if (!polygon || polygon.length < 3) return false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[j]
const b = polygon[i]
if (pointOnSegment(p, a, b)) return true
}
let inside = false
const x = p.lng
const y = p.lat
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lng
const yi = polygon[i].lat
const xj = polygon[j].lng
const yj = polygon[j].lat
const intersect = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersect) inside = !inside
}
return inside
}
export function pointInAnyPolygon(p: LngLat, polygons: LngLat[][]): boolean {
for (const poly of polygons) {
if (pointInPolygon(p, poly)) return true
}
return false
}

114
src_bak/utils/index.ts Normal file
View File

@@ -0,0 +1,114 @@
import dayjs from 'dayjs'
import CryptoJS from 'crypto-js'
/**
* 日期格式化
* @param date 日期
* @param format 格式
* @returns 格式化后的日期字符串
*/
export const formatDate = (date: Date | string, format = 'YYYY-MM-DD HH:mm:ss') => {
return dayjs(date).format(format)
}
/**
* MD5 加密
* @param str 待加密字符串
* @returns MD5 值
*/
export const md5 = (str: string) => {
return CryptoJS.MD5(str).toString()
}
/**
* AES 加密
* @param data 待加密数据
* @param key 密钥
* @returns 加密后的字符串
*/
export const aesEncrypt = (data: string, key: string) => {
return CryptoJS.AES.encrypt(data, key).toString()
}
/**
* AES 解密
* @param encrypted 加密字符串
* @param key 密钥
* @returns 解密后的字符串
*/
export const aesDecrypt = (encrypted: string, key: string) => {
return CryptoJS.AES.decrypt(encrypted, key).toString(CryptoJS.enc.Utf8)
}
/**
* 防抖函数
* @param fn 目标函数
* @param delay 延迟时间
* @returns 防抖后的函数
*/
export const debounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timer: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
/**
* 节流函数
* @param fn 目标函数
* @param interval 间隔时间
* @returns 节流后的函数
*/
export const throttle = <T extends (...args: any[]) => any>(
fn: T,
interval: number
): ((...args: Parameters<T>) => void) => {
let lastTime = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn(...args)
}
}
}
/**
* 深拷贝
* @param obj 目标对象
* @returns 拷贝后的对象
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item)) as T
}
const cloned = {} as T
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
export default {
formatDate,
md5,
aesEncrypt,
aesDecrypt,
debounce,
throttle,
deepClone
}

66
src_bak/utils/invite.ts Normal file
View File

@@ -0,0 +1,66 @@
import Taro from '@tarojs/taro'
export interface InviteParams {
inviter?: string
source?: string
t?: string
}
const STORAGE_KEY = 'invite_params'
export function parseInviteParams(options: any): InviteParams | null {
try {
const query = options?.query || options || {}
if (query.inviter) {
return {
inviter: String(query.inviter),
source: query.source ? String(query.source) : 'share',
t: query.t ? String(query.t) : undefined,
}
}
if (query.scene && String(query.scene).startsWith('uid_')) {
return {
inviter: String(query.scene).replace('uid_', ''),
source: 'qrcode',
t: Date.now().toString(),
}
}
return null
} catch (_error) {
return null
}
}
export function saveInviteParams(params: InviteParams) {
Taro.setStorageSync(STORAGE_KEY, {
...params,
timestamp: Date.now(),
})
}
export function getStoredInviteParams(): InviteParams | null {
const stored = Taro.getStorageSync(STORAGE_KEY)
if (!stored?.inviter) return null
return {
inviter: stored.inviter,
source: stored.source,
t: stored.t,
}
}
export function clearInviteParams() {
Taro.removeStorageSync(STORAGE_KEY)
}
export function hasPendingInvite() {
return !!getStoredInviteParams()?.inviter
}
export function trackInviteSource(_source: string, _inviterId?: number) {
return
}
export async function checkAndHandleInviteRelation() {
clearInviteParams()
return true
}

View File

@@ -0,0 +1,95 @@
import Taro from '@tarojs/taro'
import { isLoggedIn, goToRegister } from './auth'
/**
* 需要登录才能执行的动作类型。
* - 这些点都是"成交前置行为":加购、结算、收藏、领券、查看订单、下单等
* - 业务页(首页/分类/搜索/详情)始终对游客开放,价格字段做脱敏即可
*/
export type LoginRequiredAction =
| 'addToCart'
| 'buyNow'
| 'checkout'
| 'favorite'
| 'receiveCoupon'
| 'viewOrder'
| 'payOrder'
| 'submitComment'
const ACTION_LABEL: Record<LoginRequiredAction, string> = {
addToCart: '加入购物车',
buyNow: '立即购买',
checkout: '结算',
favorite: '收藏',
receiveCoupon: '领取优惠券',
viewOrder: '查看订单',
payOrder: '支付订单',
submitComment: '提交评价',
}
const ACTION_REDIRECT_FALLBACK: Record<LoginRequiredAction, string> = {
addToCart: '/pages/shop/cart',
buyNow: '/pages/shop/cart',
checkout: '/pages/shop/cart',
favorite: '/pages/user/favorite-list',
receiveCoupon: '/pages/index/coupon-center',
viewOrder: '/pages/order/order',
payOrder: '/pages/order/order',
submitComment: '/pages/order/order',
}
export interface RequireLoginOptions {
/** 动作类型,用于生成提示文案 */
action: LoginRequiredAction
/** 登录后跳回的目标 URL相对于本小程序不传则用动作默认值 */
redirect?: string
/** 自定义弹窗标题 */
title?: string
/** 自定义弹窗内容(不传则按 action 自动生成) */
content?: string
/**
* true: 不弹窗,直接跳登录页(仅用于"非常确定用户就要登录"的场景,比如优惠券领取)
* 默认 false: 弹一个非阻塞的二次确认
*/
silent?: boolean
}
/**
* 行为级登录守卫:游客态触发时弹出一个非阻塞的二次确认。
*
* 返回:
* - true : 已登录,可继续执行原动作
* - false : 游客态,已拦截(弹窗点了取消 / 弹窗点了去登录但还在当前页停留)
*
* 微信审核要点:
* 1. 永远不要在 useLaunch / onLoad 里主动调;
* 2. 永远不要在 tabBar 切换时调tabBar 三个页面要 100% 游客可浏览);
* 3. 不要在 onShow 里无脑调,否则审核员每次切回都要点确认,体验差。
*/
export function requireLogin(options: RequireLoginOptions): boolean {
if (isLoggedIn()) return true
const actionLabel = ACTION_LABEL[options.action]
const redirect = options.redirect || ACTION_REDIRECT_FALLBACK[options.action]
const title = options.title || '需要登录'
const content = options.content || `登录后才能${actionLabel},是否前往登录?`
if (options.silent) {
goToRegister({ redirect })
return false
}
Taro.showModal({
title,
content,
confirmText: '去登录',
cancelText: '再逛逛',
confirmColor: '#0e932e',
success: ({ confirm }) => {
if (confirm) {
goToRegister({ redirect })
}
},
})
return false
}

284
src_bak/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 || 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,
}

23
src_bak/utils/server.ts Normal file
View File

@@ -0,0 +1,23 @@
import Taro from '@tarojs/taro'
import { ServerBaseUrl } from '@/config/app'
import type { User } from '@/api/system/user/model'
export const SERVER_API_URL = ServerBaseUrl
export function saveStorageByLoginUser(token: string, user: User) {
Taro.setStorageSync('TenantId', user.tenantId)
Taro.setStorageSync('access_token', token)
Taro.setStorageSync('UserId', user.userId)
Taro.setStorageSync('Phone', user.phone)
Taro.setStorageSync('WxNickName', user.nickname)
Taro.setStorageSync('User', user)
}
export function clearStorageByLoginUser() {
Taro.removeStorageSync('TenantId')
Taro.removeStorageSync('access_token')
Taro.removeStorageSync('UserId')
Taro.removeStorageSync('Phone')
Taro.removeStorageSync('WxNickName')
Taro.removeStorageSync('User')
}