feat: 下线支付功能; 月卡生效时间从第一次约课开始
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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} 天`,
|
||||
|
||||
2
packages/app/src/config/features.ts
Normal file
2
packages/app/src/config/features.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Restore this flag to re-enable the existing online purchase flow.
|
||||
export const ONLINE_PAYMENT_ENABLED = false
|
||||
@@ -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>
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user