feat(store): 新增门店中心及VIP会员升级功能

- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示
- 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式
- 新增升级VIP会员入口,突出展示并添加推荐标签
- 新建VIP会员升级页面,包含门店信息表单和VIP权益预览
- 提交升级申请调用新增api,支持状态检测表单只读
- 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口
- 页面加载校验店员身份,非店员拒绝访问并提示
- 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice
- 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
This commit is contained in:
2026-07-04 10:26:47 +08:00
parent 2deaaffe13
commit 994695c405
19 changed files with 1744 additions and 632 deletions

View File

@@ -0,0 +1,389 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { listShopDealerApply, updateShopDealerApply } from '@/api/shop/shopDealerApply'
import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model'
import { addUserRole, listUserRole } from '@/api/system/userRole'
import { getMyClerk } from '@/api/shop/shopStoreUser'
definePageConfig({
navigationBarTitleText: 'VIP会员审核',
})
// Tab 配置
type TabKey = 'pending' | 'approved' | 'rejected'
const TABS: { key: TabKey; label: string; status: number }[] = [
{ key: 'pending', label: '待审核', status: 10 },
{ key: 'approved', label: '已通过', status: 20 },
{ key: 'rejected', label: '已驳回', status: 30 },
]
// VIP 角色信息(对应 system/role 表)
const VIP_ROLE_ID = 2032
const VIP_ROLE_CODE = 'vip'
const VIP_ROLE_NAME = 'VIP会员'
// 格式化时间戳
function formatTime(time?: number | string): string {
if (!time) return '-'
let date: Date
if (typeof time === 'number') {
// 后端 applyTime 是毫秒时间戳
date = new Date(time > 1e12 ? time : time * 1000)
} else {
date = new Date(time)
}
if (isNaN(date.getTime())) return '-'
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${mi}`
}
// 格式化日期为 Spring Boot LocalDateTime 格式
function formatDateTime(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${mi}:${s}`
}
/**
* 给用户添加 VIP 角色
* 先查询是否已有 VIP 角色,没有则新增,避免重复绑定
*/
async function assignVipRole(userId?: number): Promise<void> {
if (!userId) return
// 查询用户已有角色
const userRoles = await listUserRole({ userId })
const hasVip = (userRoles || []).some(
r => r.roleId === VIP_ROLE_ID || r.roleCode === VIP_ROLE_CODE
)
if (hasVip) {
// 已拥有 VIP 角色,跳过
return
}
// 新增 VIP 角色绑定
await addUserRole({
userId,
roleId: VIP_ROLE_ID,
roleCode: VIP_ROLE_CODE,
roleName: VIP_ROLE_NAME,
})
}
const VipReviewPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>('pending')
const [list, setList] = useState<ShopDealerApply[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [isClerk, setIsClerk] = useState(false)
const loadingRef = useRef(false)
// 验证当前用户是否为店员
useEffect(() => {
getMyClerk()
.then(data => {
if (data) {
setIsClerk(true)
} else {
setIsClerk(false)
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
}
})
.catch(() => {
setIsClerk(false)
Taro.showToast({ title: '获取店员信息失败', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
})
}, [])
// 加载申请列表
const loadList = useCallback(async (tab: TabKey) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const tabConfig = TABS.find(t => t.key === tab)
if (!tabConfig) return
// 获取所有申请记录,前端按状态过滤
const data = await listShopDealerApply({})
const filtered = (data || []).filter(item => item.applyStatus === tabConfig.status)
// 按申请时间倒序
filtered.sort((a, b) => {
const ta = a.applyTime ? (typeof a.applyTime === 'number' ? a.applyTime : new Date(a.applyTime).getTime()) : 0
const tb = b.applyTime ? (typeof b.applyTime === 'number' ? b.applyTime : new Date(b.applyTime).getTime()) : 0
return tb - ta
})
setList(filtered)
} catch (e: any) {
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
setList([])
} finally {
loadingRef.current = false
setLoading(false)
}
}, [])
// 切换 tab 时重新加载
useEffect(() => {
if (isClerk) {
loadList(activeTab)
}
}, [activeTab, isClerk, loadList])
// 页面重新显示时刷新
useDidShow(() => {
if (isClerk) {
loadList(activeTab)
}
})
// 审核通过
const handleApprove = (item: ShopDealerApply) => {
Taro.showModal({
title: '确认通过',
content: `确认通过「${item.realName || item.merchantName || '该用户'}」的VIP会员申请\n\n请确认已实地考察核实门店名称和地址。`,
confirmColor: '#22c55e',
success: async (res) => {
if (!res.confirm) return
setSubmitting(true)
try {
await updateShopDealerApply({
applyId: item.applyId,
userId: item.userId,
realName: item.realName,
merchantName: item.merchantName,
address: item.address,
applyType: item.applyType,
applyStatus: 20, // 审核通过
auditTime: Date.now(),
} as ShopDealerApply)
// 审核通过后,给用户添加 VIP 角色
await assignVipRole(item.userId)
Taro.showToast({ title: '已通过', icon: 'success' })
loadList(activeTab)
} catch (e: any) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
setSubmitting(false)
}
},
})
}
// 审核驳回
const handleReject = (item: ShopDealerApply) => {
Taro.showModal({
title: '驳回申请',
content: `确认驳回「${item.realName || item.merchantName || '该用户'}」的VIP会员申请`,
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
setSubmitting(true)
try {
await updateShopDealerApply({
applyId: item.applyId,
userId: item.userId,
realName: item.realName,
merchantName: item.merchantName,
address: item.address,
applyType: item.applyType,
applyStatus: 30, // 驳回
auditTime: Date.now(),
rejectReason: '门店信息核实不通过',
} as ShopDealerApply)
Taro.showToast({ title: '已驳回', icon: 'success' })
loadList(activeTab)
} catch (e: any) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
setSubmitting(false)
}
},
})
}
// 渲染状态标签
const renderStatusTag = (status: number) => {
if (status === 10) {
return (
<View className='px-2 py-0.5 rounded-full bg-amber-100'>
<Text className='text-xs text-amber-600'></Text>
</View>
)
}
if (status === 20) {
return (
<View className='px-2 py-0.5 rounded-full bg-green-100'>
<Text className='text-xs text-green-600'></Text>
</View>
)
}
if (status === 30) {
return (
<View className='px-2 py-0.5 rounded-full bg-red-100'>
<Text className='text-xs text-red-500'></Text>
</View>
)
}
return null
}
// 渲染申请卡片
const renderCard = (item: ShopDealerApply) => {
const canReview = item.applyStatus === 10
return (
<View key={item.applyId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
{/* 头部:申请人 + 状态 */}
<View className='flex justify-between items-center mb-3'>
<View className='flex items-center gap-2'>
<View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center'>
<Text className='text-sm'>👤</Text>
</View>
<View>
<Text className='text-sm font-medium text-gray-800 block'>
{item.realName || item.merchantName || '未知用户'}
</Text>
<Text className='text-xs text-gray-400'>ID: {item.userId}</Text>
</View>
</View>
{renderStatusTag(item.applyStatus!)}
</View>
{/* 门店信息 */}
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<View className='flex items-start gap-2 mb-2'>
<Text className='text-xs text-gray-400 mt-0.5'>🏪</Text>
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className='text-sm text-gray-700 block mt-0.5'>
{item.merchantName || item.realName || '-'}
</Text>
</View>
</View>
<View className='flex items-start gap-2'>
<Text className='text-xs text-gray-400 mt-0.5'>📍</Text>
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className='text-sm text-gray-700 block mt-0.5'>
{item.address || '-'}
</Text>
</View>
</View>
</View>
{/* 时间信息 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-400'>
{formatTime(item.applyTime)}
</Text>
{item.auditTime ? (
<Text className='text-xs text-gray-400'>
{formatTime(item.auditTime)}
</Text>
) : null}
</View>
{/* 驳回原因 */}
{item.applyStatus === 30 && item.rejectReason && (
<View className='bg-red-50 rounded-lg p-3 mb-3'>
<Text className='text-xs text-red-400 block'></Text>
<Text className='text-sm text-red-600 block mt-1'>{item.rejectReason}</Text>
</View>
)}
{/* 操作按钮 */}
{canReview && (
<View className='flex gap-3 border-t border-gray-50 pt-3'>
<View
className='flex-1 py-2.5 rounded-lg border border-red-300 flex items-center justify-center'
onClick={submitting ? undefined : () => handleReject(item)}
>
<Text className='text-sm text-red-500'></Text>
</View>
<View
className='flex-1 py-2.5 rounded-lg flex items-center justify-center'
style={{ backgroundColor: submitting ? '#86efac' : '#22c55e' }}
onClick={submitting ? undefined : () => handleApprove(item)}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
)}
</View>
)
}
if (!isClerk) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)
}
return (
<View className='min-h-full bg-gray-50'>
{/* 提示信息 */}
<View className='bg-green-50 mx-3 mt-3 rounded-xl p-3 flex items-start gap-2'>
<Text className='text-sm'>💡</Text>
<View className='flex-1'>
<Text className='text-xs text-green-700 block font-medium'></Text>
<Text className='text-xs text-green-600 block mt-1 leading-relaxed'>
VIP会员
</Text>
</View>
</View>
{/* Tab 栏 */}
<View className='bg-white mx-3 mt-3 rounded-xl flex'>
{TABS.map(tab => (
<View
key={tab.key}
className={`flex-1 text-center py-3 ${activeTab === tab.key ? 'border-b-2 border-green-500' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
<Text
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}
>
{tab.label}
</Text>
</View>
))}
</View>
{/* 列表 */}
<ScrollView scrollY style={{ height: 'calc(100vh - 180px)' }}>
{loading && list.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'>...</Text>
</View>
) : list.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400 text-sm'>{activeTab === 'pending' ? '待审核' : activeTab === 'approved' ? '已通过' : '已驳回'}</Text>
</View>
) : (
list.map(renderCard)
)}
<View className='h-6' />
</ScrollView>
</View>
)
}
export default VipReviewPage