Files
mp-pilates/packages/app/src/pages/admin/members.vue
2026-04-05 21:35:30 +08:00

481 lines
12 KiB
Vue
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>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="会员管理" show-back />
<!-- Search bar -->
<view class="filter-bar">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索昵称或 OpenID"
placeholder-style="color:#bbb"
@confirm="onSearch"
confirm-type="search"
/>
<view v-if="searchQuery" class="search-clear" @tap="onClear">
<text class="search-clear-icon">×</text>
</view>
<view class="search-btn" @tap="onSearch">
<text class="search-btn-text">搜索</text>
</view>
</view>
<!-- Stats row -->
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ total }}</text>
<text class="stat-label">总会员</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading && !members.length" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!loading && !members.length" class="empty-state">
<view class="empty-icon-wrap">
<view class="empty-icon-person" />
</view>
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text>
</view>
<!-- Member list -->
<view v-else class="member-list">
<view
v-for="m in members"
:key="m.userId"
class="member-row"
@tap="openDetail(m)"
>
<view class="member-avatar">
<image v-if="m.avatarUrl" class="avatar-img" :src="m.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder">
<text class="avatar-text">{{ (m.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<view class="member-info">
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
<text class="member-openid">{{ m.openid }}</text>
</view>
<view class="member-stats">
<text class="member-stat-value">{{ m.totalBookings }}</text>
<text class="member-stat-label">次预约</text>
</view>
<text class="member-arrow"></text>
</view>
</view>
<!-- Bottom status -->
<view v-if="members.length" class="list-footer">
<text class="list-footer-text">
{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '— 已加载全部 —' }}
</text>
</view>
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<view class="modal">
<view class="detail-header">
<view class="detail-avatar">
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder avatar-placeholder--lg">
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">
{{ detailMember.openid }}
</text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
<view class="detail-stats">
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</text>
</view>
</view>
<view class="modal-close" @tap="showDetail = false">
<text class="modal-close-text">关闭</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
const members = ref<MemberSummary[]>([])
const loading = ref(false)
const searchQuery = ref('')
const page = ref(1)
const total = ref(0)
const hasMore = ref(false)
const showDetail = ref(false)
const detailMember = ref<MemberSummary | null>(null)
const LIMIT = 20
async function loadMembers(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
members.value = []
}
loading.value = true
try {
const search = searchQuery.value.trim()
const result = await adminStore.fetchMembers({
page: page.value,
limit: LIMIT,
...(search ? { search } : {}),
})
if (reset) {
members.value = [...result.items]
} else {
members.value = [...members.value, ...result.items]
}
total.value = result.total
hasMore.value = members.value.length < result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function onSearch() {
loadMembers(true)
}
function onClear() {
searchQuery.value = ''
loadMembers(true)
}
// Scroll to bottom → load next page
onReachBottom(() => {
if (!hasMore.value || loading.value) return
page.value++
loadMembers(false)
})
function openDetail(m: MemberSummary) {
detailMember.value = m
showDetail.value = true
}
function copyOpenid(openid: string) {
uni.setClipboardData({
data: openid,
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
})
}
onMounted(() => loadMembers(true))
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 40rpx;
}
/* ── Filter bar ──────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx;
background: $bg-card;
border-bottom: 1rpx solid $border-color;
position: relative;
}
.search-input {
flex: 1;
height: 72rpx;
background: $bg-page;
border-radius: 36rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: $text-primary;
}
.search-clear {
position: absolute;
right: 168rpx;
width: 44rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear-icon {
font-size: 32rpx;
color: $text-hint;
line-height: 1;
}
.search-btn {
background: $brand-color;
border-radius: 36rpx;
padding: 16rpx 32rpx;
}
.search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; }
/* ── Stats row ───────────────────────────── */
.stats-row {
display: flex;
padding: 24rpx 28rpx 16rpx;
}
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
.stat-value { font-size: 36rpx; font-weight: 800; color: $accent-color; }
.stat-label { font-size: 24rpx; color: $text-hint; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 120rpx;
border-radius: $radius-md;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-icon-wrap {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.empty-icon-person {
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 3rpx solid $text-hint;
border-radius: 50%;
position: absolute;
top: 22rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
&::after {
content: '';
width: 36rpx;
height: 16rpx;
border: 3rpx solid $text-hint;
border-bottom: none;
border-radius: 20rpx 20rpx 0 0;
position: absolute;
bottom: 20rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
}
.empty-text { font-size: 28rpx; color: $text-hint; }
/* ── Member list ─────────────────────────── */
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
.member-row {
background: $bg-card;
border-radius: $radius-md;
padding: 24rpx;
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.member-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $brand-color;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder--lg {
width: 120rpx;
height: 120rpx;
}
.avatar-text {
font-size: 32rpx;
font-weight: 700;
color: $accent-color;
}
.avatar-text--lg { font-size: 48rpx; }
.member-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.member-name {
font-size: 28rpx;
font-weight: 600;
color: $brand-color;
}
.member-openid {
font-size: 20rpx;
color: $text-hint;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: Menlo, Monaco, Consolas, monospace;
}
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; flex-shrink: 0; }
.member-stat-value { font-size: 32rpx; font-weight: 700; color: $accent-color; }
.member-stat-label { font-size: 20rpx; color: $text-hint; }
.member-arrow { font-size: 36rpx; color: $text-hint; transform: scaleX(0.6); }
/* ── List footer ─────────────────────────── */
.list-footer {
text-align: center;
padding: 28rpx 0 16rpx;
}
.list-footer-text { font-size: 24rpx; color: $text-hint; }
/* ── Detail modal ────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.modal {
width: 100%;
background: $bg-card;
border-radius: $radius-lg $radius-lg 0 0;
padding: 48rpx 32rpx 60rpx;
}
.detail-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
margin-bottom: 40rpx;
}
.detail-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
margin-bottom: 8rpx;
}
.detail-name { font-size: 32rpx; font-weight: 700; color: $brand-color; }
.detail-openid {
font-size: 22rpx;
color: $accent-color;
font-family: Menlo, Monaco, Consolas, monospace;
padding: 6rpx 16rpx;
background: rgba($accent-color, 0.08);
border-radius: 8rpx;
}
.detail-phone { font-size: 26rpx; color: $text-secondary; }
.detail-stats {
display: flex;
justify-content: space-around;
background: $bg-page;
border-radius: $radius-md;
padding: 28rpx;
margin-bottom: 32rpx;
}
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: $accent-color; }
.detail-stat-label { font-size: 22rpx; color: $text-hint; }
.modal-close {
width: 100%;
height: 88rpx;
background: $bg-page;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close-text { font-size: 28rpx; color: $text-secondary; }
</style>