420 lines
18 KiB
Vue
420 lines
18 KiB
Vue
<template>
|
||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="会员档案" show-back />
|
||
|
||
<view v-if="loading && !detail" class="skeleton-wrap">
|
||
<view class="skeleton-hero" />
|
||
<view class="skeleton-block" />
|
||
<view class="skeleton-block" />
|
||
</view>
|
||
|
||
<template v-else-if="detail">
|
||
<view class="hero">
|
||
<view class="hero-inner">
|
||
<view class="hero-avatar">
|
||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||
<view v-else class="avatar-fallback">
|
||
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="hero-copy">
|
||
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="detail.user.openid" class="member-id" @tap="copyOpenid">
|
||
<text class="member-id-label">微信标识</text>
|
||
<text class="member-id-value">{{ detail.user.openid }}</text>
|
||
<text class="member-id-copy">复制</text>
|
||
</view>
|
||
<view class="time-pair">
|
||
<view class="time-cell">
|
||
<text class="time-label">注册时间</text>
|
||
<text class="time-value">{{ formatDateTimeFull(detail.user.createdAt) }}</text>
|
||
</view>
|
||
<view class="time-rule" />
|
||
<view class="time-cell">
|
||
<text class="time-label">最近登录</text>
|
||
<text class="time-value">{{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 ›</button></view>
|
||
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
|
||
<template v-if="activeTab === 'overview'">
|
||
<view class="section section--practice">
|
||
<view class="section-heading">
|
||
<text class="section-label">上课情况</text>
|
||
<button class="supplement-entry" @tap="goSupplement">补录上课 <text>›</text></button>
|
||
</view>
|
||
<view class="stats-strip">
|
||
<view class="stat">
|
||
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
|
||
<text class="stat-name">累计预约</text>
|
||
</view>
|
||
<view class="stat stat--completed">
|
||
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
|
||
<text class="stat-name">累计上课</text>
|
||
</view>
|
||
<view class="stat">
|
||
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
|
||
<text class="stat-name">已取消</text>
|
||
</view>
|
||
<view class="stat">
|
||
<text class="stat-num">{{ detail.stats.noShowBookings }}</text>
|
||
<text class="stat-name">未到课</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-heading">
|
||
<text class="section-label">会员卡</text>
|
||
<text class="section-note">{{ detail.memberships.length }} 张</text>
|
||
</view>
|
||
<view v-if="detail.memberships.length" class="card-list">
|
||
<view
|
||
v-for="card in detail.memberships"
|
||
:key="card.id"
|
||
class="mship"
|
||
>
|
||
<view class="mship-head">
|
||
<view class="mship-titles">
|
||
<text class="mship-name">{{ card.cardType.name }}</text>
|
||
<text class="mship-type">{{ getCardTypeLabel(card.cardType.type) }}</text>
|
||
</view>
|
||
<view class="mship-status" :class="'mship-status--' + card.status.toLowerCase()">
|
||
<text class="mship-status-text">{{ membershipStatusLabel(card.status) }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="card.remainingTimes !== null" class="mship-times">
|
||
<text class="mship-times-num">{{ card.remainingTimes }}</text>
|
||
<text class="mship-times-unit">次可用</text>
|
||
</view>
|
||
<view v-else class="mship-duration">
|
||
<text class="mship-duration-title">有效期内使用</text>
|
||
<text class="mship-times-unit">不限次数</text>
|
||
</view>
|
||
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
|
||
<view class="progress-bar">
|
||
<view class="progress-fill" :style="{ width: getMembershipProgressWidth(card) }" />
|
||
</view>
|
||
<text class="progress-text">
|
||
已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
|
||
</text>
|
||
</view>
|
||
<view class="mship-dates">
|
||
<text>{{ formatDate(card.startDate) }} 起</text>
|
||
<text>{{ formatDate(card.expireDate) }} 止</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="empty-card">
|
||
<text class="empty-card-title">尚未开卡</text>
|
||
<text class="empty-card-sub">开通体验卡、次卡或月卡后即可安排课程</text>
|
||
<view class="empty-card-btn" @tap="goEdit">
|
||
<text class="empty-card-btn-text">去开卡</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section section--last">
|
||
<view class="section-heading">
|
||
<text class="section-label">即将上课</text>
|
||
<text v-if="detail.upcomingBookings.length" class="section-note">{{ detail.upcomingBookings.length }} 节待上</text>
|
||
</view>
|
||
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
|
||
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
|
||
<view class="upcoming-time">
|
||
<text class="upcoming-date">{{ item.date.slice(5, 10).replace('-', ' / ') }}</text>
|
||
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}</text>
|
||
</view>
|
||
<view class="upcoming-meta">
|
||
<text class="upcoming-card">{{ item.cardName }}</text>
|
||
<text class="upcoming-status">{{ bookingStatusLabel(item.status) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="upcoming-empty">
|
||
<text class="upcoming-empty-text">近期没有待上的课</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</template>
|
||
|
||
<view v-if="activeTab === 'overview'" class="dock">
|
||
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||
<text class="dock-btn-text">编辑资料</text>
|
||
</view>
|
||
<view
|
||
class="dock-btn dock-btn--solid"
|
||
:class="{ 'dock-btn--disabled': !canArrange }"
|
||
@tap="goArrange"
|
||
>
|
||
<text class="dock-btn-text dock-btn-text--solid">安排课程</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||
import MemberProgress from '../../components/MemberProgress.vue'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
import {
|
||
formatDateTimeFull,
|
||
getCardTypeLabel,
|
||
getMembershipProgressWidth,
|
||
getMembershipUsedTimes,
|
||
getMembershipTotalTimes,
|
||
} from '../../utils/format'
|
||
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
||
import { getErrorMessage } from '../../utils/auth'
|
||
import { useAdminStore } from './stores/admin'
|
||
|
||
const adminStore = useAdminStore()
|
||
const navBarHeight = ref('64px')
|
||
const userId = ref('')
|
||
const loading = ref(false)
|
||
const activeTab = ref('overview'), progressRefreshKey = ref(0)
|
||
const detail = ref<AdminMemberDetail | null>(null)
|
||
|
||
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||
|
||
function isArrangableMembership(membership: MembershipWithCardType): boolean {
|
||
if (membership.status !== MembershipStatus.ACTIVE) return false
|
||
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
|
||
return new Date(membership.expireDate) > new Date()
|
||
}
|
||
|
||
function membershipStatusLabel(status: string): string {
|
||
const map: Record<string, string> = {
|
||
ACTIVE: '有效',
|
||
EXPIRED: '已过期',
|
||
USED_UP: '已用完',
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
function bookingStatusLabel(status: BookingStatus): string {
|
||
return BOOKING_STATUS_LABELS[status] || status
|
||
}
|
||
|
||
function formatDate(dateStr: string): string {
|
||
return dateStr.slice(0, 10)
|
||
}
|
||
|
||
function copyOpenid() {
|
||
const openid = detail.value?.user.openid
|
||
if (!openid) return
|
||
uni.setClipboardData({
|
||
data: openid,
|
||
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
|
||
})
|
||
}
|
||
|
||
async function loadDetail() {
|
||
if (!userId.value) return
|
||
loading.value = true
|
||
try {
|
||
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||
} catch (err: unknown) {
|
||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
|
||
function goSupplement() {
|
||
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
||
}
|
||
|
||
function goEdit() {
|
||
if (!userId.value) return
|
||
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
|
||
}
|
||
|
||
function goArrange() {
|
||
if (!canArrange.value) {
|
||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||
return
|
||
}
|
||
uni.navigateTo({ url: `/pages/admin/member-arrange?userId=${userId.value}` })
|
||
}
|
||
|
||
onLoad((query) => {
|
||
userId.value = String(query?.userId || '')
|
||
})
|
||
|
||
onMounted(() => {
|
||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||
})
|
||
|
||
onShow(() => {
|
||
progressRefreshKey.value++
|
||
if (userId.value) {
|
||
loadDetail()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
|
||
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
|
||
|
||
.page {
|
||
--ink: #514943;
|
||
--muted: #8b817b;
|
||
--line: #eee8e3;
|
||
--sage: #617d73;
|
||
min-height: 100vh;
|
||
box-sizing: border-box;
|
||
background: #fbf9f6;
|
||
color: var(--ink);
|
||
padding-bottom: calc(152rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.skeleton-wrap { padding: 28rpx 32rpx; }
|
||
.skeleton-hero, .skeleton-block {
|
||
border-radius: 28rpx;
|
||
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
}
|
||
.skeleton-hero { height: 300rpx; margin-bottom: 28rpx; }
|
||
.skeleton-block { height: 180rpx; margin-bottom: 28rpx; }
|
||
|
||
.hero {
|
||
margin: 28rpx 32rpx 0;
|
||
padding: 32rpx;
|
||
border-radius: 32rpx;
|
||
background: #f3eae5;
|
||
}
|
||
.hero-inner { display: flex; align-items: center; gap: 24rpx; }
|
||
.hero-avatar {
|
||
width: 112rpx;
|
||
height: 112rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
border: 6rpx solid #fcf8f4;
|
||
flex-shrink: 0;
|
||
}
|
||
.avatar-img { width: 100%; height: 100%; }
|
||
.avatar-fallback {
|
||
width: 100%; height: 100%; background: #e0cec4;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.avatar-letter {
|
||
font-family: 'Songti SC', 'STSong', serif;
|
||
font-size: 44rpx; font-weight: 400; color: #735e53;
|
||
}
|
||
.hero-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||
.hero-name { font-size: 38rpx; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
|
||
.hero-phone { font-size: 26rpx; color: #84746b; letter-spacing: 1rpx; }
|
||
.member-id {
|
||
display: flex; align-items: center; gap: 14rpx;
|
||
margin-top: 24rpx; min-height: 48rpx;
|
||
font-size: 20rpx; color: #88786f;
|
||
}
|
||
.member-id-label { flex-shrink: 0; }
|
||
.member-id-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.member-id-copy { flex-shrink: 0; color: #715a4e; padding: 8rpx 0 8rpx 8rpx; }
|
||
.time-pair {
|
||
display: flex; gap: 20rpx;
|
||
margin-top: 20rpx; padding-top: 24rpx;
|
||
border-top: 1rpx solid #e4d8d0;
|
||
}
|
||
.time-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||
.time-rule { width: 1rpx; background: #e4d8d0; }
|
||
.time-label { font-size: 21rpx; color: #88786f; }
|
||
.time-value { font-size: 22rpx; color: #6d5c52; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||
|
||
.section { padding: 36rpx 32rpx 0; }
|
||
.section--last { padding-bottom: 24rpx; }
|
||
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
|
||
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
|
||
.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
|
||
.section-note { font-size: 22rpx; color: var(--muted); }
|
||
.stats-strip {
|
||
display: flex; padding: 26rpx 0;
|
||
border-radius: 24rpx; background: #ffffff;
|
||
}
|
||
.stat {
|
||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||
align-items: center; gap: 12rpx; border-right: 1rpx solid var(--line);
|
||
&:last-child { border-right: none; }
|
||
}
|
||
.stat-num { font-size: 40rpx; font-weight: 400; line-height: 1.1; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; font-variant-numeric: tabular-nums; }
|
||
.stat-name { font-size: 22rpx; color: var(--muted); }
|
||
.stat--completed { .stat-num, .stat-name { color: var(--sage); } }
|
||
|
||
.card-list { display: flex; flex-direction: column; gap: 20rpx; }
|
||
.mship { padding: 28rpx; background: #fff; border-radius: 28rpx; border: 1rpx solid var(--line); }
|
||
.mship-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
|
||
.mship-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||
.mship-name { font-size: 29rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||
.mship-type { font-size: 21rpx; color: var(--muted); }
|
||
.mship-status {
|
||
flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx;
|
||
background: #edf2ee; color: #617d73; line-height: 1.3;
|
||
&--expired, &--used_up { background: #f2efec; color: #8b817b; }
|
||
}
|
||
.mship-status-text { font-size: 21rpx; }
|
||
.mship-times { margin-top: 22rpx; display: flex; align-items: baseline; gap: 10rpx; }
|
||
.mship-times-num { font-size: 52rpx; font-weight: 400; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; line-height: 1.2; }
|
||
.mship-times-unit { font-size: 22rpx; color: var(--muted); }
|
||
.mship-duration { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||
.mship-duration-title { font-size: 28rpx; color: #617d73; }
|
||
.progress { margin-top: 18rpx; }
|
||
.progress-bar { height: 6rpx; border-radius: 6rpx; background: #f0efea; overflow: hidden; }
|
||
.progress-fill { height: 100%; border-radius: 6rpx; background: #a5b8ab; }
|
||
.progress-text { display: block; margin-top: 10rpx; font-size: 20rpx; color: var(--muted); }
|
||
.mship-dates {
|
||
margin-top: 22rpx; padding-top: 18rpx; border-top: 1rpx solid #f3efeb;
|
||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8rpx 20rpx;
|
||
font-size: 21rpx; color: var(--muted); line-height: 1.5;
|
||
}
|
||
.empty-card {
|
||
background: #f4f2ed; border-radius: 28rpx; padding: 36rpx 28rpx;
|
||
display: flex; flex-direction: column; align-items: flex-start; gap: 12rpx;
|
||
}
|
||
.empty-card-title { font-size: 28rpx; font-weight: 500; }
|
||
.empty-card-sub { font-size: 23rpx; color: var(--muted); line-height: 1.7; }
|
||
.empty-card-btn { margin-top: 10rpx; padding: 14rpx 28rpx; border-radius: 999rpx; background: #e4ebe4; }
|
||
.empty-card-btn-text { font-size: 24rpx; color: #526e62; }
|
||
|
||
.upcoming-list { background: #fff; border-radius: 28rpx; padding: 0 28rpx; }
|
||
.upcoming-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 24rpx;
|
||
padding: 26rpx 0; border-bottom: 1rpx solid var(--line);
|
||
&:last-child { border-bottom: none; }
|
||
}
|
||
.upcoming-time { flex-shrink: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||
.upcoming-date { font-size: 28rpx; font-weight: 500; }
|
||
.upcoming-hour { font-size: 23rpx; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
.upcoming-meta { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
|
||
.upcoming-card { font-size: 24rpx; text-align: right; overflow-wrap: anywhere; }
|
||
.upcoming-status { font-size: 21rpx; color: var(--sage); }
|
||
.upcoming-empty { padding: 32rpx 28rpx; background: #f4f2ed; border-radius: 24rpx; }
|
||
.upcoming-empty-text { font-size: 24rpx; color: var(--muted); }
|
||
|
||
.dock {
|
||
position: fixed; z-index: 10; left: 0; right: 0; bottom: 0;
|
||
display: flex; gap: 20rpx;
|
||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||
background: #fbf9f6; border-top: 1rpx solid var(--line);
|
||
}
|
||
.dock-btn { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||
.dock-btn--ghost { background: #f0eae4; }
|
||
.dock-btn--solid { flex: 1.35; background: #6b8276; }
|
||
.dock-btn--disabled { background: #d9dfd8; .dock-btn-text--solid { color: #677467; } }
|
||
.dock-btn-text { font-size: 28rpx; font-weight: 500; color: #78675c; }
|
||
.dock-btn-text--solid { color: #fff; }
|
||
</style>
|