perf: 优化订阅刷新逻辑

This commit is contained in:
richarjiang
2026-09-07 14:06:19 +08:00
parent 88cd8419c8
commit f5c7b7eaac
27 changed files with 3248 additions and 1060 deletions

View File

@@ -99,6 +99,24 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/admin/member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-arrange",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/admin/orders", "path": "pages/admin/orders",
"style": { "style": {

View File

@@ -146,6 +146,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared' import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared' import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
@@ -165,6 +166,7 @@ const bookingStore = useBookingStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const refreshing = ref(false) const refreshing = ref(false)
const loading = ref(false) const loading = ref(false)
const hasLoadedOnce = ref(false)
// ─── Filter state ───────────────────────────────────────────────────────── // ─── Filter state ─────────────────────────────────────────────────────────
type FilterValue = string | null type FilterValue = string | null
@@ -215,9 +217,9 @@ function formatTimelineText(h: BookingStatusHistory): string {
} }
// ─── Data loading ───────────────────────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────────────────────
async function loadBookings(append = false) { async function loadBookings(append = false, opts: { silent?: boolean } = {}) {
if (loading.value) return if (loading.value) return
loading.value = true if (!opts.silent) loading.value = true
try { try {
const page = append ? currentPage.value + 1 : 1 const page = append ? currentPage.value + 1 : 1
@@ -234,8 +236,9 @@ async function loadBookings(append = false) {
totalCount.value = result.total totalCount.value = result.total
hasMore.value = bookings.value.length < result.total hasMore.value = bookings.value.length < result.total
// Fetch history for each booking // Fetch history for each booking. Skip in silent mode — history is only
if (!append) { // shown as a small inline preview, and the detail page has the full one.
if (!append && !opts.silent) {
await Promise.all( await Promise.all(
bookings.value.map((b) => fetchHistory(b.id)), bookings.value.map((b) => fetchHistory(b.id)),
) )
@@ -247,9 +250,9 @@ async function loadBookings(append = false) {
} }
} catch (err) { } catch (err) {
console.error('Load bookings failed:', err) console.error('Load bookings failed:', err)
uni.showToast({ title: '加载失败', icon: 'none' }) if (!opts.silent) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
loading.value = false if (!opts.silent) loading.value = false
} }
} }
@@ -391,6 +394,24 @@ onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadBookings(false) loadBookings(false)
loadAllForStats() loadAllForStats()
hasLoadedOnce.value = true
})
// After returning from booking detail (where status may have changed),
// re-sync the list with the server. Local row actions still call onRefresh
// directly — this onShow is the safety net for the navigate-back path.
// Uses silent mode so the list stays visible (no skeleton flash).
onShow(() => {
if (!hasLoadedOnce.value) return
// Skip while a refresh is already in flight to avoid overlap.
if (refreshing.value || loading.value) return
Promise.all([
loadBookings(false, { silent: true }),
loadAllForStats(),
]).catch(() => {
// Errors are non-fatal here — list keeps showing stale data until
// the next explicit refresh.
})
}) })
</script> </script>

View File

