feat(store): 新增门店中心用户管理页面
- 创建用户管理新页面,支持分页查询用户列表
- 实现昵称/手机号搜索及全部/正常/已禁用Tab筛选
- 增加用户详情弹窗展示详细信息
- 添加禁用/启用用户功能及确认弹窗
- 路由注册新增用户管理路径
- 门店中心首页添加用户管理入口卡片,蓝色主题带👥图标
- 头像支持图片压缩展示,支持无头像显示默认图标
- 优化用户状态显示,颜色区分正常与已禁用状态
- 详情弹窗覆盖手机号、真实姓名、性别、会员等级、余额、积分、邮箱等信息
- 支持列表和详情弹窗中用户状态同步更新
This commit is contained in:
@@ -5,3 +5,11 @@
|
||||
- 问题:订单商品图片不显示,字段名与模型不匹配
|
||||
- 修复:`coverImage` → `image`、`specInfo` → `spec`、`quantity` → `totalNum`(按 `ShopOrderGoods` 模型定义)
|
||||
- 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-16(64px),压缩宽度 80 够用
|
||||
|
||||
## 门店中心新增用户管理功能
|
||||
- 新页面:`src/pages/store/users/index.tsx`
|
||||
- 功能:分页查询用户列表、搜索(昵称/手机号)、Tab 筛选(全部/正常/已禁用)、查看用户详情弹窗、禁用/启用用户
|
||||
- 使用的 API:`pageUsers`(`/system/user/page`)、`updateUserStatus`(`/system/user/status`,参数 userId + status,0=正常 1=禁用)
|
||||
- 入口:`src/pages/store/center/index.tsx` FEATURE_CARDS 中,在 VIP会员审核上方加了"用户管理"卡片(蓝色主题 👥 图标)
|
||||
- 路由注册:`src/app.config.ts` 新增 `pages/store/users/index`
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export default {
|
||||
'pages/store/center/index',
|
||||
'pages/store/orders/index',
|
||||
'pages/store/goods/index',
|
||||
'pages/store/users/index',
|
||||
// 售后页面
|
||||
'pages/after-sale/apply/index',
|
||||
'pages/after-sale/progress/index',
|
||||
|
||||
@@ -32,6 +32,16 @@ const FEATURE_CARDS = [
|
||||
// 显示待处理订单数量角标
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
icon: '👥',
|
||||
title: '用户管理',
|
||||
desc: '查看用户信息、禁用/启用用户',
|
||||
url: '/pages/store/users/index',
|
||||
color: '#2563eb',
|
||||
bgColor: '#dbeafe',
|
||||
showBadge: false,
|
||||
},
|
||||
{
|
||||
key: 'vip-review',
|
||||
icon: '👑',
|
||||
|
||||
445
src/pages/store/users/index.tsx
Normal file
445
src/pages/store/users/index.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
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.5 py-0.5 rounded text-[10px]'
|
||||
style={{
|
||||
color: isDisabled ? '#dc2626' : '#16a34a',
|
||||
background: isDisabled ? '#fef2f2' : '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
{isDisabled ? '已禁用' : '正常'}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-0.5 block'>
|
||||
{user.phone || user.mobile || '未绑定手机'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 禁用/启用按钮 */}
|
||||
<View
|
||||
className={`px-3 py-1.5 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-[10px] 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-[10px] 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-[10px] 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.5'>
|
||||
<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.5 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-0.5 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user