Compare commits
20 Commits
v0.0.2
...
b8c0dd6781
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c0dd6781 | ||
|
|
806f3ee770 | ||
|
|
9af558286b | ||
|
|
99814f3720 | ||
|
|
014685ed04 | ||
|
|
9f5608f13c | ||
|
|
3e049d2c1d | ||
|
|
d32f592e54 | ||
|
|
d793749134 | ||
|
|
57edd8dcc0 | ||
|
|
51dea488f6 | ||
|
|
22407a7ff9 | ||
|
|
d941f1b6a9 | ||
|
|
6fab1155c7 | ||
|
|
139882d7a1 | ||
|
|
c3e46f7ffa | ||
|
|
75ef5e94a6 | ||
|
|
57107c02dc | ||
|
|
726c65f0f0 | ||
|
|
6e8fc45138 |
15
.deploy-tmp/check-fk.js
Normal file
15
.deploy-tmp/check-fk.js
Normal 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) })
|
||||
33
.deploy-tmp/resolve-migration.js
Normal file
33
.deploy-tmp/resolve-migration.js
Normal 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) })
|
||||
27
.deploy-tmp/verify-migration.js
Normal file
27
.deploy-tmp/verify-migration.js
Normal 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) })
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -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 仅使用可选 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0;累计课时包含有效补录,里程碑为 10/30/50 节。
|
||||
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL,读取用短时签名,禁止落库公共链接;上传凭证绑定学员及照片记录。
|
||||
- 新增迁移目录只放 migration.sql,测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
|
||||
|
||||
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API,避免主包引用 admin 分包 Store;馆主评价页面仍通过 admin Store 访问。
|
||||
|
||||
41
docs/flash-sale-removal.md
Normal file
41
docs/flash-sale-removal.md
Normal 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
41
docs/member-care.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 课后评价与成长档案
|
||||
|
||||
入口:预约详情评价、个人中心「我的成长档案」、管理中心「课后评价」与会员档案「成长档案」。评价归属 booking 模块,档案归属 user 模块。共享契约在 `packages/shared/src/types/member-care.ts`。
|
||||
|
||||
## 产品口径
|
||||
|
||||
- 课程变为 COMPLETED 后即可评价,没有 24 小时截止。完成后 24 小时只提醒仍未评价的预约。
|
||||
- 同一预约只能评价一次。提醒状态写在 Booking 上,定时任务原子领取;发送结果未知或失败不自动重发。
|
||||
- 星级均分与 NPS 分开。NPS 只用可选的 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||
- 教练私密笔记只在服务端过滤。课程批注必须属于该学员的已完成预约。
|
||||
- 体测允许缺项,缺项不按 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 对象都不再保留。
|
||||
- 体测可只填一项,柔韧度允许负值;累计课时含未撤销补录。
|
||||
- 未配置评价模板时,定时任务不领取预约。
|
||||
- 小程序真机确认可上传、可用签名链接预览成长照片。
|
||||
@@ -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; }
|
||||
|
||||
76
packages/app/src/components/ClassReviewForm.vue
Normal file
76
packages/app/src/components/ClassReviewForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
211
packages/app/src/components/MemberProgress.vue
Normal file
211
packages/app/src/components/MemberProgress.vue
Normal file
File diff suppressed because one or more lines are too long
54
packages/app/src/components/OwnedMembershipCard.vue
Normal file
54
packages/app/src/components/OwnedMembershipCard.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
9
packages/app/src/components/ReviewSummaryCard.vue
Normal file
9
packages/app/src/components/ReviewSummaryCard.vue
Normal 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>
|
||||
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"optimization": {
|
||||
"subPackages": true
|
||||
},
|
||||
"permission": {
|
||||
"scope.userLocation": {
|
||||
"desc": "用于获取工作室位置导航"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
12
packages/app/src/pages/admin/member-progress.vue
Normal file
12
packages/app/src/pages/admin/member-progress.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
53
packages/app/src/pages/admin/reviews.vue
Normal file
53
packages/app/src/pages/admin/reviews.vue
Normal 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">推荐意愿 9–10 分为推荐者,0–6 分为贬损者;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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
packages/app/src/pages/profile/progress.vue
Normal file
8
packages/app/src/pages/profile/progress.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal file
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
8
packages/server/src/booking/dto/create-review.dto.ts
Normal file
8
packages/server/src/booking/dto/create-review.dto.ts
Normal 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
|
||||
}
|
||||
27
packages/server/src/booking/review.controller.ts
Normal file
27
packages/server/src/booking/review.controller.ts
Normal 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() }
|
||||
}
|
||||
61
packages/server/src/booking/review.service.ts
Normal file
61
packages/server/src/booking/review.service.ts
Normal 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))) }))
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,6 @@ function buildPrismaMock() {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
flashSaleOrder: {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
})
|
||||
107
packages/server/src/scheduler/class-reminder.service.ts
Normal file
107
packages/server/src/scheduler/class-reminder.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/server/src/scheduler/review-reminder.service.ts
Normal file
25
packages/server/src/scheduler/review-reminder.service.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('照片删除失败')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
21
packages/server/src/user/dto/member-progress.dto.ts
Normal file
21
packages/server/src/user/dto/member-progress.dto.ts
Normal 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 }
|
||||
27
packages/server/src/user/member-progress.controller.ts
Normal file
27
packages/server/src/user/member-progress.controller.ts
Normal 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) }
|
||||
}
|
||||
87
packages/server/src/user/member-progress.service.ts
Normal file
87
packages/server/src/user/member-progress.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
45
packages/server/src/user/progress-photo-storage.service.ts
Normal file
45
packages/server/src/user/progress-photo-storage.service.ts
Normal 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('照片删除失败,请重试')
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user