Files
mp-pilates/packages/app/src/pages/admin/member-detail.vue
richarjiang 139882d7a1 feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00

420 lines
18 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 />
<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>