feat: 下线支付功能; 月卡生效时间从第一次约课开始

This commit is contained in:
richarjiang
2026-10-30 20:51:21 +08:00
parent a192bbb186
commit 21558f841d
42 changed files with 490 additions and 196 deletions

View File

@@ -44,12 +44,13 @@
<view class="card-item selected">
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text v-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text>
<text v-if="memberships[0].startDate === null" class="card-remain">首次课程核销当天生效</text>
<text v-else-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</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) }}
{{ memberships[0].expireDate ? '有效期至 ' + formatChinaDate(memberships[0].expireDate) : '生效后 ' + (memberships[0].activationDurationDays ?? memberships[0].cardType.durationDays) + ' 天内不限次' }}
</text>
</view>
<view class="check-mark">
@@ -69,12 +70,13 @@
>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text v-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
<text v-if="m.startDate === null" class="card-remain">首次课程核销当天生效</text>
<text v-else-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }} 次
</text>
<text class="card-remain" v-else>
有效期至 {{ m.expireDate.slice(0, 10) }}
{{ m.expireDate ? '有效期至 ' + formatChinaDate(m.expireDate) : '生效后 ' + (m.activationDurationDays ?? m.cardType.durationDays) + ' 天内不限次' }}
</text>
</view>
<view class="check-mark" v-if="selectedMembershipId === m.id">

View File

@@ -13,7 +13,7 @@
</view>
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
<view v-else class="pass-rule" />
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view>
<view class="pass-dates"><text v-if="!compact">{{ formatChinaDate(membership.startDate).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ formatDateTimeFull(membership.expireDate) }} 到期</text></view>
<slot />
</view>
</template>
@@ -21,14 +21,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
import { getCardTypeLabel, formatChinaDate, formatDateTimeFull, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
const unlimited = computed(() => props.membership.remainingTimes === null)
const total = computed(() => getMembershipTotalTimes(props.membership))
const used = computed(() => getMembershipUsedTimes(props.membership))
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000)))
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate ?? props.now).getTime() - props.now) / 86400000)))
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
</script>

View File

@@ -31,6 +31,11 @@
</view>
</template>
<view v-else-if="hasAwaitingCheckIn" class="entry-pill pill-active" role="button" hover-class="entry-pressed" @tap="handleBooking">
<text class="pill-label">月卡待首次核销 · 可预约课程</text>
<view class="pill-action action-book"><text class="pill-action-text">预约课程</text></view>
</view>
<view v-else-if="hasScheduledMembership" class="entry-pill pill-expired" role="button" hover-class="entry-pressed" @tap="handleMemberships">
<text class="pill-label">会员卡尚未生效</text>
<view class="pill-action"><text class="pill-action-text">查看会员卡</text></view>
@@ -54,7 +59,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getMembershipEffectiveState, getMembershipRenewalHint, MembershipStatus } from '@mp-pilates/shared'
import { getMembershipEffectiveState, getMembershipRenewalHint, isMembershipAwaitingCheckIn, MembershipStatus } from '@mp-pilates/shared'
import { useUserStore } from '../stores/user'
import { getHomeMembershipDays } from '../utils/home'
@@ -70,6 +75,7 @@ const activeMemberships = computed(() => userStore.memberships.filter(membership
&& (membership.remainingTimes === null || membership.remainingTimes > 0),
))
const renewalHint = computed(() => getMembershipRenewalHint(userStore.memberships, new Date(props.now)))
const hasAwaitingCheckIn = computed(() => userStore.memberships.some(membership => isMembershipAwaitingCheckIn(membership) && (membership.remainingTimes === null || membership.remainingTimes > 0)))
const hasScheduledMembership = computed(() => userStore.memberships.some(membership =>
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.SCHEDULED,
))

View File

