- 门店中心右上角新增扫码登录按钮,使用base64 SVG图标兼容小程序Image组件渲染 - 扫码后通过解析二维码内容调用confirmWechatQRLogin接口完成PC端登录确认 - 支持扫码取消静默处理,扫码失败则弹窗提示错误信息 - 优化门店中心页面布局,调整扫码按钮绝对定位及样式 - 优化了用户管理页面的小型样式,包括按钮和文本间距调整 - 调整用户页面部分Badge显示逻辑,排除tabIndex为4的项不显示角标
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
|
||
import Taro, { useDidShow } from '@tarojs/taro'
|
||
import { pageUsers, updateUserStatus } from '@/api/system/user'
|
||
import type { User } from '@/api/system/user/model'
|
||
import { getCompressedImageUrl } from '@/utils/image'
|
||
|
||
definePageConfig({
|
||
navigationBarTitleText: '用户管理',
|
||
})
|
||
|
||
// ─── Tab 配置 ──────────────────────────────────────────────────
|
||
type TabKey = 'all' | 'normal' | 'disabled'
|
||
|
||
const TABS: { key: TabKey; label: string; status?: number }[] = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'normal', label: '正常', status: 0 },
|
||
{ key: 'disabled', label: '已禁用', status: 1 },
|
||
]
|
||
|
||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||
export default function StoreUsersPage() {
|
||
const [activeTab, setActiveTab] = useState<TabKey>('all')
|
||
|
||
// 用户列表与分页
|
||
const [userList, setUserList] = useState<User[]>([])
|
||
const [page, setPage] = useState(1)
|
||
const [hasMore, setHasMore] = useState(true)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
// 搜索
|
||
const [keyword, setKeyword] = useState('')
|
||
const [searchText, setSearchText] = useState('')
|
||
|
||
// 用户详情弹窗
|
||
const [showDetail, setShowDetail] = useState(false)
|
||
const [detailUser, setDetailUser] = useState<User | null>(null)
|
||
|
||
const pageSize = 20
|
||
const loadingRef = useRef(false)
|
||
|
||
/** 加载用户列表 */
|
||
const loadUsers = useCallback(async (tab: TabKey, pageNo: number = 1, append = false, kw?: string) => {
|
||
if (loadingRef.current) return
|
||
loadingRef.current = true
|
||
setLoading(true)
|
||
|
||
try {
|
||
const tabConfig = TABS.find(t => t.key === tab)
|
||
const params: any = {
|
||
page: pageNo,
|
||
limit: pageSize,
|
||
}
|
||
if (tabConfig?.status !== undefined) {
|
||
params.status = tabConfig.status
|
||
}
|
||
const effectiveKw = kw !== undefined ? kw : keyword
|
||
if (effectiveKw) params.keywords = effectiveKw
|
||
|
||
const res = await pageUsers(params)
|
||
const list = res?.list || []
|
||
|
||
setUserList(prev => append ? [...prev, ...list] : list)
|
||
setPage(pageNo)
|
||
setHasMore(list.length >= pageSize)
|
||
} catch (e: any) {
|
||
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||
if (!append) setUserList([])
|
||
} finally {
|
||
loadingRef.current = false
|
||
setLoading(false)
|
||
}
|
||
}, [keyword])
|
||
|
||
// 切换 tab 时重新加载
|
||
useEffect(() => {
|
||
loadUsers(activeTab, 1)
|
||
}, [activeTab, loadUsers])
|
||
|
||
// 页面重新显示时刷新
|
||
useDidShow(() => {
|
||
loadUsers(activeTab, 1)
|
||
})
|
||
|
||
/** 加载更多 */
|
||
const handleLoadMore = () => {
|
||
if (hasMore && !loadingRef.current) {
|
||
loadUsers(activeTab, page + 1, true)
|
||
}
|
||
}
|
||
|
||
/** 执行搜索 */
|
||
const handleSearch = () => {
|
||
setKeyword(searchText)
|
||
loadUsers(activeTab, 1, false, searchText)
|
||
}
|
||
|
||
/** 清除搜索 */
|
||
const handleClearSearch = () => {
|
||
setSearchText('')
|
||
setKeyword('')
|
||
loadUsers(activeTab, 1, false, '')
|
||
}
|
||
|
||
/** 切换用户状态(禁用/启用) */
|
||
const handleToggleStatus = (user: User) => {
|
||
const currentStatus = user.status ?? 0
|
||
const newStatus = currentStatus === 0 ? 1 : 0
|
||
const actionText = newStatus === 1 ? '禁用' : '启用'
|
||
|
||
Taro.showModal({
|
||
title: '确认操作',
|
||
content: `确定要${actionText}用户「${user.nickname || user.realName || user.phone || user.userId}」吗?${newStatus === 1 ? '禁用后该用户将无法登录。' : ''}`,
|
||
confirmColor: newStatus === 1 ? '#ee0a24' : '#0e932e',
|
||
success: async (res) => {
|
||
if (!res.confirm) return
|
||
try {
|
||
Taro.showLoading({ title: `${actionText}中...` })
|
||
await updateUserStatus(user.userId, newStatus)
|
||
Taro.hideLoading()
|
||
Taro.showToast({ title: `${actionText}成功`, icon: 'success' })
|
||
|
||
// 更新列表和详情中的用户状态
|
||
setUserList(prev => prev.map(u =>
|
||
u.userId === user.userId ? { ...u, status: newStatus } : u
|
||
))
|
||
if (detailUser?.userId === user.userId) {
|
||
setDetailUser(prev => prev ? { ...prev, status: newStatus } : null)
|
||
}
|
||
} catch (e: any) {
|
||
Taro.hideLoading()
|
||
Taro.showToast({ title: e.message || `${actionText}失败`, icon: 'none' })
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 打开用户详情 */
|
||
const openDetail = (user: User) => {
|
||
setDetailUser(user)
|
||
setShowDetail(true)
|
||
}
|
||
|
||
/** 格式化时间 */
|
||
const formatTime = (time?: string) => {
|
||
if (!time) return '-'
|
||
// 兼容各种时间格式
|
||
return time.replace('T', ' ').substring(0, 19)
|
||
}
|
||
|
||
/** 渲染单个用户卡片 */
|
||
const renderUserCard = (user: User) => {
|
||
const isDisabled = user.status === 1
|
||
const displayName = user.nickname || user.realName || '未设置昵称'
|
||
|
||
return (
|
||
<View
|
||
key={user.userId || user.id}
|
||
className='bg-white rounded-xl mx-3 mt-3 p-4 active:opacity-80'
|
||
onClick={() => openDetail(user)}
|
||
>
|
||
{/* 用户头部 */}
|
||
<View className='flex items-center gap-3'>
|
||
{/* 头像 */}
|
||
<View className='w-12 h-12 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
|
||
{user.avatar ? (
|
||
<Image
|
||
className='w-full h-full'
|
||
src={getCompressedImageUrl(user.avatar, { width: 80, quality: 80 })}
|
||
mode='aspectFill'
|
||
/>
|
||
) : (
|
||
<View className='w-full h-full flex items-center justify-center'>
|
||
<Text className='text-2xl'>👤</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 信息 */}
|
||
<View className='flex-1 min-w-0'>
|
||
<View className='flex items-center gap-2'>
|
||
<Text className='text-sm text-gray-800 font-medium' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{displayName}
|
||
</Text>
|
||
<View
|
||
className='px-1 py-1 rounded text-xs'
|
||
style={{
|
||
color: isDisabled ? '#dc2626' : '#16a34a',
|
||
background: isDisabled ? '#fef2f2' : '#f0fdf4',
|
||
}}
|
||
>
|
||
{isDisabled ? '已禁用' : '正常'}
|
||
</View>
|
||
</View>
|
||
<Text className='text-xs text-gray-400 mt-1 block'>
|
||
{user.phone || user.mobile || '未绑定手机'}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* 禁用/启用按钮 */}
|
||
<View
|
||
className={`px-3 py-1 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleToggleStatus(user)
|
||
}}
|
||
>
|
||
<Text className={`text-xs ${isDisabled ? 'text-green-500' : 'text-red-500'}`}>
|
||
{isDisabled ? '启用' : '禁用'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 附加信息 */}
|
||
<View className='flex items-center gap-4 mt-3 pt-3 border-t border-gray-50'>
|
||
{user.memberLevelName && (
|
||
<View className='flex items-center gap-1'>
|
||
<Text className='text-xs text-gray-400'>会员:</Text>
|
||
<Text className='text-xs text-purple-500'>{user.memberLevelName}</Text>
|
||
</View>
|
||
)}
|
||
<View className='flex items-center gap-1'>
|
||
<Text className='text-xs text-gray-400'>注册:</Text>
|
||
<Text className='text-xs text-gray-500'>{formatTime(user.createTime)}</Text>
|
||
</View>
|
||
{user.balance !== undefined && Number(user.balance) > 0 && (
|
||
<View className='flex items-center gap-1'>
|
||
<Text className='text-xs text-gray-400'>余额:</Text>
|
||
<Text className='text-xs text-orange-500'>¥{user.balance}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<View className='min-h-full bg-gray-50'>
|
||
{/* 搜索栏 */}
|
||
<View className='bg-white px-3 py-2 flex items-center gap-2'>
|
||
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1'>
|
||
<Input
|
||
className='flex-1 text-sm'
|
||
placeholder='搜索昵称 / 手机号'
|
||
value={searchText}
|
||
onInput={(e) => setSearchText(e.detail.value)}
|
||
onConfirm={handleSearch}
|
||
confirmType='search'
|
||
/>
|
||
{searchText ? (
|
||
<Text
|
||
className='text-gray-400 text-lg px-1'
|
||
onClick={handleClearSearch}
|
||
>×</Text>
|
||
) : null}
|
||
</View>
|
||
<View
|
||
className='px-3 py-1 rounded-lg bg-blue-500'
|
||
onClick={handleSearch}
|
||
>
|
||
<Text className='text-sm text-white'>搜索</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Tab 栏 */}
|
||
<View className='bg-white flex border-b border-gray-50'>
|
||
{TABS.map(tab => (
|
||
<View
|
||
key={tab.key}
|
||
className={`flex-1 text-center py-3 border-b-2 ${activeTab === tab.key ? 'border-blue-500' : 'border-transparent'}`}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
>
|
||
<Text className={`text-sm ${activeTab === tab.key ? 'text-blue-500 font-medium' : 'text-gray-500'}`}>
|
||
{tab.label}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{/* 用户列表 */}
|
||
<ScrollView
|
||
scrollY
|
||
style={{ height: 'calc(100vh - 100px)' }}
|
||
onScrollToLower={handleLoadMore}
|
||
lowerThreshold={100}
|
||
>
|
||
{loading && userList.length === 0 ? (
|
||
<View className='flex justify-center items-center py-20'>
|
||
<Text className='text-gray-400'>加载中...</Text>
|
||
</View>
|
||
) : userList.length === 0 ? (
|
||
<View className='flex justify-center items-center py-20'>
|
||
<Text className='text-gray-400'>暂无用户</Text>
|
||
</View>
|
||
) : (
|
||
userList.map(renderUserCard)
|
||
)}
|
||
{loading && userList.length > 0 && (
|
||
<View className='flex justify-center items-center py-4'>
|
||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||
</View>
|
||
)}
|
||
{!hasMore && userList.length > 0 && (
|
||
<View className='flex justify-center items-center py-4'>
|
||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||
</View>
|
||
)}
|
||
<View className='h-6' />
|
||
</ScrollView>
|
||
|
||
{/* 用户详情弹窗 */}
|
||
{showDetail && detailUser && (
|
||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||
{/* 遮罩 */}
|
||
<View className='absolute inset-0 bg-black/50' onClick={() => setShowDetail(false)} />
|
||
{/* 弹窗内容 */}
|
||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[75vh] overflow-y-auto'>
|
||
{/* 标题 */}
|
||
<View className='flex justify-between items-center mb-5'>
|
||
<Text className='text-lg font-medium text-gray-800'>用户详情</Text>
|
||
<Text className='text-gray-400 text-lg' onClick={() => setShowDetail(false)}>×</Text>
|
||
</View>
|
||
|
||
{/* 用户基本信息 */}
|
||
<View className='flex items-center gap-4 mb-5'>
|
||
<View className='w-16 h-16 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
|
||
{detailUser.avatar ? (
|
||
<Image
|
||
className='w-full h-full'
|
||
src={getCompressedImageUrl(detailUser.avatar, { width: 100, quality: 90 })}
|
||
mode='aspectFill'
|
||
/>
|
||
) : (
|
||
<View className='w-full h-full flex items-center justify-center'>
|
||
<Text className='text-3xl'>👤</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
<View className='flex-1 min-w-0'>
|
||
<View className='flex items-center gap-2'>
|
||
<Text className='text-base font-medium text-gray-800'>
|
||
{detailUser.nickname || detailUser.realName || '未设置昵称'}
|
||
</Text>
|
||
<View
|
||
className='px-2 py-1 rounded text-xs'
|
||
style={{
|
||
color: detailUser.status === 1 ? '#dc2626' : '#16a34a',
|
||
background: detailUser.status === 1 ? '#fef2f2' : '#f0fdf4',
|
||
}}
|
||
>
|
||
{detailUser.status === 1 ? '已禁用' : '正常'}
|
||
</View>
|
||
</View>
|
||
<Text className='text-xs text-gray-400 mt-1 block'>
|
||
ID: {detailUser.userId || detailUser.id}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 详细信息列表 */}
|
||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||
{/* 手机号 */}
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>手机号</Text>
|
||
<Text className='text-sm text-gray-800'>{detailUser.phone || detailUser.mobile || '-'}</Text>
|
||
</View>
|
||
|
||
{/* 真实姓名 */}
|
||
{detailUser.realName && (
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>真实姓名</Text>
|
||
<Text className='text-sm text-gray-800'>{detailUser.realName}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 性别 */}
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>性别</Text>
|
||
<Text className='text-sm text-gray-800'>
|
||
{detailUser.gender === 1 ? '男' : detailUser.gender === 2 ? '女' : '未知'}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* 会员等级 */}
|
||
{detailUser.memberLevelName && (
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>会员等级</Text>
|
||
<Text className='text-sm text-purple-500'>{detailUser.memberLevelName}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 余额 */}
|
||
{detailUser.balance !== undefined && (
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>余额</Text>
|
||
<Text className='text-sm text-orange-500'>¥{detailUser.balance || '0.00'}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 积分 */}
|
||
{detailUser.points !== undefined && (
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>积分</Text>
|
||
<Text className='text-sm text-gray-800'>{detailUser.points || 0}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 邮箱 */}
|
||
{detailUser.email && (
|
||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||
<Text className='text-sm text-gray-500'>邮箱</Text>
|
||
<Text className='text-sm text-gray-800'>{detailUser.email}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 注册时间 */}
|
||
<View className='flex justify-between items-center py-2'>
|
||
<Text className='text-sm text-gray-500'>注册时间</Text>
|
||
<Text className='text-sm text-gray-800'>{formatTime(detailUser.createTime)}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 操作按钮 */}
|
||
<View className='flex gap-3'>
|
||
<View
|
||
className='flex-1 py-3 rounded-xl bg-gray-100 text-center'
|
||
onClick={() => setShowDetail(false)}
|
||
>
|
||
<Text className='text-sm text-gray-600'>关闭</Text>
|
||
</View>
|
||
<View
|
||
className={`flex-1 py-3 rounded-xl text-center ${detailUser.status === 1 ? 'bg-green-500' : 'bg-red-500'}`}
|
||
onClick={() => handleToggleStatus(detailUser)}
|
||
>
|
||
<Text className='text-sm text-white'>
|
||
{detailUser.status === 1 ? '启用用户' : '禁用用户'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
)
|
||
}
|