430 lines
9.2 KiB
Vue
430 lines
9.2 KiB
Vue
<template>
|
||
<!-- Overlay mask -->
|
||
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
|
||
<!-- Popup panel – stop propagation so tapping inside doesn't close -->
|
||
<view class="popup-panel" @tap.stop>
|
||
<!-- Header -->
|
||
<view class="popup-header">
|
||
<text class="popup-title">确认预约</text>
|
||
<view class="close-btn" @tap="handleCancel">
|
||
<text class="close-icon">✕</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Course info -->
|
||
<view class="info-section">
|
||
<view class="info-row">
|
||
<text class="info-label">日期</text>
|
||
<text class="info-value">{{ timeSlot?.date }}</text>
|
||
</view>
|
||
<view class="info-row">
|
||
<text class="info-label">时间</text>
|
||
<text class="info-value" v-if="timeSlot">
|
||
{{ timeSlot.startTime.slice(0, 5) }} - {{ timeSlot.endTime.slice(0, 5) }}
|
||
</text>
|
||
</view>
|
||
<view class="info-row">
|
||
<text class="info-label">剩余</text>
|
||
<text class="info-value" v-if="timeSlot">
|
||
{{ timeSlot.capacity - timeSlot.bookedCount }} 个名额
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="divider" />
|
||
|
||
<!-- Membership card selection -->
|
||
<view class="card-section">
|
||
<view class="section-label-row">
|
||
<text class="section-label">选择扣课会员卡</text>
|
||
</view>
|
||
|
||
<!-- Single membership -->
|
||
<view v-if="memberships.length === 1" class="card-single">
|
||
<view class="card-item selected">
|
||
<view class="card-icon-wrap">
|
||
<text class="card-icon">💳</text>
|
||
</view>
|
||
<view class="card-info">
|
||
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
||
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
||
剩余 {{ memberships[0].remainingTimes }} 次
|
||
</text>
|
||
<text class="card-remain" v-else>
|
||
有效期至 {{ memberships[0].expireDate.slice(0, 10) }}
|
||
</text>
|
||
</view>
|
||
<view class="check-mark">
|
||
<text class="check-icon">✓</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Multiple memberships picker -->
|
||
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
|
||
<view
|
||
v-for="m in memberships"
|
||
:key="m.id"
|
||
class="card-item"
|
||
:class="{ selected: selectedMembershipId === m.id }"
|
||
@tap="selectedMembershipId = m.id"
|
||
>
|
||
<view class="card-icon-wrap">
|
||
<text class="card-icon">💳</text>
|
||
</view>
|
||
<view class="card-info">
|
||
<text class="card-name">{{ m.cardType.name }}</text>
|
||
<text class="card-remain" v-if="m.remainingTimes !== null">
|
||
剩余 {{ m.remainingTimes }} 次
|
||
</text>
|
||
<text class="card-remain" v-else>
|
||
有效期至 {{ m.expireDate.slice(0, 10) }}
|
||
</text>
|
||
</view>
|
||
<view class="check-mark" v-if="selectedMembershipId === m.id">
|
||
<text class="check-icon">✓</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- No memberships fallback (should not normally appear) -->
|
||
<view v-else class="no-card-tip">
|
||
<text class="no-card-text">暂无可用会员卡</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Deduction tip -->
|
||
<view class="deduction-tip" v-if="selectedMembership">
|
||
<text class="deduction-text">
|
||
确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
|
||
</text>
|
||
</view>
|
||
|
||
<!-- Action buttons -->
|
||
<view class="action-row">
|
||
<view class="btn-outline" @tap="handleCancel">
|
||
<text class="btn-outline-text">取消</text>
|
||
</view>
|
||
<view
|
||
class="btn-confirm"
|
||
:class="{ disabled: !selectedMembershipId }"
|
||
@tap="handleConfirm"
|
||
>
|
||
<text class="btn-confirm-text">确认预约</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue'
|
||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||
|
||
const props = defineProps<{
|
||
visible: boolean
|
||
timeSlot: TimeSlotWithBookingStatus | null
|
||
memberships: MembershipWithCardType[]
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'confirm', payload: { timeSlotId: string; membershipId: string }): void
|
||
(e: 'cancel'): void
|
||
(e: 'update:visible', val: boolean): void
|
||
}>()
|
||
|
||
const selectedMembershipId = ref<string>('')
|
||
|
||
// Auto-select the first membership when popup opens or memberships list changes
|
||
watch(
|
||
[() => props.visible, () => props.memberships],
|
||
([visible, memberships]) => {
|
||
if (visible && memberships.length > 0) {
|
||
selectedMembershipId.value = memberships[0].id
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const selectedMembership = computed(() =>
|
||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||
)
|
||
|
||
function handleConfirm() {
|
||
if (!props.timeSlot || !selectedMembershipId.value) return
|
||
emit('confirm', {
|
||
timeSlotId: props.timeSlot.id,
|
||
membershipId: selectedMembershipId.value,
|
||
})
|
||
}
|
||
|
||
function handleCancel() {
|
||
emit('cancel')
|
||
emit('update:visible', false)
|
||
}
|
||
|
||
function handleMaskTap() {
|
||
handleCancel()
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.popup-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
}
|
||
|
||
.popup-panel {
|
||
width: 100%;
|
||
background: #fff;
|
||
border-radius: 32rpx 32rpx 0 0;
|
||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.popup-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 32rpx;
|
||
}
|
||
|
||
.popup-title {
|
||
font-size: 34rpx;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: $primary-selected-bg;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.close-icon {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
|
||
/* Info rows */
|
||
.info-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.info-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
width: 80rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 28rpx;
|
||
color: #222;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.divider {
|
||
height: 1rpx;
|
||
background: $primary-border;
|
||
margin: 8rpx 0 28rpx;
|
||
}
|
||
|
||
/* Card selection */
|
||
.card-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.section-label-row {
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card-single,
|
||
.card-picker-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.card-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 24rpx 20rpx;
|
||
border-radius: 16rpx;
|
||
border: 2rpx solid $primary-border;
|
||
background: $primary-bg;
|
||
gap: 20rpx;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
|
||
&.selected {
|
||
border-color: $primary-dark;
|
||
background: $primary-selected-bg;
|
||
}
|
||
}
|
||
|
||
.card-icon-wrap {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
border-radius: 14rpx;
|
||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-icon {
|
||
font-size: 32rpx;
|
||
}
|
||
|
||
.card-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #222;
|
||
}
|
||
|
||
.card-remain {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.check-mark {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
border-radius: 50%;
|
||
background: $primary-dark;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.check-icon {
|
||
font-size: 24rpx;
|
||
color: #fff;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.no-card-tip {
|
||
padding: 24rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.no-card-text {
|
||
font-size: 26rpx;
|
||
color: #bbb;
|
||
}
|
||
|
||
/* Deduction tip */
|
||
.deduction-tip {
|
||
background: $primary-selected-bg;
|
||
border-radius: 12rpx;
|
||
padding: 16rpx 20rpx;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.deduction-text {
|
||
font-size: 24rpx;
|
||
color: $primary-dark;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Action buttons */
|
||
.action-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 20rpx;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.btn-outline {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 44rpx;
|
||
border: 2rpx solid $primary-border;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&:active {
|
||
background: #f5f5f5;
|
||
}
|
||
}
|
||
|
||
.btn-outline-text {
|
||
font-size: 30rpx;
|
||
color: #666;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-confirm {
|
||
flex: 2;
|
||
height: 88rpx;
|
||
border-radius: 44rpx;
|
||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&:active {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
&.disabled {
|
||
background: $primary-border;
|
||
}
|
||
}
|
||
|
||
.btn-confirm-text {
|
||
font-size: 30rpx;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
letter-spacing: 1rpx;
|
||
|
||
.disabled & {
|
||
color: #bbb;
|
||
}
|
||
}
|
||
</style>
|