Files
websopy-taro/src/pages/user/user.tsx
赵忠林 8d4b55098f feat(user): 统一VIP概念为开发者并优化开发者登录及申请
- 将VIP相关文案、图标改为开发者,涉及用户中心、门店中心、审核页等
- 优化登录弹窗UI,改为红色系,提升视觉统一性和交互体验
- 关于我们页面品牌和公司信息内容更新,展示最新企业介绍和业务范围
- VIP升级页新增“所在城市”选择,使用微信原生地区选择器,完善地址信息
- 用户申请表单中新增城市字段及校验,提交时携带城市信息
- 优化审核页面文案,统称开发者身份,明确申请审核内容
- 修复UserRoleController.save接口bug,避免userId被无条件覆盖导致角色绑错
- 动态查询VIP角色ID,避免硬编码,支持多租户环境正确绑定角色
2026-07-22 13:19:10 +08:00

326 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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