@@ -0,0 +1,694 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="安排课程" show-back />
<view v-if="detail" class="who-bar">
<view class="who-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="who-copy">
<text class="who-kicker">为学员安排</text>
<text class="who-name">{{ detail.user.nickname || '未知用户' }}</text>
</view>
</view>
<view v-if="usableMemberships.length" class="card-switch">
<scroll-view scroll-x class="card-scroll" :show-scrollbar="false">
<view class="card-track">
<view
v-for="card in usableMemberships"
:key="card.id"
class="card-pill"
:class="{ 'card-pill--on': selectedMembershipId === card.id }"
@tap="selectedMembershipId = card.id"
>
<text class="card-pill-name">{{ card.cardType.name }}</text>
<text class="card-pill-meta">
{{ card.remainingTimes == null ? '月卡不扣次' : `${card.remainingTimes}` }}
</text>
</view>
</view>
</scroll-view>
</view>
<view v-else class="ban-banner">
<text class="ban-text">该会员没有可扣课的有效卡请先开卡</text>
</view>
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" />
<view v-if="slotsLoading" class="slot-skeleton">
<view v-for="i in 4" :key="i" class="slot-skel" />
</view>
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
<text class="empty-title">这天还没有可安排的课表</text>
<text class="empty-sub">可以去排课管理发布或在下方加开一个时段</text>
<view class="ghost-link" @tap="goSchedule">
<text class="ghost-link-text">前往排课管理</text>
</view>
</view>
<view v-else class="slot-list">
<view
v-for="slot in filteredSlots"
:key="slot.startTime + slot.endTime + (slot.id || 'draft')"
class="slot-row"
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
@tap="onPickSlot(slot)"
>
<view class="slot-time-col">
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}</text>
<text class="slot-end">{{ slot.endTime.slice(0, 5) }}</text>
</view>
<view class="slot-body">
<text class="slot-title">{{ slotLabel(slot) }}</text>
<text class="slot-cap">{{ slot.bookedCount }}/{{ slot.capacity }} </text>
</view>
<text class="slot-action">{{ slotActionLabel(slot) }}</text>
</view>
</view>
<view class="custom-block">
<text class="custom-title">加开此时段</text>
<view class="custom-row">
<picker mode="time" :value="customStart" @change="onCustomStartChange">
<view class="custom-picker">
<text class="custom-picker-text">{{ customStart }} {{ customEnd }}</text>
<text class="custom-picker-arrow">选择开始时间</text>
</view>
</picker>
<view
class="custom-btn"
:class="{ 'custom-btn--disabled': !canArrange }"
@tap="onCustomArrange"
>
<text class="custom-btn-text">加开并安排</text>
</view>
</view>
</view>
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
<view class="sheet" @tap.stop>
<text class="sheet-kicker">立即确认</text>
<text class="sheet-title">安排给 {{ detail?.user.nickname || '该会员' }}</text>
<view class="sheet-lines">
<view class="sheet-line">
<text class="sheet-label">时间</text>
<text class="sheet-value">{{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}{{ pendingSlot.endTime.slice(0, 5) }}</text>
</view>
<view class="sheet-line">
<text class="sheet-label">扣卡</text>
<text class="sheet-value">{{ selectedMembership?.cardType.name }}</text>
</view>
</view>
<text class="sheet-note">{{ deductHint }}</text>
<view class="sheet-actions">
<view class="sheet-cancel" @tap="confirmVisible = false">
<text class="sheet-cancel-text">取消</text>
</view>
<view class="sheet-ok" :class="{ 'sheet-ok--disabled': arranging }" @tap="confirmArrange">
<text class="sheet-ok-text">{{ arranging ? '安排中...' : '确认安排' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type {
AdminMemberDetail,
ScheduleSlotPreview,
} from '@mp-pilates/shared'
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, TimeSlotSource } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDate, isSlotPast } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
type PeriodKey = keyof typeof TIME_PERIODS | null
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const detail = ref<AdminMemberDetail | null>(null)
const selectedMembershipId = ref('')
const selectedDate = ref(formatDate(new Date()))
const selectedPeriod = ref<PeriodKey>(null)
const slots = ref<ScheduleSlotPreview[]>([])
const slotsLoading = ref(false)
const customStart = ref('10:00')
const confirmVisible = ref(false)
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
const arranging = ref(false)
const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) =>
item.status === MembershipStatus.ACTIVE
&& (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(),
),
)
const selectedMembership = computed(
() => usableMemberships.value.find((item) => item.id === selectedMembershipId.value) ?? null,
)
const canArrange = computed(() => Boolean(selectedMembership.value))
const isDurationCard = computed(() => selectedMembership.value?.remainingTimes == null)
const deductHint = computed(() => {
if (isDurationCard.value) {
return '将立即确认该课,月卡不扣次,会员无需再确认。'
}
return '将立即确认该课并扣除 1 次,会员无需再确认。'
})
const filteredSlots = computed(() => {
if (!selectedPeriod.value) return slots.value
const period = TIME_PERIODS[selectedPeriod.value]
return slots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
})
const customEnd = computed(() => addHour(customStart.value))
function addHour(time: string): string {
const [hours, minutes] = time.split(':').map(Number)
const total = hours * 60 + (minutes || 0) + 60
const nextHours = Math.floor(total / 60) % 24
const nextMinutes = total % 60
return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`
}
function canPickSlot(slot: ScheduleSlotPreview): boolean {
if (!canArrange.value) return false
if (isSlotPast(slot.date, slot.startTime)) return false
if (slot.status === TimeSlotStatus.CLOSED) return false
if (slot.isPublished && (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity)) {
return false
}
return true
}
function slotLabel(slot: ScheduleSlotPreview): string {
if (!slot.isPublished || !slot.id) return '未发布时段'
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
return '可安排'
}
function slotActionLabel(slot: ScheduleSlotPreview): string {
if (!slot.isPublished || !slot.id) return '加开'
if (!canPickSlot(slot)) return '—'
return '安排'
}
async function loadSlots(date: string) {
slotsLoading.value = true
try {
slots.value = await adminStore.previewScheduleByDate(date)
} catch (err: unknown) {
slots.value = []
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
} finally {
slotsLoading.value = false
}
}
async function loadPage() {
detail.value = await adminStore.fetchMemberDetail(userId.value)
const first = usableMemberships.value[0]
selectedMembershipId.value = first?.id ?? ''
await loadSlots(selectedDate.value)
}
function onDateSelect(date: string) {
selectedDate.value = date
loadSlots(date)
}
function onCustomStartChange(e: { detail: { value: string } }) {
customStart.value = e.detail.value
}
function goSchedule() {
uni.navigateTo({ url: '/pages/admin/schedule' })
}
function onPickSlot(slot: ScheduleSlotPreview) {
if (!canPickSlot(slot)) return
pendingSlot.value = slot
confirmVisible.value = true
}
function onCustomArrange() {
if (!canArrange.value) {
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
return
}
if (customEnd.value <= customStart.value) {
uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
return
}
if (isSlotPast(selectedDate.value, customStart.value)) {
uni.showToast({ title: '不能安排已经过去的时间', icon: 'none' })
return
}
pendingSlot.value = {
id: null,
date: selectedDate.value,
startTime: customStart.value,
endTime: customEnd.value,
capacity: 1,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.MANUAL,
templateId: null,
isPublished: false,
}
confirmVisible.value = true
}
async function confirmArrange() {
const slot = pendingSlot.value
const membership = selectedMembership.value
if (!slot || !membership || arranging.value) return
arranging.value = true
try {
let timeSlotId = slot.id
await adminStore.arrangeMemberBooking({
userId: userId.value,
membershipId: membership.id,
...(timeSlotId ? { timeSlotId } : {}),
date: slot.date,
startTime: slot.startTime.slice(0, 5),
endTime: slot.endTime.slice(0, 5),
capacity: slot.capacity || 1,
})
confirmVisible.value = false
uni.showToast({ title: '已安排并确认', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '安排失败'), icon: 'none' })
} finally {
arranging.value = false
}
}
onLoad((query) => {
userId.value = String(query?.userId || '')
})
onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
try {
await loadPage()
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 80rpx;
}
.who-bar {
margin: 16rpx 24rpx 0;
padding: 20rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #3c3228, #5a4a3a);
display: flex;
align-items: center;
gap: 18rpx;
}
.who-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
overflow: hidden;
flex-shrink: 0;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-fallback {
width: 100%;
height: 100%;
background: $accent-color;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-letter {
color: #fff8f0;
font-weight: 700;
}
.who-copy {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.who-kicker {
font-size: 18rpx;
letter-spacing: 3rpx;
color: rgba(255, 248, 240, 0.55);
}
.who-name {
font-size: 32rpx;
font-weight: 700;
color: #fff8f0;
}
.card-switch {
padding: 16rpx 0 4rpx;
}
.card-scroll { white-space: nowrap; }
.card-track {
display: inline-flex;
gap: 12rpx;
padding: 0 24rpx;
}
.card-pill {
padding: 14rpx 22rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx solid rgba(180, 160, 130, 0.18);
display: flex;
flex-direction: column;
gap: 4rpx;
}
.card-pill--on {
border-color: $brand-color;
background: #3c3228;
}
.card-pill-name {
font-size: 24rpx;
font-weight: 700;
color: $text-primary;
}
.card-pill--on .card-pill-name,
.card-pill--on .card-pill-meta {
color: #fff8f0;
}
.card-pill-meta {
font-size: 20rpx;
color: $text-hint;
}
.ban-banner {
margin: 16rpx 24rpx;
padding: 20rpx;
border-radius: 16rpx;
background: rgba($error-color, 0.1);
}
.ban-text {
font-size: 24rpx;
color: $error-color;
}
.slot-skeleton { padding: 16rpx 24rpx; }
.slot-skel {
height: 112rpx;
border-radius: 18rpx;
margin-bottom: 12rpx;
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.empty-slots {
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
.empty-title {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
}
.empty-sub {
font-size: 24rpx;
color: $text-hint;
text-align: center;
}
.ghost-link {
margin-top: 8rpx;
padding: 10rpx 24rpx;
border-radius: 999rpx;
border: 1rpx solid $brand-color;
}
.ghost-link-text {
font-size: 22rpx;
color: $brand-color;
}
.slot-list {
padding: 16rpx 24rpx 8rpx;
}
.slot-row {
display: flex;
align-items: center;
gap: 20rpx;
background: #fff;
border-radius: 18rpx;
padding: 22rpx 20rpx;
margin-bottom: 12rpx;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.slot-row--disabled {
opacity: 0.45;
}
.slot-time-col {
width: 120rpx;
display: flex;
flex-direction: column;
}
.slot-time {
font-size: 34rpx;
font-weight: 800;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.slot-end {
font-size: 22rpx;
color: $text-hint;
}
.slot-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.slot-title {
font-size: 26rpx;
font-weight: 600;
color: $text-primary;
}
.slot-cap {
font-size: 22rpx;
color: $text-hint;
}
.slot-action {
font-size: 24rpx;
font-weight: 700;
color: $accent-color;
}
.custom-block {
margin: 12rpx 24rpx 40rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
border: 1rpx dashed rgba(180, 160, 130, 0.28);
}
.custom-title {
display: block;
font-size: 22rpx;
letter-spacing: 3rpx;
color: $text-hint;
margin-bottom: 16rpx;
}
.custom-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.custom-picker {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.custom-picker-text {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.custom-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
.custom-btn {
padding: 16rpx 22rpx;
border-radius: 14rpx;
background: $brand-color;
}
.custom-btn--disabled { opacity: 0.4; }
.custom-btn-text {
font-size: 24rpx;
font-weight: 700;
color: #fff8f0;
}
.mask {
position: fixed;
inset: 0;
background: rgba(44, 36, 28, 0.45);
display: flex;
align-items: flex-end;
z-index: 20;
}
.sheet {
width: 100%;
background: #fff8f0;
border-radius: 28rpx 28rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-kicker {
font-size: 20rpx;
letter-spacing: 4rpx;
color: $text-hint;
}
.sheet-title {
display: block;
margin-top: 8rpx;
font-size: 36rpx;
font-weight: 700;
color: $text-primary;
font-family: 'Songti SC', Georgia, serif;
}
.sheet-lines {
margin: 28rpx 0 16rpx;
}
.sheet-line {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.14);
}
.sheet-label {
font-size: 24rpx;
color: $text-hint;
}
.sheet-value {
font-size: 26rpx;
color: $text-primary;
}
.sheet-note {
display: block;
font-size: 24rpx;
color: $text-secondary;
line-height: 1.5;
}
.sheet-actions {
display: flex;
gap: 16rpx;
margin-top: 28rpx;
}
.sheet-cancel,
.sheet-ok {
flex: 1;
height: 84rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.sheet-cancel {
background: #fff;
border: 2rpx solid $brand-color;
}
.sheet-ok {
background: $brand-color;
}
.sheet-ok--disabled { opacity: 0.5; }
.sheet-cancel-text {
font-size: 28rpx;
font-weight: 700;
color: $brand-color;
}
.sheet-ok-text {
font-size: 28rpx;
font-weight: 700;
color: #fff8f0;
}
</style>

View File

@@ -0,0 +1,680 @@
<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-grain" />
<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-kicker">STUDIO LEDGER</text>
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
<text class="hero-openid" @tap="copyOpenid">{{ detail.user.openid }}</text>
</view>
</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="section">
<text class="section-label">上课情况</text>
<view class="stats-strip">
<view class="stat">
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
<text class="stat-name">累计预约</text>
</view>
<view class="stat">
<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>
<text class="noshow-hint">未到 {{ detail.stats.noShowBookings }} </text>
</view>
<view class="section">
<text class="section-label">会员卡</text>
<view v-if="detail.memberships.length" class="card-list">
<view
v-for="card in detail.memberships"
:key="card.id"
class="mship"
>
<view class="mship-strip" :class="getCardGradientClass(card.cardType.type)" />
<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-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">
<text class="section-label">即将上课</text>
<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) }}</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>
<view 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 CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import {
formatDateTimeFull,
getCardTypeLabel,
getCardGradientClass,
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 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 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(() => {
if (userId.value) {
loadDetail()
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 180rpx;
}
.skeleton-wrap {
padding: 24rpx;
}
.skeleton-hero,
.skeleton-block {
border-radius: 24rpx;
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-hero { height: 360rpx; margin-bottom: 24rpx; }
.skeleton-block { height: 180rpx; margin-bottom: 20rpx; }
.hero {
margin: 20rpx 24rpx 8rpx;
border-radius: 28rpx;
overflow: hidden;
background: linear-gradient(160deg, #2c241c 0%, #4a4035 58%, #6b5a48 100%);
position: relative;
box-shadow: 0 18rpx 40rpx rgba(44, 36, 28, 0.22);
}
.hero-grain {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 18% 20%, rgba(169, 191, 204, 0.18), transparent 36%),
radial-gradient(circle at 90% 80%, rgba(232, 168, 124, 0.16), transparent 32%);
}
.hero-inner {
position: relative;
display: flex;
gap: 24rpx;
padding: 36rpx 32rpx 20rpx;
}
.hero-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 28rpx;
overflow: hidden;
border: 3rpx solid rgba(255, 248, 240, 0.35);
flex-shrink: 0;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-fallback {
width: 100%;
height: 100%;
background: #7ba5be;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-letter {
font-size: 48rpx;
font-weight: 700;
color: #fff8f0;
}
.hero-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.hero-kicker {
font-size: 18rpx;
letter-spacing: 4rpx;
color: rgba(200, 216, 228, 0.72);
}
.hero-name {
font-size: 40rpx;
font-weight: 700;
color: #fff8f0;
font-family: 'Songti SC', 'Noto Serif SC', Georgia, serif;
}
.hero-phone,
.hero-openid {
font-size: 22rpx;
color: rgba(255, 248, 240, 0.68);
}
.hero-openid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-pair {
position: relative;
margin: 8rpx 20rpx 20rpx;
background: rgba(255, 248, 240, 0.08);
border: 1rpx solid rgba(255, 248, 240, 0.1);
border-radius: 18rpx;
display: flex;
padding: 18rpx 8rpx;
}
.time-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.time-rule {
width: 1rpx;
background: rgba(255, 248, 240, 0.16);
}
.time-label {
font-size: 20rpx;
color: rgba(255, 248, 240, 0.5);
letter-spacing: 2rpx;
}
.time-value {
font-size: 22rpx;
color: #fff8f0;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.section {
padding: 28rpx 24rpx 0;
}
.section--last {
padding-bottom: 24rpx;
}
.section-label {
display: block;
font-size: 22rpx;
letter-spacing: 4rpx;
color: $text-hint;
margin-bottom: 16rpx;
}
.stats-strip {
background: $bg-card;
border-radius: 20rpx;
display: flex;
padding: 28rpx 12rpx;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 40rpx;
font-weight: 800;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.stat-name {
font-size: 22rpx;
color: $text-hint;
}
.noshow-hint {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: $text-hint;
}
.card-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.mship {
background: $bg-card;
border-radius: 20rpx;
overflow: hidden;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.mship-strip {
height: 8rpx;
}
.gradient--times { background: linear-gradient(90deg, #7ba5be, #a9bfcc); }
.gradient--duration { background: linear-gradient(90deg, #7A9E7E, #b7cbb8); }
.gradient--trial { background: linear-gradient(90deg, #C47A7A, #e8b4b4); }
.mship-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24rpx 24rpx 8rpx;
}
.mship-titles {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.mship-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
}
.mship-type {
font-size: 22rpx;
color: $text-hint;
}
.mship-status {
padding: 4rpx 12rpx;
border-radius: 8rpx;
background: rgba($success-color, 0.14);
&--expired,
&--used_up { background: rgba($text-hint, 0.14); }
}
.mship-status-text {
font-size: 20rpx;
color: $text-secondary;
}
.mship-times {
padding: 4rpx 24rpx 0;
display: flex;
align-items: baseline;
gap: 8rpx;
}
.mship-times-num {
font-size: 48rpx;
font-weight: 800;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.mship-times-unit {
font-size: 22rpx;
color: $text-hint;
}
.progress {
padding: 12rpx 24rpx 0;
}
.progress-bar {
height: 8rpx;
border-radius: 8rpx;
background: rgba(180, 160, 130, 0.16);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 8rpx;
background: $accent-color;
}
.progress-text {
display: block;
margin-top: 8rpx;
font-size: 20rpx;
color: $text-hint;
}
.mship-dates {
padding: 16rpx 24rpx 24rpx;
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: $text-secondary;
}
.empty-card {
background: $bg-card;
border-radius: 20rpx;
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
border: 1rpx dashed rgba(180, 160, 130, 0.28);
}
.empty-card-title {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
}
.empty-card-sub {
font-size: 24rpx;
color: $text-hint;
}
.empty-card-btn {
margin-top: 12rpx;
padding: 12rpx 32rpx;
border-radius: 999rpx;
background: $brand-color;
}
.empty-card-btn-text {
font-size: 24rpx;
color: $primary-dark;
font-weight: 600;
}
.upcoming-list {
background: $bg-card;
border-radius: 20rpx;
overflow: hidden;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.upcoming-row {
display: flex;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
&:last-child { border-bottom: none; }
}
.upcoming-time {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.upcoming-date {
font-size: 26rpx;
font-weight: 700;
color: $text-primary;
}
.upcoming-hour {
font-size: 24rpx;
color: $accent-color;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.upcoming-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6rpx;
}
.upcoming-card {
font-size: 24rpx;
color: $text-secondary;
}
.upcoming-status {
font-size: 20rpx;
color: $text-hint;
}
.upcoming-empty {
padding: 32rpx;
background: $bg-card;
border-radius: 20rpx;
}
.upcoming-empty-text {
font-size: 24rpx;
color: $text-hint;
}
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: rgba(250, 248, 245, 0.94);
border-top: 1rpx solid rgba(180, 160, 130, 0.14);
}
.dock-btn {
flex: 1;
height: 88rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dock-btn--ghost {
background: #fff;
border: 2rpx solid $brand-color;
}
.dock-btn--solid {
background: $brand-color;
}
.dock-btn--disabled {
opacity: 0.38;
}
.dock-btn-text {
font-size: 28rpx;
font-weight: 700;
color: $brand-color;
}
.dock-btn-text--solid {
color: #fff8f0;
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="编辑资料" show-back />
<view v-if="pageLoading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else class="form">
<view class="block">
<text class="block-title">基本信息</text>
<view class="field">
<text class="field-label">昵称</text>
<input class="field-input" v-model="profileForm.nickname" maxlength="32" placeholder="会员昵称" />
</view>
<view class="field">
<text class="field-label">手机号</text>
<input class="field-input" v-model="profileForm.phone" maxlength="20" type="number" placeholder="未绑定可手动填写" />
</view>
</view>
<view class="block">
<text class="block-title">{{ editingMembership ? '会员卡' : '开通会员卡' }}</text>
<view v-if="existingMemberships.length > 1" class="field">
<text class="field-label">选择卡片</text>
<picker
class="field-picker"
mode="selector"
:value="membershipIndex"
:range="membershipPickerLabels"
@change="onMembershipPick"
>
<view class="picker-inner">
<text class="picker-text">{{ membershipPickerLabels[membershipIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="field">
<text class="field-label">卡类型</text>
<picker
class="field-picker"
mode="selector"
:value="editForm.cardTypeIndex"
:range="cardTypes"
range-key="name"
@change="onCardTypeChange"
>
<view class="picker-inner">
<text class="picker-text">{{ cardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="isTimeBasedCard" class="field">
<text class="field-label">剩余次数</text>
<input class="field-input" type="number" v-model="editForm.remainingTimes" placeholder="请输入剩余次数" />
</view>
<view class="field">
<text class="field-label">开始日期</text>
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
<view class="picker-inner">
<text class="picker-text">{{ editForm.startDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="field">
<text class="field-label">到期日期</text>
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
<view class="picker-inner">
<text class="picker-text">{{ editForm.expireDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="editingMembership" class="danger-link" @tap="onClearMembership">
<text class="danger-link-text">解除该会员卡</text>
</view>
</view>
<view
class="save-btn"
:class="{ 'save-btn--disabled': submitting }"
@tap="onSave"
>
<text class="save-btn-text">{{ submitting ? '保存中...' : '保存' }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { AdminMemberDetail, CardType, MembershipWithCardType } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDateLocal } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const pageLoading = ref(true)
const submitting = ref(false)
const detail = ref<AdminMemberDetail | null>(null)
const cardTypes = ref<CardType[]>([])
const membershipIndex = ref(0)
const profileForm = ref({
nickname: '',
phone: '',
})
const editForm = ref({
membershipId: '' as string | '',
cardTypeIndex: 0,
cardTypeId: '',
remainingTimes: null as number | null,
startDate: '',
expireDate: '',
manuallyEditedExpire: false,
})
const existingMemberships = computed(() => detail.value?.memberships ?? [])
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
const membershipPickerLabels = computed(() =>
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
)
const isTimeBasedCard = computed(() => {
const card = cardTypes.value[editForm.value.cardTypeIndex]
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
})
function calculateExpireDate(startDate: string, durationDays: number): string {
const d = new Date(startDate)
d.setDate(d.getDate() + durationDays)
return formatDateLocal(d)
}
function applyMembership(membership: MembershipWithCardType | null) {
const types = cardTypes.value
if (membership) {
const idx = types.findIndex((item) => item.id === membership.cardTypeId)
editForm.value = {
membershipId: membership.id,
cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: membership.cardTypeId,
remainingTimes: membership.remainingTimes,
startDate: membership.startDate.slice(0, 10),
expireDate: membership.expireDate.slice(0, 10),
manuallyEditedExpire: false,
}
return
}
editForm.value = {
membershipId: '',
cardTypeIndex: 0,
cardTypeId: types[0]?.id || '',
remainingTimes: types[0]?.totalTimes ?? null,
startDate: formatDateLocal(new Date()),
expireDate: calculateExpireDate(formatDateLocal(new Date()), types[0]?.durationDays ?? 30),
manuallyEditedExpire: false,
}
}
function onMembershipPick(e: { detail: { value: number } }) {
membershipIndex.value = Number(e.detail.value)
applyMembership(existingMemberships.value[membershipIndex.value] ?? null)
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = Number(e.detail.value)
const cardType = cardTypes.value[idx]
editForm.value.cardTypeIndex = idx
editForm.value.cardTypeId = cardType.id
if (cardType.totalTimes != null) {
editForm.value.remainingTimes = cardType.totalTimes
}
if (!editForm.value.manuallyEditedExpire) {
editForm.value.startDate = formatDateLocal(new Date())
editForm.value.expireDate = calculateExpireDate(formatDateLocal(new Date()), cardType.durationDays)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
editForm.value.startDate = e.detail.value
if (!editForm.value.manuallyEditedExpire) {
const cardType = cardTypes.value[editForm.value.cardTypeIndex]
if (cardType) {
editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays)
}
}
}
function onExpireDateChange(e: { detail: { value: string } }) {
editForm.value.expireDate = e.detail.value
editForm.value.manuallyEditedExpire = true
}
async function loadPage() {
pageLoading.value = true
try {
const [member, types] = await Promise.all([
adminStore.fetchMemberDetail(userId.value),
cardTypes.value.length ? Promise.resolve(cardTypes.value) : adminStore.fetchCardTypes(),
])
detail.value = member
cardTypes.value = [...types]
profileForm.value = {
nickname: member.user.nickname || '',
phone: member.user.phone || '',
}
membershipIndex.value = 0
applyMembership(member.memberships[0] ?? null)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function onSave() {
if (submitting.value || !userId.value) return
if (!editForm.value.cardTypeId) {
uni.showToast({ title: '请选择卡类型', icon: 'none' })
return
}
submitting.value = true
try {
await adminStore.updateMemberProfile(userId.value, {
nickname: profileForm.value.nickname.trim(),
phone: profileForm.value.phone.trim(),
})
await adminStore.updateUserMembership(userId.value, {
...(editForm.value.membershipId ? { membershipId: editForm.value.membershipId } : {}),
cardTypeId: editForm.value.cardTypeId,
remainingTimes: isTimeBasedCard.value ? Number(editForm.value.remainingTimes) || 0 : null,
startDate: editForm.value.startDate,
expireDate: editForm.value.expireDate,
})
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
} finally {
submitting.value = false
}
}
function onClearMembership() {
if (!userId.value) return
uni.showModal({
title: '确认解除',
content: '确定要解除当前这张会员卡吗?其他卡不受影响。',
confirmColor: '#C47A7A',
success: async (res) => {
if (!res.confirm) return
const membershipId = editForm.value.membershipId
if (!membershipId) {
uni.showToast({ title: '没有可解除的会员卡', icon: 'none' })
return
}
try {
await adminStore.deleteUserMembership(userId.value, membershipId)
uni.showToast({ title: '已解除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
}
},
})
}
onLoad((query) => {
userId.value = String(query?.userId || '')
})
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadPage()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 80rpx;
}
.loading-wrap {
padding: 80rpx 0;
text-align: center;
}
.loading-text {
font-size: 26rpx;
color: $text-hint;
}
.form {
padding: 24rpx;
}
.block {
background: $bg-card;
border-radius: 20rpx;
padding: 8rpx 24rpx 16rpx;
margin-bottom: 20rpx;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.block-title {
display: block;
padding: 20rpx 0 8rpx;
font-size: 22rpx;
letter-spacing: 3rpx;
color: $text-hint;
}
.field {
padding: 20rpx 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
&:last-of-type { border-bottom: none; }
}
.field-label {
display: block;
font-size: 24rpx;
color: $text-hint;
margin-bottom: 12rpx;
}
.field-input {
height: 64rpx;
font-size: 30rpx;
color: $text-primary;
}
.field-picker {
display: block;
}
.picker-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 64rpx;
}
.picker-text {
font-size: 30rpx;
color: $text-primary;
}
.picker-arrow {
font-size: 24rpx;
color: $text-hint;
}
.danger-link {
padding: 20rpx 0 8rpx;
}
.danger-link-text {
font-size: 24rpx;
color: $error-color;
}
.save-btn {
margin-top: 12rpx;
height: 88rpx;
border-radius: 18rpx;
background: $brand-color;
display: flex;
align-items: center;
justify-content: center;
}
.save-btn--disabled { opacity: 0.5; }
.save-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff8f0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -163,6 +163,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import type { BookingWithDetails } from '@mp-pilates/shared' import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared' import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
@@ -295,6 +296,8 @@ function formatDateDisplay(dateStr: string): string {
} }
// ─── Actions ────────────────────────────────────────────── // ─── Actions ──────────────────────────────────────────────
const hasLoadedOnce = ref(false)
function selectTab(key: TabKey) { function selectTab(key: TabKey) {
activeTab.value = key activeTab.value = key
} }
@@ -351,7 +354,19 @@ onMounted(() => {
const windowInfo = uni.getWindowInfo() const windowInfo = uni.getWindowInfo()
const statusBarH = windowInfo.statusBarHeight ?? 20 const statusBarH = windowInfo.statusBarHeight ?? 20
navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px` navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px`
bookingStore.fetchMyBookings() bookingStore.fetchMyBookings().then(() => {
hasLoadedOnce.value = true
})
})
// After returning from booking detail (where status may have changed),
// silently re-sync without flipping loading state — keeps the list visible
// and avoids the skeleton flash. Store action `replaceBooking` already
// keeps `myBookings` in sync for the common case; this is the safety net
// for any state we didn't locally patch (e.g. server-side cascading fields).
onShow(() => {
if (!hasLoadedOnce.value) return
bookingStore.fetchMyBookings(undefined, { silent: true })
}) })
</script> </script>

View File

@@ -18,6 +18,12 @@ import type {
UpdateFlashSaleDto, UpdateFlashSaleDto,
CreateStudioUploadCredentialDto, CreateStudioUploadCredentialDto,
StudioUploadCredential, StudioUploadCredential,
AdminMemberSummary,
AdminMemberDetail,
UpdateAdminMemberProfileDto,
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
interface LegacyPaginatedData<T> { interface LegacyPaginatedData<T> {
@@ -53,16 +59,7 @@ export interface AdminStats {
totalBookings: number totalBookings: number
} }
export interface MemberSummary { export type MemberSummary = AdminMemberSummary
userId: string
openid: string
nickname: string
phone: string | null
avatarUrl: string | null
totalBookings: number
completedBookings: number
cancelledBookings: number
}
export interface UserMembership { export interface UserMembership {
userId: string userId: string
@@ -176,20 +173,38 @@ export const useAdminStore = defineStore('admin', () => {
return get<UserMembership>(`/admin/members/${userId}/membership`) return get<UserMembership>(`/admin/members/${userId}/membership`)
} }
async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> {
return get<AdminMemberDetail>(`/admin/members/${userId}`)
}
async function updateMemberProfile(
userId: string,
dto: UpdateAdminMemberProfileDto,
): Promise<AdminMemberDetail> {
return put<AdminMemberDetail>(`/admin/members/${userId}`, dto as unknown as Record<string, unknown>)
}
async function arrangeMemberBooking(dto: AdminArrangeBookingDto): Promise<BookingWithDetails> {
return post<BookingWithDetails>('/admin/bookings', dto as unknown as Record<string, unknown>)
}
async function updateUserMembership( async function updateUserMembership(
userId: string, userId: string,
dto: { dto: {
membershipId?: string
cardTypeId: string cardTypeId: string
remainingTimes?: number | null remainingTimes?: number | null
startDate: string startDate: string
expireDate: string expireDate: string
}, },
): Promise<any> { ): Promise<MembershipWithCardType> {
return put<any>(`/admin/members/${userId}/membership`, dto) return put<MembershipWithCardType>(`/admin/members/${userId}/membership`, dto)
} }
async function deleteUserMembership(userId: string): Promise<void> { async function deleteUserMembership(userId: string, membershipId: string): Promise<void> {
return del<void>(`/admin/members/${userId}/membership`) return del<void>(
`/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`,
)
} }
// ── Time slots ─────────────────────────────────────────────────── // ── Time slots ───────────────────────────────────────────────────
@@ -216,7 +231,7 @@ export const useAdminStore = defineStore('admin', () => {
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> { async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
scheduleLoading.value = true scheduleLoading.value = true
try { try {
const data = await get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date }) const data = await previewScheduleByDate(date)
schedulePreview.value = data schedulePreview.value = data
return data return data
} finally { } finally {
@@ -224,6 +239,10 @@ export const useAdminStore = defineStore('admin', () => {
} }
} }
async function previewScheduleByDate(date: string): Promise<ScheduleSlotPreview[]> {
return get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
}
async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> { async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> {
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>) await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
await fetchSchedulePreview(dto.date) await fetchSchedulePreview(dto.date)
@@ -275,6 +294,9 @@ export const useAdminStore = defineStore('admin', () => {
fetchAdminBookings, fetchAdminBookings,
// Members // Members
fetchMembers, fetchMembers,
fetchMemberDetail,
updateMemberProfile,
arrangeMemberBooking,
getUserMembership, getUserMembership,
updateUserMembership, updateUserMembership,
deleteUserMembership, deleteUserMembership,
@@ -285,6 +307,7 @@ export const useAdminStore = defineStore('admin', () => {
generateSlots, generateSlots,
// Schedule // Schedule
fetchSchedulePreview, fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots, publishDaySlots,
// Stats // Stats
fetchDashboardStats, fetchDashboardStats,

View File

@@ -44,13 +44,27 @@ export const useBookingStore = defineStore('booking', () => {
return result return result
} }
/**
* Replace a booking in `myBookings` by id. Preserves immutability: always
* returns a new array reference so Vue's computed/watchers pick up the change.
* If the booking isn't in the list (e.g. paginated out), leaves state untouched.
*/
function replaceBooking(updated: BookingWithDetails) {
const idx = myBookings.value.findIndex((b) => b.id === updated.id)
if (idx === -1) return
const next = myBookings.value.slice()
next[idx] = updated
myBookings.value = next
}
async function cancelBooking(bookingId: string) { async function cancelBooking(bookingId: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`) const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
replaceBooking(result)
return result return result
} }
async function fetchMyBookings(status?: string) { async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
loadingBookings.value = true if (!opts.silent) loadingBookings.value = true
try { try {
const params: Record<string, unknown> = status ? { status } : {} const params: Record<string, unknown> = status ? { status } : {}
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params) const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
@@ -59,7 +73,7 @@ export const useBookingStore = defineStore('booking', () => {
console.error('Fetch bookings failed:', err) console.error('Fetch bookings failed:', err)
myBookings.value = [] myBookings.value = []
} finally { } finally {
loadingBookings.value = false if (!opts.silent) loadingBookings.value = false
} }
} }
@@ -106,6 +120,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -113,6 +128,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -120,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -159,5 +176,6 @@ export const useBookingStore = defineStore('booking', () => {
fetchBookingHistory, fetchBookingHistory,
fetchSlotById, fetchSlotById,
fetchBookingById, fetchBookingById,
replaceBooking,
} }
}) })

