Files
medical-mall/pages/mall/admin/user-management.uvue

1118 lines
23 KiB
Plaintext

<!-- 用户管理页面 - 基于CRMEB设计 -->
<template>
<view class="user-management">
<!-- 搜索和筛选区域 -->
<view class="search-section">
<view class="search-form" :class="{ collapsed: !showAdvancedSearch }">
<view class="search-row">
<view class="form-item">
<text class="label">用户搜索:</text>
<view class="input-group">
<picker mode="selector" :range="searchTypes" range-key="label" @change="onSearchTypeChange">
<view class="search-type">{{ searchTypes[currentSearchType].label }}</view>
</picker>
<input
v-model="searchForm.keyword"
placeholder="请输入搜索内容"
clearable
class="search-input"
/>
</view>
</view>
<view class="form-item">
<text class="label">用户等级:</text>
<picker mode="selector" :range="userLevels" range-key="name" @change="onLevelChange">
<view class="picker-text">{{ selectedLevel || '全部' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">用户分组:</text>
<picker mode="selector" :range="userGroups" range-key="group_name" @change="onGroupChange">
<view class="picker-text">{{ selectedGroup || '全部' }}</view>
</picker>
</view>
</view>
<!-- 高级搜索选项 -->
<view v-if="showAdvancedSearch" class="search-row advanced">
<view class="form-item">
<text class="label">分销等级:</text>
<picker mode="selector" :range="agentLevels" range-key="name" @change="onAgentLevelChange">
<view class="picker-text">{{ selectedAgentLevel || '全部' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">注册时间:</text>
<view class="date-range">
<picker mode="date" @change="onStartDateChange">
<view class="date-picker">{{ startDate || '开始日期' }}</view>
</picker>
<text class="date-separator">至</text>
<picker mode="date" @change="onEndDateChange">
<view class="date-picker">{{ endDate || '结束日期' }}</view>
</picker>
</view>
</view>
<view class="form-item">
<text class="label">用户标签:</text>
<view class="tag-selector" @click="showTagSelector = true">
<view class="tag-list">
<text v-for="tag in selectedTags" :key="tag.id" class="tag">{{ tag.label_name }}</text>
</view>
<text v-if="!selectedTags.length" class="placeholder">选择标签</text>
<text class="dropdown-icon">▼</text>
</view>
</view>
</view>
<view class="form-actions">
<button class="btn btn-primary" @click="handleSearch">搜索</button>
<button class="btn btn-default" @click="handleReset">重置</button>
<text class="toggle-search" @click="showAdvancedSearch = !showAdvancedSearch">
{{ showAdvancedSearch ? '收起' : '展开' }} <text class="icon">{{ showAdvancedSearch ? '▲' : '▼' }}</text>
</text>
</view>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="action-bar">
<view class="action-buttons">
<button class="btn btn-success" @click="handleBatchAction('export')">导出用户</button>
<button class="btn btn-warning" @click="handleBatchAction('sendMessage')">群发消息</button>
<button class="btn btn-info" @click="handleBatchAction('adjustBalance')">调整余额</button>
</view>
<view class="data-info">
<text class="total-count">共 {{ totalUsers }} 个用户</text>
<text class="page-info">{{ currentPage }}/{{ totalPages }}</text>
</view>
</view>
<!-- 用户列表 -->
<view class="user-list">
<!-- 表头 -->
<view class="table-header">
<view class="table-row">
<view class="table-cell checkbox-cell">
<checkbox :checked="selectAll" @change="onSelectAllChange" />
</view>
<view class="table-cell user-cell">用户信息</view>
<view class="table-cell">用户等级</view>
<view class="table-cell">余额</view>
<view class="table-cell">积分</view>
<view class="table-cell">订单数</view>
<view class="table-cell">注册时间</view>
<view class="table-cell">状态</view>
<view class="table-cell">操作</view>
</view>
</view>
<!-- 表格内容 -->
<view class="table-body">
<view v-for="user in userList" :key="user.id" class="table-row data-row">
<view class="table-cell checkbox-cell">
<checkbox :checked="selectedUsers.includes(user.id)" @change="onUserSelectChange(user.id)" />
</view>
<view class="table-cell user-cell">
<view class="user-info">
<image :src="user.avatar || '/static/default-avatar.png'" class="user-avatar" />
<view class="user-details">
<text class="user-name">{{ user.nickname }}</text>
<text class="user-phone">{{ user.phone }}</text>
<text class="user-id">ID: {{ user.id }}</text>
</view>
</view>
</view>
<view class="table-cell">
<text class="level-tag" :class="'level-' + user.level_id">{{ getLevelName(user.level_id) }}</text>
</view>
<view class="table-cell">
<text class="balance">¥{{ user.balance }}</text>
</view>
<view class="table-cell">
<text class="integral">{{ user.integral }}</text>
</view>
<view class="table-cell">
<text class="order-count">{{ user.order_count || 0 }}</text>
</view>
<view class="table-cell">
<text class="register-time">{{ formatDate(user.created_at) }}</text>
</view>
<view class="table-cell">
<text class="status-tag" :class="user.status === 1 ? 'active' : 'inactive'">
{{ user.status === 1 ? '正常' : '禁用' }}
</text>
</view>
<view class="table-cell action-cell">
<view class="action-buttons">
<text class="action-link" @click="viewUserDetail(user.id)">详情</text>
<text class="action-link" @click="editUser(user.id)">编辑</text>
<text class="action-link" :class="user.status === 1 ? 'danger' : 'success'"
@click="toggleUserStatus(user.id, user.status)">
{{ user.status === 1 ? '禁用' : '启用' }}
</text>
</view>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination">
<view class="page-buttons">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
<view class="page-numbers">
<button
v-for="page in visiblePages"
:key="page"
class="page-number"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>{{ page }}</button>
</view>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
</view>
</view>
<!-- 标签选择器弹窗 -->
<view v-if="showTagSelector" class="modal-overlay" @click="showTagSelector = false">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">选择用户标签</text>
<text class="modal-close" @click="showTagSelector = false">✕</text>
</view>
<view class="modal-body">
<view v-for="tag in userTags" :key="tag.id" class="tag-item">
<checkbox :checked="selectedTags.some(t => t.id === tag.id)" @change="onTagSelectChange(tag)" />
<text class="tag-name">{{ tag.label_name }}</text>
</view>
</view>
<view class="modal-footer">
<button class="btn btn-default" @click="showTagSelector = false">取消</button>
<button class="btn btn-primary" @click="confirmTagSelection">确定</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
// 响应式数据
const showAdvancedSearch = ref(false)
const showTagSelector = ref(false)
const selectAll = ref(false)
const selectedUsers = ref<number[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const totalUsers = ref(0)
const totalPages = ref(0)
// 搜索表单
const searchForm = ref({
keyword: '',
field: 'all' // all, uid, phone, nickname
})
const currentSearchType = ref(0)
const searchTypes = ref([
{ value: 'all', label: '全部' },
{ value: 'uid', label: 'UID' },
{ value: 'phone', label: '手机号' },
{ value: 'nickname', label: '用户昵称' }
])
// 筛选选项
const userLevels = ref([])
const userGroups = ref([])
const agentLevels = ref([])
const userTags = ref([])
const selectedLevel = ref('')
const selectedGroup = ref('')
const selectedAgentLevel = ref('')
const selectedTags = ref<any[]>([])
const startDate = ref('')
const endDate = ref('')
// 用户列表
const userList = ref([])
// 计算属性
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
// 方法
const onSearchTypeChange = (e: any) => {
currentSearchType.value = e.detail.value
searchForm.value.field = searchTypes.value[currentSearchType.value].value
}
const onLevelChange = (e: any) => {
selectedLevel.value = userLevels.value[e.detail.value]?.name || ''
}
const onGroupChange = (e: any) => {
selectedGroup.value = userGroups.value[e.detail.value]?.group_name || ''
}
const onAgentLevelChange = (e: any) => {
selectedAgentLevel.value = agentLevels.value[e.detail.value]?.name || ''
}
const onStartDateChange = (e: any) => {
startDate.value = e.detail.value
}
const onEndDateChange = (e: any) => {
endDate.value = e.detail.value
}
const onSelectAllChange = (e: any) => {
selectAll.value = e.detail.value
if (selectAll.value) {
selectedUsers.value = userList.value.map((user: any) => user.id)
} else {
selectedUsers.value = []
}
}
const onUserSelectChange = (userId: number) => {
const index = selectedUsers.value.indexOf(userId)
if (index > -1) {
selectedUsers.value.splice(index, 1)
} else {
selectedUsers.value.push(userId)
}
selectAll.value = selectedUsers.value.length === userList.value.length
}
const onTagSelectChange = (tag: any) => {
const index = selectedTags.value.findIndex((t: any) => t.id === tag.id)
if (index > -1) {
selectedTags.value.splice(index, 1)
} else {
selectedTags.value.push(tag)
}
}
const confirmTagSelection = () => {
showTagSelector.value = false
}
const handleSearch = () => {
loadUserList()
}
const handleReset = () => {
searchForm.value.keyword = ''
currentSearchType.value = 0
searchForm.value.field = 'all'
selectedLevel.value = ''
selectedGroup.value = ''
selectedAgentLevel.value = ''
selectedTags.value = []
startDate.value = ''
endDate.value = ''
loadUserList()
}
const handleBatchAction = (action: string) => {
if (selectedUsers.value.length === 0) {
uni.showToast({
title: '请选择用户',
icon: 'none'
})
return
}
switch (action) {
case 'export':
exportUsers()
break
case 'sendMessage':
sendBatchMessage()
break
case 'adjustBalance':
adjustBatchBalance()
break
}
}
const viewUserDetail = (userId: number) => {
uni.navigateTo({
url: `/pages/mall/admin/user-detail?id=${userId}`
})
}
const editUser = (userId: number) => {
uni.navigateTo({
url: `/pages/mall/admin/user-detail?id=${userId}&edit=true`
})
}
const toggleUserStatus = async (userId: number, currentStatus: number) => {
try {
const newStatus = currentStatus === 1 ? 0 : 1
await supa.from('users').update({ status: newStatus }).eq('id', userId)
uni.showToast({
title: newStatus === 1 ? '启用成功' : '禁用成功',
icon: 'success'
})
loadUserList()
} catch (error) {
console.error('更新用户状态失败:', error)
uni.showToast({
title: '操作失败',
icon: 'error'
})
}
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadUserList()
}
}
const getLevelName = (levelId: number) => {
const level = userLevels.value.find((l: any) => l.id === levelId)
return level?.name || '普通用户'
}
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString()
}
// 数据加载方法
const loadUserLevels = async () => {
try {
const { data } = await supa.from('user_levels').select('*').order('grade', { ascending: true })
userLevels.value = data || []
} catch (error) {
console.error('加载用户等级失败:', error)
}
}
const loadUserGroups = async () => {
try {
const { data } = await supa.from('user_groups').select('*')
userGroups.value = data || []
} catch (error) {
console.error('加载用户分组失败:', error)
}
}
const loadAgentLevels = async () => {
try {
const { data } = await supa.from('agent_levels').select('*').order('grade', { ascending: true })
agentLevels.value = data || []
} catch (error) {
console.error('加载分销等级失败:', error)
}
}
const loadUserTags = async () => {
try {
const { data } = await supa.from('user_tags').select('*')
userTags.value = data || []
} catch (error) {
console.error('加载用户标签失败:', error)
}
}
const loadUserList = async () => {
try {
let query = supa.from('users').select(`
*,
user_levels!inner(name),
user_groups(group_name),
orders(count)
`)
// 搜索条件
if (searchForm.value.keyword) {
switch (searchForm.value.field) {
case 'uid':
query = query.eq('id', parseInt(searchForm.value.keyword))
break
case 'phone':
query = query.ilike('phone', `%${searchForm.value.keyword}%`)
break
case 'nickname':
query = query.ilike('nickname', `%${searchForm.value.keyword}%`)
break
default:
query = query.or(`phone.ilike.%${searchForm.value.keyword}%,nickname.ilike.%${searchForm.value.keyword}%`)
break
}
}
// 等级筛选
if (selectedLevel.value) {
const level = userLevels.value.find((l: any) => l.name === selectedLevel.value)
if (level) {
query = query.eq('level_id', level.id)
}
}
// 分组筛选
if (selectedGroup.value) {
const group = userGroups.value.find((g: any) => g.group_name === selectedGroup.value)
if (group) {
query = query.eq('group_id', group.id)
}
}
// 日期筛选
if (startDate.value && endDate.value) {
query = query.gte('created_at', startDate.value).lte('created_at', endDate.value)
}
// 分页
const from = (currentPage.value - 1) * pageSize.value
const to = from + pageSize.value - 1
const { data, count } = await query.range(from, to)
userList.value = data || []
totalUsers.value = count || 0
totalPages.value = Math.ceil(totalUsers.value / pageSize.value)
} catch (error) {
console.error('加载用户列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'error'
})
}
}
// 批量操作方法
const exportUsers = () => {
uni.showToast({
title: '导出功能开发中',
icon: 'none'
})
}
const sendBatchMessage = () => {
uni.showToast({
title: '群发消息功能开发中',
icon: 'none'
})
}
const adjustBatchBalance = () => {
uni.showToast({
title: '批量调整余额功能开发中',
icon: 'none'
})
}
// 页面初始化
onMounted(async () => {
await Promise.all([
loadUserLevels(),
loadUserGroups(),
loadAgentLevels(),
loadUserTags(),
loadUserList()
])
})
</script>
<style lang="scss">
.user-management {
padding: 30rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
// 搜索区域样式
.search-section {
background-color: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.search-form {
transition: all 0.3s ease;
&.collapsed {
max-height: 120rpx;
overflow: hidden;
}
}
.search-row {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-bottom: 20rpx;
&.advanced {
border-top: 1rpx solid #e8e8e8;
padding-top: 20rpx;
}
}
.form-item {
display: flex;
align-items: center;
min-width: 300rpx;
margin-bottom: 20rpx;
.label {
font-size: 28rpx;
color: #666;
margin-right: 20rpx;
white-space: nowrap;
}
}
.input-group {
display: flex;
align-items: center;
flex: 1;
.search-type {
padding: 0 20rpx;
height: 60rpx;
line-height: 60rpx;
background-color: #f5f5f5;
border: 1rpx solid #ddd;
border-radius: 6rpx 0 0 6rpx;
font-size: 26rpx;
min-width: 120rpx;
}
.search-input {
flex: 1;
height: 60rpx;
border: 1rpx solid #ddd;
border-left: none;
border-radius: 0 6rpx 6rpx 0;
padding: 0 20rpx;
font-size: 26rpx;
}
}
.picker-text {
padding: 0 20rpx;
height: 60rpx;
line-height: 60rpx;
border: 1rpx solid #ddd;
border-radius: 6rpx;
font-size: 26rpx;
color: #333;
min-width: 200rpx;
}
.date-range {
display: flex;
align-items: center;
flex: 1;
.date-picker {
padding: 0 20rpx;
height: 60rpx;
line-height: 60rpx;
border: 1rpx solid #ddd;
border-radius: 6rpx;
font-size: 26rpx;
color: #333;
flex: 1;
}
.date-separator {
margin: 0 10rpx;
color: #666;
}
}
.tag-selector {
display: flex;
align-items: center;
flex: 1;
padding: 0 20rpx;
height: 60rpx;
border: 1rpx solid #ddd;
border-radius: 6rpx;
cursor: pointer;
.tag-list {
display: flex;
flex-wrap: wrap;
flex: 1;
gap: 10rpx;
}
.tag {
background-color: #e1f5fe;
color: #0277bd;
padding: 4rpx 12rpx;
border-radius: 4rpx;
font-size: 24rpx;
}
.placeholder {
color: #999;
font-size: 26rpx;
}
.dropdown-icon {
margin-left: 10rpx;
color: #999;
}
}
.form-actions {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 20rpx;
.btn {
padding: 12rpx 24rpx;
border-radius: 6rpx;
font-size: 26rpx;
border: none;
cursor: pointer;
&.btn-primary {
background-color: #007bff;
color: white;
}
&.btn-default {
background-color: #f5f5f5;
color: #333;
}
}
.toggle-search {
margin-left: auto;
color: #007bff;
font-size: 26rpx;
cursor: pointer;
.icon {
font-size: 20rpx;
}
}
}
// 操作栏样式
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
padding: 20rpx 30rpx;
border-radius: 12rpx;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.action-buttons {
display: flex;
gap: 20rpx;
.btn {
padding: 12rpx 24rpx;
border-radius: 6rpx;
font-size: 26rpx;
border: none;
cursor: pointer;
&.btn-success {
background-color: #28a745;
color: white;
}
&.btn-warning {
background-color: #ffc107;
color: #212529;
}
&.btn-info {
background-color: #17a2b8;
color: white;
}
}
}
.data-info {
display: flex;
align-items: center;
gap: 20rpx;
.total-count,
.page-info {
font-size: 26rpx;
color: #666;
}
}
}
// 用户列表样式
.user-list {
background-color: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.table-header {
background-color: #f8f9fa;
border-bottom: 1rpx solid #e9ecef;
.table-row {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
font-weight: bold;
font-size: 26rpx;
color: #495057;
}
}
.table-body {
.data-row {
border-bottom: 1rpx solid #e9ecef;
&:hover {
background-color: #f8f9fa;
}
&:last-child {
border-bottom: none;
}
}
}
.table-row {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
min-height: 120rpx;
.table-cell {
flex: 1;
font-size: 26rpx;
color: #495057;
display: flex;
align-items: center;
&.checkbox-cell {
flex: 0 0 60rpx;
justify-content: center;
}
&.user-cell {
flex: 2;
}
&.action-cell {
flex: 0 0 200rpx;
justify-content: flex-end;
}
}
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
gap: 4rpx;
.user-name {
font-size: 28rpx;
font-weight: bold;
color: #212529;
}
.user-phone,
.user-id {
font-size: 24rpx;
color: #6c757d;
}
}
}
.level-tag {
padding: 4rpx 12rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: bold;
&.level-0 {
background-color: #e9ecef;
color: #495057;
}
&.level-1 {
background-color: #cce5ff;
color: #0066cc;
}
&.level-2 {
background-color: #d1ecf1;
color: #0c5460;
}
}
.balance,
.integral,
.order-count {
font-weight: bold;
color: #28a745;
}
.register-time {
color: #6c757d;
}
.status-tag {
padding: 4rpx 12rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: bold;
&.active {
background-color: #d4edda;
color: #155724;
}
&.inactive {
background-color: #f8d7da;
color: #721c24;
}
}
.action-buttons {
display: flex;
gap: 20rpx;
.action-link {
color: #007bff;
font-size: 24rpx;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&.danger {
color: #dc3545;
}
&.success {
color: #28a745;
}
}
}
// 分页样式
.pagination {
display: flex;
justify-content: center;
margin-top: 30rpx;
.page-buttons {
display: flex;
align-items: center;
gap: 10rpx;
}
.page-btn,
.page-number {
padding: 12rpx 20rpx;
border: 1rpx solid #ddd;
background-color: #fff;
color: #333;
border-radius: 6rpx;
font-size: 26rpx;
cursor: pointer;
transition: all 0.2s;
&:disabled {
background-color: #f5f5f5;
color: #999;
cursor: not-allowed;
}
&:hover:not(:disabled) {
background-color: #007bff;
color: white;
border-color: #007bff;
}
&.active {
background-color: #007bff;
color: white;
border-color: #007bff;
}
}
}
// 弹窗样式
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #fff;
border-radius: 12rpx;
width: 80%;
max-width: 600rpx;
max-height: 80vh;
overflow: hidden;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #e9ecef;
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #212529;
}
.modal-close {
font-size: 36rpx;
color: #999;
cursor: pointer;
&:hover {
color: #333;
}
}
}
.modal-body {
padding: 30rpx;
max-height: 400rpx;
overflow-y: auto;
.tag-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.tag-name {
font-size: 28rpx;
color: #333;
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 20rpx;
padding: 30rpx;
border-top: 1rpx solid #e9ecef;
.btn {
padding: 12rpx 24rpx;
border-radius: 6rpx;
font-size: 26rpx;
border: none;
cursor: pointer;
&.btn-default {
background-color: #f5f5f5;
color: #333;
}
&.btn-primary {
background-color: #007bff;
color: white;
}
}
}
}
// 响应式设计
@media (max-width: 750rpx) {
.search-row {
flex-direction: column;
align-items: stretch;
}
.form-item {
min-width: auto;
margin-bottom: 20rpx;
}
.table-row {
flex-wrap: wrap;
padding: 15rpx;
.table-cell {
min-width: 200rpx;
margin-bottom: 10rpx;
&.user-cell {
min-width: 300rpx;
}
}
}
.action-bar {
flex-direction: column;
gap: 20rpx;
align-items: stretch;
.action-buttons {
justify-content: center;
}
}
}
</style>