@@ -141,8 +141,8 @@ const cardProgress = computed(() => activeMemberships.value.map(m => {
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes} 次`,
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
}
const start = new Date(m.startDate).getTime()
const end = new Date(m.expireDate).getTime()
const start = new Date(m.startDate ?? props.now).getTime()
const end = new Date(m.expireDate ?? props.now).getTime()
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
return { id: m.id, name: m.cardType.name,
label: `不限次 · 有效期剩 ${remainingDays} 天`,

View File

@@ -0,0 +1,2 @@
// Restore this flag to re-enable the existing online purchase flow.
export const ONLINE_PAYMENT_ENABLED = false

View File

@@ -18,7 +18,7 @@
<view v-for="item in legacyExpanded ? candidates : []" :key="item.membershipId" class="legacy-item">
<view class="legacy-info">
<text class="legacy-name">{{ item.user.nickname || '未命名学员' }}{{ item.user.phone ? ' · ' + item.user.phone : '' }}</text>
<text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · 至 {{ formatChinaDate(item.expireDate) }}</text>
<text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · {{ item.expireDate ? '至 ' + formatChinaDate(item.expireDate) : '首次课程核销当天生效' }}</text>
</view>
<input v-model="amounts[item.membershipId]" class="legacy-input" type="digit" placeholder="实付(元)" />
<button class="legacy-button" :disabled="creating === item.membershipId" @tap="createLegacy(item)">发起</button>

View File

@@ -29,7 +29,8 @@
<text class="card-pill-meta">
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
</text>
<text v-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text>
<text v-if="card.startDate === null" class="card-pill-meta">首次课程核销当天生效</text>
<text v-else-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text>
</view>
</view>
</scroll-view>
@@ -137,7 +138,7 @@ const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) =>
(item.status === MembershipStatus.ACTIVE || item.status === MembershipStatus.SCHEDULED)
&& (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(),
&& (item.expireDate === null || new Date(item.expireDate) > new Date()),
),
)

View File

@@ -105,8 +105,8 @@
</text>
</view>
<view class="mship-dates">
<text>{{ formatDate(card.startDate) }} 起</text>
<text>{{ formatDate(card.expireDate) }} 止</text>
<text v-if="card.startDate === null">首次课程核销当天生效</text><text v-else>{{ formatDate(card.startDate) }} 起</text>
<text v-if="card.expireDate">{{ formatDate(card.expireDate) }} 止</text>
</view>
</view>
</view>
@@ -189,7 +189,7 @@ const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArran
function isArrangableMembership(membership: MembershipWithCardType): boolean {
if (membership.status !== MembershipStatus.ACTIVE && membership.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
return new Date(membership.expireDate) > new Date()
return membership.expireDate === null || new Date(membership.expireDate) > new Date()
}
function membershipStatusLabel(status: string): string {

View File

@@ -67,7 +67,8 @@
/>
</view>
<view class="field">
<view v-if="awaitingFirstCheckIn" class="field"><text class="field-label">生效规则</text><text>首次预约课程核销当天生效,有效期 {{ editingMembership?.activationDurationDays ?? selectedCardType?.durationDays }} 个自然日。</text></view>
<view v-if="!awaitingFirstCheckIn" class="field">
<text class="field-label">开始日期</text>
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
<view class="picker-inner">
@@ -77,7 +78,7 @@
</picker>
</view>
<view class="field">
<view v-if="!awaitingFirstCheckIn" class="field">
<text class="field-label">到期日期</text>
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
<view class="picker-inner">
@@ -139,6 +140,7 @@ const editForm = ref({
const existingMemberships = computed(() => detail.value?.memberships ?? [])
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
const awaitingFirstCheckIn = computed(() => selectedCardType.value?.type === 'DURATION' && (!editingMembership.value || editingMembership.value.startDate === null))
const membershipPickerLabels = computed(() =>
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
@@ -170,8 +172,8 @@ function applyMembership(membership: MembershipWithCardType | null) {
cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: membership.cardTypeId,
remainingTimes: membership.remainingTimes,
startDate: membership.startDate.slice(0, 10),
expireDate: membership.expireDate.slice(0, 10),
startDate: membership.startDate?.slice(0, 10) ?? formatDateLocal(new Date()),
expireDate: membership.expireDate?.slice(0, 10) ?? calculateExpireDate(formatDateLocal(new Date()), membership.activationDurationDays ?? membership.cardType.durationDays),
manuallyEditedExpire: false,
}
return

View File

@@ -81,7 +81,7 @@ const cardIndex = ref(0)
const pending = ref<CreateLessonSupplementDto | null>(null)
const storageKey = computed(() => `lesson-supplement-pending:${userId.value}`)
const cards = computed(() => (detail.value?.memberships ?? []).filter(c => c.remainingTimes !== null && c.remainingTimes > 0))
const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes} 次${new Date(c.expireDate) <= new Date() || c.status === 'EXPIRED' ? ' · 已过期' : ''}`))
const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes} 次${(c.expireDate !== null && new Date(c.expireDate) <= new Date()) || c.status === 'EXPIRED' ? ' · 已过期' : ''}`))
const selectedCard = computed(() => cards.value[cardIndex.value])
const count = computed(() => Number(quantity.value))
const validQuantity = computed(() => /^\d{1,3}$/.test(quantity.value) && count.value >= 1 && count.value <= 999)

View File

@@ -244,9 +244,13 @@
</view>
</view>
<view v-if="cardData.type === CardTypeCategory.DURATION" class="notice-card">
<view class="section-heading"><text class="section-kicker">START WITH YOUR FIRST CLASS</text><text class="section-title">第一次上课核销,才开始计时</text></view>
<text class="notice-text">购买后可预约课程,月卡从首次预约课程核销当天起生效,按中国时区计算 {{ cardData.durationDays }} 个自然日。仅预约、确认预约、取消或未出席均不会开始计时,具体起止日期可在「我的会员卡」查看。</text>
</view>
<view class="notice-card">
<view class="section-heading">
<text class="section-kicker">BEFORE YOU PAY</text>
<text class="section-kicker">BEFORE YOU BUY</text>
<text class="section-title">{{ isRenewal ? '续卡说明' : '使用与退款说明' }}</text>
</view>
<view
@@ -257,16 +261,28 @@
<text class="notice-number">0{{ index + 1 }}</text>
<text class="notice-text">{{ note }}</text>
</view>
<button v-if="contractRequired" class="contract-preview-button" @tap="previewContract">查看《会员卡服务合同》 ›</button>
</view>
</view>
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary">
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
<text class="price-summary-label">{{ isRenewal ? '续卡价格' : '会员卡价格' }}</text>
<text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
<text class="price-summary-hint">{{ bottomPriceHint }}</text>
</view>
<button
v-if="!ONLINE_PAYMENT_ENABLED"
class="buy-btn"
:class="{ 'buy-btn--renew': isRenewal }"
open-type="contact"
:show-message-card="true"
:send-message-title="cardData.name + ' · 咨询购买'"
:send-message-path="'/pages/card/detail?id=' + cardData.id"
@error="handleContactError"
>联系馆主</button>
<button
v-else
class="buy-btn"
:class="{ 'buy-btn--busy': isPurchaseBusy, 'buy-btn--renew': isRenewal }"
:disabled="isPurchaseBusy"
@@ -278,7 +294,7 @@
</template>
<view
v-if="purchaseConfirmVisible && card"
v-if="ONLINE_PAYMENT_ENABLED && purchaseConfirmVisible && card"
class="purchase-sheet-layer"
@tap="closePurchaseConfirmation"
@touchmove.stop.prevent
@@ -354,6 +370,7 @@
</template>
<script setup lang="ts">
import { ONLINE_PAYMENT_ENABLED } from '../../config/features'
import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onLoad, onShow, onHide, onShareAppMessage } from '@dcloudio/uni-app'
@@ -465,8 +482,19 @@ const cardData = computed<CardType>(() => card.value as CardType)
const contractRequired = computed(() => !!card.value && requiresMembershipContract(card.value))
const contractAccepted = ref(false)
function previewContract() {
async function previewContract() {
if (!card.value) return
if (!userStore.loggedIn) {
const confirmation = await uni.showModal({ title: '登录后查看合同', content: '登录后可查看该会员卡的服务合同范本。', confirmText: '去登录' })
if (!confirmation.confirm) return
try {
const result = await userStore.loginWithSetup()
if (result.isNewUser) return
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '登录失败,请重试'), icon: 'none' })
return
}
}
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?previewCardTypeId=${card.value.id}` })
}
@@ -507,7 +535,7 @@ function syncRenewalClock() {
stopRenewalClock()
renewalNow.value = Date.now()
if (!renewalPageVisible) return
const expires = renewalTarget.value ? new Date(renewalTarget.value.expireDate).getTime() : 0
const expires = renewalTarget.value?.expireDate ? new Date(renewalTarget.value.expireDate).getTime() : 0
if (expires > renewalNow.value) {
const remaining = expires - renewalNow.value
const nextDayBoundary = remaining - (Math.ceil(remaining / 86_400_000) - 1) * 86_400_000
@@ -525,7 +553,8 @@ const grantPreview = computed(() => {
})
})
function formatStudioDate(value: Date | string): string {
function formatStudioDate(value: Date | string | null): string {
if (value === null) return '首次核销后确定'
const date = typeof value === 'string' ? new Date(value) : value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -537,7 +566,7 @@ const renewalPreview = computed(() => {
const grant = grantPreview.value
const currentCard = card.value
const now = new Date(renewalNow.value)
const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())[0]
const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && m.startDate !== null && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate ?? b.createdAt).getTime() - new Date(a.startDate ?? a.createdAt).getTime())[0]
const target = currentCard?.type === CardTypeCategory.DURATION ? current || renewalTarget.value : renewalTarget.value
if (!target || !grant || !currentCard) return null
@@ -563,16 +592,16 @@ const renewalPreview = computed(() => {
: String(currentCard.durationDays),
highlightUnit: isTimes ? '次剩余' : '天有效',
currentExpire: formatStudioDate(target.expireDate),
afterStart: isTimes ? '' : formatStudioDate(grant.startDate) + ' 生效',
afterExpire: formatStudioDate(grant.expireDate) + (isTimes ? '' : ' 到期'),
afterStart: isTimes ? '' : '首次预约课程核销当天生效',
afterExpire: isTimes ? formatStudioDate(grant.expireDate) : '自首次核销当天起 ' + currentCard.durationDays + ' 个自然日',
currentAllowance: !isTimes && target.remainingTimes !== null
? awaitingPeriod ? '当前无生效周期' : isExpired ? '原周期余次已失效' : '本周期剩余 ' + String(effective.remainingTimes) + ' 次' : '',
afterAllowance: !isTimes ? (grant.startDate > now ? '下期待生效 · ' : '新周期 · ') + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '',
afterAllowance: !isTimes ? '新周期 · ' + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '',
addedLabel: isTimes
? '本次预计续入 +' + String(currentCard.totalTimes ?? 0) + ' 次 · +' + String(currentCard.durationDays) + ' 天'
: '本次月卡' + (currentCard.totalTimes !== null ? '包含 ' + String(currentCard.totalTimes) + ' 次 · ' : ' · ') + String(currentCard.durationDays) + ' 天有效',
forfeitNote: !isTimes
? grant.startDate > now ? '本期权益保留至到期,下期单独生效;月卡余次不跨期累计。' : '原周期已结束,剩余课时不结转;新周期按本次购买额度起算。'
? '每张新购月卡独立计算,从该卡首次预约课程核销当天起生效,余次不跨期累计。'
: isExpired && grant.remainingTimes !== null
? '到期余次不结转,按新购 ' + String(currentCard.totalTimes) + ' 次起算'
: '',
@@ -592,15 +621,12 @@ const purchaseNotes = computed(() => {
if (!card.value) return []
const notes: string[] = []
if (renewalTarget.value && isMembershipExpired(renewalTarget.value, new Date(renewalNow.value))) {
notes.push('旧卡已到期,原周期剩余课时失效;支付确认后按新购权益重新生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
} else if (isRenewal.value && card.value.type === CardTypeCategory.DURATION) {
notes.push('提前续费将生成独立的下期月卡,本期权益保留至到期,未用完的课时不结转。')
notes.push('本期课时用完后,可在「我的会员卡」确认提前启用下一期;原周期随之结束。')
if (card.value.type === CardTypeCategory.DURATION) {
notes.push('购买后可预约课程;月卡从首次预约课程核销当天起生效,按中国时区计算 ' + card.value.durationDays + ' 个自然日。预约和确认预约不触发计时。')
} else if (isRenewal.value) {
notes.push('支付确认后,现有会员有效期预计再延长 ' + String(card.value.durationDays) + ' 天。')
notes.push('购卡确认后的权益与有效期请联系馆主核对。')
} else {
notes.push('支付确认后会员卡生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
notes.push('有效期为 ' + card.value.durationDays + ' 天,购卡及生效详情请联系馆主。')
}
if (card.value.totalTimes === null) {
@@ -610,18 +636,18 @@ const purchaseNotes = computed(() => {
}
if (card.value.type === CardTypeCategory.DURATION) {
notes.push('每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。')
notes.push('每张月卡独立计算,未用课时到期失效;新购月卡无需手动提前启用。')
} else {
notes.push(card.value.totalTimes === null ? '到期后自动失效。' : '到期或课时用完后自动失效。')
}
if (requiresMembershipContract(card.value)) {
notes.push('付款 7 日内未上课可全额退款;之后退卡,已上课时按单节标准课价扣除,不收手续费,详见合同。')
notes.push('支付成功后需在线签署《会员卡服务合同》,签署后即可预约课程。')
notes.push('购卡后请按提示签署《会员卡服务合同》,签署后即可预约课程。')
} else if (card.value.totalTimes !== null) {
notes[notes.length - 1] = '到期或课时用完后自动失效,不可退款。'
}
notes.push('支持微信支付,支付过程由微信安全保障。')
notes.push(ONLINE_PAYMENT_ENABLED ? '支持微信支付,支付过程由微信安全保障。' : '点击「联系馆主」进入微信客服会话,咨询购买与续卡。')
return notes
})
@@ -641,7 +667,7 @@ const buyButtonLabel = computed(() => {
})
const bottomPriceHint = computed(() => {
const effectiveHint = card.value?.type === CardTypeCategory.DURATION && grantPreview.value && grantPreview.value.startDate.getTime() > renewalNow.value ? '支付后排期待生效' : '支付确认后生效'
const effectiveHint = card.value?.type === CardTypeCategory.DURATION ? '首次课程核销当天生效' : ONLINE_PAYMENT_ENABLED ? '支付确认后生效' : '联系馆主咨询购买'
if (savingsLabel.value) return savingsLabel.value + ' · ' + effectiveHint
return effectiveHint
})
@@ -811,8 +837,12 @@ function showReauthenticationPrompt() {
})
}
function handleContactError() {
uni.showToast({ title: '暂时无法打开客服会话,请稍后重试', icon: 'none' })
}
async function handleBuy() {
if (!card.value || isPurchaseBusy.value) return
if (!ONLINE_PAYMENT_ENABLED || !card.value || isPurchaseBusy.value) return
if (!userStore.loggedIn) {
showLoginPrompt()
return
@@ -821,7 +851,7 @@ async function handleBuy() {
}
async function preparePurchaseConfirmation() {
if (!card.value || isPurchaseBusy.value) return
if (!ONLINE_PAYMENT_ENABLED || !card.value || isPurchaseBusy.value) return
purchasePreparing.value = true
try {
await invite.refresh()
@@ -953,7 +983,7 @@ function goToMembership() {
}
async function doPurchase() {
if (!card.value || buying.value) return
if (!ONLINE_PAYMENT_ENABLED || !card.value || buying.value) return
if (contractRequired.value && !contractAccepted.value) {
uni.showToast({ title: '请先阅读并同意《会员卡服务合同》', icon: 'none' })
return
@@ -1038,6 +1068,7 @@ onUnmounted(() => {
</script>
<style lang="scss" scoped>
.contract-preview-button { margin: 24rpx 0 0; padding: 0; background: transparent; color: #617d70; text-align: left; font-size: 24rpx; line-height: 1.8; &::after { border: 0; } }
.card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; }
.card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } }
.card-share-icon { font-size: 28rpx; }

View File

@@ -12,7 +12,7 @@
<template v-else>
<view v-if="previewCardTypeId" class="status status--preview">
<text class="status__title">合同范本预览</text>
<text class="status__copy">支付成功后将生成正式合同,填写姓名并手写签名后生效。金额、日期以支付确认后的正式合同为准。</text>
<text class="status__copy">购卡后请核对正式合同,填写姓名并手写签名。月卡权益从首次预约课程核销当天起算,具体以正式合同与会员卡记录为准。</text>
</view>
<view v-else-if="detail" class="status" :class="'status--' + detail.status.toLowerCase()">
<text class="status__title">{{ CONTRACT_STATUS_LABEL[detail.status] }}</text>

View File

@@ -31,7 +31,7 @@
<text class="contract-card__meta">合同编号 {{ item.contractNo }}</text>
<view class="contract-card__facts">
<text>¥{{ formatPrice(item.amount) }}</text>
<text>有效期至 {{ formatChinaDate(item.expireDate) }}</text>
<text>{{ item.expireDate ? '有效期至 ' + formatChinaDate(item.expireDate) : '首次课程核销当天生效' }}</text>
</view>
<view class="contract-card__foot">
<text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text>

View File

@@ -22,7 +22,7 @@
<view v-if="step === 'identity'" class="panel">
<view class="card-summary">
<text class="card-summary__name">{{ detail.cardName }}</text>
<text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · 有效期至 {{ formatChinaDate(detail.expireDate) }}</text>
<text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · {{ detail.expireDate ? '有效期至 ' + formatChinaDate(detail.expireDate) : '首次课程核销当天生效' }}</text>
<text v-if="detail.source === MembershipContractSource.LEGACY" class="card-summary__tag">存量会员卡补签</text>
</view>
<text class="panel__title">确认签约人信息</text>

View File

@@ -10,6 +10,7 @@
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · 节</text></view>
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
</view>
<text v-if="pendingCheckInCount" class="summary-note">另有 {{ pendingCheckInCount }} 张月卡待首次核销,可预约课程。</text>
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡,可在有效期内预约</text>
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新,点击重试 ›</button>
</view>
@@ -45,12 +46,12 @@
</view>
<view v-if="scheduledMemberships.length" class="group-section">
<view class="group-header"><text class="group-title">下期待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view>
<view class="group-header"><text class="group-title">待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view>
<view v-for="m in scheduledMemberships" :key="m.id" class="mc mc--scheduled">
<view class="mc-top"><view class="mc-name-area"><text class="mc-name">{{ m.cardType.name }}</text><text class="mc-type-text">{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}</text></view><text class="mc-status">待生效</text></view>
<view class="mc-history-bottom"><view class="mc-history-info"><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></view><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view>
<view class="mc-history-bottom"><view class="mc-history-info"><template v-if="isMembershipAwaitingCheckIn(m)"><text class="mc-date-label">首次预约课程核销当天生效</text><text class="mc-date-label">生效后 {{ m.activationDurationDays ?? m.cardType.durationDays }} 个自然日有效</text></template><template v-else><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></template></view><button v-if="isMembershipAwaitingCheckIn(m) && m.remainingTimes !== 0" class="mc-renew" @tap="goBooking">预约课程</button><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view>
</view>
<text class="usage-note">下期课时与本期分别计算,不计入当前可用余额。预约需落在对应周期内。</text>
<text class="usage-note">新购月卡可先预约,首次课程核销当天开始计时;每张卡的课时与有效期独立计算。</text>
</view>
<view v-if="inactiveMemberships.length" class="group-section">
@@ -70,7 +71,7 @@
<view class="mc-history-info">
<text v-if="m.cardType.type === CardTypeCategory.DURATION && m.status === MembershipStatus.EXPIRED" class="mc-date-label">原周期未用课时已失效</text>
<text v-else-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
<text class="mc-date-label">{{ m.expireDate?.slice(0, 10) ?? '待确定' }} 到期</text>
</view>
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
</view>
@@ -89,7 +90,7 @@
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide, onResize } from '@dcloudio/uni-app'
import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState, isMembershipAwaitingCheckIn } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, formatDateTimeFull } from '../../utils/format'
@@ -118,14 +119,16 @@ function syncBoundaryClock() {
stopBoundaryClock()
membershipNow.value = Date.now()
if (!pageVisible) return
const boundaries = userStore.memberships.flatMap(m => [new Date(m.startDate).getTime(), new Date(m.expireDate).getTime()]).filter(time => time > membershipNow.value)
const boundaries = userStore.memberships.flatMap(m => [m.startDate, m.expireDate].filter((date): date is string => date !== null).map(date => new Date(date).getTime())).filter(time => time > membershipNow.value)
const delay = Math.min(60_000, ...boundaries.map(time => time - membershipNow.value + 1))
boundaryTimer = setTimeout(syncBoundaryClock, delay)
}
watch(() => userStore.memberships, syncBoundaryClock)
const allMemberships = computed(() => userStore.memberships.map(m => ({ ...m, ...getMembershipEffectiveState(m, new Date(membershipNow.value)) })))
const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()))
const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate ?? a.createdAt).getTime() - new Date(b.startDate ?? b.createdAt).getTime()))
const pendingCheckInCount = computed(() => allMemberships.value.filter(isMembershipAwaitingCheckIn).length)
const activeMemberships = computed(() =>
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
@@ -190,7 +193,7 @@ function goRenew(m: MembershipWithCardType) {
}
function canActivate(m: MembershipWithCardType): boolean {
return m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0
return !isMembershipAwaitingCheckIn(m) && m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0
&& scheduledMemberships.value.find(period => period.cardTypeId === m.cardTypeId)?.id === m.id
&& !activeMemberships.value.some(period => period.cardTypeId === m.cardTypeId && period.remainingTimes !== 0)
}

View File

@@ -13,14 +13,16 @@ export function formatPrice(cents: number): string {
}
/** 中国自然日 YYYY-MM-DD,用于时间戳与 UTC 日期列展示 */
export function formatChinaDate(value: Date | string): string {
export function formatChinaDate(value: Date | string | null): string {
if (value === null) return '首次核销后确定'
const time = new Date(value).getTime()
if (!Number.isFinite(time)) return ''
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
}
/** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string {
export function formatDate(date: Date | string | null): string {
if (date === null) return '首次核销后确定'
const d = typeof date === 'string' ? new Date(date) : date
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
@@ -139,7 +141,8 @@ export function getStockPercent(soldCount: number, totalStock: number): string {
}
/** 格式化日期时间为 YYYY-MM-DD HH:mm */
export function formatDateTimeFull(dateStr: string): string {
export function formatDateTimeFull(dateStr: string | null): string {
if (dateStr === null) return '首次核销后确定'
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return '-'
const year = d.getFullYear()

View File

@@ -43,7 +43,8 @@ export function getHomeBookingDate(value: string) {
}
}
export function getHomeMembershipDays(expireDate: string, now: Date) {
export function getHomeMembershipDays(expireDate: string | null, now: Date) {
if (expireDate === null) return 0
const expiry = new Date(expireDate).getTime()
if (!Number.isFinite(expiry)) return 0
return Math.max(0, Math.ceil((expiry - now.getTime()) / DAY_MS))