View File

@@ -143,6 +143,18 @@ export function getStockPercent(soldCount: number, totalStock: number): string {
return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%` return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%`
} }
/** 格式化日期时间为 YYYY-MM-DD HH:mm */
export function formatDateTimeFull(dateStr: string): string {
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return '-'
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${min}`
}
/** 格式化日期时间为 MM-DD HH:mm:ss */ /** 格式化日期时间为 MM-DD HH:mm:ss */
export function formatDateTime(dateStr: string): string { export function formatDateTime(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME(3) NULL;

View File

@@ -80,6 +80,7 @@ model User {
avatarUrl String? @map("avatar_url") avatarUrl String? @map("avatar_url")
role UserRole @default(MEMBER) role UserRole @default(MEMBER)
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count") adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -25,6 +25,7 @@ const mockUser = {
avatarUrl: null, avatarUrl: null,
role: UserRole.MEMBER, role: UserRole.MEMBER,
adminBookingSubscriptionCount: 0, adminBookingSubscriptionCount: 0,
lastLoginAt: new Date('2024-01-01T00:00:00Z'),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
} }
@@ -109,7 +110,12 @@ describe('AuthService', () => {
where: { openid: OPENID }, where: { openid: OPENID },
}) })
expect(mockPrismaService.user.create).toHaveBeenCalledWith({ expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 }, data: {
openid: OPENID,
nickname: TEST_NICKNAME,
adminBookingSubscriptionCount: 0,
lastLoginAt: expect.any(Date),
},
}) })
expect(result.user).toEqual(expect.objectContaining({ expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id, id: mockUser.id,
@@ -148,12 +154,19 @@ describe('AuthService', () => {
await authService.login(loginCode) await authService.login(loginCode)
expect(mockPrismaService.user.create).toHaveBeenCalledWith({ expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 }, data: {
openid: OPENID,
unionid,
nickname: TEST_NICKNAME,
adminBookingSubscriptionCount: 0,
lastLoginAt: expect.any(Date),
},
}) })
}) })
it('returns existing user when openid already exists', async () => { it('returns existing user when openid already exists', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.user.update.mockResolvedValue(mockUser)
const result = await authService.login(loginCode) const result = await authService.login(loginCode)
@@ -161,6 +174,10 @@ describe('AuthService', () => {
where: { openid: OPENID }, where: { openid: OPENID },
}) })
expect(mockPrismaService.user.create).not.toHaveBeenCalled() expect(mockPrismaService.user.create).not.toHaveBeenCalled()
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { id: USER_ID },
data: { lastLoginAt: expect.any(Date) },
})
expect(result.user).toEqual(expect.objectContaining({ expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id, id: mockUser.id,
nickname: mockUser.nickname, nickname: mockUser.nickname,
@@ -171,6 +188,7 @@ describe('AuthService', () => {
it('returns a valid JWT token', async () => { it('returns a valid JWT token', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.user.update.mockResolvedValue(mockUser)
const result = await authService.login(loginCode) const result = await authService.login(loginCode)
@@ -183,6 +201,7 @@ describe('AuthService', () => {
it('returns both token and user in result', async () => { it('returns both token and user in result', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.user.update.mockResolvedValue(mockUser)
const result = await authService.login(loginCode) const result = await authService.login(loginCode)
@@ -203,6 +222,7 @@ describe('AuthService', () => {
it('includes active membership count and invite eligibility in login response', async () => { it('includes active membership count and invite eligibility in login response', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.user.update.mockResolvedValue(mockUser)
mockPrismaService.membership.count.mockResolvedValue(2) mockPrismaService.membership.count.mockResolvedValue(2)
const result = await authService.login(loginCode) const result = await authService.login(loginCode)
@@ -232,6 +252,7 @@ describe('AuthService', () => {
sessionKey: SESSION_KEY, sessionKey: SESSION_KEY,
}) })
mockPrismaService.user.findUnique.mockResolvedValue(mockUser) mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.user.update.mockResolvedValue(mockUser)
mockJwtService.sign.mockReturnValue(JWT_TOKEN) mockJwtService.sign.mockReturnValue(JWT_TOKEN)
await authService.login('login_code') await authService.login('login_code')
}) })

View File

@@ -126,30 +126,26 @@ export class AuthService {
}) })
const isNewUser = existingUser === null const isNewUser = existingUser === null
const now = new Date()
const user = const user = isNewUser
existingUser ?? ? await this.prisma.user.create({
(await this.prisma.user.create({
data: { data: {
openid, openid,
...(unionid !== undefined && { unionid }), ...(unionid !== undefined && { unionid }),
nickname: nickname || generateDefaultNickname(this.randomFn), nickname: nickname || generateDefaultNickname(this.randomFn),
...(avatarUrl && { avatarUrl }), ...(avatarUrl && { avatarUrl }),
adminBookingSubscriptionCount: 0, adminBookingSubscriptionCount: 0,
lastLoginAt: now,
},
})
: await this.prisma.user.update({
where: { id: existingUser.id },
data: {
lastLoginAt: now,
...(avatarUrl && { avatarUrl, ...(nickname && { nickname }) }),
}, },
}))
// Update avatar for existing users if new avatar is provided
if (existingUser && avatarUrl) {
const updated = await this.prisma.user.update({
where: { id: existingUser.id },
data: { avatarUrl, ...(nickname && { nickname }) },
}) })
sessionKeyStore.set(updated.id, sessionKey)
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
}
sessionKeyStore.set(user.id, sessionKey) sessionKeyStore.set(user.id, sessionKey)

View File

@@ -129,6 +129,8 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
timeSlot: { timeSlot: {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
create: jest.fn(),
updateMany: jest.fn(),
}, },
booking: { booking: {
findUnique: jest.fn(), findUnique: jest.fn(),
@@ -140,6 +142,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
user: {
findUnique: jest.fn(),
},
bookingStatusHistory: { bookingStatusHistory: {
create: jest.fn(), create: jest.fn(),
}, },
@@ -1001,4 +1006,412 @@ describe('BookingService', () => {
) )
}) })
}) })
describe('adminArrangeBooking', () => {
const MOCK_ADMIN_ID = 'admin-001'
const dto = {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
}
const mockTrialCardType = {
...mockTimesCardType,
id: 'ct-trial-001',
name: '体验卡',
type: CardTypeCategory.TRIAL,
totalTimes: 1,
}
const mockTrialMembership = {
...mockActiveMembership,
id: 'mem-trial-001',
cardTypeId: mockTrialCardType.id,
remainingTimes: 1,
cardType: mockTrialCardType,
}
function stubArrangeSuccess(
tx: ReturnType<typeof buildTxMock>,
options?: {
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
slot?: typeof mockOpenSlot
existing?: typeof mockConfirmedBooking | null
},
) {
const membership = options?.membership ?? mockActiveMembership
const slot = options?.slot ?? mockOpenSlot
const existing = options?.existing ?? null
const arranged = {
...mockConfirmedBooking,
membershipId: membership.id,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date(),
operatorId: MOCK_ADMIN_ID,
}
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.timeSlot.findUnique
.mockResolvedValueOnce(slot)
.mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.booking.findUnique.mockResolvedValue(existing)
tx.membership.findUnique.mockResolvedValue(membership)
tx.booking.create.mockResolvedValue(arranged)
tx.booking.update.mockResolvedValue(arranged)
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.membership.update.mockResolvedValue({
...membership,
remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...arranged,
timeSlot: slot,
membership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
return arranged
}
it('creates a confirmed times-card booking and deducts one session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx)
const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: MOCK_SLOT_ID,
status: TimeSlotStatus.OPEN,
bookedCount: { lt: mockOpenSlot.capacity },
}),
data: { bookedCount: { increment: 1 } },
}),
)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toStatus: BookingStatus.CONFIRMED,
remark: '老师代为安排',
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled()
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled()
expect(result.status).toBe(BookingStatus.CONFIRMED)
})
it('does not deduct remaining times for duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockDurationMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
})
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalled()
})
it('deducts a trial card session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockTrialMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockTrialMembership.id,
})
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
})
it('rejects when the times card has no remaining sessions', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('rejects when a duration card has expired', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockDurationMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
}),
).rejects.toThrow(BadRequestException)
})
it('rejects when the time slot is full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.membership.findUnique).not.toHaveBeenCalled()
})
it('rejects duplicate active bookings for the same slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ConflictException,
)
})
it('rejects arranging a past time slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue({
...mockOpenSlot,
date: new Date('2020-01-01T00:00:00Z'),
startTime: '09:00',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
})
it('revives a cancelled booking instead of creating a new row', async () => {
const tx = buildTxMock()
const cancelled = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
}
stubArrangeSuccess(tx, { existing: cancelled })
await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: cancelled.id },
data: expect.objectContaining({
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalled()
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4 }),
}),
)
})
it('rejects when the member does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
NotFoundException,
)
})
it('rejects when the membership belongs to another member', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
userId: 'other-user',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ForbiddenException,
)
})
it('rejects an expired times card even if remaining sessions exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
})
it('rejects when occupancy update races and the slot is already full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.timeSlot.updateMany.mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('reuses an existing slot when arranging by date and time', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx)
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
})
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: {
date_startTime_endTime: {
date: new Date('2099-12-31T00:00:00.000Z'),
startTime: '09:00',
endTime: '10:00',
},
},
}),
)
expect(tx.timeSlot.create).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalled()
})
it('creates a manual slot when arranging a missing date and time', async () => {
const tx = buildTxMock()
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
tx.timeSlot.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
tx.timeSlot.create.mockResolvedValue(createdSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
timeSlotId: createdSlot.id,
status: BookingStatus.CONFIRMED,
})
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
timeSlotId: createdSlot.id,
status: BookingStatus.CONFIRMED,
timeSlot: createdSlot,
membership: mockActiveMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
})
expect(tx.timeSlot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
startTime: '09:00',
endTime: '10:00',
source: 'MANUAL',
}),
}),
)
})
it('rejects custom slots whose end time is not after start time', async () => {
const tx = buildTxMock()
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '23:00',
endTime: '00:00',
}),
).rejects.toThrow(BadRequestException)
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
expect(tx.timeSlot.create).not.toHaveBeenCalled()
})
})
}) })

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator'
import { BookingService } from './booking.service' import { BookingService } from './booking.service'
import { CreateBookingDto } from './dto/create-booking.dto' import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
@Controller() @Controller()
export class BookingController { export class BookingController {
@@ -92,6 +93,16 @@ export class BookingController {
) )
} }
@Post('admin/bookings')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async arrangeBooking(
@CurrentUser('sub') operatorId: string,
@Body() dto: AdminArrangeBookingDto,
) {
return this.bookingService.adminArrangeBooking(operatorId, dto)
}
@Get('admin/teaching-schedule') @Get('admin/teaching-schedule')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)

View File

@@ -5,11 +5,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common' } from '@nestjs/common'
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client' import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client'
import { import {
BookingStatus, BookingStatus,
CardTypeCategory, CardTypeCategory,
DEFAULT_SLOT_CAPACITY,
MembershipStatus, MembershipStatus,
TimeSlotSource,
TimeSlotStatus, TimeSlotStatus,
type TeachingScheduleSlot, type TeachingScheduleSlot,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
@@ -18,6 +20,7 @@ import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service' import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service' import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto' import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
import { InviteService } from '../invite/invite.service' import { InviteService } from '../invite/invite.service'
// ─── Types ───────────────────────────────────────────────────────────────── // ─── Types ─────────────────────────────────────────────────────────────────
@@ -48,6 +51,23 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number {
return d.getTime() return d.getTime()
} }
function normalizeClock(time: string): string {
return time.slice(0, 5)
}
function clockToMinutes(time: string): number {
const [hours, minutes] = normalizeClock(time).split(':').map(Number)
return hours * 60 + minutes
}
function parseSlotDate(date: string): Date {
const parsed = new Date(`${date}T00:00:00.000Z`)
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Invalid date')
}
return parsed
}
// ─── Service ─────────────────────────────────────────────────────────────── // ─── Service ───────────────────────────────────────────────────────────────
@Injectable() @Injectable()
@@ -273,6 +293,195 @@ export class BookingService {
return confirmedBooking return confirmedBooking
} }
async adminArrangeBooking(
operatorId: string,
dto: AdminArrangeBookingDto,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
const timeSlot = await this.resolveArrangeSlot(tx, dto)
if (timeSlot.status !== TimeSlotStatus.OPEN) {
throw new BadRequestException(
`TimeSlot is not available (status: ${timeSlot.status})`,
)
}
if (Date.now() >= buildSlotStartMs(timeSlot.date, timeSlot.startTime)) {
throw new BadRequestException('Cannot arrange a past time slot')
}
const user = await tx.user.findUnique({
where: { id: dto.userId },
select: { id: true },
})
if (!user) {
throw new NotFoundException(`User ${dto.userId} not found`)
}
const existing = await tx.booking.findUnique({
where: {
userId_timeSlotId: {
userId: dto.userId,
timeSlotId: timeSlot.id,
},
},
})
if (existing && existing.status !== BookingStatus.CANCELLED) {
throw new ConflictException('Member already has a booking for this time slot')
}
const membership = await tx.membership.findUnique({
where: { id: dto.membershipId },
include: { cardType: true },
})
if (!membership) {
throw new NotFoundException(`Membership ${dto.membershipId} not found`)
}
if (membership.userId !== dto.userId) {
throw new ForbiddenException('This membership does not belong to the member')
}
if (membership.status !== MembershipStatus.ACTIVE) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
}
if (membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
const cardType = membership.cardType
const isTimeBased =
cardType.type === CardTypeCategory.TIMES ||
cardType.type === CardTypeCategory.TRIAL
if (isTimeBased && (membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
const occupied = await tx.timeSlot.updateMany({
where: {
id: timeSlot.id,
status: TimeSlotStatus.OPEN,
bookedCount: { lt: timeSlot.capacity },
},
data: {
bookedCount: { increment: 1 },
},
})
if (occupied.count !== 1) {
throw new BadRequestException('Time slot is full')
}
const occupiedSlot = await tx.timeSlot.findUnique({ where: { id: timeSlot.id } })
if (occupiedSlot && occupiedSlot.bookedCount >= occupiedSlot.capacity) {
await tx.timeSlot.update({
where: { id: timeSlot.id },
data: { status: TimeSlotStatus.FULL },
})
}
const now = new Date()
const arranged = existing
? await tx.booking.update({
where: { id: existing.id },
data: {
membershipId: dto.membershipId,
status: BookingStatus.CONFIRMED,
cancelledAt: null,
confirmedAt: now,
completedAt: null,
operatorId,
},
})
: await tx.booking.create({
data: {
userId: dto.userId,
timeSlotId: timeSlot.id,
membershipId: dto.membershipId,
status: BookingStatus.CONFIRMED,
confirmedAt: now,
operatorId,
},
})
if (isTimeBased) {
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
await tx.membership.update({
where: { id: membership.id },
data: {
remainingTimes: newRemainingTimes,
status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
}
await tx.bookingStatusHistory.create({
data: {
bookingId: arranged.id,
fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.CONFIRMED,
operatorId,
remark: '老师代为安排',
},
})
return arranged
})
const arrangedBooking = await this.fetchBookingWithRelations(booking.id)
await this.trySendBookingConfirmedSubscriptionMessage(arrangedBooking)
return arrangedBooking
}
private async resolveArrangeSlot(
tx: Prisma.TransactionClient,
dto: AdminArrangeBookingDto,
): Promise<TimeSlot> {
if (dto.timeSlotId) {
const slot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
})
if (!slot) {
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
}
return slot
}
if (!dto.date || !dto.startTime || !dto.endTime) {
throw new BadRequestException('timeSlotId or date+startTime+endTime is required')
}
const startTime = normalizeClock(dto.startTime)
const endTime = normalizeClock(dto.endTime)
if (clockToMinutes(endTime) <= clockToMinutes(startTime)) {
throw new BadRequestException('End time must be after start time')
}
const date = parseSlotDate(dto.date)
const existing = await tx.timeSlot.findUnique({
where: {
date_startTime_endTime: {
date,
startTime,
endTime,
},
},
})
if (existing) {
return existing
}
return tx.timeSlot.create({
data: {
date,
startTime,
endTime,
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.MANUAL,
},
})
}
// ─── Complete / NoShow Booking (Admin) ────────────────────────────────── // ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
async completeBooking( async completeBooking(

View File

@@ -0,0 +1,40 @@
import { Type } from 'class-transformer'
import {
IsDateString,
IsInt,
IsOptional,
IsUUID,
Matches,
Min,
ValidateIf,
} from 'class-validator'
export class AdminArrangeBookingDto {
@IsUUID()
userId!: string
@IsUUID()
membershipId!: string
@IsOptional()
@IsUUID()
timeSlotId?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@IsDateString()
date?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
startTime?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
endTime?: string
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
capacity?: number
}

View File

@@ -1,11 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing' import { Test, TestingModule } from '@nestjs/testing'
import { NotFoundException } from '@nestjs/common' import { BadRequestException, NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service' import { UserService } from '../user.service'
import { PrismaService } from '../../prisma/prisma.service' import { PrismaService } from '../../prisma/prisma.service'
import { import {
MembershipStatus, MembershipStatus,
BookingStatus, BookingStatus,
UserRole, UserRole,
CardTypeCategory,
SubscriptionMessageScene, SubscriptionMessageScene,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
@@ -23,6 +24,7 @@ const makeUser = (overrides: Record<string, unknown> = {}) => ({
avatarUrl: 'https://example.com/avatar.png', avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER, role: UserRole.MEMBER,
adminBookingSubscriptionCount: 0, adminBookingSubscriptionCount: 0,
lastLoginAt: new Date('2024-06-01T08:00:00Z'),
createdAt: new Date('2024-01-01T00:00:00Z'), createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'),
_count: { memberships: 2 }, _count: { memberships: 2 },
@@ -53,6 +55,8 @@ const makeBooking = (
const mockPrisma = { const mockPrisma = {
user: { user: {
findUnique: jest.fn(), findUnique: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
subscriptionMessageConsent: { subscriptionMessageConsent: {
@@ -61,6 +65,13 @@ const mockPrisma = {
}, },
booking: { booking: {
findMany: jest.fn(), findMany: jest.fn(),
groupBy: jest.fn(),
},
membership: {
findFirst: jest.fn(),
update: jest.fn(),
create: jest.fn(),
updateMany: jest.fn(),
}, },
} }
@@ -120,6 +131,7 @@ describe('UserService', () => {
avatarUrl: 'https://example.com/avatar.png', avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER, role: UserRole.MEMBER,
activeMembershipCount: 3, activeMembershipCount: 3,
inviteShareEligible: true,
adminBookingSubscriptionCount: 0, adminBookingSubscriptionCount: 0,
subscriptionMessageTemplates: { subscriptionMessageTemplates: {
templates: [ templates: [
@@ -423,4 +435,153 @@ describe('UserService', () => {
) )
}) })
}) })
describe('getMemberDetail', () => {
const cardType = {
id: 'ct-1',
name: '10次卡',
type: CardTypeCategory.TIMES,
totalTimes: 10,
durationDays: 180,
price: 150000,
originalPrice: null,
description: null,
coverUrl: null,
isActive: true,
sortOrder: 0,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const membership = {
id: 'mem-1',
userId: 'user-1',
cardTypeId: 'ct-1',
remainingTimes: 6,
totalTimes: 10,
startDate: new Date('2024-01-01T00:00:00Z'),
expireDate: new Date('2099-01-01T00:00:00Z'),
status: MembershipStatus.ACTIVE,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
cardType,
}
it('returns profile, memberships, stats and upcoming bookings', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
...makeUser(),
memberships: [membership],
})
mockPrisma.booking.groupBy.mockResolvedValue([
{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } },
{ userId: 'user-1', status: BookingStatus.CANCELLED, _count: { id: 1 } },
{ userId: 'user-1', status: BookingStatus.NO_SHOW, _count: { id: 1 } },
])
mockPrisma.booking.findMany.mockResolvedValue([
{
id: 'booking-up-1',
status: BookingStatus.CONFIRMED,
timeSlot: {
date: new Date('2099-12-31T00:00:00Z'),
startTime: '09:00',
endTime: '10:00',
},
membership: { cardType: { name: '10次卡' } },
},
])
const result = await service.getMemberDetail('user-1')
expect(result.user.userId).toBe('user-1')
expect(result.user.lastLoginAt).toBe('2024-06-01T08:00:00.000Z')
expect(result.memberships).toHaveLength(1)
expect(result.memberships[0].cardType.name).toBe('10次卡')
expect(result.stats).toEqual({
totalBookings: 5,
completedBookings: 3,
cancelledBookings: 1,
noShowBookings: 1,
})
expect(result.upcomingBookings).toEqual([
{
id: 'booking-up-1',
status: BookingStatus.CONFIRMED,
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
cardName: '10次卡',
},
])
})
it('throws NotFoundException when member does not exist', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null)
await expect(service.getMemberDetail('missing')).rejects.toThrow(NotFoundException)
})
})
describe('updateMemberProfile', () => {
it('updates nickname and phone then returns the dossier', async () => {
mockPrisma.user.findUnique
.mockResolvedValueOnce(makeUser())
.mockResolvedValueOnce({ ...makeUser({ nickname: 'Bob', phone: '13900000000' }), memberships: [] })
mockPrisma.user.update.mockResolvedValue(makeUser({ nickname: 'Bob', phone: '13900000000' }))
mockPrisma.booking.groupBy.mockResolvedValue([])
mockPrisma.booking.findMany.mockResolvedValue([])
const result = await service.updateMemberProfile('user-1', {
nickname: 'Bob',
phone: '13900000000',
})
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { nickname: 'Bob', phone: '13900000000' },
})
expect(result.user.nickname).toBe('Bob')
expect(result.user.phone).toBe('13900000000')
})
})
describe('deleteUserMembership', () => {
it('expires only the selected membership', async () => {
mockPrisma.membership.findFirst.mockResolvedValue({
id: 'mem-1',
userId: 'user-1',
status: MembershipStatus.ACTIVE,
})
mockPrisma.membership.update.mockResolvedValue({
id: 'mem-1',
status: MembershipStatus.EXPIRED,
})
await service.deleteUserMembership('user-1', 'mem-1')
expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({
where: { id: 'mem-1', userId: 'user-1' },
})
expect(mockPrisma.membership.update).toHaveBeenCalledWith({
where: { id: 'mem-1' },
data: { status: MembershipStatus.EXPIRED },
})
expect(mockPrisma.membership.updateMany).not.toHaveBeenCalled()
})
it('rejects when the membership is missing or belongs to another user', async () => {
mockPrisma.membership.findFirst.mockResolvedValue(null)
await expect(service.deleteUserMembership('user-1', 'mem-other')).rejects.toThrow(
NotFoundException,
)
expect(mockPrisma.membership.update).not.toHaveBeenCalled()
})
it('rejects a missing membershipId', async () => {
await expect(service.deleteUserMembership('user-1', '')).rejects.toThrow(
BadRequestException,
)
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
})
})
}) })

View File

@@ -0,0 +1,14 @@
import { IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator'
export class UpdateAdminMemberProfileDto {
@IsOptional()
@IsString()
@MaxLength(32)
readonly nickname?: string
@IsOptional()
@ValidateIf((_, value: unknown) => value !== null)
@IsString()
@MaxLength(20)
readonly phone?: string | null
}

View File

@@ -2,6 +2,10 @@ import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
import { Type } from 'class-transformer' import { Type } from 'class-transformer'
export class UpdateUserMembershipDto { export class UpdateUserMembershipDto {
@IsOptional()
@IsUUID()
membershipId?: string
@IsUUID() @IsUUID()
cardTypeId!: string cardTypeId!: string

View File

@@ -8,6 +8,7 @@ import {
Query, Query,
Post, Post,
UseGuards, UseGuards,
BadRequestException,
} from '@nestjs/common' } from '@nestjs/common'
import { UserRole, CardTypeCategory } from '@mp-pilates/shared' import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { JwtAuthGuard } from '../auth/jwt-auth.guard'
@@ -17,6 +18,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
import { UserService } from './user.service' import { UserService } from './user.service'
import { UpdateProfileDto } from './dto/update-profile.dto' import { UpdateProfileDto } from './dto/update-profile.dto'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto' import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
import { UpdateAdminMemberProfileDto } from './dto/update-admin-member-profile.dto'
import { ReportSubscriptionMessageRequestDto } from './dto/report-subscription-message.dto' import { ReportSubscriptionMessageRequestDto } from './dto/report-subscription-message.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory)) const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@@ -105,7 +107,30 @@ export class UserController {
@Delete('admin/members/:userId/membership') @Delete('admin/members/:userId/membership')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
deleteUserMembership(@Param('userId') userId: string) { deleteUserMembership(
return this.userService.deleteUserMembership(userId) @Param('userId') userId: string,
@Query('membershipId') membershipId?: string,
) {
if (!membershipId) {
throw new BadRequestException('membershipId is required')
}
return this.userService.deleteUserMembership(userId, membershipId)
}
@Get('admin/members/:userId')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
getMemberDetail(@Param('userId') userId: string) {
return this.userService.getMemberDetail(userId)
}
@Put('admin/members/:userId')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
updateMemberProfile(
@Param('userId') userId: string,
@Body() dto: UpdateAdminMemberProfileDto,
) {
return this.userService.updateMemberProfile(userId, dto)
} }
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common' import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { import {
MembershipStatus, MembershipStatus,
BookingStatus, BookingStatus,
@@ -15,6 +15,10 @@ import type {
SubscriptionMessageRequestResult, SubscriptionMessageRequestResult,
SubscriptionMessageTemplate, SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig, SubscriptionMessageTemplateConfig,
AdminMemberSummary,
AdminMemberDetail,
MembershipWithCardType,
UpdateAdminMemberProfileDto,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
@@ -23,6 +27,63 @@ import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory)) const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1 const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
function serializeMembership(membership: {
id: string
userId: string
cardTypeId: string
remainingTimes: number | null
totalTimes: number | null
startDate: Date
expireDate: Date
status: string
createdAt: Date
updatedAt: Date
cardType: {
id: string
name: string
type: string
totalTimes: number | null
durationDays: number
price: number | { toString(): string }
originalPrice: number | { toString(): string } | null
description: string | null
coverUrl: string | null
isActive: boolean
sortOrder: number
createdAt: Date
updatedAt: Date
}
}): MembershipWithCardType {
const { cardType, ...rest } = membership
return {
id: rest.id,
userId: rest.userId,
cardTypeId: rest.cardTypeId,
remainingTimes: rest.remainingTimes,
totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(),
expireDate: rest.expireDate.toISOString(),
status: rest.status as MembershipStatus,
createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(),
cardType: {
id: cardType.id,
name: cardType.name,
type: cardType.type as CardTypeCategory,
totalTimes: cardType.totalTimes,
durationDays: cardType.durationDays,
price: Number(cardType.price),
originalPrice: cardType.originalPrice == null ? null : Number(cardType.originalPrice),
description: cardType.description,
coverUrl: cardType.coverUrl,
isActive: cardType.isActive,
sortOrder: cardType.sortOrder,
createdAt: cardType.createdAt.toISOString(),
updatedAt: cardType.updatedAt.toISOString(),
},
}
}
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent'] type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number] type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
@@ -298,16 +359,7 @@ export class UserService {
limit: number, limit: number,
search?: string, search?: string,
cardType?: string, cardType?: string,
): Promise<PaginatedData<{ ): Promise<PaginatedData<AdminMemberSummary>> {
userId: string
openid: string
nickname: string
phone: string | null
avatarUrl: string | null
totalBookings: number
completedBookings: number
cancelledBookings: number
}>> {
const where: { const where: {
OR?: Array<{ [key: string]: unknown }> OR?: Array<{ [key: string]: unknown }>
memberships?: { memberships?: {
@@ -348,6 +400,14 @@ export class UserService {
nickname: true, nickname: true,
phone: true, phone: true,
avatarUrl: true, avatarUrl: true,
createdAt: true,
lastLoginAt: true,
memberships: {
where: { status: MembershipStatus.ACTIVE },
include: { cardType: { select: { name: true, type: true } } },
orderBy: { expireDate: 'desc' },
take: 1,
},
_count: { _count: {
select: { select: {
bookings: true, bookings: true,
@@ -383,12 +443,18 @@ export class UserService {
const items = users.map((u) => { const items = users.map((u) => {
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 } const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
const active = u.memberships[0]
return { return {
userId: u.id, userId: u.id,
openid: u.openid, openid: u.openid,
nickname: u.nickname, nickname: u.nickname,
phone: u.phone, phone: u.phone,
avatarUrl: u.avatarUrl, avatarUrl: u.avatarUrl,
createdAt: u.createdAt.toISOString(),
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
activeCard: active
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
: null,
totalBookings: s.total, totalBookings: s.total,
completedBookings: s.completed, completedBookings: s.completed,
cancelledBookings: s.cancelled, cancelledBookings: s.cancelled,
@@ -398,6 +464,109 @@ export class UserService {
return { items, total, page, limit } return { items, total, page, limit }
} }
async getMemberDetail(userId: string): Promise<AdminMemberDetail> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
memberships: {
include: { cardType: true },
orderBy: [{ createdAt: 'desc' }],
},
},
})
if (!user) {
throw new NotFoundException('User not found')
}
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const [bookingStats, upcoming] = await Promise.all([
this.prisma.booking.groupBy({
by: ['status'],
where: { userId },
_count: { id: true },
}),
this.prisma.booking.findMany({
where: {
userId,
status: {
in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED],
},
timeSlot: { date: { gte: today } },
},
include: {
timeSlot: true,
membership: { include: { cardType: { select: { name: true } } } },
},
orderBy: [
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
],
}),
])
const stats = { total: 0, completed: 0, cancelled: 0, noShow: 0 }
for (const row of bookingStats) {
stats.total += row._count.id
if (row.status === BookingStatus.COMPLETED) stats.completed += row._count.id
if (row.status === BookingStatus.CANCELLED) stats.cancelled += row._count.id
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
}
return {
user: {
userId: user.id,
openid: user.openid,
nickname: user.nickname,
phone: user.phone,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
},
memberships: user.memberships.map((membership) => serializeMembership(membership)),
stats: {
totalBookings: stats.total,
completedBookings: stats.completed,
cancelledBookings: stats.cancelled,
noShowBookings: stats.noShow,
},
upcomingBookings: upcoming.map((booking) => ({
id: booking.id,
status: booking.status as BookingStatus,
date: booking.timeSlot.date.toISOString().slice(0, 10),
startTime: booking.timeSlot.startTime,
endTime: booking.timeSlot.endTime,
cardName: booking.membership.cardType.name,
})),
}
}
async updateMemberProfile(
userId: string,
dto: UpdateAdminMemberProfileDto,
): Promise<AdminMemberDetail> {
const existing = await this.prisma.user.findUnique({ where: { id: userId } })
if (!existing) {
throw new NotFoundException('User not found')
}
const phone = dto.phone === undefined
? undefined
: (dto.phone?.trim() ? dto.phone.trim() : null)
await this.prisma.user.update({
where: { id: userId },
data: {
...(dto.nickname !== undefined && { nickname: dto.nickname.trim() }),
...(phone !== undefined && { phone }),
},
})
return this.getMemberDetail(userId)
}
// ─── Membership management ──────────────────────────────────────────────── // ─── Membership management ────────────────────────────────────────────────
async getUserMembership(userId: string) { async getUserMembership(userId: string) {
@@ -429,7 +598,15 @@ export class UserService {
status, status,
} }
const existing = await this.prisma.membership.findFirst({ where: { userId } }) const existing = dto.membershipId
? await this.prisma.membership.findFirst({
where: { id: dto.membershipId, userId },
})
: await this.prisma.membership.findFirst({ where: { userId } })
if (dto.membershipId && !existing) {
throw new NotFoundException('Membership not found')
}
if (existing) { if (existing) {
return this.prisma.membership.update({ return this.prisma.membership.update({
@@ -445,9 +622,20 @@ export class UserService {
}) })
} }
async deleteUserMembership(userId: string): Promise<void> { async deleteUserMembership(userId: string, membershipId: string): Promise<void> {
await this.prisma.membership.updateMany({ if (!membershipId) {
where: { userId }, throw new BadRequestException('membershipId is required')
}
const existing = await this.prisma.membership.findFirst({
where: { id: membershipId, userId },
})
if (!existing) {
throw new NotFoundException('Membership not found')
}
await this.prisma.membership.update({
where: { id: existing.id },
data: { status: MembershipStatus.EXPIRED }, data: { status: MembershipStatus.EXPIRED },
}) })
} }

View File

@@ -57,6 +57,12 @@ export type {
UserProfileResponse, UserProfileResponse,
UpdateProfileDto, UpdateProfileDto,
UserStatsResponse, UserStatsResponse,
AdminMemberActiveCardSummary,
AdminMemberSummary,
AdminMemberUpcomingBooking,
AdminMemberBookingStats,
AdminMemberDetail,
UpdateAdminMemberProfileDto,
CardType, CardType,
CreateCardTypeDto, CreateCardTypeDto,
UpdateCardTypeDto, UpdateCardTypeDto,
@@ -77,6 +83,7 @@ export type {
TeachingScheduleSlot, TeachingScheduleSlot,
BookingStatusHistory, BookingStatusHistory,
CreateBookingDto, CreateBookingDto,
AdminArrangeBookingDto,
Order, Order,
OrderWithDetails, OrderWithDetails,
CreateOrderDto, CreateOrderDto,

View File

@@ -69,3 +69,13 @@ export interface CreateBookingDto {
readonly timeSlotId: string readonly timeSlotId: string
readonly membershipId: string readonly membershipId: string
} }
export interface AdminArrangeBookingDto {
readonly userId: string
readonly membershipId: string
readonly timeSlotId?: string
readonly date?: string
readonly startTime?: string
readonly endTime?: string
readonly capacity?: number
}

View File

@@ -1,4 +1,15 @@
export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } from './user' export type {
User,
UserProfileResponse,
UpdateProfileDto,
UserStatsResponse,
AdminMemberActiveCardSummary,
AdminMemberSummary,
AdminMemberUpcomingBooking,
AdminMemberBookingStats,
AdminMemberDetail,
UpdateAdminMemberProfileDto,
} from './user'
export type { export type {
SubscriptionMessageRequestResult, SubscriptionMessageRequestResult,
SubscriptionMessageRequestItem, SubscriptionMessageRequestItem,
@@ -19,6 +30,7 @@ export type {
TeachingScheduleSlot, TeachingScheduleSlot,
BookingStatusHistory, BookingStatusHistory,
CreateBookingDto, CreateBookingDto,
AdminArrangeBookingDto,
} from './booking' } from './booking'
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order' export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { export type {

View File

@@ -1,4 +1,5 @@
import { UserRole } from '../enums' import { BookingStatus, CardTypeCategory, UserRole } from '../enums'
import type { MembershipWithCardType } from './membership'
import type { SubscriptionMessageTemplateConfig } from './subscription' import type { SubscriptionMessageTemplateConfig } from './subscription'
export interface User { export interface User {
@@ -9,6 +10,7 @@ export interface User {
readonly nickname: string readonly nickname: string
readonly avatarUrl: string | null readonly avatarUrl: string | null
readonly role: UserRole readonly role: UserRole
readonly lastLoginAt: string | null
readonly createdAt: string readonly createdAt: string
readonly updatedAt: string readonly updatedAt: string
} }
@@ -38,3 +40,58 @@ export interface UserStatsResponse {
readonly monthDays: number readonly monthDays: number
readonly monthHours: number readonly monthHours: number
} }
export interface AdminMemberActiveCardSummary {
readonly name: string
readonly type: CardTypeCategory
}
export interface AdminMemberSummary {
readonly userId: string
readonly openid: string
readonly nickname: string
readonly phone: string | null
readonly avatarUrl: string | null
readonly createdAt: string
readonly lastLoginAt: string | null
readonly activeCard: AdminMemberActiveCardSummary | null
readonly totalBookings: number
readonly completedBookings: number
readonly cancelledBookings: number
}
export interface AdminMemberUpcomingBooking {
readonly id: string
readonly status: BookingStatus
readonly date: string
readonly startTime: string
readonly endTime: string
readonly cardName: string
}
export interface AdminMemberBookingStats {
readonly totalBookings: number
readonly completedBookings: number
readonly cancelledBookings: number
readonly noShowBookings: number
}
export interface AdminMemberDetail {
readonly user: {
readonly userId: string
readonly openid: string
readonly nickname: string
readonly phone: string | null
readonly avatarUrl: string | null
readonly createdAt: string
readonly lastLoginAt: string | null
}
readonly memberships: readonly MembershipWithCardType[]
readonly stats: AdminMemberBookingStats
readonly upcomingBookings: readonly AdminMemberUpcomingBooking[]
}
export interface UpdateAdminMemberProfileDto {
readonly nickname?: string
readonly phone?: string | null
}