Files
websopy-taro/src/pages/user/user.tsx
赵忠林 9fba545d34 feat(user): 补充开发者审核通过时添加店员记录功能
- 引入 addShopStoreUser 接口用于新增门店店员记录
- 新增 clerkStoreId 和 clerkTenantId 状态,保存当前审核店员的门店和租户信息
- 在审核通过后调用 addShopStoreUser,添加申请人为当前门店店员
- 保证只有当 clerkStoreId 存在时才执行新增操作
- 调整平台管理页 UI,使其与门店中心保持一致风格
- 门店中心及平台管理入口权限调整,仅超级管理员可访问
- 用户菜单中平台管理和门店中心入口现基于 isSuperAdmin 字段控制
- 优化登录及权限校验流程,避免权限不足时页面闪烁显示
- 更新工作记录文档,补充审核通过流程中新添加的店员记录步骤
2026-07-23 18:25:35 +08:00

330 lines
19 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 },
// 开发者中心:仅 isDeveloper 用户显示
...((user as any)?.isDeveloper ? [{ icon: '🛠️', label: '开发者中心', url: '/pages/developer/index/index', requireAuth: true }] : []),
// 平台管理:仅 isSuperAdmin=1 用户显示
...((user as any)?.isSuperAdmin === 1 ? [{ icon: '🛡️', label: '平台管理', url: '/pages/admin/index/index', requireAuth: true }] : []),
// 门店中心:仅 isSuperAdmin=1 用户显示
...((user as any)?.isSuperAdmin === 1 ? [{ 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