Files
xinlong-shop-taro/src/pages/store/users/index.tsx
赵忠林 bd3589af61 feat(store-center): 在门店中心添加扫码登录PC后台功能
- 门店中心右上角新增扫码登录按钮,使用base64 SVG图标兼容小程序Image组件渲染
- 扫码后通过解析二维码内容调用confirmWechatQRLogin接口完成PC端登录确认
- 支持扫码取消静默处理,扫码失败则弹窗提示错误信息
- 优化门店中心页面布局,调整扫码按钮绝对定位及样式
- 优化了用户管理页面的小型样式,包括按钮和文本间距调整
- 调整用户页面部分Badge显示逻辑,排除tabIndex为4的项不显示角标
2026-07-17 12:38:27 +08:00

446 lines
17 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, { 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>
)
}