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