## Features ### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`) - Interactive date-based slot editor for managing daily schedules - Real-time slot editing: start/end times, capacity adjustments - Slot deletion with conflict warnings when bookings exist - Add new slots with modal dialog - Live booking status display (booked count, people names) - Publish/Save changes with sync feedback - Revert unsaved changes with confirmation - Skeleton loading states and empty state handling - Responsive design with optimized mobile UX ### Backend Enhancements - **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation - Date string validation - Slot array with existing slot IDs for updates - Time and capacity validation per slot - **Schedule Preview API** (`getSchedulePreview`): - Check for existing published slots - Fallback to active WeekTemplates for unpublished dates - Unified response format with isPublished flag - **Publish Slots API** (`publishDaySlots`): - Atomic transaction for consistency - Update existing slots with new times/capacity - Create new slots from template data - Delete unpublished slots or set to CLOSED if bookings exist - Prevent capacity reduction below existing bookings - Returns all published slots for feedback ### State Management - Enhanced admin store with schedule state - Support for pending/unsaved slot changes - Optimistic UI updates with server sync ### Documentation - Comprehensive scheduling system architecture docs - Quick reference for admin workflows - Flow diagrams and state transitions - Implementation guide for future maintenance ## Breaking Changes None ## Testing Recommendations - Create slots for future dates via schedule editor - Verify booking prevention for locked/full slots - Test capacity adjustments with existing bookings - Confirm template-based schedule generation - Verify transaction rollback on publish failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
331 lines
7.8 KiB
Vue
331 lines
7.8 KiB
Vue
<template>
|
||
<view class="user-card">
|
||
<!-- Header: gradient background -->
|
||
<view class="user-card__header">
|
||
<!-- Not logged in state -->
|
||
<view v-if="!loggedIn" class="user-card__guest">
|
||
<view class="user-card__avatar-wrap">
|
||
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
|
||
</view>
|
||
<view class="user-card__guest-info">
|
||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
||
<text class="user-card__guest-sub">登录后查看个人数据</text>
|
||
</view>
|
||
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
|
||
微信登录
|
||
</button>
|
||
</view>
|
||
|
||
<!-- Logged in + profile loaded -->
|
||
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
|
||
<view class="user-card__avatar-wrap">
|
||
<image
|
||
class="user-card__avatar-img"
|
||
:src="avatarSrc"
|
||
mode="aspectFill"
|
||
@error="onAvatarError"
|
||
/>
|
||
<view class="user-card__vip-badge" v-if="vipLevel">
|
||
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="user-card__info">
|
||
<view class="user-card__name-row">
|
||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||
</view>
|
||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Logged in but profile still loading -->
|
||
<view v-else class="user-card__loading">
|
||
<view class="user-card__avatar-wrap">
|
||
<view class="user-card__avatar-skeleton" />
|
||
</view>
|
||
<view class="user-card__info">
|
||
<view class="user-card__name-row">
|
||
<view class="user-card__nickname-skeleton" />
|
||
</view>
|
||
<view class="user-card__phone-skeleton" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Stats row: shown only when profile is loaded -->
|
||
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
||
<view class="user-card__stat-item">
|
||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
||
<text class="user-card__stat-label">总训练(次)</text>
|
||
</view>
|
||
<view class="user-card__stat-divider" />
|
||
<view class="user-card__stat-item">
|
||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
|
||
<text class="user-card__stat-label">本月(次)</text>
|
||
</view>
|
||
<view class="user-card__stat-divider" />
|
||
<view class="user-card__stat-item">
|
||
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
||
<text class="user-card__stat-label">剩余课时(节)</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue'
|
||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||
import { MembershipStatus } from '@mp-pilates/shared'
|
||
|
||
const props = defineProps<{
|
||
loggedIn: boolean
|
||
hasProfile: boolean
|
||
user: UserProfileResponse | null
|
||
stats: UserStatsResponse | null
|
||
memberships?: readonly MembershipWithCardType[]
|
||
loading?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'login'): void
|
||
}>()
|
||
|
||
const avatarFailed = ref(false)
|
||
|
||
// 头像 URL 变化时重置加载错误状态,避免新头像因偶发加载失败而被永久隐藏
|
||
watch(
|
||
() => props.user?.avatarUrl,
|
||
(newUrl, oldUrl) => {
|
||
if (newUrl && newUrl !== oldUrl) {
|
||
avatarFailed.value = false
|
||
}
|
||
},
|
||
)
|
||
|
||
const avatarSrc = computed(() => {
|
||
if (avatarFailed.value || !props.user?.avatarUrl) {
|
||
return '/static/default-avatar.png'
|
||
}
|
||
return props.user.avatarUrl
|
||
})
|
||
|
||
const maskedPhone = computed(() => {
|
||
const phone = props.user?.phone
|
||
if (!phone) return null
|
||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||
})
|
||
|
||
// Derive VIP level from active memberships count
|
||
const activeMemberships = computed(() =>
|
||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||
)
|
||
|
||
const vipLevel = computed(() => {
|
||
const count = activeMemberships.value.length
|
||
if (count >= 3) return 'VIP3'
|
||
if (count >= 2) return 'VIP2'
|
||
if (count >= 1) return 'VIP1'
|
||
return null
|
||
})
|
||
|
||
// Sum remaining sessions from all active time-based memberships
|
||
const remainingSessions = computed(() =>
|
||
activeMemberships.value
|
||
.filter((m) => m.cardType.type === 'TIMES')
|
||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
||
)
|
||
|
||
function onAvatarError() {
|
||
avatarFailed.value = true
|
||
}
|
||
|
||
function handleLogin() {
|
||
emit('login')
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.user-card {
|
||
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
|
||
border-radius: 0 0 40rpx 40rpx;
|
||
overflow: hidden;
|
||
|
||
&__header {
|
||
padding: 60rpx $spacing-lg $spacing-lg;
|
||
}
|
||
|
||
// ── Guest state ──
|
||
&__guest {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $spacing-md;
|
||
}
|
||
|
||
&__guest-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $spacing-xs;
|
||
}
|
||
|
||
&__guest-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
|
||
&__guest-sub {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
}
|
||
|
||
&__login-btn {
|
||
flex-shrink: 0;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: #ffffff;
|
||
font-size: 26rpx;
|
||
font-weight: 500;
|
||
border: none;
|
||
border-radius: $radius-lg;
|
||
padding: 0 $spacing-md;
|
||
height: 64rpx;
|
||
line-height: 64rpx;
|
||
min-width: 160rpx;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
// ── Logged-in user ──
|
||
&__user {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $spacing-md;
|
||
}
|
||
|
||
&__avatar-wrap {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
}
|
||
|
||
&__avatar-img {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 50%;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
&__vip-badge {
|
||
position: absolute;
|
||
bottom: -6rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||
border-radius: 20rpx;
|
||
padding: 2rpx 12rpx;
|
||
border: 2rpx solid #ffffff;
|
||
}
|
||
|
||
&__vip-text {
|
||
font-size: 18rpx;
|
||
font-weight: 700;
|
||
color: #7c2d12;
|
||
line-height: 1;
|
||
}
|
||
|
||
&__info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
&__name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $spacing-sm;
|
||
}
|
||
|
||
&__nickname {
|
||
font-size: 36rpx;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
|
||
&__phone {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
// ── Loading state ──
|
||
&__loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $spacing-md;
|
||
}
|
||
|
||
&__avatar-skeleton {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
&__nickname-skeleton {
|
||
width: 160rpx;
|
||
height: 36rpx;
|
||
border-radius: $radius-sm;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
&__phone-skeleton {
|
||
width: 120rpx;
|
||
height: 26rpx;
|
||
border-radius: $radius-sm;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
// ── Stats row ──
|
||
&__stats {
|
||
display: flex;
|
||
align-items: stretch;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
margin: 0 $spacing-lg $spacing-lg;
|
||
border-radius: $radius-lg;
|
||
padding: $spacing-md 0;
|
||
backdrop-filter: blur(10rpx);
|
||
}
|
||
|
||
&__stat-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: $spacing-xs 0;
|
||
}
|
||
|
||
&__stat-divider {
|
||
width: 1rpx;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
margin: $spacing-xs 0;
|
||
}
|
||
|
||
&__stat-value {
|
||
font-size: 44rpx;
|
||
font-weight: 700;
|
||
color: #ffffff;
|
||
line-height: 1;
|
||
}
|
||
|
||
&__stat-label {
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
margin-top: 6rpx;
|
||
}
|
||
}
|
||
</style>
|