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

30
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,30 @@
// Context Hooks
export { useAppContext } from './useAppContext'
export { useUser } from './useUser'
export { useCartContext } from '../contexts/CartContext'
// Theme
export { useTheme } from './useTheme'
// Utility Hooks
export { useCountDown } from './useCountDown'
export type { CountDownResult } from './useCountDown'
export { useCountUp } from './useCountUp'
export type { CountUpResult, CountUpOptions } from './useCountUp'
export { useRequest } from './useRequest'
export type {
RequestState,
UseRequestOptions,
UseRequestReturn,
} from './useRequest'
// Feature Hooks
export { useAddress } from './useAddress'
export { useCoupon } from './useCoupon'
export { usePagination } from './usePagination'
export { usePayment } from './usePayment'
export { useScrollHeight } from './useScrollHeight'
export { useNewOrderDetector } from './useNewOrderDetector'
export type { NewOrderItem, UseNewOrderDetectorOptions, UseNewOrderDetectorReturn } from './useNewOrderDetector'

120
src/hooks/useAddress.ts Normal file
View File

@@ -0,0 +1,120 @@
import { useState, useCallback } from 'react'
import {
listShopUserAddress,
getShopUserAddress,
addShopUserAddress,
updateShopUserAddress,
removeShopUserAddress,
setDefaultAddress,
} from '@/api/shop/shopUserAddress'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
interface UseAddressReturn {
addresses: ShopUserAddress[]
loading: boolean
defaultAddress: ShopUserAddress | null
loadAddresses: () => Promise<void>
getAddress: (id: number) => Promise<ShopUserAddress | null>
addAddress: (data: Partial<ShopUserAddress>) => Promise<boolean>
updateAddress: (data: ShopUserAddress) => Promise<boolean>
deleteAddress: (id: number) => Promise<boolean>
setDefault: (id: number) => Promise<boolean>
}
/**
* 兜底:保证列表中最多只有一个地址是 isDefault=true。
* 兼容历史脏数据(之前没有"取消其他默认"逻辑时遗留下来的多个默认)。
* 保留最早创建id 最小)的为默认;其余的在前端展示时按非默认处理。
*/
function ensureSingleDefault(list: ShopUserAddress[]): ShopUserAddress[] {
const defaults = list.filter(a => a.isDefault)
if (defaults.length <= 1) return list
// 多个默认:保留 createTime 最早的;如果都缺 createTime则保留 id 最小的
const sorted = [...defaults].sort((a, b) => {
const at = a.createTime || ''
const bt = b.createTime || ''
if (at && bt) return at < bt ? -1 : at > bt ? 1 : 0
if (at) return -1
if (bt) return 1
return (a.id || 0) - (b.id || 0)
})
const keepId = sorted[0].id
return list.map(a => {
if (a.isDefault && a.id !== keepId) {
return {...a, isDefault: false}
}
return a
})
}
export function useAddress(): UseAddressReturn {
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
const [loading, setLoading] = useState(false)
const loadAddresses = useCallback(async () => {
setLoading(true)
try {
const list = await listShopUserAddress()
const raw: ShopUserAddress[] = Array.isArray(list) ? list : (list as any)?.data || []
setAddresses(ensureSingleDefault(raw))
} catch (error) {
console.error('Load addresses error:', error)
setAddresses([])
} finally {
setLoading(false)
}
}, [])
/** 根据 ID 获取单个地址 */
const getAddress = useCallback(async (id: number): Promise<ShopUserAddress | null> => {
try {
return await getShopUserAddress(id)
} catch {
return null
}
}, [])
const addAddress = useCallback(async (data: Partial<ShopUserAddress>): Promise<boolean> => {
try {
await addShopUserAddress(data as any)
await loadAddresses()
return true
} catch {
return false
}
}, [loadAddresses])
const updateAddress = useCallback(async (data: ShopUserAddress): Promise<boolean> => {
try {
await updateShopUserAddress(data)
await loadAddresses()
return true
} catch {
return false
}
}, [loadAddresses])
const deleteAddress = useCallback(async (id: number): Promise<boolean> => {
try {
await removeShopUserAddress(id)
setAddresses(prev => prev.filter(a => a.id !== id))
return true
} catch {
return false
}
}, [])
const setDefault = useCallback(async (id: number): Promise<boolean> => {
try {
await setDefaultAddress(id)
await loadAddresses()
return true
} catch {
return false
}
}, [loadAddresses])
const defaultAddress = addresses.find(a => a.isDefault) || addresses[0] || null
return { addresses, loading, defaultAddress, loadAddresses, getAddress, addAddress, updateAddress, deleteAddress, setDefault }
}

View File

@@ -0,0 +1,12 @@
import { useContext } from 'react'
import AppContext from '@/contexts/AppContext'
export const useAppContext = () => {
const context = useContext(AppContext)
if (!context) {
throw new Error('useAppContext 必须在 AppContext.Provider 内使用')
}
return context
}

145
src/hooks/useCountDown.ts Normal file
View File

