Files
medical-mall/pages/mall/admin/user/user-management/index.uvue
2026-02-05 10:11:09 +08:00

1026 lines
25 KiB
Plaintext
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.
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">管理商城注册用户信息</text>
</view>
<!-- 筛选区域 -->
<view class="filter-section">
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">搜索</text>
<input
v-model="searchText"
class="filter-input"
placeholder="用户名/邮箱/真实姓名"
@confirm="handleSearch"
/>
</view>
<view class="filter-item">
<text class="filter-label">角色</text>
<picker
:value="roleIndex"
:range="roleOptions"
range-key="label"
@change="onRoleChange"
>
<view class="picker">
{{ roleOptions[roleIndex]?.label || '全部' }}
</view>
</picker>
</view>
<view class="filter-item">
<text class="filter-label">状态</text>
<picker
:value="statusIndex"
:range="statusOptions"
range-key="label"
@change="onStatusChange"
>
<view class="picker">
{{ statusOptions[statusIndex]?.label || '全部' }}
</view>
</picker>
</view>
<view class="filter-item">
<text class="filter-label">会员</text>
<picker
:value="memberIndex"
:range="memberOptions"
range-key="label"
@change="onMemberChange"
>
<view class="picker">
{{ memberOptions[memberIndex]?.label || '全部' }}
</view>
</picker>
</view>
<view class="filter-actions">
<button class="btn btn-primary" @click="handleSearch">搜索</button>
<button class="btn btn-secondary" @click="handleReset">重置</button>
</view>
</view>
</view>
<!-- 用户列表 -->
<view class="table-section">
<view class="table-header">
<text class="table-title">用户列表</text>
<text class="table-count">共 {{ total }} 条</text>
</view>
<view class="table-container">
<view class="table">
<!-- 表头 -->
<view class="table-row header">
<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
v-for="user in userList"
:key="user.id"
class="table-row"
@click="handleViewUser(user)"
>
<view class="table-cell user-info">
<view class="user-avatar">
<image
v-if="user.avatar_url"
:src="user.avatar_url"
class="avatar-img"
/>
<view v-else class="avatar-placeholder">
{{ user.username?.charAt(0)?.toUpperCase() || 'U' }}
</view>
</view>
<view class="user-details">
<text class="username">{{ user.username || '-' }}</text>
<text class="email">{{ user.email || '-' }}</text>
<text v-if="user.real_name" class="real-name">{{ user.real_name }}</text>
</view>
</view>
<view class="table-cell">
<view class="role-tag" :class="getRoleClass(user.role)">
{{ getRoleLabel(user.role) }}
</view>
</view>
<view class="table-cell">
<view class="status-tag" :class="getStatusClass(user.profile_status)">
{{ getStatusLabel(user.profile_status) }}
</view>
</view>
<view class="table-cell member-info">
<view v-if="user.is_member" class="member-active">
<text class="member-plan">{{ user.member_plan_name || '会员' }}</text>
<text v-if="user.member_end_date" class="member-end">
到期: {{ formatDate(user.member_end_date) }}
</text>
</view>
<view v-else class="member-none">
<text class="non-member">非会员</text>
</view>
</view>
<view class="table-cell">
<text class="balance">¥{{ user.balance?.toFixed(2) || '0.00' }}</text>
</view>
<view class="table-cell">
<text class="date">{{ formatDate(user.created_at) }}</text>
</view>
<view class="table-cell actions">
<button class="btn-small btn-view" @click.stop="handleViewUser(user)">
查看
</button>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<view v-if="hasMore || page > 1" class="pagination">
<button
class="btn btn-secondary"
:disabled="page <= 1"
@click="handlePrevPage"
>
上一页
</button>
<text class="page-info">第 {{ page }} 页</text>
<button
class="btn btn-secondary"
:disabled="!hasMore"
@click="handleNextPage"
>
下一页
</button>
</view>
</view>
<!-- 用户详情弹窗 -->
<view v-if="showUserDetail" class="modal-overlay" @click="closeUserDetail">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">用户详情</text>
<button class="btn-close" @click="closeUserDetail">×</button>
</view>
<view v-if="userDetail" class="modal-body">
<!-- 基本信息 -->
<view class="detail-section">
<text class="section-title">基本信息</text>
<view class="detail-grid">
<view class="detail-item">
<text class="detail-label">用户名</text>
<text class="detail-value">{{ userDetail.username || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">邮箱</text>
<text class="detail-value">{{ userDetail.email || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">角色</text>
<text class="detail-value">{{ getRoleLabel(userDetail.role) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">状态</text>
<text class="detail-value">{{ getStatusLabel(userDetail.profile_status) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">真实姓名</text>
<text class="detail-value">{{ userDetail.real_name || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">信用分数</text>
<text class="detail-value">{{ userDetail.credit_score || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">注册时间</text>
<text class="detail-value">{{ formatDate(userDetail.created_at) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">余额</text>
<text class="detail-value">¥{{ userDetail.balance?.toFixed(2) || '0.00' }}</text>
</view>
</view>
</view>
<!-- 会员信息 -->
<view v-if="userDetail.is_member && userDetail.member_info" class="detail-section">
<text class="section-title">会员信息</text>
<view class="member-detail">
<view class="detail-item">
<text class="detail-label">方案名称</text>
<text class="detail-value">{{ userDetail.member_info.plan_name }}</text>
</view>
<view class="detail-item">
<text class="detail-label">方案编码</text>
<text class="detail-value">{{ userDetail.member_info.plan_code }}</text>
</view>
<view class="detail-item">
<text class="detail-label">计费周期</text>
<text class="detail-value">{{ getBillingPeriodLabel(userDetail.member_info.billing_period) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">价格</text>
<text class="detail-value">¥{{ userDetail.member_info.price?.toFixed(2) || '0.00' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">状态</text>
<text class="detail-value">{{ getMemberStatusLabel(userDetail.member_info.status) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">开始时间</text>
<text class="detail-value">{{ formatDate(userDetail.member_info.start_date) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">到期时间</text>
<text class="detail-value">{{ userDetail.member_info.end_date ? formatDate(userDetail.member_info.end_date) : '永久' }}</text>
</view>
<view class="detail-item">
<text class="detail-label">自动续费</text>
<text class="detail-value">{{ userDetail.member_info.auto_renew ? '是' : '否' }}</text>
</view>
</view>
</view>
<!-- 地址信息 -->
<view v-if="userDetail.addresses && userDetail.addresses.length > 0" class="detail-section">
<text class="section-title">地址信息</text>
<view class="address-list">
<view
v-for="address in userDetail.addresses"
:key="address.id"
class="address-item"
>
<view class="address-header">
<text class="address-name">{{ address.receiver_name }}</text>
<text class="address-phone">{{ address.receiver_phone }}</text>
<view v-if="address.is_default" class="default-tag">默认</view>
</view>
<view class="address-detail">
<text>{{ address.province }} {{ address.city }} {{ address.district }} {{ address.address_detail }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn btn-secondary" @click="closeUserDetail">关闭</button>
</view>
</view>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
import AdminUserService, { type AdminUserItem, type AdminUserDetail } from '@/services/admin/AdminUserService.uts'
const currentPage = ref<string>('user-list')
const title = ref<string>('用户管理')
// 筛选相关
const searchText = ref<string>('')
const roleIndex = ref<number>(0)
const statusIndex = ref<number>(0)
const memberIndex = ref<number>(0)
const roleOptions = ref([
{ label: '全部', value: null },
{ label: '消费者', value: 'customer' },
{ label: '商家', value: 'merchant' },
{ label: '配送员', value: 'delivery' },
{ label: '客服', value: 'service' },
{ label: '管理员', value: 'admin' },
{ label: '数据分析', value: 'analytics' }
])
const statusOptions = ref([
{ label: '全部', value: null },
{ label: '正常', value: 1 },
{ label: '冻结', value: 2 },
{ label: '注销', value: 3 },
{ label: '待审核', value: 4 }
])
const memberOptions = ref([
{ label: '全部', value: null },
{ label: '会员', value: true },
{ label: '非会员', value: false }
])
// 列表数据
const userList = ref<AdminUserItem[]>([])
const total = ref<number>(0)
const page = ref<number>(1)
const limit = ref<number>(20)
const hasMore = ref<boolean>(false)
const loading = ref<boolean>(false)
// 详情弹窗
const showUserDetail = ref<boolean>(false)
const userDetail = ref<AdminUserDetail | null>(null)
// 加载用户列表
async function loadUserList() {
if (loading.value) return
loading.value = true
try {
const params = {
page: page.value,
limit: limit.value,
search: searchText.value || null,
role: roleOptions.value[roleIndex.value]?.value,
status: statusOptions.value[statusIndex.value]?.value,
is_member: memberOptions.value[memberIndex.value]?.value
}
const response = await AdminUserService.getUserList(params)
userList.value = response.items
total.value = response.total
hasMore.value = response.has_more
} catch (error) {
console.error('加载用户列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 搜索
function handleSearch() {
page.value = 1
loadUserList()
}
// 重置筛选
function handleReset() {
searchText.value = ''
roleIndex.value = 0
statusIndex.value = 0
memberIndex.value = 0
page.value = 1
loadUserList()
}
// 分页
function handlePrevPage() {
if (page.value > 1) {
page.value--
loadUserList()
}
}
function handleNextPage() {
if (hasMore.value) {
page.value++
loadUserList()
}
}
// 查看用户详情
async function handleViewUser(user: AdminUserItem) {
try {
const detail = await AdminUserService.getUserDetail(user.id)
userDetail.value = detail
showUserDetail.value = true
} catch (error) {
console.error('获取用户详情失败:', error)
uni.showToast({
title: '获取详情失败',
icon: 'none'
})
}
}
// 关闭详情弹窗
function closeUserDetail() {
showUserDetail.value = false
userDetail.value = null
}
// 筛选器变更
function onRoleChange(e: any) {
roleIndex.value = e.detail.value
}
function onStatusChange(e: any) {
statusIndex.value = e.detail.value
}
function onMemberChange(e: any) {
memberIndex.value = e.detail.value
}
// 工具函数
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function getRoleLabel(role: string): string {
const roleMap = {
'customer': '消费者',
'merchant': '商家',
'delivery': '配送员',
'service': '客服',
'admin': '管理员',
'analytics': '数据分析'
}
return roleMap[role] || role
}
function getRoleClass(role: string): string {
const classMap = {
'customer': 'role-customer',
'merchant': 'role-merchant',
'delivery': 'role-delivery',
'service': 'role-service',
'admin': 'role-admin',
'analytics': 'role-analytics'
}
return classMap[role] || 'role-default'
}
function getStatusLabel(status: number): string {
const statusMap = {
1: '正常',
2: '冻结',
3: '注销',
4: '待审核'
}
return statusMap[status] || '未知'
}
function getStatusClass(status: number): string {
const classMap = {
1: 'status-normal',
2: 'status-frozen',
3: 'status-canceled',
4: 'status-pending'
}
return classMap[status] || 'status-default'
}
function getBillingPeriodLabel(period: string): string {
const periodMap = {
'monthly': '月付',
'yearly': '年付'
}
return periodMap[period] || period
}
function getMemberStatusLabel(status: string): string {
const statusMap = {
'trial': '试用',
'active': '活跃',
'past_due': '逾期',
'canceled': '已取消',
'expired': '已过期'
}
return statusMap[status] || status
}
// 页面加载时获取数据
onMounted(() => {
loadUserList()
})
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page {
padding: $space-lg;
background-color: $background-page;
}
.header {
padding: $space-lg;
border-radius: $radius;
background: $background-primary;
box-shadow: $shadow-xs;
margin-bottom: $space-lg;
}
.title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-primary;
}
.sub-title {
margin-top: $space-xs;
font-size: $font-size-md;
color: $text-secondary;
}
// 筛选区域
.filter-section {
background: $background-primary;
border-radius: $radius;
padding: $space-lg;
margin-bottom: $space-lg;
box-shadow: $shadow-xs;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: $space-md;
align-items: end;
}
.filter-item {
display: flex;
flex-direction: column;
gap: $space-xs;
min-width: 160px;
}
.filter-label {
font-size: $font-size-sm;
color: $text-secondary;
font-weight: $font-weight-medium;
}
.filter-input {
padding: $space-sm;
border: 1px solid $border;
border-radius: $radius-sm;
font-size: $font-size-md;
background: $background-primary;
&:focus {
border-color: $primary;
outline: none;
}
}
.picker {
padding: $space-sm;
border: 1px solid $border;
border-radius: $radius-sm;
font-size: $font-size-md;
background: $background-primary;
color: $text-primary;
}
.filter-actions {
display: flex;
gap: $space-sm;
}
.btn {
padding: $space-sm $space-md;
border-radius: $radius-sm;
font-size: $font-size-md;
border: none;
cursor: pointer;
transition: all 0.2s;
&.btn-primary {
background: $primary;
color: white;
&:hover {
background: darken($primary, 10%);
}
}
&.btn-secondary {
background: $background-secondary;
color: $text-primary;
border: 1px solid $border;
&:hover {
background: darken($background-secondary, 5%);
}
}
&.btn-small {
padding: $space-xs $space-sm;
font-size: $font-size-sm;
}
&.btn-view {
background: $primary;
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 表格区域
.table-section {
background: $background-primary;
border-radius: $radius;
box-shadow: $shadow-xs;
overflow: hidden;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-lg;
border-bottom: 1px solid $border;
}
.table-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-primary;
}
.table-count {
font-size: $font-size-sm;
color: $text-secondary;
}
.table-container {
overflow-x: auto;
}
.table {
width: 100%;
min-width: 800px;
}
.table-row {
display: flex;
border-bottom: 1px solid $border;
&.header {
background: $background-secondary;
font-weight: $font-weight-medium;
}
&:hover:not(.header) {
background: $background-hover;
}
}
.table-cell {
flex: 1;
padding: $space-md;
display: flex;
align-items: center;
min-width: 120px;
word-break: break-word;
}
// 用户信息单元格
.user-info {
display: flex;
align-items: center;
gap: $space-sm;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: $primary;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-md;
font-weight: $font-weight-bold;
}
.user-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
font-weight: $font-weight-medium;
color: $text-primary;
}
.email {
font-size: $font-size-sm;
color: $text-secondary;
}
.real-name {
font-size: $font-size-sm;
color: $primary;
}
// 标签样式
.role-tag, .status-tag {
padding: 2px 8px;
border-radius: $radius-sm;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
}
.role-customer { background: #e6f7ff; color: #1890ff; }
.role-merchant { background: #f6ffed; color: #52c41a; }
.role-delivery { background: #fff2e8; color: #fa8c16; }
.role-service { background: #f9f0ff; color: #722ed1; }
.role-admin { background: #fff1f0; color: #ff4d4f; }
.role-analytics { background: #e6fffb; color: #13c2c2; }
.status-normal { background: #f6ffed; color: #52c41a; }
.status-frozen { background: #fff2e8; color: #fa8c16; }
.status-canceled { background: #fff1f0; color: #ff4d4f; }
.status-pending { background: #f0f5ff; color: #1890ff; }
// 会员信息
.member-info {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.member-active {
.member-plan {
font-weight: $font-weight-medium;
color: $primary;
}
.member-end {
font-size: $font-size-xs;
color: $text-secondary;
}
}
.member-none {
.non-member {
color: $text-secondary;
font-size: $font-size-sm;
}
}
.balance {
font-weight: $font-weight-medium;
color: $success;
}
.date {
font-size: $font-size-sm;
color: $text-secondary;
}
.actions {
justify-content: center;
}
// 分页
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: $space-md;
padding: $space-lg;
border-top: 1px solid $border;
}
.page-info {
font-size: $font-size-sm;
color: $text-secondary;
}
// 弹窗
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: $background-primary;
border-radius: $radius;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-lg;
border-bottom: 1px solid $border;
}
.modal-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-primary;
}
.btn-close {
background: none;
border: none;
font-size: $font-size-xl;
color: $text-secondary;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $text-primary;
}
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: $space-lg;
}
.detail-section {
margin-bottom: $space-xl;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-primary;
margin-bottom: $space-md;
padding-bottom: $space-sm;
border-bottom: 1px solid $border;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: $space-md;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-sm 0;
}
.detail-label {
font-size: $font-size-sm;
color: $text-secondary;
}
.detail-value {
font-size: $font-size-sm;
color: $text-primary;
font-weight: $font-weight-medium;
}
.member-detail, .address-list {
display: flex;
flex-direction: column;
gap: $space-md;
}
.address-item {
padding: $space-md;
border: 1px solid $border;
border-radius: $radius-sm;
}
.address-header {
display: flex;
align-items: center;
gap: $space-sm;
margin-bottom: $space-xs;
}
.address-name {
font-weight: $font-weight-medium;
color: $text-primary;
}
.address-phone {
font-size: $font-size-sm;
color: $text-secondary;
}
.default-tag {
background: $primary;
color: white;
padding: 2px 6px;
border-radius: $radius-xs;
font-size: $font-size-xs;
}
.address-detail {
font-size: $font-size-sm;
color: $text-primary;
}
.modal-footer {
padding: $space-lg;
border-top: 1px solid $border;
display: flex;
justify-content: flex-end;
}
// 响应式
@media screen and (max-width: 768px) {
.page {
padding: $space-md;
}
.filter-row {
flex-direction: column;
align-items: stretch;
}
.filter-item {
min-width: auto;
}
.filter-actions {
justify-content: stretch;
.btn {
flex: 1;
}
}
.table-row {
flex-direction: column;
align-items: stretch;
.table-cell {
min-width: auto;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
}
.modal-content {
width: 95%;
max-height: 90vh;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>