perf: 支持课程补录

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

View File

@@ -86,3 +86,8 @@ pnpm deploy:server # 部署后端到生产环境
### Admin Store (`src/stores/admin.ts`) ### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等 - 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]` - 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
### 历史课程补录
- `LessonSupplement` 独立记录历史累计课时,归属 user 模块DTO 放 `user/dto`,测试放 `user/__tests__`,前端入口为 `pages/admin/member-supplement.vue`
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。

View File

@@ -117,6 +117,7 @@ echo_info "文件上传完成"
# 6. 服务器端安装依赖并重启 # 6. 服务器端安装依赖并重启
echo_info "服务器部署操作..." echo_info "服务器部署操作..."
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH' ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
set -e
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH" export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
SERVER_PATH="/usr/local/web/mp-pilates-server" SERVER_PATH="/usr/local/web/mp-pilates-server"
PM2_APP_NAME="mp-pilates-server" PM2_APP_NAME="mp-pilates-server"

29
docs/lesson-supplement.md Normal file
View File

@@ -0,0 +1,29 @@
# 历史课程补录
入口:会员档案 → 上课情况 → 补录上课。一次填写 1999 节,可备注,默认仅补记录,也可选择本人有限次会员卡扣次(含已过期卡)。不限次卡无需扣次;余额不足阻止提交。
一批补录展示为「系统补录 · N 节」,不是 N 个虚拟预约。累计已上课次数(个人中心、会员档案、会员列表已完成)增加 N累计预约、月度节数/天数/时长、最近 30 天网格、邀请奖励保持正常预约口径,因为补录没有真实上课日期。记录时间明确为补录时间。
老师与管理员共用 ADMIN 权限;成员只能读取自己的生效记录。事务保存记录与扣次快照,请求标识唯一,网络重试不重复扣次。撤销软删除,保留操作人与时间,只返还当时实际扣次,过期卡不重新激活。若原卡已转为不限次,撤销阻止并要求先核对卡状态。
## 迁移与发布
新增 `lesson_supplements` 表及关联索引,不修改既有预约数据。按仓库发布流程先执行 `prisma migrate deploy` 并重新生成 Prisma Client、发布后端再发布小程序。开发检查不自动触及生产数据库。
回退:先回退应用代码,保留新增表即可,旧代码不读取该表。若一定要移除表,须先导出审计数据并核对已扣会员卡余额;仅在未写入任何补录的环境可执行 `DROP TABLE lesson_supplements;`。MySQL DDL 不能靠事务回滚,不在生产自动执行删除。
## 本次验证
- 补录与用户统计相关 62 项测试通过,覆盖权限、数量校验、仅补记录、扣次、余额不足、过期卡、重复提交、并发冲突、撤销和统计口径。
- 共享包与后端构建、小程序构建、vue-tsc、Prisma schema 校验通过。
- 使用真实 Vue 页面与示例接口检查 375/320 宽度布局、十节补录、撤销、余额不足及学员历史记录;不替代微信真机与真实数据库联调。
- 全量测试存在原有 SchedulerService 测试依赖缺失:未提供 FlashSaleService8 项失败;其余已执行用例通过。
- 2026-09-08 已经用户授权执行生产迁移及后端发布;小程序仍需单独发布。
## 生产发布记录2026-09-08
- 执行 `pnpm deploy:server`,迁移 `20260908090000_add_lesson_supplements` 成功,全部 4 个迁移处于最新状态。
- 发布前数据库及后端备份保存在服务器 `/usr/local/web/mp-pilates-backups/20260908-145852/`
- 新表读取成功,验收时为 0 条记录;未创建测试补录或修改学员余额。
- 内网及公网 `https://focus.richarjiang.com/api/health` 均返回 `success: true``status: ok`PM2 `mp-pilates-server` 为 online重启计数为 0。
- 部署脚本增加远端 `set -e`,使依赖安装或迁移失败时停止发布。

View File

