perf: 支持课程补录

This commit is contained in:
richarjiang
2026-09-08 15:08:29 +08:00
parent d1f193e13e
commit b4b3caac70
24 changed files with 1074 additions and 945 deletions

View File

@@ -41,7 +41,10 @@
</view>
<view class="section section--practice">
<text class="section-label">上课情况</text>
<view class="section-heading">
<text class="section-label">上课情况</text>
<button class="supplement-entry" @tap="goSupplement">补录上课 <text></text></button>
</view>
<view class="stats-strip">
<view class="stat">
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
@@ -49,7 +52,7 @@
</view>
<view class="stat stat--completed">
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
<text class="stat-name">已完成</text>
<text class="stat-name">累计上课</text>
</view>
<view class="stat">
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
@@ -221,6 +224,10 @@ async function loadDetail() {
}
}
function goSupplement() {
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
}
function goEdit() {
if (!userId.value) return
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
@@ -321,6 +328,7 @@ onShow(() => {
.section--last { padding-bottom: 24rpx; }
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
.section-note { font-size: 22rpx; color: var(--muted); }
.stats-strip {
display: flex; padding: 26rpx 0;

View File

@@ -0,0 +1,211 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="补录上课" show-back />
<view v-if="loading && !detail" class="state">正在读取会员资料</view>
<view v-else-if="!detail" class="state"><text>{{ loadError || '暂时无法读取会员资料' }}</text><button @tap="load">重新加载</button></view>
<template v-else>
<view v-if="loadError" class="pending-note">{{ loadError }}<button class="reload" :disabled="loading || busy" @tap="load">刷新资料</button></view>
<view class="intro">
<text class="eyebrow">历史课程</text>
<text class="title">补齐每一节练习</text>
<text class="intro-note"> {{ detail.user.nickname || '这位学员' }} 补录已经上完的课程</text>
<view class="current"><text>累计上课</text><text>{{ detail.stats.completedBookings }} </text></view>
</view>
<view v-if="pending" class="pending-note">有一笔补录正在核对请点击下方重试系统会避免重复计入</view>
<view class="form-card">
<text class="label">补录节数</text>
<view class="quantity-row">
<input v-model="quantity" class="quantity-input" type="number" maxlength="3" placeholder="0" :disabled="busy || !!pending" :cursor-spacing="24" />
<text class="unit">节已上课程</text>
</view>
<view class="quick-values"><button v-for="n in [1, 5, 10, 20]" :key="n" :class="{ selected: Number(quantity) === n }" :disabled="busy || !!pending" @tap="quantity = String(n)">{{ n }} </button></view>
<text class="hint">可填写 1999 无需逐节创建预约</text>
</view>
<view class="form-card">
<text class="label">会员卡次数</text>
<view class="options">
<button class="option" :class="{ chosen: !deduct }" :disabled="busy || !!pending" @tap="deduct = false"><text class="option-title">仅补记录</text><text class="option-note">不改变会员卡余额</text></button>
<button class="option" :class="{ chosen: deduct }" :disabled="busy || !!pending" @tap="deduct = true"><text class="option-title">同时扣次</text><text class="option-note">从指定会员卡扣除</text></button>
</view>
<template v-if="deduct">
<text v-if="!cards.length" class="field-error">暂无可扣次的会员卡请选择仅补记录</text>
<picker v-else :range="cardLabels" :value="cardIndex" :disabled="busy || !!pending" @change="cardIndex = Number($event.detail.value)">
<view class="card-picker"><text>{{ cardLabels[cardIndex] || '请选择会员卡' }}</text><text></text></view>
</picker>
<text v-if="selectedCard && validQuantity" class="balance" :class="{ 'field-error': insufficient }">{{ insufficient ? '余额不足请调整节数或更换会员卡' : `扣除 ${count} 次后剩余 ${selectedCard.remainingTimes! - count} ` }}</text>
<text class="hint">过去已扣过的课程请选择仅补记录不限次卡无需扣次</text>
</template>
</view>
<view class="form-card">
<view class="label-row"><text class="label">备注</text><text class="optional">选填</text></view>
<textarea v-model="remark" class="remark" maxlength="200" placeholder="例如:系统启用前已完成的 10 节私教课" :disabled="busy || !!pending" :cursor-spacing="32" />
<text class="hint">{{ remark.length }} / 200</text>
</view>
<view class="summary">
<text class="summary-title">{{ validQuantity ? `累计上课将增加 ${count}` : '填写已完成的课程节数' }}</text>
<text class="hint">记录显示为系统补录不计入本月上课练习天数与最近 30 天网格</text>
</view>
<view class="submit-wrap"><button class="submit" :disabled="busy || loading || !!loadError || (!pending && !canSubmit)" :loading="busy" @tap="submit">{{ busy ? '正在处理' : pending ? '核对并重试补录' : '确认补录' }}</button><text v-if="submitError" class="field-error">{{ submitError }}</text></view>
<view class="history">
<view class="label-row"><text class="label">补录记录</text><text class="optional">{{ records.length }} </text></view>
<LessonSupplementList v-if="records.length" :records="records" editable :busy="busy || !!pending" @revoke="revoke" />
<text v-else class="empty">还没有补录记录</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { useAdminStore } from '../../stores/admin'
import { getSystemLayout } from '../../utils/system'
import { HttpRequestError } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
const store = useAdminStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const userId = ref('')
const detail = ref<AdminMemberDetail | null>(null)
const records = ref<LessonSupplementRecord[]>([])
const loading = ref(false)
const busy = ref(false)
const loadError = ref('')
const submitError = ref('')
const quantity = ref('')
const remark = ref('')
const deduct = ref(false)
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 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)
const insufficient = computed(() => !!selectedCard.value && count.value > selectedCard.value.remainingTimes!)
const canSubmit = computed(() => validQuantity.value && (!deduct.value || (!!selectedCard.value && !insufficient.value)))
function clearPending() {
pending.value = null
uni.removeStorageSync(storageKey.value)
}
async function load() {
if (!userId.value) { loadError.value = '缺少会员信息,请返回会员档案'; return }
loading.value = true
loadError.value = ''
try {
const [member, items] = await Promise.all([store.fetchMemberDetail(userId.value), store.fetchLessonSupplements(userId.value)])
detail.value = member
records.value = items
if (pending.value && items.some(item => item.requestId === pending.value!.requestId)) {
clearPending()
quantity.value = ''
remark.value = ''
submitError.value = ''
}
} catch (err) {
loadError.value = getErrorMessage(err, '加载失败,请重试')
if (detail.value) uni.showToast({ title: loadError.value, icon: 'none' })
} finally { loading.value = false }
}
async function submit() {
if (busy.value || loading.value || loadError.value || (!pending.value && !canSubmit.value)) return
busy.value = true
submitError.value = ''
try {
if (!pending.value) {
const result = await uni.showModal({
title: `补录 ${count.value} 节已上课程`,
content: `学员:${detail.value?.user.nickname || '未命名学员'}${deduct.value ? `从「${selectedCard.value.cardType.name}」扣除 ${count.value} 次,剩余 ${selectedCard.value.remainingTimes! - count.value} 次。` : '仅增加上课记录,不扣会员卡次数。'}`,
confirmText: '确认补录', confirmColor: '#617d73',
})
if (!result.confirm) return
const payload: CreateLessonSupplementDto = {
requestId: `supp_${Date.now()}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`,
quantity: count.value, ...(deduct.value ? { membershipId: selectedCard.value.id } : {}),
...(remark.value.trim() ? { remark: remark.value.trim() } : {}),
}
// Retain the exact request across timeouts, navigation and application restarts.
uni.setStorageSync(storageKey.value, payload)
pending.value = payload
}
const record = await store.createLessonSupplement(userId.value, pending.value)
if (!records.value.some(item => item.id === record.id)) records.value = [record, ...records.value]
clearPending()
quantity.value = ''
remark.value = ''
uni.showToast({ title: `已补录 ${record.quantity} 节课`, icon: 'success' })
await load()
} catch (err) {
if (err instanceof HttpRequestError && err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 408) clearPending()
submitError.value = getErrorMessage(err, '提交失败,请重试')
if (pending.value) submitError.value = '暂未确认提交结果,请核对并重试,不会重复补录。'
} finally { busy.value = false }
}
async function revoke(item: LessonSupplementRecord) {
if (busy.value || pending.value) return
busy.value = true
try {
const result = await uni.showModal({ title: '撤销这笔补录?', content: `累计上课减少 ${item.quantity} 节。${item.deductedTimes ? `向原会员卡返还 ${item.deductedTimes} 次。` : '会员卡余额不变。'}记录会保留为已撤销。`, confirmText: '确认撤销', confirmColor: '#967561' })
if (!result.confirm) return
await store.revokeLessonSupplement(userId.value, item.id)
uni.showToast({ title: '已撤销补录', icon: 'success' })
await load()
} catch (err) { uni.showToast({ title: getErrorMessage(err, '撤销失败,请重试'), icon: 'none' }) }
finally { busy.value = false }
}
onLoad(query => {
userId.value = String(query?.userId || '')
const saved = uni.getStorageSync(storageKey.value) as CreateLessonSupplementDto | undefined
if (saved?.requestId && Number.isInteger(saved.quantity)) {
pending.value = saved
quantity.value = String(saved.quantity)
remark.value = saved.remark || ''
deduct.value = !!saved.membershipId
}
load().then(() => {
if (pending.value?.membershipId) cardIndex.value = cards.value.findIndex(c => c.id === pending.value!.membershipId)
})
})
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; background: #fbf9f6; color: #514943; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
.intro { margin: 28rpx 32rpx; padding: 32rpx; border-radius: 30rpx; background: #f2e9e3; }
.eyebrow { display: block; font-size: 21rpx; color: #967c69; }
.title { display: block; margin-top: 14rpx; font-size: 38rpx; font-family: 'Songti SC', 'STSong', serif; }
.intro-note { display: block; margin-top: 16rpx; font-size: 24rpx; line-height: 1.7; color: #87786b; overflow-wrap: anywhere; }
.current { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 26rpx; padding-top: 22rpx; border-top: 1rpx solid #e4d8cf; font-size: 24rpx; color: #78675c; }
.form-card { margin: 24rpx 32rpx; padding: 28rpx; border-radius: 26rpx; border: 1rpx solid #eee8e3; background: #fff; }
.label { font-size: 27rpx; font-weight: 500; }
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.optional { font-size: 21rpx; color: #9a9086; }
.quantity-row { display: flex; align-items: baseline; gap: 20rpx; margin-top: 20rpx; }
.quantity-input { width: 180rpx; min-width: 0; height: 92rpx; font-size: 64rpx; color: #617d73; font-variant-numeric: tabular-nums; }
.unit { font-size: 24rpx; color: #8b817b; }
.quick-values { display: flex; gap: 14rpx; margin-top: 20rpx; button { flex: 1; padding: 0; margin: 0; border-radius: 14rpx; font-size: 24rpx; line-height: 64rpx; background: #f5f3ef; color: #81756b; &::after { border: none; } &.selected { background: #e8efe7; color: #617d73; } } }
.hint { display: block; margin-top: 18rpx; color: #92867b; font-size: 22rpx; line-height: 1.7; }
.options { display: flex; gap: 16rpx; margin-top: 22rpx; }
.option { flex: 1; min-width: 0; margin: 0; padding: 22rpx 12rpx; text-align: left; border-radius: 18rpx; border: 2rpx solid #eee8e3; background: #fff; line-height: 1.5; &::after { border: none; } &.chosen { border-color: #a6b8a9; background: #eff3ed; } }
.option-title { display: block; font-size: 26rpx; color: #617d73; }
.option-note { display: block; margin-top: 8rpx; font-size: 20rpx; color: #8b817b; }
.card-picker { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; padding: 20rpx; border-radius: 16rpx; background: #f6f3ef; font-size: 24rpx; line-height: 1.6; }
.balance { display: block; margin-top: 16rpx; font-size: 23rpx; color: #617d73; }
.remark { width: 100%; height: 136rpx; box-sizing: border-box; padding: 18rpx; border-radius: 16rpx; background: #faf8f5; font-size: 25rpx; line-height: 1.6; }
.summary { margin: 28rpx 40rpx; }
.summary-title { font-size: 26rpx; color: #617d73; }
.submit-wrap { margin: 28rpx 32rpx; }
.submit { display: block; width: 100%; padding: 0; margin: 0; line-height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
.history { margin: 44rpx 32rpx 0; }
.empty { display: block; padding: 36rpx 28rpx; border-radius: 24rpx; background: #f3f0eb; font-size: 24rpx; color: #8b817b; }
.field-error { display: block; margin-top: 18rpx; color: #a36e59; font-size: 23rpx; line-height: 1.6; }
.pending-note { margin: 24rpx 32rpx; padding: 24rpx; background: #f2e9e3; color: #8b7160; border-radius: 20rpx; font-size: 24rpx; line-height: 1.7; }
.reload { margin: 16rpx 0 0; font-size: 24rpx; line-height: 2; background: #e5eade; color: #617d73; &::after { border: none; } }
.state { padding: 60rpx 32rpx; font-size: 26rpx; color: #8b817b; text-align: center; button { margin-top: 24rpx; font-size: 26rpx; } }
</style>

View File

@@ -105,6 +105,12 @@
:refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory"
>
<view v-if="supplements.length || supplementsError || supplementsLoading" class="supplement-history">
<text class="supplement-heading">历史课程补录</text>
<text v-if="supplementsLoading" class="supplement-message">正在加载补录记录</text>
<button v-else-if="supplementsError" class="supplement-retry" @tap="fetchSupplements">补录记录加载失败点击重试</button>
<LessonSupplementList v-else :records="supplements" />
</view>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card">
@@ -117,7 +123,7 @@
</view>
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<view v-else-if="historyBookings.length === 0 && !supplements.length && !supplementsLoading && !supplementsError" class="empty-wrap">
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
@@ -164,13 +170,26 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import type { BookingWithDetails } from '@mp-pilates/shared'
import type { BookingWithDetails, LessonSupplementRecord } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { formatDate, getWeekdayLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { get } from '../../utils/request'
const bookingStore = useBookingStore()
const supplements = ref<LessonSupplementRecord[]>([])
const supplementsLoading = ref(false)
const supplementsError = ref(false)
async function fetchSupplements() {
supplementsLoading.value = true
supplementsError.value = false
try { supplements.value = await get<LessonSupplementRecord[]>('/user/lesson-supplements') }
catch { supplementsError.value = true }
finally { supplementsLoading.value = false }
}
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
@@ -310,7 +329,7 @@ async function onRefreshUpcoming() {
async function onRefreshHistory() {
refreshingHistory.value = true
await bookingStore.fetchMyBookings()
await Promise.all([bookingStore.fetchMyBookings(), fetchSupplements()])
refreshingHistory.value = false
}
@@ -351,6 +370,7 @@ async function handleCancel(booking: BookingWithDetails) {
// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => {
fetchSupplements()
const windowInfo = uni.getWindowInfo()
const statusBarH = windowInfo.statusBarHeight ?? 20
navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px`
@@ -366,11 +386,17 @@ onMounted(() => {
// for any state we didn't locally patch (e.g. server-side cascading fields).
onShow(() => {
if (!hasLoadedOnce.value) return
fetchSupplements()
bookingStore.fetchMyBookings(undefined, { silent: true })
})
</script>
<style lang="scss" scoped>
.supplement-history { padding: 28rpx 24rpx 0; }
.supplement-heading { display: block; margin-bottom: 20rpx; font-size: 26rpx; color: #786b61; }
.supplement-message, .supplement-retry { font-size: 24rpx; color: #8b817b; }
.supplement-retry { padding: 24rpx; line-height: 1.6; background: #f2e9e3; border-radius: 20rpx; &::after { border: none; } }
.bookings-page {
min-height: 100vh;
background: #f5f3f0;

View File

@@ -1,112 +1,62 @@
<template>
<view class="info-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar
:title="isFromLogin ? '完善个人信息' : '个人信息'"
:show-back="!isFromLogin"
/>
<!-- First-login welcome banner -->
<CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
<view v-if="isFromLogin" class="welcome-banner">
<view class="welcome-content">
<view class="welcome-text">
<text class="welcome-title">欢迎加入</text>
<text class="welcome-desc">设置你的头像和昵称让大家认识你</text>
</view>
</view>
<text class="welcome-title">欢迎来到工作室</text>
<text class="welcome-desc">设置头像和昵称方便老师认识你</text>
</view>
<!-- Avatar section -->
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<view class="avatar-section">
<button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap">
<image
v-if="displayAvatarUrl"
class="avatar"
:src="displayAvatarUrl"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
</view>
<!-- Upload hint overlay -->
<view class="avatar-overlay">
<text class="avatar-overlay-text">点击更换</text>
</view>
<image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
<view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
</view>
<view class="avatar-copy">
<text class="avatar-title">头像</text>
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
</view>
<text class="avatar-arrow"></text>
</button>
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
<text class="avatar-hint">点击头像选择微信头像</text>
<text class="avatar-hint">头像更换后自动保存</text>
</view>
<!-- Form fields -->
<view class="form-card">
<!-- Nickname (editable) -->
<view class="form-row">
<text class="form-label">昵称</text>
<input
class="form-input"
type="nickname"
v-model="form.nickname"
placeholder="点击右侧按钮获取微信昵称"
placeholder-style="color: #ccc"
maxlength="20"
:disabled="saving"
/>
<text class="form-arrow"></text>
</view>
<!-- Phone (hide in first-login mode) -->
<view v-if="!isFromLogin" class="form-row form-row--last">
<text class="form-label">手机号</text>
<!-- Phone set: display masked -->
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<!-- Phone not set: bind button -->
<button
v-else
class="bind-phone-btn"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhone"
>
<text class="bind-phone-text">绑定手机号</text>
</button>
<view class="form-section">
<text class="section-title">基本资料</text>
<view class="form-card">
<view class="nickname-field">
<view class="field-heading">
<text class="form-label">昵称</text>
<text class="field-count">{{ form.nickname.length }} / 20</text>
</view>
<input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
</view>
<view v-if="!isFromLogin" class="form-row">
<text class="form-label">手机号</text>
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
</view>
</view>
</view>
<!-- Read-only info card (hide in first-login mode) -->
<view v-if="!isFromLogin" class="info-card">
<view class="info-row">
<text class="info-label">注册时间</text>
<text class="info-value">{{ joinDateDisplay }}</text>
</view>
<view class="info-row info-row--last">
<text class="info-label">会员卡数量</text>
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
</view>
<view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
<view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} </text></view>
</view>
<!-- Save button -->
<view class="save-wrap">
<view
class="save-btn"
:class="{
'save-btn--loading': saving,
'save-btn--disabled': !isFromLogin && (!isDirty || saving),
}"
@tap="handleSave"
>
<text class="save-btn-text">
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
</text>
</view>
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
<button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
{{ saving ? '保存中' : isFromLogin ? '保存并进入' : '保存修改' }}
</button>
<text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后请点击保存' : '修改昵称后即可保存' }}</text>
<button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '../../stores/user'
import { wxBindPhone } from '../../utils/auth'
@@ -125,7 +75,7 @@ onLoad((query) => {
})
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
// ─── Form state ───────────────────────────────────────────
const form = ref({
@@ -134,6 +84,11 @@ const form = ref({
const originalNickname = ref('')
const saving = ref(false)
const uploadingAvatar = ref(false)
const bindingPhone = ref(false)
const initializing = ref(true)
const avatarFailed = ref(false)
const busy = computed(() => saving.value || uploadingAvatar.value || bindingPhone.value || initializing.value)
const saveDisabled = computed(() => busy.value || (!isFromLogin.value && !isDirty.value))
// ─── Computed ─────────────────────────────────────────────
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
@@ -165,26 +120,19 @@ const activeMembershipCount = computed(
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
)
// ─── Default avatar ───────────────────────────────────────
const defaultAvatarUrl = computed(() => {
const nickname = form.value.nickname || 'user'
// 使用 dicebear 生成基于昵称的随机头像
return `https://api.dicebear.com/7.x/identicon/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=c9a87c,e8c88a`
})
const displayAvatarUrl = computed(() => {
return avatarUrl.value || defaultAvatarUrl.value
})
// Use a local fallback when no avatar is set or the remote image fails.
const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
watch(avatarUrl, () => { avatarFailed.value = false })
// ─── Avatar upload ────────────────────────────────────────
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
const { avatarUrl } = e.detail
if (!avatarUrl) return
if (!avatarUrl || busy.value) return
uploadingAvatar.value = true
try {
await userStore.updateProfile({ avatarUrl })
await userStore.fetchProfile()
avatarFailed.value = false
uni.showToast({ title: '头像更新成功', icon: 'success' })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '更新失败,请重试'
@@ -198,32 +146,32 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
async function handleGetPhone(e: {
detail: { encryptedData: string; iv: string; errMsg: string }
}) {
if (busy.value) return
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
// User denied or cancelled
return
}
bindingPhone.value = true
uni.showLoading({ title: '绑定中...' })
try {
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
// Refresh store with updated profile
await userStore.fetchProfile()
uni.hideLoading()
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
// Sync nickname from updated profile
if (updated.nickname) {
form.value = { nickname: updated.nickname }
originalNickname.value = updated.nickname
}
// Keep the local nickname draft while refreshing the phone information.
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
bindingPhone.value = false
}
}
// ─── Save ─────────────────────────────────────────────────
async function handleSave() {
if (saving.value) return
if (busy.value) return
// In first-login mode, allow saving even if not dirty (user may just want to proceed)
if (!isFromLogin.value && !isDirty.value) return
@@ -262,6 +210,7 @@ async function handleSave() {
// ─── Skip (first-login only) ─────────────────────────────
function handleSkip() {
if (busy.value) return
uni.showModal({
title: '确认跳过?',
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
@@ -278,312 +227,58 @@ function handleSkip() {
// ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
if (!isFromLogin.value) {
await userStore.fetchProfile()
}
if (userStore.user) {
form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
try {
if (!isFromLogin.value) {
await userStore.fetchProfile()
}
if (userStore.user) {
form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
}
} finally {
initializing.value = false
}
})
</script>
<style lang="scss" scoped>
.info-page {
min-height: 100vh;
background: $bg-page;
}
/* ── Welcome banner (first-login) ───────────────────── */
.welcome-banner {
position: relative;
margin: 0 $spacing-lg $spacing-md;
padding: 36rpx 32rpx;
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
border-radius: $radius-lg;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -40rpx;
right: -20rpx;
width: 180rpx;
height: 180rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
pointer-events: none;
}
}
.welcome-content {
position: relative;
z-index: 1;
}
.welcome-text {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.welcome-title {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.welcome-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
}
/* ── Avatar section ──────────────────────────────────── */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 56rpx 0 40rpx;
background: $bg-card;
margin-bottom: $spacing-md;
border-bottom: 1rpx solid $border-color;
&--welcome {
padding-top: 40rpx;
}
}
.info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
.welcome-banner { margin: 28rpx 32rpx 0; }
.welcome-title { display: block; font-size: 32rpx; font-weight: 500; }
.welcome-desc { display: block; margin-top: 12rpx; font-size: 24rpx; color: #8b817b; line-height: 1.7; }
.avatar-section { margin: 28rpx 32rpx 0; padding: 28rpx; border-radius: 28rpx; background: #f2e9e3; }
.avatar-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
&::after {
border: none;
}
}
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin-bottom: 16rpx;
}
.avatar {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid $border-color;
}
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $brand-color, $accent-color);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder-text {
font-size: 64rpx;
font-weight: 700;
color: #fff;
}
.avatar-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52rpx;
background: rgba(0, 0, 0, 0.45);
border-radius: 0 0 80rpx 80rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.avatar-overlay-text {
font-size: 20rpx;
color: #ffffff;
letter-spacing: 1rpx;
}
.avatar-name {
font-size: 34rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 6rpx;
}
.avatar-hint {
font-size: 22rpx;
color: $text-hint;
}
/* ── Form card ───────────────────────────────────────── */
.form-card {
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-md;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba($border-color, 0.5);
min-height: 100rpx;
&--last {
border-bottom: none;
}
}
.form-label {
font-size: 28rpx;
color: $text-secondary;
width: 120rpx;
flex-shrink: 0;
font-weight: 500;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: $text-primary;
text-align: right;
background: transparent;
min-height: 44rpx;
}
.form-value {
flex: 1;
font-size: 28rpx;
color: $text-hint;
text-align: right;
}
.form-arrow {
font-size: 36rpx;
color: $text-hint;
margin-left: 8rpx;
line-height: 1;
}
/* Bind phone button (styled, not default wx button) */
.bind-phone-btn {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
background: transparent;
border: none;
padding: 0;
margin: 0;
line-height: normal;
/* reset uni button default styles */
&::after {
border: none;
}
}
.bind-phone-text {
font-size: 26rpx;
color: $accent-color;
font-weight: 600;
text-decoration: underline;
}
/* ── Read-only info card ──────────────────────────────── */
.info-card {
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-lg;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid rgba($border-color, 0.5);
&--last {
border-bottom: none;
}
}
.info-label {
font-size: 26rpx;
color: $text-hint;
}
.info-value {
font-size: 26rpx;
color: $text-secondary;
font-weight: 500;
}
/* ── Save button ─────────────────────────────────────── */
.save-wrap {
padding: 8rpx $spacing-lg 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, $brand-color, #5e5045);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba($brand-color, 0.25);
transition: all 0.25s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba($brand-color, 0.2);
}
&--loading,
&--disabled {
opacity: 0.45;
box-shadow: none;
}
}
.save-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.skip-text {
font-size: 26rpx;
color: $text-hint;
padding: 8rpx 24rpx;
&:active {
opacity: 0.6;
}
display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
height: auto; margin: 0; padding: 0; border: none; background: transparent;
border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
&::after { border: none; }
&[disabled] { background: transparent; color: #8b817b; }
}
.avatar-wrap { width: 120rpx; height: 120rpx; flex-shrink: 0; border-radius: 50%; overflow: hidden; box-sizing: border-box; border: 5rpx solid #fcf8f4; }
.avatar, .avatar-placeholder { display: block; width: 100%; height: 100%; }
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: #e0cec4; color: #7b6254; font-size: 44rpx; font-weight: 400; line-height: 1; }
.avatar-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.avatar-title { font-size: 30rpx; font-weight: 500; }
.avatar-action { font-size: 24rpx; color: #8b7160; }
.avatar-arrow { flex-shrink: 0; font-size: 32rpx; color: #a38d7d; }
.avatar-hint { display: block; margin-top: 20rpx; font-size: 21rpx; color: #9a8577; }
.form-section { margin: 32rpx 32rpx 0; }
.section-title { display: block; margin-bottom: 18rpx; font-size: 28rpx; font-weight: 500; }
.form-card { padding: 0 28rpx; background: #fff; border: 1rpx solid #eee8e3; border-radius: 28rpx; }
.nickname-field { padding: 26rpx 0 20rpx; }
.field-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.form-label { flex-shrink: 0; font-size: 26rpx; color: #786b61; }
.field-count { font-size: 20rpx; color: #a2978e; }
.form-input { display: block; box-sizing: border-box; width: 100%; min-width: 0; height: 76rpx; margin-top: 12rpx; padding: 0 18rpx; border-radius: 14rpx; background: #faf8f5; font-size: 27rpx; color: #514943; line-height: normal; text-align: left; }
.form-row { display: flex; align-items: center; gap: 24rpx; min-height: 104rpx; border-top: 1rpx solid #f0ebe6; }
.form-value { flex: 1; min-width: 0; text-align: right; font-size: 26rpx; color: #6f655e; }
.bind-phone-btn { margin: 0 0 0 auto; padding: 0 20rpx; height: 60rpx; line-height: 60rpx; flex-shrink: 0; border: none; border-radius: 999rpx; background: #edf2ec; color: #617d73; font-size: 24rpx; &::after { border: none; } }
.info-card { margin: 24rpx 32rpx 0; padding: 8rpx 28rpx; border-radius: 26rpx; background: #f3f0eb; }
.info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; padding: 18rpx 0; }
.info-label { flex-shrink: 0; font-size: 23rpx; color: #8b817b; }
.info-value { font-size: 23rpx; color: #786b61; text-align: right; }
.save-wrap { margin: 32rpx 32rpx 0; }
.save-btn { display: block; width: 100%; height: 88rpx; line-height: 88rpx; margin: 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; font-size: 28rpx; font-weight: 400; color: #fff; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
.save-hint { display: block; margin-top: 18rpx; font-size: 21rpx; color: #9a9086; text-align: center; }
.skip-btn { text-align: center; margin: 16rpx auto 0; padding: 8rpx 24rpx; width: auto; border: none; background: transparent; font-size: 24rpx; color: #8b817b; &::after { border: none; } }
</style>

View File

@@ -1,162 +1,95 @@
<template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="loading && !refreshing" class="loading-wrap">
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" />
</view>
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
<view class="empty-card">
<view class="empty-deco empty-deco--1" />
<view class="empty-deco empty-deco--2" />
<text class="empty-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">去选购</text>
</view>
<text class="empty-sub">选一张适合自己的卡开始第一次练习</text>
<button class="empty-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
<!-- Membership list -->
<view v-else class="list">
<!-- Active cards -->
<view v-if="activeMemberships.length > 0" class="group-section">
<view v-if="activeMemberships.length" class="group-section">
<view class="group-header">
<text class="group-title">有效会员卡</text>
<text class="group-count">{{ activeMemberships.length }} </text>
<text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 有效卡</text>
</view>
<view
v-for="m in activeMemberships"
:key="m.id"
class="mc"
:class="cardBgClass(m.cardType.type)"
>
<!-- Decorative circles -->
<view class="mc-deco mc-deco--1" />
<view class="mc-deco mc-deco--2" />
<!-- Top row: name + status -->
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<view class="mc-status mc-status--active">
<view class="mc-status-dot" />
<text class="mc-status-text">有效</text>
<text class="mc-status mc-status--active">有效</text>
</view>
<view class="mc-balance">
<view class="mc-number-row">
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
</view>
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} </text>
</view>
</view>
<!-- Center: highlight number (times card) -->
<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="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track">
<view
class="mc-progress-fill"
:style="{ width: getMembershipProgressWidth(m) }"
/>
</view>
<text class="mc-progress-label">
已用 {{ getMembershipUsedTimes(m) }} {{ getMembershipTotalTimes(m) }}
</text>
</view>
</view>
<!-- Center: duration card (no times) -->
<view v-else class="mc-center">
<text class="mc-big-num">{{ daysRemaining(m) }}</text>
<text class="mc-big-unit">天剩余</text>
</view>
<!-- Bottom: dates -->
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始</text>
<text class="mc-date-label">开始日期</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-sep" />
<view class="mc-date-item">
<text class="mc-date-label">到期</text>
<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>
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="mc mc--inactive"
>
<view class="mc-deco mc-deco--1" />
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="mc-status" :class="inactiveStatusClass(m.status)">
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
</view>
</view>
<view class="mc-inactive-info">
<view v-if="m.remainingTimes !== null" class="mc-date-item">
<text class="mc-date-label">剩余</text>
<text class="mc-date-value">{{ m.remainingTimes }} </text>
</view>
<view class="mc-date-item">
<view class="mc-date-item mc-date-item--end">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
</view>
</view>
<view v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap.stop="goRenew(m)">
<text class="mc-renew-text">续同款</text>
<view v-if="inactiveMemberships.length" class="group-section">
<view class="group-header">
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
<view v-for="m in inactiveMemberships" :key="m.id" class="mc mc--inactive">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<text class="mc-status" :class="inactiveStatusClass(m.status)">{{ statusLabel(m.status) }}</text>
</view>
<view class="mc-history-bottom">
<view class="mc-history-info">
<text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} </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>
</view>
</view>
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-text">+ 购买会员卡</text>
<view v-if="allMemberships.length" class="purchase-dock">
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { onShow, onResize } from '@dcloudio/uni-app'
import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
@@ -166,7 +99,9 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
const navBarHeight = ref('64px')
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = ref(false)
const refreshing = ref(false)
@@ -218,8 +153,15 @@ async function loadMemberships() {
async function onRefresh() {
refreshing.value = true
await userStore.fetchMemberships()
refreshing.value = false
try {
await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
}
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goStore() {
@@ -235,408 +177,56 @@ function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
})
onShow(loadMemberships)
</script>
<style lang="scss" scoped>
.membership-page {
min-height: 100vh;
background: $bg-page;
}
.scroll {
height: 100vh;
}
/* ── Loading ─────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.skeleton-card {
height: 320rpx;
border-radius: 24rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ────────────────────────────────── */
.empty-wrap {
padding: 80rpx 24rpx;
}
.empty-card {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #E8D5C4, #D8C8DC);
border-radius: 24rpx;
padding: 64rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.empty-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 200rpx;
height: 200rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 140rpx;
height: 140rpx;
bottom: -40rpx;
left: -20rpx;
background: rgba(255, 255, 255, 0.2);
}
}
.empty-title {
font-size: 34rpx;
font-weight: 700;
color: $brand-color;
z-index: 1;
}
.empty-sub {
font-size: 26rpx;
color: $text-secondary;
z-index: 1;
}
.empty-btn {
margin-top: 16rpx;
padding: 20rpx 56rpx;
border-radius: 40rpx;
background: rgba(74, 64, 53, 0.12);
z-index: 1;
&:active { background: rgba(74, 64, 53, 0.18); }
}
.empty-btn-text {
font-size: 28rpx;
color: $brand-color;
font-weight: 600;
}
/* ── List ─────────────────────────────────── */
.list {
padding: 16rpx 24rpx 0;
}
/* ── Group ────────────────────────────────── */
.group-section {
margin-bottom: 16rpx;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 8rpx 16rpx;
}
.group-title {
font-size: 28rpx;
color: $text-primary;
font-weight: 600;
}
.group-count {
font-size: 22rpx;
color: $text-hint;
}
/* ══════════════════════════════════════════════
MEMBERSHIP CARD (mc)
══════════════════════════════════════════════ */
.mc {
position: relative;
overflow: hidden;
border-radius: 24rpx;
padding: 28rpx 32rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* Card type backgrounds */
.mc--times {
background: linear-gradient(135deg, #EDE0D4 0%, #E2D2C2 100%);
box-shadow: 0 4rpx 20rpx rgba(212, 191, 168, 0.3);
}
.mc--duration {
background: linear-gradient(135deg, #E0D4E4 0%, #D4C6DA 100%);
box-shadow: 0 4rpx 20rpx rgba(196, 174, 203, 0.3);
}
.mc--trial {
background: linear-gradient(135deg, #D4E2DC 0%, #C6D8D0 100%);
box-shadow: 0 4rpx 20rpx rgba(169, 196, 188, 0.3);
}
.mc--inactive {
background: linear-gradient(135deg, #E8E4E0, #DDD9D5);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.12);
opacity: 0.75;
gap: 16rpx;
}
/* Decorative circles */
.mc-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 180rpx;
height: 180rpx;
top: -50rpx;
right: -30rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 120rpx;
height: 120rpx;
bottom: -30rpx;
left: 40rpx;
background: rgba(255, 255, 255, 0.2);
}
}
/* ── Top row ──────────────────────────────── */
.mc-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
z-index: 1;
}
.mc-name-area {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.mc-name {
font-size: 34rpx;
font-weight: 700;
color: #2C2420;
line-height: 1.2;
}
.mc-type-tag {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 10rpx;
background: rgba(44, 36, 32, 0.1);
}
.mc-type-text {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.6);
font-weight: 500;
}
/* Status */
.mc-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 16rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.mc-status--active {
background: rgba(122, 158, 126, 0.18);
}
.mc-status--expired,
.mc-status--used {
background: rgba(74, 64, 53, 0.08);
}
.mc-status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $success-color;
}
.mc-status-text {
font-size: 22rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Center: big number ───────────────────── */
.mc-center {
display: flex;
flex-direction: column;
align-items: center;
padding: 8rpx 0;
z-index: 1;
}
.mc-big-num {
font-size: 80rpx;
font-weight: 800;
color: #2C2420;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.mc-big-unit {
font-size: 24rpx;
color: rgba(44, 36, 32, 0.55);
font-weight: 500;
margin-top: 4rpx;
}
/* Progress */
.mc-progress {
width: 100%;
max-width: 400rpx;
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.mc-progress-track {
height: 10rpx;
background: rgba(44, 36, 32, 0.1);
border-radius: 5rpx;
overflow: hidden;
}
.mc-progress-fill {
height: 100%;
background: rgba(44, 36, 32, 0.35);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.mc-progress-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.45);
text-align: center;
}
/* ── Bottom: dates ────────────────────────── */
.mc-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
z-index: 1;
padding-top: 4rpx;
border-top: 1rpx solid rgba(44, 36, 32, 0.1);
}
.mc-date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.mc-date-sep {
width: 1rpx;
height: 40rpx;
background: rgba(44, 36, 32, 0.12);
flex-shrink: 0;
}
.mc-date-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.4);
}
.mc-date-value {
font-size: 24rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Inactive info ────────────────────────── */
.mc-inactive-info {
display: flex;
gap: 40rpx;
padding-left: 4rpx;
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;
bottom: calc(32rpx + env(safe-area-inset-bottom));
right: 32rpx;
background: $brand-color;
border-radius: 44rpx;
padding: 22rpx 36rpx;
box-shadow: 0 6rpx 24rpx rgba(74, 64, 53, 0.25);
z-index: 100;
&:active { opacity: 0.85; }
}
.fab-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
letter-spacing: 1rpx;
}
/* ── Spacer ───────────────────────────────── */
.scroll-bottom-spacer {
height: 140rpx;
}
.membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
.scroll { flex: 1; height: 0; min-height: 0; }
.loading-wrap { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 24rpx; }
.skeleton-card { height: 420rpx; border-radius: 28rpx; background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
.empty-wrap { padding: 40rpx 32rpx; }
.empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
.empty-title { font-size: 32rpx; font-weight: 500; }
.empty-sub { font-size: 24rpx; color: #8b7b70; line-height: 1.7; }
.empty-btn { margin: 12rpx 0 0; padding: 0 32rpx; height: 76rpx; line-height: 76rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; &::after { border: none; } }
.list { padding: 28rpx 32rpx 0; }
.group-section + .group-section { margin-top: 36rpx; }
.group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; }
.mc-book { background: #6b8276; color: #fff; }
.mc--inactive { background: #f4f1ed; border-color: #eee8e3; .mc-name { font-size: 27rpx; font-weight: 400; color: #786d64; } }
.mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
.mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
.mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
.scroll-bottom-spacer { height: 20rpx; }
</style>