Files
mp-pilates/packages/app/src/components/UserCard.vue
richarjiang b6986ba30c feat(admin): implement full day-by-day schedule editor with live preview
## 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>
2026-04-05 12:18:49 +08:00

331 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="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>