20 Commits

Author SHA1 Message Date
richarjiang
b8c0dd6781 fix(server): hide CLOSED rows from preview for legacy mini-program compatibility
The previous fix made publishDaySlots CLOSE rows instead of DELETE them.
That works correctly on the server, but the live mini-program still ships
the old schedule.vue which only knows about isPublished (no status
field). It would re-render the CLOSED rows with the '已发布' badge,
making it look like 'clear day' did nothing.

Hide CLOSED rows from getSchedulePreview so the legacy client shows
'当日暂无排课' for rest days — the same UX it had before this fix
shipped. All-CLOSED days return []; days with a mix return only the
non-CLOSED rows; days the cron hasn't touched still return the ghost
template.

The republished mini-program (with status-aware rendering) will see the
same [] for rest days, which is consistent. Tests: 4 new specs covering
all-OPEN / all-CLOSED / mixed / empty-day cases.
2026-09-10 22:36:41 +08:00
richarjiang
806f3ee770 fix(server): preserve admin 'rest day' intent across cron runs
publishDaySlots used to DELETE the TimeSlot rows of a day that an admin
just 'cleared', which made the nightly slot-generation cron resurrect
the default schedule the next morning — a real production bug where
classes would reappear on a teacher's rest day.

Changes:
- publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes
  the default template for days with no rows so an empty publish still
  records intent. update on existing rows preserves their current status,
  so re-publishing the page cannot silently reopen a CLOSED rest day.
- SlotGeneratorService.generateSlots pre-fetches dates that already have any
  TimeSlot row and skips them, so the cron no longer overwrites admin-touched
  days even when admin only set status without changing times.
- SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots
  with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule
  above does not leak rows forever.
- schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and
  card style.

Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots
behaviour) and slot-generator.service.spec.ts (skip touched dates +
prune conditions). Full suite: 315 / 315 passing.
2026-09-10 22:32:32 +08:00
richarjiang
9af558286b chore: 清理管理中心 fetchDashboardStats 与 GET /admin/stats 已无用的接口 2026-09-10 19:23:08 +08:00
richarjiang
99814f3720 refactor(app): 移除管理中心顶部统计卡,数据已在统计分析页提供 2026-09-10 19:22:44 +08:00
richarjiang
014685ed04 refactor(app): 管理中心列表改为纯文字入口并按业务重组分组 2026-09-10 19:22:18 +08:00
richarjiang
9f5608f13c fix(app): 教学报告两张卡片合并为同一列表组,去掉四周圆角 2026-09-10 19:21:18 +08:00
richarjiang
3e049d2c1d feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:20:13 +08:00
richarjiang
d32f592e54 fix(app): 修复预约页首次进入定位当前时段不生效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:33:37 +08:00
richarjiang
d793749134 fix(app): 在确认手势内同步调起订阅,预约页首次进入定位到当前时段
微信要求授权框落在 tap 同步栈,先 await 会丢失弹层;订阅失败不再打断预约或支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 14:53:14 +08:00
richarjiang
57edd8dcc0 perf: 移除秒杀相关功能 2026-09-10 11:33:19 +08:00
richarjiang
51dea488f6 feat: 优化预定页面UI和交互 2026-09-10 11:00:13 +08:00
richarjiang
22407a7ff9 fix(booking): 允许管理员/老师通过同一接口取消任意预约
将 cancelBooking 改为接收 actor({ id, isAdmin }),服务端按 JWT role
分流:普通成员仅能取消自己的预约,管理员跳过 owner check。
history 备注自动按 actor 选择「学员/管理员」前缀,审计可追溯。

客户端无感,仍调 PUT /booking/:id/cancel,前端零改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 19:50:41 +08:00
richarjiang
d941f1b6a9 perf: 优化进度条 2026-09-09 19:19:18 +08:00
richarjiang
6fab1155c7 fix: 防止成长档案编辑弹窗被误关
微信端点击输入会冒泡到遮罩,且 onShow 刷新会清空编辑态,导致体测、笔记和照片无法填写。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:02:24 +08:00
richarjiang
139882d7a1 feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00
richarjiang
c3e46f7ffa fix(teaching-schedule): 当日课表展示所有状态,并支持点入课程详情
- 后端 getTeachingScheduleByDate 在查询当天时不再以 PENDING/CONFIRMED 过滤 timeSlot
  也不限制 include.bookings 的状态,但仍保留 EXISTS 守卫过滤空 slot
- 其他日期维持原「只看待上课」语义
- 抽出私有 formatLocalDate 工具方法,消除服务端 YYYY-MM-DD 构造的 3 处重复
- 前端 teaching-schedule 的 session 卡片可点击跳到 /pages/booking/detail?slotId=
- 补全 student__status--completed / --cancelled 样式
2026-09-09 14:51:06 +08:00
richarjiang
75ef5e94a6 refactor: 调整 admin 目录结构并精简个人中心快捷区
- 将 admin 相关的 store/utils 移入对应子目录(pages/admin/stores、pages/admin/utils)
- 更新 manifest.json、pages.json 路由与配置
- 个人中心 ProfileMenu 移除顶部「我的会员卡 / 我的预约」快捷卡片,与 UserCard 会员卡入口合并
- 「我的预约」下移至菜单列表,紧邻「个人信息」
- 清理 profile/index.vue 中不再使用的 bookingStore / 计算属性 / 透传
2026-09-09 14:36:34 +08:00
richarjiang
57107c02dc perf: 优化个人中心会员卡进度展示 2026-09-09 11:27:03 +08:00
richarjiang
726c65f0f0 perf: 会员卡页面支持分享 2026-09-09 10:28:11 +08:00
richarjiang
6e8fc45138 fix: 修复默认启动入口 2026-09-09 10:26:16 +08:00
107 changed files with 4860 additions and 3995 deletions

15
.deploy-tmp/check-fk.js Normal file
View File

