feat: 支持同卡种续卡叠加并修复过期登录态

支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-07 14:04:11 +08:00
parent 801e2f47da
commit 88cd8419c8
22 changed files with 1370 additions and 123 deletions

View File

@@ -178,12 +178,12 @@
</view>
</view>
<!-- Progress bar -->
<view v-if="detailMembership.remainingTimes !== null && detailMembership.cardType?.totalTimes" class="mship-progress-wrap">
<view v-if="detailMembership.remainingTimes !== null && getMembershipTotalTimes(detailMembership)" class="mship-progress-wrap">
<view class="mship-progress-bar">
<view class="mship-progress-fill" :style="{ width: getMembershipProgressWidth(detailMembership) }" />
</view>
<text class="mship-progress-label">
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }}
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }}
</text>
</view>
<!-- Date info -->
@@ -313,7 +313,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary, UserMembership } from '../../stores/admin'
import type { CardType } from '@mp-pilates/shared'

View File

@@ -1,6 +1,6 @@
<template>
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="购买会员卡" show-back />
<view class="card-detail-page" :class="{ 'page--renew': isRenewal }" :style="{ paddingTop: navBarHeight }">
<CustomNavBar :title="isRenewal ? '续卡' : '购买会员卡'" show-back />
<!-- Loading state -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-header" />
@@ -54,7 +54,10 @@
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ c.name }}</text>
<view class="card-name-row">
<text class="card-name">{{ c.name }}</text>
<text v-if="isOwnedForRenew(c)" class="renew-tag"></text>
</view>
<text class="card-validity">有效期 {{ c.durationDays }} </text>
</view>
<view class="info-bottom">
@@ -85,7 +88,7 @@
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<view class="card-hero" :class="[cardData.coverUrl ? 'hero--custom' : heroClass, isRenewal ? 'hero--renew' : '']">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
@@ -99,9 +102,15 @@
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
<view class="hero-badge-row">
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
</view>
<view v-if="isRenewal" class="hero-badge hero-badge--renew">
<text class="hero-badge-text">续卡</text>
</view>
</view>
<text v-if="isRenewal" class="hero-kicker">把练习节奏接下去</text>
<text class="hero-name">{{ cardData.name }}</text>
<view class="hero-price-row">
<text class="hero-currency">¥</text>
@@ -144,15 +153,49 @@
<text class="desc-content">{{ cardData.description }}</text>
</view>
<view v-if="renewalPreview" class="renew-compare">
<view class="renew-compare-head">
<text class="renew-compare-kicker">续卡对照</text>
<text class="renew-compare-status">{{ renewalPreview.statusLabel }}</text>
</view>
<view class="renew-compare-body">
<view class="renew-col">
<text class="renew-col-label">当前</text>
<text class="renew-col-value">{{ renewalPreview.currentHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text class="renew-col-date">{{ renewalPreview.currentExpire }}</text>
</view>
<view class="renew-arrow">
<view class="renew-arrow-line" />
<view class="renew-arrow-diamond" />
<view class="renew-arrow-line" />
</view>
<view class="renew-col renew-col--after">
<text class="renew-col-label">续后</text>
<text class="renew-col-value">{{ renewalPreview.afterHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text class="renew-col-date">{{ renewalPreview.afterExpire }}</text>
</view>
</view>
<view class="renew-compare-foot">
<text class="renew-compare-add">{{ renewalPreview.addedLabel }}</text>
<text v-if="renewalPreview.forfeitNote" class="renew-compare-note">{{ renewalPreview.forfeitNote }}</text>
</view>
</view>
<!-- Features list -->
<view class="features-card">
<view class="section-header">
<view class="section-dot" />
<text class="section-title">购买须知</text>
<text class="section-title">{{ isRenewal ? '续卡须知' : '购买须知' }}</text>
</view>
<view class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">购买后立即生效有效期 {{ cardData.durationDays }} </text>
<text class="feature-text">{{ isRenewal ? `续卡后有效期接续,再延长 ${cardData.durationDays}` : `购买后立即生效,有效期 ${cardData.durationDays}` }}</text>
</view>
<view v-if="cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
@@ -186,17 +229,17 @@
</view>
<!-- Bottom action bar -->
<view class="bottom-bar">
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary">
<text class="summary-label">实付金额</text>
<text class="summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
</view>
<view
class="buy-btn"
:class="{ 'buy-btn--loading': buying }"
:class="{ 'buy-btn--loading': buying, 'buy-btn--renew': isRenewal }"
@tap="handleBuy"
>
<text class="buy-btn-text">{{ buying ? '支付中...' : '立即购买' }}</text>
<text class="buy-btn-text">{{ buying ? '支付中...' : (isRenewal ? '确认续卡' : '立即购买') }}</text>
</view>
</view>
</template>
@@ -206,10 +249,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, pickRenewalTarget, getMembershipDaysLeft } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { get, post } from '../../utils/request'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
@@ -261,6 +304,78 @@ const unitPrice = computed(() => {
const cardData = computed<CardType>(() => card.value as CardType)
const renewalTarget = computed(() => {
if (!card.value || card.value.type === CardTypeCategory.TRIAL) return null
return pickRenewalTarget(userStore.memberships, card.value.id)
})
const isRenewal = computed(() => !!renewalTarget.value)
const grantPreview = computed(() => {
if (!card.value || !renewalTarget.value) return null
return computeMembershipGrant({
existing: renewalTarget.value,
cardType: card.value,
now: new Date(),
})
})
function formatStudioDate(value: Date | string): string {
const date = typeof value === 'string' ? new Date(value) : value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
const renewalPreview = computed(() => {
const target = renewalTarget.value
const grant = grantPreview.value
const currentCard = card.value
if (!target || !grant || !currentCard) return null
const isTimes = grant.remainingTimes !== null
const isExpired =
target.status === MembershipStatus.EXPIRED ||
new Date(target.expireDate).getTime() <= Date.now()
const statusLabel =
target.status === MembershipStatus.USED_UP
? '次数已用完'
: isExpired
? '已到期'
: '使用中'
return {
isTimes,
isExpired,
statusLabel,
currentHighlight: isTimes ? String(target.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(target.expireDate))),
afterHighlight: isTimes ? String(grant.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(grant.expireDate))),
highlightUnit: isTimes ? '次剩余' : '天有效',
currentExpire: formatStudioDate(target.expireDate),
afterExpire: formatStudioDate(grant.expireDate),
addedLabel: isTimes
? `本次续入 +${currentCard.totalTimes ?? 0} 次 · +${currentCard.durationDays}`
: `本次续入 +${currentCard.durationDays}`,
forfeitNote: isExpired && isTimes && (target.remainingTimes ?? 0) > 0
? '到期余次不结转,按新购次数起算'
: '',
}
})
const renewalPreviewText = computed(() => {
const preview = renewalPreview.value
if (!preview) return ''
if (preview.isTimes) {
return `续卡后剩余 ${preview.afterHighlight} 次,有效期至 ${preview.afterExpire}`
}
return `续卡后有效期至 ${preview.afterExpire}`
})
function isOwnedForRenew(target: CardType): boolean {
return target.type !== CardTypeCategory.TRIAL && !!pickRenewalTarget(userStore.memberships, target.id)
}
// ─── Data loading ─────────────────────────────────────────
async function loadCard() {
loading.value = true
@@ -324,8 +439,10 @@ async function handleBuy() {
}
uni.showModal({
title: '确认购买',
content: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}`,
title: isRenewal.value ? '确认续卡' : '确认购买',
content: isRenewal.value
? `确认续卡「${card.value.name}」,${renewalPreviewText.value},实付 ¥${formatPrice(card.value.price)}`
: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}`,
confirmText: '确认支付',
success: async (res) => {
if (!res.confirm) return
@@ -364,7 +481,7 @@ async function doPurchase() {
// Payment succeeded — refresh memberships then navigate
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '购买成功!', icon: 'success' })
uni.showToast({ title: isRenewal.value ? '续卡成功!' : '购买成功!', icon: 'success' })
await userStore.fetchMemberships()
setTimeout(() => {
uni.navigateTo({ url: '/pages/profile/membership' })
@@ -391,6 +508,9 @@ onMounted(() => {
isTrial.value = options.trial === '1'
showAll.value = options.showAll === '1'
loadCard()
if (userStore.loggedIn) {
userStore.fetchMemberships()
}
})
</script>
@@ -401,6 +521,10 @@ onMounted(() => {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.page--renew {
background: #f4f1eb;
}
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 0 0 32rpx;
@@ -473,19 +597,23 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
background: linear-gradient(160deg, #f3e9de 0%, #e6d5c4 100%);
}
&.hero--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
background: linear-gradient(160deg, #eee6ea 0%, #dccfd6 100%);
}
&.hero--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
background: linear-gradient(160deg, #e4eee9 0%, #c9dbd3 100%);
}
&.hero--custom {
background: #333;
background: #5c534c;
}
&.hero--renew {
background: linear-gradient(165deg, #e4ede8 0%, #f3ebe3 52%, #e8dcd0 100%);
}
}
@@ -501,7 +629,7 @@ onMounted(() => {
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.38);
pointer-events: none;
&--1 {
@@ -519,26 +647,57 @@ onMounted(() => {
}
}
.hero--renew .hero-deco--1 {
background: rgba(143, 168, 154, 0.22);
}
.hero--renew .hero-deco--2 {
background: rgba(255, 252, 248, 0.45);
}
.hero-badge-row {
display: flex;
align-items: center;
gap: 12rpx;
z-index: 1;
}
.hero-badge {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(74, 64, 53, 0.1);
border: 1rpx solid rgba(74, 64, 53, 0.15);
background: rgba(255, 252, 248, 0.45);
border: 1rpx solid rgba(95, 122, 110, 0.18);
z-index: 1;
}
.hero-badge--renew {
background: rgba(143, 168, 154, 0.22);
border-color: rgba(95, 122, 110, 0.22);
.hero-badge-text {
color: #5f7a6e;
}
}
.hero-badge-text {
font-size: 22rpx;
color: $brand-color;
color: #5c534c;
font-weight: 600;
letter-spacing: 1rpx;
letter-spacing: 2rpx;
}
.hero-kicker {
font-size: 24rpx;
color: #7a8f85;
letter-spacing: 4rpx;
z-index: 1;
}
.hero-name {
font-size: 48rpx;
font-weight: 800;
color: $brand-color;
color: #4f5d56;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -553,20 +712,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(74, 64, 53, 0.7);
color: #7a8f85;
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
color: $brand-color;
color: #4f5d56;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
color: rgba(74, 64, 53, 0.4);
color: rgba(92, 83, 76, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -592,7 +751,7 @@ onMounted(() => {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: $primary-dark;
background: #8fa89a;
flex-shrink: 0;
}
@@ -633,13 +792,13 @@ onMounted(() => {
.cell-value {
font-size: 44rpx;
font-weight: 800;
color: #1a1a1a;
color: #4f5d56;
line-height: 1.1;
}
.cell-label {
font-size: 22rpx;
color: #999;
color: #9a9088;
}
/* ── Description card ────────────────────────────────── */
@@ -656,6 +815,128 @@ onMounted(() => {
line-height: 1.75;
}
.renew-compare {
background: linear-gradient(180deg, #fffcfa 0%, #f3eee6 100%);
border-radius: 28rpx;
padding: 32rpx 28rpx 28rpx;
box-shadow: 0 10rpx 32rpx rgba(95, 122, 110, 0.08);
border: 1rpx solid rgba(143, 168, 154, 0.28);
}
.renew-compare-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
}
.renew-compare-kicker {
font-size: 22rpx;
font-weight: 600;
letter-spacing: 4rpx;
color: #7a8f85;
}
.renew-compare-status {
font-size: 20rpx;
color: #f7faf8;
background: #8fa89a;
padding: 6rpx 16rpx;
border-radius: 20rpx;
letter-spacing: 1rpx;
}
.renew-compare-body {
display: flex;
align-items: center;
justify-content: space-between;
}
.renew-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.renew-col-label {
font-size: 20rpx;
color: #a3988e;
letter-spacing: 2rpx;
margin-bottom: 8rpx;
}
.renew-col-value {
font-size: 72rpx;
font-weight: 800;
color: #8a7e74;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.renew-col--after .renew-col-value {
color: #5f7a6e;
}
.renew-col-unit {
font-size: 22rpx;
color: #8a7e74;
margin-top: 8rpx;
}
.renew-col-date {
font-size: 22rpx;
color: #a3988e;
margin-top: 12rpx;
letter-spacing: 1rpx;
}
.renew-arrow {
width: auto;
padding: 0 8rpx 28rpx;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.renew-arrow-line {
width: 28rpx;
height: 2rpx;
background: rgba(143, 168, 154, 0.45);
}
.renew-arrow-diamond {
width: 14rpx;
height: 14rpx;
background: #8fa89a;
transform: rotate(45deg);
}
.renew-compare-foot {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx dashed rgba(143, 168, 154, 0.4);
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
.renew-compare-add {
font-size: 24rpx;
font-weight: 600;
color: #5f7a6e;
letter-spacing: 1rpx;
}
.renew-compare-note {
font-size: 20rpx;
color: #a3988e;
}
/* ── Features card ───────────────────────────────────── */
.features-card {
background: #fff;
@@ -676,14 +957,14 @@ onMounted(() => {
.feature-dot {
font-size: 26rpx;
color: $primary-dark;
color: #8fa89a;
line-height: 1.65;
flex-shrink: 0;
}
.feature-text {
font-size: 26rpx;
color: #555;
color: #6f675f;
line-height: 1.65;
}
@@ -693,14 +974,19 @@ onMounted(() => {
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1rpx solid #f0ece8;
background: rgba(255, 252, 250, 0.96);
border-top: 1rpx solid #efe8df;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
box-shadow: 0 -8rpx 28rpx rgba(95, 122, 110, 0.06);
}
.bottom-bar--renew {
background: #fffcfa;
border-top-color: rgba(143, 168, 154, 0.22);
}
.price-summary {
@@ -711,24 +997,24 @@ onMounted(() => {
.summary-label {
font-size: 22rpx;
color: #999;
color: #9a9088;
}
.summary-price {
font-size: 40rpx;
font-weight: 800;
color: $primary-dark;
color: #5f7a6e;
}
.buy-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
background: #6e8b7e;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
box-shadow: 0 8rpx 24rpx rgba(95, 122, 110, 0.22);
&:active {
opacity: 0.85;
@@ -737,13 +1023,17 @@ onMounted(() => {
&--loading {
opacity: 0.6;
}
&--renew {
background: linear-gradient(135deg, #6e8b7e 0%, #8fa89a 100%);
}
}
.buy-btn-text {
font-size: 32rpx;
font-size: 30rpx;
font-weight: 700;
color: $primary-dark;
letter-spacing: 2rpx;
color: #fffcfa;
letter-spacing: 4rpx;
}
/* ── All cards list ────────────────────────────────────── */
@@ -849,6 +1139,25 @@ onMounted(() => {
line-height: 1.2;
}
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
.renew-tag {
flex-shrink: 0;
font-size: 18rpx;
font-weight: 700;
color: #5f7a6e;
background: rgba(143, 168, 154, 0.2);
padding: 2rpx 10rpx;
border-radius: 8rpx;
line-height: 1.4;
letter-spacing: 1rpx;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;

View File

@@ -63,7 +63,7 @@
<view v-if="m.remainingTimes !== null" class="mc-center">
<text class="mc-big-num">{{ m.remainingTimes }}</text>
<text class="mc-big-unit">次剩余</text>
<view v-if="m.cardType.totalTimes" class="mc-progress">
<view v-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track">
<view
class="mc-progress-fill"
@@ -71,7 +71,7 @@
/>
</view>
<text class="mc-progress-label">
已用 {{ getMembershipUsedTimes(m) }} {{ m.cardType.totalTimes }}
已用 {{ getMembershipUsedTimes(m) }} {{ getMembershipTotalTimes(m) }}
</text>
</view>
</view>
@@ -94,6 +94,10 @@
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view v-if="canRenewMembership(m)" class="mc-renew" @tap.stop="goRenew(m)">
<text class="mc-renew-text">续卡</text>
</view>
</view>
</view>
@@ -133,6 +137,10 @@
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap.stop="goRenew(m)">
<text class="mc-renew-text">续同款</text>
</view>
</view>
</view>
</view>
@@ -153,7 +161,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
@@ -215,9 +223,18 @@ async function onRefresh() {
}
function goStore() {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
function canRenewMembership(m: MembershipWithCardType): boolean {
return m.cardType.type !== CardTypeCategory.TRIAL
}
function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -571,6 +588,32 @@ onMounted(() => {
z-index: 1;
}
.mc-renew {
z-index: 1;
align-self: stretch;
height: 64rpx;
border-radius: 32rpx;
background: rgba(255, 252, 248, 0.42);
border: 1rpx solid rgba(143, 168, 154, 0.4);
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.85; }
}
.mc-renew--inactive {
background: rgba(143, 168, 154, 0.1);
border-color: rgba(143, 168, 154, 0.28);
}
.mc-renew-text {
font-size: 24rpx;
font-weight: 600;
color: #5f7a6e;
letter-spacing: 4rpx;
}
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;