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

@@ -47,7 +47,10 @@
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ card.name }}</text>
<view class="card-name-row">
<text class="card-name">{{ card.name }}</text>
<text v-if="isRenewable(card)" class="renew-tag"></text>
</view>
<text class="card-validity">有效期 {{ card.durationDays }} </text>
</view>
<view class="info-bottom">
@@ -80,15 +83,32 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice, getCardCoverClass } from '../utils/format'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const hasLoaded = ref(false)
const renewableCardTypeIds = computed(() => {
const ids = new Set<string>()
for (const membership of userStore.memberships) {
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
ids.add(membership.cardTypeId)
}
}
return ids
})
function isRenewable(card: CardType): boolean {
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
}
async function fetchCardTypes() {
// Stale-While-Revalidate: only show skeleton on first load
// Subsequent refreshes silently update data in background
@@ -250,7 +270,15 @@ function goToAllCards() {
gap: 16rpx;
}
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
.card-name {
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
@@ -261,6 +289,18 @@ function goToAllCards() {
line-height: 1.2;
}
.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

@@ -33,8 +33,8 @@
</view>
<!-- Running low: thin accent strip -->
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} · 续卡保持节奏</text>
<view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
<text class="renew-strip-text">{{ renewStripText }}</text>
<text class="renew-strip-arrow"></text>
</view>
</template>
@@ -43,7 +43,7 @@
<view
v-else
class="entry-pill pill-expired"
@tap="scrollToCardShop"
@tap="handleRenew"
>
<view class="pill-dot dot-expired" />
<text class="pill-label">会员卡已到期</text>
@@ -91,6 +91,15 @@ function scrollToCardShop() {
emit('scroll-to-card-shop')
}
function handleRenew() {
const cardTypeId = userStore.renewalHint?.cardTypeId
if (cardTypeId) {
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
return
}
scrollToCardShop()
}
const activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships
if (!active.length) return ''
@@ -105,24 +114,19 @@ const activeMembershipLabel = computed(() => {
return `${cardName} · 剩余 ${daysLeft}`
})
const isRunningLow = computed(() => {
return userStore.activeMemberships.some(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
})
const lowestRemainingTimes = computed(() => {
const timesCards = userStore.activeMemberships.filter(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
if (!timesCards.length) return 0
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
const renewStripText = computed(() => {
const hint = userStore.renewalHint
if (!hint || !userStore.hasValidMembership) return ''
if (hint.kind === 'times_low') {
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
}
if (hint.kind === 'days_low') {
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
}
if (hint.kind === 'trial_low') {
return '体验课将尽 · 选购会员卡'
}
return ''
})
</script>
@@ -254,8 +258,8 @@ const lowestRemainingTimes = computed(() => {
}
.action-renew {
background: #e0e0e0;
.pill-action-text { color: #555; }
background: #e8efe9;
.pill-action-text { color: #5f7a6e; }
}
/* ── Renew strip (running low) ── */
@@ -266,21 +270,21 @@ const lowestRemainingTimes = computed(() => {
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
background: linear-gradient(135deg, #FF6B35, #FF8E53);
background: linear-gradient(135deg, #8fa89a, #a8c0b4);
border-radius: 24rpx;
}
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
color: #fff;
color: #fffcfa;
letter-spacing: 0.5rpx;
}
.renew-strip-arrow {
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 252, 250, 0.82);
line-height: 1;
}
</style>