@@ -0,0 +1,15 @@
// One-off diagnostic — list FK constraints on orders.flash_sale_id
// Run from packages/server so .env is loaded.
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
;(async () => {
const rows = await p.$queryRawUnsafe(`
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('orders', 'flash_sales', 'flash_sale_orders')
ORDER BY TABLE_NAME, ORDINAL_POSITION
`)
console.log('FK rows:', JSON.stringify(rows, null, 2))
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

View File

@@ -0,0 +1,33 @@
// Mark the failed drop_flash_sale migration as rolled_back so deploy can retry.
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
const dump = (x) => JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2)
;(async () => {
const before = await p.$queryRawUnsafe(
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
FROM _prisma_migrations
WHERE migration_name = ?`,
'20260910030338_drop_flash_sale'
)
console.log('before:', dump(before))
const result = await p.$executeRawUnsafe(
`UPDATE _prisma_migrations
SET rolled_back_at = NOW()
WHERE migration_name = ?
AND finished_at IS NULL
AND rolled_back_at IS NULL`,
'20260910030338_drop_flash_sale'
)
console.log('rows updated:', result)
const after = await p.$queryRawUnsafe(
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
FROM _prisma_migrations
WHERE migration_name = ?`,
'20260910030338_drop_flash_sale'
)
console.log('after:', dump(after))
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

View File

@@ -0,0 +1,27 @@
// Verify migration result on production
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
;(async () => {
const tables = await p.$queryRawUnsafe(`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE '%flash%'
`)
const ordersCol = await p.$queryRawUnsafe(`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'orders'
AND COLUMN_NAME = 'flash_sale_id'
`)
const mig = await p.$queryRawUnsafe(`
SELECT migration_name, finished_at, rolled_back_at
FROM _prisma_migrations
WHERE migration_name = '20260910030338_drop_flash_sale'
`)
console.log('flash tables:', tables)
console.log('orders.flash_sale_id col:', ordersCol)
console.log('migration row:', mig)
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

View File

@@ -98,3 +98,19 @@ pnpm deploy:server # 部署后端到生产环境
- 当前无老师归属字段统计范围是工作室operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。
### 个人中心会员卡包
- 个人中心资料区域以单行展示持有卡种和张数,下方每张卡以单行浅色进度槽承载卡名和用量文字,不使用大卡片或轮播;点击进入我的会员卡。`OwnedMembershipCard.vue` 在我的会员卡页面展示余额、已用进度和到期日。
- 累计上课、本月上课、剩余课时集中在我的会员卡页面,个人资料卡不重复展示汇总。
- 次数限制以 remainingTimes 是否为 null 判断,不能按卡种推断。次数进度表示已用占总次数,已用包括预约占用;不限次卡不伪造耗课数。
- 会员卡加载失败显示重试,不当作无卡;会话变化时丢弃旧请求结果。
### 课后评价与成长档案
- 评价归属 booking 模块,档案归属 user 模块;共享契约放 shared/src/types/member-care.ts页面沿用 booking/profile/admin 目录,不新增顶层业务目录。
- COMPLETED 后立即可评价,无 24 小时截止;完成后 24 小时仅提醒未评价预约。唯一 bookingId 防重复,提醒状态保存在 Booking 上,定时任务原子领取,未知发送结果不自动重发。
- 星级均分与 NPS 分开NPS 仅使用可选 010 推荐意愿910 推荐者、06 贬损者),按中国自然月聚合并展示样本数。
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0累计课时包含有效补录里程碑为 10/30/50 节。
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL读取用短时签名禁止落库公共链接上传凭证绑定学员及照片记录。
- 新增迁移目录只放 migration.sql测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API避免主包引用 admin 分包 Store馆主评价页面仍通过 admin Store 访问。

View File

@@ -0,0 +1,41 @@
# 移除秒杀功能
按业务调整下线限时秒杀活动。前后端代码、共享类型、Prisma schema 与迁移均已同步清理。
## 清理范围
| 层 | 内容 |
| --- | --- |
| 后端模块 | `packages/server/src/flash-sale/`(service、controller、admin controller、dto、测试) |
| 调度任务 | `SchedulerService.handleExpireFlashSaleReservations` 及其依赖注入 |
| 支付流程 | `PaymentService``flashSaleOrder` 标记为 PAID 的分支 |
| 前端页面 | `pages/flash-sale/detail.vue``pages/admin/flash-sales.vue` |
| 前端组件 | `components/FlashSaleSection.vue` |
| 前端状态 | `stores/flash-sale.ts` |
| 前端入口 | `pages/admin/index.vue` 秒杀管理菜单与对应样式 |
| 首页 | `pages/home/index.vue``FlashSaleSection` 引用 |
| 共享类型 | `shared/src/types/flash-sale.ts`,`enums.ts``FlashSaleStatus``FlashSaleOrderStatus`,以及 `index.ts` 的 re-export |
| 数据库 | Prisma 删除 `FlashSale``FlashSaleOrder` 模型、相关枚举与 `Order.flash_sale_id` 字段 |
## 数据库迁移
新增 `packages/server/prisma/migrations/20260910030338_drop_flash_sale/migration.sql`
执行 `pnpm prisma migrate deploy` 后以下内容会被移除:
- `flash_sales``flash_sale_orders` 两张表
- `orders.flash_sale_id` 列与外键
- `FlashSaleStatus``FlashSaleOrderStatus` 枚举
迁移以 MySQL 方言编写;SQLite 开发环境由 Prisma shadow database 自动重建。
## 验证
```bash
pnpm build:shared
pnpm build:server
pnpm test # 包含 payment / scheduler 套件
```
## 回退说明
回退需要还原 schema 与源码,本项目不保留 git 自动恢复外的额外兜底。如需重新启用秒杀,按 git 历史恢复即可,并运行 `pnpm prisma migrate reset` 重新初始化数据库。

41
docs/member-care.md Normal file
View File

@@ -0,0 +1,41 @@
# 课后评价与成长档案
入口:预约详情评价、个人中心「我的成长档案」、管理中心「课后评价」与会员档案「成长档案」。评价归属 booking 模块,档案归属 user 模块。共享契约在 `packages/shared/src/types/member-care.ts`
## 产品口径
- 课程变为 COMPLETED 后即可评价,没有 24 小时截止。完成后 24 小时只提醒仍未评价的预约。
- 同一预约只能评价一次。提醒状态写在 Booking 上,定时任务原子领取;发送结果未知或失败不自动重发。
- 星级均分与 NPS 分开。NPS 只用可选的 010 推荐意愿910 推荐者、06 贬损者),按中国自然月聚合并展示样本数。
- 教练私密笔记只在服务端过滤。课程批注必须属于该学员的已完成预约。
- 体测允许缺项,缺项不按 0 计算。累计课时含有效补录,里程碑为 10 / 30 / 50 节。
- 成长照片仅用于学员与馆主之间的档案,不用于公开宣传。学员本人授权或撤回,馆主不能代授权。误传或学员主动删除会同时删除数据库记录和 COS 对象。
## 部署配置
新增环境变量(见 `packages/server/.env.example`
| 变量 | 作用 |
| --- | --- |
| `WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW` | 课后评价订阅消息模板 ID |
成长照片与馆图共用 `COS_BUCKET``COS_SECRET_ID``COS_SECRET_KEY``COS_REGION`。对象写在 `progress/{userId}/` 下,上传带私有 ACL读取用约 60 秒签名 URL不落库公共链接。小程序合法域名沿用现有 COS 域名即可。
评价提醒模板字段由服务端按预约填充:`thing1` 课程名称(工作室名)、`thing2` 课程教练Iris`time3` 课程时间、`thing4` 温馨提示。未配置模板 ID 时定时任务不领取预约。
回退:先回退应用代码。新增表可保留;`progress/` 下对象不会被旧代码读取。不要在生产直接 `DROP TABLE`
## 迁移
目录 `packages/server/prisma/migrations/20260909120000_member_care/migration.sql`。发布时先 `prisma migrate deploy`,再发布后端,最后发布小程序。
## 验收清单
- 学员在 CONFIRMED 或 COMPLETED 且未评价时都可以订阅提醒;核销后仍能订阅。
- 完成后可立即评价;重复提交返回已评价。
- 首页匿名均分不含评论文案管理端趋势按中国自然月NPS 与星级分开展示。
- 私密笔记学员不可见;馆主在学员授权前不能读取照片,授权后可看,撤回后不能再签发。
- 误传照片可删除,删除后档案和 COS 对象都不再保留。
- 体测可只填一项,柔韧度允许负值;累计课时含未撤销补录。
- 未配置评价模板时,定时任务不领取预约。
- 小程序真机确认可上传、可用签名链接预览成长照片。

View File

@@ -96,6 +96,11 @@
</text>
</view>
<!-- Subscribe note -->
<view class="subscribe-tip">
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果课前1小时提醒与取消通知</text>
</view>
<!-- Action buttons -->
<view class="action-row">
<view class="btn-outline" @tap="handleCancel">
@@ -159,9 +164,7 @@ async function handleConfirm() {
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '订阅消息授权失败'
uni.showToast({ title: message, icon: 'none' })
return
console.warn('[subscribe] booking confirm failed', err)
} finally {
requestingSubscribe.value = false
}
@@ -212,6 +215,8 @@ function handleMaskTap() {
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }

View File

@@ -0,0 +1,76 @@
<template>
<view class="review">
<text class="eyebrow">课后 · 留一点感受</text>
<text class="title">这节课感觉怎么样</text>
<text class="hint">文字与标签仅你和馆主可见星级会计入首页匿名均分</text>
<view v-if="loading" class="hint">正在读取评价</view>
<view v-else-if="error" class="hint">{{ error }}<button class="secondary" @tap="load">重新加载</button></view>
<template v-else-if="review">
<text class="saved-stars">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</text>
<view class="tags"><text v-for="tag in review.tags" :key="tag" class="tag selected">{{ tag }}</text></view>
<text class="comment">{{ review.comment || '谢谢你留下这份反馈。' }}</text>
<text class="hint">已评价 · {{ formatChinaDate(review.createdAt) }}</text>
</template>
<template v-else-if="canReview">
<view class="stars"><button v-for="n in 5" :key="n" :aria-label="n + ' 星'" :class="{ chosen: rating >= n }" @tap="rating = n">{{ rating >= n ? '★' : '☆' }}</button></view>
<text class="rating-label">{{ rating ? labels[rating - 1] : '轻触星星,为这节课评分' }}</text>
<view class="tags"><button v-for="tag in REVIEW_TAGS" :key="tag" class="tag" :class="{ selected: tags.includes(tag) }" @tap="toggle(tag)">{{ tag }}</button></view>
<text class="hint">可选最多 3 个标签</text>
<textarea v-model="comment" maxlength="200" placeholder="哪里让你有收获?还有什么可以做得更好?" />
<text class="counter">{{ comment.length }} / 200</text>
<picker :range="recommendations" @change="recommendation = Number($event.detail.value) - 1"><view class="recommend">你有多愿意推荐我们<text>{{ recommendation < 0 ? '选填 ' : recommendation + ' / 10 ' }}</text></view></picker>
<text class="hint">0 表示完全不愿意10 表示非常愿意</text>
<button class="primary" :loading="saving" :disabled="saving || !rating" @tap="submit">提交评价</button>
</template>
<text v-else class="hint">课程完成后即可评价</text>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { REVIEW_TAGS } from '@mp-pilates/shared'
import type { ClassReview } from '@mp-pilates/shared'
import { get, post } from '../utils/request'
import { formatChinaDate } from '../utils/format'
const props = defineProps<{ bookingId: string }>()
const review = ref<ClassReview | null>(null), canReview = ref(false), loading = ref(false), saving = ref(false), error = ref('')
const rating = ref(0), tags = ref<string[]>([]), comment = ref(''), recommendation = ref(-1)
const labels = ['不太满意', '有待改善', '整体还好', '很满意', '非常满意']
const recommendations = ['暂不填写', ...Array.from({ length: 11 }, (_, n) => String(n))]
let sequence = 0
async function load() {
const seq = ++sequence; loading.value = true; error.value = ''
try { const result = await get<{ review: ClassReview | null; canReview: boolean }>(`/booking/${props.bookingId}/review`); if (seq === sequence) { review.value = result.review; canReview.value = result.canReview } }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '评价加载失败' }
finally { if (seq === sequence) loading.value = false }
}
function toggle(tag: string) {
if (tags.value.includes(tag)) tags.value = tags.value.filter(t => t !== tag)
else if (tags.value.length < 3) tags.value = [...tags.value, tag]
else uni.showToast({ title: '最多选择 3 个标签', icon: 'none' })
}
async function submit() {
if (saving.value || !rating.value) return
saving.value = true
try { review.value = await post<ClassReview>(`/booking/${props.bookingId}/review`, { rating: rating.value, tags: tags.value, comment: comment.value, ...(recommendation.value >= 0 ? { recommendation: recommendation.value } : {}) }); uni.showToast({ title: '谢谢你的反馈', icon: 'success' }) }
catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '提交失败,请重试', icon: 'none' }) }
finally { saving.value = false }
}
watch(() => props.bookingId, () => { review.value = null; rating.value = 0; tags.value = []; comment.value = ''; recommendation.value = -1; void load() }, { immediate: true })
</script>
<style lang="scss" scoped>
.review { margin: 28rpx 32rpx; padding: 32rpx; border: 1rpx solid #e7e0d7; border-radius: 28rpx; background: #fffdf9; color: #514943; }
.eyebrow { display:block; color:#8c7867; font-size:22rpx; letter-spacing:3rpx; }
.title { display:block; margin:18rpx 0; font-family:'Songti SC','STSong',serif; font-size:38rpx; }
.hint { display:block; font-size:23rpx; line-height:1.8; color:#82796e; }
.stars { display:flex; justify-content:space-between; margin:24rpx 0 8rpx; }
.stars button { padding:0; margin:0; width:96rpx; height:96rpx; line-height:96rpx; font-size:60rpx; background:transparent; color:#b7ac9c; &::after {border:0;} &.chosen {color:#a5824b;} }
.rating-label {display:block; text-align:center; color:#8c7867; font-size:24rpx; margin-bottom:24rpx;}
.tags {display:flex;flex-wrap:wrap;gap:14rpx;margin:18rpx 0;}
.tag {margin:0;padding:15rpx 20rpx;font-size:24rpx;line-height:1.5;border-radius:16rpx;background:#f4f1eb;color:#72695f;&::after{border:0;} &.selected{background:#e7eee7;color:#4d685c;}}
textarea {margin-top:22rpx;padding:24rpx;width:100%;height:190rpx;box-sizing:border-box;background:#f6f3ed;border-radius:18rpx;font-size:26rpx;line-height:1.7;}
.counter{display:block;text-align:right;color:#8b817b;font-size:21rpx;margin:10rpx 0 20rpx;}
.recommend{display:flex;justify-content:space-between;gap:16rpx;align-items:center;min-height:88rpx;border-top:1rpx solid #eee8e0;font-size:24rpx;}
.primary,.secondary{margin-top:24rpx;min-height:88rpx;line-height:88rpx;border-radius:22rpx;background:#617d70;color:#fff;font-size:27rpx;&::after{border:0;}}
.secondary{background:#eee9df;color:#645c52;}.primary[disabled]{background:#d5dcd2;color:#697365;}
.saved-stars{display:block;font-size:46rpx;color:#a5824b;margin-top:24rpx;}.comment{display:block;line-height:1.8;font-size:27rpx;margin:20rpx 0;white-space:pre-wrap;}
</style>

View File

@@ -1,185 +0,0 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<text class="section-title">限时秒杀</text>
<text v-if="hasOngoing" class="live-note">进行中</text>
</view>
</view>
<!-- Horizontal scroll cards -->
<scroll-view
scroll-x
:show-scrollbar="false"
class="flash-scroll"
>
<view class="flash-card-list">
<view
v-for="sale in flashSales"
:key="sale.id"
class="flash-card"
:class="cardPhaseClass(sale.phase)"
@tap="goToDetail(sale.id)"
>
<!-- Top gradient band -->
<view class="card-top">
<!-- Phase badge -->
<view class="phase-badge" :class="badgeClass(sale.phase)">
<text class="phase-badge-text">{{ phaseLabel(sale.phase) }}</text>
</view>
<!-- Countdown / status text -->
<view class="countdown-row">
<text v-if="sale.phase === FlashSalePhase.UPCOMING" class="countdown-label">距开始</text>
<text v-else-if="sale.phase === FlashSalePhase.ONGOING" class="countdown-label">剩余</text>
<view v-if="sale.phase === FlashSalePhase.UPCOMING || sale.phase === FlashSalePhase.ONGOING" class="countdown-blocks">
<text class="cd-block">{{ getSaleCountdown(sale).h }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).m }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).s }}</text>
</view>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<text class="card-title">{{ sale.title }}</text>
<text class="card-type-name">{{ sale.cardType.name }}</text>
<!-- Price area -->
<view class="price-area">
<view class="flash-price-row">
<text class="flash-currency">¥</text>
<text class="flash-price">{{ formatPrice(invite.price(sale.flashPrice)) }}</text>
</view>
<text v-if="invite.eligible" class="original-price">好友 95 </text>
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
</view>
<!-- Stock progress -->
<view class="stock-area">
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': getStockRatio(sale.soldCount, sale.totalStock) > 0.6 }"
:style="{ width: stockPercent(sale) }"
/>
</view>
<text class="stock-text">
{{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `${sale.remainingStock}/${sale.totalStock}` }}
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useInviteStore } from "../stores/invite"
const invite = useInviteStore()
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleListItem } from '@mp-pilates/shared'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../utils/format'
import { get } from '../utils/request'
const flashSales = ref<FlashSaleListItem[]>([])
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const hasOngoing = computed(() =>
flashSales.value.some((s) => s.phase === FlashSalePhase.ONGOING),
)
async function fetchFlashSales() {
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
} catch {
flashSales.value = []
}
}
// Expose for parent page refresh
defineExpose({ fetchFlashSales })
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function cardPhaseClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'card--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'card--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'card--soldout'
return 'card--ended'
}
function badgeClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'badge--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'badge--upcoming'
return 'badge--inactive'
}
function stockPercent(sale: FlashSaleListItem): string {
return getStockPercent(sale.soldCount, sale.totalStock)
}
function getSaleCountdown(sale: FlashSaleListItem) {
void tick.value
const target = sale.phase === FlashSalePhase.UPCOMING ? sale.startTime : sale.endTime
return getCountdownParts(target)
}
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/flash-sale/detail?id=${id}` })
}
onMounted(() => {
fetchFlashSales()
timer = setInterval(() => {
tick.value++
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.flash-sale-section { margin: 36rpx 32rpx 0; }
.section-header { margin-bottom: 20rpx; }
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.live-note { font-size: 22rpx; color: #9b7b66; }
.flash-scroll { width: 100%; white-space: nowrap; }
.flash-card-list { display: inline-flex; gap: 20rpx; }
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
.card--upcoming .card-top { background: #eaf0e8; }
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
.countdown-label { font-size: 20rpx; color: #8b817b; }
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.card-type-name { font-size: 22rpx; color: #8b817b; }
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
.stock-text { font-size: 20rpx; color: #8b817b; }
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,54 @@
<template>
<view class="pass" :class="[tone, { 'pass--compact': compact }]">
<view class="pass-heading">
<view class="pass-identity"><text class="pass-kind">{{ getCardTypeLabel(membership.cardType.type) }} · MEMBERSHIP</text><text class="pass-name">{{ membership.cardType.name }}</text></view>
<text class="pass-mark">{{ compact ? '查看 ' : '有效' }}</text>
</view>
<view class="pass-balance">
<text class="balance-label">{{ unlimited ? '有效期内' : '剩余' }}</text>
<text class="balance-number">{{ unlimited ? '不限次' : membership.remainingTimes }}</text>
<text v-if="!unlimited" class="balance-unit"></text>
<text v-if="!unlimited && total !== null" class="usage-label">已用 {{ used }} / {{ total }} </text>
<text v-else-if="unlimited" class="usage-label">{{ days }} 天后到期</text>
</view>
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
<view v-else class="pass-rule" />
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view>
<slot />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
const unlimited = computed(() => props.membership.remainingTimes === null)
const total = computed(() => getMembershipTotalTimes(props.membership))
const used = computed(() => getMembershipUsedTimes(props.membership))
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000)))
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
</script>
<style scoped lang="scss">
.pass { --pass-bg: #f0e6d8; --pass-ink: #655240; --pass-line: #d7c3a9; --pass-track: #e3d4c1; box-sizing: border-box; padding: 30rpx; border-radius: 24rpx; background: var(--pass-bg); color: var(--pass-ink); border: 1rpx solid var(--pass-line); }
.pass--sage { --pass-bg: #e7eee4; --pass-ink: #465f4d; --pass-line: #b8c9b3; --pass-track: #d4dfce; }
.pass--clay { --pass-bg: #f2e5df; --pass-ink: #845c4a; --pass-line: #d9b9aa; --pass-track: #e7d0c4; }
.pass-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.pass-identity { min-width: 0; flex: 1; }
.pass-kind { display: block; font-size: 18rpx; letter-spacing: 2rpx; }
.pass-name { display: block; margin-top: 12rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; line-height: 1.4; overflow-wrap: anywhere; }
.pass-mark { flex-shrink: 0; font-size: 21rpx; padding-top: 4rpx; }
.pass-balance { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10rpx; margin-top: 30rpx; }
.balance-label, .balance-unit { font-size: 23rpx; }
.balance-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 60rpx; line-height: 1.2; font-variant-numeric: tabular-nums; }
.usage-label { font-size: 23rpx; margin-left: auto; }
.usage-track { height: 7rpx; border-radius: 8rpx; background: var(--pass-track); overflow: hidden; margin-top: 22rpx; }
.usage-fill { height: 100%; background: var(--pass-ink); border-radius: 8rpx; }
.pass-rule { height: 1rpx; margin-top: 22rpx; background: var(--pass-line); }
.pass-dates { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; font-size: 22rpx; font-variant-numeric: tabular-nums; }
.expiry-soon { font-weight: 600; }
.pass--compact { height: 100%; padding: 24rpx; border-radius: 20rpx; .pass-name { font-size: 30rpx; margin-top: 8rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pass-balance { margin-top: 18rpx; } .balance-number { font-size: 42rpx; } .usage-label { font-size: 21rpx; } .pass-dates { justify-content: flex-end; font-size: 21rpx; margin-top: 14rpx; } .usage-track, .pass-rule { margin-top: 16rpx; } }
</style>

View File

@@ -1,21 +1,9 @@
<template>
<view class="profile-menu">
<view class="profile-menu__shortcuts">
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
<view class="profile-menu__shortcut-head">
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
<text class="profile-menu__arrow"></text>
</view>
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
</view>
</view>
<slot />
<view class="profile-menu__links">
<template v-for="item in secondaryItems" :key="item.key">
<template v-for="item in menuItems" :key="item.key">
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
@@ -36,49 +24,24 @@ interface MenuItem {
title?: string
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear'
action?: 'clear' | 'notifications'
requireAuth?: boolean
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>()
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'require-login'): void
(e: 'open-notifications'): void
}>()
const menuItems = computed<MenuItem[]>(() => {
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
? `${props.activeMembershipCount}`
: undefined
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
? `${props.upcomingBookingCount}`
: undefined
const items: MenuItem[] = [
{
key: 'membership',
type: 'item',
title: '我的会员卡',
path: '/pages/profile/membership',
badge: membershipBadge,
requireAuth: true,
},
{
key: 'bookings',
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
badge: bookingBadge,
requireAuth: true,
},
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
...(props.isAdmin
? [{
key: 'teaching-schedule',
@@ -88,16 +51,13 @@ const menuItems = computed<MenuItem[]>(() => {
requireAuth: true,
}]
: []),
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
// ...(props.inviteShareEligible
// ? [{
// key: 'invite',
// type: 'item' as const,
// title: '邀请好友',
// path: '/pages/profile/invite',
// requireAuth: true,
// }]
// : []),
{
key: 'bookings',
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
requireAuth: true,
},
{
key: 'info',
type: 'item',
@@ -105,6 +65,13 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/info',
requireAuth: true,
},
{
key: 'notifications',
type: 'item',
title: '消息提醒设置',
action: 'notifications',
requireAuth: true,
},
{
key: 'sep1',
type: 'separator',
@@ -132,9 +99,6 @@ const menuItems = computed<MenuItem[]>(() => {
return items
})
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) {
emit('require-login')
@@ -142,6 +106,8 @@ function handleTap(item: MenuItem) {
}
if (item.action === 'clear') {
emit('clear-cache')
} else if (item.action === 'notifications') {
emit('open-notifications')
} else if (item.path) {
uni.navigateTo({ url: item.path })
}
@@ -150,14 +116,6 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped>
.profile-menu {
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
&__item--hover { background: #f4f1ec; }

View File

@@ -0,0 +1,9 @@
<template><view v-if="summary && summary.count > 0" class="summary"><view><text class="label">来自真实课后反馈</text><text class="caption">每一次被听见,让练习更贴近你。</text></view><view class="score"><text>{{ summary.average }}<text class="star">★</text></text><text class="count">{{ summary.count }} 份评价</text></view></view></template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { get } from '../utils/request'
const summary = ref<{ count: number; average: number | null } | null>(null)
onShow(async () => { try { summary.value = await get('/reviews/summary') } catch { summary.value = null } })
</script>
<style scoped>.summary{margin:24rpx 32rpx;padding:26rpx;display:flex;justify-content:space-between;align-items:center;gap:20rpx;border:1rpx solid #e2e5d9;border-radius:24rpx;background:#f1f3e9;}.label{display:block;font-size:26rpx;color:#65735b;}.caption{display:block;font-size:22rpx;color:#8a8d7c;line-height:1.7;margin-top:10rpx;}.score{flex-shrink:0;text-align:right;font-size:46rpx;color:#6b785c;font-family:'Baskerville',serif;}.star{font-size:27rpx;margin-left:8rpx;color:#a1874b;}.count{display:block;font-size:21rpx;font-family:inherit;line-height:1.8;color:#858874;}</style>

View File

@@ -0,0 +1,376 @@
<template>
<view v-if="visible" class="modal-mask" @tap="handleClose">
<view class="modal-panel" @tap.stop>
<view class="modal-header">
<text class="modal-title">微信消息提醒设置</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<view class="notice-box">
<text class="notice-title">💡 为什么需要增加订阅次数</text>
<text class="notice-desc">
微信订阅消息每授权 1 次可接收 1 条通知建议点击下方按钮并在弹出的微信授权窗中勾选<text class="notice-highlight">总是保持以上选择</text>即可永久无感自动接收课程变动与上课提醒
</text>
</view>
<view class="quota-list">
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载提醒状态中...</text>
</view>
<view v-else-if="quotas.length === 0" class="empty-wrap">
<text class="empty-text">未检测到可用提醒模板配置</text>
</view>
<view v-else v-for="item in quotas" :key="item.scene" class="quota-item">
<view class="quota-info">
<view class="quota-title-row">
<text class="quota-icon">{{ getSceneIcon(item.scene) }}</text>
<text class="quota-name">{{ getSceneName(item.scene) }}</text>
</view>
<text class="quota-desc">{{ item.description }}</text>
</view>
<view class="quota-badge" :class="getBadgeClass(item.remainingQuota)">
<text class="badge-text">
{{ item.remainingQuota > 0 ? `${item.remainingQuota}` : '待补充' }}
</text>
</view>
</view>
</view>
<view class="modal-actions">
<button
class="btn-primary"
:loading="subscribing"
:disabled="subscribing"
@tap="handleTopUp"
>
一键补充全部提醒次数 (+3)
</button>
<view class="btn-secondary" @tap="handleOpenSettings">
<text class="btn-secondary-text">微信权限与通知设置</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { SubscriptionMessageScene } from '@mp-pilates/shared'
import type { SubscriptionQuotaItem } from '@mp-pilates/shared'
import {
fetchUserSubscriptionQuotas,
requestBookingBundleSubscriptionMessage,
openSubscribeSettings,
} from '../utils/wechat-subscription'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const quotas = ref<SubscriptionQuotaItem[]>([])
const loading = ref(false)
const subscribing = ref(false)
async function loadQuotas() {
loading.value = true
try {
quotas.value = await fetchUserSubscriptionQuotas()
} finally {
loading.value = false
}
}
watch(
() => props.visible,
(val) => {
if (val) {
void loadQuotas()
}
},
{ immediate: true },
)
function handleClose() {
emit('update:visible', false)
}
function getSceneIcon(scene: SubscriptionMessageScene): string {
switch (scene) {
case SubscriptionMessageScene.BOOKING_CREATED:
return '📅'
case SubscriptionMessageScene.CLASS_REMINDER:
return '⏰'
case SubscriptionMessageScene.BOOKING_CANCELLED:
return '📋'
case SubscriptionMessageScene.CLASS_REVIEW:
return '⭐'
default:
return '🔔'
}
}
function getSceneName(scene: SubscriptionMessageScene): string {
switch (scene) {
case SubscriptionMessageScene.BOOKING_CREATED:
return '约课成功通知'
case SubscriptionMessageScene.CLASS_REMINDER:
return '上课前 1 小时提醒'
case SubscriptionMessageScene.BOOKING_CANCELLED:
return '约课取消通知'
case SubscriptionMessageScene.CLASS_REVIEW:
return '课后评价提醒'
default:
return '课程服务通知'
}
}
function getBadgeClass(quota: number): string {
if (quota >= 3) return 'badge--healthy'
if (quota > 0) return 'badge--warning'
return 'badge--danger'
}
async function handleTopUp() {
if (subscribing.value) return
subscribing.value = true
try {
const results = await requestBookingBundleSubscriptionMessage()
const acceptedCount = results.filter((r) => r.result === 'accept').length
if (acceptedCount > 0) {
uni.showToast({ title: `已成功补充 ${acceptedCount} 项提醒额度`, icon: 'success' })
await loadQuotas()
} else {
uni.showToast({ title: '未增加额度,可再次点击尝试', icon: 'none' })
}
} catch (error) {
console.warn('[subscribe] top-up failed', error)
uni.showToast({ title: '授权未完成,可进入微信设置检查', icon: 'none' })
} finally {
subscribing.value = false
}
}
async function handleOpenSettings() {
await openSubscribeSettings()
await loadQuotas()
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
inset: 0;
background: rgba(56, 48, 42, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-panel {
width: 100%;
box-sizing: border-box;
background: #fbf9f6;
border-radius: 36rpx 36rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 500;
color: #514943;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
font-size: 28rpx;
color: #8b817b;
}
.notice-box {
background: #f0f4ee;
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.notice-title {
font-size: 24rpx;
font-weight: 500;
color: #476d54;
}
.notice-desc {
font-size: 22rpx;
color: #657568;
line-height: 1.6;
}
.notice-highlight {
font-weight: 600;
color: #3b5a45;
}
.quota-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 28rpx;
max-height: 480rpx;
overflow-y: auto;
}
.loading-wrap,
.empty-wrap {
padding: 40rpx 0;
text-align: center;
}
.loading-text,
.empty-text {
font-size: 24rpx;
color: #8b817b;
}
.quota-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background: #fff;
border: 2rpx solid #eee8e3;
border-radius: 20rpx;
gap: 16rpx;
}
.quota-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.quota-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.quota-icon {
font-size: 26rpx;
}
.quota-name {
font-size: 26rpx;
font-weight: 500;
color: #514943;
}
.quota-desc {
font-size: 21rpx;
color: #8b817b;
}
.quota-badge {
padding: 6rpx 16rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.badge-text {
font-size: 22rpx;
font-weight: 500;
}
.badge--healthy {
background: #edf5eb;
.badge-text {
color: #4f7957;
}
}
.badge--warning {
background: #fdf5ea;
.badge-text {
color: #b07d39;
}
}
.badge--danger {
background: #fbeee9;
.badge-text {
color: #bc5e4c;
}
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.btn-primary {
width: 100%;
height: 88rpx;
border-radius: 999rpx;
background: #6b8276;
color: #fff;
font-size: 28rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
line-height: 1;
&::after {
border: none;
}
&:active {
background: #597264;
}
}
.btn-secondary {
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-secondary-text {
font-size: 25rpx;
color: #7b8974;
}
</style>

View File

@@ -49,36 +49,35 @@
</view>
</view>
<!-- Stats row: shown only when profile is loaded -->
<view v-if="loggedIn && hasProfile" class="user-card__stats">
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
<text class="user-card__stat-label">累计上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
<text class="user-card__stat-label">本月上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ remainingSessions }}</text>
<text class="user-card__stat-label">剩余课时 · </text>
</view>
<button v-if="loggedIn && hasProfile" class="membership-row" @tap="handleMembershipTap">
<text class="membership-row__label">会员卡</text>
<text class="membership-row__value">{{ membershipLabel }}</text>
<text v-if="!membershipsError && membershipsLoaded && activeMemberships.length > 1" class="membership-row__count">{{ activeMemberships.length }} </text>
<text class="membership-row__arrow"></text>
</button>
<view v-if="loggedIn && hasProfile && membershipsLoaded && !membershipsError && activeMemberships.length" class="mini-progress-list">
<button v-for="item in cardProgress" :key="item.id" class="mini-progress" :aria-label="`${item.name}${item.label}`" @tap="handleMembershipTap">
<view v-if="item.percent !== null" class="mini-progress__fill" :style="{ width: `${item.percent}%` }" />
<view class="mini-progress__heading"><text class="mini-progress__name">{{ item.name }}</text><text class="mini-progress__usage">{{ item.label }}</text></view>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{
loggedIn: boolean
hasProfile: boolean
user: UserProfileResponse | null
stats: UserStatsResponse | null
now: number
membershipsLoading?: boolean
membershipsLoaded?: boolean
membershipsError?: boolean
memberships?: readonly MembershipWithCardType[]
loading?: boolean
}>()
@@ -86,6 +85,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'login'): void
(e: 'edit'): void
(e: 'refresh-memberships'): void
}>()
const avatarFailed = ref(false)
@@ -123,16 +123,32 @@ const activeMembershipCount = computed(
const hasMembership = computed(() => activeMembershipCount.value > 0)
function toSafeCount(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
const membershipLabel = computed(() => {
if (props.membershipsError) return '暂未更新,点击重试'
if (!props.membershipsLoaded) return '加载中…'
if (!activeMemberships.value.length) return '暂无有效卡'
return [...new Set(activeMemberships.value.map(m => getCardTypeLabel(m.cardType.type)))].join(' · ')
})
const cardProgress = computed(() => activeMemberships.value.map(m => {
const total = getMembershipTotalTimes(m)
if (m.remainingTimes !== null) {
const used = getMembershipUsedTimes(m)
return { id: m.id, name: m.cardType.name,
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes}`,
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
}
const start = new Date(m.startDate).getTime()
const end = new Date(m.expireDate).getTime()
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
return { id: m.id, name: m.cardType.name,
label: `不限次 · 有效期剩 ${remainingDays}`,
percent: end > start ? Math.max(0, Math.min(100, (end - props.now) / (end - start) * 100)) : null }
}))
// Sum remaining sessions from all active count-limited memberships.
const remainingSessions = computed(() =>
activeMemberships.value
.filter((m) => m.remainingTimes !== null)
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
)
function handleMembershipTap() {
if (props.membershipsError) { emit('refresh-memberships'); return }
uni.navigateTo({ url: '/pages/profile/membership' })
}
function onAvatarError() {
avatarFailed.value = true
@@ -155,11 +171,6 @@ function handleLogin() {
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
&__phone { font-size: 24rpx; color: #8b7b70; }
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
&__stat-label { font-size: 20rpx; color: #8b7b70; }
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
@@ -169,4 +180,15 @@ function handleLogin() {
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
}
.membership-row { display: flex; align-items: center; gap: 12rpx; width: 100%; margin: 20rpx 0 0; padding: 18rpx 0 0; min-height: 62rpx; border-radius: 0; border-top: 1rpx solid #e3d7ce; background: transparent; text-align: left; line-height: 1.5; font-size: 23rpx; color: #796759; &::after { border: none; } }
.membership-row__label { flex-shrink: 0; color: #8b7b70; }
.membership-row__value { flex: 1; min-width: 0; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.membership-row__count { flex-shrink: 0; font-size: 21rpx; color: #8b7b70; }
.membership-row__arrow { flex-shrink: 0; font-size: 28rpx; color: #8b7b70; }
.mini-progress-list { margin-top: 12rpx; display: flex; flex-direction: column; gap: 8rpx; }
.mini-progress { position: relative; display: block; width: 100%; height: 48rpx; margin: 0; padding: 0 16rpx; overflow: hidden; border: 1rpx solid #d6d9ca; border-radius: 10rpx; background: #eeece4; text-align: left; line-height: 46rpx; &::after { border: none; } &:active { opacity: .8; } }
.mini-progress__heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 12rpx; height: 100%; font-size: 20rpx; color: #46513f; }
.mini-progress__name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.mini-progress__usage { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.mini-progress__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #dce3d3, #c9d7bf); border-right: 1rpx solid rgba(109, 135, 91, .16); }
</style>

View File

@@ -13,6 +13,10 @@
"minified": true
},
"usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"optimization": {
"subPackages": true
},
"permission": {
"scope.userLocation": {
"desc": "用于获取工作室位置导航"

View File

@@ -3,7 +3,6 @@
"autoscan": true
},
"pages": [
{ "path": "pages/admin/analytics", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
{
"path": "pages/home/index",
"style": {
@@ -13,13 +12,19 @@
{
"path": "pages/booking/index",
"style": {
"navigationStyle": "custom"
"navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
}
},
{
"path": "pages/booking/detail",
"style": {
"navigationStyle": "custom"
"navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
}
},
{
@@ -71,86 +76,108 @@
}
},
{
"path": "pages/admin/index",
"path": "pages/profile/progress",
"style": {
"navigationStyle": "custom"
}
},
}
],
"subPackages": [
{
"path": "pages/admin/bookings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/slot-adjust",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/members",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-supplement",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/admin/member-arrange",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/orders",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/card-types",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/studio",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/flash-sales",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"style": {
"navigationStyle": "custom"
}
"root": "pages/admin",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "analytics",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "bookings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "slot-adjust",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "members",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-supplement",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-arrange",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "orders",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "card-types",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "studio",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-progress",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "reviews",
"style": {
"navigationStyle": "custom"
}
}
]
}
],
"globalStyle": {

View File

@@ -103,7 +103,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import type { TeachingAnalytics } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'

File diff suppressed because it is too large Load Diff

View File

@@ -239,9 +239,9 @@
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatPrice } from '../../utils/format'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { uploadStudioAsset } from './utils/studio-upload'
import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared'

View File

@@ -1,863 +0,0 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="秒杀管理" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ total }} 个秒杀活动</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新建秒杀</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="pageLoading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!items.length" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无秒杀活动点击右上角新建</text>
</view>
<!-- Flash sale list -->
<view v-else class="fs-list">
<view
v-for="item in items"
:key="item.id"
class="fs-card"
>
<!-- Header band -->
<view class="fs-header" :class="headerStatusClass(item)">
<view class="fs-header-left">
<text class="fs-title">{{ item.title }}</text>
</view>
<view class="fs-status-tag" :class="phaseTagClass(item.phase)">
<text class="fs-status-text">{{ phaseLabel(item.phase) }}</text>
</view>
</view>
<!-- Body -->
<view class="fs-body">
<view class="fs-info-row">
<text class="fs-card-type">关联卡种: {{ item.cardType.name }}</text>
</view>
<view class="fs-price-row">
<view class="fs-price-block">
<text class="fs-price-label">秒杀价</text>
<text class="fs-price-value flash">¥{{ formatPrice(item.flashPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">原价</text>
<text class="fs-price-value original">¥{{ formatPrice(item.originalPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">库存</text>
<text class="fs-price-value">{{ item.soldCount }}/{{ item.totalStock }}</text>
</view>
</view>
<!-- Stock progress bar -->
<view class="fs-stock-bar">
<view
class="fs-stock-fill"
:style="{ width: stockPercent(item) }"
/>
</view>
<view class="fs-time-row">
<text class="fs-time">{{ formatDateTime(item.startTime) }} {{ formatDateTime(item.endTime) }}</text>
</view>
</view>
<!-- Actions -->
<view class="fs-actions">
<view class="fs-action-btn edit-btn" @tap.stop="openEdit(item)">
<text class="fs-action-text">编辑</text>
</view>
<view
v-if="item.status === 'DRAFT'"
class="fs-action-btn activate-btn"
@tap.stop="confirmActivate(item)"
>
<text class="fs-action-text">上线</text>
</view>
<view
v-else-if="item.status === 'ACTIVE'"
class="fs-action-btn end-btn"
@tap.stop="confirmEnd(item)"
>
<text class="fs-action-text">结束</text>
</view>
<view
v-if="item.soldCount === 0"
class="fs-action-btn delete-btn"
@tap.stop="confirmDelete(item)"
>
<text class="fs-action-text">删除</text>
</view>
</view>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
<view class="modal-container" @tap.stop>
<scroll-view scroll-y class="modal-scroll">
<!-- Header -->
<view class="modal-header">
<text class="modal-title">{{ editTarget ? '编辑秒杀' : '新建秒杀' }}</text>
<view class="modal-close" @tap="closeModal">
<text class="modal-close-icon"></text>
</view>
</view>
<!-- Form fields -->
<view class="modal-body">
<!-- Card type picker -->
<view class="modal-field">
<text class="modal-label">关联卡种</text>
<picker
mode="selector"
:range="cardTypeOptions"
range-key="label"
:value="form.cardTypeIdx"
@change="onCardTypeChange"
:disabled="!!editTarget"
>
<view class="picker-display">
<text class="picker-text">{{ cardTypeOptions[form.cardTypeIdx]?.label || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">活动标题</text>
<input
class="modal-input"
v-model="form.title"
placeholder="如:新春限时秒杀"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">原价</text>
<input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="展示划线价"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">秒杀价</text>
<input
class="modal-input"
type="digit"
v-model="form.flashPriceStr"
placeholder="实际支付价格"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">库存数量</text>
<input
class="modal-input"
type="number"
v-model="form.totalStockStr"
placeholder="秒杀总量"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.startDate"
@change="onStartDateChange"
>
<text class="datetime-text">{{ form.startDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.startTimeStr"
@change="onStartTimeChange"
>
<text class="datetime-text">{{ form.startTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.endDate"
@change="onEndDateChange"
>
<text class="datetime-text">{{ form.endDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.endTimeStr"
@change="onEndTimeChange"
>
<text class="datetime-text">{{ form.endTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">排序值</text>
<input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="越小越靠前"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">活动说明</text>
<textarea
class="modal-textarea"
v-model="form.description"
placeholder="可选,向用户展示"
placeholder-style="color:#bbb"
:maxlength="500"
auto-height
/>
</view>
</view>
<!-- Action buttons -->
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDateTime, getFlashSalePhaseLabel, getStockPercent, formatDateLocal, formatTimeLocal } from '../../utils/format'
import { FlashSaleStatus, FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleAdminItem, CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
// ─── Data ────────────────────────────────────────────
const items = ref<FlashSaleAdminItem[]>([])
const total = ref(0)
const pageLoading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<FlashSaleAdminItem | null>(null)
const cardTypes = ref<CardType[]>([])
const cardTypeOptions = computed(() =>
cardTypes.value.map((ct) => ({
label: `${ct.name}(¥${formatPrice(ct.price)}`,
value: ct.id,
})),
)
const defaultForm = () => ({
cardTypeIdx: 0,
title: '',
originalPriceStr: '',
flashPriceStr: '',
totalStockStr: '',
startDate: '',
startTimeStr: '',
endDate: '',
endTimeStr: '',
sortOrderStr: '0',
description: '',
})
const form = ref(defaultForm())
// ─── Data loading ─────────────────────────────────────
async function loadData() {
pageLoading.value = true
try {
const [salesResult, cardTypesResult] = await Promise.all([
adminStore.fetchFlashSales(),
adminStore.fetchCardTypes(),
])
items.value = [...salesResult.items]
total.value = salesResult.total
cardTypes.value = [...cardTypesResult]
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function reloadSales() {
try {
const result = await adminStore.fetchFlashSales()
items.value = [...result.items]
total.value = result.total
} catch {
// silent
}
}
// ─── Helpers ──────────────────────────────────────────
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function phaseTagClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'tag--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'tag--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'tag--soldout'
return 'tag--ended'
}
function headerStatusClass(item: FlashSaleAdminItem): string {
if (item.status === FlashSaleStatus.DRAFT) return 'header--draft'
if (item.status === FlashSaleStatus.ENDED) return 'header--ended'
return 'header--active'
}
function stockPercent(item: FlashSaleAdminItem): string {
return getStockPercent(item.soldCount, item.totalStock)
}
// ─── Modal ────────────────────────────────────────────
function openAdd() {
editTarget.value = null
form.value = defaultForm()
showModal.value = true
}
function openEdit(item: FlashSaleAdminItem) {
editTarget.value = item
const startDt = new Date(item.startTime)
const endDt = new Date(item.endTime)
const ctIdx = cardTypes.value.findIndex((ct) => ct.id === item.cardTypeId)
form.value = {
cardTypeIdx: ctIdx >= 0 ? ctIdx : 0,
title: item.title,
originalPriceStr: String(item.originalPrice / 100),
flashPriceStr: String(item.flashPrice / 100),
totalStockStr: String(item.totalStock),
startDate: formatDateLocal(startDt),
startTimeStr: formatTimeLocal(startDt),
endDate: formatDateLocal(endDt),
endTimeStr: formatTimeLocal(endDt),
sortOrderStr: String(item.sortOrder),
description: item.description ?? '',
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = Number(e.detail.value)
form.value.cardTypeIdx = idx
// Auto-fill original price from card type
const ct = cardTypes.value[idx]
if (ct && !form.value.originalPriceStr) {
form.value.originalPriceStr = String(Number(ct.price) / 100)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
form.value.startDate = e.detail.value
}
function onStartTimeChange(e: { detail: { value: string } }) {
form.value.startTimeStr = e.detail.value
}
function onEndDateChange(e: { detail: { value: string } }) {
form.value.endDate = e.detail.value
}
function onEndTimeChange(e: { detail: { value: string } }) {
form.value.endTimeStr = e.detail.value
}
// ─── Form submit ──────────────────────────────────────
async function submitForm() {
if (submitting.value) return
if (!form.value.title.trim()) {
uni.showToast({ title: '请填写活动标题', icon: 'none' }); return
}
const originalPrice = parseFloat(form.value.originalPriceStr)
if (isNaN(originalPrice) || originalPrice <= 0) {
uni.showToast({ title: '请填写有效原价', icon: 'none' }); return
}
const flashPrice = parseFloat(form.value.flashPriceStr)
if (isNaN(flashPrice) || flashPrice <= 0) {
uni.showToast({ title: '请填写有效秒杀价', icon: 'none' }); return
}
const totalStock = parseInt(form.value.totalStockStr, 10)
if (isNaN(totalStock) || totalStock < 1) {
uni.showToast({ title: '请填写有效库存', icon: 'none' }); return
}
if (!form.value.startDate || !form.value.startTimeStr) {
uni.showToast({ title: '请选择开始时间', icon: 'none' }); return
}
if (!form.value.endDate || !form.value.endTimeStr) {
uni.showToast({ title: '请选择结束时间', icon: 'none' }); return
}
const startTime = `${form.value.startDate}T${form.value.startTimeStr}:00`
const endTime = `${form.value.endDate}T${form.value.endTimeStr}:00`
if (new Date(endTime) <= new Date(startTime)) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }); return
}
submitting.value = true
try {
if (editTarget.value) {
await adminStore.updateFlashSale(editTarget.value.id, {
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
} else {
const selectedCardType = cardTypes.value[form.value.cardTypeIdx]
if (!selectedCardType) {
uni.showToast({ title: '请选择卡种', icon: 'none' }); return
}
await adminStore.createFlashSale({
cardTypeId: selectedCardType.id,
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await reloadSales()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
// ─── Actions ──────────────────────────────────────────
function confirmActivate(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认上线',
content: `上线后「${item.title}」将对用户可见,到达秒杀时间后用户可抢购。`,
confirmText: '上线',
confirmColor: '#27ae60',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '上线中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ACTIVE })
uni.hideLoading()
uni.showToast({ title: '已上线', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '上线失败', icon: 'none' })
}
},
})
}
function confirmEnd(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认结束',
content: `结束后「${item.title}」将停止售卖,已购买的不受影响。`,
confirmText: '结束',
confirmColor: '#e67e22',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '结束中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ENDED })
uni.hideLoading()
uni.showToast({ title: '已结束', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
})
}
function confirmDelete(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认删除',
content: `确定删除「${item.title}」?此操作不可恢复。`,
confirmText: '删除',
confirmColor: '#c0392b',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '删除中...' })
try {
await adminStore.deleteFlashSale(item.id)
uni.hideLoading()
uni.showToast({ title: '已删除', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────
onMounted(loadData)
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E);
border-radius: 32rpx;
padding: 12rpx 28rpx;
display: flex;
align-items: center;
gap: 8rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #fff; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 300rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Flash sale list ─────────────────────── */
.fs-list { padding: 0 24rpx; }
.fs-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.fs-header {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.header--active { background: linear-gradient(90deg, #D4A59A, #C08B7E); }
.header--draft { background: linear-gradient(90deg, #AEA49A, #9E948A); }
.header--ended { background: linear-gradient(90deg, #B0A898, #9A928A); }
.fs-header-left { flex: 1; min-width: 0; }
.fs-title {
font-size: 28rpx;
font-weight: 700;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fs-status-tag {
border-radius: 20rpx;
padding: 4rpx 16rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.tag--ongoing { background: rgba(255, 255, 255, 0.3); }
.tag--upcoming { background: rgba(255, 255, 255, 0.2); }
.tag--soldout { background: rgba(0, 0, 0, 0.2); }
.tag--ended { background: rgba(0, 0, 0, 0.3); }
.fs-status-text { font-size: 20rpx; color: #fff; font-weight: 600; }
.fs-body { padding: 24rpx; }
.fs-info-row { margin-bottom: 16rpx; }
.fs-card-type { font-size: 24rpx; color: #888; }
.fs-price-row {
display: flex;
gap: 32rpx;
margin-bottom: 16rpx;
}
.fs-price-block {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.fs-price-label { font-size: 20rpx; color: #aaa; }
.fs-price-value {
font-size: 30rpx;
font-weight: 700;
color: #333;
&.flash { color: #B5725E; }
&.original { color: #aaa; text-decoration: line-through; font-weight: 400; }
}
/* Stock progress bar */
.fs-stock-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.fs-stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 4rpx;
transition: width 0.3s;
}
.fs-time-row { margin-top: 4rpx; }
.fs-time { font-size: 22rpx; color: #999; }
/* ── Actions ─────────────────────────────── */
.fs-actions {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.fs-action-btn {
flex: 1;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1rpx solid #f5f5f5;
&:last-child { border-right: none; }
&:active { background: #f9f9f9; }
}
.fs-action-text { font-size: 26rpx; font-weight: 600; }
.edit-btn .fs-action-text { color: #1a1a2e; }
.activate-btn .fs-action-text { color: #27ae60; }
.end-btn .fs-action-text { color: #e67e22; }
.delete-btn .fs-action-text { color: #c0392b; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.modal-container {
width: 100%;
max-height: 85vh;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-scroll { flex: 1; max-height: 85vh; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 16rpx;
position: sticky;
top: 0;
background: #fff;
z-index: 10;
}
.modal-title { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.modal-close-icon { font-size: 24rpx; color: #999; }
.modal-body { padding: 0 32rpx; }
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
gap: 16rpx;
&--last { border-bottom: none; align-items: flex-start; }
}
.modal-label { font-size: 26rpx; color: #555; width: 160rpx; flex-shrink: 0; }
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.datetime-picker-group {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
justify-content: flex-end;
}
.datetime-text {
font-size: 26rpx;
color: #222;
padding: 8rpx 16rpx;
background: #f8f8f8;
border-radius: 8rpx;
}
.modal-textarea {
flex: 1;
font-size: 26rpx;
color: #222;
min-height: 80rpx;
text-align: right;
}
.modal-actions {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { background: #e8e8e8; }
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.85; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #fff; }
</style>

View File

@@ -2,182 +2,68 @@
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Stats summary card -->
<view class="stats-card-wrap">
<view class="stats-card">
<view v-if="statsLoading" class="stats-loading">
<view v-for="i in 3" :key="i" class="stat-skeleton" />
</view>
<template v-else>
<view class="stat-block">
<text class="stat-num">{{ stats.todayBookings }}</text>
<text class="stat-sub">今日预约</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalOrders }}</text>
<text class="stat-sub">总订单</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalBookings }}</text>
<text class="stat-sub">总预约</text>
</view>
</template>
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课务运营</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
<text class="item-title">排课管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<text class="item-title">预约管理</text>
<text class="arrow-text"></text>
</view>
</view>
<view class="section-header"><text class="section-title">教学报告</text></view>
<!-- Section: 教学复盘 -->
<view class="section-header">
<text class="section-title">教学复盘</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe"><text class="item-icon-text"></text></view>
<view class="item-text-group"><text class="item-title">统计分析</text><text class="item-desc">月度课次 · 学员出勤 · 上课明细</text></view>
</view>
<view class="item-arrow"><text class="arrow-text"></text></view>
<text class="item-title">统计分析</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section header: 课程管理 -->
<view class="section-header">
<text class="section-title">课程管理</text>
</view>
<!-- List: schedule -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-left">
<view class="item-icon-wrap icon--bookings">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">预约管理</text>
<text class="item-desc">查看/确认/核销学员预约</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
<view class="item-left">
<view class="item-icon-wrap icon--schedule">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课管理</text>
<text class="item-desc">管理每周课程时段</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
<text class="item-title">课后评价</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section header: 会员与订单 -->
<!-- Section: 会员与订单 -->
<view class="section-header">
<text class="section-title">会员与订单</text>
</view>
<!-- List: members & orders -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/members')">
<view class="item-left">
<view class="item-icon-wrap icon--members">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">会员管理</text>
<text class="item-desc">查看所有会员信息</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
<text class="item-title">会员管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/orders')">
<view class="item-left">
<view class="item-icon-wrap icon--orders">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">订单管理</text>
<text class="item-desc">查看所有订单记录</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
<view class="item-left">
<view class="item-icon-wrap icon--card">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">卡种管理</text>
<text class="item-desc">设置会员卡类型</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
<text class="item-title">订单管理</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section header: 系统 -->
<!-- Section: 系统设置 -->
<view class="section-header">
<text class="section-title">系统</text>
<text class="section-title">系统设置</text>
</view>
<!-- List: settings -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<view class="item-left">
<view class="item-icon-wrap icon--studio">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">工作室设置</text>
<text class="item-desc">工作室信息与配置</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
<text class="item-title">卡种管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<text class="item-title">工作室设置</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">增加订阅次数</text>
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
<text class="item-title">增加订阅次数</text>
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
</view>
@@ -190,37 +76,21 @@ import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import type { AdminStats } from '../../stores/admin'
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const adminSubscribeLoading = ref(false)
function navigate(path: string) {
uni.navigateTo({ url: path })
}
async function loadStats() {
statsLoading.value = true
try {
stats.value = await adminStore.fetchDashboardStats()
} catch {
// fail silently — stats are non-critical
} finally {
statsLoading.value = false
}
}
async function handleIncreaseSubscriptionCount() {
if (adminSubscribeLoading.value) {
return
@@ -245,78 +115,18 @@ async function handleIncreaseSubscriptionCount() {
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats()
userStore.fetchProfile()
})
</script>
<style lang="scss" scoped>
/* ── Page ───────────────────────────────────── */
/* ── Page ───────────────────────── */
.page {
min-height: 100vh;
padding-bottom: 40rpx;
}
/* ── Stats card ─────────────────────────────── */
.stats-card-wrap {
padding: 24rpx 24rpx 8rpx;
}
.stats-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.stats-loading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-skeleton {
width: 100rpx;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.6s ease infinite;
}
.stat-block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 44rpx;
font-weight: 700;
color: #4A4035;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-sub {
font-size: 22rpx;
color: #A09080;
letter-spacing: 0.5rpx;
}
.stat-sep {
width: 1rpx;
height: 56rpx;
background: rgba(180, 160, 130, 0.2);
}
/* ── Section header ─────────────────────────── */
/* ── Section header ───────────────── */
.section-header {
padding: 32rpx 24rpx 12rpx;
}
@@ -329,7 +139,7 @@ onMounted(() => {
text-transform: uppercase;
}
/* ── List ───────────────────────────────────── */
/* ── List ───────────────────────── */
.list {
background: #FFFFFF;
margin: 0 24rpx;
@@ -342,8 +152,8 @@ onMounted(() => {
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 24rpx;
gap: 16rpx;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
transition: background 0.15s ease;
@@ -356,64 +166,23 @@ onMounted(() => {
}
}
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.item-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-icon-text {
font-size: 32rpx;
color: #FFFFFF;
line-height: 1;
}
/* Icon variants — warm muted tones */
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
.item-text-group {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.item-title {
flex: 1;
font-size: 30rpx;
font-weight: 600;
font-weight: 500;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.item-desc {
.item-extra {
font-size: 24rpx;
color: #A09080;
}
.item-arrow {
flex-shrink: 0;
padding-left: 16rpx;
}
.arrow-text {
font-size: 40rpx;
flex-shrink: 0;
font-size: 36rpx;
color: rgba(180, 160, 130, 0.5);
font-weight: 300;
line-height: 1;

View File

@@ -115,7 +115,7 @@ import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDate, isSlotPast } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
type PeriodKey = keyof typeof TIME_PERIODS | null

View File

@@ -40,6 +40,9 @@
</view>
</view>
<view class="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 </button></view>
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
<template v-if="activeTab === 'overview'">
<view class="section section--practice">
<view class="section-heading">
<text class="section-label">上课情况</text>
@@ -137,9 +140,10 @@
<text class="upcoming-empty-text">近期没有待上的课</text>
</view>
</view>
</template>
</template>
<view class="dock">
<view v-if="activeTab === 'overview'" class="dock">
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
<text class="dock-btn-text">编辑资料</text>
</view>
@@ -159,6 +163,7 @@ import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
import MemberProgress from '../../components/MemberProgress.vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import {
@@ -170,12 +175,13 @@ import {
} from '../../utils/format'
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const loading = ref(false)
const activeTab = ref('overview'), progressRefreshKey = ref(0)
const detail = ref<AdminMemberDetail | null>(null)
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
@@ -224,6 +230,7 @@ async function loadDetail() {
}
}
function goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
function goSupplement() {
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
}
@@ -250,6 +257,7 @@ onMounted(() => {
})
onShow(() => {
progressRefreshKey.value++
if (userId.value) {
loadDetail()
}
@@ -257,6 +265,9 @@ onShow(() => {
</script>
<style lang="scss" scoped>
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
.page {
--ink: #514943;
--muted: #8b817b;

View File

@@ -111,7 +111,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDateLocal } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')

View File

@@ -0,0 +1,12 @@
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress admin :user-id="userId" :booking-id="bookingId" /></view></template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import MemberProgress from '../../components/MemberProgress.vue'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const userId = ref(''), bookingId = ref('')
onLoad(query => { userId.value = String(query?.userId || ''); bookingId.value = String(query?.bookingId || '') })
</script>
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>

View File

@@ -60,7 +60,7 @@ 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 { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system'
import { HttpRequestError } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'

View File

@@ -103,8 +103,8 @@ import { onReachBottom, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel } from '../../utils/format'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import type { MemberSummary } from './stores/admin'
const adminStore = useAdminStore()

View File

@@ -137,7 +137,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatPrice, formatDateTime } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'

View File

@@ -0,0 +1,53 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="userId ? '学员评价历史' : '课后评价'" show-back />
<view class="content">
<text class="kicker">LISTEN & GROW</text><text class="title">听见每一次练习</text><text class="intro">真实的感受是下一次更好教学的起点</text>
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
<template v-else>
<template v-if="!userId && trend.length">
<picker mode="date" fields="month" :value="month" :end="today" @change="changeMonth($event.detail.value)"><view class="month-picker">{{ month }} 教学反馈 <text>切换月份 </text></view></picker>
<view class="summary"><view><text class="number">{{ current?.average ?? '—' }}</text><text>星级均分 / 5</text><text class="muted">{{ current?.count || 0 }} 份评价</text></view><view><text class="number">{{ current?.nps ?? '—' }}</text><text>净推荐值 NPS</text><text class="muted">{{ current?.npsCount || 0 }} 份推荐评分</text></view></view>
<view class="panel"><text class="section-title">近六个月 · 星级均分</text><view class="chart"><view v-for="item in trend" :key="item.month" class="column"><text>{{ item.average ?? '—' }}</text><view class="track"><view class="bar" :style="{ height: ((item.average || 0) / 5 * 100) + '%' }" /></view><text class="muted">{{ item.month.slice(5) }} </text></view></view></view>
<view class="panel"><text class="section-title">推荐意愿趋势</text><view v-for="item in trend" :key="item.month" class="trend-row"><text>{{ item.month }}</text><text>{{ item.nps === null ? '暂无样本' : 'NPS ' + item.nps }}</text><text class="muted">{{ item.npsCount }} 人</text></view><text class="footnote">推荐意愿 910 分为推荐者06 分为贬损者NPS = 推荐者占比 贬损者占比仅统计填写推荐分的评价星级不参与 NPS</text></view>
</template>
<view class="list-heading"><text>评价原声</text><text class="muted">{{ total }} · 按提交时间</text></view>
<view v-if="!rows.length && !loading" class="state">还没有评价<text class="intro">学员完成课程后可在预约详情留下反馈</text></view>
<view v-for="row in rows" :key="row.id" class="panel">
<view class="list-heading"><button class="member" @tap="openMember(row.booking.userId)">{{ row.booking.user.nickname || '学员' }} </button><text class="stars">{{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}</text></view>
<text class="lesson">{{ formatChinaDate(row.booking.timeSlot.date) }} · {{ row.booking.timeSlot.startTime }}{{ row.booking.timeSlot.endTime }}</text>
<view class="tags"><text v-for="tag in row.tags" :key="tag">{{ tag }}</text></view><text class="comment">{{ row.comment || '这位学员留下了星级评分' }}</text>
<view class="list-heading"><text class="muted">{{ formatChinaDate(row.createdAt) }} 提交</text><text v-if="row.recommendation !== null" class="muted">推荐意愿 {{ row.recommendation }} / 10</text></view>
</view>
<view v-if="loading" class="state">正在读取反馈…</view><button v-else-if="rows.length < total" class="more" @tap="loadMore">加载更多</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatChinaDate } from '../../utils/format'
import { useAdminStore } from './stores/admin'
const store = useAdminStore(), navBarHeight = getSystemLayout().navBarHeight
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), month = ref(today.slice(0, 7)), userId = ref('')
const rows = ref<ReviewEntry[]>([]), trend = ref<ReviewSummary[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref('')
const current = computed(() => trend.value[trend.value.length - 1])
let sequence = 0
async function reload() {
const seq = ++sequence; loading.value = true; error.value = ''
try { const [list, summary] = await Promise.all([store.fetchReviews(userId.value, 1), userId.value ? Promise.resolve([]) : store.fetchReviewTrend(month.value)]); if (seq !== sequence) return; rows.value = list.data; total.value = list.total; page.value = 1; trend.value = summary }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '加载失败' } finally { if (seq === sequence) loading.value = false }
}
async function loadMore() { if (loading.value) return; loading.value = true; const seq = sequence; try { const list = await store.fetchReviews(userId.value, page.value + 1); if (seq !== sequence) return; rows.value = [...rows.value, ...list.data]; total.value = list.total; page.value++ } catch { uni.showToast({ title: '加载失败,请重试', icon: 'none' }) } finally { if (seq === sequence) loading.value = false } }
function changeMonth(value: string) { month.value = value.slice(0, 7); void reload() }
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${id}` }) }
onLoad(query => { userId.value = String(query?.userId || '') })
onShow(() => { void reload() })
</script>
<style lang="scss" scoped>
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;color:#514943;}.content{padding:36rpx 32rpx 80rpx;}.kicker{display:block;font-size:21rpx;letter-spacing:4rpx;color:#8b806e;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;margin:22rpx 0 14rpx;}.intro{display:block;font-size:25rpx;color:#8b8174;line-height:1.8;margin-bottom:30rpx;}.month-picker{display:flex;justify-content:space-between;align-items:center;min-height:88rpx;font-size:28rpx;}.month-picker text{font-size:23rpx;color:#728065;}.summary{display:flex;padding:34rpx 0;background:#e9eee3;border:1rpx solid #dce3d3;border-radius:24rpx;margin:12rpx 0 24rpx;}.summary>view{flex:1;text-align:center;border-right:1rpx solid #d2dac9;}.summary>view:last-child{border:0;}.summary text{display:block;font-size:24rpx;line-height:1.8;}.summary .number{font-size:64rpx;font-family:'Baskerville',serif;color:#576b50;line-height:1.2;margin-bottom:10rpx;}.summary .muted,.muted{font-size:22rpx;color:#8b8276;line-height:1.7;}.panel{border:1rpx solid #e6dfd4;border-radius:24rpx;background:#fffdf9;padding:28rpx;margin-bottom:22rpx;}.section-title{font-size:28rpx;}.chart{display:flex;gap:12rpx;margin-top:25rpx;}.column{flex:1;text-align:center;font-size:23rpx;color:#62715c;}.track{height:140rpx;display:flex;align-items:flex-end;justify-content:center;margin:14rpx 0;}.bar{width:34rpx;background:#97aa89;border-radius:6rpx 6rpx 0 0;}.trend-row{display:flex;justify-content:space-between;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid #eee8df;font-size:24rpx;}.footnote{display:block;font-size:22rpx;line-height:1.8;color:#938575;margin-top:22rpx;}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:50rpx;font-size:29rpx;}.content>.list-heading{margin:34rpx 0 20rpx;}.member{margin:0;padding:0;background:transparent;color:#5f6856;font-size:28rpx;line-height:64rpx;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.member::after,.more::after{border:0;}.stars{font-size:29rpx;color:#ab8951;}.lesson{display:block;font-size:23rpx;color:#928575;margin:10rpx 0 22rpx;}.tags{display:flex;flex-wrap:wrap;gap:12rpx;}.tags text{padding:7rpx 16rpx;font-size:22rpx;border-radius:10rpx;background:#eff1e8;color:#748064;}.comment{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:28rpx;line-height:1.9;margin:22rpx 0;}.state{padding:50rpx 15rpx;text-align:center;font-size:26rpx;color:#8b806f;line-height:1.8;}.more{font-size:26rpx;background:#ebece2;color:#69745b;border-radius:20rpx;line-height:88rpx;}
</style>

View File

@@ -160,9 +160,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue'
import {
@@ -170,7 +171,7 @@ import {
timeToPickerIndex,
pickerIndexToTime,
addOneHourCapped,
} from '../../utils/schedule-time'
} from './utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
@@ -181,6 +182,7 @@ interface EditableSlot {
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus
isPublished: boolean
isNew: boolean
isRemoved: boolean
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
@@ -323,6 +326,7 @@ function submitAdd() {
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
isPublished: false,
isNew: true,
isRemoved: false,
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template'
}
function slotBadgeClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '默认时段'
@@ -495,6 +502,12 @@ onMounted(() => {
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
&--closed {
opacity: 0.55;
background: #fafafa;
border-color: #e5e5e5;
}
}
/* ── Slot header ─────────────────────────── */
@@ -516,6 +529,8 @@ onMounted(() => {
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.badge--closed { background: rgba(0, 0, 0, 0.06); }
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }

View File

@@ -152,14 +152,14 @@
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
import {
SCHEDULE_TIME_PICKER_RANGE,
timeToPickerIndex,
pickerIndexToTime,
} from '../../utils/schedule-time'
} from './utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE

View File

@@ -1,6 +1,7 @@
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import { get, post, put, del } from '../../../utils/request'
import type {
TeachingAnalytics,
CardType,
@@ -14,9 +15,6 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
AdminMemberSummary,
@@ -56,12 +54,6 @@ function normalizePaginatedData<T>(
}
}
export interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
export type MemberSummary = AdminMemberSummary
export interface UserMembership {
@@ -84,6 +76,9 @@ export interface UserMembership {
}
export const useAdminStore = defineStore('admin', () => {
async function fetchReviews(userId = '', page = 1) { return get<{ data: ReviewEntry[]; total: number }>('/admin/reviews', { ...(userId ? { userId } : {}), page }) }
async function fetchReviewTrend(month: string) { return get<ReviewSummary[]>('/admin/reviews/trend', { month }) }
// ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([])
@@ -263,36 +258,13 @@ export const useAdminStore = defineStore('admin', () => {
await fetchSchedulePreview(dto.date)
}
// ── Dashboard stats ──────────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> {
return get<AdminStats>('/admin/stats')
}
// ── Flash sales ─────────────────────────────────────────────────
async function fetchFlashSales(params?: {
page?: number
limit?: number
}): Promise<PaginatedData<FlashSaleAdminItem>> {
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
}
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
// ── Teaching analytics ─────────────────────────────────────────
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State
cardTypes,
@@ -332,12 +304,5 @@ export const useAdminStore = defineStore('admin', () => {
fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
}
})

View File

@@ -197,9 +197,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { uploadStudioAsset } from './utils/studio-upload'
import { getSystemLayout } from '../../utils/system'
type FormState = {

View File

@@ -89,6 +89,16 @@
</view>
</view>
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
<view v-if="canSubscribeClassReminder" class="panel">
<button class="review-subscribe" @tap="subscribeClassReminder">🔔 订阅开课前 1 小时微信提醒</button>
</view>
<view v-if="canSubscribeReview" class="panel">
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评24 小时后提醒我' : '订阅课后评价提醒' }}</button>
</view>
<view v-if="isAdmin && booking?.status === BookingStatus.COMPLETED" class="panel">
<button class="review-subscribe" @tap="openProgress">为这次课写评语</button>
</view>
<view v-if="showReminders" class="panel">
<text class="panel-title">上课前</text>
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
@@ -179,8 +189,8 @@
</view>
<BookingConfirmPopup
v-if="isSlotMode"
:visible="showConfirmPopup"
v-if="showConfirmPopup"
:visible="true"
:time-slot="slotData"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@@ -199,7 +209,7 @@ import type {
TimeSlotWithBookingStatus,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
@@ -212,8 +222,42 @@ import {
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ClassReviewForm from '../../components/ClassReviewForm.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import {
requestSubscriptionMessage,
requestBookingCancelSubscriptionMessage,
requestClassReminderSubscriptionMessage,
cacheSubscriptionMessageTemplateConfig,
} from '../../utils/wechat-subscription'
import { get } from '../../utils/request'
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
const canSubscribeClassReminder = computed(() => {
if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
if (booking.value.userId !== userStore.user?.id) return false
const slot = booking.value.timeSlot
if (!slot) return false
return !isSlotPast(slot.date, slot.startTime)
})
async function subscribeClassReminder() {
try {
const results = await requestClassReminderSubscriptionMessage()
uni.showToast({
title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
icon: 'none',
})
} catch {
uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
}
}
async function subscribeReview() {
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
}
function openProgress() { if (booking.value) uni.navigateTo({ url: `/pages/admin/member-progress?userId=${booking.value.userId}&bookingId=${booking.value.id}` }) }
const bookingStore = useBookingStore()
const userStore = useUserStore()
@@ -232,6 +276,12 @@ const slotData = ref<TimeSlotWithBookingStatus | null>(null)
const showConfirmPopup = ref(false)
const isAdmin = computed(() => userStore.isAdmin)
const canSubscribeReview = computed(() => {
const current = booking.value
if (!current || current.userId !== userStore.user?.id) return false
if (current.status === BookingStatus.CONFIRMED) return true
return current.status === BookingStatus.COMPLETED && !current.review
})
const showActions = computed(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
booking.value?.status === BookingStatus.CONFIRMED,
@@ -584,6 +634,12 @@ async function handleNoShow() {
}
async function handleCancel() {
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({
title: '取消预约',
content: '确定要取消该预约?',
@@ -614,6 +670,7 @@ onMounted(async () => {
})
onLoad((query) => {
void get<SubscriptionMessageTemplateConfig>('/user/subscription-messages/templates').then(cacheSubscriptionMessageTemplateConfig).catch(() => {})
updateLayout()
const q = query as Record<string, string>
@@ -637,6 +694,7 @@ function updateLayout() {
</script>
<style lang="scss" scoped>
.review-subscribe {font-size:26rpx;background:#eaf0e8;color:#536f60;border-radius:20rpx;min-height:88rpx;line-height:88rpx;&::after{border:0;}}
.page {
height: 100vh;
box-sizing: border-box;

View File

@@ -22,6 +22,8 @@
<scroll-view
class="slot-scroll"
scroll-y
:scroll-into-view="targetSlotId"
scroll-with-animation
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@@ -52,14 +54,18 @@
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</view>
<SlotCard
<view
v-for="item in filteredSlots"
:id="`slot-${item.id}`"
:key="item.id"
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
>
<SlotCard
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
</view>
<!-- Bottom padding spacer -->
@@ -68,7 +74,8 @@
<!-- Confirm popup -->
<BookingConfirmPopup
:visible="showConfirmPopup"
v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@@ -78,19 +85,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { formatDate } from '../../utils/format'
import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type PeriodKey = keyof typeof TIME_PERIODS | null
@@ -104,6 +112,10 @@ const selectedPeriod = ref<PeriodKey>(null)
const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后
// 切换日期/时段或下拉刷新后不再重置位置,避免打断用户的浏览位置。
const hasAutoScrolled = ref(false)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
@@ -167,6 +179,94 @@ async function onRefresh() {
refreshing.value = false
}
const instance = getCurrentInstance()
let isAutoScrolling = false
/**
* 轮询等待目标节点在视图层完成挂载与排版
*/
function waitForElement(selector: string, maxRetries = 10, interval = 50): Promise<boolean> {
return new Promise((resolve) => {
let retries = 0
function check() {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery()
const q = query
.select(selector)
.boundingClientRect((data) => {
const node = Array.isArray(data) ? data[0] : data
if (node && node.top !== undefined) {
resolve(true)
} else if (retries < maxRetries) {
retries++
setTimeout(check, interval)
} else {
resolve(false)
}
})
// 触发查询
q['exec']()
}
check()
})
}
// 首次进入时滚动到当天第一个未开始的课程("本时段及以后"
async function scrollToUpcoming() {
if (hasAutoScrolled.value || isAutoScrolling) return
if (bookingStore.loadingSlots) return
const slots = filteredSlots.value
if (slots.length === 0) {
// 列表还没加载出来(加载中或当天无课),不要把「仅一次」用掉。
return
}
const upcomingIndex = slots.findIndex((slot) => !isSlotPast(slot.date, slot.startTime))
if (upcomingIndex === -1) {
// 当天所有课程均已结束,标记已滚动过,留在当前位置
hasAutoScrolled.value = true
return
}
if (upcomingIndex === 0) {
// 本时段及以后的第一个课程正好是列表第 1 项,页面已经在顶部,无需额外滚动
hasAutoScrolled.value = true
return
}
isAutoScrolling = true
const dateWhenStarted = selectedDate.value
const upcoming = slots[upcomingIndex]
const targetId = `slot-${upcoming.id}`
try {
// 等待 Vue 虚拟 DOM 提交并分发 setData
await nextTick()
// 确保原生视图层已完成该节点的挂载与布局排版
const isReady = await waitForElement(`#${targetId}`, 10, 50)
if (!isReady || selectedDate.value !== dateWhenStarted) {
// 节点尚未在视图层就绪(例如 Tab 处于后台未完成渲染)或用户已切换日期,不锁定 hasAutoScrolled留待 onShow 或后续就绪时执行
return
}
if (targetSlotId.value === targetId) {
targetSlotId.value = ''
await new Promise((resolve) => setTimeout(resolve, 50))
if (selectedDate.value !== dateWhenStarted) return
}
targetSlotId.value = targetId
hasAutoScrolled.value = true
} finally {
isAutoScrolling = false
}
}
// ─── Event handlers ───────────────────────────────────────
function onDateSelect(date: string) {
selectedDate.value = date
@@ -269,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
if (!slot.myBookingId) return
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({
title: '取消预约',
content: '确定要取消这个预约吗?',
@@ -295,12 +401,21 @@ async function onCancelTap(slot: TimeSlotWithBookingStatus) {
// ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => {
const tasks: Promise<unknown>[] = [loadSlots(selectedDate.value)]
// Load memberships if logged in but not yet fetched
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
await userStore.fetchMemberships()
tasks.push(userStore.fetchMemberships())
}
await Promise.all(tasks)
// 首次进入:自动定位到当天本时段及以后的第一个课程
await scrollToUpcoming()
})
onShow(async () => {
// 如果首次进入时页面在后台预加载完成,或从其他 Tab 切入时定位未生效,在页面可见时触发定位
if (!hasAutoScrolled.value && filteredSlots.value.length > 0 && !bookingStore.loadingSlots) {
await scrollToUpcoming()
}
// Load today's slots
await loadSlots(selectedDate.value)
})
</script>

View File

@@ -9,6 +9,11 @@
<text>{{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}</text>
<text class="invite-banner-note">{{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ' }}</text>
</view>
<view v-if="!loading && (card || allCards.length)" class="card-share-row">
<button class="card-share-button" open-type="share" aria-label="分享会员卡给微信好友或群聊">
<text class="card-share-icon"></text><text>分享给好友 / 群聊</text>
</button>
</view>
<view v-if="inviteVisible" class="purchase-sheet-layer" @touchmove.stop.prevent>
<view class="purchase-sheet">
<text class="sheet-kicker">A GIFT FROM YOUR FRIEND</text>
@@ -337,7 +342,7 @@
<script setup lang="ts">
import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import {
CardTypeCategory,
@@ -352,7 +357,7 @@ import { get, post } from '../../utils/request'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
import { requestBookingCreatedSubscriptionMessage } from '../../utils/wechat-subscription'
import CustomNavBar from '../../components/CustomNavBar.vue'
interface MyOrderStatusResponse {
@@ -371,6 +376,7 @@ const inviteInput = ref('')
const inviteBusy = ref(false)
const inviteError = ref('')
onLoad((options) => {
uni.showShareMenu({ menus: ['shareAppMessage'] })
if (options?.inviteCode) {
invite.pendingCode = options.inviteCode.toUpperCase()
inviteInput.value = invite.pendingCode
@@ -410,6 +416,22 @@ const paymentRedirecting = ref(false)
const paymentConfirmationSession = ref(0)
const failedCoverIds = ref<Set<string>>(new Set())
onShareAppMessage(() => {
const sharedCard = showAll.value ? null : card.value
const id = sharedCard?.id || (!showAll.value ? cardId.value : '')
const path = id
? `/pages/card/detail?id=${encodeURIComponent(id)}`
: isTrialEntry.value && !showAll.value
? '/pages/card/detail?trial=1'
: '/pages/card/detail?showAll=1'
return {
title: sharedCard ? `${sharedCard.name} · 一起练普拉提` : '选择你的普拉提会员卡,一起开始练习',
path,
...(sharedCard && hasCardCover(sharedCard) ? { imageUrl: sharedCard.coverUrl! } : {}),
}
})
const pageTitle = computed(() => {
if (showAll.value) return '选择会员卡'
return isRenewal.value ? '续卡' : '购买会员卡'
@@ -866,9 +888,14 @@ async function doPurchase() {
paymentRedirecting.value = false
paymentConfirmationSession.value++
pendingOrderId.value = ''
uni.showLoading({ title: '创建订单...' })
try {
// 必须在 tap 同步栈里调起订阅框;失败不打断支付。
await requestBookingCreatedSubscriptionMessage().catch((error) => {
console.warn('[subscribe] purchase pre-subscribe failed', error)
})
uni.showLoading({ title: '创建订单...' })
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
})
@@ -888,7 +915,6 @@ async function doPurchase() {
})
pendingOrderId.value = result.order.id
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
await settlePaidOrder(result.order.id)
} catch (err: unknown) {
uni.hideLoading()
@@ -927,6 +953,10 @@ onUnmounted(() => {
</script>
<style lang="scss" scoped>
.card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; }
.card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } }
.card-share-icon { font-size: 28rpx; }
.invite-banner { margin: 24rpx 32rpx 0; padding: 24rpx; border-radius: 20rpx; background: #eaf0e5; color: #526e58; font-size: 27rpx; }
.invite-banner-note { display: block; margin-top: 10rpx; color: #7b8974; font-size: 22rpx; line-height: 1.6; }
.invite-input { margin: 26rpx 0; padding: 24rpx; border: 1rpx dashed #a9b99e; border-radius: 16rpx; font: 34rpx monospace; letter-spacing: 6rpx; height: 55rpx; text-align: center; }

View File

@@ -1,848 +0,0 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="限时秒杀" show-back />
<!-- Loading -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-hero" />
<view class="skeleton-body">
<view class="skeleton-line w80" />
<view class="skeleton-line w60" />
<view class="skeleton-line w40" />
</view>
</view>
<!-- Error -->
<view v-else-if="!detail" class="error-wrap">
<text class="error-icon"></text>
<text class="error-text">活动信息加载失败</text>
<view class="retry-btn" @tap="loadDetail">
<text class="retry-text">点击重试</text>
</view>
</view>
<template v-else>
<!-- Hero Section -->
<view class="hero" :class="heroPhaseClass">
<!-- Decorative elements -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="hero-deco hero-deco--3" />
<!-- Phase badge -->
<view class="hero-phase-badge" :class="phaseBadgeClass">
<text class="hero-phase-text">{{ phaseLabel }}</text>
</view>
<!-- Title -->
<text class="hero-title">{{ detail.title }}</text>
<!-- Price row -->
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text v-if="invite.eligible" class="hero-discount-text">好友 95 </text>
<text class="hero-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
<view class="hero-original-wrap">
<text class="hero-original-label">原价</text>
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
</view>
</view>
<!-- Discount badge -->
<view class="hero-discount-badge">
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - invite.price(detail.flashPrice)) }}</text>
</view>
<!-- Countdown -->
<view
v-if="detail.phase === FlashSalePhase.UPCOMING || detail.phase === FlashSalePhase.ONGOING"
class="hero-countdown"
>
<text class="cd-label">
{{ detail.phase === FlashSalePhase.UPCOMING ? '距开始' : '距结束' }}
</text>
<view class="cd-blocks">
<text class="cd-block">{{ countdown.h }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.m }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.s }}</text>
</view>
</view>
</view>
<!-- Stock Bar -->
<view class="stock-section">
<view class="stock-info">
<text class="stock-label">抢购进度</text>
<text class="stock-count">
{{ detail.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `已抢 ${detail.soldCount}/${detail.totalStock}` }}
</text>
</view>
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': stockRatio > 0.6 }"
:style="{ width: stockPercent }"
/>
</view>
</view>
<!-- Phone Auth Prompt -->
<view
v-if="userStore.loggedIn && !userStore.user?.phone"
class="phone-prompt-card"
>
<view class="phone-prompt-content">
<view class="phone-prompt-icon">📱</view>
<view class="phone-prompt-text">
<text class="phone-prompt-title">提前授权手机号</text>
<text class="phone-prompt-desc">授权后抢购更快也方便馆主联系您</text>
</view>
</view>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhone"
>
<text class="phone-auth-text">立即授权</text>
</button>
</view>
<!-- Card Info -->
<view class="detail-section">
<view class="info-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">会员卡信息</text>
</view>
<view class="info-grid">
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.name }}</text>
<text class="cell-label">卡种</text>
</view>
<view v-if="detail.cardType.totalTimes" class="info-cell">
<text class="cell-value">{{ detail.cardType.totalTimes }}</text>
<text class="cell-label">课时次数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.durationDays }}</text>
<text class="cell-label">有效天数</text>
</view>
</view>
</view>
<!-- Description -->
<view v-if="detail.description" class="desc-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">活动说明</text>
</view>
<text class="desc-content">{{ detail.description }}</text>
</view>
<!-- Purchase Notes -->
<view class="notes-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">参与须知</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">每位用户同一秒杀活动仅限参与一次</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">购买后立即生效有效期 {{ detail.cardType.durationDays }} </text>
</view>
<view v-if="detail.cardType.totalTimes" class="note-item">
<text class="note-dot"></text>
<text class="note-text"> {{ detail.cardType.totalTimes }} 次课时可灵活预约</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">需登录并授权手机号后方可参与秒杀</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">建议提前完善账号信息及手机号授权方便馆主联系</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">秒杀卡不可退款到期或课时用完后自动失效</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">支持微信支付安全便捷</text>
</view>
<view class="note-item note-item--disclaimer">
<text class="note-text disclaimer-text">* 本活动最终解释权归普拉提馆所有</text>
</view>
</view>
</view>
<!-- Bottom Action Bar -->
<view class="bottom-bar">
<view class="bar-price-area">
<text class="bar-price-label">秒杀价</text>
<view class="bar-price-row">
<text class="bar-currency">¥</text>
<text class="bar-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
</view>
</view>
<view
class="action-btn"
:class="actionBtnClass"
@tap="handleAction"
>
<text class="action-btn-text">{{ actionBtnText }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { useInviteStore } from "../../stores/invite"
const invite = useInviteStore()
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
FlashSalePhase,
FlashSaleOrderStatus,
} from '@mp-pilates/shared'
import type { FlashSaleDetail } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { useFlashSaleStore } from '../../stores/flash-sale'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { post } from '../../utils/request'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
const userStore = useUserStore()
const flashSaleStore = useFlashSaleStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const buying = ref(false)
const detail = ref<FlashSaleDetail | null>(null)
const flashSaleId = ref('')
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
// ─── Computed ─────────────────────────────────────────
const phaseLabel = computed(() => {
if (!detail.value) return ''
return getFlashSalePhaseLabel(detail.value.phase)
})
const heroPhaseClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'hero--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'hero--upcoming'
return 'hero--inactive'
})
const phaseBadgeClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'pbadge--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'pbadge--upcoming'
return 'pbadge--inactive'
})
const stockRatio = computed(() => {
if (!detail.value) return 0
return getStockRatio(detail.value.soldCount, detail.value.totalStock)
})
const stockPercent = computed(() => {
if (!detail.value) return '0%'
return getStockPercent(detail.value.soldCount, detail.value.totalStock)
})
const countdown = computed(() => {
void tick.value
if (!detail.value) return { h: '00', m: '00', s: '00' }
const target = detail.value.phase === FlashSalePhase.UPCOMING
? detail.value.startTime
: detail.value.endTime
return getCountdownParts(target)
})
const isDisabled = computed(() => {
if (!detail.value) return true
const d = detail.value
if (d.hasParticipated) return true
if (d.phase === FlashSalePhase.SOLD_OUT) return true
if (d.phase === FlashSalePhase.ENDED) return true
if (d.phase === FlashSalePhase.UPCOMING) return true
if (buying.value) return true
return false
})
const actionBtnText = computed(() => {
if (!detail.value) return ''
const d = detail.value
if (d.hasParticipated) {
if (d.userOrderStatus === FlashSaleOrderStatus.PAID) return '已成功抢购'
if (d.userOrderStatus === FlashSaleOrderStatus.RESERVED) return '待支付'
return '已参与'
}
if (d.phase === FlashSalePhase.SOLD_OUT) return '已售罄'
if (d.phase === FlashSalePhase.ENDED) return '活动已结束'
if (d.phase === FlashSalePhase.UPCOMING) return `距开始 ${countdown.value.h}:${countdown.value.m}:${countdown.value.s}`
if (!userStore.loggedIn) return '登录后参与'
if (!userStore.user?.phone) return '授权手机号后参与'
if (buying.value) return '抢购中...'
return `¥${formatPrice(invite.price(d.flashPrice))} 立即抢购`
})
const actionBtnClass = computed(() => {
if (isDisabled.value) return 'action-btn--disabled'
return 'action-btn--active'
})
// ─── Data loading ────────────────────────────────────
async function loadDetail() {
if (!flashSaleId.value) return
loading.value = true
try {
detail.value = await flashSaleStore.fetchDetail(flashSaleId.value)
} catch {
detail.value = null
} finally {
loading.value = false
}
}
// ─── Phone auth ──────────────────────────────────────
async function handleGetPhone(e: { detail: { code?: string; errMsg?: string } }) {
if (!e.detail.code) return
try {
await post('/auth/phone', { code: e.detail.code })
await userStore.fetchProfile()
uni.showToast({ title: '授权成功', icon: 'success' })
} catch {
uni.showToast({ title: '授权失败,请重试', icon: 'none' })
}
}
// ─── Action handler ──────────────────────────────────
async function handleAction() {
if (!detail.value || isDisabled.value) return
// Check login
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再参与秒杀',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await loadDetail() // refresh participation status
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
})
return
}
// Check phone
if (!userStore.user?.phone) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
try { await invite.refresh() } catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(invite.price(detail.value.flashPrice))} 抢购「${detail.value.title}」?`,
confirmText: '确认抢购',
success: async (res) => {
if (res.confirm) {
await doPurchase()
}
},
})
}
async function doPurchase() {
if (!detail.value || buying.value) return
buying.value = true
uni.showLoading({ title: '抢购中...' })
try {
const result = await flashSaleStore.purchase(detail.value.id)
uni.hideLoading()
// Launch WeChat Pay
await new Promise<void>((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: result.paymentParams.timeStamp,
nonceStr: result.paymentParams.nonceStr,
package: result.paymentParams.package,
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
paySign: result.paymentParams.paySign,
success: () => resolve(),
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
})
})
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '抢购成功!', icon: 'success' })
await userStore.fetchMemberships()
await loadDetail() // refresh status
setTimeout(() => {
uni.navigateTo({ url: '/pages/profile/membership' })
}, 1500)
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '抢购失败'
if (!msg.includes('取消') && !msg.includes('cancel')) {
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
}
// Refresh detail to show updated status
await loadDetail()
} finally {
buying.value = false
}
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const pages = getCurrentPages()
const current = pages[pages.length - 1]
const options = (current as { options?: Record<string, string> }).options ?? {}
flashSaleId.value = options.id ?? ''
loadDetail()
timer = setInterval(() => { tick.value++ }, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
/* ── Loading ────────────────────────────── */
.loading-wrap { padding: 0; }
.skeleton-hero {
height: 420rpx;
background: linear-gradient(90deg, #ede8e3 25%, #e4dfd9 50%, #ede8e3 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-body { padding: 32rpx 24rpx; display: flex; flex-direction: column; gap: 20rpx; }
.skeleton-line {
height: 28rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&.w80 { width: 80%; }
&.w60 { width: 60%; }
&.w40 { width: 40%; }
}
/* ── Error ───────────────────────────────── */
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 40rpx;
gap: 24rpx;
}
.error-icon { font-size: 80rpx; }
.error-text { font-size: 30rpx; color: $text-hint; }
.retry-btn {
padding: 20rpx 48rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
}
.retry-text { font-size: 28rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
HERO — warm blush tones
═══════════════════════════════════════════ */
.hero {
padding: 56rpx 36rpx 48rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
position: relative;
overflow: hidden;
}
.hero--ongoing {
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 35%, #B5836E 100%);
}
.hero--upcoming {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.hero--inactive {
background: linear-gradient(135deg, #C4BAB0 0%, #AEA49A 100%);
}
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
&--1 { width: 300rpx; height: 300rpx; top: -60rpx; right: -40rpx; }
&--2 { width: 200rpx; height: 200rpx; bottom: -60rpx; left: 30rpx; }
&--3 { width: 120rpx; height: 120rpx; top: 40rpx; left: -30rpx; background: rgba(255, 255, 255, 0.05); }
}
.hero-phase-badge {
align-self: flex-start;
padding: 8rpx 24rpx;
border-radius: 24rpx;
z-index: 1;
}
.pbadge--ongoing { background: rgba(255, 255, 255, 0.3); }
.pbadge--upcoming { background: rgba(255, 255, 255, 0.25); }
.pbadge--inactive { background: rgba(0, 0, 0, 0.12); }
.hero-phase-text { font-size: 24rpx; color: #fff; font-weight: 600; letter-spacing: 1rpx; }
.hero-title {
font-size: 44rpx;
font-weight: 800;
color: #fff;
z-index: 1;
line-height: 1.2;
}
.hero-price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
z-index: 1;
}
.hero-currency { font-size: 30rpx; font-weight: 700; color: rgba(255, 255, 255, 0.9); }
.hero-price { font-size: 72rpx; font-weight: 800; color: #fff; line-height: 1; }
.hero-original-wrap {
display: flex;
flex-direction: column;
margin-left: 16rpx;
}
.hero-original-label { font-size: 18rpx; color: rgba(255, 255, 255, 0.65); }
.hero-original { font-size: 26rpx; color: rgba(255, 255, 255, 0.55); text-decoration: line-through; }
.hero-discount-badge {
align-self: flex-start;
padding: 6rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.35);
z-index: 1;
}
.hero-discount-text { font-size: 22rpx; color: #fff; font-weight: 600; }
/* Countdown */
.hero-countdown {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
z-index: 1;
}
.cd-label { font-size: 24rpx; color: rgba(255, 255, 255, 0.85); }
.cd-blocks { display: flex; align-items: center; gap: 6rpx; }
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 28rpx;
font-weight: 700;
padding: 8rpx 14rpx;
border-radius: 8rpx;
font-family: 'DIN Alternate', monospace;
min-width: 48rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-colon { color: #fff; font-size: 28rpx; font-weight: 700; }
/* ═══════════════════════════════════════════
STOCK
═══════════════════════════════════════════ */
.stock-section {
margin: 0 24rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-top: -20rpx;
position: relative;
z-index: 2;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.1);
}
.stock-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.stock-label { font-size: 26rpx; color: $text-secondary; font-weight: 600; }
.stock-count { font-size: 24rpx; color: #B5725E; font-weight: 600; }
.stock-bar {
height: 16rpx;
background: #f5f0ed;
border-radius: 8rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 8rpx;
transition: width 0.3s;
&--hot { animation: stockPulse 2s ease infinite; }
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ═══════════════════════════════════════════
PHONE PROMPT
═══════════════════════════════════════════ */
.phone-prompt-card {
margin: 20rpx 24rpx 0;
background: linear-gradient(135deg, #FBF5F3, #F5ECEA);
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border: 1rpx solid rgba(192, 139, 126, 0.2);
}
.phone-prompt-content {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.phone-prompt-icon { font-size: 40rpx; }
.phone-prompt-text {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.phone-prompt-title { font-size: 26rpx; font-weight: 700; color: #B5725E; }
.phone-prompt-desc { font-size: 22rpx; color: $text-hint; }
.phone-auth-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E) !important;
border-radius: 32rpx !important;
padding: 12rpx 28rpx !important;
border: none !important;
line-height: 1.4 !important;
font-size: 24rpx !important;
margin: 0 !important;
flex-shrink: 0;
&::after { border: none; }
}
.phone-auth-text { font-size: 24rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
DETAIL SECTION
═══════════════════════════════════════════ */
.detail-section {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.section-header-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.section-dot {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: #C08B7E;
flex-shrink: 0;
}
.section-label { font-size: 30rpx; font-weight: 700; color: $text-primary; }
/* Info card */
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.info-grid {
display: flex;
justify-content: space-around;
align-items: center;
}
.info-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
position: relative;
& + & { border-left: 1rpx solid #f0ece8; }
}
.cell-value { font-size: 36rpx; font-weight: 800; color: $text-primary; line-height: 1.1; }
.cell-label { font-size: 22rpx; color: $text-hint; }
/* Description */
.desc-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.desc-content { font-size: 27rpx; color: $text-secondary; line-height: 1.75; }
/* Notes */
.notes-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.note-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
padding: 6rpx 0;
}
.note-dot { font-size: 26rpx; color: #C08B7E; line-height: 1.65; flex-shrink: 0; }
.note-text { font-size: 26rpx; color: $text-secondary; line-height: 1.65; }
.note-item--disclaimer { margin-top: 12rpx; padding-top: 16rpx; border-top: 1rpx solid #f0ece8; }
.disclaimer-text { color: #bbb; font-size: 22rpx; }
/* ═══════════════════════════════════════════
BOTTOM BAR
═══════════════════════════════════════════ */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1rpx solid #f0ece8;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.bar-price-area {
display: flex;
flex-direction: column;
gap: 2rpx;
}
.bar-price-label { font-size: 20rpx; color: $text-hint; }
.bar-price-row { display: flex; align-items: baseline; }
.bar-currency { font-size: 24rpx; font-weight: 700; color: #B5725E; }
.bar-price { font-size: 44rpx; font-weight: 800; color: #B5725E; line-height: 1; }
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn--active {
background: linear-gradient(90deg, #D4A59A, #B5836E);
box-shadow: 0 4rpx 16rpx rgba(192, 139, 126, 0.35);
&:active { opacity: 0.85; }
}
.action-btn--disabled {
background: #d0cac4;
}
.action-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
</style>

View File

@@ -11,8 +11,8 @@
<view class="card-handle"><view class="card-handle-bar" /></view>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
@@ -28,10 +28,10 @@ import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue'
import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue'
import AboutSection from '../../components/AboutSection.vue'
@@ -62,7 +62,6 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -103,10 +102,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop and flash sales
// Also refresh card shop
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}

View File

@@ -57,7 +57,7 @@
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text>
</view>
<view class="row-bottom">
@@ -124,7 +124,7 @@
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text>
</view>
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
@@ -154,6 +154,7 @@ import {
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type TabKey = 'upcoming' | 'history'
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
}
async function handleCancel(booking: BookingWithDetails) {
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = startTime(booking)

View File

@@ -5,8 +5,9 @@
</view>
<!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
:memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<InviteCard />
@@ -14,11 +15,9 @@
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
:active-membership-count="activeMembershipCount"
:upcoming-booking-count="upcomingBookingCount"
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@require-login="handleLogin"
@open-notifications="showNotificationsModal = true"
>
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu>
@@ -27,42 +26,37 @@
<view v-if="loggedIn" class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view>
<!-- Notification Settings Modal -->
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
</view>
</template>
<script setup lang="ts">
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
const invite = useInviteStore()
const userStore = useUserStore()
const bookingStore = useBookingStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { upcomingBookings } = storeToRefs(bookingStore)
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const showNotificationsModal = ref(false)
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false)
const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
)
const upcomingBookingCount = computed(
() => (loggedIn.value ? upcomingBookings.value.length : 0),
)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
@@ -84,15 +78,14 @@ onMounted(() => {
})
onShow(async () => {
membershipNow.value = Date.now()
activityRefreshKey.value += 1
if (loggedIn.value) {
await Promise.all([
invite.refresh().catch(() => {}),
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
])
}
})
@@ -103,11 +96,7 @@ async function handleLogin() {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await Promise.all([
invite.refreshActivity().catch(() => {}),
userStore.fetchStats(),
bookingStore.fetchUpcomingBookings(),
])
await invite.refreshActivity().catch(() => {})
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })

View File

@@ -2,7 +2,19 @@
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-if="userStore.loggedIn" class="practice-summary">
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习都在积累</text></view>
<view class="summary-grid">
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.totalBookings ?? '—' }}</text><text class="summary-label">累计上课 · </text></view>
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · </text></view>
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
</view>
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡可在有效期内预约</text>
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新点击重试 </button>
</view>
<view v-if="!userStore.loggedIn" class="empty-wrap"><view class="empty-card"><text class="empty-title">登录后查看会员卡</text><button class="empty-btn" @tap="goProfile">前往个人中心</button></view></view>
<view v-else-if="userStore.membershipsError" class="empty-wrap"><view class="empty-card"><text class="empty-title">会员卡暂时未能更新</text><text class="empty-sub">请重试后查看最新余额和有效期。</text><button class="empty-btn" @tap="loadMemberships">重新加载</button></view></view>
<view v-else-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" />
</view>
@@ -20,40 +32,15 @@
<text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
</view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<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 v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
<OwnedMembershipCard :membership="m" :now="membershipNow">
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
<text class="mc-status mc-status--active">有效</text>
</view>
<view class="mc-balance">
<view class="mc-number-row">
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
</view>
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} </text>
</view>
</view>
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始日期</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-item mc-date-item--end">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
</OwnedMembershipCard>
</view>
<text class="usage-note">已用次数包含预约扣次不等同于已完成上课进度条表示已用次数占比</text>
</view>
<view v-if="inactiveMemberships.length" class="group-section">
@@ -81,7 +68,7 @@
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
<view v-if="allMemberships.length" class="purchase-dock">
<view v-if="userStore.loggedIn && allMemberships.length" class="purchase-dock">
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
@@ -94,15 +81,17 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { getCardTypeLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
const userStore = useUserStore()
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 = computed(() => userStore.membershipsLoading)
const membershipNow = ref(Date.now())
const refreshing = ref(false)
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
@@ -129,36 +118,23 @@ function inactiveStatusClass(status: MembershipStatus): string {
return 'mc-status--expired'
}
function cardBgClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
if (type === CardTypeCategory.DURATION) return 'mc--duration'
return 'mc--times'
}
function daysRemaining(m: MembershipWithCardType): number {
const diff = new Date(m.expireDate).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / 86_400_000))
}
const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
async function loadMemberships() {
loading.value = true
try {
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
membershipNow.value = Date.now()
if (!userStore.loggedIn) return
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
}
async function onRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
try { await loadMemberships() }
finally { refreshing.value = false }
}
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
@@ -196,28 +172,12 @@ onShow(loadMemberships)
.group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; }
@@ -229,4 +189,16 @@ onShow(loadMemberships)
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
.scroll-bottom-spacer { height: 20rpx; }
.practice-summary { padding: 32rpx; margin: 24rpx 32rpx 0; border-radius: 24rpx; background: #eef1e9; border: 1rpx solid #dce3d4; color: #465c4a; }
.summary-heading { display: flex; flex-direction: column; gap: 12rpx; }
.summary-kicker { font-size: 18rpx; letter-spacing: 3rpx; color: #6d7d64; }
.summary-title { font-size: 32rpx; font-family: 'Songti SC', 'STSong', serif; }
.summary-grid { display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid #d4dec9; }
.summary-grid > view { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; text-align: center; }
.summary-grid > view + view { border-left: 1rpx solid #d4dec9; }
.summary-value { font-size: 42rpx; font-family: 'Baskerville', 'Times New Roman', serif; line-height: 1.3; font-variant-numeric: tabular-nums; }
.summary-label { font-size: 20rpx; color: #6d7d64; }
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
.owned-card-wrap { margin-bottom: 20rpx; }
</style>

View File

@@ -0,0 +1,8 @@
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress /></view></template>
<script setup lang="ts">
import CustomNavBar from '../../components/CustomNavBar.vue'
import MemberProgress from '../../components/MemberProgress.vue'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
</script>
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>

View File

@@ -53,19 +53,20 @@
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
</view>
<view v-else class="agenda">
<view v-for="slot in slots" :key="slot.slotId" class="session">
<view v-for="slot in slots" :key="slot.slotId" class="session" hover-class="session--hover"
:aria-label="`${slot.startTime.slice(0, 5)} ${slot.endTime.slice(0, 5)}${slot.students.length} `" @tap="openSlot(slot.slotId)">
<view class="session__time">
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
</view>
<view class="session__roster">
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} </text></view>
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} </text></view>
<view v-for="student in slot.students" :key="student.bookingId" class="student">
<view class="student__headline">
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
</view>
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap.stop="contactStudent(student.phone)">
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 </text>
</button>
<text v-else class="student__no-phone">未留手机号</text>
@@ -175,6 +176,9 @@ function formatPhone(phone: string) {
function contactStudent(phone: string) {
uni.makePhoneCall({ phoneNumber: phone })
}
function openSlot(slotId: string) {
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
}
const STATUS_LABELS: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
@@ -219,6 +223,7 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
.session--hover { background: #f4f1ec; }
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
.session__end { font-size: 24rpx; color: #687367; }
@@ -232,6 +237,8 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
.student__status--confirmed { background: #edf3ed; color: #526e62; }
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
.student__status--no_show { background: #f8eeea; color: #a06456; }
.student__status--completed { background: #e8e8e1; color: #5b6660; }
.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; }
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
.student__contact-label { color: #526e62; font-size: 22rpx; }
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }

View File

@@ -1,43 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
FlashSaleListItem,
FlashSaleDetail,
FlashSalePurchaseResponse,
} from '@mp-pilates/shared'
import { get, post } from '../utils/request'
export const useFlashSaleStore = defineStore('flash-sale', () => {
const flashSales = ref<FlashSaleListItem[]>([])
const loading = ref(false)
async function fetchFlashSales(): Promise<FlashSaleListItem[]> {
loading.value = true
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
return data
} catch {
flashSales.value = []
return []
} finally {
loading.value = false
}
}
async function fetchDetail(id: string): Promise<FlashSaleDetail> {
return get<FlashSaleDetail>(`/flash-sales/${id}`)
}
async function purchase(id: string): Promise<FlashSalePurchaseResponse> {
return post<FlashSalePurchaseResponse>(`/flash-sales/${id}/purchase`)
}
return {
flashSales,
loading,
fetchFlashSales,
fetchDetail,
purchase,
}
})

View File

@@ -26,6 +26,13 @@ export const useUserStore = defineStore('user', () => {
const user = ref<UserProfileResponse | null>(null)
const stats = ref<UserStatsResponse | null>(null)
const memberships = ref<readonly MembershipWithCardType[]>([])
const membershipsLoading = ref(false)
const membershipsLoaded = ref(false)
const membershipsError = ref(false)
const statsLoading = ref(false)
const statsError = ref(false)
let membershipRequestId = 0
let statsRequestId = 0
const token = ref<string>(uni.getStorageSync('token') as string || '')
// Getters
@@ -82,23 +89,42 @@ export const useUserStore = defineStore('user', () => {
}
}
async function fetchStats() {
if (!isLoggedIn()) return
async function fetchStats(): Promise<boolean> {
if (!isLoggedIn()) return false
const id = ++statsRequestId
const session = token.value
statsLoading.value = true
statsError.value = false
try {
stats.value = await get<UserStatsResponse>('/user/stats')
} catch (err) {
console.error('Fetch stats failed:', err)
const result = await get<UserStatsResponse>('/user/stats')
if (id !== statsRequestId || session !== token.value) return false
stats.value = result
return true
} catch {
if (id === statsRequestId && session === token.value) statsError.value = true
return false
} finally {
if (id === statsRequestId && session === token.value) statsLoading.value = false
}
}
async function fetchMemberships(): Promise<boolean> {
if (!isLoggedIn()) return false
const id = ++membershipRequestId
const session = token.value
membershipsLoading.value = true
membershipsError.value = false
try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
const result = await get<MembershipWithCardType[]>('/membership/my')
if (id !== membershipRequestId || session !== token.value) return false
memberships.value = [...result]
membershipsLoaded.value = true
return true
} catch (err) {
console.error('Fetch memberships failed:', err)
} catch {
if (id === membershipRequestId && session === token.value) membershipsError.value = true
return false
} finally {
if (id === membershipRequestId && session === token.value) membershipsLoading.value = false
}
}
@@ -122,6 +148,13 @@ export const useUserStore = defineStore('user', () => {
}
function clearSession() {
membershipRequestId++
statsRequestId++
membershipsLoading.value = false
membershipsLoaded.value = false
membershipsError.value = false
statsLoading.value = false
statsError.value = false
token.value = ''
useInviteStore().reset()
user.value = null
@@ -138,6 +171,11 @@ export const useUserStore = defineStore('user', () => {
setUnauthorizedHandler(clearSession)
return {
membershipsLoading,
membershipsLoaded,
membershipsError,
statsLoading,
statsError,
user,
stats,
memberships,

View File

@@ -1,5 +1,4 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
@@ -13,6 +12,13 @@ export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
}
/** 中国自然日 YYYY-MM-DD用于时间戳与 UTC 日期列展示 */
export function formatChinaDate(value: Date | string): string {
const time = new Date(value).getTime()
if (!Number.isFinite(time)) return ''
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
}
/** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
@@ -121,17 +127,6 @@ export function getCountdownParts(targetTime: string): { readonly h: string; rea
}
}
/** 秒杀阶段中文标签 */
export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
const map: Record<FlashSalePhase, string> = {
[FlashSalePhase.UPCOMING]: '即将开始',
[FlashSalePhase.ONGOING]: '抢购中',
[FlashSalePhase.SOLD_OUT]: '已售罄',
[FlashSalePhase.ENDED]: '已结束',
}
return map[phase]
}
/** 库存已售比例 */
export function getStockRatio(soldCount: number, totalStock: number): number {
if (totalStock === 0) return 0

View File

@@ -6,9 +6,11 @@ import type {
SubscriptionMessageRequestItem,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
SubscriptionQuotaItem,
SubscriptionQuotasResponse,
UserProfileResponse,
} from '@mp-pilates/shared'
import { post } from './request'
import { get, post } from './request'
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
@@ -86,20 +88,27 @@ function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestIte
return null
}
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
function getTemplateConfigSync(): SubscriptionMessageTemplateConfig | null {
if (cachedConfig) {
return cachedConfig
}
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
if (!stored || !Array.isArray(stored.templates)) {
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
return null
}
const config: SubscriptionMessageTemplateConfig = {
cachedConfig = {
templates: stored.templates.filter((item) => item.templateId),
}
cachedConfig = config
return cachedConfig
}
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
const config = getTemplateConfigSync()
if (!config) {
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
}
return config
}
@@ -136,37 +145,11 @@ async function reportResults(requests: SubscriptionMessageRequestItem[]): Promis
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
}
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
if (!isMpWeixin()) {
return []
}
const config = await fetchTemplateConfig()
const templates = getTemplatesByScene(config, scene)
if (templates.length === 0) {
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
return []
}
const templateIds = templates.map((item) => item.templateId)
const debugContext = getSubscribeDebugContext()
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
uni.requestSubscribeMessage({
tmplIds: templateIds,
success: (res) => {
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
resolve(res as RequestSubscribeMessageSuccess)
},
fail: (err) => {
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
},
})
})
const requests = templates
function normalizeSubscribeResults(
templates: SubscriptionMessageTemplate[],
result: RequestSubscribeMessageSuccess,
): SubscriptionMessageRequestItem[] {
return templates
.map<SubscriptionMessageRequestItem | null>((item) => {
const normalized = normalizeResult(result[item.templateId])
if (!normalized) {
@@ -180,19 +163,134 @@ export async function requestSubscriptionMessage(scene: SubscriptionMessageScene
}
})
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
await reportResults(requests)
return requests
}
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [
SubscriptionMessageScene.BOOKING_CREATED,
SubscriptionMessageScene.CLASS_REMINDER,
SubscriptionMessageScene.BOOKING_CANCELLED,
]
export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [
SubscriptionMessageScene.BOOKING_CANCELLED,
SubscriptionMessageScene.CLASS_REMINDER,
]
/**
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
*/
export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise<SubscriptionMessageRequestItem[]> {
if (!isMpWeixin()) {
return Promise.resolve([])
}
const config = getTemplateConfigSync()
if (!config) {
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
}
// 按场景收集模板,去重 templateId微信单次最多支持 3 个模板
const templateMap = new Map<string, SubscriptionMessageTemplate>()
for (const scene of scenes) {
const list = getTemplatesByScene(config, scene)
for (const tpl of list) {
if (tpl.templateId && !templateMap.has(tpl.templateId)) {
templateMap.set(tpl.templateId, tpl)
}
if (templateMap.size >= 3) break
}
if (templateMap.size >= 3) break
}
const templates = Array.from(templateMap.values())
if (templates.length === 0) {
console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() }))
return Promise.resolve([])
}
const templateIds = templates.map((item) => item.templateId)
const debugContext = getSubscribeDebugContext()
console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
uni.requestSubscribeMessage({
tmplIds: templateIds,
success: (res) => {
const response = res as RequestSubscribeMessageSuccess
const requests = normalizeSubscribeResults(templates, response)
console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext }))
console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext }))
void reportResults(requests)
.then(() => resolve(requests))
.catch(reject)
},
fail: (err) => {
console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext }))
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds))
},
})
})
}
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle([scene])
}
/**
* 约课三合一组合授权包含约课成功确认、开课前1小时提醒、课程取消通知
* 一次点击,三个场景额度同时 +1
*/
export function requestBookingBundleSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING)
}
/**
* 兼容原有命名,直接升级为约课三合一组合授权
*/
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestBookingBundleSubscriptionMessage()
}
/**
* 取消预约时的组合授权:课程取消通知 + 下次上课提醒
*/
export function requestBookingCancelSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL)
}
/**
* 上课前1小时提醒单项授权
*/
export function requestClassReminderSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER)
}
/**
* 获取当前登录用户的各订阅场景额度水位
*/
export async function fetchUserSubscriptionQuotas(): Promise<SubscriptionQuotaItem[]> {
try {
const res = await get<SubscriptionQuotasResponse>('/user/subscription-messages/quotas')
return res.quotas || []
} catch (error) {
console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error)
return []
}
}
/**
* 引导打开微信系统设置页,供用户恢复通知授权
*/
export function openSubscribeSettings(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
uni.openSetting({
success: (res) => {
resolve(!!res.authSetting)
},
fail: () => resolve(false),
})
})
}
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {

View File

@@ -21,6 +21,9 @@ API_BASE_URL=https://focus.richarjiang.com/
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
# COS upload
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu

View File

@@ -11,3 +11,9 @@ COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
# WeChat subscribe message for class review reminders (24h after completion)
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0

View File

@@ -0,0 +1,85 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `review_reminder_claimed_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_due_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_sent_at` DATETIME(3) NULL;
-- CreateTable
CREATE TABLE `booking_reviews` (
`id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NOT NULL,
`rating` INTEGER NOT NULL,
`recommendation` INTEGER NULL,
`tags` JSON NOT NULL,
`comment` VARCHAR(200) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `booking_reviews_booking_id_key`(`booking_id`),
INDEX `booking_reviews_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `body_metrics` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`recorded_at` DATE NOT NULL,
`weight` DOUBLE NULL,
`body_fat` DOUBLE NULL,
`waist` DOUBLE NULL,
`hip` DOUBLE NULL,
`flexibility` DOUBLE NULL,
`remark` VARCHAR(200) NOT NULL DEFAULT '',
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `body_metrics_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `member_notes` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NULL,
`content` VARCHAR(1000) NOT NULL,
`shared` BOOLEAN NOT NULL DEFAULT false,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `member_notes_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `progress_photos` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`object_key` VARCHAR(191) NOT NULL,
`caption` VARCHAR(200) NOT NULL DEFAULT '',
`recorded_at` DATE NOT NULL,
`uploaded_at` DATETIME(3) NULL,
`consented_at` DATETIME(3) NULL,
`revoked_at` DATETIME(3) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `progress_photos_object_key_key`(`object_key`),
INDEX `progress_photos_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `booking_reviews` ADD CONSTRAINT `booking_reviews_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `body_metrics` ADD CONSTRAINT `body_metrics_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- Drop flash sale feature
-- See docs/flash-sale-removal.md for context
--
-- NOTE: production MySQL has no FK on orders.flash_sale_id, so we drop the
-- column directly without a prior DROP FOREIGN KEY.
-- Drop tables first
DROP TABLE IF EXISTS `flash_sale_orders`;
DROP TABLE IF EXISTS `flash_sales`;
-- Remove order.flash_sale_id column
ALTER TABLE `orders` DROP COLUMN `flash_sale_id`;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `class_reminder_claimed_at` DATETIME(3) NULL,
ADD COLUMN `class_reminder_due_at` DATETIME(3) NULL,
ADD COLUMN `class_reminder_sent_at` DATETIME(3) NULL;

View File

@@ -51,18 +51,6 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
@@ -85,11 +73,13 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
bodyMetrics BodyMetric[]
memberNotes MemberNote[]
progressPhotos ProgressPhoto[]
lessonSupplements LessonSupplement[]
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
@@ -140,7 +130,6 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -226,6 +215,14 @@ model Booking {
membership Membership @relation(fields: [membershipId], references: [id])
qualifiedInviteReferrals InviteReferral[]
review BookingReview?
memberNotes MemberNote[]
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
classReminderDueAt DateTime? @map("class_reminder_due_at")
classReminderClaimedAt DateTime? @map("class_reminder_claimed_at")
classReminderSentAt DateTime? @map("class_reminder_sent_at")
statusHistory BookingStatusHistory[]
@@unique([userId, timeSlotId])
@@ -261,14 +258,12 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@ -334,51 +329,6 @@ model StudioConfig {
@@map("studio_config")
}
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}
// Historical totals without fabricated scheduled dates.
model LessonSupplement {
id String @id @default(uuid())
@@ -402,3 +352,63 @@ model LessonSupplement {
@@index([userId, revokedAt, createdAt])
@@map("lesson_supplements")
}
model BookingReview {
id String @id @default(uuid())
bookingId String @unique @map("booking_id")
rating Int
recommendation Int?
tags Json
comment String @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at")
booking Booking @relation(fields: [bookingId], references: [id])
@@index([createdAt])
@@map("booking_reviews")
}
model BodyMetric {
id String @id @default(uuid())
userId String @map("user_id")
recordedAt DateTime @db.Date @map("recorded_at")
weight Float?
bodyFat Float? @map("body_fat")
waist Float?
hip Float?
flexibility Float?
remark String @default("") @db.VarChar(200)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("body_metrics")
}
model MemberNote {
id String @id @default(uuid())
userId String @map("user_id")
bookingId String? @map("booking_id")
content String @db.VarChar(1000)
shared Boolean @default(false)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id])
@@index([userId, createdAt])
@@map("member_notes")
}
model ProgressPhoto {
id String @id @default(uuid())
userId String @map("user_id")
objectKey String @unique @map("object_key")
caption String @default("") @db.VarChar(200)
recordedAt DateTime @db.Date @map("recorded_at")
uploadedAt DateTime? @map("uploaded_at")
consentedAt DateTime? @map("consented_at")
revokedAt DateTime? @map("revoked_at")
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("progress_photos")
}

View File

@@ -4,40 +4,15 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
constructor(private readonly prisma: PrismaService, private readonly analytics: TeachingAnalyticsService) {}
constructor(private readonly analytics: TeachingAnalyticsService) {}
@Get('teaching-analytics')
getTeachingAnalytics(@Query('month') month: string) {
return this.analytics.getMonthly(month)
}
@Get('stats')
async getStats(): Promise<AdminStats> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const [todayBookings, totalOrders, totalBookings] = await Promise.all([
this.prisma.booking.count({
where: {
timeSlot: { date: today },
},
}),
this.prisma.order.count(),
this.prisma.booking.count(),
])
return { todayBookings, totalOrders, totalBookings }
}
}

View File

@@ -11,7 +11,6 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module'
@Module({
@@ -30,7 +29,6 @@ import { InviteModule } from './invite/invite.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
],
controllers: [AppController],

View File

@@ -84,7 +84,7 @@ describe('AuthService', () => {
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
mockPrismaService.membership.count.mockResolvedValue(0)
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '')
})
// ── login ──────────────────────────────────────────────────────────────────

View File

@@ -71,6 +71,7 @@ export class AuthService {
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,

View File

@@ -20,6 +20,10 @@ const MOCK_SLOT_ID = 'slot-001'
const MOCK_MEMBERSHIP_ID = 'mem-001'
const MOCK_BOOKING_ID = 'booking-001'
const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false }
const MOCK_ADMIN_ID = 'admin-001'
const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true }
const mockTimesCardType = {
id: 'ct-times-001',
name: '10次卡',
@@ -166,7 +170,12 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
let subscriptionMessageService: {
sendBookingConfirmedMessage: jest.Mock
sendAdminBookingCreatedMessage: jest.Mock
sendBookingCancelledMessage: jest.Mock
sendClassReminderMessage: jest.Mock
}
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => {
@@ -183,6 +192,7 @@ describe('BookingService', () => {
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
groupBy: jest.fn(),
},
timeSlot: {
findUnique: jest.fn(),
@@ -218,6 +228,8 @@ describe('BookingService', () => {
useValue: {
sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(),
sendBookingCancelledMessage: jest.fn(),
sendClassReminderMessage: jest.fn(),
},
},
{
@@ -408,6 +420,12 @@ describe('BookingService', () => {
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: BookingStatus.COMPLETED,
reviewReminderDueAt: expect.any(Date),
}),
}))
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})
@@ -756,7 +774,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -807,7 +825,7 @@ describe('BookingService', () => {
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -840,7 +858,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
@@ -869,7 +887,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
@@ -891,7 +909,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(false)
// membership.update must NOT be called
@@ -914,7 +932,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
// slot was FULL → should be restored to OPEN
expect(tx.timeSlot.update).toHaveBeenCalledWith(
@@ -949,7 +967,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -965,7 +983,7 @@ describe('BookingService', () => {
it('throws NotFoundException when booking does not exist', async () => {
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null)
await expect(service.cancelBooking(MOCK_USER_ID, 'nonexistent')).rejects.toThrow(
await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow(
NotFoundException,
)
})
@@ -974,7 +992,7 @@ describe('BookingService', () => {
const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking)
await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow(
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
ForbiddenException,
)
})
@@ -988,10 +1006,119 @@ describe('BookingService', () => {
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking)
await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow(
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
BadRequestException,
)
})
// ─── Admin actor branch ─────────────────────────────────────────────────
it('admin actor can cancel another user\'s booking (skips owner check)', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: BookingStatus.CANCELLED }),
}),
)
})
it('admin actor records 管理员 remark in bookingStatusHistory', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_ADMIN_ID,
remark: '管理员取消预约(超时退款)',
}),
}),
)
})
it('member actor records 学员 remark when cancelling own booking', async () => {
const ownBooking = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_USER_ID,
remark: '学员取消预约(超时退款)',
}),
}),
)
})
it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => {
const futureDate = new Date(Date.now() + 86400000 * 2)
const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' }
const ownBooking = {
...mockConfirmedBooking,
userId: MOCK_USER_ID,
timeSlot: futureSlot,
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'test-user-openid',
userId: MOCK_USER_ID,
bookingId: MOCK_BOOKING_ID,
courseName: 'Test Studio',
}),
)
})
})
// ─── getMyBookings ────────────────────────────────────────────────────────
@@ -1080,8 +1207,10 @@ describe('BookingService', () => {
membership: mockActiveMembership,
},
]
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.CONFIRMED, _count: { _all: 1 } },
])
;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings)
;(prisma.booking.count as jest.Mock).mockResolvedValue(1)
const result = await service.getAllBookings(1, 10)
@@ -1101,6 +1230,45 @@ describe('BookingService', () => {
}),
)
})
it('sorts the unfiltered list by status priority and pages across segments', async () => {
// 全部视图:待确认 → 已确认 → 已完成 → 已取消,未到归入已完成之后
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.PENDING_CONFIRMATION, _count: { _all: 1 } },
{ status: BookingStatus.CONFIRMED, _count: { _all: 2 } },
{ status: BookingStatus.COMPLETED, _count: { _all: 1 } },
{ status: BookingStatus.CANCELLED, _count: { _all: 1 } },
])
const byStatus: Record<string, { id: string }[]> = {
[BookingStatus.PENDING_CONFIRMATION]: [{ id: 'b-pending' }],
[BookingStatus.CONFIRMED]: [{ id: 'b-confirmed-1' }, { id: 'b-confirmed-2' }],
[BookingStatus.COMPLETED]: [{ id: 'b-completed' }],
[BookingStatus.CANCELLED]: [{ id: 'b-cancelled' }],
}
;(prisma.booking.findMany as jest.Mock).mockImplementation(
(args: { where: { status: string } }) => byStatus[args.where.status] ?? [],
)
const page1 = await service.getAllBookings(1, 3)
expect(page1.total).toBe(5)
expect(page1.data.map((b) => b.id)).toEqual(['b-pending', 'b-confirmed-1', 'b-confirmed-2'])
const page2 = await service.getAllBookings(2, 3)
expect(page2.data.map((b) => b.id)).toEqual(['b-completed', 'b-cancelled'])
// 段内排序:已确认按上课时间正序,其余按创建时间倒序
const confirmedCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.CONFIRMED,
)
expect(confirmedCall[0].orderBy).toEqual([
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
])
const pendingCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.PENDING_CONFIRMATION,
)
expect(pendingCall[0].orderBy).toEqual({ createdAt: 'desc' })
})
})
describe('getTeachingScheduleByDate', () => {
@@ -1138,7 +1306,7 @@ describe('BookingService', () => {
},
])
const result = await service.getTeachingScheduleByDate('2026-04-19')
const result = await service.getTeachingScheduleByDate('2099-12-31')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1158,7 +1326,7 @@ describe('BookingService', () => {
expect(result).toEqual([
{
slotId: 'slot-01',
date: '2026-04-19',
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
@@ -1175,7 +1343,7 @@ describe('BookingService', () => {
},
{
slotId: 'slot-02',
date: '2026-04-19',
date: '2099-12-31',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
@@ -1193,6 +1361,98 @@ describe('BookingService', () => {
])
})
it('returns all-status bookings when the date is today', async () => {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-today',
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
bookings: [
{
id: 'booking-completed',
status: BookingStatus.COMPLETED,
createdAt: new Date(`${today}T00:00:00Z`),
user: { id: 'user-1', nickname: '完成', phone: '13800000001' },
},
{
id: 'booking-no-show',
status: BookingStatus.NO_SHOW,
createdAt: new Date(`${today}T00:00:01Z`),
user: { id: 'user-2', nickname: '未到', phone: null },
},
{
id: 'booking-cancelled',
status: BookingStatus.CANCELLED,
createdAt: new Date(`${today}T00:00:02Z`),
user: { id: 'user-3', nickname: '取消', phone: '13800000003' },
},
{
id: 'booking-confirmed',
status: BookingStatus.CONFIRMED,
createdAt: new Date(`${today}T00:00:03Z`),
user: { id: 'user-4', nickname: '确认', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate(today)
// 当日课表timeSlot 仍用 EXISTS 守卫过滤完全没人预约的空 slot不带状态条件
// 且 included bookings 不再限制状态。
const callArg = (prisma.timeSlot.findMany as jest.Mock).mock.calls[0][0]
expect(callArg.where).toEqual({
date: expect.any(Date),
bookings: { some: {} },
})
expect(callArg.include.bookings.where).toBeUndefined()
expect(result).toEqual([
{
slotId: 'slot-today',
date: today,
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
students: [
{
bookingId: 'booking-completed',
userId: 'user-1',
nickname: '完成',
phone: '13800000001',
status: BookingStatus.COMPLETED,
},
{
bookingId: 'booking-no-show',
userId: 'user-2',
nickname: '未到',
phone: null,
status: BookingStatus.NO_SHOW,
},
{
bookingId: 'booking-cancelled',
userId: 'user-3',
nickname: '取消',
phone: '13800000003',
status: BookingStatus.CANCELLED,
},
{
bookingId: 'booking-confirmed',
userId: 'user-4',
nickname: '确认',
phone: null,
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException,

View File

@@ -0,0 +1,69 @@
import { ReviewService, summarizeReviews } from '../review.service'
import { PrismaService } from '../../prisma/prisma.service'
import { Prisma } from '@prisma/client'
import { CreateReviewDto } from '../dto/create-review.dto'
import { validate } from 'class-validator'
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { ReviewController, PublicReviewController } from '../review.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
describe('Class reviews', () => {
const db = { booking: { findFirst: jest.fn() }, bookingReview: { create: jest.fn(), findMany: jest.fn(), count: jest.fn() } }
let service: ReviewService
beforeEach(() => { jest.resetAllMocks(); service = new ReviewService(db as unknown as PrismaService) })
const dto = { rating: 5, tags: ['氛围好'], comment: '很有收获' }
it('does not expose another member booking or review', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.get('other', 'booking')).rejects.toThrow('预约不存在')
expect(db.booking.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'booking', userId: 'other' } }))
await expect(service.create('other', 'booking', dto)).rejects.toThrow('预约不存在')
expect(db.bookingReview.create).not.toHaveBeenCalled()
})
it.each(['CONFIRMED', 'NO_SHOW', 'CANCELLED', 'PENDING_CONFIRMATION'])('rejects %s booking', async status => {
db.booking.findFirst.mockResolvedValue({ status })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('完成课程后才能评价')
})
it('allows older completed lessons, with database uniqueness protecting double submits', async () => {
db.booking.findFirst.mockResolvedValue({ status: 'COMPLETED', completedAt: new Date('2020-01-01') })
db.bookingReview.create.mockResolvedValueOnce({ id: 'review' }).mockRejectedValueOnce(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
await expect(service.create('member', 'booking', dto)).resolves.toEqual({ id: 'review' })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('已经评价')
})
it.each([{ rating: 0 }, { rating: 6 }, { rating: 2.5 }, { tags: ['伪造标签'] }, { tags: ['氛围好', '氛围好'] }, { tags: ['动作到位','氛围好','强度合适','讲解清晰'] }, { recommendation: 11 }, { recommendation: -1 }, { comment: '长'.repeat(201) }])('rejects invalid payload %j', async patch => {
expect((await validate(Object.assign(new CreateReviewDto(), dto, patch))).length).toBeGreaterThan(0)
})
it('accepts optional recommendation including zero', async () => {
expect(await validate(Object.assign(new CreateReviewDto(), dto, { recommendation: 0 }))).toHaveLength(0)
})
it('keeps star average separate from NPS and handles zero samples', () => {
expect(summarizeReviews([])).toEqual({ count: 0, average: null, nps: null, npsCount: 0 })
expect(summarizeReviews([{ rating: 5, recommendation: 0 }, { rating: 1, recommendation: 9 }, { rating: 4, recommendation: 7 }, { rating: 4, recommendation: null }])).toEqual({ count: 4, average: 3.5, nps: 0, npsCount: 3 })
})
it('groups the six Chinese calendar months across year and UTC boundaries', async () => {
db.bookingReview.findMany.mockResolvedValue([{ rating: 5, recommendation: 10, createdAt: new Date('2025-12-31T16:00:00Z') }, { rating: 1, recommendation: 0, createdAt: new Date('2025-12-31T15:59:59Z') }])
const result = await service.trend('2026-01')
expect(result.map(r => r.month)).toEqual(['2025-08','2025-09','2025-10','2025-11','2025-12','2026-01'])
expect(result[4].average).toBe(1); expect(result[5].average).toBe(5)
expect(db.bookingReview.findMany.mock.calls[0][0].where.createdAt).toEqual({ gte: new Date('2025-07-31T16:00:00Z'), lt: new Date('2026-01-31T16:00:00Z') })
})
})
describe('Review authorization', () => {
it('requires authentication for member and admin review endpoints', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ReviewController)).toContain(JwtAuthGuard)
})
it('keeps the public summary unauthenticated', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, PublicReviewController) || []).not.toContain(JwtAuthGuard)
})
it.each(['list', 'trend'] as const)('restricts %s to admins', method => {
const handler = ReviewController.prototype[method]
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => ReviewController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -14,6 +14,7 @@ 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 { AuthenticatedUser } from '../auth/jwt.strategy'
import { BookingService } from './booking.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
@@ -36,10 +37,13 @@ export class BookingController {
@Put('booking/:id/cancel')
@UseGuards(JwtAuthGuard)
async cancelBooking(
@CurrentUser('sub') userId: string,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
return this.bookingService.cancelBooking(userId, id)
return this.bookingService.cancelBooking(
{ id: user.sub, isAdmin: user.role === UserRole.ADMIN },
id,
)
}
@Get('booking/my/activity')

View File

@@ -1,3 +1,5 @@
import { ReviewController, PublicReviewController } from './review.controller'
import { ReviewService } from './review.service'
import { Module } from '@nestjs/common'
import { BookingController } from './booking.controller'
import { BookingService } from './booking.service'
@@ -8,8 +10,8 @@ import { InviteModule } from '../invite/invite.module'
@Module({
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
controllers: [BookingController],
providers: [BookingService],
controllers: [BookingController, ReviewController, PublicReviewController],
providers: [BookingService, ReviewService],
exports: [BookingService],
})
export class BookingModule {}

View File

@@ -41,6 +41,10 @@ export interface CancelBookingResult {
refunded: boolean
}
export type AdminBookingRow = BookingWithRelations & {
user: { id: string; nickname: string; phone: string | null }
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function buildSlotStartMs(slotDate: Date, startTime: string): number {
@@ -183,6 +187,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
})
@@ -455,6 +460,7 @@ export class BookingService {
}
if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date()
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000)
}
const updated = await tx.booking.update({
@@ -485,7 +491,7 @@ export class BookingService {
// ─── Cancel Booking ──────────────────────────────────────────────────────
async cancelBooking(
userId: string,
actor: { id: string; isAdmin: boolean },
bookingId: string,
): Promise<CancelBookingResult> {
const booking = await this.prisma.booking.findUnique({
@@ -499,10 +505,12 @@ export class BookingService {
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`)
}
if (booking.userId !== userId) {
// Members can only cancel their own bookings; admins can cancel any.
if (!actor.isAdmin && booking.userId !== actor.id) {
throw new ForbiddenException('This booking does not belong to you')
}
const actorLabel = actor.isAdmin ? '管理员' : '学员'
let refunded = false
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
@@ -518,11 +526,12 @@ export class BookingService {
bookingId,
fromStatus: BookingStatus.PENDING_CONFIRMATION,
toStatus: BookingStatus.CANCELLED,
operatorId: userId,
remark: '学员取消预约(待确认状态)',
operatorId: actor.id,
remark: `${actorLabel}取消预约(待确认状态)`,
},
})
})
await this.trySendBookingCancelledSubscriptionMessage(booking)
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
}
@@ -599,14 +608,18 @@ export class BookingService {
bookingId,
fromStatus: BookingStatus.CONFIRMED,
toStatus: BookingStatus.CANCELLED,
operatorId: userId,
remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)',
operatorId: actor.id,
remark: refunded
? `${actorLabel}取消预约(超时退款)`
: `${actorLabel}取消预约(未超时不退款)`,
},
})
return cancelled
})
await this.trySendBookingCancelledSubscriptionMessage(booking)
return { booking: { ...updatedBooking }, refunded }
}
@@ -628,6 +641,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
user: { select: { id: true, nickname: true, phone: true } },
},
})
@@ -653,6 +667,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
@@ -714,6 +729,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
orderBy: [
{ timeSlot: { date: 'asc' } },
@@ -726,22 +742,51 @@ export class BookingService {
// ─── Get All Bookings (Admin) ─────────────────────────────────────────────
// “全部”视图下的展示优先级:待处理的事置顶,历史记录沉底。
// NO_SHOW 排在已完成之后,与前端“已完成”统计口径一致。
private static readonly STATUS_PRIORITY: readonly BookingStatus[] = [
BookingStatus.PENDING_CONFIRMATION,
BookingStatus.CONFIRMED,
BookingStatus.COMPLETED,
BookingStatus.NO_SHOW,
BookingStatus.CANCELLED,
]
private readonly adminBookingInclude = {
user: { select: { id: true, nickname: true, phone: true } },
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
}
async getAllBookings(
page = 1,
limit = 10,
status?: BookingStatus,
): Promise<PaginatedResult<BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } }>> {
const where = status ? { status } : {}
): Promise<PaginatedResult<AdminBookingRow>> {
return status ? this.getBookingsPageByStatus(page, limit, status) : this.getAllBookingsPageByStatusPriority(page, limit)
}
// Confirmed-but-not-yet-completed sessions are the ones a teacher is about
// to run, so sort them by upcoming time (soonest first). For every other
// status (PENDING / COMPLETED / CANCELLED / NO_SHOW), creation order
// (newest first) is the more useful default.
private bookingsOrderByForStatus(status: BookingStatus) {
return status === BookingStatus.CONFIRMED
? [
{ timeSlot: { date: 'asc' as const } },
{ timeSlot: { startTime: 'asc' as const } },
]
: { createdAt: 'desc' as const }
}
private async getBookingsPageByStatus(page: number, limit: number, status: BookingStatus) {
const where = { status }
const [bookings, total] = await Promise.all([
this.prisma.booking.findMany({
where,
include: {
user: { select: { id: true, nickname: true, phone: true } },
timeSlot: true,
membership: { include: { cardType: true } },
},
orderBy: { createdAt: 'desc' },
include: this.adminBookingInclude,
orderBy: this.bookingsOrderByForStatus(status),
skip: (page - 1) * limit,
take: limit,
}),
@@ -749,9 +794,49 @@ export class BookingService {
])
return {
data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & {
user: { id: string; nickname: string; phone: string | null }
})[],
data: bookings.map((b) => ({ ...b })) as unknown as AdminBookingRow[],
total,
page,
limit,
}
}
// Prisma 的 orderBy 表达不了 CASE 式的状态优先级,所以“全部”视图先按状态
// groupBy 计数,再把分页窗口按优先级切进各状态段分别查询,最后按优先级拼接。
private async getAllBookingsPageByStatusPriority(page: number, limit: number) {
const groups = await this.prisma.booking.groupBy({
by: ['status'],
_count: { _all: true },
})
const counts = new Map(groups.map((g) => [g.status, g._count._all]))
const total = BookingService.STATUS_PRIORITY.reduce((sum, s) => sum + (counts.get(s) ?? 0), 0)
const windowStart = (page - 1) * limit
const windowEnd = windowStart + limit
let segmentStart = 0
const queries: Promise<unknown[]>[] = []
for (const status of BookingService.STATUS_PRIORITY) {
const segmentCount = counts.get(status) ?? 0
if (segmentCount === 0) continue
const segmentEnd = segmentStart + segmentCount
if (segmentEnd > windowStart && segmentStart < windowEnd) {
queries.push(
this.prisma.booking.findMany({
where: { status },
include: this.adminBookingInclude,
orderBy: this.bookingsOrderByForStatus(status),
skip: Math.max(0, windowStart - segmentStart),
take: Math.min(segmentEnd, windowEnd) - Math.max(segmentStart, windowStart),
}),
)
}
segmentStart = segmentEnd
}
const segments = await Promise.all(queries)
return {
data: segments.flat() as unknown as AdminBookingRow[],
total,
page,
limit,
@@ -764,20 +849,22 @@ export class BookingService {
throw new BadRequestException('Invalid date')
}
// 当日的课表需要包含所有状态的预约:老师下课后,已核销 / 标记未到 / 已取消
// 的预约不应从课表里消失,状态由前端标签呈现;其他日期维持「只看待上课」语义。
const showAllStatuses = this.isLocalToday(date)
const activeBookingFilter = {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}
const slots = await this.prisma.timeSlot.findMany({
where: {
date: dayStart,
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
// 仍用 EXISTS 守卫过滤掉完全没人预约的空 slot但今天放开状态过滤。
bookings: { some: showAllStatuses ? {} : activeBookingFilter },
},
include: {
bookings: {
where: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
...(showAllStatuses ? {} : { where: activeBookingFilter }),
include: {
user: {
select: {
@@ -824,6 +911,14 @@ export class BookingService {
})
}
private isLocalToday(date: string): boolean {
return date === this.formatLocalDate(new Date())
}
private formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
@@ -832,6 +927,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
})
@@ -855,8 +951,7 @@ export class BookingService {
}
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
@@ -871,6 +966,39 @@ export class BookingService {
}
}
private async trySendBookingCancelledSubscriptionMessage(
booking: {
id: string
userId: string
timeSlot: { date: Date; startTime: string; endTime: string }
},
): Promise<void> {
try {
const user = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { openid: true },
})
if (!user?.openid) {
return
}
const studio = await this.studioService.getInfo()
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const courseTime = `${booking.timeSlot.startTime.slice(0, 5)}-${booking.timeSlot.endTime.slice(0, 5)}`
await this.subscriptionMessageService.sendBookingCancelledMessage({
openid: user.openid,
userId: booking.userId,
bookingId: booking.id,
bookingDate: dateLabel,
courseTime,
courseName: studio.name || '普拉提课程',
})
} catch (error) {
console.error('Send booking cancelled subscription message failed:', error)
}
}
private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations,
): Promise<void> {
@@ -894,8 +1022,7 @@ export class BookingService {
select: { nickname: true, phone: true },
})
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled(
admins

View File

@@ -0,0 +1,8 @@
import { ArrayMaxSize, ArrayUnique, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'
import { REVIEW_TAGS } from '@mp-pilates/shared'
export class CreateReviewDto {
@IsInt() @Min(1) @Max(5) rating!: number
@IsOptional() @IsInt() @Min(0) @Max(10) recommendation?: number
@IsArray() @ArrayMaxSize(3) @ArrayUnique() @IsIn(REVIEW_TAGS, { each: true }) tags!: string[]
@IsString() @MaxLength(200) comment!: string
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post, Query, 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 { ReviewService } from './review.service'
import { CreateReviewDto } from './dto/create-review.dto'
@Controller()
@UseGuards(JwtAuthGuard)
export class ReviewController {
constructor(private readonly service: ReviewService) {}
@Get('booking/:id/review')
get(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.get(userId, id) }
@Post('booking/:id/review')
create(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: CreateReviewDto) { return this.service.create(userId, id, dto) }
@Get('admin/reviews') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
list(@Query('userId') userId?: string, @Query('page') page?: string) { return this.service.list(userId, page ? Number(page) : 1) }
@Get('admin/reviews/trend') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
trend(@Query('month') month?: string) { return this.service.trend(month) }
}
@Controller('reviews')
export class PublicReviewController {
constructor(private readonly service: ReviewService) {}
@Get('summary') summary() { return this.service.publicSummary() }
}

View File

@@ -0,0 +1,61 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { CreateReviewDto } from './dto/create-review.dto'
export function summarizeReviews(rows: { rating: number; recommendation: number | null }[]) {
const recommendations = rows.filter(r => r.recommendation !== null)
return {
count: rows.length,
average: rows.length ? Math.round(rows.reduce((sum, r) => sum + r.rating, 0) / rows.length * 10) / 10 : null,
npsCount: recommendations.length,
nps: recommendations.length ? Math.round(100 * (recommendations.filter(r => r.recommendation! >= 9).length - recommendations.filter(r => r.recommendation! <= 6).length) / recommendations.length) : null,
}
}
@Injectable()
export class ReviewService {
constructor(private readonly prisma: PrismaService) {}
async get(userId: string, bookingId: string) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId }, include: { review: true } })
if (!booking) throw new NotFoundException('预约不存在')
return { review: booking.review, canReview: booking.status === 'COMPLETED' && !booking.review }
}
async create(userId: string, bookingId: string, dto: CreateReviewDto) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId } })
if (!booking) throw new NotFoundException('预约不存在')
if (booking.status !== 'COMPLETED') throw new BadRequestException('完成课程后才能评价')
try {
return await this.prisma.bookingReview.create({ data: { bookingId, rating: dto.rating, recommendation: dto.recommendation, tags: dto.tags, comment: dto.comment.trim() } })
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') throw new BadRequestException('这节课已经评价过了')
throw error
}
}
async publicSummary() {
const result = await this.prisma.bookingReview.aggregate({ _count: { id: true }, _avg: { rating: true } })
return { count: result._count.id, average: result._avg.rating === null ? null : Math.round(result._avg.rating * 10) / 10 }
}
async list(userId?: string, page = 1) {
if (!Number.isInteger(page) || page < 1 || page > 10000) throw new BadRequestException('页码无效')
const where = userId ? { booking: { userId } } : {}
const [data, total] = await Promise.all([
this.prisma.bookingReview.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * 20, take: 20,
include: { booking: { select: { userId: true, user: { select: { nickname: true } }, timeSlot: { select: { date: true, startTime: true, endTime: true } } } } } }),
this.prisma.bookingReview.count({ where }),
])
return { data, total, page, limit: 20 }
}
async trend(month?: string) {
const current = month || new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7)
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(current)) throw new BadRequestException('月份格式应为 YYYY-MM')
const end = new Date(current + '-01T00:00:00+08:00')
const months = Array.from({ length: 6 }, (_, i) => {
const date = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1 - (5 - i), 1))
return date.toISOString().slice(0, 7)
})
const from = new Date(months[0] + '-01T00:00:00+08:00')
const to = new Date(Date.UTC(Number(current.slice(0, 4)), Number(current.slice(5)), 1) - 8 * 3600000)
const rows = await this.prisma.bookingReview.findMany({ where: { createdAt: { gte: from, lt: to } }, select: { rating: true, recommendation: true, createdAt: true } })
return months.map(month => ({ month, ...summarizeReviews(rows.filter(r => new Date(r.createdAt.getTime() + 8 * 3600000).toISOString().startsWith(month))) }))
}
}

View File

@@ -1,25 +0,0 @@
import { FlashSaleService } from '../flash-sale.service'
import { PrismaService } from '../../prisma/prisma.service'
import { WechatPayService } from '../../payment/wechat-pay.service'
describe('Flash sale referral pricing', () => {
it.each([true, false])('snapshots referral and category with eligible=%s', async (eligible) => {
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'buyer', phone: '123', openid: 'openid' }) },
flashSale: {
findUnique: jest.fn().mockResolvedValue({ id: 'sale', title: 'sale', cardTypeId: 'card', cardType: { type: 'TIMES' }, status: 'ACTIVE', startTime: new Date(0), endTime: new Date(Date.now() + 60000), totalStock: 10, flashPrice: 999 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
inviteReferral: { findUnique: jest.fn().mockResolvedValue(eligible ? { inviterId: 'friend' } : null) },
order: { create: jest.fn().mockImplementation(({ data }) => ({ ...data, id: 'order' })) },
flashSaleOrder: { create: jest.fn().mockResolvedValue({ id: 'reserved' }) },
$transaction: jest.fn(),
}
prisma.$transaction.mockImplementation((fn) => fn(prisma))
const pay = { createUnifiedOrder: jest.fn().mockResolvedValue({}) }
const service = new FlashSaleService(prisma as unknown as PrismaService, pay as unknown as WechatPayService)
await service.purchase('sale', 'buyer')
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: eligible ? 949 : 999, inviteInviterId: eligible ? 'friend' : undefined, purchasedCategory: 'TIMES' }) })
expect(pay.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: eligible ? 949 : 999 }))
})
})

View File

@@ -1,35 +0,0 @@
import { IsUUID, IsString, IsInt, IsDateString, IsOptional, Min, IsNumber } from 'class-validator'
export class CreateFlashSaleDto {
@IsUUID()
cardTypeId!: string
@IsString()
title!: string
@IsNumber()
@Min(1)
originalPrice!: number
@IsNumber()
@Min(1)
flashPrice!: number
@IsInt()
@Min(1)
totalStock!: number
@IsDateString()
startTime!: string
@IsDateString()
endTime!: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -1,43 +0,0 @@
import { IsString, IsInt, IsDateString, IsOptional, Min, IsNumber, IsEnum } from 'class-validator'
import { FlashSaleStatus } from '@mp-pilates/shared'
export class UpdateFlashSaleDto {
@IsOptional()
@IsString()
title?: string
@IsOptional()
@IsNumber()
@Min(1)
originalPrice?: number
@IsOptional()
@IsNumber()
@Min(1)
flashPrice?: number
@IsOptional()
@IsInt()
@Min(1)
totalStock?: number
@IsOptional()
@IsDateString()
startTime?: string
@IsOptional()
@IsDateString()
endTime?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsEnum(FlashSaleStatus)
status?: FlashSaleStatus
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -1,67 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
ValidationPipe,
} 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 { FlashSaleService } from './flash-sale.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
@Controller('admin/flash-sales')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class FlashSaleAdminController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** POST /admin/flash-sales — create */
@Post()
create(
@Body(new ValidationPipe({ whitelist: true })) dto: CreateFlashSaleDto,
) {
return this.flashSaleService.createFlashSale(dto)
}
/** GET /admin/flash-sales — list (paginated) */
@Get()
list(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.flashSaleService.getAdminFlashSales(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
)
}
/** GET /admin/flash-sales/:id — detail */
@Get(':id')
detail(@Param('id') id: string) {
return this.flashSaleService.getFlashSaleDetail(id)
}
/** PUT /admin/flash-sales/:id — update */
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ whitelist: true })) dto: UpdateFlashSaleDto,
) {
return this.flashSaleService.updateFlashSale(id, dto)
}
/** DELETE /admin/flash-sales/:id — delete (DRAFT only) */
@Delete(':id')
remove(@Param('id') id: string) {
return this.flashSaleService.deleteFlashSale(id)
}
}

View File

@@ -1,40 +0,0 @@
import {
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { FlashSaleService } from './flash-sale.service'
@Controller('flash-sales')
export class FlashSaleController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** GET /flash-sales — list active/upcoming (public) */
@Get()
getActiveFlashSales() {
return this.flashSaleService.getActiveFlashSales()
}
/** GET /flash-sales/:id — detail (optionally authenticated) */
@Get(':id')
getFlashSaleDetail(
@Param('id') id: string,
@CurrentUser('sub') userId?: string,
) {
return this.flashSaleService.getFlashSaleDetail(id, userId)
}
/** POST /flash-sales/:id/purchase — requires auth */
@Post(':id/purchase')
@UseGuards(JwtAuthGuard)
purchase(
@Param('id') flashSaleId: string,
@CurrentUser('sub') userId: string,
) {
return this.flashSaleService.purchase(flashSaleId, userId)
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { PaymentModule } from '../payment/payment.module'
import { FlashSaleService } from './flash-sale.service'
import { FlashSaleController } from './flash-sale.controller'
import { FlashSaleAdminController } from './flash-sale-admin.controller'
@Module({
imports: [PrismaModule, PaymentModule],
controllers: [FlashSaleController, FlashSaleAdminController],
providers: [FlashSaleService],
exports: [FlashSaleService],
})
export class FlashSaleModule {}

View File

@@ -1,412 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { Prisma } from '@prisma/client'
import {
FlashSaleStatus,
FlashSaleOrderStatus,
MembershipStatus,
OrderStatus,
} from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from '../payment/wechat-pay.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
// ── Helpers ─────────────────────────────────────────────────
function computePhase(sale: {
startTime: Date
endTime: Date
soldCount: number
totalStock: number
status: string
}): FlashSalePhase {
if (sale.status === FlashSaleStatus.ENDED) return FlashSalePhase.ENDED
const now = new Date()
if (now < sale.startTime) return FlashSalePhase.UPCOMING
if (now > sale.endTime) return FlashSalePhase.ENDED
if (sale.soldCount >= sale.totalStock) return FlashSalePhase.SOLD_OUT
return FlashSalePhase.ONGOING
}
function toNumber(val: Prisma.Decimal | number): number {
return typeof val === 'number' ? val : Number(val)
}
// ── Service ─────────────────────────────────────────────────
@Injectable()
export class FlashSaleService {
private readonly logger = new Logger(FlashSaleService.name)
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
) {}
// ═══════════════════════════════════════════════════════════
// USER: List active/upcoming flash sales
// ═══════════════════════════════════════════════════════════
async getActiveFlashSales() {
const sales = await this.prisma.flashSale.findMany({
where: {
status: FlashSaleStatus.ACTIVE,
endTime: { gt: new Date() },
},
include: {
cardType: {
select: { name: true, type: true, totalTimes: true, durationDays: true },
},
},
orderBy: [{ sortOrder: 'asc' }, { startTime: 'asc' }],
})
return sales.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
remainingStock: s.totalStock - s.soldCount,
cardType: s.cardType,
}))
}
// ═══════════════════════════════════════════════════════════
// USER: Get detail (with participation check)
// ═══════════════════════════════════════════════════════════
async getFlashSaleDetail(id: string, userId?: string) {
const sale = await this.prisma.flashSale.findUnique({
where: { id },
include: {
cardType: {
select: {
name: true,
type: true,
totalTimes: true,
durationDays: true,
description: true,
},
},
},
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
let hasParticipated = false
let userOrderStatus: FlashSaleOrderStatus | null = null
if (userId) {
const existing = await this.prisma.flashSaleOrder.findUnique({
where: { flashSaleId_userId: { flashSaleId: id, userId } },
})
if (existing && existing.status !== FlashSaleOrderStatus.EXPIRED) {
hasParticipated = true
userOrderStatus = existing.status as FlashSaleOrderStatus
}
}
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
remainingStock: sale.totalStock - sale.soldCount,
cardType: { ...sale.cardType },
hasParticipated,
userOrderStatus,
serverTime: new Date().toISOString(),
}
}
// ═══════════════════════════════════════════════════════════
// PURCHASE — Atomic stock deduction
// ═══════════════════════════════════════════════════════════
async purchase(flashSaleId: string, userId: string) {
// ① Pre-validate (fast-fail before transaction)
const user = await this.prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('用户不存在')
if (!user.phone) throw new BadRequestException('请先授权手机号后再参与秒杀')
const sale = await this.prisma.flashSale.findUnique({
where: { id: flashSaleId },
include: { cardType: true },
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
if (sale.status !== FlashSaleStatus.ACTIVE) {
throw new BadRequestException('秒杀活动未上线')
}
const now = new Date()
if (now < sale.startTime) throw new BadRequestException('秒杀尚未开始')
if (now > sale.endTime) throw new BadRequestException('秒杀已结束')
// ② Atomic transaction: reserve stock + create FlashSaleOrder + create Order
let result: { order: { id: string; orderNo: string; amount: Prisma.Decimal }; flashSaleOrderId: string }
try {
result = await this.prisma.$transaction(async (tx) => {
// ②-a: CAS optimistic lock stock deduction
const updated = await tx.flashSale.updateMany({
where: {
id: flashSaleId,
soldCount: { lt: sale.totalStock },
},
data: {
soldCount: { increment: 1 },
},
})
if (updated.count === 0) {
throw new BadRequestException('手慢了,已售罄')
}
// ②-b: Create Order with flash sale price
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const referral = await tx.inviteReferral.findUnique({ where: { inviteeId: userId } })
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: referral ? Math.round(Number(sale.flashPrice) * 95 / 100) : sale.flashPrice,
inviteInviterId: referral?.inviterId,
purchasedCategory: sale.cardType.type,
status: OrderStatus.PENDING,
flashSaleId,
},
})
// ②-c: Create FlashSaleOrder (unique constraint prevents duplicate)
const flashSaleOrder = await tx.flashSaleOrder.create({
data: {
flashSaleId,
userId,
orderId: order.id,
status: FlashSaleOrderStatus.RESERVED,
},
})
return {
order: { id: order.id, orderNo: order.orderNo, amount: order.amount },
flashSaleOrderId: flashSaleOrder.id,
}
})
} catch (err) {
// Handle unique constraint violation (user already participated)
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new ConflictException('您已参与过此秒杀活动')
}
throw err
}
// ③ Create WeChat unified order (outside transaction — network call)
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
openid: user.openid,
description: `秒杀-${sale.title}`,
})
return {
flashSaleOrderId: result.flashSaleOrderId,
order: {
id: result.order.id,
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
},
paymentParams,
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Create flash sale
// ═══════════════════════════════════════════════════════════
async createFlashSale(dto: CreateFlashSaleDto) {
const cardType = await this.prisma.cardType.findUnique({
where: { id: dto.cardTypeId },
})
if (!cardType) throw new NotFoundException('卡种不存在')
const startTime = new Date(dto.startTime)
const endTime = new Date(dto.endTime)
if (endTime <= startTime) {
throw new BadRequestException('结束时间必须晚于开始时间')
}
const sale = await this.prisma.flashSale.create({
data: {
cardTypeId: dto.cardTypeId,
title: dto.title,
originalPrice: dto.originalPrice,
flashPrice: dto.flashPrice,
totalStock: dto.totalStock,
startTime,
endTime,
description: dto.description ?? null,
sortOrder: dto.sortOrder ?? 0,
status: FlashSaleStatus.DRAFT,
},
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Update flash sale
// ═══════════════════════════════════════════════════════════
async updateFlashSale(id: string, dto: UpdateFlashSaleDto) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
const data: Record<string, unknown> = {}
if (dto.title !== undefined) data.title = dto.title
if (dto.originalPrice !== undefined) data.originalPrice = dto.originalPrice
if (dto.flashPrice !== undefined) data.flashPrice = dto.flashPrice
if (dto.totalStock !== undefined) {
if (dto.totalStock < existing.soldCount) {
throw new BadRequestException('库存不能小于已售数量')
}
data.totalStock = dto.totalStock
}
if (dto.startTime !== undefined) data.startTime = new Date(dto.startTime)
if (dto.endTime !== undefined) data.endTime = new Date(dto.endTime)
if (dto.description !== undefined) data.description = dto.description
if (dto.status !== undefined) data.status = dto.status
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder
const sale = await this.prisma.flashSale.update({
where: { id },
data,
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Delete flash sale (only DRAFT)
// ═══════════════════════════════════════════════════════════
async deleteFlashSale(id: string) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
if (existing.soldCount > 0) {
throw new BadRequestException('已有用户参与,无法删除,请结束活动')
}
await this.prisma.flashSale.delete({ where: { id } })
return { deleted: true }
}
// ═══════════════════════════════════════════════════════════
// ADMIN: List all flash sales (paginated)
// ═══════════════════════════════════════════════════════════
async getAdminFlashSales(page = 1, limit = 20) {
const skip = (page - 1) * limit
const [data, total] = await Promise.all([
this.prisma.flashSale.findMany({
include: {
cardType: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.flashSale.count(),
])
return {
data: data.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
cardType: s.cardType,
})),
total,
page,
limit,
}
}
// ═══════════════════════════════════════════════════════════
// SCHEDULER: Expire unpaid reservations (release stock)
// ═══════════════════════════════════════════════════════════
async expireUnpaidReservations(expireMinutes = 15): Promise<number> {
const cutoff = new Date(Date.now() - expireMinutes * 60_000)
const expiredOrders = await this.prisma.flashSaleOrder.findMany({
where: {
status: FlashSaleOrderStatus.RESERVED,
reservedAt: { lt: cutoff },
},
})
if (expiredOrders.length === 0) return 0
// Group by flashSaleId to batch stock release
const stockDecrements = new Map<string, number>()
const orderIds: string[] = []
const flashSaleOrderIds: string[] = []
for (const fo of expiredOrders) {
flashSaleOrderIds.push(fo.id)
stockDecrements.set(fo.flashSaleId, (stockDecrements.get(fo.flashSaleId) ?? 0) + 1)
if (fo.orderId) orderIds.push(fo.orderId)
}
try {
await this.prisma.$transaction([
// Batch mark all as expired
this.prisma.flashSaleOrder.updateMany({
where: { id: { in: flashSaleOrderIds } },
data: { status: FlashSaleOrderStatus.EXPIRED, expiredAt: new Date() },
}),
// Release stock per flash sale
...Array.from(stockDecrements.entries()).map(([flashSaleId, count]) =>
this.prisma.flashSale.update({
where: { id: flashSaleId },
data: { soldCount: { decrement: count } },
}),
),
// Cancel associated payment orders
...(orderIds.length > 0
? [
this.prisma.order.updateMany({
where: { id: { in: orderIds } },
data: { status: OrderStatus.REFUNDED },
}),
]
: []),
])
} catch (err) {
this.logger.error('Failed to batch-expire flash sale orders', err)
return 0
}
return expiredOrders.length
}
}

View File

@@ -90,9 +90,6 @@ function buildPrismaMock() {
findFirst: jest.fn(),
update: jest.fn(),
},
flashSaleOrder: {
updateMany: jest.fn(),
},
$transaction: jest.fn(),
}
}

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { OrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
@@ -154,21 +154,6 @@ export class PaymentService {
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {
await this.prisma.flashSaleOrder.updateMany({
where: {
orderId: existingOrder.id,
status: FlashSaleOrderStatus.RESERVED,
},
data: {
status: FlashSaleOrderStatus.PAID,
paidAt: now,
},
})
this.logger.log(`Flash sale order marked PAID for orderNo=${notification.orderNo}`)
}
return this.buildSuccessXml()
}

View File

@@ -0,0 +1,122 @@
import { ClassReminderService } from '../class-reminder.service'
import { PrismaService } from '../../prisma/prisma.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { ConfigService } from '@nestjs/config'
import { Logger } from '@nestjs/common'
import { BookingStatus } from '@mp-pilates/shared'
describe('ClassReminderService', () => {
const db = {
booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
studioConfig: { findFirst: jest.fn() },
}
const messages = {
getClassReminderTemplateId: jest.fn(),
sendClassReminderMessage: jest.fn(),
}
const config = { get: jest.fn() }
let service: ClassReminderService
beforeEach(() => {
jest.resetAllMocks()
messages.getClassReminderTemplateId.mockReturnValue('reminder-template-id')
db.studioConfig.findFirst.mockResolvedValue({ name: 'FocusCore Studio' })
service = new ClassReminderService(
db as unknown as PrismaService,
messages as unknown as SubscriptionMessageService,
config as unknown as ConfigService,
)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {})
})
afterEach(() => jest.restoreAllMocks())
it('skips run when class reminder template is not configured', async () => {
messages.getClassReminderTemplateId.mockReturnValue('')
await service.run()
expect(db.booking.findMany).not.toHaveBeenCalled()
})
it('scans and sends reminders for bookings starting in ~1 hour (e.g. 60 mins)', async () => {
// Current time fixed or calculated
const now = new Date()
const targetDate = new Date(now.getTime() + 60 * 60 * 1000)
// Convert targetDate to China time string representation
// China is UTC+8
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
const startTime = `${hours}:${minutes}:00`
db.booking.findMany.mockResolvedValue([
{
id: 'booking-reminder-1',
userId: 'user-1',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-user-1' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime,
endTime: '12:00:00',
},
},
])
db.booking.updateMany.mockResolvedValue({ count: 1 })
messages.sendClassReminderMessage.mockResolvedValue(true)
await service.run()
expect(db.booking.updateMany).toHaveBeenCalledWith({
where: {
id: 'booking-reminder-1',
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
},
data: { classReminderClaimedAt: expect.any(Date) },
})
expect(messages.sendClassReminderMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'openid-user-1',
userId: 'user-1',
bookingId: 'booking-reminder-1',
courseName: 'FocusCore Studio',
}),
)
expect(db.booking.update).toHaveBeenCalledWith({
where: { id: 'booking-reminder-1' },
data: { classReminderSentAt: expect.any(Date) },
})
})
it('skips bookings that are not in the 50-70 minutes window (e.g. starting in 2 hours)', async () => {
const now = new Date()
const targetDate = new Date(now.getTime() + 120 * 60 * 1000)
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
db.booking.findMany.mockResolvedValue([
{
id: 'booking-far',
userId: 'user-far',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-far' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime: `${hours}:${minutes}:00`,
endTime: '20:00:00',
},
},
])
await service.run()
expect(db.booking.updateMany).not.toHaveBeenCalled()
expect(messages.sendClassReminderMessage).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
import { ReviewReminderService } from '../review-reminder.service'
import { PrismaService } from '../../prisma/prisma.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { ConfigService } from '@nestjs/config'
import { Logger } from '@nestjs/common'
describe('Review reminder queue', () => {
const db = { booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() } }
const messages = { sendReviewReminder: jest.fn() }, config = { get: jest.fn() }
let service: ReviewReminderService
beforeEach(() => {
jest.resetAllMocks()
config.get.mockReturnValue('tmpl')
service = new ReviewReminderService(db as unknown as PrismaService, messages as unknown as SubscriptionMessageService, config as unknown as ConfigService)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
db.booking.findMany.mockResolvedValue([{ id: 'b', userId: 'u', user: { openid: 'o' } }])
})
afterEach(() => jest.restoreAllMocks())
it('does not consume queue when template is unconfigured', async () => { config.get.mockReturnValue(''); await service.run(); expect(db.booking.findMany).not.toHaveBeenCalled() })
it('queries only due, unreviewed completed lessons and atomically claims a booking', async () => {
db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockResolvedValue(true)
await service.run()
const where = db.booking.findMany.mock.calls[0][0].where
expect(where.status).toBe('COMPLETED'); expect(where.review).toBeNull(); expect(where.reviewReminderDueAt.lte).toBeInstanceOf(Date)
expect(db.booking.updateMany.mock.calls[0][0].where).toEqual({ id: 'b', status: 'COMPLETED', review: null, reviewReminderClaimedAt: null })
expect(db.booking.update).toHaveBeenCalledWith({ where: { id: 'b' }, data: { reviewReminderSentAt: expect.any(Date) } })
})
it('does not send if another worker claimed or member already reviewed', async () => { db.booking.updateMany.mockResolvedValue({ count: 0 }); await service.run(); expect(messages.sendReviewReminder).not.toHaveBeenCalled() })
it('does not mark a failed or unknown send as delivered or automatically release it', async () => { db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockRejectedValue(new Error('timeout')); await service.run(); expect(db.booking.update).not.toHaveBeenCalled(); expect(db.booking.updateMany).toHaveBeenCalledTimes(1) })
})

View File

@@ -0,0 +1,107 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { ConfigService } from '@nestjs/config'
import { BookingStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
@Injectable()
export class ClassReminderService {
private readonly logger = new Logger(ClassReminderService.name)
constructor(
private readonly prisma: PrismaService,
private readonly messages: SubscriptionMessageService,
private readonly config: ConfigService,
) {}
/**
* Run every 5 minutes to scan for upcoming confirmed classes starting in ~1 hour (50-70 mins).
*/
@Cron('*/5 * * * *')
async run(): Promise<void> {
const templateId = this.messages.getClassReminderTemplateId()
if (!templateId) {
return
}
const now = new Date()
const nowMs = now.getTime()
// Restrict date range to around today to avoid full table scan
const yesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1))
const inTwoDays = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2))
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
const courseName = studio?.name || '普拉提课程'
const rows = await this.prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
classReminderSentAt: null,
timeSlot: {
date: {
gte: yesterday,
lte: inTwoDays,
},
},
},
include: {
user: { select: { openid: true } },
timeSlot: { select: { date: true, startTime: true, endTime: true } },
},
take: 100,
})
for (const row of rows) {
try {
const dateStr = row.timeSlot.date.toISOString().slice(0, 10)
const startTime = row.timeSlot.startTime.slice(0, 5)
const classStartMs = new Date(`${dateStr}T${startTime}:00+08:00`).getTime()
const diffMs = classStartMs - nowMs
// Target: starting in 50 to 70 minutes (around 1 hour ahead)
if (diffMs < 50 * 60 * 1000 || diffMs > 70 * 60 * 1000) {
continue
}
// Atomically claim the booking to prevent race conditions
const claimed = await this.prisma.booking.updateMany({
where: {
id: row.id,
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
},
data: {
classReminderClaimedAt: new Date(),
},
})
if (!claimed.count) {
continue
}
const classTime = `${dateStr} ${startTime}`
const sent = await this.messages.sendClassReminderMessage({
openid: row.user.openid,
userId: row.userId,
bookingId: row.id,
courseName,
classTime,
tips: '课程将于1小时后开始请准时出席',
})
if (sent) {
await this.prisma.booking.update({
where: { id: row.id },
data: { classReminderSentAt: new Date() },
})
this.logger.log(`Class reminder sent successfully for booking ${row.id}`)
}
} catch (error) {
this.logger.error(`Failed to process class reminder for booking ${row.id}`, error)
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
@Injectable()
export class ReviewReminderService {
private readonly logger = new Logger(ReviewReminderService.name)
constructor(private readonly prisma: PrismaService, private readonly messages: SubscriptionMessageService, private readonly config: ConfigService) {}
@Cron('*/5 * * * *')
async run() {
const templateId = this.config.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW')
if (!templateId) return
const rows = await this.prisma.booking.findMany({ where: { status: 'COMPLETED', review: null, reviewReminderDueAt: { lte: new Date(), gte: new Date(Date.now() - 7 * 86400000) }, reviewReminderClaimedAt: null }, include: { user: { select: { openid: true } } }, orderBy: { reviewReminderDueAt: 'asc' }, take: 100 })
for (const row of rows) {
try {
const claimed = await this.prisma.booking.updateMany({ where: { id: row.id, status: 'COMPLETED', review: null, reviewReminderClaimedAt: null }, data: { reviewReminderClaimedAt: new Date() } })
if (!claimed.count) continue
if (await this.messages.sendReviewReminder(row.userId, row.user.openid, row.id)) {
await this.prisma.booking.update({ where: { id: row.id }, data: { reviewReminderSentAt: new Date() } })
}
} catch (error) { this.logger.error(`评价提醒未发送或结果未知: ${row.id}`, error) }
}
}
}

View File

@@ -1,15 +1,18 @@
import { UserModule } from '../user/user.module'
import { ConfigModule } from '@nestjs/config'
import { ReviewReminderService } from './review-reminder.service'
import { ClassReminderService } from './class-reminder.service'
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
import { FlashSaleModule } from '../flash-sale/flash-sale.module'
import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
UserModule, ConfigModule,
TimeSlotModule,
FlashSaleModule,
],
providers: [SchedulerService],
providers: [SchedulerService, ReviewReminderService, ClassReminderService],
})
export class SchedulerModule {}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
import { FlashSaleService } from '../flash-sale/flash-sale.service'
@Injectable()
export class SchedulerService {
@@ -9,7 +8,6 @@ export class SchedulerService {
constructor(
private readonly slotGenerator: SlotGeneratorService,
private readonly flashSaleService: FlashSaleService,
) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@@ -34,6 +32,17 @@ export class SchedulerService {
}
}
/** 02:35 daily — delete past CLOSED slots with no bookings attached */
@Cron('35 2 * * *')
async handlePrunePastClosedSlots(): Promise<void> {
try {
const count = await this.slotGenerator.prunePastClosedSlots()
this.logger.log(`[handlePrunePastClosedSlots] Pruned ${count} past closed slots`)
} catch (err) {
this.logger.error('[handlePrunePastClosedSlots] Failed to prune slots', err)
}
}
/** 03:00 daily — expire memberships past their end date or with 0 sessions */
@Cron('0 3 * * *')
async handleCheckMemberships(): Promise<void> {
@@ -55,17 +64,4 @@ export class SchedulerService {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
}
}
/** Every 5 min — expire unpaid flash sale reservations older than 15min */
@Cron('*/5 * * * *')
async handleExpireFlashSaleReservations(): Promise<void> {
try {
const count = await this.flashSaleService.expireUnpaidReservations(15)
if (count > 0) {
this.logger.log(`[handleExpireFlashSaleReservations] Expired ${count} unpaid reservations`)
}
} catch (err) {
this.logger.error('[handleExpireFlashSaleReservations] Failed', err)
}
}
}

View File

@@ -54,10 +54,11 @@ export class StudioUploadService {
}
}
private buildPostPolicy(params: {
protected buildPostPolicy(params: {
bucket: string
key: string
expiresAt: number
privateRead?: boolean
}): Record<string, string> {
const secretId = this.getRequiredConfig('COS_SECRET_ID')
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
@@ -66,6 +67,7 @@ export class StudioUploadService {
expiration: new Date(params.expiresAt * 1000).toISOString(),
conditions: [
{ bucket: params.bucket },
...(params.privateRead ? [{ 'x-cos-acl': 'private' }] : []),
['eq', '$key', params.key],
{ success_action_status: '200' },
{ 'q-sign-algorithm': 'sha1' },
@@ -87,6 +89,7 @@ export class StudioUploadService {
return {
key: params.key,
...(params.privateRead ? { 'x-cos-acl': 'private' } : {}),
policy: policyBase64,
success_action_status: '200',
'q-sign-algorithm': 'sha1',
@@ -121,7 +124,7 @@ export class StudioUploadService {
return `${startTime};${expiresAt}`
}
private resolveExtension(fileName: string, contentType?: string): string {
protected resolveExtension(fileName: string, contentType?: string): string {
const cleanedName = fileName.trim().toLowerCase()
const fileExtension = cleanedName.includes('.')
? cleanedName.split('.').pop() ?? ''
@@ -158,7 +161,7 @@ export class StudioUploadService {
.replace(/^\/+|\/+$/g, '')
}
private getRequiredConfig(key: string): string {
protected getRequiredConfig(key: string): string {
const value = this.configService.get<string>(key)?.trim()
if (!value) {

View File

@@ -15,8 +15,10 @@ import {
const mockPrisma = {
timeSlot: {
findMany: jest.fn(),
createMany: jest.fn(),
updateMany: jest.fn(),
deleteMany: jest.fn(),
},
membership: {
updateMany: jest.fn(),
@@ -35,6 +37,8 @@ describe('SlotGeneratorService', () => {
beforeEach(async () => {
jest.clearAllMocks()
// Default: no dates are pre-touched by an admin. Individual tests override.
mockPrisma.timeSlot.findMany.mockResolvedValue([])
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -110,6 +114,78 @@ describe('SlotGeneratorService', () => {
})
})
it('skips dates that already have any TimeSlot row (admin-touched)', async () => {
const defaultSlots = getDefaultTimeSlots()
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
// Pre-mark day 0 and day 2 as touched (any status).
const touchedDay0 = new Date(tomorrow)
const touchedDay2 = new Date(tomorrow)
touchedDay2.setDate(touchedDay2.getDate() + 2)
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
{ date: touchedDay0 },
{ date: touchedDay2 },
])
// createMany mock reports whatever it would actually insert.
// With 1 day not touched (13 default slots), that's defaultSlots.length.
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
const count = await service.generateSlots(3)
// Day 1 should be the only day generated (3 - 2 touched = 1).
expect(count).toBe(defaultSlots.length)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ date: Date }>
}
expect(data).toHaveLength(defaultSlots.length)
// All generated dates must be UTC midnights within the requested window.
const rangeEnd = new Date(tomorrow)
rangeEnd.setDate(rangeEnd.getDate() + 2)
rangeEnd.setUTCHours(23, 59, 59, 999)
for (const row of data) {
expect(row.date.getTime()).toBeGreaterThanOrEqual(tomorrow.getTime())
expect(row.date.getTime()).toBeLessThanOrEqual(rangeEnd.getTime())
}
// The two touched dates must not appear in the generated batch.
const generatedKeys = new Set(data.map((r) => r.date.toISOString().slice(0, 10)))
expect(generatedKeys.has(touchedDay0.toISOString().slice(0, 10))).toBe(false)
expect(generatedKeys.has(touchedDay2.toISOString().slice(0, 10))).toBe(false)
})
it('returns 0 and skips createMany when every date in the window is already touched', async () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
const allDays = [0, 1, 2].map((offset) => {
const d = new Date(tomorrow)
d.setDate(d.getDate() + offset)
return { date: d }
})
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(allDays)
const count = await service.generateSlots(3)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('still uses skipDuplicates on createMany when it does run', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
}
expect(call.skipDuplicates).toBe(true)
})
// -------------------------------------------------------------------------
// cleanupExpiredSlots
// -------------------------------------------------------------------------
@@ -148,6 +224,53 @@ describe('SlotGeneratorService', () => {
})
})
// -------------------------------------------------------------------------
// prunePastClosedSlots
// -------------------------------------------------------------------------
describe('prunePastClosedSlots', () => {
it('deletes past CLOSED slots that have no bookings', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 4 })
const count = await service.prunePastClosedSlots()
expect(count).toBe(4)
expect(mockPrisma.timeSlot.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: TimeSlotStatus.CLOSED,
}),
}),
)
})
it('only deletes slots with date strictly before today', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
await service.prunePastClosedSlots()
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
where: { date: { lt: Date } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
it('keeps CLOSED slots that still have bookings attached', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
await service.prunePastClosedSlots()
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
where: { bookings: { none: Record<string, never> } }
}).where
expect(where.bookings).toEqual({ none: {} })
})
})
// -------------------------------------------------------------------------
// checkExpiredMemberships
// -------------------------------------------------------------------------
@@ -217,7 +340,7 @@ describe('SlotGeneratorService', () => {
where: expect.objectContaining({
status: BookingStatus.CONFIRMED,
}),
data: { status: BookingStatus.COMPLETED },
data: { status: BookingStatus.COMPLETED, completedAt: expect.any(Date), reviewReminderDueAt: expect.any(Date) },
}),
)
})

View File

@@ -39,6 +39,8 @@ const mockPrisma = {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
createMany: jest.fn(),
delete: jest.fn(),
},
weekTemplate: {
findMany: jest.fn(),
@@ -48,6 +50,27 @@ const mockPrisma = {
$transaction: jest.fn(),
}
/**
* Forward $transaction's callback to a tx object that shares the timeSlot
* mock implementations, so transactional calls are observable the same way
* as top-level ones.
*/
function bindTransaction() {
const mockTx = {
timeSlot: {
findMany: mockPrisma.timeSlot.findMany,
findUnique: mockPrisma.timeSlot.findUnique,
create: mockPrisma.timeSlot.create,
update: mockPrisma.timeSlot.update,
createMany: mockPrisma.timeSlot.createMany,
delete: mockPrisma.timeSlot.delete,
},
}
mockPrisma.$transaction.mockImplementation(
async (cb: (tx: typeof mockTx) => unknown) => cb(mockTx),
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -57,6 +80,7 @@ describe('TimeSlotService', () => {
beforeEach(async () => {
jest.clearAllMocks()
bindTransaction()
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -207,6 +231,69 @@ describe('TimeSlotService', () => {
})
})
// -------------------------------------------------------------------------
// getSchedulePreview
// -------------------------------------------------------------------------
describe('getSchedulePreview', () => {
const date = '2026-04-07'
it('returns OPEN/FULL rows with isPublished: true', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'slot-1', startTime: '09:00', endTime: '10:00', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'slot-2', startTime: '10:30', endTime: '11:30', status: TimeSlotStatus.FULL }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.every((s) => s.isPublished === true)).toBe(true)
})
it('HIDES CLOSED rows so the legacy client does not render them as "已发布"', async () => {
// Simulates an admin "clear day" — DB now has 13 CLOSED rows.
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(
Array.from({ length: 13 }, (_, i) =>
makeSlot({
id: `slot-${i}`,
startTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`,
endTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '30' : '30'}`,
status: TimeSlotStatus.CLOSED,
}),
),
)
const result = await service.getSchedulePreview(date)
// Critical for online compatibility: the legacy client must see []
// so it shows "当日暂无排课", not 13 phantom "已发布" slots.
expect(result).toEqual([])
})
it('returns the OPEN subset and hides CLOSED when mixed', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'open-1', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'closed-1', status: TimeSlotStatus.CLOSED, startTime: '10:00', endTime: '11:00' }),
makeSlot({ id: 'open-2', status: TimeSlotStatus.OPEN, startTime: '10:30', endTime: '11:30' }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.map((s) => s.id).sort()).toEqual(['open-1', 'open-2'])
expect(result.find((s) => s.id === 'closed-1')).toBeUndefined()
})
it('returns the ghost template when the day has no rows at all', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
const result = await service.getSchedulePreview(date)
// Ghost = isPublished false, id null.
expect(result.length).toBeGreaterThan(0)
expect(result.every((s) => s.isPublished === false && s.id === null)).toBe(true)
})
})
// -------------------------------------------------------------------------
// createManualSlot
// -------------------------------------------------------------------------
@@ -270,4 +357,173 @@ describe('TimeSlotService', () => {
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
})
})
// -------------------------------------------------------------------------
// publishDaySlots — admin schedule management
// -------------------------------------------------------------------------
describe('publishDaySlots', () => {
const date = '2026-04-07'
it('materializes the default template when the day has no rows yet', async () => {
// First findMany (existing check) → empty; second findMany (re-read) → materialized rows.
const materialized = [
makeSlot({ id: 'slot-A', startTime: '08:00', endTime: '09:00', source: TimeSlotSource.TEMPLATE }),
makeSlot({ id: 'slot-B', startTime: '09:30', endTime: '10:30', source: TimeSlotSource.TEMPLATE }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([]) // initial check
.mockResolvedValueOnce(materialized) // after materialize
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(materialized) // final state
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const createCall = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ source: string; status: string; date: Date }>
skipDuplicates: boolean
}
expect(createCall.skipDuplicates).toBe(true)
for (const row of createCall.data) {
expect(row.source).toBe(TimeSlotSource.TEMPLATE)
expect(row.status).toBe(TimeSlotStatus.OPEN)
}
})
it('CLOSEs (never deletes) orphaned rows when admin publishes empty list', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 0 }),
makeSlot({ id: 'slot-2', bookedCount: 0, startTime: '09:30', endTime: '10:30' }),
]
// Initial check + final-state read both return the (now-CLOSED) rows.
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValue({})
mockPrisma.timeSlot.delete = jest.fn() // safety: ensure delete is never used here
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
// Each orphan is updated to CLOSED (no bookedCount discrimination).
const updateCalls = mockPrisma.timeSlot.update.mock.calls
expect(updateCalls.length).toBe(2)
for (const call of updateCalls) {
const data = (call[0] as { data: { status: string } }).data
expect(data.status).toBe(TimeSlotStatus.CLOSED)
}
})
it('CLOSEs (never deletes) orphaned rows even when they have bookings', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 3 }), // has bookings
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValue({})
mockPrisma.timeSlot.delete = jest.fn()
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
const updateCall = mockPrisma.timeSlot.update.mock.calls[0]
const data = (updateCall[0] as { data: { status: string } }).data
expect(data.status).toBe(TimeSlotStatus.CLOSED)
})
it('updates existing slot referenced by existingSlotId and keeps its capacity >= bookedCount', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 5 }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
const updated = makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 4 })
mockPrisma.timeSlot.update.mockResolvedValueOnce(updated)
await service.publishDaySlots({
date,
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 2 }],
})
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
where: { id: string }
data: { capacity: number; status?: string }
})
expect(updateData.where.id).toBe('slot-1')
// capacity clamped up to bookedCount (3), not the requested 2.
expect(updateData.data.capacity).toBe(3)
// status is NOT touched on update — existing CLOSED rows stay CLOSED
// so that re-publishing the day cannot silently undo "rest day" intent.
expect(updateData.data.status).toBeUndefined()
})
it('preserves CLOSED status when admin re-publishes the day unchanged', async () => {
// Scenario: admin cleared the day, then reloaded the page. The page
// re-sends all CLOSED rows via existingSlotId. They must remain CLOSED.
const existing = [
makeSlot({ id: 'slot-1', status: TimeSlotStatus.CLOSED, bookedCount: 0 }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValueOnce(existing[0])
await service.publishDaySlots({
date,
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 1 }],
})
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
data: { status?: string }
})
expect(updateData.data.status).toBeUndefined()
})
it('creates new slots for entries without an existingSlotId', async () => {
const existing: typeof makeSlot extends (...a: any) => infer R ? R : never = [] as never
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
mockPrisma.timeSlot.create.mockResolvedValueOnce(
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
)
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
])
await service.publishDaySlots({
date,
slots: [{ startTime: '14:00', endTime: '15:00', capacity: 4 }],
})
expect(mockPrisma.timeSlot.create).toHaveBeenCalledTimes(1)
const createData = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
data: { source: string; status: string; capacity: number }
}).data
expect(createData.source).toBe(TimeSlotSource.MANUAL)
expect(createData.status).toBe(TimeSlotStatus.OPEN)
expect(createData.capacity).toBe(4)
})
it('returns the final state of the day ordered by startTime', async () => {
const finalState = [
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
makeSlot({ id: 'b', startTime: '10:30', endTime: '11:30' }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([]) // initial check
.mockResolvedValueOnce([]) // after materialize
.mockResolvedValueOnce(finalState) // final state
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
const result = await service.publishDaySlots({ date, slots: [] })
expect(result).toHaveLength(2)
expect(result[0].startTime).toBe('09:00')
expect(result[1].startTime).toBe('10:30')
})
})
})

View File

@@ -17,6 +17,18 @@ function toUtcMidnight(date: Date): Date {
return d
}
/** Add `days` whole days to a UTC date, returning a new Date. */
function addDays(date: Date, days: number): Date {
const d = new Date(date)
d.setUTCDate(d.getUTCDate() + days)
return d
}
/** Normalise a Date (UTC midnight) to its `YYYY-MM-DD` key for set membership. */
function toDateKey(date: Date): string {
return date.toISOString().slice(0, 10)
}
@Injectable()
export class SlotGeneratorService {
private readonly logger = new Logger(SlotGeneratorService.name)
@@ -26,7 +38,13 @@ export class SlotGeneratorService {
/**
* Generate time slots for the next `daysAhead` days based on the fixed
* default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly).
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
*
* Behaviour:
* - Days that already have any TimeSlot row (any status, including CLOSED)
* are treated as "admin-touched" and skipped, so that an explicit "rest
* day" intent cannot be silently overwritten by the nightly cron.
* - The final `createMany` call still uses `skipDuplicates` so that
* re-runs against partially-existing dates remain safe.
*
* @returns Number of newly created slots
*/
@@ -37,6 +55,18 @@ export class SlotGeneratorService {
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
const rangeStart = toUtcMidnight(tomorrow)
const rangeEnd = toUtcMidnight(addDays(tomorrow, daysAhead - 1))
// Pre-fetch dates inside the window that already have at least one row.
// Any status (OPEN / FULL / CLOSED) counts as "admin touched this day".
const touchedRows = await this.prisma.timeSlot.findMany({
where: { date: { gte: rangeStart, lte: rangeEnd } },
select: { date: true },
distinct: ['date'],
})
const touchedKeys = new Set(touchedRows.map((r) => toDateKey(r.date)))
const slotsToCreate: Array<{
date: Date
startTime: string
@@ -46,12 +76,15 @@ export class SlotGeneratorService {
}> = []
for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow)
target.setDate(target.getDate() + offset)
const target = toUtcMidnight(addDays(tomorrow, offset))
if (touchedKeys.has(toDateKey(target))) {
continue
}
for (const slot of defaultSlots) {
slotsToCreate.push({
date: toUtcMidnight(target),
date: target,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
@@ -61,6 +94,9 @@ export class SlotGeneratorService {
}
if (slotsToCreate.length === 0) {
this.logger.log(
`Skipped ${touchedKeys.size} admin-touched date(s); nothing to generate`,
)
return 0
}
@@ -69,7 +105,9 @@ export class SlotGeneratorService {
skipDuplicates: true,
})
this.logger.log(`Generated ${result.count} new time slots`)
this.logger.log(
`Generated ${result.count} new time slots (skipped ${touchedKeys.size} admin-touched date(s))`,
)
return result.count
}
@@ -94,6 +132,34 @@ export class SlotGeneratorService {
return result.count
}
/**
* Delete past TimeSlot rows that are CLOSED and have no bookings attached.
*
* Without this, every admin "clear" leaves 13 CLOSED/TEMPLATE rows in the
* DB forever (no member ever queries past dates, but they pile up).
* Rows with `bookedCount > 0` or any Booking record are kept so we never
* break the Booking.timeSlotId foreign key.
*
* Runs nightly alongside cleanupExpiredSlots.
*
* @returns Number of slots deleted
*/
async prunePastClosedSlots(): Promise<number> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const result = await this.prisma.timeSlot.deleteMany({
where: {
status: TimeSlotStatus.CLOSED,
date: { lt: today },
bookings: { none: {} },
},
})
this.logger.log(`Pruned ${result.count} past closed time slots`)
return result.count
}
/**
* Expire memberships whose end date has passed or whose remaining sessions
* have been exhausted.
@@ -144,7 +210,7 @@ export class SlotGeneratorService {
date: { lt: today },
},
},
data: { status: BookingStatus.COMPLETED },
data: { status: BookingStatus.COMPLETED, completedAt: new Date(), reviewReminderDueAt: new Date(Date.now() + 24 * 3600000) },
})
this.logger.log(`Completed ${result.count} past bookings`)

View File

@@ -148,13 +148,26 @@ export class TimeSlotService {
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from the fixed default schedule (isPublished: false).
*
* Visibility rules (driven by the online mini-program's existing client):
* - OPEN/FULL slots are returned with `isPublished: true`.
* - CLOSED slots are HIDDEN from preview entirely. The pre-fix admin flow
* used to DELETE these rows on "clear day", but a non-deleting flow
* leaves CLOSED rows behind — the existing mini-program UI does not
* understand the CLOSED status and would render them as "已发布",
* making it look like the clear did not take effect. Hiding them
* preserves the legacy "empty list = rest day" UX until the client
* is republished with CLOSED-aware rendering.
* - When the day is entirely CLOSED (a "rest day"), return an empty
* array rather than the default ghost template, so the client shows
* "当日暂无排课" instead of inviting another publish-loop cycle.
* - When the day has no rows at all (never generated), return the
* default template as ghost previews (`isPublished: false`).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
// 1. Check for existing TimeSlot records (all statuses)
// 1. Check for existing TimeSlot records (all statuses).
const existingSlots = await this.prisma.timeSlot.findMany({
where: {
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
@@ -162,8 +175,14 @@ export class TimeSlotService {
orderBy: { startTime: 'asc' },
})
if (existingSlots.length > 0) {
return existingSlots.map((slot) => ({
// 2. Filter out CLOSED — they are admin-closed (typically "rest day")
// and must not surface to the legacy mini-program client.
const openSlots = existingSlots.filter(
(slot) => slot.status !== TimeSlotStatus.CLOSED,
)
if (openSlots.length > 0) {
return openSlots.map((slot) => ({
id: slot.id,
date: date,
startTime: slot.startTime,
@@ -177,6 +196,12 @@ export class TimeSlotService {
}))
}
// 3. All rows for the day are CLOSED → admin declared a rest day.
// Return [] so the client shows the "no slots" empty state.
if (existingSlots.length > 0) {
return []
}
// 2. No existing slots — use fixed default schedule
const defaultSlots = getDefaultTimeSlots()
@@ -195,19 +220,61 @@ export class TimeSlotService {
}
/**
* Publish (create/update/remove) time slots for a specific date.
* - Slots with existingSlotId → update
* - New slots → create
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
* Publish (create/update/close) time slots for a specific date.
*
* Behavior:
* - If the day has no TimeSlot rows yet (e.g. admin opened a future date
* that the cron has not generated), materialize the default template
* first so that any subsequent close intent persists.
* - Slots referenced via existingSlotId → updated in place; existing
* status (OPEN / CLOSED) is preserved. Reopening a CLOSED slot is
* intentionally NOT possible through this endpoint — the front-end
* re-sends unchanged rows on every publish, so an explicit reopen
* would silently undo "rest day" intent. Use the dedicated reopen
* endpoint (TODO) or delete + re-add via the UI instead.
* - Slots with no existingSlotId → created as MANUAL/OPEN.
* - Existing rows that the request drops are set to CLOSED rather than
* deleted, so that the admin's intent ("this is a rest day") survives
* the nightly slot-generation cron (skipDuplicates / touched-date skip).
* - Materialize + orphan-close interaction: when the day has no rows yet,
* every default template slot is written first as OPEN/TEMPLATE, then
* any slot not referenced by the request is closed. So if an admin
* edits a ghost preview and publishes only a subset, the unused default
* slots end up CLOSED/TEMPLATE rather than OPEN/TEMPLATE.
*
* @returns Final TimeSlot rows for the day, ordered by startTime.
*/
async publishDaySlots(dto: PublishDaySlotsDto) {
const parsedDate = new Date(dto.date + 'T00:00:00Z')
return this.prisma.$transaction(async (tx) => {
// 1. Get existing slots for this date
const existing = await tx.timeSlot.findMany({
where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
})
const dayRange = {
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
}
// 1. Look up the existing rows for the day.
let existing = await tx.timeSlot.findMany({ where: dayRange })
// 2. Materialize the default template if the admin is touching a day
// that has never been generated. This records their intent (the row
// now exists), and any empty publish will leave the day in a
// CLOSED-everything state that the nightly cron will not undo.
if (existing.length === 0) {
const defaultSlots = getDefaultTimeSlots()
await tx.timeSlot.createMany({
data: defaultSlots.map((slot) => ({
date: this.toDateOfDay(parsedDate),
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE,
status: TimeSlotStatus.OPEN,
})),
skipDuplicates: true,
})
existing = await tx.timeSlot.findMany({ where: dayRange })
}
const existingMap = new Map(existing.map((s) => [s.id, s]))
const keptIds = new Set<string>()
@@ -222,10 +289,10 @@ export class TimeSlotService {
source: string
}> = []
// 2. Process each slot in the request
// 3. Process each slot in the request.
for (const item of dto.slots) {
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
// Update existing slot
// Update existing slot. Never reduce capacity below bookedCount.
const existingSlot = existingMap.get(item.existingSlotId)!
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
@@ -235,12 +302,15 @@ export class TimeSlotService {
startTime: item.startTime,
endTime: item.endTime,
capacity: safeCapacity,
// Existing status is preserved: CLOSED rows stay CLOSED, so an
// admin's "rest day" intent cannot be undone by a no-op
// re-publish. Reopening must go through a dedicated endpoint.
},
})
keptIds.add(item.existingSlotId)
results.push(updated)
} else {
// Create new slot
// Create a new slot.
const created = await tx.timeSlot.create({
data: {
date: parsedDate,
@@ -255,22 +325,27 @@ export class TimeSlotService {
}
}
// 3. Handle orphaned existing slots (not in request)
// 4. Close orphaned existing rows. We never delete — keeping the row
// preserves the admin's "rest day" intent across cron runs.
for (const slot of existing) {
if (!keptIds.has(slot.id)) {
if (slot.bookedCount > 0) {
// Has bookings → close instead of delete
if (slot.status !== TimeSlotStatus.CLOSED) {
await tx.timeSlot.update({
where: { id: slot.id },
data: { status: TimeSlotStatus.CLOSED },
})
} else {
await tx.timeSlot.delete({ where: { id: slot.id } })
}
}
}
return results.map((slot) => ({
// 5. Return the final state of the day so the admin UI can refresh
// without a second round-trip.
const finalState = await tx.timeSlot.findMany({
where: dayRange,
orderBy: { startTime: 'asc' },
})
return finalState.map((slot) => ({
id: slot.id,
date: slot.date.toISOString().split('T')[0],
startTime: slot.startTime,

View File

@@ -0,0 +1,102 @@
import { MemberProgressService } from '../member-progress.service'
import { PrismaService } from '../../prisma/prisma.service'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
import { BodyMetricDto } from '../dto/member-progress.dto'
import { validate } from 'class-validator'
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
describe('Member progress permissions and measurements', () => {
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
let service: MemberProgressService
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
it('filters private notes on the server, strips photo keys and counts only valid supplements', async () => {
const result = await service.archive('member')
expect(db.memberNote.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', shared: true })
expect(db.progressPhoto.findMany.mock.calls[0][0].select.objectKey).toBeUndefined()
expect(db.lessonSupplement.aggregate.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
expect(result.completedCount).toBe(11); expect(result.milestones).toEqual([10])
await service.archive('member', true)
expect(db.memberNote.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
})
it('rejects missing metrics, future and invalid dates; preserves negative flexibility', async () => {
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
await expect(service.addMetric('member', 'admin', { recordedAt: '2099-01-01', weight: 50 })).rejects.toThrow('日期')
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
})
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
})
it('only annotates the target member completed booking', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
expect(db.booking.findFirst.mock.calls[0][0].where).toEqual({ id: 'foreign', userId: 'member', status: 'COMPLETED' })
expect(db.memberNote.create).not.toHaveBeenCalled()
})
it('never signs another member photo', async () => {
db.progressPhoto.findFirst.mockResolvedValue(null)
await expect(service.photoUrl('other', 'photo')).rejects.toThrow('照片不存在')
expect(storage.signedUrl).not.toHaveBeenCalled()
})
it('lets a member preview for informed consent but blocks admin until authorized', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'private/photo', consentedAt: null })
storage.signedUrl.mockReturnValue('short-lived')
await expect(service.photoUrl('member', 'photo', true)).rejects.toThrow('尚未授权')
await expect(service.photoUrl('member', 'photo')).resolves.toEqual({ url: 'short-lived', expiresIn: 60 })
})
it('consent and revocation are scoped to current member and uploaded photo', async () => {
db.progressPhoto.updateMany.mockResolvedValue({ count: 1 })
await service.consent('member', 'photo', false)
expect(db.progressPhoto.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'photo', userId: 'member', uploadedAt: { not: null } }, data: { consentedAt: null, revokedAt: expect.any(Date) } })
})
it('requires confirmed storage upload before exposing a photo record', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'key' })
storage.verify.mockRejectedValue(new Error('not uploaded'))
await expect(service.finishUpload('member', 'photo')).rejects.toThrow('not uploaded')
expect(db.progressPhoto.update).not.toHaveBeenCalled()
})
it('deletes a photo from private storage and the database, scoped to the member', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
await service.remove('member', 'photos', 'photo')
expect(storage.removeObject).toHaveBeenCalledWith('progress/member/a.jpg')
expect(db.progressPhoto.delete).toHaveBeenCalledWith({ where: { id: 'photo' } })
})
it('does not delete the database row if storage deletion fails', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
storage.removeObject.mockRejectedValue(new Error('cos down'))
await expect(service.remove('member', 'photos', 'photo')).rejects.toThrow('cos down')
expect(db.progressPhoto.delete).not.toHaveBeenCalled()
})
it('does not delete another member metric, note or photo', async () => {
db.bodyMetric.deleteMany.mockResolvedValue({ count: 0 })
db.memberNote.deleteMany.mockResolvedValue({ count: 0 })
db.progressPhoto.findFirst.mockResolvedValue(null)
await expect(service.remove('other', 'metrics', 'm')).rejects.toThrow('记录不存在')
await expect(service.remove('other', 'notes', 'n')).rejects.toThrow('记录不存在')
await expect(service.remove('other', 'photos', 'p')).rejects.toThrow('照片不存在')
expect(storage.removeObject).not.toHaveBeenCalled()
})
})
describe('Member progress authorization', () => {
it('requires authentication on member and admin progress controllers', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(JwtAuthGuard)
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(RolesGuard)
})
it('restricts admin progress reads to admins', () => {
const handler = AdminMemberProgressController.prototype.archive
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -0,0 +1,59 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
describe('Progress photo private storage', () => {
const config = { get: jest.fn() }
let service: ProgressPhotoStorageService
const originalFetch = global.fetch
const defaults = {
COS_BUCKET: 'plates-1251306435',
COS_REGION: 'ap-guangzhou',
COS_SECRET_ID: 'id',
COS_SECRET_KEY: 'key',
}
const values: Record<string, string> = { ...defaults }
beforeEach(() => {
jest.resetAllMocks()
Object.assign(values, defaults)
config.get.mockImplementation((key: string) => values[key] || '')
service = new ProgressPhotoStorageService(config as unknown as ConfigService)
})
afterEach(() => { global.fetch = originalFetch })
it('signs a private object under the member prefix in the shared studio bucket', () => {
const credential = service.credential('member-1', 'pose.PNG')
expect(credential.key).toMatch(/^progress\/member-1\/[0-9a-f-]+\.png$/)
expect(credential.uploadUrl).toBe('https://plates-1251306435.cos.ap-guangzhou.myqcloud.com')
expect(credential.formData['x-cos-acl']).toBe('private')
expect(credential.formData.key).toBe(credential.key)
})
it('treats missing or oversized objects and non-images as client errors after HEAD succeeds', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '0' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(BadRequestException)
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': String(11 * 1024 * 1024), 'content-type': 'image/jpeg' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('图片大小无效')
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '123', 'content-type': 'application/pdf' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('仅支持图片文件')
})
it('allows verify when COS omits content-length for an image object', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }) }) as unknown as typeof fetch
await expect(service.verify('progress/member/a.jpg')).resolves.toBeUndefined()
})
it('treats a missing COS object as retryable and ignores 404 on delete', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() })
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(ServiceUnavailableException)
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 })
await expect(service.removeObject('progress/member/a.jpg')).resolves.toBeUndefined()
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 })
await expect(service.removeObject('progress/member/a.jpg')).rejects.toThrow('照片删除失败')
})
})

View File

@@ -0,0 +1,79 @@
import {
buildClassReviewSubscribeData,
buildBookingCancelledSubscribeData,
buildClassReminderSubscribeData,
} from '../subscription-message.service'
describe('Class review subscribe payload', () => {
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
expect(buildClassReviewSubscribeData({
studioName: 'Focus Core 普拉提工作室',
date: new Date('2026-09-09T00:00:00.000Z'),
startTime: '10:00:00',
})).toEqual({
thing1: { value: 'Focus Core 普拉提工作室' },
thing2: { value: 'Iris' },
time3: { value: '2026年09月09日 10:00' },
thing4: { value: '欢迎留下这节课的感受' },
})
})
it('falls back to a short course name and trims thing fields to 20 characters', () => {
const data = buildClassReviewSubscribeData({
studioName: '超长工作室名称用来测试微信订阅消息字段截断是否生效',
date: new Date('2026-01-02T00:00:00.000Z'),
startTime: '09:30',
})
expect(data.thing1.value).toHaveLength(20)
expect(data.thing1.value).toBe('超长工作室名称用来测试微信订阅消息字段截断是否生效'.slice(0, 20))
expect(data.time3.value).toBe('2026年01月02日 09:30')
})
})
describe('Booking cancelled subscribe payload', () => {
it('formats date11, date1 and thing2 correctly', () => {
const data = buildBookingCancelledSubscribeData({
bookingDate: '2026-09-10',
courseTime: '10:00-11:00',
courseName: '普拉提一对一私教体验课',
})
expect(data).toEqual({
date11: { value: '2026-09-10' },
date1: { value: '10:00-11:00' },
thing2: { value: '普拉提一对一私教体验课' },
})
})
it('trims courseName to 20 characters', () => {
const data = buildBookingCancelledSubscribeData({
bookingDate: '2026-09-10',
courseTime: '10:00-11:00',
courseName: '这是一个超过二十个汉字的普拉提非常长非常长非常长的课程名称',
})
expect(data.thing2.value).toHaveLength(20)
})
})
describe('Class reminder subscribe payload', () => {
it('formats thing1, time2 and thing5 correctly', () => {
const data = buildClassReminderSubscribeData({
courseName: '普拉提核心床小班课',
classTime: '2026-09-10 15:00',
tips: '课程将于1小时后开始请准时出席',
})
expect(data).toEqual({
thing1: { value: '普拉提核心床小班课' },
time2: { value: '2026-09-10 15:00' },
thing5: { value: '课程将于1小时后开始请准时出席' },
})
})
it('falls back to default tips and trims fields to 20 characters', () => {
const data = buildClassReminderSubscribeData({
courseName: '超过二十个字的超长课程名称请务必进行截断处理测试',
classTime: '14:00',
})
expect(data.thing1.value).toHaveLength(20)
expect(data.thing5.value).toBe('课程即将于1小时后开始请准时出席'.slice(0, 20))
})
})

View File

@@ -83,6 +83,8 @@ const mockPrisma = {
const mockConfigService = {
get: jest.fn((key: string, defaultValue = '') => {
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED') return '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM'
if (key === 'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER') return 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0'
return defaultValue
}),
}
@@ -143,7 +145,19 @@ describe('UserService', () => {
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
description: '预约成功后推送确认通知',
usageTarget: 'consent',
},
{
templateId: '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
description: '约课取消后推送取消通知',
usageTarget: 'consent',
},
{
templateId: 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
scene: SubscriptionMessageScene.CLASS_REMINDER,
description: '开课前 1 小时推送上课提醒',
usageTarget: 'consent',
},
{
@@ -282,7 +296,7 @@ describe('UserService', () => {
expect(result.avatarUrl).toBe('https://example.com/new.png')
expect(result.activeMembershipCount).toBe(1)
expect(result.adminBookingSubscriptionCount).toBe(0)
expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
expect(result.subscriptionMessageTemplates.templates).toHaveLength(4)
})
it('increments admin booking subscription count for admin users', async () => {
@@ -742,4 +756,31 @@ describe('UserService', () => {
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
})
})
describe('getUserSubscriptionQuotas', () => {
it('returns calculated quotas for consent templates', async () => {
mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
acceptCount: 5,
sentCount: 2,
lastResult: 'accept',
},
])
const res = await service.getUserSubscriptionQuotas('user-1')
expect(res.quotas).toHaveLength(3) // BOOKING_CREATED, BOOKING_CANCELLED, CLASS_REMINDER
const bookingQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CREATED)
expect(bookingQuota).toBeDefined()
expect(bookingQuota?.remainingQuota).toBe(3)
expect(bookingQuota?.acceptCount).toBe(5)
expect(bookingQuota?.sentCount).toBe(2)
expect(bookingQuota?.lastResult).toBe('accept')
const cancelledQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CANCELLED)
expect(cancelledQuota).toBeDefined()
expect(cancelledQuota?.remainingQuota).toBe(0)
})
})
})

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {
@IsString() @MinLength(1) @MaxLength(1000) content!: string
@IsBoolean() shared!: boolean
@IsOptional() @IsString() bookingId?: string
}
export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsString() @MaxLength(200) caption!: string
@IsString() @MaxLength(100) fileName!: string
}
export class PhotoConsentDto { @IsBoolean() consent!: boolean }

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Delete, 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 { MemberProgressService } from './member-progress.service'
import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
@Controller('user/progress') @UseGuards(JwtAuthGuard)
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
}
@Controller('admin/members/:userId/progress') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN)
export class AdminMemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }
@Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) }
@Get('photos/:id/url') url(@Param('userId') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id, true) }
}

View File

@@ -0,0 +1,87 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
@Injectable()
export class MemberProgressService {
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
private async member(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { nickname: true } })
if (!user) throw new NotFoundException('学员不存在')
return user
}
private date(value: string) {
const date = new Date(value + 'T00:00:00.000Z')
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value || value > new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)) throw new BadRequestException('请选择有效的记录日期,不能晚于今天')
return date
}
async archive(userId: string, admin = false) {
const user = await this.member(userId)
const [count, supplements, metrics, notes, photos] = await Promise.all([
this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }),
this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true } }),
this.prisma.bodyMetric.findMany({ where: { userId }, orderBy: [{ recordedAt: 'desc' }, { createdAt: 'desc' }] }),
this.prisma.memberNote.findMany({ where: { userId, ...(admin ? {} : { shared: true }) }, orderBy: { createdAt: 'desc' }, include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } } }),
this.prisma.progressPhoto.findMany({ where: { userId, uploadedAt: { not: null } }, orderBy: { recordedAt: 'desc' }, select: { id: true, caption: true, recordedAt: true, consentedAt: true, revokedAt: true } }),
])
const completedCount = count + (supplements._sum.quantity || 0)
return { nickname: user.nickname, completedCount, milestones: [10, 30, 50].filter(n => completedCount >= n), metrics, notes, photos }
}
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
await this.member(userId)
if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
}
async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') {
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })
if (!result.count) throw new NotFoundException('记录不存在')
return { deleted: true }
}
if (kind === 'notes') {
const result = await this.prisma.memberNote.deleteMany({ where: { id, userId } })
if (!result.count) throw new NotFoundException('记录不存在')
return { deleted: true }
}
if (kind === 'photos') {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
if (!photo) throw new NotFoundException('照片不存在')
await this.storage.removeObject(photo.objectKey)
await this.prisma.progressPhoto.delete({ where: { id } })
return { deleted: true }
}
throw new BadRequestException('记录类型无效')
}
async upload(userId: string, operatorId: string, dto: ProgressPhotoDto) {
await this.member(userId)
const recordedAt = this.date(dto.recordedAt)
const credential = this.storage.credential(userId, dto.fileName)
const photo = await this.prisma.progressPhoto.create({ data: { userId, operatorId, objectKey: credential.key, recordedAt, caption: dto.caption } })
return { ...credential, id: photo.id }
}
async finishUpload(userId: string, id: string) {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
if (!photo) throw new NotFoundException('照片不存在')
await this.storage.verify(photo.objectKey)
await this.prisma.progressPhoto.update({ where: { id }, data: { uploadedAt: new Date() } })
return { uploaded: true }
}
async consent(userId: string, id: string, consent: boolean) {
const result = await this.prisma.progressPhoto.updateMany({ where: { id, userId, uploadedAt: { not: null } }, data: { consentedAt: consent ? new Date() : null, revokedAt: consent ? null : new Date() } })
if (!result.count) throw new NotFoundException('照片不存在')
return { consent }
}
async photoUrl(userId: string, id: string, admin = false) {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId, uploadedAt: { not: null } } })
if (!photo) throw new NotFoundException('照片不存在')
// Members can privately preview their own photo to make an informed consent decision.
if (admin && !photo.consentedAt) throw new ForbiddenException('学员尚未授权展示这张照片')
return { url: this.storage.signedUrl(photo.objectKey), expiresIn: 60 }
}
}

View File

@@ -0,0 +1,45 @@
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createHash, createHmac, randomUUID } from 'crypto'
import { StudioUploadService } from '../studio/studio-upload.service'
@Injectable()
export class ProgressPhotoStorageService extends StudioUploadService {
constructor(config: ConfigService) { super(config) }
private endpoint() {
const bucket = this.getRequiredConfig('COS_BUCKET')
const region = this.getRequiredConfig('COS_REGION')
return { bucket, host: `${bucket}.cos.${region}.myqcloud.com` }
}
credential(userId: string, fileName: string) {
const { bucket, host } = this.endpoint()
const extension = this.resolveExtension(fileName)
const key = `progress/${userId}/${randomUUID()}.${extension}`
const expiresAt = Math.floor(Date.now() / 1000) + 300
return { key, uploadUrl: `https://${host}`, expiresAt, formData: this.buildPostPolicy({ bucket, key, expiresAt, privateRead: true }) }
}
signedUrl(key: string, method = 'get') {
const { host } = this.endpoint()
const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + 60}`
const signKey = createHmac('sha1', this.getRequiredConfig('COS_SECRET_KEY')).update(keyTime).digest('hex')
const http = `${method}\n/${key}\n\nhost=${encodeURIComponent(host)}\n`
const toSign = `sha1\n${keyTime}\n${createHash('sha1').update(http).digest('hex')}\n`
const signature = createHmac('sha1', signKey).update(toSign).digest('hex')
const query = new URLSearchParams({ 'q-sign-algorithm': 'sha1', 'q-ak': this.getRequiredConfig('COS_SECRET_ID'), 'q-sign-time': keyTime, 'q-key-time': keyTime, 'q-header-list': 'host', 'q-url-param-list': '', 'q-signature': signature })
return `https://${host}/${key}?${query}`
}
async verify(key: string) {
const response = await fetch(this.signedUrl(key, 'head'), { method: 'HEAD', signal: AbortSignal.timeout(10000) })
if (!response.ok) throw new ServiceUnavailableException('图片尚未上传成功,请重试')
const rawSize = response.headers.get('content-length')
if (rawSize != null && rawSize !== '') {
const size = Number(rawSize)
if (!Number.isFinite(size) || size <= 0 || size > 10 * 1024 * 1024) throw new BadRequestException('图片大小无效')
}
const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
if (type && !type.startsWith('image/')) throw new BadRequestException('仅支持图片文件')
}
async removeObject(key: string) {
const response = await fetch(this.signedUrl(key, 'delete'), { method: 'DELETE', signal: AbortSignal.timeout(10000) })
if (!response.ok && response.status !== 404) throw new ServiceUnavailableException('照片删除失败,请重试')
}
}

View File

@@ -16,6 +16,24 @@ interface BookingConfirmedTemplatePayload {
readonly bookingEndTime: string
}
export interface BookingCancelledTemplatePayload {
readonly openid: string
readonly userId?: string
readonly bookingId: string
readonly bookingDate: string
readonly courseTime: string
readonly courseName: string
}
export interface ClassReminderTemplatePayload {
readonly openid: string
readonly userId?: string
readonly bookingId: string
readonly courseName: string
readonly classTime: string
readonly tips?: string
}
interface AdminBookingCreatedTemplatePayload {
readonly openid: string
readonly bookingId: string
@@ -45,6 +63,33 @@ function stringifyDebugPayload(payload: unknown): string {
}
}
export function buildClassReviewSubscribeData(input: { studioName: string | null; date: Date; startTime: string }) {
const calendar = input.date.toISOString().slice(0, 10)
const [year, month, day] = calendar.split('-')
return {
thing1: { value: (input.studioName || '普拉提私教').slice(0, 20) },
thing2: { value: 'Iris' },
time3: { value: `${year}${month}${day}${input.startTime.slice(0, 5)}` },
thing4: { value: '欢迎留下这节课的感受' },
}
}
export function buildBookingCancelledSubscribeData(input: { bookingDate: string; courseTime: string; courseName: string }) {
return {
date11: { value: input.bookingDate },
date1: { value: input.courseTime },
thing2: { value: (input.courseName || '普拉提私教').slice(0, 20) },
}
}
export function buildClassReminderSubscribeData(input: { courseName: string; classTime: string; tips?: string }) {
return {
thing1: { value: (input.courseName || '普拉提私教').slice(0, 20) },
time2: { value: input.classTime },
thing5: { value: (input.tips || '课程即将于1小时后开始请准时出席').slice(0, 20) },
}
}
@Injectable()
export class SubscriptionMessageService {
private readonly logger = new Logger(SubscriptionMessageService.name)
@@ -59,10 +104,154 @@ export class SubscriptionMessageService {
return this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
}
getBookingCancelledTemplateId(): string {
return this.configService.get<string>(
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
)
}
getClassReminderTemplateId(): string {
return this.configService.get<string>(
'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
)
}
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
return this.sendConsentBasedBookingMessage(payload)
}
async sendBookingCancelledMessage(payload: BookingCancelledTemplatePayload): Promise<boolean> {
const templateId = this.getBookingCancelledTemplateId()
if (!templateId) {
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED is not configured, skip sending cancelled message')
return false
}
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
where: {
user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
templateId,
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
acceptCount: { gt: 0 },
},
orderBy: [
{ lastRequestedAt: 'desc' },
{ updatedAt: 'desc' },
],
})
if (!consent) {
this.logger.warn(`No subscription quota found for booking cancelled: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
return false
}
if (consent.sentCount >= consent.acceptCount) {
this.logger.warn(`Subscription quota exhausted for booking cancelled: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
return false
}
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
where: {
id: consent.id,
sentCount: consent.sentCount,
acceptCount: { gt: consent.sentCount },
},
data: {
sentCount: { increment: 1 },
lastSentAt: new Date(),
},
})
if (!claimed.count) {
return false
}
const data = buildBookingCancelledSubscribeData({
bookingDate: payload.bookingDate,
courseTime: payload.courseTime,
courseName: payload.courseName,
})
return this.postWechatSubscribeSend({
openid: payload.openid,
templateId,
page: `pages/booking/detail?id=${payload.bookingId}`,
data,
logContext: {
target: 'member',
bookingId: payload.bookingId,
consentId: consent.id,
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
},
})
}
async sendClassReminderMessage(payload: ClassReminderTemplatePayload): Promise<boolean> {
const templateId = this.getClassReminderTemplateId()
if (!templateId) {
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER is not configured, skip sending reminder message')
return false
}
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
where: {
user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
templateId,
scene: SubscriptionMessageScene.CLASS_REMINDER,
acceptCount: { gt: 0 },
},
orderBy: [
{ lastRequestedAt: 'desc' },
{ updatedAt: 'desc' },
],
})
if (!consent) {
this.logger.warn(`No subscription quota found for class reminder: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
return false
}
if (consent.sentCount >= consent.acceptCount) {
this.logger.warn(`Subscription quota exhausted for class reminder: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
return false
}
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
where: {
id: consent.id,
sentCount: consent.sentCount,
acceptCount: { gt: consent.sentCount },
},
data: {
sentCount: { increment: 1 },
lastSentAt: new Date(),
},
})
if (!claimed.count) {
return false
}
const data = buildClassReminderSubscribeData({
courseName: payload.courseName,
classTime: payload.classTime,
tips: payload.tips,
})
return this.postWechatSubscribeSend({
openid: payload.openid,
templateId,
page: `pages/booking/detail?id=${payload.bookingId}`,
data,
logContext: {
target: 'member',
bookingId: payload.bookingId,
consentId: consent.id,
scene: SubscriptionMessageScene.CLASS_REMINDER,
},
})
}
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise<boolean> {
const templateId = this.getBookingConfirmedTemplateId()
if (!templateId) {
@@ -171,28 +360,22 @@ export class SubscriptionMessageService {
return true
}
private async sendWechatSubscribeMessage(params: {
private async postWechatSubscribeSend(params: {
openid: string
bookingId: string
templateId: string
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
page: string
data: Record<string, { value: string }>
logContext: Record<string, unknown>
}): Promise<boolean> {
const accessToken = await this.getAccessToken()
const page = `/pages/booking/detail?id=${params.bookingId}`
const requestBody = {
touser: params.openid,
template_id: params.templateId,
page,
data: {
thing1: { value: params.payload.bookingContent.slice(0, 20) },
time2: { value: params.payload.bookingTime.slice(0, 20) },
thing25: { value: params.payload.courseName.slice(0, 20) },
time35: { value: params.payload.bookingEndTime.slice(0, 20) },
},
page: params.page,
data: params.data,
}
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, ...params.logContext })}`)
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
@@ -202,25 +385,80 @@ export class SubscriptionMessageService {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(15000),
},
)
if (!response.ok) {
const responseText = await response.text()
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
throw new InternalServerErrorException('调用微信订阅消息接口失败')
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, templateId: params.templateId, requestBody, ...params.logContext })}`)
return false
}
const result = (await response.json()) as WechatSubscribeSendResponse
if (result.errcode && result.errcode !== 0) {
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
return false
}
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, response: result, ...params.logContext })}`)
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ templateId: params.templateId, response: result, ...params.logContext })}`)
return true
}
private async sendWechatSubscribeMessage(params: {
openid: string
bookingId: string
templateId: string
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
logContext: Record<string, unknown>
}): Promise<boolean> {
const data = {
thing1: { value: params.payload.bookingContent.slice(0, 20) },
time2: { value: params.payload.bookingTime.slice(0, 20) },
thing25: { value: params.payload.courseName.slice(0, 20) },
time35: { value: params.payload.bookingEndTime.slice(0, 20) },
}
return this.postWechatSubscribeSend({
openid: params.openid,
templateId: params.templateId,
page: `/pages/booking/detail?id=${params.bookingId}`,
data,
logContext: { bookingId: params.bookingId, ...params.logContext },
})
}
async sendReviewReminder(userId: string, openid: string, bookingId: string): Promise<boolean> {
const templateId = this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', '')
if (!templateId) return false
const booking = await this.prisma.booking.findFirst({
where: { id: bookingId, userId, status: 'COMPLETED' },
include: { timeSlot: { select: { date: true, startTime: true } } },
})
if (!booking) return false
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
const data = buildClassReviewSubscribeData({ studioName: studio?.name ?? null, date: booking.timeSlot.date, startTime: booking.timeSlot.startTime })
const consent = await this.prisma.subscriptionMessageConsent.findUnique({ where: { userId_templateId_scene: { userId, templateId, scene: SubscriptionMessageScene.CLASS_REVIEW } } })
if (!consent || consent.sentCount >= consent.acceptCount) return false
// Reserve quota before external I/O. Ambiguous network outcomes must not cause duplicate sends.
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({ where: { id: consent.id, sentCount: consent.sentCount, acceptCount: { gt: consent.sentCount } }, data: { sentCount: { increment: 1 }, lastSentAt: new Date() } })
if (!claimed.count) return false
return this.postWechatSubscribeSend({
openid,
templateId,
page: `pages/booking/detail?id=${bookingId}`,
data,
logContext: {
target: 'member',
userId,
bookingId,
consentId: consent.id,
scene: SubscriptionMessageScene.CLASS_REVIEW,
},
})
}
private async getAccessToken(): Promise<string> {
const now = Date.now()
if (this.accessTokenCache && this.accessTokenCache.expireAt > now) {

View File

@@ -51,6 +51,11 @@ export class UserController {
return this.userService.getSubscriptionMessageTemplates()
}
@Get('user/subscription-messages/quotas')
getUserSubscriptionQuotas(@CurrentUser('sub') userId: string) {
return this.userService.getUserSubscriptionQuotas(userId)
}
@Post('user/subscription-messages/report')
reportSubscriptionMessageRequests(
@CurrentUser('sub') userId: string,

View File

@@ -1,3 +1,6 @@
import { MemberProgressController, AdminMemberProgressController } from './member-progress.controller'
import { MemberProgressService } from './member-progress.service'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AuthModule } from '../auth/auth.module'
@@ -10,8 +13,8 @@ import { LessonSupplementController } from './lesson-supplement.controller'
@Module({
imports: [AuthModule, ConfigModule],
controllers: [UserController, LessonSupplementController],
providers: [LessonSupplementService, UserService, SubscriptionMessageService],
controllers: [MemberProgressController, AdminMemberProgressController, UserController, LessonSupplementController],
providers: [MemberProgressService, ProgressPhotoStorageService, LessonSupplementService, UserService, SubscriptionMessageService],
exports: [UserService, SubscriptionMessageService],
})
export class UserModule {}

View File

@@ -15,6 +15,8 @@ import type {
SubscriptionMessageRequestResult,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
SubscriptionQuotaItem,
SubscriptionQuotasResponse,
AdminMemberSummary,
AdminMemberDetail,
MembershipWithCardType,
@@ -96,10 +98,29 @@ export class UserService {
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
description: '预约成功后推送确认通知',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>(
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
),
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
description: '约课取消后推送取消通知',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>(
'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
),
scene: SubscriptionMessageScene.CLASS_REMINDER,
description: '开课前 1 小时推送上课提醒',
usageTarget: 'consent' as const,
},
{
@@ -259,6 +280,48 @@ export class UserService {
}))
}
async getUserSubscriptionQuotas(userId: string): Promise<SubscriptionQuotasResponse> {
const config = this.buildSubscriptionTemplateConfig()
const consentTemplates = config.templates.filter((item) => item.usageTarget !== 'counter')
if (consentTemplates.length === 0) {
return { quotas: [] }
}
const consents = await this.prisma.subscriptionMessageConsent.findMany({
where: {
userId,
OR: consentTemplates.map((t) => ({
templateId: t.templateId,
scene: t.scene,
})),
},
})
const consentMap = new Map<string, SubscriptionMessageConsentRecord>()
for (const consent of consents) {
consentMap.set(`${consent.templateId}_${consent.scene}`, consent)
}
const quotas: SubscriptionQuotaItem[] = consentTemplates.map((tpl) => {
const record = consentMap.get(`${tpl.templateId}_${tpl.scene}`)
const acceptCount = record?.acceptCount ?? 0
const sentCount = record?.sentCount ?? 0
const remainingQuota = Math.max(0, acceptCount - sentCount)
return {
scene: tpl.scene,
templateId: tpl.templateId,
description: tpl.description,
remainingQuota,
acceptCount,
sentCount,
lastResult: (record?.lastResult as SubscriptionMessageRequestResult) ?? null,
}
})
return { quotas }
}
async grantAdminBookingSubscriptionCount(userId: string): Promise<UserProfileResponse> {
const user = await this.prisma.user.findUnique({
where: { id: userId },

Some files were not shown because too many files have changed in this diff Show More