perf: 支持课程补录
This commit is contained in:
@@ -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;
|
||||
|
||||
211
packages/app/src/pages/admin/member-supplement.vue
Normal file
211
packages/app/src/pages/admin/member-supplement.vue
Normal 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">可填写 1–999 节,无需逐节创建预约</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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user