@@ -0,0 +1,39 @@
<template>
<view class="supplements">
<view v-for="item in records" :key="item.id" class="record" :class="{ 'record--revoked': item.revokedAt }">
<view class="record-head">
<view class="record-title"><text>系统补录</text><text class="record-sub">{{ item.revokedAt ? '已撤销' : '历史课程' }}</text></view>
<text class="record-quantity">{{ item.quantity }}<text class="record-unit"> </text></text>
</view>
<text class="record-date">{{ formatDateTimeFull(item.createdAt) }} 补录</text>
<text class="record-note">{{ item.deductedTimes ? `${item.cardName} · ${item.revokedAt ? '已返还' : '扣除'} ${item.deductedTimes}` : '仅补记录 · 未扣会员卡' }}</text>
<text v-if="item.remark" class="record-note">{{ item.remark }}</text>
<view v-if="editable" class="record-footer">
<text class="record-operator"> {{ item.operatorName }} 补录</text>
<button v-if="!item.revokedAt" class="revoke" :disabled="busy" @tap="emit('revoke', item)">撤销补录</button>
<text v-else class="record-operator">{{ formatDateTimeFull(item.revokedAt) }} 撤销</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { LessonSupplementRecord } from '@mp-pilates/shared'
import { formatDateTimeFull } from '../utils/format'
defineProps<{ records: readonly LessonSupplementRecord[]; editable?: boolean; busy?: boolean }>()
const emit = defineEmits<{ revoke: [record: LessonSupplementRecord] }>()
</script>
<style lang="scss" scoped>
.supplements { display: flex; flex-direction: column; gap: 20rpx; }
.record { padding: 28rpx; border: 1rpx solid #e9e7df; border-radius: 26rpx; background: #fff; color: #514943; }
.record--revoked { background: #f4f1ed; color: #8b817b; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.record-title { display: flex; flex-direction: column; gap: 8rpx; font-size: 28rpx; }
.record-sub { font-size: 20rpx; color: #8b817b; }
.record-quantity { flex-shrink: 0; font-size: 42rpx; font-variant-numeric: tabular-nums; color: #617d73; }
.record-unit { font-size: 22rpx; }
.record-date { display: block; margin-top: 20rpx; color: #8b817b; font-size: 21rpx; }
.record-note { display: block; margin-top: 12rpx; font-size: 23rpx; color: #786b61; line-height: 1.6; overflow-wrap: anywhere; }
.record-footer { margin-top: 20rpx; padding-top: 16rpx; border-top: 1rpx solid #eee8e3; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.record-operator { color: #8b817b; font-size: 21rpx; }
.revoke { margin: 0; padding: 8rpx 12rpx; font-size: 22rpx; line-height: 1.5; background: transparent; color: #967561; &::after { border: none; } }
</style>

View File

@@ -111,6 +111,10 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/admin/member-supplement",
"style": { "navigationStyle": "custom" }
},
{ {
"path": "pages/admin/member-arrange", "path": "pages/admin/member-arrange",
"style": { "style": {

View File

@@ -41,7 +41,10 @@
</view> </view>
<view class="section section--practice"> <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="stats-strip">
<view class="stat"> <view class="stat">
<text class="stat-num">{{ detail.stats.totalBookings }}</text> <text class="stat-num">{{ detail.stats.totalBookings }}</text>
@@ -49,7 +52,7 @@
</view> </view>
<view class="stat stat--completed"> <view class="stat stat--completed">
<text class="stat-num">{{ detail.stats.completedBookings }}</text> <text class="stat-num">{{ detail.stats.completedBookings }}</text>
<text class="stat-name">已完成</text> <text class="stat-name">累计上课</text>
</view> </view>
<view class="stat"> <view class="stat">
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text> <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() { function goEdit() {
if (!userId.value) return if (!userId.value) return
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` }) uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
@@ -321,6 +328,7 @@ onShow(() => {
.section--last { padding-bottom: 24rpx; } .section--last { padding-bottom: 24rpx; }
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; } .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; } .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); } .section-note { font-size: 22rpx; color: var(--muted); }
.stats-strip { .stats-strip {
display: flex; padding: 26rpx 0; display: flex; padding: 26rpx 0;

View File

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

View File

@@ -105,6 +105,12 @@
:refresher-triggered="refreshingHistory" :refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory" @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 --> <!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap"> <view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card"> <view v-for="i in 3" :key="i" class="skeleton-card">
@@ -117,7 +123,7 @@
</view> </view>
<!-- Empty --> <!-- 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-illustration">
<view class="empty-circle outer" /> <view class="empty-circle outer" />
<view class="empty-circle inner" /> <view class="empty-circle inner" />
@@ -164,13 +170,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' 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 { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { formatDate, getWeekdayLabel } from '../../utils/format' import { formatDate, getWeekdayLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { get } from '../../utils/request'
const bookingStore = useBookingStore() 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 ────────────────────────────────────── // ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
@@ -310,7 +329,7 @@ async function onRefreshUpcoming() {
async function onRefreshHistory() { async function onRefreshHistory() {
refreshingHistory.value = true refreshingHistory.value = true
await bookingStore.fetchMyBookings() await Promise.all([bookingStore.fetchMyBookings(), fetchSupplements()])
refreshingHistory.value = false refreshingHistory.value = false
} }
@@ -351,6 +370,7 @@ async function handleCancel(booking: BookingWithDetails) {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(() => { onMounted(() => {
fetchSupplements()
const windowInfo = uni.getWindowInfo() const windowInfo = uni.getWindowInfo()
const statusBarH = windowInfo.statusBarHeight ?? 20 const statusBarH = windowInfo.statusBarHeight ?? 20
navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px` 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). // for any state we didn't locally patch (e.g. server-side cascading fields).
onShow(() => { onShow(() => {
if (!hasLoadedOnce.value) return if (!hasLoadedOnce.value) return
fetchSupplements()
bookingStore.fetchMyBookings(undefined, { silent: true }) bookingStore.fetchMyBookings(undefined, { silent: true })
}) })
</script> </script>
<style lang="scss" scoped> <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 { .bookings-page {
min-height: 100vh; min-height: 100vh;
background: #f5f3f0; background: #f5f3f0;

View File

@@ -1,112 +1,62 @@
<template> <template>
<view class="info-page" :style="{ paddingTop: navBarHeight }"> <view class="info-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar <CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
:title="isFromLogin ? '完善个人信息' : '个人信息'"
:show-back="!isFromLogin"
/>
<!-- First-login welcome banner -->
<view v-if="isFromLogin" class="welcome-banner"> <view v-if="isFromLogin" class="welcome-banner">
<view class="welcome-content"> <text class="welcome-title">欢迎来到工作室</text>
<view class="welcome-text"> <text class="welcome-desc">设置头像和昵称方便老师认识你</text>
<text class="welcome-title">欢迎加入</text>
<text class="welcome-desc">设置你的头像和昵称让大家认识你</text>
</view>
</view>
</view> </view>
<!-- Avatar section --> <view class="avatar-section">
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }"> <button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap"> <view class="avatar-wrap">
<image <image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
v-if="displayAvatarUrl" <view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
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>
</view> </view>
<view class="avatar-copy">
<text class="avatar-title">头像</text>
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
</view>
<text class="avatar-arrow"></text>
</button> </button>
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text> <text class="avatar-hint">头像更换后自动保存</text>
<text class="avatar-hint">点击头像选择微信头像</text>
</view> </view>
<!-- Form fields --> <view class="form-section">
<view class="form-card"> <text class="section-title">基本资料</text>
<!-- Nickname (editable) --> <view class="form-card">
<view class="form-row"> <view class="nickname-field">
<text class="form-label">昵称</text> <view class="field-heading">
<input <text class="form-label">昵称</text>
class="form-input" <text class="field-count">{{ form.nickname.length }} / 20</text>
type="nickname" </view>
v-model="form.nickname" <input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
placeholder="点击右侧按钮获取微信昵称" placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
placeholder-style="color: #ccc" </view>
maxlength="20" <view v-if="!isFromLogin" class="form-row">
:disabled="saving" <text class="form-label">手机号</text>
/> <text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<text class="form-arrow"></text> <button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
</view> </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> </view>
</view> </view>
<!-- Read-only info card (hide in first-login mode) -->
<view v-if="!isFromLogin" class="info-card"> <view v-if="!isFromLogin" class="info-card">
<view class="info-row"> <view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
<text class="info-label">注册时间</text> <view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} </text></view>
<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> </view>
<!-- Save button -->
<view class="save-wrap"> <view class="save-wrap">
<view <button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
class="save-btn" {{ saving ? '保存中' : isFromLogin ? '保存并进入' : '保存修改' }}
:class="{ </button>
'save-btn--loading': saving, <text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后请点击保存' : '修改昵称后即可保存' }}</text>
'save-btn--disabled': !isFromLogin && (!isDirty || saving), <button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
}"
@tap="handleSave"
>
<text class="save-btn-text">
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
</text>
</view>
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { wxBindPhone } from '../../utils/auth' import { wxBindPhone } from '../../utils/auth'
@@ -125,7 +75,7 @@ onLoad((query) => {
}) })
// ─── Nav bar height ────────────────────────────────────── // ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px') const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
// ─── Form state ─────────────────────────────────────────── // ─── Form state ───────────────────────────────────────────
const form = ref({ const form = ref({
@@ -134,6 +84,11 @@ const form = ref({
const originalNickname = ref('') const originalNickname = ref('')
const saving = ref(false) const saving = ref(false)
const uploadingAvatar = 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 ───────────────────────────────────────────── // ─── Computed ─────────────────────────────────────────────
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value) const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
@@ -165,26 +120,19 @@ const activeMembershipCount = computed(
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length, () => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
) )
// ─── Default avatar ─────────────────────────────────────── // Use a local fallback when no avatar is set or the remote image fails.
const defaultAvatarUrl = computed(() => { const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
const nickname = form.value.nickname || 'user' watch(avatarUrl, () => { avatarFailed.value = false })
// 使用 dicebear 生成基于昵称的随机头像
return `https://api.dicebear.com/7.x/identicon/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=c9a87c,e8c88a`
})
const displayAvatarUrl = computed(() => {
return avatarUrl.value || defaultAvatarUrl.value
})
// ─── Avatar upload ──────────────────────────────────────── // ─── Avatar upload ────────────────────────────────────────
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) { async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
const { avatarUrl } = e.detail const { avatarUrl } = e.detail
if (!avatarUrl) return if (!avatarUrl || busy.value) return
uploadingAvatar.value = true uploadingAvatar.value = true
try { try {
await userStore.updateProfile({ avatarUrl }) await userStore.updateProfile({ avatarUrl })
await userStore.fetchProfile() avatarFailed.value = false
uni.showToast({ title: '头像更新成功', icon: 'success' }) uni.showToast({ title: '头像更新成功', icon: 'success' })
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '更新失败,请重试' const msg = err instanceof Error ? err.message : '更新失败,请重试'
@@ -198,32 +146,32 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
async function handleGetPhone(e: { async function handleGetPhone(e: {
detail: { encryptedData: string; iv: string; errMsg: string } detail: { encryptedData: string; iv: string; errMsg: string }
}) { }) {
if (busy.value) return
if (e.detail.errMsg !== 'getPhoneNumber:ok') { if (e.detail.errMsg !== 'getPhoneNumber:ok') {
// User denied or cancelled // User denied or cancelled
return return
} }
bindingPhone.value = true
uni.showLoading({ title: '绑定中...' }) uni.showLoading({ title: '绑定中...' })
try { try {
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0]) await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
// Refresh store with updated profile // Refresh store with updated profile
await userStore.fetchProfile() await userStore.fetchProfile()
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '手机号绑定成功', icon: 'success' }) uni.showToast({ title: '手机号绑定成功', icon: 'success' })
// Sync nickname from updated profile // Keep the local nickname draft while refreshing the phone information.
if (updated.nickname) {
form.value = { nickname: updated.nickname }
originalNickname.value = updated.nickname
}
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '绑定失败,请重试' const msg = err instanceof Error ? err.message : '绑定失败,请重试'
uni.showToast({ title: msg, icon: 'none' }) uni.showToast({ title: msg, icon: 'none' })
} finally {
bindingPhone.value = false
} }
} }
// ─── Save ───────────────────────────────────────────────── // ─── Save ─────────────────────────────────────────────────
async function handleSave() { 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) // In first-login mode, allow saving even if not dirty (user may just want to proceed)
if (!isFromLogin.value && !isDirty.value) return if (!isFromLogin.value && !isDirty.value) return
@@ -262,6 +210,7 @@ async function handleSave() {
// ─── Skip (first-login only) ───────────────────────────── // ─── Skip (first-login only) ─────────────────────────────
function handleSkip() { function handleSkip() {
if (busy.value) return
uni.showModal({ uni.showModal({
title: '确认跳过?', title: '确认跳过?',
content: '完善头像和昵称可以让教练和伙伴更容易认识你', content: '完善头像和昵称可以让教练和伙伴更容易认识你',
@@ -278,312 +227,58 @@ function handleSkip() {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
if (!isFromLogin.value) { try {
await userStore.fetchProfile() if (!isFromLogin.value) {
} await userStore.fetchProfile()
if (userStore.user) { }
form.value = { nickname: userStore.user.nickname } if (userStore.user) {
originalNickname.value = userStore.user.nickname form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
}
} finally {
initializing.value = false
} }
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.info-page { .info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
min-height: 100vh; .welcome-banner { margin: 28rpx 32rpx 0; }
background: $bg-page; .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; }
/* ── 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;
}
}
.avatar-btn { .avatar-btn {
background: transparent; display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
border: none; height: auto; margin: 0; padding: 0; border: none; background: transparent;
padding: 0; border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
margin: 0; &::after { border: none; }
&[disabled] { background: transparent; color: #8b817b; }
&::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;
}
} }
.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> </style>

View File

@@ -1,162 +1,95 @@
<template> <template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }"> <view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back /> <CustomNavBar title="我的会员卡" show-back />
<scroll-view <scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
class="scroll" <view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="loading && !refreshing" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" /> <view v-for="i in 2" :key="i" class="skeleton-card" />
</view> </view>
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap"> <view v-else-if="allMemberships.length === 0" class="empty-wrap">
<view class="empty-card"> <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-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text> <text class="empty-sub">选一张适合自己的卡开始第一次练习</text>
<view class="empty-btn" @tap="goStore"> <button class="empty-btn" @tap="goStore">选购会员卡</button>
<text class="empty-btn-text">去选购</text>
</view>
</view> </view>
</view> </view>
<!-- Membership list -->
<view v-else class="list"> <view v-else class="list">
<!-- Active cards --> <view v-if="activeMemberships.length" class="group-section">
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header"> <view class="group-header">
<text class="group-title">有效会员卡</text> <text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} </text> <text class="group-count">{{ activeMemberships.length }} 有效卡</text>
</view> </view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<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 class="mc-top"> <view class="mc-top">
<view class="mc-name-area"> <view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text> <text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag"> <text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view> </view>
<view class="mc-status mc-status--active"> <text class="mc-status mc-status--active">有效</text>
<view class="mc-status-dot" /> </view>
<text class="mc-status-text">有效</text> <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>
</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-bottom">
<view class="mc-date-item"> <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> <text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view> </view>
<view class="mc-date-sep" /> <view class="mc-date-item mc-date-item--end">
<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">
<text class="mc-date-label">有效期至</text> <text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text> <text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view> </view>
</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)"> <view v-if="inactiveMemberships.length" class="group-section">
<text class="mc-renew-text">续同款</text> <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> </view>
</view> </view>
<view class="scroll-bottom-spacer" /> <view class="scroll-bottom-spacer" />
</scroll-view> </scroll-view>
<view v-if="allMemberships.length" class="purchase-dock">
<!-- Buy more FAB --> <button class="purchase-btn" @tap="goStore">选购会员卡</button>
<view class="fab" @tap="goStore">
<text class="fab-text">+ 购买会员卡</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <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 type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
@@ -166,7 +99,9 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore() 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 loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
@@ -218,8 +153,15 @@ async function loadMemberships() {
async function onRefresh() { async function onRefresh() {
refreshing.value = true refreshing.value = true
await userStore.fetchMemberships() try {
refreshing.value = false await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
}
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
} }
function goStore() { function goStore() {
@@ -235,408 +177,56 @@ function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` }) uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
} }
onMounted(() => { onShow(loadMemberships)
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.membership-page { .membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
min-height: 100vh; .scroll { flex: 1; height: 0; min-height: 0; }
background: $bg-page; .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; }
.scroll { .empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
height: 100vh; .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; } }
/* ── Loading ─────────────────────────────── */ .list { padding: 28rpx 32rpx 0; }
.loading-wrap { .group-section + .group-section { margin-top: 36rpx; }
padding: 24rpx; .group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
display: flex; .group-title { font-size: 28rpx; font-weight: 500; }
flex-direction: column; .group-count { font-size: 22rpx; color: #8b817b; }
gap: 24rpx; .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; }
.skeleton-card { .mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
height: 320rpx; .mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
border-radius: 24rpx; .mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%); .mc-type-text { font-size: 22rpx; color: #8b817b; }
background-size: 400% 100%; .mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
animation: shimmer 1.4s infinite; .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; }
/* ── Empty ────────────────────────────────── */ .mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.empty-wrap { .mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
padding: 80rpx 24rpx; .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); }
.empty-card { .mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
position: relative; .mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
overflow: hidden; .mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
background: linear-gradient(135deg, #E8D5C4, #D8C8DC); .mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
border-radius: 24rpx; .mc-date-item--end { text-align: right; }
padding: 64rpx 40rpx; .mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
display: flex; .mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
flex-direction: column; .mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
align-items: center; .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; } }
gap: 16rpx; .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; } }
.empty-deco { .mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
position: absolute; .mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
border-radius: 50%; .mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
pointer-events: none; .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; } }
&--1 { .scroll-bottom-spacer { height: 20rpx; }
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;
}
</style> </style>

View File

@@ -20,6 +20,8 @@ import type {
StudioUploadCredential, StudioUploadCredential,
AdminMemberSummary, AdminMemberSummary,
AdminMemberDetail, AdminMemberDetail,
LessonSupplementRecord,
CreateLessonSupplementDto,
UpdateAdminMemberProfileDto, UpdateAdminMemberProfileDto,
AdminArrangeBookingDto, AdminArrangeBookingDto,
MembershipWithCardType, MembershipWithCardType,
@@ -173,6 +175,18 @@ export const useAdminStore = defineStore('admin', () => {
return get<UserMembership>(`/admin/members/${userId}/membership`) return get<UserMembership>(`/admin/members/${userId}/membership`)
} }
async function fetchLessonSupplements(userId: string): Promise<LessonSupplementRecord[]> {
return get<LessonSupplementRecord[]>(`/admin/members/${userId}/lesson-supplements`)
}
async function createLessonSupplement(userId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
return post<LessonSupplementRecord>(`/admin/members/${userId}/lesson-supplements`, { ...dto })
}
async function revokeLessonSupplement(userId: string, id: string): Promise<void> {
await post(`/admin/members/${userId}/lesson-supplements/${id}/revoke`)
}
async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> { async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> {
return get<AdminMemberDetail>(`/admin/members/${userId}`) return get<AdminMemberDetail>(`/admin/members/${userId}`)
} }
@@ -295,6 +309,9 @@ export const useAdminStore = defineStore('admin', () => {
// Members // Members
fetchMembers, fetchMembers,
fetchMemberDetail, fetchMemberDetail,
fetchLessonSupplements,
createLessonSupplement,
revokeLessonSupplement,
updateMemberProfile, updateMemberProfile,
arrangeMemberBooking, arrangeMemberBooking,
getUserMembership, getUserMembership,

View File

@@ -11,6 +11,13 @@ interface RequestOptions {
readonly header?: Record<string, string> readonly header?: Record<string, string>
} }
export class HttpRequestError extends Error {
constructor(message: string, readonly statusCode: number) {
super(message)
this.name = 'HttpRequestError'
}
}
export function request<T>(options: RequestOptions): Promise<T> { export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string const token = uni.getStorageSync('token') as string
@@ -34,7 +41,7 @@ export function request<T>(options: RequestOptions): Promise<T> {
} }
if (res.statusCode >= 400) { if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown> const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`)) reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
return return
} }
const body = res.data as ApiResponse<T> const body = res.data as ApiResponse<T>

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE `lesson_supplements` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`request_id` VARCHAR(80) NOT NULL,
`quantity` INTEGER NOT NULL,
`membership_id` VARCHAR(191) NULL,
`deducted_times` INTEGER NOT NULL DEFAULT 0,
`card_name` VARCHAR(191) NULL,
`remark` VARCHAR(200) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`operator_name` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`revoked_at` DATETIME(3) NULL,
`revoked_by` VARCHAR(191) NULL,
INDEX `lesson_supplements_user_id_revoked_at_created_at_idx`(`user_id`, `revoked_at`, `created_at`),
UNIQUE INDEX `lesson_supplements_user_id_request_id_key`(`user_id`, `request_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -84,6 +84,7 @@ model User {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
lessonSupplements LessonSupplement[]
memberships Membership[] memberships Membership[]
bookings Booking[] bookings Booking[]
orders Order[] orders Order[]
@@ -155,6 +156,7 @@ model Membership {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
lessonSupplements LessonSupplement[]
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id]) cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[] bookings Booking[]
@@ -307,7 +309,7 @@ model InviteRewardGrant {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id]) inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id]) membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
@@index([inviterId, grantedAt]) @@index([inviterId, grantedAt])
@@map("invite_reward_grants") @@map("invite_reward_grants")
@@ -373,3 +375,27 @@ model FlashSaleOrder {
@@index([status]) @@index([status])
@@map("flash_sale_orders") @@map("flash_sale_orders")
} }
// Historical totals without fabricated scheduled dates.
model LessonSupplement {
id String @id @default(uuid())
userId String @map("user_id")
requestId String @map("request_id") @db.VarChar(80)
quantity Int
membershipId String? @map("membership_id")
deductedTimes Int @default(0) @map("deducted_times")
cardName String? @map("card_name")
remark String? @db.VarChar(200)
operatorId String @map("operator_id")
operatorName String @map("operator_name")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
revokedBy String? @map("revoked_by")
user User @relation(fields: [userId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
@@unique([userId, requestId])
@@index([userId, revokedAt, createdAt])
@@map("lesson_supplements")
}

View File

@@ -0,0 +1,205 @@
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { LessonSupplementController } from '../lesson-supplement.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { ConflictException, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'
import { validate } from 'class-validator'
import { LessonSupplementService } from '../lesson-supplement.service'
import { CreateLessonSupplementDto } from '../dto/create-lesson-supplement.dto'
import { PrismaService } from '../../prisma/prisma.service'
const dto = { requestId: 'supp_request_123456', quantity: 10 }
const makeRecord = (extra = {}) => ({
id: 'supp-1', userId: 'member', ...dto, membershipId: null,
deductedTimes: 0, cardName: null, remark: null, operatorId: 'teacher', operatorName: '老师',
createdAt: new Date('2026-09-08T04:00:00Z'), revokedAt: null, revokedBy: null, ...extra,
})
const makeCard = (extra = {}) => ({
id: 'card', userId: 'member', remainingTimes: 20, status: 'ACTIVE',
expireDate: new Date('2099-01-01'), updatedAt: new Date('2026-09-01'), cardType: { name: '私教次卡' }, ...extra,
})
describe('LessonSupplementService', () => {
let service: LessonSupplementService
let db: any
beforeEach(() => {
db = {
user: { findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: where.id, nickname: '老师' })) },
membership: { findUnique: jest.fn().mockResolvedValue(makeCard()), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
lessonSupplement: {
findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(makeRecord()),
findMany: jest.fn().mockResolvedValue([makeRecord()]),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(makeRecord(data))),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
}
db.$transaction = jest.fn(callback => callback(db))
service = new LessonSupplementService(db as PrismaService)
})
it('records ten historical classes without a card, time slot or booking', async () => {
const result = await service.create('member', 'teacher', dto)
expect(result.quantity).toBe(10)
expect(result.deductedTimes).toBe(0)
expect(db.membership.updateMany).not.toHaveBeenCalled()
expect(db.lessonSupplement.create).toHaveBeenCalledWith({ data: expect.objectContaining({ operatorId: 'teacher', operatorName: '老师', membershipId: null }) })
})
it('atomically deducts selected card and preserves the actual deduction snapshot', async () => {
const result = await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(result.deductedTimes).toBe(10)
expect(result.cardName).toBe('私教次卡')
expect(db.$transaction).toHaveBeenCalledTimes(1)
expect(db.membership.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'card', userId: 'member', remainingTimes: 20 }),
data: { remainingTimes: { decrement: 10 }, status: 'ACTIVE' },
})
})
it('marks an exhausted card used up', async () => {
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 10 }))
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('USED_UP')
})
it('allows historical deduction from an expired card without reactivating it', async () => {
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
})
it.each([
[makeCard({ userId: 'another-member' }), '请选择该学员的会员卡'],
[null, '请选择该学员的会员卡'],
[makeCard({ remainingTimes: 9 }), '会员卡剩余次数不足'],
[makeCard({ remainingTimes: null }), '不限次会员卡无需扣次'],
])('rejects invalid card %j before writing', async (card, message) => {
db.membership.findUnique.mockResolvedValue(card)
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toThrow(message)
expect(db.lessonSupplement.create).not.toHaveBeenCalled()
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('rejects nonexistent members', async () => {
db.user.findUnique.mockResolvedValue(null)
await expect(service.create('missing', 'teacher', dto)).rejects.toBeInstanceOf(NotFoundException)
})
it('fails the transaction if the membership changed concurrently', async () => {
db.membership.updateMany.mockResolvedValue({ count: 0 })
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toBeInstanceOf(ConflictException)
})
it('replays a lost success response without another write or deduction', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
expect((await service.create('member', 'teacher', dto)).id).toBe('supp-1')
expect(db.$transaction).not.toHaveBeenCalled()
})
it('does not reuse a request identity for a different quantity', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
await expect(service.create('member', 'teacher', { ...dto, quantity: 20 })).rejects.toBeInstanceOf(ConflictException)
})
it('does not replay a revoked supplement', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord({ revokedAt: new Date() }))
await expect(service.create('member', 'teacher', dto)).rejects.toBeInstanceOf(ConflictException)
})
it('recovers a simultaneous duplicate submit after unique-key rejection', async () => {
db.lessonSupplement.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRecord())
db.lessonSupplement.create.mockRejectedValue(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
expect((await service.create('member', 'teacher', dto)).quantity).toBe(10)
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('refunds precisely the snapshot and reactivates an exhausted unexpired card', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 0, status: 'USED_UP' }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany.mock.calls[0][0].data).toEqual({ remainingTimes: { increment: 10 }, status: 'ACTIVE' })
expect(db.lessonSupplement.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'supp-1', userId: 'member', revokedAt: null }, data: { revokedAt: expect.any(Date), revokedBy: 'teacher' } })
})
it('keeps an expired card expired on refund', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
})
it('does not refund a non-deducting supplement', async () => {
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('does not double-refund an already revoked record', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ revokedAt: new Date(), deductedTimes: 10, membershipId: 'card' }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('does not double-refund a concurrent revocation', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.lessonSupplement.updateMany.mockResolvedValue({ count: 0 })
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('rejects revocation of another member record', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(null)
await expect(service.revoke('other', 'supp-1', 'teacher')).rejects.toBeInstanceOf(NotFoundException)
expect(db.lessonSupplement.findFirst).toHaveBeenCalledWith({ where: { id: 'supp-1', userId: 'other' } })
})
it('blocks refund after a card is changed to unlimited', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: null }))
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
})
it('fails revocation transaction when a concurrent card update wins', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.updateMany.mockResolvedValue({ count: 0 })
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
})
it('limits member history to their non-revoked supplements', async () => {
await service.list('member')
expect(db.lessonSupplement.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
await service.list('member', true)
expect(db.lessonSupplement.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
})
})
describe('CreateLessonSupplementDto', () => {
it.each([0, -1, 1.5, 1000, '10', null, undefined])('rejects invalid quantity %s', async quantity => {
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), dto, { quantity }))
expect(errors.some(error => error.property === 'quantity')).toBe(true)
})
it('accepts a quantity-only supplement', async () => {
expect(await validate(Object.assign(new CreateLessonSupplementDto(), dto))).toEqual([])
})
it('rejects oversized notes and missing retry identity', async () => {
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), { quantity: 10, remark: '字'.repeat(201) }))
expect(errors.map(error => error.property)).toEqual(expect.arrayContaining(['requestId', 'remark']))
})
})
describe('LessonSupplement authorization', () => {
it('requires authentication for all supplement endpoints', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, LessonSupplementController)).toContain(JwtAuthGuard)
})
it.each(['list', 'create', 'revoke'] as const)('restricts %s to teachers/admins', method => {
const handler = LessonSupplementController.prototype[method]
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => LessonSupplementController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -53,6 +53,10 @@ const makeBooking = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const mockPrisma = { const mockPrisma = {
lessonSupplement: {
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
groupBy: jest.fn().mockResolvedValue([]),
},
user: { user: {
findUnique: jest.fn(), findUnique: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),
@@ -374,6 +378,13 @@ describe('UserService', () => {
expect(result.totalBookings).toBe(2) expect(result.totalBookings).toBe(2)
}) })
it('adds ten active historical classes only to lifetime total', async () => {
mockPrisma.booking.findMany.mockResolvedValue([makeBooking(dateInMonth(1), '09:00', '10:00')])
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
expect(await service.getStats('user-1')).toEqual({ totalBookings: 11, totalDays: 1, monthBookings: 1, monthDays: 1, monthHours: 1 })
expect(mockPrisma.lessonSupplement.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-1', revokedAt: null }, _sum: { quantity: true } })
})
it('counts distinct dates for totalDays', async () => { it('counts distinct dates for totalDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([ mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'), makeBooking(dateInMonth(1), '09:00', '10:00'),
@@ -436,6 +447,16 @@ describe('UserService', () => {
}) })
}) })
it('batch-adds supplements to member-list completed counts without inflating reservations', async () => {
mockPrisma.user.findMany.mockResolvedValue([makeUser({ memberships: [] })])
mockPrisma.user.count.mockResolvedValue(1)
mockPrisma.booking.groupBy.mockResolvedValue([{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }])
mockPrisma.lessonSupplement.groupBy.mockResolvedValueOnce([{ userId: 'user-1', _sum: { quantity: 10 } }])
const result = await service.getMembers(1, 20)
expect(result.items[0]).toMatchObject({ totalBookings: 3, completedBookings: 13 })
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
})
describe('getMemberDetail', () => { describe('getMemberDetail', () => {
const cardType = { const cardType = {
id: 'ct-1', id: 'ct-1',
@@ -490,6 +511,7 @@ describe('UserService', () => {
}, },
]) ])
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
const result = await service.getMemberDetail('user-1') const result = await service.getMemberDetail('user-1')
expect(result.user.userId).toBe('user-1') expect(result.user.userId).toBe('user-1')
@@ -498,7 +520,7 @@ describe('UserService', () => {
expect(result.memberships[0].cardType.name).toBe('10次卡') expect(result.memberships[0].cardType.name).toBe('10次卡')
expect(result.stats).toEqual({ expect(result.stats).toEqual({
totalBookings: 5, totalBookings: 5,
completedBookings: 3, completedBookings: 13,
cancelledBookings: 1, cancelledBookings: 1,
noShowBookings: 1, noShowBookings: 1,
}) })

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator'
export class CreateLessonSupplementDto {
@IsString()
@Matches(/^[a-zA-Z0-9_-]{16,80}$/)
readonly requestId!: string
@IsInt()
@Min(1)
@Max(999)
readonly quantity!: number
@IsOptional()
@IsString()
@Matches(/\S/)
@MaxLength(191)
readonly membershipId?: string
@IsOptional()
@IsString()
@MaxLength(200)
readonly remark?: string
}

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { LessonSupplementService } from './lesson-supplement.service'
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
@Controller()
@UseGuards(JwtAuthGuard)
export class LessonSupplementController {
constructor(private readonly service: LessonSupplementService) {}
@Get('user/lesson-supplements')
listMine(@CurrentUser('sub') userId: string) { return this.service.list(userId) }
@Get('admin/members/:userId/lesson-supplements')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
list(@Param('userId') userId: string) { return this.service.list(userId, true) }
@Post('admin/members/:userId/lesson-supplements')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
create(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: CreateLessonSupplementDto) {
return this.service.create(userId, operatorId, dto)
}
@Post('admin/members/:userId/lesson-supplements/:id/revoke')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
async revoke(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string) {
await this.service.revoke(userId, id, operatorId)
return { revoked: true }
}
}

View File

@@ -0,0 +1,115 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { LessonSupplement, Prisma } from '@prisma/client'
import { LessonSupplementRecord, MembershipStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
@Injectable()
export class LessonSupplementService {
constructor(private readonly prisma: PrismaService) {}
private serialize(row: LessonSupplement): LessonSupplementRecord {
return {
id: row.id, requestId: row.requestId, quantity: row.quantity,
deductedTimes: row.deductedTimes, cardName: row.cardName,
remark: row.remark, operatorName: row.operatorName,
createdAt: row.createdAt.toISOString(), revokedAt: row.revokedAt?.toISOString() ?? null,
}
}
async list(userId: string, includeRevoked = false): Promise<LessonSupplementRecord[]> {
const records = await this.prisma.lessonSupplement.findMany({
where: { userId, ...(includeRevoked ? {} : { revokedAt: null }) },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
})
return records.map(row => this.serialize(row))
}
private replay(row: LessonSupplement, dto: CreateLessonSupplementDto) {
if (row.quantity !== dto.quantity || row.membershipId !== (dto.membershipId ?? null)
|| row.remark !== (dto.remark?.trim() || null)) {
throw new ConflictException('这次补录已提交,请刷新记录后再操作')
}
if (row.revokedAt) throw new ConflictException('这次补录已撤销,请重新填写')
return this.serialize(row)
}
async create(userId: string, operatorId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
const key = { userId_requestId: { userId, requestId: dto.requestId } }
// Persisted request identity makes retries safe, including a lost success response.
const existing = await this.prisma.lessonSupplement.findUnique({ where: key })
if (existing) return this.replay(existing, dto)
try {
return await this.prisma.$transaction(async tx => {
const user = await tx.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('会员不存在')
const operator = await tx.user.findUnique({ where: { id: operatorId } })
if (!operator) throw new NotFoundException('操作人不存在')
const card = dto.membershipId
? await tx.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true } })
: null
if (dto.membershipId && (!card || card.userId !== userId)) {
throw new BadRequestException('请选择该学员的会员卡')
}
if (card && (card.remainingTimes === null || card.remainingTimes < dto.quantity)) {
throw new BadRequestException(card.remainingTimes === null ? '不限次会员卡无需扣次,请选择仅补记录' : '会员卡剩余次数不足')
}
// Historical use may belong to an expired card. Its expiry/status stays expired.
const row = await tx.lessonSupplement.create({ data: {
userId, requestId: dto.requestId, quantity: dto.quantity,
membershipId: card?.id ?? null, deductedTimes: card ? dto.quantity : 0,
cardName: card?.cardType.name ?? null, remark: dto.remark?.trim() || null,
operatorId, operatorName: operator.nickname || '老师',
} })
if (card) {
const remaining = card.remainingTimes! - dto.quantity
const changed = await tx.membership.updateMany({
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { decrement: dto.quantity },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
}
return this.serialize(row)
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const row = await this.prisma.lessonSupplement.findUnique({ where: key })
if (row) return this.replay(row, dto)
}
throw error
}
}
async revoke(userId: string, id: string, operatorId: string): Promise<void> {
await this.prisma.$transaction(async tx => {
const row = await tx.lessonSupplement.findFirst({ where: { id, userId } })
if (!row) throw new NotFoundException('补录记录不存在')
if (row.revokedAt) return
// Claim once before refunding; concurrent revocations cannot refund twice.
const claimed = await tx.lessonSupplement.updateMany({
where: { id, userId, revokedAt: null }, data: { revokedAt: new Date(), revokedBy: operatorId },
})
if (!claimed.count) return
if (row.deductedTimes > 0) {
if (!row.membershipId) throw new ConflictException('原会员卡不存在,请先核对扣次记录')
const card = await tx.membership.findUnique({ where: { id: row.membershipId } })
if (!card || card.remainingTimes === null) {
throw new ConflictException('原会员卡已变更为不限次或不存在,请先核对会员卡后再撤销')
}
const restored = await tx.membership.updateMany({
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { increment: row.deductedTimes },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : MembershipStatus.ACTIVE,
},
})
if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
}
})
}
}

View File

@@ -5,10 +5,13 @@ import { UserController } from './user.controller'
import { UserService } from './user.service' import { UserService } from './user.service'
import { SubscriptionMessageService } from './subscription-message.service' import { SubscriptionMessageService } from './subscription-message.service'
import { LessonSupplementService } from './lesson-supplement.service'
import { LessonSupplementController } from './lesson-supplement.controller'
@Module({ @Module({
imports: [AuthModule, ConfigModule], imports: [AuthModule, ConfigModule],
controllers: [UserController], controllers: [UserController, LessonSupplementController],
providers: [UserService, SubscriptionMessageService], providers: [LessonSupplementService, UserService, SubscriptionMessageService],
exports: [UserService, SubscriptionMessageService], exports: [UserService, SubscriptionMessageService],
}) })
export class UserModule {} export class UserModule {}

View File

@@ -319,6 +319,9 @@ export class UserService {
}, },
}) })
const supplements = await this.prisma.lessonSupplement.aggregate({
where: { userId, revokedAt: null }, _sum: { quantity: true },
})
const now = new Date() const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
@@ -344,7 +347,7 @@ export class UserService {
}, 0) }, 0)
return { return {
totalBookings: completedBookings.length, totalBookings: completedBookings.length + (supplements._sum.quantity ?? 0),
totalDays, totalDays,
monthBookings: monthBookings.length, monthBookings: monthBookings.length,
monthDays, monthDays,
@@ -441,6 +444,14 @@ export class UserService {
statsMap.set(stat.userId, entry) statsMap.set(stat.userId, entry)
} }
const supplements = userIds.length
? await this.prisma.lessonSupplement.groupBy({
by: ['userId'], where: { userId: { in: userIds }, revokedAt: null },
_sum: { quantity: true },
})
: []
const supplementMap = new Map(supplements.map(row => [row.userId, row._sum.quantity ?? 0]))
const items = users.map((u) => { const items = users.map((u) => {
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 } const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
const active = u.memberships[0] const active = u.memberships[0]
@@ -456,7 +467,7 @@ export class UserService {
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory } ? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
: null, : null,
totalBookings: s.total, totalBookings: s.total,
completedBookings: s.completed, completedBookings: s.completed + (supplementMap.get(u.id) ?? 0),
cancelledBookings: s.cancelled, cancelledBookings: s.cancelled,
} }
}) })
@@ -515,6 +526,10 @@ export class UserService {
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
} }
const supplements = await this.prisma.lessonSupplement.aggregate({
where: { userId, revokedAt: null }, _sum: { quantity: true },
})
return { return {
user: { user: {
userId: user.id, userId: user.id,
@@ -528,7 +543,7 @@ export class UserService {
memberships: user.memberships.map((membership) => serializeMembership(membership)), memberships: user.memberships.map((membership) => serializeMembership(membership)),
stats: { stats: {
totalBookings: stats.total, totalBookings: stats.total,
completedBookings: stats.completed, completedBookings: stats.completed + (supplements._sum.quantity ?? 0),
cancelledBookings: stats.cancelled, cancelledBookings: stats.cancelled,
noShowBookings: stats.noShow, noShowBookings: stats.noShow,
}, },

View File

@@ -62,6 +62,8 @@ export type {
AdminMemberUpcomingBooking, AdminMemberUpcomingBooking,
AdminMemberBookingStats, AdminMemberBookingStats,
AdminMemberDetail, AdminMemberDetail,
LessonSupplementRecord,
CreateLessonSupplementDto,
UpdateAdminMemberProfileDto, UpdateAdminMemberProfileDto,
CardType, CardType,
CreateCardTypeDto, CreateCardTypeDto,

View File

@@ -8,6 +8,8 @@ export type {
AdminMemberUpcomingBooking, AdminMemberUpcomingBooking,
AdminMemberBookingStats, AdminMemberBookingStats,
AdminMemberDetail, AdminMemberDetail,
LessonSupplementRecord,
CreateLessonSupplementDto,
UpdateAdminMemberProfileDto, UpdateAdminMemberProfileDto,
} from './user' } from './user'
export type { export type {

View File

@@ -95,3 +95,23 @@ export interface UpdateAdminMemberProfileDto {
readonly nickname?: string readonly nickname?: string
readonly phone?: string | null readonly phone?: string | null
} }
/** Historical classes with no inferred attendance date. */
export interface LessonSupplementRecord {
readonly id: string
readonly requestId: string
readonly quantity: number
readonly deductedTimes: number
readonly cardName: string | null
readonly remark: string | null
readonly operatorName: string
readonly createdAt: string
readonly revokedAt: string | null
}
export interface CreateLessonSupplementDto {
readonly requestId: string
readonly quantity: number
readonly membershipId?: string
readonly remark?: string
}