feat: add monthly card periods and apparatus lesson feedback

This commit is contained in:
richarjiang
2026-10-18 15:47:29 +08:00
parent b452de379d
commit a3d58fa8f2
40 changed files with 968 additions and 170 deletions

View File

@@ -0,0 +1,16 @@
<template>
<view v-if="entries.length" class="apparatus-notes">
<view class="notes-heading"><text>器械练习</text><text class="count">{{ entries.length }} 项记录</text></view>
<view v-for="entry in entries" :key="entry.apparatus" class="apparatus-note"><text class="apparatus-label">{{ entry.apparatus }}</text><text class="apparatus-copy">{{ entry.content }}</text></view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { filledApparatusNotes } from '@mp-pilates/shared'
import type { ApparatusNotes } from '@mp-pilates/shared'
const props = defineProps<{ notes?: ApparatusNotes | null }>()
const entries = computed(() => filledApparatusNotes(props.notes))
</script>
<style scoped>
.apparatus-notes{margin:24rpx 0 20rpx;padding-top:22rpx;border-top:1rpx solid #e8e3d9;}.notes-heading{display:flex;align-items:center;justify-content:space-between;color:#8a8275;font-size:22rpx;margin-bottom:8rpx;}.count{font-size:20rpx;color:#a0978a;}.apparatus-note{padding:20rpx 0;border-bottom:1rpx solid #eee9e1;}.apparatus-note:last-child{border:0;padding-bottom:0;}.apparatus-label{display:inline-block;min-width:52rpx;border-radius:8rpx;padding:4rpx 12rpx;background:#eaf0e5;color:#60735b;font-family:'Baskerville','Songti SC',serif;font-size:26rpx;text-align:center;}.apparatus-copy{display:block;margin-top:12rpx;font-size:27rpx;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere;color:#5e574d;}
</style>

View File

@@ -44,6 +44,7 @@
<view class="card-item selected">
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text v-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
剩余 {{ memberships[0].remainingTimes }} 次
</text>
@@ -68,6 +69,7 @@
>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text v-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }} 次
</text>
@@ -92,7 +94,7 @@
<text class="deduction-text">
{{ selectedMembership.remainingTimes === null
? `「${selectedMembership.cardType.name}」有效期内不限次`
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
: `教练确认后将从「${selectedMembership.cardType.name}」对应周期扣除 1 次课时` }}
</text>
</view>
@@ -124,6 +126,7 @@
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
import { formatChinaDate } from '../utils/format'
const props = defineProps<{
visible: boolean

View File

@@ -0,0 +1,99 @@
<template>
<view class="note-overlay" :class="{ leaving: closing }" @tap="requestClose" @touchmove.stop.prevent>
<view class="note-sheet" @tap.stop @touchmove.stop>
<view class="sheet-header">
<view class="handle" />
<view class="title-row">
<view><text class="eyebrow">AFTER CLASS · 练习手记</text><text class="title">记录这一课的进步</text></view>
<button class="close" :disabled="busy || closing" aria-label="关闭课后评价" @tap="requestClose">×</button>
</view>
<text class="subtitle">{{ bookingId ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录</text>
</view>
<scroll-view scroll-y class="sheet-body" :show-scrollbar="false">
<view class="body-inner">
<view class="section-heading"><text>整体评价</text><text class="optional">选填</text></view>
<view class="summary-input">
<textarea v-model="content" aria-label="整体评价(选填)" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" placeholder="本次练习的整体表现、进步或下次建议…" placeholder-class="note-placeholder" />
<text class="counter">{{ content.length }} / {{ LESSON_NOTE_MAX_LENGTH }}</text>
</view>
<view class="apparatus-heading"><view><text class="section-heading">器械练习记录</text><text class="section-caption">点击器械展开,记录本次上课情况</text></view><text class="progress">已填写 {{ filledCount }} / 7</text></view>
<view class="apparatus-list">
<view v-for="(apparatus, index) in LESSON_APPARATUS" :key="apparatus" class="apparatus-card" :class="{ expanded: active === apparatus, filled: !!notes[apparatus]?.trim() }">
<button class="apparatus-toggle" :disabled="busy" :aria-expanded="active === apparatus" @tap="toggle(apparatus)">
<text class="apparatus-index">0{{ index + 1 }}</text><text class="apparatus-name">{{ apparatus }}</text>
<text class="apparatus-state">{{ notes[apparatus]?.trim() ? '已填写' : '选填' }}</text><text class="chevron" :class="{ rotated: active === apparatus }">⌄</text>
</button>
<view class="apparatus-body" :class="{ open: active === apparatus }" :aria-hidden="active !== apparatus">
<view class="apparatus-input">
<textarea v-if="active === apparatus" v-model="notes[apparatus]" :aria-label="apparatus + ' 器械记录(选填)'" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" :placeholder="'记录 ' + apparatus + ' 的练习内容与表现…'" placeholder-class="note-placeholder" />
<view class="input-foot"><text>由教练自由填写</text><text>{{ notes[apparatus]?.length || 0 }} / {{ LESSON_NOTE_MAX_LENGTH }}</text></view>
</view>
</view>
<text v-if="active !== apparatus && notes[apparatus]?.trim()" class="preview">{{ notes[apparatus]?.trim() }}</text>
</view>
</view>
<view class="visibility"><view><text class="visibility-title">分享给学员</text><text class="section-caption">{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }}</text></view><switch :checked="shared" :disabled="busy" color="#617d70" @change="changeShared" /></view>
</view>
</scroll-view>
<view class="sheet-footer">
<text v-if="error" class="error">{{ error }}</text>
<button class="save" :disabled="busy || closing || !hasContent" :loading="busy" @tap="save">{{ busy ? '正在保存…' : '保存课后评价' }}</button>
<text class="footer-hint">填写整体评价或任意一项器械即可保存</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '@mp-pilates/shared'
import type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared'
import { post } from '../utils/request'
const props = defineProps<{ userId: string; bookingId?: string }>()
const emit = defineEmits<{ close: []; saved: [] }>()
const content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false)
const confirmingClose = ref(false)
const busy = ref(false), closing = ref(false), error = ref('')
const filledCount = computed(() => filledApparatusNotes(notes.value).length)
const hasContent = computed(() => !!content.value.trim() || filledCount.value > 0)
const hasDraft = computed(() => !!content.value || Object.values(notes.value).some(Boolean))
let closeTimer: ReturnType<typeof setTimeout> | undefined
function toggle(apparatus: LessonApparatus) { active.value = active.value === apparatus ? null : apparatus }
function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
function close() {
uni.hideKeyboard()
closing.value = true
closeTimer = setTimeout(() => emit('close'), 220)
}
async function requestClose() {
if (busy.value || closing.value || confirmingClose.value) return
if (hasDraft.value) {
confirmingClose.value = true
const result = await uni.showModal({ title: '放弃这份未保存的评价?', content: '已填写的内容尚未保存,可以继续填写。', confirmText: '继续填写', cancelText: '放弃' }).finally(() => { confirmingClose.value = false })
if (!result.cancel) return
}
close()
}
async function save() {
if (busy.value || closing.value || !hasContent.value) return
busy.value = true
error.value = ''
try {
await post(`/admin/members/${props.userId}/progress/notes`, {
content: content.value.trim(),
apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])),
shared: shared.value,
...(props.bookingId ? { bookingId: props.bookingId } : {}),
})
emit('saved')
uni.showToast({ title: '课后评价已保存', icon: 'success' })
close()
} catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' }
finally { busy.value = false }
}
onBeforeUnmount(() => { if (closeTimer) clearTimeout(closeTimer) })
</script>
<style lang="scss" scoped>
.note-overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:flex-end;background:rgba(42,48,40,.42);animation:veil-in .22s ease both;color:#514943;}
.note-sheet{display:flex;flex-direction:column;width:100%;height:88vh;box-sizing:border-box;background:#fbf9f5;border-radius:36rpx 36rpx 0 0;overflow:hidden;animation:sheet-in .3s cubic-bezier(.2,.8,.2,1) both;}
button{font-weight:400;}button::after{border:0;}.sheet-header{padding:16rpx 32rpx 24rpx;border-bottom:1rpx solid #e8e3d9;}.handle{width:64rpx;height:6rpx;border-radius:8rpx;background:#d6d8cd;margin:0 auto 26rpx;}.title-row{display:flex;align-items:center;justify-content:space-between;gap:16rpx;}.eyebrow{display:block;color:#75816f;letter-spacing:3rpx;font-size:18rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:38rpx;line-height:1.5;margin-top:10rpx;}.close{margin:0;flex-shrink:0;width:72rpx;height:72rpx;line-height:66rpx;padding:0;border-radius:50%;background:#efeee7;color:#827b70;font-size:42rpx;}.subtitle{display:block;font-size:23rpx;color:#827b70;margin-top:14rpx;}.sheet-body{flex:1;height:0;min-height:0;}.body-inner{padding:30rpx 32rpx 10rpx;}.section-heading{display:flex;align-items:center;gap:16rpx;font-size:28rpx;font-weight:500;}.optional{font-size:21rpx;color:#948a7b;font-weight:400;}.summary-input{margin-top:18rpx;background:#f0ede6;border:1rpx solid #e6e1d7;border-radius:20rpx;padding:20rpx;}.summary-input textarea{height:148rpx;}.counter{display:block;text-align:right;font-size:20rpx;color:#938b7f;margin-top:10rpx;}textarea{width:100%;box-sizing:border-box;font-size:27rpx;line-height:1.8;color:#514943;}.note-placeholder{color:#9a9488;font-size:25rpx;}.apparatus-heading{display:flex;justify-content:space-between;align-items:center;gap:12rpx;margin:34rpx 0 20rpx;}.section-caption{display:block;font-size:22rpx;color:#8b8276;line-height:1.7;margin-top:8rpx;}.progress{flex-shrink:0;font-size:21rpx;padding:10rpx 14rpx;background:#eaf0e6;border-radius:30rpx;color:#61775c;}.apparatus-list{display:flex;flex-direction:column;gap:14rpx;}.apparatus-card{border:1rpx solid #e6e1d8;border-radius:20rpx;background:#fffdf9;overflow:hidden;transition:border-color .22s,background-color .22s;}.apparatus-card.expanded{border-color:#91a28a;background:#f6f8f1;}.apparatus-toggle{display:flex;align-items:center;width:100%;margin:0;padding:0 22rpx;height:96rpx;line-height:1.5;background:transparent;text-align:left;border-radius:0;color:#514943;}.apparatus-index{font-family:'Baskerville',serif;font-size:24rpx;color:#aaa18f;width:48rpx;}.apparatus-name{flex:1;font-family:'Baskerville','Songti SC',serif;font-size:34rpx;letter-spacing:1rpx;}.apparatus-state{font-size:21rpx;color:#a09789;}.filled .apparatus-state{color:#617d70;}.chevron{display:block;font-size:30rpx;margin-left:20rpx;color:#8b9381;transition:transform .22s;}.chevron.rotated{transform:rotate(180deg);}.apparatus-body{height:0;opacity:0;overflow:hidden;transition:height .24s ease,opacity .2s ease;}.apparatus-body.open{height:292rpx;opacity:1;}.apparatus-input{padding:0 22rpx 20rpx;}.apparatus-input textarea{height:220rpx;padding:16rpx;background:#fffdf9;border-radius:12rpx;border:1rpx solid #e7e9df;}.input-foot{display:flex;justify-content:space-between;color:#959180;font-size:20rpx;line-height:32rpx;margin-top:12rpx;}.preview{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:23rpx;line-height:1.7;color:#8a8276;margin:-6rpx 22rpx 20rpx 70rpx;}.visibility{display:flex;align-items:center;justify-content:space-between;gap:16rpx;margin:28rpx 0 20rpx;padding:24rpx 0;border-top:1rpx solid #e6e1d8;}.visibility-title{font-size:27rpx;}.sheet-footer{padding:20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));border-top:1rpx solid #e8e3d9;background:#fbf9f5;}.save{display:block;width:100%;margin:0;border-radius:20rpx;background:#617d70;color:#fff;line-height:88rpx;font-size:28rpx;transition:background .2s,transform .15s;}.save:active{transform:scale(.985);}.save[disabled]{background:#dce1d7;color:#8e9788;}.footer-hint{display:block;text-align:center;color:#9a9285;font-size:20rpx;margin-top:14rpx;}.error{display:block;font-size:24rpx;line-height:1.6;color:#a35645;margin-bottom:16rpx;}.leaving{animation:veil-out .22s ease both;}.leaving .note-sheet{animation:sheet-out .22s ease both;}@keyframes veil-in{from{background:rgba(42,48,40,0)}to{background:rgba(42,48,40,.42)}}@keyframes veil-out{to{background:rgba(42,48,40,0)}}@keyframes sheet-in{from{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes sheet-out{to{transform:translateY(100%)}}@media(prefers-reduced-motion:reduce){.note-overlay,.note-sheet,.leaving,.leaving .note-sheet{animation:none;}.apparatus-body,.chevron,.save{transition:none;}}
</style>

View File

@@ -12,12 +12,13 @@
<view class="tabs"><button v-for="item in tabs" :key="item.key" :class="{ active: tab === item.key }" @tap="tab = item.key">{{ item.name }}</button></view>
<BodyAssessmentRecords v-if="tab === 'metrics'" :records="archive.metrics" :admin="admin" :user-id="userId" @remove="remove('metrics', $event)" />
<template v-if="tab === 'notes'">
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写笔记</button></view>
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写评价</button></view>
<text class="muted intro">{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}</text>
<view v-if="!archive.notes.length" class="empty">静待第一份练习寄语<text>教练分享的课后反馈会留在这里。</text></view>
<view v-for="note in archive.notes" :key="note.id" class="panel note" :class="{ private: !note.shared }">
<view class="heading"><text class="badge">{{ note.shared ? '教练寄语 · 学员可见' : '私密 · 仅馆主可见' }}</text><button v-if="admin" class="text-btn" @tap="remove('notes', note.id)">删除</button></view>
<text class="body-copy">{{ note.content }}</text>
<text v-if="note.content" class="body-copy">{{ note.content }}</text>
<ApparatusNoteList :notes="note.apparatusNotes" />
<text v-if="note.booking" class="lesson">课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}</text>
<text class="muted">{{ formatChinaDate(note.createdAt) }}</text>
</view>
@@ -44,12 +45,12 @@
<view v-for="n in [10, 30, 50]" :key="n" class="milestone" :class="{ locked: !archive.milestones.includes(n) }"><text class="kicker">A MOMENT TO REMEMBER</text><text class="milestone-number">{{ n }}<text>节</text></text><text class="milestone-title">{{ milestoneTitles[n] }}</text><text class="muted">{{ archive.milestones.includes(n) ? '你已走过这段旅程' : '还差 ' + (n - archive.completedCount) + ' 节,一步步来' }}</text><button v-if="archive.milestones.includes(n) && !admin" class="small" :disabled="busy" @tap="saveMilestone(n)">保存纪念卡</button></view>
</template>
</template>
<view v-if="editor" class="overlay" @tap="onBackdropTap">
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>{{ editor === 'notes' ? '写一份教练笔记' : '添加成长照片' }}</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
<template v-if="editor !== 'notes'"><text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker></template>
<template v-if="editor === 'notes'"><text v-if="bookingId" class="lesson">已关联本次完成课程</text><textarea v-model="noteContent" maxlength="1000" placeholder="今天观察到了什么?下一次练习需要注意什么?" /><text class="muted">{{ noteContent.length }} / 1000</text><view class="switch-row"><view><text>分享给学员</text><text class="muted">{{ shared ? '这份评语将出现在学员档案' : '仅馆主自己可见' }}</text></view><switch :checked="shared" color="#617d70" @change="changeShared" /></view></template>
<template v-if="editor === 'photos'"><textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text></template>
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">{{ editor === 'photos' ? '上传并请求学员授权' : '保存记录' }}</button>
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="load" />
<view v-if="editor === 'photos'" class="overlay" @tap="onBackdropTap">
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>添加成长照片</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
<text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker>
<textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text>
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">上传并请求学员授权</button>
</scroll-view>
</view>
<canvas canvas-id="milestoneCard" class="export-canvas" style="width:600px;height:800px" />
@@ -59,6 +60,8 @@
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import BodyAssessmentRecords from './BodyAssessmentRecords.vue'
import CoachNoteEditor from './CoachNoteEditor.vue'
import ApparatusNoteList from './ApparatusNoteList.vue'
import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared'
import { get, post, del } from '../utils/request'
import { formatChinaDate } from '../utils/format'
@@ -66,7 +69,7 @@ const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string
const archive = ref<ProgressArchive | null>(null), loading = ref(false), error = ref(''), tab = ref('metrics'), editor = ref(''), busy = ref(false), saveError = ref('')
const tabs = [{ key: 'metrics', name: '体测' }, { key: 'notes', name: '寄语' }, { key: 'photos', name: '照片' }, { key: 'milestones', name: '里程碑' }]
const base = computed(() => props.admin ? `/admin/members/${props.userId}/progress` : '/user/progress')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), noteContent = ref(''), shared = ref(false), caption = ref(''), photoPath = ref('')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), caption = ref(''), photoPath = ref('')
const photoUrls = ref<Record<string, string>>({}), selectedPhotos = ref<string[]>([]), comparison = ref<{ id: string; url: string; date: string }[]>([])
const milestoneTitles: Record<number, string> = { 10: '开始,与身体相识', 30: '坚持,找到自己的节奏', 50: '积累,看见自己的力量' }
const instance = getCurrentInstance()
@@ -85,14 +88,11 @@ async function load() {
finally { if (seq === sequence && isInitial) loading.value = false }
}
function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) }
function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
function message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' }
function openEditor(kind: string) {
openedAt = Date.now()
editor.value = kind
saveError.value = ''
noteContent.value = ''
shared.value = false
caption.value = ''
photoPath.value = ''
recordDate.value = today
@@ -103,15 +103,10 @@ async function save() {
if (busy.value) return
busy.value = true; saveError.value = ''
try {
if (editor.value === 'notes') {
if (!noteContent.value.trim()) throw new Error('请先填写笔记')
await post(base.value + '/notes', { content: noteContent.value, shared: shared.value, ...(props.bookingId ? { bookingId: props.bookingId } : {}) })
} else {
if (!photoPath.value) throw new Error('请先选择照片')
const credentials = await post<{ id: string; uploadUrl: string; formData: Record<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
await new Promise<void>((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
await post(base.value + `/photos/${credentials.id}/complete`)
}
if (!photoPath.value) throw new Error('请先选择照片')
const credentials = await post<{ id: string; uploadUrl: string; formData: Record<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
await new Promise<void>((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
await post(base.value + `/photos/${credentials.id}/complete`)
editor.value = ''; await load(); uni.showToast({ title: '记录已保存', icon: 'success' })
} catch (e) { saveError.value = message(e) } finally { busy.value = false }
}

View File

@@ -29,6 +29,7 @@
<text class="card-pill-meta">
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
</text>
<text v-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text>
</view>
</view>
</scroll-view>
@@ -108,7 +109,7 @@ import type {
AdminMemberDetail,
ScheduleSlotPreview,
} from '@mp-pilates/shared'
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared'
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, canUseMembershipForSlot } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
@@ -134,7 +135,7 @@ const arranging = ref(false)
const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) =>
item.status === MembershipStatus.ACTIVE
(item.status === MembershipStatus.ACTIVE || item.status === MembershipStatus.SCHEDULED)
&& (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(),
),
@@ -169,6 +170,7 @@ const filteredSlots = computed(() => {
function canPickSlot(slot: ScheduleSlotPreview): boolean {
if (!canArrange.value) return false
if (!selectedMembership.value || !canUseMembershipForSlot(selectedMembership.value, slot)) return false
if (isSlotPast(slot.date, slot.startTime)) return false
if (slot.status === TimeSlotStatus.CLOSED) return false
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false
@@ -176,6 +178,7 @@ function canPickSlot(slot: ScheduleSlotPreview): boolean {
}
function slotLabel(slot: ScheduleSlotPreview): string {
if (selectedMembership.value && !canUseMembershipForSlot(selectedMembership.value, slot)) return '周期外'
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
if (isSlotPast(slot.date, slot.startTime)) return '已过点'

View File

@@ -187,13 +187,14 @@ const detail = ref<AdminMemberDetail | null>(null)
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
function isArrangableMembership(membership: MembershipWithCardType): boolean {
if (membership.status !== MembershipStatus.ACTIVE) return false
if (membership.status !== MembershipStatus.ACTIVE && membership.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
return new Date(membership.expireDate) > new Date()
}
function membershipStatusLabel(status: string): string {
const map: Record<string, string> = {
[MembershipStatus.SCHEDULED]: '待生效',
ACTIVE: '有效',
EXPIRED: '已过期',
USED_UP: '已用完',

View File

@@ -77,7 +77,7 @@
v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
:memberships="slotMemberships"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
/>
@@ -88,7 +88,7 @@
import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS, canUseMembershipForSlot } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
@@ -111,6 +111,7 @@ const selectedDate = ref<string>(formatDate(new Date()))
const selectedPeriod = ref<PeriodKey>(null)
const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const slotMemberships = computed(() => pendingSlot.value ? userStore.memberships.filter(m => canUseMembershipForSlot(m, pendingSlot.value!)) : [])
const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后,
@@ -327,10 +328,10 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
}
// 2. Ensure has valid membership
if (!userStore.hasValidMembership) {
if (!userStore.memberships.some(m => canUseMembershipForSlot(m, slot))) {
uni.showModal({
title: '暂无可用会员卡',
content: '您当前没有有效的会员卡,购买后即可预约课程',
content: '该课程时间不在现有会员卡可用周期内,或对应周期课时已用完。可查看会员卡或购买新的周期。',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {

View File

@@ -221,6 +221,7 @@
<text class="renew-col-value">{{ renewalPreview.currentHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text class="renew-col-date">{{ renewalPreview.currentExpire }}</text>
<text v-if="renewalPreview.currentAllowance" class="renew-col-date">{{ renewalPreview.currentAllowance }}</text>
</view>
<view class="renew-arrow">
<view class="renew-arrow-line" />
@@ -231,7 +232,9 @@
<text class="renew-col-label">确认后</text>
<text class="renew-col-value">{{ renewalPreview.afterHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text v-if="renewalPreview.afterStart" class="renew-col-date">{{ renewalPreview.afterStart }}</text>
<text class="renew-col-date">{{ renewalPreview.afterExpire }}</text>
<text v-if="renewalPreview.afterAllowance" class="renew-col-date">{{ renewalPreview.afterAllowance }}</text>
</view>
</view>
@@ -341,8 +344,8 @@
<script setup lang="ts">
import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onLoad, onShow, onHide, onShareAppMessage } from '@dcloudio/uni-app'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import {
CardTypeCategory,
@@ -351,6 +354,8 @@ import {
computeMembershipGrant,
getMembershipDaysLeft,
pickRenewalTarget,
isMembershipExpired,
getMembershipEffectiveState,
} from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { get, post } from '../../utils/request'
@@ -415,6 +420,9 @@ const paymentStatusIssue = ref<PaymentStatusIssue>('none')
const paymentRedirecting = ref(false)
const paymentConfirmationSession = ref(0)
const failedCoverIds = ref<Set<string>>(new Set())
const renewalNow = ref(Date.now())
let renewalClock: ReturnType<typeof setTimeout> | undefined
let renewalPageVisible = false
onShareAppMessage(() => {
const sharedCard = showAll.value ? null : card.value
@@ -471,12 +479,29 @@ const renewalTarget = computed(() => {
const isRenewal = computed(() => !!renewalTarget.value)
function stopRenewalClock() {
if (renewalClock) clearTimeout(renewalClock)
renewalClock = undefined
}
function syncRenewalClock() {
stopRenewalClock()
renewalNow.value = Date.now()
if (!renewalPageVisible) return
const expires = renewalTarget.value ? new Date(renewalTarget.value.expireDate).getTime() : 0
if (expires > renewalNow.value) {
const remaining = expires - renewalNow.value
const nextDayBoundary = remaining - (Math.ceil(remaining / 86_400_000) - 1) * 86_400_000
renewalClock = setTimeout(syncRenewalClock, Math.max(1, nextDayBoundary + 1))
}
}
watch(() => renewalTarget.value?.expireDate, syncRenewalClock)
const grantPreview = computed(() => {
if (!card.value || !renewalTarget.value) return null
return computeMembershipGrant({
existing: renewalTarget.value,
cardType: card.value,
now: new Date(),
now: new Date(renewalNow.value),
})
})
@@ -489,39 +514,47 @@ function formatStudioDate(value: Date | string): string {
}
const renewalPreview = computed(() => {
const target = renewalTarget.value
const grant = grantPreview.value
const currentCard = card.value
const now = new Date(renewalNow.value)
const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())[0]
const target = currentCard?.type === CardTypeCategory.DURATION ? current || renewalTarget.value : renewalTarget.value
if (!target || !grant || !currentCard) return null
const isTimes = grant.remainingTimes !== null
const isExpired =
target.status === MembershipStatus.EXPIRED ||
new Date(target.expireDate).getTime() <= Date.now()
const isTimes = currentCard.type === CardTypeCategory.TIMES
const isExpired = isMembershipExpired(target, now)
const effective = getMembershipEffectiveState(target, now)
const awaitingPeriod = effective.status === MembershipStatus.SCHEDULED
const statusLabel =
target.status === MembershipStatus.USED_UP
? '次数已用完'
: isExpired
? '已到期'
awaitingPeriod ? '下期待生效' : isExpired
? '已到期'
: target.status === MembershipStatus.USED_UP
? '次数已用完'
: '使用中'
return {
isTimes,
statusLabel,
currentHighlight: isTimes
? String(target.remainingTimes ?? 0)
: String(Math.max(0, getMembershipDaysLeft(target.expireDate))),
? String(effective.remainingTimes ?? 0)
: String(isExpired || awaitingPeriod ? 0 : Math.max(0, getMembershipDaysLeft(target.expireDate, now))),
afterHighlight: isTimes
? String(grant.remainingTimes ?? 0)
: String(Math.max(0, getMembershipDaysLeft(grant.expireDate))),
: String(currentCard.durationDays),
highlightUnit: isTimes ? '次剩余' : '天有效',
currentExpire: formatStudioDate(target.expireDate),
afterExpire: formatStudioDate(grant.expireDate),
afterStart: isTimes ? '' : formatStudioDate(grant.startDate) + ' 生效',
afterExpire: formatStudioDate(grant.expireDate) + (isTimes ? '' : ' 到期'),
currentAllowance: !isTimes && target.remainingTimes !== null
? awaitingPeriod ? '当前无生效周期' : isExpired ? '原周期余次已失效' : '本周期剩余 ' + String(effective.remainingTimes) + ' 次' : '',
afterAllowance: !isTimes ? (grant.startDate > now ? '下期待生效 · ' : '新周期 · ') + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '',
addedLabel: isTimes
? '本次预计续入 +' + String(currentCard.totalTimes ?? 0) + ' 次 · +' + String(currentCard.durationDays) + ' 天'
: '本次预计续入 +' + String(currentCard.durationDays) + ' 天',
forfeitNote: isExpired && isTimes && (target.remainingTimes ?? 0) > 0
? '到期余次不结转,按新购次数起算'
: '本次月卡' + (currentCard.totalTimes !== null ? '包含 ' + String(currentCard.totalTimes) + ' 次 · ' : ' · ') + String(currentCard.durationDays) + ' 天有效',
forfeitNote: !isTimes
? grant.startDate > now ? '本期权益保留至到期,下期单独生效;月卡余次不跨期累计。' : '原周期已结束,剩余课时不结转;新周期按本次购买额度起算。'
: isExpired && grant.remainingTimes !== null
? '到期余次不结转,按新购 ' + String(currentCard.totalTimes) + ' 次起算'
: '',
}
})
@@ -532,14 +565,19 @@ const renewalPreviewText = computed(() => {
if (preview.isTimes) {
return '确认后预计剩余 ' + preview.afterHighlight + ' 次,有效期至 ' + preview.afterExpire
}
return '确认后预计有效期至 ' + preview.afterExpire
return preview.afterAllowance + ',' + preview.afterStart + ',' + preview.afterExpire
})
const purchaseNotes = computed(() => {
if (!card.value) return []
const notes: string[] = []
if (isRenewal.value) {
if (renewalTarget.value && isMembershipExpired(renewalTarget.value, new Date(renewalNow.value))) {
notes.push('旧卡已到期,原周期剩余课时失效;支付确认后按新购权益重新生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
} else if (isRenewal.value && card.value.type === CardTypeCategory.DURATION) {
notes.push('提前续费将生成独立的下期月卡,本期权益保留至到期,未用完的课时不结转。')
notes.push('本期课时用完后,可在「我的会员卡」确认提前启用下一期;原周期随之结束。')
} else if (isRenewal.value) {
notes.push('支付确认后,现有会员有效期预计再延长 ' + String(card.value.durationDays) + ' 天。')
} else {
notes.push('支付确认后会员卡生效,有效期为 ' + String(card.value.durationDays) + ' 天。')
@@ -548,7 +586,7 @@ const purchaseNotes = computed(() => {
if (card.value.totalTimes === null) {
notes.push('有效期内可不限次预约课程。')
} else {
notes.push('共 ' + String(card.value.totalTimes) + ' 次课时,每次预约扣除 1 次。')
notes.push('共 ' + String(card.value.totalTimes) + ' 次课时,教练确认预约后扣除 1 次。')
}
if (card.value.type === CardTypeCategory.DURATION) {
@@ -556,7 +594,9 @@ const purchaseNotes = computed(() => {
}
notes.push(
card.value.totalTimes === null
card.value.type === CardTypeCategory.DURATION
? '每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。'
: card.value.totalTimes === null
? '到期后自动失效。'
: '到期或课时用完后自动失效,不可退款。',
)
@@ -580,8 +620,9 @@ const buyButtonLabel = computed(() => {
})
const bottomPriceHint = computed(() => {
if (savingsLabel.value) return savingsLabel.value + ' · 支付确认后生效'
return '支付确认后生效'
const effectiveHint = card.value?.type === CardTypeCategory.DURATION && grantPreview.value && grantPreview.value.startDate.getTime() > renewalNow.value ? '支付后排期待生效' : '支付确认后生效'
if (savingsLabel.value) return savingsLabel.value + ' · ' + effectiveHint
return effectiveHint
})
const purchaseConfirmationNote = computed(() => {
@@ -772,6 +813,7 @@ async function preparePurchaseConfirmation() {
uni.showToast({ title: '暂时无法核对会员权益,请稍后重试', icon: 'none' })
return
}
syncRenewalClock()
purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
@@ -848,7 +890,8 @@ async function settlePaidOrder(orderId: string) {
pendingOrderId.value = ''
paymentRedirecting.value = true
uni.showToast({ title: isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
const scheduledMonthly = card.value?.type === CardTypeCategory.DURATION && userStore.memberships.some(m => m.cardTypeId === card.value?.id && m.status === MembershipStatus.SCHEDULED)
uni.showToast({ title: scheduledMonthly ? '下期月卡已安排' : isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
setTimeout(() => {
if (session === paymentConfirmationSession.value) goToMembership()
}, 900)
@@ -941,13 +984,19 @@ onMounted(() => {
})
onShow(() => {
renewalPageVisible = true
syncRenewalClock()
if (userStore.loggedIn) void invite.refresh().catch(() => {})
if (!paymentConfirming.value && !paymentPending.value) {
void refreshMembershipContext()
}
})
onHide(() => { renewalPageVisible = false; stopRenewalClock() })
onUnmounted(() => {
renewalPageVisible = false
stopRenewalClock()
paymentConfirmationSession.value++
})
</script>

View File

@@ -43,6 +43,15 @@
<text class="usage-note">已用次数包含预约扣次,不等同于已完成上课;进度条表示已用次数占比。</text>
</view>
<view v-if="scheduledMemberships.length" class="group-section">
<view class="group-header"><text class="group-title">下期待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view>
<view v-for="m in scheduledMemberships" :key="m.id" class="mc mc--scheduled">
<view class="mc-top"><view class="mc-name-area"><text class="mc-name">{{ m.cardType.name }}</text><text class="mc-type-text">{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}</text></view><text class="mc-status">待生效</text></view>
<view class="mc-history-bottom"><view class="mc-history-info"><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></view><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view>
</view>
<text class="usage-note">下期课时与本期分别计算,不计入当前可用余额。预约需落在对应周期内。</text>
</view>
<view v-if="inactiveMemberships.length" class="group-section">
<view class="group-header">
<text class="group-title">历史记录</text>
@@ -58,7 +67,8 @@
</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 v-if="m.cardType.type === CardTypeCategory.DURATION && m.status === MembershipStatus.EXPIRED" class="mc-date-label">原周期未用课时已失效</text>
<text v-else-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
</view>
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
@@ -75,13 +85,15 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onResize } from '@dcloudio/uni-app'
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide, onResize } from '@dcloudio/uni-app'
import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel } from '../../utils/format'
import { getCardTypeLabel, formatDateTimeFull } from '../../utils/format'
import { post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
@@ -93,19 +105,34 @@ onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = computed(() => userStore.membershipsLoading)
const membershipNow = ref(Date.now())
const refreshing = ref(false)
const activating = ref('')
let boundaryTimer: ReturnType<typeof setTimeout> | undefined
let pageVisible = false
function stopBoundaryClock() { if (boundaryTimer) clearTimeout(boundaryTimer); boundaryTimer = undefined }
function syncBoundaryClock() {
stopBoundaryClock()
membershipNow.value = Date.now()
if (!pageVisible) return
const boundaries = userStore.memberships.flatMap(m => [new Date(m.startDate).getTime(), new Date(m.expireDate).getTime()]).filter(time => time > membershipNow.value)
const delay = Math.min(60_000, ...boundaries.map(time => time - membershipNow.value + 1))
boundaryTimer = setTimeout(syncBoundaryClock, delay)
}
watch(() => userStore.memberships, syncBoundaryClock)
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const allMemberships = computed(() => userStore.memberships.map(m => ({ ...m, ...getMembershipEffectiveState(m, new Date(membershipNow.value)) })))
const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()))
const activeMemberships = computed(() =>
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE && m.status !== MembershipStatus.SCHEDULED),
)
function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = {
[MembershipStatus.SCHEDULED]: '待生效',
[MembershipStatus.ACTIVE]: '有效',
[MembershipStatus.EXPIRED]: '已过期',
[MembershipStatus.USED_UP]: '已用完',
@@ -153,7 +180,27 @@ function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onShow(loadMemberships)
function canActivate(m: MembershipWithCardType): boolean {
return m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0
&& scheduledMemberships.value.find(period => period.cardTypeId === m.cardTypeId)?.id === m.id
&& !activeMemberships.value.some(period => period.cardTypeId === m.cardTypeId && period.remainingTimes !== 0)
}
async function activate(m: MembershipWithCardType) {
if (activating.value || !canActivate(m)) return
activating.value = m.id
try {
const confirmation = await uni.showModal({ title: '提前启用下一期月卡?', content: '原周期将立即结束,新一期从现在重新计算有效期,后续待生效周期按新日期重新衔接。已有预约若超出新的周期范围,需要先调整。', confirmText: '确认启用' })
if (!confirmation.confirm) return
await post('/membership/' + encodeURIComponent(m.id) + '/activate', { confirmed: true })
await loadMemberships()
uni.showToast({ title: '新周期已启用', icon: 'success' })
} catch (error) { uni.showToast({ title: getErrorMessage(error, '启用失败,请重试'), icon: 'none' }) }
finally { activating.value = '' }
}
onShow(() => { pageVisible = true; syncBoundaryClock(); void loadMemberships() })
onHide(() => { pageVisible = false; stopBoundaryClock() })
onUnmounted(stopBoundaryClock)
</script>
<style lang="scss" scoped>
@@ -201,4 +248,5 @@ onShow(loadMemberships)
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
.owned-card-wrap { margin-bottom: 20rpx; }
.mc--scheduled { background: #f0f5f7; border-color: #dce7ed; .mc-status { color: #7291a1; } }
</style>

View File

@@ -11,6 +11,7 @@ import {
UserRole,
MembershipStatus,
getMembershipRenewalHint,
getMembershipEffectiveState,
} from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { setUnauthorizedHandler, setRenewedHandler } from '../utils/session'
@@ -120,7 +121,8 @@ export const useUserStore = defineStore('user', () => {
try {
const result = await get<MembershipWithCardType[]>('/membership/my')
if (id !== membershipRequestId || session !== sessionId) return false
memberships.value = [...result]
const now = new Date()
memberships.value = result.map(membership => ({ ...membership, ...getMembershipEffectiveState(membership, now) }))
membershipsLoaded.value = true
return true
} catch {