- 将VIP相关文案、图标改为开发者,涉及用户中心、门店中心、审核页等 - 优化登录弹窗UI,改为红色系,提升视觉统一性和交互体验 - 关于我们页面品牌和公司信息内容更新,展示最新企业介绍和业务范围 - VIP升级页新增“所在城市”选择,使用微信原生地区选择器,完善地址信息 - 用户申请表单中新增城市字段及校验,提交时携带城市信息 - 优化审核页面文案,统称开发者身份,明确申请审核内容 - 修复UserRoleController.save接口bug,避免userId被无条件覆盖导致角色绑错 - 动态查询VIP角色ID,避免硬编码,支持多租户环境正确绑定角色
326 lines
18 KiB
TypeScript
326 lines
18 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||
import Taro, { useDidShow } from '@tarojs/taro'
|
||
import { useUser } from '@/hooks/useUser'
|
||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||
import { useRequest } from '@/hooks/useRequest'
|
||
import { getUserCardStats, getUserOrderStats } from '@/api/shop/shopUserCard'
|
||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||
import { listUserRole } from '@/api/system/userRole'
|
||
import type { UserRole } from '@/api/system/userRole/model'
|
||
import { checkAndCacheVipStatus } from '@/utils/vip'
|
||
import { requireLogin } from '@/utils/login-guard'
|
||
import MemberBadge from '@/components/business/MemberBadge'
|
||
import Badge from '@/components/common/Badge'
|
||
import ArrowRight from '@/components/common/ArrowRight'
|
||
|
||
const UserPage: React.FC = () => {
|
||
const scrollHeight = useScrollHeight()
|
||
|
||
const { user, isLoggedIn, loading: userLoading, refreshUser, syncFromStorage } = useUser()
|
||
|
||
// 查询门店关联信息
|
||
const [storeInfo, setStoreInfo] = useState<any>(null)
|
||
// 用户角色列表
|
||
const [userRoles, setUserRoles] = useState<UserRole[]>([])
|
||
|
||
// 获取用户卡片统计(余额/积分/优惠券/礼品卡)
|
||
// stale-while-revalidate: 缓存命中即时展示 + 后台静默刷新
|
||
// localStorage 持久化: 冷启动先读 storage 即时展示,避免骨架屏
|
||
const { data: cardStats, run: runCardStats, loading: cardStatsLoading, mutate: mutateCardStats } = useRequest(getUserCardStats, {
|
||
manual: true,
|
||
cacheKey: 'user_card_stats',
|
||
cacheTime: 30 * 1000,
|
||
onSuccess: (data) => {
|
||
// 请求成功后持久化到本地存储,下次冷启动可直接展示
|
||
Taro.setStorageSync('user_card_stats_v2', data)
|
||
},
|
||
})
|
||
|
||
// 获取用户订单统计(同上 stale-while-revalidate + localStorage 持久化)
|
||
const { data: orderStats, run: runOrderStats, loading: orderStatsLoading, mutate: mutateOrderStats } = useRequest(getUserOrderStats, {
|
||
manual: true,
|
||
cacheKey: 'user_order_stats',
|
||
cacheTime: 30 * 1000,
|
||
onSuccess: (data) => {
|
||
// v3: 字段结构变更(与后端 UserOrderStats DTO 对齐),旧版 v2 缓存自动失效
|
||
Taro.setStorageSync('user_order_stats_v3', data)
|
||
},
|
||
})
|
||
|
||
// 登录后加载数据
|
||
useEffect(() => {
|
||
if (isLoggedIn) {
|
||
// 冷启动优化:先从 localStorage 读取缓存即时展示,消除骨架屏
|
||
const cachedCardStats = Taro.getStorageSync('user_card_stats_v2')
|
||
if (cachedCardStats) mutateCardStats(cachedCardStats)
|
||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||
if (cachedOrderStats) mutateOrderStats(cachedOrderStats)
|
||
// 然后发起网络请求(stale-while-revalidate 后台刷新)
|
||
runCardStats()
|
||
runOrderStats()
|
||
// 查询门店关联
|
||
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
|
||
// 查询用户角色
|
||
listUserRole({ userId: user?.userId }).then(data => setUserRoles(data || [])).catch(() => setUserRoles([]))
|
||
// 检查并缓存 VIP 状态
|
||
checkAndCacheVipStatus((user as any)?.userId || (user as any)?.id)
|
||
} else {
|
||
setStoreInfo(null)
|
||
setUserRoles([])
|
||
}
|
||
}, [isLoggedIn])
|
||
|
||
// 每次页面重新显示时(从登录页返回、从其他页面返回)检查登录状态并刷新数据
|
||
useDidShow(() => {
|
||
// 先从 storage 快速同步,立即更新 UI(无网络延迟)
|
||
const hasUser = syncFromStorage()
|
||
if (hasUser) {
|
||
// 冷启动优化:先从 localStorage 读取上次缓存的数据即时展示,消除骨架屏
|
||
const cachedCardStats = Taro.getStorageSync('user_card_stats_v2')
|
||
if (cachedCardStats) {
|
||
mutateCardStats(cachedCardStats)
|
||
}
|
||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||
if (cachedOrderStats) {
|
||
mutateOrderStats(cachedOrderStats)
|
||
}
|
||
// 然后发起网络请求(stale-while-revalidate 自动在后台刷新)
|
||
runCardStats()
|
||
runOrderStats()
|
||
// 刷新门店关联
|
||
getMyClerk().then(data => setStoreInfo(data)).catch(() => setStoreInfo(null))
|
||
// 刷新用户角色
|
||
const userId = Taro.getStorageSync('UserId')
|
||
if (userId) {
|
||
listUserRole({ userId }).then(data => setUserRoles(data || [])).catch(() => {})
|
||
// 刷新 VIP 状态缓存
|
||
checkAndCacheVipStatus(userId)
|
||
}
|
||
}
|
||
})
|
||
|
||
const menuItems = [
|
||
// 申请成为开发者 - 醒目样式
|
||
{ icon: '🔰', label: '申请成为开发者', url: '/pages/user/vip-upgrade/index', highlight: true, requireAuth: true },
|
||
// 门店中心:仅门店店员/店长显示(通过 /shop/shop-store-user/my 判断)
|
||
...(storeInfo ? [{ icon: '🏪', label: '门店中心', url: '/pages/store/center/index', requireAuth: true }] : []),
|
||
{ icon: '💰', label: '我的钱包', url: '/pages/user/wallet', requireAuth: true },
|
||
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
||
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
||
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
|
||
{ icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
|
||
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
||
// 门店管理:仅门店经理/店员显示
|
||
// ...(storeInfo ? [{ icon: '🏪', label: '门店管理', url: '/pages/store/list/index' }] : []),
|
||
{ icon: '⚙️', label: '设置', url: '/pages/user/setting' },
|
||
]
|
||
|
||
const handleAvatarClick = () => {
|
||
if (!isLoggedIn) {
|
||
Taro.navigateTo({ url: '/passport/login' })
|
||
} else {
|
||
Taro.navigateTo({ url: '/pages/user/profile' })
|
||
}
|
||
}
|
||
|
||
// 菜单项点击:需要登录的功能先拦截
|
||
const handleMenuClick = (item: typeof menuItems[number]) => {
|
||
if ((item as any).requireAuth) {
|
||
if (!requireLogin({
|
||
action: 'viewOrder',
|
||
redirect: item.url,
|
||
content: `登录后才能使用「${item.label}」功能,是否前往登录?`
|
||
})) return
|
||
}
|
||
Taro.navigateTo({ url: item.url })
|
||
}
|
||
|
||
// 订单快捷入口点击:需要登录
|
||
const handleOrderTabClick = (tabIndex: number) => {
|
||
if (!requireLogin({ action: 'viewOrder', redirect: `/pages/order/list?tab=${tabIndex}`, content: '登录后才能查看订单,是否前往登录?' })) return
|
||
Taro.setStorageSync('order_tab', tabIndex)
|
||
Taro.navigateTo({ url: `/pages/order/list?tab=${tabIndex}` })
|
||
}
|
||
|
||
const handleViewAllOrders = () => {
|
||
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
|
||
Taro.navigateTo({ url: '/pages/order/list' })
|
||
}
|
||
|
||
const loading = cardStatsLoading || orderStatsLoading
|
||
|
||
// 是否有可用数据(缓存或请求结果),用于判断是否需要骨架屏
|
||
const hasCardData = !!cardStats
|
||
const hasOrderData = !!orderStats
|
||
|
||
// 下拉刷新状态
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
// 下拉刷新:同时刷新用户信息、卡片统计、订单统计、角色
|
||
const onRefresh = async () => {
|
||
if (!isLoggedIn) { setRefreshing(false); return }
|
||
setRefreshing(true)
|
||
try {
|
||
await Promise.all([refreshUser(), runCardStats(), runOrderStats()])
|
||
// 刷新角色
|
||
const uid = user?.userId
|
||
if (uid) {
|
||
listUserRole({ userId: uid }).then(data => setUserRoles(data || [])).catch(() => {})
|
||
// 刷新 VIP 状态
|
||
checkAndCacheVipStatus(uid)
|
||
}
|
||
} finally {
|
||
setRefreshing(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View className='h-full bg-gray-50'>
|
||
<ScrollView scrollY refresherEnabled={!!isLoggedIn} refresherTriggered={refreshing} onRefresherRefresh={onRefresh} style={{ height: scrollHeight }}>
|
||
{/* 用户信息卡片 */}
|
||
<View className='mx-3 mt-3 p-4 rounded-xl relative overflow-hidden' style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}>
|
||
{/* 装饰性光晕 */}
|
||
<View className='absolute -top-8 -right-8 w-32 h-32 rounded-full opacity-20' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||
<View className='absolute -bottom-4 -left-4 w-20 h-20 rounded-full opacity-10' style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||
<View className='relative z-10'>
|
||
<View className='flex items-center gap-3' onClick={handleAvatarClick}>
|
||
{isLoggedIn && user?.avatar ? (
|
||
<Image className='w-14 h-14 rounded-full border-2 border-white shadow-sm' src={user.avatar} mode='aspectFill' />
|
||
) : (
|
||
<View className='w-14 h-14 rounded-full bg-white bg-opacity-20 flex items-center justify-center border-2 border-white border-opacity-30'>
|
||
<Text className='text-2xl'>👤</Text>
|
||
</View>
|
||
)}
|
||
<View className='flex-1'>
|
||
<Text className='text-lg font-medium text-white block'>
|
||
{isLoggedIn ? (user?.nickname || user?.phone || '用户') : '点击登录'}
|
||
</Text>
|
||
<View className='flex items-center gap-2 mt-1 flex-wrap'>
|
||
{/*{isLoggedIn && (*/}
|
||
{/* <View*/}
|
||
{/* className='inline-flex items-center rounded-full text-xs px-2 py-1'*/}
|
||
{/* style={{ background: 'rgba(255,255,255,0.2)', color: '#ffffff', border: '1px solid rgba(255,255,255,0.3)' }}*/}
|
||
{/* >*/}
|
||
{/* <Text>{(user as any)?.memberLevelName || '普通用户'}</Text>*/}
|
||
{/* </View>*/}
|
||
{/*)}*/}
|
||
{userRoles.map(role => (
|
||
<View
|
||
key={role.roleId}
|
||
className='inline-flex items-center rounded-full text-xs px-2 py-0.5'
|
||
style={{ background: 'rgba(255,255,255,0.2)', color: '#ffffff', border: '1px solid rgba(255,255,255,0.3)' }}
|
||
>
|
||
<Text>{role.roleName}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
{isLoggedIn && <ArrowRight className='text-white text-opacity-60' />}
|
||
</View>
|
||
{/* 数据概览 */}
|
||
{isLoggedIn && (
|
||
<View className='grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-white border-opacity-15'>
|
||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/wallet' })}>
|
||
{!hasCardData ? (
|
||
<View className='h-7 w-16 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||
) : (
|
||
<Text className='text-lg font-bold text-white block'>
|
||
{cardStats?.balance || '0.00'}
|
||
</Text>
|
||
)}
|
||
<Text className='text-xs text-white text-opacity-70'>余额</Text>
|
||
</View>
|
||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/points-record' })}>
|
||
{!hasCardData ? (
|
||
<View className='h-7 w-10 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||
) : (
|
||
<Text className='text-lg font-bold text-white block'>
|
||
{cardStats?.points || (user as any)?.points || 0}
|
||
</Text>
|
||
)}
|
||
<Text className='text-xs text-white text-opacity-70'>积分</Text>
|
||
</View>
|
||
<View className='text-center' onClick={() => Taro.navigateTo({ url: '/pages/user/coupon-list' })}>
|
||
{!hasCardData ? (
|
||
<View className='h-7 w-10 mx-auto rounded animate-pulse' style={{ background: 'rgba(255,255,255,0.25)' }} />
|
||
) : (
|
||
<Text className='text-lg font-bold text-white block'>
|
||
{cardStats?.coupons || 0}
|
||
</Text>
|
||
)}
|
||
<Text className='text-xs text-white text-opacity-70'>优惠券</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
{/* 我的订单快捷入口 */}
|
||
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||
<View className='flex justify-between items-center mb-3'>
|
||
<Text className='text-base font-medium text-gray-800'>我的订单</Text>
|
||
<View className='flex items-center text-xs text-gray-400' onClick={handleViewAllOrders}>
|
||
<Text>全部订单</Text>
|
||
<ArrowRight className='ml-1' />
|
||
</View>
|
||
</View>
|
||
<View className='grid grid-cols-4 gap-2'>
|
||
{[
|
||
// status 与 OrderListStatus 对齐:0待付款 1待发货 3待收货 5已完成
|
||
{ icon: '💳', label: '待付款', tabIndex: 1, count: orderStats?.waitPay },
|
||
{ icon: '📦', label: '待发货', tabIndex: 2, count: orderStats?.waitDeliver },
|
||
{ icon: '🚚', label: '待收货', tabIndex: 3, count: orderStats?.waitReceive },
|
||
{ icon: '✅', label: '已完成', tabIndex: 4, count: orderStats?.completed },
|
||
].map(item => (
|
||
<View
|
||
key={item.tabIndex}
|
||
className={`flex flex-col items-center py-2 relative ${!isLoggedIn ? 'opacity-50' : ''}`}
|
||
onClick={() => handleOrderTabClick(item.tabIndex)}
|
||
>
|
||
<Text className='text-xl mb-1'>{item.icon}</Text>
|
||
<Text className='text-xs text-gray-600'>{item.label}</Text>
|
||
{/* 数量角标:count 为 0 时组件内部不渲染 */}
|
||
<Badge count={item.count} className='absolute -top-1 -right-1' />
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
{/* 功能菜单 */}
|
||
<View className='bg-white rounded-xl mx-3 mt-3 overflow-hidden'>
|
||
{menuItems.map((item, idx) => (
|
||
<View
|
||
key={item.label}
|
||
className={`flex items-center justify-between px-4 py-3 ${
|
||
idx < menuItems.length - 1 ? 'border-b border-gray-50' : ''
|
||
} ${(item as any).highlight ? '' : ''} ${!isLoggedIn && (item as any).requireAuth ? 'opacity-50' : ''}`}
|
||
style={(item as any).highlight ? {
|
||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||
} : {}}
|
||
onClick={() => handleMenuClick(item)}
|
||
>
|
||
<View className='flex items-center gap-3'>
|
||
<View className={`${(item as any).highlight ? 'w-8 h-8 rounded-lg flex items-center justify-center text-lg' : ''}`} style={(item as any).highlight ? { background: 'linear-gradient(135deg, #f59e0b, #d97706)' } : {}}>
|
||
<Text className='text-base'>{(item as any).highlight ? '' : item.icon}{(item as any).highlight ? item.icon : ''}</Text>
|
||
</View>
|
||
<View className='flex items-center gap-2'>
|
||
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-900 font-semibold' : 'text-gray-700'}`}>{item.label}</Text>
|
||
{(item as any).highlight && (
|
||
<View className='px-1.5 py-0.5 rounded-full' style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)' }}>
|
||
<Text className='text-white text-xs'>推荐</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<ArrowRight className={(item as any).highlight ? 'text-amber-700' : 'text-gray-300'} />
|
||
</View>
|
||
))}
|
||
</View>
|
||
<View className='h-6' />
|
||
</ScrollView>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
export default UserPage
|