@@ -0,0 +1,145 @@
import { useState, useEffect, useCallback, useRef } from 'react'
export interface CountDownResult {
/** 剩余秒数 */
seconds: number
/** 剩余分钟 */
minutes: number
/** 剩余小时 */
hours: number
/** 剩余天数 */
days: number
/** 总剩余秒数 */
totalSeconds: number
/** 是否在运行 */
isRunning: boolean
/** 是否已结束 */
isExpired: boolean
/** 格式化后的字符串 */
formatted: string
/** 暂停 */
pause: () => void
/** 继续 */
resume: () => void
/** 重置 */
reset: (newTargetDate?: number) => void
}
/**
* 倒计时 Hook
* @param targetDate 目标时间戳毫秒0 表示立即结束
* @param options 配置选项
*/
export function useCountDown(
targetDate: number,
options: {
/** 触发间隔(毫秒),默认 1000 */
interval?: number
/** 结束时的回调 */
onComplete?: () => void
/** 是否自动开始,默认 true */
autoStart?: boolean
/** 格式化函数,默认返回 HH:MM:SS 或 DD天HH:MM:SS */
format?: (result: Pick<CountDownResult, 'days' | 'hours' | 'minutes' | 'seconds'>) => string
} = {}
): CountDownResult {
const { interval = 1000, onComplete, autoStart = true, format } = options
const targetRef = useRef(targetDate)
const onCompleteRef = useRef(onComplete)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [state, setState] = useState(() => calculateTimeLeft(targetDate))
const [isRunning, setIsRunning] = useState(autoStart)
// 更新回调引用
useEffect(() => {
onCompleteRef.current = onComplete
}, [onComplete])
// 更新目标时间
useEffect(() => {
targetRef.current = targetDate
setState(calculateTimeLeft(targetDate))
}, [targetDate])
// 计算剩余时间
function calculateTimeLeft(target: number) {
const now = Date.now()
const diff = Math.max(0, Math.floor((target - now) / 1000))
return {
totalSeconds: diff,
days: Math.floor(diff / 86400),
hours: Math.floor((diff % 86400) / 3600),
minutes: Math.floor((diff % 3600) / 60),
seconds: diff % 60,
}
}
// 默认格式化
const defaultFormat = useCallback((r: Pick<CountDownResult, 'days' | 'hours' | 'minutes' | 'seconds'>) => {
const pad = (n: number) => String(n).padStart(2, '0')
if (r.days > 0) {
return `${r.days}${pad(r.hours)}:${pad(r.minutes)}:${pad(r.seconds)}`
}
return `${pad(r.hours)}:${pad(r.minutes)}:${pad(r.seconds)}`
}, [])
// 启动定时器
const startInterval = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
const left = calculateTimeLeft(targetRef.current)
setState(left)
if (left.totalSeconds <= 0) {
if (intervalRef.current) clearInterval(intervalRef.current)
setIsRunning(false)
onCompleteRef.current?.()
}
}, interval)
}, [interval])
// 控制定时器
useEffect(() => {
if (isRunning) {
// 立即执行一次
const left = calculateTimeLeft(targetRef.current)
setState(left)
if (left.totalSeconds <= 0) {
setIsRunning(false)
onCompleteRef.current?.()
} else {
startInterval()
}
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [isRunning, startInterval])
const pause = useCallback(() => setIsRunning(false), [])
const resume = useCallback(() => {
if (state.totalSeconds > 0) setIsRunning(true)
}, [state.totalSeconds])
const reset = useCallback((newTarget?: number) => {
const target = newTarget ?? targetDate
targetRef.current = target
setState(calculateTimeLeft(target))
setIsRunning(true)
}, [targetDate])
return {
...state,
isRunning,
isExpired: state.totalSeconds <= 0,
formatted: (format ?? defaultFormat)(state),
pause,
resume,
reset,
}
}

130
src/hooks/useCountUp.ts Normal file
View File

@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback, useRef } from 'react'
export interface CountUpResult {
/** 当前秒数 */
seconds: number
/** 当前分钟 */
minutes: number
/** 当前小时 */
hours: number
/** 总秒数 */
totalSeconds: number
/** 是否在运行 */
isRunning: boolean
/** 格式化后的字符串 */
formatted: string
/** 暂停 */
pause: () => void
/** 继续 */
resume: () => void
/** 重置 */
reset: (newStartFrom?: number) => void
}
export interface CountUpOptions {
/** 起始值(秒),默认 0 */
startFrom?: number
/** 触发间隔(毫秒),默认 1000 */
interval?: number
/** 最大值,超出后停止 */
maxValue?: number
/** 格式化函数 */
format?: (result: Pick<CountUpResult, 'hours' | 'minutes' | 'seconds'>) => string
}
/**
* 正向计时 Hook
* @param options 配置选项
*/
export function useCountUp(options: CountUpOptions = {}): CountUpResult {
const { startFrom = 0, interval = 1000, maxValue, format } = options
const startRef = useRef(startFrom)
const maxRef = useRef(maxValue)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [totalSeconds, setTotalSeconds] = useState(startFrom)
const [isRunning, setIsRunning] = useState(false)
// 计算时分秒
const calcTime = useCallback((total: number) => ({
totalSeconds: total,
hours: Math.floor(total / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
}), [])
const [time, setTime] = useState(() => calcTime(startFrom))
// 默认格式化
const defaultFormat = useCallback((r: Pick<CountUpResult, 'hours' | 'minutes' | 'seconds'>) => {
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(r.hours)}:${pad(r.minutes)}:${pad(r.seconds)}`
}, [])
const startInterval = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
setTotalSeconds(prev => {
const next = prev + Math.floor(interval / 1000)
const max = maxRef.current
if (max !== undefined && next >= max) {
if (intervalRef.current) clearInterval(intervalRef.current)
setIsRunning(false)
return max
}
return next
})
}, interval)
}, [interval])
// 更新 refs
useEffect(() => {
startRef.current = startFrom
setTotalSeconds(startFrom)
setTime(calcTime(startFrom))
}, [startFrom, calcTime])
useEffect(() => {
maxRef.current = maxValue
}, [maxValue])
// 同步 totalSeconds 到 time
useEffect(() => {
setTime(calcTime(totalSeconds))
}, [totalSeconds, calcTime])
// 控制定时器
useEffect(() => {
if (isRunning) {
startInterval()
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [isRunning, startInterval])
const pause = useCallback(() => setIsRunning(false), [])
const resume = useCallback(() => setIsRunning(true), [])
const reset = useCallback((newStart?: number) => {
const start = newStart ?? startFrom
startRef.current = start
setTotalSeconds(start)
setTime(calcTime(start))
setIsRunning(true)
}, [startFrom, calcTime])
return {
...time,
isRunning,
formatted: (format ?? defaultFormat)(time),
pause,
resume,
reset,
}
}

104
src/hooks/useCoupon.ts Normal file
View File

@@ -0,0 +1,104 @@
import { useState, useCallback } from 'react'
import {
listShopUserCoupon,
pageShopUserCoupon,
removeShopUserCoupon,
getMyAvailableCoupons,
getMyUsedCoupons,
getMyExpiredCoupons,
} from '@/api/shop/shopUserCoupon'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
export type CouponTab = 'available' | 'used' | 'expired'
interface UseCouponReturn {
coupons: ShopUserCoupon[]
loading: boolean
tab: CouponTab
setTab: (tab: CouponTab) => void
loadCoupons: () => Promise<void>
deleteCoupon: (id: string) => Promise<boolean>
}
export function useCoupon(): UseCouponReturn {
const [coupons, setCoupons] = useState<ShopUserCoupon[]>([])
const [loading, setLoading] = useState(false)
const [tab, setTab] = useState<CouponTab>('available')
const loadCoupons = useCallback(async () => {
setLoading(true)
try {
let list: ShopUserCoupon[] = []
switch (tab) {
case 'available':
list = await getMyAvailableCoupons()
break
case 'used':
list = await getMyUsedCoupons()
break
case 'expired':
list = await getMyExpiredCoupons()
break
}
setCoupons(list || [])
} catch (error) {
console.error('Load coupons error:', error)
} finally {
setLoading(false)
}
}, [tab])
const deleteCoupon = useCallback(async (id: string): Promise<boolean> => {
try {
await removeShopUserCoupon(Number(id))
setCoupons(prev => prev.filter(c => c.id !== id))
return true
} catch {
return false
}
}, [])
return { coupons, loading, tab, setTab, loadCoupons, deleteCoupon }
}
// 获取优惠券类型文本
export function getCouponTypeText(type?: number): string {
switch (type) {
case 10: return '满减券'
case 20: return '折扣券'
case 30: return '免费券'
case 40: return '无门槛券'
case 50: return '场地使用券'
default: return '优惠券'
}
}
// 获取优惠券状态文本
export function getCouponStatusText(coupon: ShopUserCoupon): string {
if (coupon.status === 1) return '已使用'
if (coupon.status === 2 || coupon.isExpire === 1) return '已过期'
return '未使用'
}
// 格式化优惠券金额
export function formatCouponValue(coupon: ShopUserCoupon): string {
switch (coupon.type) {
case 20:
// 折扣券
return `${coupon.discount || 0}`
case 40:
// 无门槛券
return `¥${coupon.reducePrice || '0'}`
case 50:
// 场地使用券
return coupon.useCount && coupon.useCount > 0 ? `${coupon.useCount}` : '不限次'
default:
// 满减券/免费券
return `¥${coupon.reducePrice || '0'}`
}
}
// 检查优惠券是否即将过期3天内
export function isExpiringSoon(coupon: ShopUserCoupon): boolean {
return coupon.isExpiringSoon === true || (coupon.daysRemaining !== undefined && coupon.daysRemaining <= 3)
}

View File

@@ -0,0 +1,129 @@
import { useRef, useState, useCallback } from 'react'
import { useDidShow, useDidHide } from '@tarojs/taro'
export interface NewOrderItem {
orderId?: number
orderNo?: string
[key: string]: unknown
}
export interface UseNewOrderDetectorOptions {
/** 轮询间隔(毫秒),默认 3000030秒 */
interval?: number
/** 获取最新订单列表(按时间降序),返回前几条用于对比 */
fetchLatestOrders: () => Promise<NewOrderItem[]>
/** 发现新订单时的回调 */
onNewOrders?: (count: number, newOrders: NewOrderItem[]) => void
}
export interface UseNewOrderDetectorReturn {
/** 未读新订单数 */
newOrderCount: number
/** 新订单列表 */
newOrders: NewOrderItem[]
/** 清除未读计数(用户查看后调用) */
clearUnread: () => void
/** 手动触发一次检测 */
checkNow: () => Promise<void>
}
/**
* 新订单轮询检测 Hook
*
* 用于门店订单管理页面,轮询检测是否有新订单产生。
* 页面显示时自动启动轮询,隐藏时暂停,避免无效请求。
*
* @example
* const { newOrderCount, newOrders, clearUnread } = useNewOrderDetector({
* fetchLatestOrders: () => pageShopOrder({ page: 1, limit: 5 }),
* onNewOrders: (count) => Taro.vibrateShort({ type: 'medium' }),
* })
*/
export function useNewOrderDetector(
options: UseNewOrderDetectorOptions,
): UseNewOrderDetectorReturn {
const { interval = 30000, fetchLatestOrders, onNewOrders } = options
// 已见过的所有 orderId 集合(初始化时需要先加载一次建立基准)
const knownOrderIdsRef = useRef<Set<number> | null>(null)
// 轮询定时器
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 是否正在检测中(防止并发)
const detectingRef = useRef(false)
const [newOrderCount, setNewOrderCount] = useState(0)
const [newOrders, setNewOrders] = useState<NewOrderItem[]>([])
/** 执行一次检测 */
const doCheck = useCallback(async () => {
if (detectingRef.current) return
detectingRef.current = true
try {
const list = await fetchLatestOrders()
if (!list || list.length === 0) return
// 首次加载:建立基准线,不触发提醒
if (knownOrderIdsRef.current === null) {
knownOrderIdsRef.current = new Set(
list.map(o => o.orderId).filter(Boolean) as number[],
)
return
}
// 对比检测新订单
const freshOrders: NewOrderItem[] = []
const newIds: number[] = []
for (const order of list) {
const id = order.orderId
if (id != null && !knownOrderIdsRef.current.has(id)) {
freshOrders.push(order)
newIds.push(id)
knownOrderIdsRef.current.add(id)
}
}
if (freshOrders.length > 0) {
setNewOrderCount(prev => prev + freshOrders.length)
setNewOrders(prev => [...freshOrders, ...prev])
onNewOrders?.(freshOrders.length, freshOrders)
}
} catch {
// 静默失败,不影响页面正常使用
} finally {
detectingRef.current = false
}
}, [fetchLatestOrders, onNewOrders])
/** 清除未读 */
const clearUnread = useCallback(() => {
setNewOrderCount(0)
setNewOrders([])
}, [])
/** 手动触发检测 */
const checkNow = useCallback(async () => {
await doCheck()
}, [doCheck])
// 页面显示时:初始化基准线 + 启动轮询
useDidShow(() => {
// 首次加载建立基准(不触发提醒)
doCheck()
// 启动定时轮询
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(doCheck, interval)
})
// 页面隐藏时:停止轮询
useDidHide(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
})
return { newOrderCount, newOrders, clearUnread, checkNow }
}

110
src/hooks/usePagination.ts Normal file
View File

@@ -0,0 +1,110 @@
import { useState, useCallback } from 'react'
import type { PageParam, PageResult } from '@/api/index'
interface UsePaginationOptions<T> {
api: (params: PageParam) => Promise<PageResult<T>>
defaultParams?: Partial<PageParam>
pageSize?: number
immediate?: boolean
}
interface UsePaginationReturn<T> {
list: T[]
loading: boolean
refreshing: boolean
loadingMore: boolean
finished: boolean
total: number
params: PageParam
run: (extraParams?: Record<string, any>) => Promise<void>
refresh: () => Promise<void>
loadMore: () => Promise<void>
reset: () => void
setParams: (params: Partial<PageParam>) => void
}
export function usePagination<T>(options: UsePaginationOptions<T>): UsePaginationReturn<T> {
const { api, defaultParams = {}, pageSize = 10, immediate = false } = options
const [list, setList] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [finished, setFinished] = useState(false)
const [total, setTotal] = useState(0)
const [params, setParamsState] = useState<PageParam>({
page: 1,
limit: pageSize,
...defaultParams,
})
const setParams = (newParams: Partial<PageParam>) => {
setParamsState(prev => ({ ...prev, ...newParams }))
}
const fetchData = useCallback(async (page: number, extraParams?: Record<string, any>) => {
const result = await api({ ...params, ...extraParams, page, limit: pageSize })
setList(result?.list || [])
setTotal(result?.count || 0)
setFinished((result?.list || []).length < pageSize)
return result
}, [api, params, pageSize])
const run = useCallback(async (extraParams?: Record<string, any>) => {
setLoading(true)
try {
await fetchData(1, extraParams)
setParamsState(prev => ({ ...prev, page: 1 }))
} catch (error) {
console.error('Pagination run error:', error)
} finally {
setLoading(false)
}
}, [fetchData])
const refresh = useCallback(async () => {
if (refreshing) return
setRefreshing(true)
setFinished(false)
try {
await fetchData(1)
setParamsState(prev => ({ ...prev, page: 1 }))
} catch (error) {
console.error('Refresh error:', error)
} finally {
setRefreshing(false)
}
}, [fetchData, refreshing])
const loadMore = useCallback(async () => {
if (loadingMore || finished) return
setLoadingMore(true)
try {
const nextPage = (params.page || 1) + 1
const result = await api({ ...params, page: nextPage, limit: pageSize })
const newList = result?.list || []
setList(prev => [...prev, ...newList])
setTotal(result?.count || 0)
setFinished(newList.length < pageSize)
setParamsState(prev => ({ ...prev, page: nextPage }))
} catch (error) {
console.error('LoadMore error:', error)
} finally {
setLoadingMore(false)
}
}, [api, params, pageSize, loadingMore, finished])
const reset = useCallback(() => {
setList([])
setFinished(false)
setTotal(0)
setParamsState(prev => ({ ...prev, page: 1 }))
}, [])
// 立即执行
if (immediate && list.length === 0 && !loading && !refreshing) {
run()
}
return { list, loading, refreshing, loadingMore, finished, total, params, run, refresh, loadMore, reset, setParams }
}

77
src/hooks/usePayment.ts Normal file
View File

@@ -0,0 +1,77 @@
import { useState, useCallback } from 'react'
import Taro from '@tarojs/taro'
interface UsePaymentOptions {
onSuccess?: (orderNo: string) => void
onFail?: (error: any) => void
}
interface UsePaymentReturn {
loading: boolean
error: string | null
requestPayment: (payParams: {
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
orderNo?: string
}) => Promise<boolean>
createAndPay: (createOrderFn: () => Promise<any>) => Promise<boolean>
}
export function usePayment(options: UsePaymentOptions = {}): UsePaymentReturn {
const { onSuccess, onFail } = options
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestPayment = useCallback(async (payParams: {
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
orderNo?: string
}): Promise<boolean> => {
setLoading(true)
setError(null)
try {
await Taro.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType as any,
paySign: payParams.paySign,
})
payParams.orderNo && onSuccess?.(payParams.orderNo)
return true
} catch (err: any) {
if (err.errMsg?.includes('cancel')) {
setError('支付已取消')
} else {
setError(err.errMsg || '支付失败')
onFail?.(err)
}
return false
} finally {
setLoading(false)
}
}, [onSuccess, onFail])
const createAndPay = useCallback(async (createOrderFn: () => Promise<any>): Promise<boolean> => {
setLoading(true)
setError(null)
try {
const result = await createOrderFn()
return await requestPayment(result)
} catch (err: any) {
setError(err.message || '创建订单失败')
onFail?.(err)
return false
} finally {
setLoading(false)
}
}, [requestPayment, onFail])
return { loading, error, requestPayment, createAndPay }
}

335
src/hooks/useRequest.ts Normal file
View File

@@ -0,0 +1,335 @@
import { useState, useEffect, useCallback, useRef } from 'react'
/** 简易 AbortSignal 替代(兼容小程序环境) */
class SimpleAbortSignal {
private _aborted = false
get aborted() { return this._aborted }
abort() { this._aborted = true }
}
class SimpleAbortController {
signal = new SimpleAbortSignal()
abort() { this.signal.abort() }
}
export interface RequestState<T = any> {
/** 请求结果数据 */
data: T | null
/** 是否加载中 */
loading: boolean
/** 错误信息 */
error: Error | null
/** 是否首次加载完成(不管成功或失败) */
initialized: boolean
}
export interface UseRequestOptions<T = any, P extends any[] = any[]> {
/** 手动触发模式下需要调用 run 执行 */
manual?: boolean
/** 初始数据 */
defaultData?: T
/** 请求函数 */
service?: (...args: P) => Promise<T>
/** 成功回调 */
onSuccess?: (data: T, params: P) => void
/** 失败回调 */
onError?: (error: Error, params: P) => void
/** 轮询间隔(毫秒),设置后自动轮询 */
pollingInterval?: number
/** 轮询在页面隐藏时暂停,默认 true */
pollingWhenHidden?: boolean
/** 防抖延迟(毫秒) */
debounceInterval?: number
/** 依赖变化时自动重新请求 */
refreshDeps?: unknown[]
/** 请求节流间隔(毫秒) */
throttleInterval?: number
/** 缓存 key相同 key 的请求结果会被缓存 */
cacheKey?: string
/** 缓存有效期(毫秒) */
cacheTime?: number
/** 重新请求尝试次数 */
retryCount?: number
/** 重试延迟(毫秒),默认 1000 */
retryInterval?: number
}
export interface UseRequestReturn<T = any, P extends any[] = any[]> {
/** 当前状态 */
state: RequestState<T>
/** 请求结果数据state.data 的快捷访问) */
data: T | null
/** 是否加载中state.loading 的快捷访问) */
loading: boolean
/** 错误信息state.error 的快捷访问) */
error: Error | null
/** 是否首次加载完成state.initialized 的快捷访问) */
initialized: boolean
/** 触发请求manual 模式下) */
run: (...params: P) => Promise<T | undefined>
/** 带 loading 的运行 */
runLoading: (...params: P) => Promise<T | undefined>
/** 取消请求 */
cancel: () => void
/** 刷新(使用上次参数) */
refresh: () => void
/** 修改 data不发起请求 */
mutate: (data: T | ((prev: T | null) => T)) => void
/** 当前参数 */
params: P
}
// 简单缓存
const cache = new Map<string, { data: unknown; expireAt: number }>()
function getCache<T>(key: string): T | null {
const item = cache.get(key)
if (!item) return null
if (Date.now() > item.expireAt) {
cache.delete(key)
return null
}
return item.data as T
}
function setCache<T>(key: string, data: T, cacheTime: number) {
cache.set(key, { data, expireAt: Date.now() + cacheTime })
}
/**
* 请求封装 Hook
* @param service 请求函数
* @param options 配置选项
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useRequest<T = any, P extends any[] = any[]>(
service: (...args: P) => Promise<T>,
options: UseRequestOptions<T, P> = {}
): UseRequestReturn<T, P>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useRequest<T = any>(
service: undefined,
options: UseRequestOptions<T, unknown[]> = {}
): UseRequestReturn<T, unknown[]>
export function useRequest<T = any, P extends any[] = any[]>(
service: ((...args: P) => Promise<T>) | undefined,
options: UseRequestOptions<T, P> = {}
): UseRequestReturn<T, P> {
const {
manual = false,
defaultData,
onSuccess,
onError,
pollingInterval,
pollingWhenHidden = true,
debounceInterval,
refreshDeps = [],
throttleInterval,
cacheKey,
cacheTime = 5 * 60 * 1000,
retryCount = 0,
retryInterval = 1000,
} = options
const [state, setState] = useState<RequestState<T>>({
data: defaultData ?? null,
loading: false,
error: null,
initialized: !!defaultData,
})
const [params, setParams] = useState<P>([] as unknown as P)
const serviceRef = useRef(service)
const onSuccessRef = useRef(onSuccess)
const onErrorRef = useRef(onError)
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const abortControllerRef = useRef<SimpleAbortController | null>(null)
const isMountedRef = useRef(true)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const throttleLastRunRef = useRef(0)
// 更新 refs
useEffect(() => {
serviceRef.current = service
}, [service])
useEffect(() => { onSuccessRef.current = onSuccess }, [onSuccess])
useEffect(() => { onErrorRef.current = onError }, [onError])
// 清理
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (pollingTimerRef.current) clearInterval(pollingTimerRef.current)
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
abortControllerRef.current?.abort()
}
}, [])
// 页面可见性处理(使用 Taro 生命周期,兼容小程序环境)
useEffect(() => {
if (!pollingInterval || !pollingWhenHidden) return
// 在小程序中Taro.useDidShow / useDidHide 仅可在页面组件中使用
// useRequest 作为通用 hook 无法直接使用这些,因此跳过
// 如需轮询暂停/恢复能力,可在页面组件中手动调用 cancel / refresh
}, [pollingInterval, pollingWhenHidden])
// 依赖刷新 - 使用 useRef 避免闭包过期
const refreshDepsRef = useRef(refreshDeps)
refreshDepsRef.current = refreshDeps
useEffect(() => {
if (!manual && refreshDepsRef.current.length > 0) {
refresh()
}
}, [...refreshDeps]) // eslint-disable-line
const run = useCallback(async (...p: P): Promise<T | undefined> => {
// 防抖
if (debounceInterval) {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
return new Promise((resolve) => {
debounceTimerRef.current = setTimeout(async () => {
const result = await doRequest(...p)
resolve(result)
}, debounceInterval)
})
}
// 节流
if (throttleInterval) {
const now = Date.now()
if (now - throttleLastRunRef.current < throttleInterval) {
return
}
throttleLastRunRef.current = now
}
return doRequest(...p)
}, [debounceInterval, throttleInterval]) // eslint-disable-line
const doRequest = useCallback(async (...p: P): Promise<T | undefined> => {
if (!serviceRef.current) return
setParams(p)
abortControllerRef.current?.abort()
abortControllerRef.current = new SimpleAbortController()
// 检查缓存 - stale-while-revalidate: 立即展示缓存数据,同时后台刷新
let servedFromCache = false
if (cacheKey) {
const cached = getCache<T>(cacheKey)
if (cached !== null) {
setState({ data: cached, loading: false, error: null, initialized: true })
servedFromCache = true
// 不 return继续发网络请求静默刷新
}
}
// 无缓存时才显示 loading skeleton
if (!servedFromCache) {
setState(prev => ({ ...prev, loading: true, error: null }))
}
try {
let result!: T
let attempt = 0
const maxAttempts = retryCount + 1
while (attempt < maxAttempts) {
try {
result = await serviceRef.current(...p)
break
} catch (err: unknown) {
attempt++
const isAbortError = err instanceof Error && err.message.includes('abort')
if (attempt >= maxAttempts || isAbortError) {
throw err
}
await new Promise(r => setTimeout(r, retryInterval * attempt))
}
}
if (!isMountedRef.current) return
// 缓存
if (cacheKey) {
setCache(cacheKey, result, cacheTime)
}
setState({ data: result, loading: false, error: null, initialized: true })
onSuccessRef.current?.(result, p)
// 轮询
if (pollingInterval && !pollingTimerRef.current) {
pollingTimerRef.current = setInterval(() => {
doRequest(...p)
}, pollingInterval)
}
return result
} catch (err: unknown) {
if (!isMountedRef.current) return
const error = err instanceof Error ? err : new Error(String(err))
// 有缓存时网络失败不清除数据,仅清除 loading 状态
if (!servedFromCache) {
setState({ data: null, loading: false, error, initialized: true })
} else {
setState(prev => ({ ...prev, loading: false }))
}
onErrorRef.current?.(error, p)
return undefined
}
}, [cacheKey, cacheTime, pollingInterval, pollingWhenHidden, retryCount, retryInterval]) // eslint-disable-line
const runLoading = useCallback(async (...p: P) => {
if (!serviceRef.current) return
setParams(p)
return doRequest(...p)
}, [doRequest])
const cancel = useCallback(() => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current)
pollingTimerRef.current = null
}
abortControllerRef.current?.abort()
setState(prev => ({ ...prev, loading: false }))
}, [])
const refresh = useCallback(() => {
if (params.length > 0) {
run(...params)
}
}, [params, run])
const mutate = useCallback((data: T | ((prev: T | null) => T)) => {
setState(prev => ({
...prev,
data: typeof data === 'function' ? (data as (prev: T | null) => T)(prev.data) : data,
}))
}, [])
// 自动执行
useEffect(() => {
if (!manual && service) {
run(...([] as unknown as P))
}
}, []) // eslint-disable-line
return {
state,
data: state.data,
loading: state.loading,
error: state.error,
initialized: state.initialized,
run,
runLoading,
cancel,
refresh,
mutate,
params,
}
}

View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
/**
* 获取可滚动区域高度,替代 calc(100vh - Npx)
* 小程序不支持 vh/vw 单位,此 hook 动态计算可用高度
* @param minus 需要减去的高度rpx 或 px默认 0
*/
export function useScrollHeight(minus: number = 0) {
const [height, setHeight] = useState('100vh')
useEffect(() => {
try {
const systemInfo = Taro.getSystemInfoSync()
// windowHeight 已经减去了导航栏等系统 UI
const available = systemInfo.windowHeight - minus
setHeight(`${available}px`)
} catch {
setHeight('100vh')
}
}, [minus])
return height
}

98
src/hooks/useShare.ts Normal file
View File

@@ -0,0 +1,98 @@
import { useRef, useEffect } from 'react'
import Taro, { useShareAppMessage, useShareTimeline } from '@tarojs/taro'
import { useUser } from './useUser'
export interface UseShareOptions {
/** 分享标题 */
title: string
/** 分享给好友的完整 path含或不含 query如 /pages/shop/product-detail?id=123 */
path?: string
/** 朋友圈的 query不含 page 与 ?),如 id=123会被拼到当前页 path 之后 */
query?: string
/** 封面图(海报临时路径或网络图地址),不传则微信截取页面 */
imageUrl?: string
/** 是否注册朋友圈分享。tabBar 页亦可分享朋友圈(仅在朋友圈打开后的单页模式里 tabBar 不渲染,发起分享不受影响),默认 true */
enableTimeline?: boolean
/** 是否启用「复制链接」(右上角菜单复制链接按钮,基础库 2.14.3+ Beta。开启后进入页面绑定、离开解绑 */
enableCopyUrl?: boolean
}
/** 当前是否处于朋友圈单页模式scene=1154 */
export function isTimelineSinglePage(): boolean {
try {
const options = Taro.getEnterOptionsSync()
return options?.scene === 1154
} catch {
return false
}
}
function buildPath(path: string | undefined, inviterId?: number | string): string {
if (!path) return ''
if (!inviterId) return path
const sep = path.includes('?') ? '&' : '?'
return `${path}${sep}inviter=${inviterId}`
}
function buildQuery(query: string | undefined, inviterId?: number | string): string {
const parts: string[] = []
if (query) parts.push(query)
if (inviterId) parts.push(`inviter=${inviterId}`)
return parts.join('&')
}
/**
* 统一注册「分享给好友」「分享到朋友圈」「复制链接」回调。
* 自动在 path/query 中追加 inviter当前登录用户 id用于分销裂变。
* 朋友圈分享对 tabBar 页同样生效朋友圈打开后为单页模式tabBar 不渲染,但发起分享不受影响)。
* 需在页面 config 中设置 enableShareAppMessage / enableShareTimeline: true 才会显示对应菜单。
*/
export function useShare(options: UseShareOptions) {
const { user } = useUser()
const inviterId = user?.id ?? user?.userId
// 用 ref 保证分享触发时读取到最新 options含异步生成的海报图
const optionsRef = useRef(options)
optionsRef.current = options
// 运行时保底:显式开启分享菜单按钮(即使页面 config 遗漏也能生效)
useEffect(() => {
const menus: Array<'shareAppMessage' | 'shareTimeline'> = ['shareAppMessage']
if (optionsRef.current.enableTimeline !== false) {
menus.push('shareTimeline')
}
Taro.showShareMenu({ menus })
}, [])
useShareAppMessage(() => {
const o = optionsRef.current
return {
title: o.title,
path: buildPath(o.path, inviterId),
imageUrl: o.imageUrl,
}
})
useShareTimeline(() => {
if (optionsRef.current.enableTimeline === false) return {}
const o = optionsRef.current
return {
title: o.title,
query: buildQuery(o.query, inviterId),
imageUrl: o.imageUrl,
}
})
// 复制链接:进入页面绑定、离开解绑(全局监听,需手动管理避免影响其它页面)
useEffect(() => {
if (!options.enableCopyUrl) return
if (typeof Taro.onCopyUrl !== 'function') return
const handler = () => {
const o = optionsRef.current
return { query: buildQuery(o.query, inviterId), title: o.title }
}
Taro.onCopyUrl(handler)
return () => {
if (typeof Taro.offCopyUrl === 'function') Taro.offCopyUrl(handler)
}
}, [options.enableCopyUrl, inviterId])
}

42
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useContext } from 'react'
import AppContext from '@/contexts/AppContext'
export interface ThemeConfig {
/** 当前主题 */
theme: 'light' | 'dark'
/** 是否为深色模式 */
isDark: boolean
/** 是否为浅色模式 */
isLight: boolean
/** 切换主题 */
toggleTheme: () => void
/** 设置为浅色主题 */
setLight: () => void
/** 设置为深色主题 */
setDark: () => void
}
/**
* 主题 Hook
* 从 AppContext 获取主题状态和切换函数
*/
export const useTheme = (): ThemeConfig => {
const { theme, toggleTheme } = useContext(AppContext)
const setLight = () => {
if (theme !== 'light') toggleTheme()
}
const setDark = () => {
if (theme !== 'dark') toggleTheme()
}
return {
theme,
isDark: theme === 'dark',
isLight: theme === 'light',
toggleTheme,
setLight,
setDark,
}
}

63
src/hooks/useUser.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useUserContext } from '@/contexts/UserContext'
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import type { User } from '@/api/system/user/model'
/**
* useUser - 兼容性 Hook
* 优先使用 UserContext在未包裹 UserProvider 时降级为本地状态
*/
export const useUser = () => {
try {
const ctx = useUserContext()
return ctx
} catch {
// 未在 UserProvider 中,使用降级逻辑
return useUserFallback()
}
}
/** 降级方案:不依赖 Context 的本地用户状态 */
const useUserFallback = () => {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const loadFromStorage = () => {
const token = Taro.getStorageSync('access_token')
const userData = Taro.getStorageSync('User')
if (token && userData) {
const parsed = typeof userData === 'string' ? JSON.parse(userData) : userData
setUser(parsed as User)
return true
}
return !!token
}
const loginUser = (token: string, userInfo: User) => {
Taro.setStorageSync('access_token', token)
Taro.setStorageSync('User', userInfo)
setUser(userInfo)
}
const logoutUser = () => {
Taro.removeStorageSync('access_token')
Taro.removeStorageSync('User')
setUser(null)
}
const refreshUser = async () => {
// 降级方案不自动刷新
return user
}
const syncFromStorage = (): boolean => {
return loadFromStorage()
}
useEffect(() => {
loadFromStorage()
setLoading(false)
}, [])
return { user, isLoggedIn: !!user, loading, loginUser, logoutUser, refreshUser, syncFromStorage }
}

48
src/hooks/useVipStatus.ts Normal file
View File

@@ -0,0 +1,48 @@
import { useState, useEffect, useCallback } from 'react'
import { checkAndCacheVipStatus, getVipStatusFromCache } from '@/utils/vip'
import { useUser } from '@/hooks/useUser'
/**
* useVipStatus - 判断当前用户是否为 VIP 会员
*
* 工作原理:
* 1. 首先从 localStorage 缓存快速读取(即时返回)
* 2. 异步调用后端接口验证并更新缓存
*
* 使用场景:
* - 商品详情页价格展示
* - 结算页价格计算
* - 任何需要判断 VIP 身份的页面
*/
export const useVipStatus = () => {
const { user, isLoggedIn } = useUser()
const [isVip, setIsVip] = useState<boolean>(getVipStatusFromCache())
const [loading, setLoading] = useState(false)
const refresh = useCallback(async () => {
if (!isLoggedIn) {
setIsVip(false)
return
}
setLoading(true)
try {
const userId = (user as any)?.userId || (user as any)?.id
const result = await checkAndCacheVipStatus(userId)
setIsVip(result)
} catch {
// 保持缓存值
} finally {
setLoading(false)
}
}, [isLoggedIn, user])
useEffect(() => {
if (isLoggedIn) {
refresh()
} else {
setIsVip(false)
}
}, [isLoggedIn, refresh])
return { isVip, loading, refresh }
}