feat(store): 新增门店中心用户管理页面

- 创建用户管理新页面,支持分页查询用户列表
- 实现昵称/手机号搜索及全部/正常/已禁用Tab筛选
- 增加用户详情弹窗展示详细信息
- 添加禁用/启用用户功能及确认弹窗
- 路由注册新增用户管理路径
- 门店中心首页添加用户管理入口卡片,蓝色主题带👥图标
- 头像支持图片压缩展示,支持无头像显示默认图标
- 优化用户状态显示,颜色区分正常与已禁用状态
- 详情弹窗覆盖手机号、真实姓名、性别、会员等级、余额、积分、邮箱等信息
- 支持列表和详情弹窗中用户状态同步更新
This commit is contained in:
2026-07-14 23:00:09 +08:00
parent 0ca26801b1
commit 4c059522a2
4 changed files with 464 additions and 0 deletions

View File

@@ -5,3 +5,11 @@
- 问题:订单商品图片不显示,字段名与模型不匹配 - 问题:订单商品图片不显示,字段名与模型不匹配
- 修复:`coverImage``image``specInfo``spec``quantity``totalNum`(按 `ShopOrderGoods` 模型定义) - 修复:`coverImage``image``specInfo``spec``quantity``totalNum`(按 `ShopOrderGoods` 模型定义)
- 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-1664px压缩宽度 80 够用 - 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-1664px压缩宽度 80 够用
## 门店中心新增用户管理功能
- 新页面:`src/pages/store/users/index.tsx`
- 功能:分页查询用户列表、搜索(昵称/手机号、Tab 筛选(全部/正常/已禁用)、查看用户详情弹窗、禁用/启用用户
- 使用的 API`pageUsers``/system/user/page`)、`updateUserStatus``/system/user/status`,参数 userId + status0=正常 1=禁用)
- 入口:`src/pages/store/center/index.tsx` FEATURE_CARDS 中,在 VIP会员审核上方加了"用户管理"卡片(蓝色主题 👥 图标)
- 路由注册:`src/app.config.ts` 新增 `pages/store/users/index`

View File

@@ -91,6 +91,7 @@ export default {
'pages/store/center/index', 'pages/store/center/index',
'pages/store/orders/index', 'pages/store/orders/index',
'pages/store/goods/index', 'pages/store/goods/index',
'pages/store/users/index',
// 售后页面 // 售后页面
'pages/after-sale/apply/index', 'pages/after-sale/apply/index',
'pages/after-sale/progress/index', 'pages/after-sale/progress/index',

View File

@@ -32,6 +32,16 @@ const FEATURE_CARDS = [
// 显示待处理订单数量角标 // 显示待处理订单数量角标
showBadge: true, showBadge: true,
}, },
{
key: 'users',
icon: '👥',
title: '用户管理',
desc: '查看用户信息、禁用/启用用户',
url: '/pages/store/users/index',
color: '#2563eb',
bgColor: '#dbeafe',
showBadge: false,
},
{ {
key: 'vip-review', key: 'vip-review',
icon: '👑', icon: '👑',

View 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>
)
}