Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd9531b3c | ||
|
|
f4cee127ce | ||
|
|
99c5c211ad | ||
|
|
30ebc1c344 | ||
|
|
5a4d2c7a1b |
11
CLAUDE.md
11
CLAUDE.md
@@ -71,12 +71,12 @@ pnpm deploy:server # 部署后端到生产环境
|
|||||||
|
|
||||||
### 卡类型枚举
|
### 卡类型枚举
|
||||||
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
|
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
|
||||||
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
|
- 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
|
||||||
- 前端选项硬编码在 `src/pages/admin/members.vue` 的 `cardTypeOptions`,需与枚举保持同步
|
- 前端选项硬编码在 `src/pages/admin/members.vue` 的 `cardTypeOptions`,需与枚举保持同步
|
||||||
|
|
||||||
### 管理后台 API 模式
|
### 管理后台 API 模式
|
||||||
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
|
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
|
||||||
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
|
- `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `CardTypeCategory`
|
||||||
- 预约统计(total/completed/cancelled)通过 `groupBy` 批量查询
|
- 预约统计(total/completed/cancelled)通过 `groupBy` 批量查询
|
||||||
|
|
||||||
### 筛选组件模式
|
### 筛选组件模式
|
||||||
@@ -91,3 +91,10 @@ pnpm deploy:server # 部署后端到生产环境
|
|||||||
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
|
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
|
||||||
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
|
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
|
||||||
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
|
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
|
||||||
|
|
||||||
|
### 月度教学统计
|
||||||
|
- 统计归属 admin 模块,服务放 `admin/teaching-analytics.service.ts`,测试放 `admin/__tests__`;共享契约放 `shared/src/types/teaching-analytics.ts`,页面为 `pages/admin/analytics.vue`。
|
||||||
|
- 按 TimeSlot.date 所属自然月查询,不按预约创建或核销日期;日期列以 UTC 日历值读取,中国时间用于判断课程结束。
|
||||||
|
- 当前无老师归属字段,统计范围是工作室;operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
|
||||||
|
- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
|
||||||
|
- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。
|
||||||
|
|||||||
32
docs/invite-marketing.md
Normal file
32
docs/invite-marketing.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 邀请好友领课时
|
||||||
|
|
||||||
|
个人中心为所有登录用户展示 6 位邀请码;首次访问 `/invite/code` 时惰性生成,字符避开 0、1、I、O。数据库唯一索引保证唯一,条件更新和冲突重试保证并发安全,无需回填存量用户。
|
||||||
|
|
||||||
|
分享入口使用微信 `button open-type="share"`,分享落地为 `/pages/card/detail?showAll=1&inviteCode=XXXXXX`。落地弹窗自动填码,未领取的列表价格标注「领取后 95 折」。用户确认后绑定一次,禁止自邀和更换邀请人;绑定后体验卡、次卡、期限卡及限时购卡均按当前售价享 95 折,按分四舍五入,后续购买自动享受。订单金额只由服务端计算。
|
||||||
|
|
||||||
|
每位好友首次支付非体验卡后,邀请人获得独立的 1 次赠课会员卡,365 天有效;邀请人无需先持有会员卡。赠课卡隐藏于商品列表,可从「我的会员卡」预约。体验卡购买与上课均不发奖。每人只奖励一次,续购不重复奖励。
|
||||||
|
|
||||||
|
订单记录下单时的邀请人和卡片类别。支付到账、购卡权益、邀请资格更新、赠课及奖励记录处于同一数据库事务。条件更新防止并发回调、同一好友多笔订单重复发奖。历史无归因快照的订单不补发奖励;历史已 QUALIFIED 的邀请保留原资格,不重复奖励。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
先备份数据库,并在服务端运行 `pnpm exec prisma migrate deploy`,应用 `20260908000000_invite_codes`。该迁移新增可空邀请码、唯一索引、订单归因字段及隐藏赠课卡类型,不更新用户历史数据。
|
||||||
|
|
||||||
|
然后运行 `pnpm prisma:generate`,从仓库根目录运行 `pnpm build:shared`、`pnpm build:server`、`pnpm build:app`。先发布服务端,再发布小程序。无需新增环境变量。已于 2026-09-08 22:29(北京时间)执行生产迁移并发布后台;小程序尚未发布。
|
||||||
|
|
||||||
|
## 真机验收
|
||||||
|
|
||||||
|
用两个微信账号验证:个人中心复制和好友分享;分享卡片落地列表与自动填码;未登录确认领取;重启后保留服务端优惠;原价和 95 折金额一致;体验卡支付不赠课;次卡/期限卡首次支付赠课到账;后续购买不再赠课;赠课可预约。还应验证支付失败、取消和重复回调。微信分享与真实微信支付仍需在部署后进行真机验证。
|
||||||
|
|
||||||
|
## 验证限制
|
||||||
|
|
||||||
|
邀请、支付和限时购卡价格测试通过。已在 390 × 844 的 H5 预览中检查个人中心入口和自动填码落地弹窗;H5 预览不代替微信真机验收。全量测试中的旧 SchedulerService 测试缺少 FlashSaleService mock,8 项失败,与本次修改无关。`pnpm lint` 因仓库未安装 ESLint 无法运行。
|
||||||
|
|
||||||
|
## 本次后台发布记录
|
||||||
|
|
||||||
|
- 目标:`129.204.155.94`,目录 `/usr/local/web/mp-pilates-server`,PM2 服务 `mp-pilates-server`,端口 3008。
|
||||||
|
- 使用服务器现有 Node 22.16.0 和 Prisma 5.22.0;现有部署脚本中的 Node 22.17.1 路径不适用于当前服务器,因此按步骤发布构建,未修改线上环境配置及证书。
|
||||||
|
- 数据库迁移状态正常,新增 3 个字段及独立隐藏赠课卡核验通过。
|
||||||
|
- 公网 `/api/health` 和 `/api/membership/card-types` 返回 200;商品列表不包含赠课卡;未登录访问 `/api/invite/code` 返回预期的 401。
|
||||||
|
- 旧构建和 Prisma schema 已归档在服务器 `/usr/local/web/releases/mp-pilates-invite-20260908/previous-build.tgz`,新构建归档为同目录 `release.tgz`。
|
||||||
|
- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。
|
||||||
47
docs/monthly-teaching-analytics.md
Normal file
47
docs/monthly-teaching-analytics.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 月度教学统计
|
||||||
|
|
||||||
|
入口:管理中心 → 统计分析。页面为 `packages/app/src/pages/admin/analytics.vue`,接口为 `GET /api/admin/teaching-analytics?month=YYYY-MM`。
|
||||||
|
|
||||||
|
## 统计口径
|
||||||
|
|
||||||
|
| 指标 | 定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已上课程 | COMPLETED 预约关联的时段数,同一时段只计一次 |
|
||||||
|
| 已完成上课人次 | COMPLETED 预约数 |
|
||||||
|
| 本月上课学员 | 已完成预约的 userId 去重,不按姓名合并 |
|
||||||
|
| 授课小时 | 已完成时段的排课分钟数之和除以 60,同一时段不重复累计 |
|
||||||
|
| 上课天数 | 已完成课程日期去重 |
|
||||||
|
| 人均上课次数 | 上课人次除以上课学员数,无学员时显示横线 |
|
||||||
|
| 上月参考 | 上一个完整自然月的已上课程节数;当月未结束时明确提示,不给误导性环比 |
|
||||||
|
| 待核对 | 已过中国时间课程结束时刻、仍待确认或已确认的预约 |
|
||||||
|
|
||||||
|
月份以 TimeSlot.date 的日历日期为准,查询使用月初包含、下月初不包含的区间。预约创建时间、完成时间和取消时间不影响月份归属。取消、未出席、待确认和已确认不计入已上课程。
|
||||||
|
|
||||||
|
当前模型没有老师归属、课程名称或课程类别,因此统计范围明确为工作室;不能把 operatorId 当作授课老师。用卡分布代表预约所用会员卡的当前卡种名称,不代表课程类别、实际消课收入或老师课酬。同名卡种合并显示。
|
||||||
|
|
||||||
|
系统存在自动完成预约任务,COMPLETED 不等于现场签到,不提供签到率。没有课程日期的历史累计补录不计入本报告。
|
||||||
|
|
||||||
|
## 页面与交互
|
||||||
|
|
||||||
|
- 米白底、松绿色总览、宋体标题与衬线数字,延续原有课表风格。
|
||||||
|
- 月份选择器、前后月切换、回到本月、手动刷新与下拉刷新;支持 2000—2099 年。
|
||||||
|
- 月历显示每天已完成课程节数,点击日期定位明细,再次点击解除日期条件。
|
||||||
|
- 学员排行支持姓名搜索,显示上课次数、天数与最近上课日期;点击查看该学员全月已完成明细。
|
||||||
|
- 明细支持日期、学员、预约状态组合筛选;每个状态显示当前日期/学员条件内的记录数。清除筛选恢复全月全部记录。
|
||||||
|
- 明细显示课程日期、起止时间、学员、用卡与状态,点击姓名进入会员详情。
|
||||||
|
- 排行每次增加 10 人、明细每次增加 20 条,避免一次渲染过多节点。接口返回当月完整记录,不以列表显示上限截断统计。
|
||||||
|
- 切月清空旧数据和筛选,请求序号防止慢响应覆盖新月份。错误提示与空月独立显示,失败不伪装成零数据。
|
||||||
|
|
||||||
|
## 实现边界与验证
|
||||||
|
|
||||||
|
接口继承管理中心的 JWT 和 ADMIN 权限保护。一次查询读取当月及上月必要关联字段,无逐学员查询;只向前端返回当月明细与两个月的汇总。未新增数据库表或迁移。
|
||||||
|
|
||||||
|
服务测试覆盖课程去重、同名不同学员、状态排除、空月、跨年、闰年、月会员管理 页面中,默认是查看全部用户,这个查看效率太低了,我希望默认看到的是会员用户增加这么一个筛选, 至于新用户,我可以选择筛选无卡用户就行了,这样的体验会更加好一些参数验证、中国时间结束判断以及接口权限元数据。
|
||||||
|
|
||||||
|
验证通过:共享包构建、后端构建、前端类型检查、微信小程序构建;新增统计测试 12 项通过。全量测试 231 项通过,原有 scheduler 测试 8 项因缺少 FlashSaleService mock 失败。
|
||||||
|
|
||||||
|
使用真实 Vue 页面与 SCSS、模拟数据进行了 390px 手机宽度预览,验证学员钻取、日期筛选、快速切月、空月和错误状态。浏览器预览仅模拟小程序容器;微信真机、原生月份选择器以及线上数据联调仍需发布前验收。
|
||||||
|
|
||||||
|
设计参考:[TeamUp 出勤报表](https://support.goteamup.com/en/articles/9327465-reports-class-attendances-all-attendances),借鉴按学员、日期和状态追溯出勤记录的交互,不复制其产品界面。
|
||||||
|
|
||||||
|
上线需要部署后端并发布小程序,本次仅实现与本地验证,未执行生产部署。
|
||||||
@@ -23,5 +23,5 @@
|
|||||||
"prisma"
|
"prisma"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "0.0.1"
|
"version": "0.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<text class="card-times-unit">课时</text>
|
<text class="card-times-unit">课时</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="price-row">
|
<view class="price-row">
|
||||||
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
|
<text class="price-current">¥{{ formatPrice(invite.price(card.price)) }}</text>
|
||||||
<text
|
<text
|
||||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
v-if="card.originalPrice && card.originalPrice > card.price"
|
||||||
class="price-original"
|
class="price-original"
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<text v-if="invite.eligible" class="renew-tag">95 折</text>
|
||||||
<!-- Arrow -->
|
<!-- Arrow -->
|
||||||
<text class="card-arrow">›</text>
|
<text class="card-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useInviteStore } from '../stores/invite'
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
@@ -89,6 +91,7 @@ import { get } from '../utils/request'
|
|||||||
import { formatPrice, getCardCoverClass } from '../utils/format'
|
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
|
|
||||||
|
const invite = useInviteStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|||||||
@@ -52,8 +52,9 @@
|
|||||||
<view class="price-area">
|
<view class="price-area">
|
||||||
<view class="flash-price-row">
|
<view class="flash-price-row">
|
||||||
<text class="flash-currency">¥</text>
|
<text class="flash-currency">¥</text>
|
||||||
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
|
<text class="flash-price">{{ formatPrice(invite.price(sale.flashPrice)) }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="invite.eligible" class="original-price">好友 95 折</text>
|
||||||
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
|
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useInviteStore } from "../stores/invite"
|
||||||
|
const invite = useInviteStore()
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { FlashSalePhase } from '@mp-pilates/shared'
|
import { FlashSalePhase } from '@mp-pilates/shared'
|
||||||
import type { FlashSaleListItem } from '@mp-pilates/shared'
|
import type { FlashSaleListItem } from '@mp-pilates/shared'
|
||||||
|
|||||||
71
packages/app/src/components/InviteCard.vue
Normal file
71
packages/app/src/components/InviteCard.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<view class="invite-entry">
|
||||||
|
<button class="invite-heading" :disabled="opening" @tap="open" aria-label="邀请好友,查看邀请码与邀请权益">
|
||||||
|
<view class="entry-copy">
|
||||||
|
<text class="entry-title">邀请好友领课时</text>
|
||||||
|
<text class="entry-caption">好友享 95 折 · {{ user.loggedIn && invite.activity ? `已获 ${invite.activity.rewardedTimes} 节赠课` : '一起练,得赠课' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="invite-count">
|
||||||
|
<text class="count-number">{{ user.loggedIn && invite.activity ? invite.activity.referrals.length : '—' }}<text v-if="user.loggedIn"> 人</text></text>
|
||||||
|
<text class="count-label">{{ user.loggedIn ? '已邀请' : '登录查看' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="entry-arrow">›</text>
|
||||||
|
</button>
|
||||||
|
<view v-if="visible" class="veil" @tap="visible = false" @touchmove.stop.prevent>
|
||||||
|
<view class="sheet" @tap.stop>
|
||||||
|
<button class="close" @tap="visible = false" aria-label="关闭">×</button>
|
||||||
|
<text class="eyebrow">A LITTLE GIFT, SHARED</text>
|
||||||
|
<text class="headline">好朋友,好好练。</text>
|
||||||
|
<text class="subtitle">把你的练习日常,分享给在意的人。</text>
|
||||||
|
<view class="benefits"><view><text class="benefit-num">1<text> 节</text></text><text>你得免费课</text></view><view><text class="benefit-num">95<text> 折</text></text><text>好友购卡优惠</text></view></view>
|
||||||
|
<view class="ticket" @tap="copy"><text>你的专属邀请码 · 点击复制</text><text class="ticket-code">{{ invite.code }}</text></view>
|
||||||
|
<text class="rules">好友确认邀请码,体验卡、次卡和期限卡均享 95 折。每位好友首次成功购买非体验卡,你得 1 节免费课;购买或完成体验课不触发赠课。</text>
|
||||||
|
<text class="rules">赠课自动存入「我的会员卡」,365 天内可预约。每位好友仅绑定一位邀请人。</text>
|
||||||
|
<button class="share" open-type="share" :disabled="!invite.code">分享给微信好友</button>
|
||||||
|
<text class="footnote">送朋友一份心意,也给自己一次练习</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useInviteStore } from '../stores/invite'
|
||||||
|
import { useUserStore } from '../stores/user'
|
||||||
|
import { getErrorMessage } from '../utils/auth'
|
||||||
|
const invite = useInviteStore()
|
||||||
|
const user = useUserStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
const opening = ref(false)
|
||||||
|
async function open() {
|
||||||
|
if (opening.value) return
|
||||||
|
opening.value = true
|
||||||
|
try {
|
||||||
|
if (!user.loggedIn) await user.login()
|
||||||
|
await Promise.all([invite.refresh(), invite.refreshActivity().catch(() => {})])
|
||||||
|
visible.value = true
|
||||||
|
} catch (err) { uni.showToast({ title: getErrorMessage(err, '暂时无法获取邀请码,请重试'), icon: 'none' }) }
|
||||||
|
finally { opening.value = false }
|
||||||
|
}
|
||||||
|
function copy() {
|
||||||
|
if (!invite.code) { open(); return }
|
||||||
|
uni.setClipboardData({ data: invite.code })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.invite-entry { margin: 16rpx 32rpx; padding: 0; background: #eaf0e7; border: 1rpx solid #d9e3d4; border-radius: 20rpx; color: #435b4c; }
|
||||||
|
.invite-heading { width: 100%; min-height: 120rpx; box-sizing: border-box; margin: 0; padding: 18rpx 24rpx; background: transparent; border-radius: 20rpx; color: inherit; display: flex; gap: 20rpx; text-align: left; align-items: center; line-height: 1.5; &::after { border: none; } &:active { background: #e2eadf; } }
|
||||||
|
.entry-copy { flex: 1; min-width: 0; }
|
||||||
|
.entry-title { display: block; font-size: 28rpx; font-weight: 500; }
|
||||||
|
.entry-caption { display: block; margin-top: 6rpx; font-size: 21rpx; color: #6b7d65; }
|
||||||
|
.invite-count { flex-shrink: 0; text-align: right; }
|
||||||
|
.count-number { display: block; font-family: Georgia, serif; font-size: 34rpx; line-height: 1.2; font-variant-numeric: tabular-nums; text { font-size: 20rpx; } }
|
||||||
|
.count-label { display: block; margin-top: 5rpx; font-size: 19rpx; color: #6b7d65; }
|
||||||
|
.entry-arrow { flex-shrink: 0; font-size: 30rpx; color: #87967f; }
|
||||||
|
.eyebrow { display: block; font-size: 19rpx; letter-spacing: 3rpx; color: #788976; }
|
||||||
|
.veil { position: fixed; inset: 0; z-index: 400; background: rgba(32,43,35,.48); display: flex; align-items: center; padding: 30rpx; }
|
||||||
|
.sheet { position: relative; width: 100%; max-height: 85vh; overflow-y: auto; box-sizing: border-box; padding: 48rpx 34rpx 32rpx; border-radius: 32rpx; background: #fcfaf5; }.close { position: absolute; right: 18rpx; top: 12rpx; margin: 0; background: transparent; color: #7b8477; font-size: 36rpx; }.close::after,.share::after { border: none; }
|
||||||
|
.headline { display: block; margin-top: 20rpx; font-family: 'Songti SC',serif; font-size: 46rpx; }.subtitle { display: block; margin-top: 12rpx; font-size: 23rpx; color: #87907f; }
|
||||||
|
.benefits { display: flex; margin: 30rpx 0; padding: 25rpx 0; background: #eaf0e5; border-radius: 22rpx; }.benefits>view { flex: 1; text-align: center; font-size: 24rpx; }.benefits>view+view { border-left: 1rpx solid #cbd8c5; }.benefit-num { display: block; font: 66rpx Georgia,serif; margin-bottom: 10rpx; }.benefit-num text { font-size: 26rpx; }
|
||||||
|
.ticket { padding: 22rpx; text-align: center; border: 1rpx dashed #b7c5af; border-radius: 16rpx; font-size: 21rpx; color: #7a8772; }.ticket-code { display: block; margin-top: 12rpx; font: 42rpx monospace; letter-spacing: 10rpx; color: #435b4c; }
|
||||||
|
.rules { display: block; margin-top: 18rpx; font-size: 21rpx; line-height: 1.8; color: #7d8076; }.share { margin-top: 26rpx; border-radius: 999rpx; background: #526e58; color: #fff; font-size: 27rpx; line-height: 86rpx; }.footnote { display: block; text-align: center; margin-top: 16rpx; font-size: 19rpx; color: #93978a; }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"autoscan": true
|
"autoscan": true
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
|
{ "path": "pages/admin/analytics", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
|
||||||
{
|
{
|
||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
279
packages/app/src/pages/admin/analytics.vue
Normal file
279
packages/app/src/pages/admin/analytics.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<view class="report" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="统计分析" show-back />
|
||||||
|
<view class="intro">
|
||||||
|
<text class="eyebrow">PILATES · MONTHLY REVIEW</text>
|
||||||
|
<text class="title">每一节课,都有迹可循。</text>
|
||||||
|
<text class="muted">工作室月度统计 · 按课程日期归档</text>
|
||||||
|
</view>
|
||||||
|
<view class="month-nav">
|
||||||
|
<button aria-label="上个月" :disabled="month === '2000-01'" @tap="shiftMonth(-1)">‹</button>
|
||||||
|
<picker mode="date" fields="month" :value="month" start="2000-01" end="2099-12" @change="changeMonth($event.detail.value)">
|
||||||
|
<view class="month-label">{{ month.replace('-', ' 年 ') }} 月 ⌄</view>
|
||||||
|
</picker>
|
||||||
|
<button aria-label="下个月" :disabled="month === '2099-12'" @tap="shiftMonth(1)">›</button>
|
||||||
|
</view>
|
||||||
|
<view class="toolbar">
|
||||||
|
<button v-if="month !== currentMonth" @tap="changeMonth(currentMonth)">回到本月</button>
|
||||||
|
<text v-else>本月持续更新</text>
|
||||||
|
<button :disabled="loading" @tap="load">刷新数据 ↻</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="!loggedIn || !isAdmin" class="empty">仅登录后的管理员可查看教学统计</view>
|
||||||
|
<view v-else-if="loading" class="empty">正在整理这个月的上课记录…</view>
|
||||||
|
<view v-else-if="error" class="empty"><text>{{ error }}</text><button class="outline" @tap="load">重新加载</button></view>
|
||||||
|
<template v-else-if="report">
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-label">已上课程</text>
|
||||||
|
<view class="hero-number">{{ report.summary.sessions }}<text>节</text></view>
|
||||||
|
<text class="hero-note">{{ report.previousMonth }} 全月 {{ report.previous.sessions }} 节 · {{ month === currentMonth ? '本月尚未结束' : '按完整自然月统计' }}</text>
|
||||||
|
<view class="hero-footer">
|
||||||
|
<view><text class="metric">{{ hours(report.summary.minutes) }}</text><text>授课小时</text></view>
|
||||||
|
<view><text class="metric">{{ report.summary.teachingDays }}</text><text>上课天数</text></view>
|
||||||
|
<view><text class="metric">{{ average }}</text><text>人均上课次数</text></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="metrics">
|
||||||
|
<view><text class="metric">{{ report.summary.attendances }}<text class="unit">人次</text></text><text>已完成上课</text></view>
|
||||||
|
<view><text class="metric">{{ report.summary.students }}<text class="unit">人</text></text><text>本月上课学员</text></view>
|
||||||
|
</view>
|
||||||
|
<button v-if="reviewCount" class="notice" @tap="showReview"><text>{{ reviewCount }} 条已过结束时间的预约仍待处理</text><text>核对明细 ›</text></button>
|
||||||
|
<view v-if="!report.records.length" class="empty compact">这个月暂无预约记录,可切换月份查看历史教学情况。</view>
|
||||||
|
<view class="section">
|
||||||
|
<view class="heading"><text>上课日历</text><text class="muted small">数字为已完成课程节数</text></view>
|
||||||
|
<view class="calendar">
|
||||||
|
<text v-for="label in weekdays" :key="label" class="weekday">{{ label }}</text>
|
||||||
|
<view v-for="n in offset" :key="`blank-${n}`" />
|
||||||
|
<button v-for="day in days" :key="day.date" class="day" :class="{ taught: day.count > 0, selected: selectedDate === day.date }" :aria-label="`${day.date},已上${day.count}节`" @tap="selectDay(day.date)">
|
||||||
|
<text>{{ day.day }}</text><text class="day-count">{{ day.count ? `${day.count}节` : '·' }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text class="footnote">点击日期查看当天明细,再次点击取消筛选</text>
|
||||||
|
</view>
|
||||||
|
<view class="section">
|
||||||
|
<view class="heading"><text>学员上课排行</text><text class="muted small">已完成 · {{ students.length }} 人</text></view>
|
||||||
|
<input v-model="search" class="search" placeholder="搜索学员姓名" :maxlength="60" @input="studentLimit = 10" />
|
||||||
|
<text v-if="!filteredStudents.length" class="footnote">{{ search ? '没有匹配的上课学员' : '本月还没有已完成的上课记录' }}</text>
|
||||||
|
<button v-for="(student, index) in filteredStudents.slice(0, studentLimit)" :key="student.id" class="student-row" @tap="selectStudent(student)">
|
||||||
|
<text class="rank">{{ String(index + 1).padStart(2, '0') }}</text>
|
||||||
|
<view class="student-main">
|
||||||
|
<text class="student-name">{{ student.name }}</text>
|
||||||
|
<view class="track"><view class="fill" :style="{ width: `${student.count / maxCount * 100}%` }" /></view>
|
||||||
|
<text class="small muted">{{ student.days }} 天 · 最近 {{ student.last.slice(5) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="student-count">{{ student.count }}<text class="small"> 次 ›</text></text>
|
||||||
|
</button>
|
||||||
|
<button v-if="filteredStudents.length > studentLimit" class="more" @tap="studentLimit += 10">查看更多学员</button>
|
||||||
|
</view>
|
||||||
|
<view class="section">
|
||||||
|
<view class="heading"><text>上课用卡分布</text><text class="muted small">已完成人次</text></view>
|
||||||
|
<view v-for="card in cards" :key="card.name" class="distribution"><text>{{ card.name }}</text><text>{{ card.count }} 人次 · {{ Math.round(card.count / report.summary.attendances * 100) }}%</text></view>
|
||||||
|
<text v-if="!cards.length" class="footnote">暂无已完成记录</text>
|
||||||
|
</view>
|
||||||
|
<view id="lesson-details" class="section">
|
||||||
|
<view class="heading"><text>上课明细</text><text class="muted small">{{ filteredRecords.length }} 条</text></view>
|
||||||
|
<scroll-view scroll-x class="status-scroll"><view class="status-tabs">
|
||||||
|
<button v-for="item in statuses" :key="item.value" :class="{ active: status === item.value }" @tap="setStatus(item.value)">{{ item.label }} {{ countStatus(item.value) }}</button>
|
||||||
|
</view></scroll-view>
|
||||||
|
<view v-if="selectedDate || selectedStudent || reviewOnly" class="filters">
|
||||||
|
<text>{{ selectedDate || '全月' }}{{ selectedStudent ? ` · ${selectedStudent.name}` : '' }}{{ reviewOnly ? ' · 待核对' : '' }}</text>
|
||||||
|
<button @tap="clearFilters">清除筛选 ×</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="!filteredRecords.length" class="footnote">当前条件下没有上课记录</text>
|
||||||
|
<view v-for="row in filteredRecords.slice(0, detailLimit)" :key="row.id" class="record">
|
||||||
|
<view class="record-top"><button class="record-name" @tap="openMember(row.userId)">{{ row.nickname || '未命名学员' }} ›</button><text class="badge" :class="{ completed: row.status === 'COMPLETED' }">{{ statusName(row.status) }}</text></view>
|
||||||
|
<text class="record-date">{{ row.date.slice(5).replace('-', '月') }}日 · {{ row.startTime }}–{{ row.endTime }}</text>
|
||||||
|
<text class="muted small">{{ row.cardName }}{{ row.needsReview ? ' · 已过结束时间,请核对' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<button v-if="filteredRecords.length > detailLimit" class="more" @tap="detailLimit += 20">再显示 20 条</button>
|
||||||
|
<text v-else-if="filteredRecords.length" class="footnote">已显示全部 {{ filteredRecords.length }} 条记录</text>
|
||||||
|
</view>
|
||||||
|
<view class="notes">
|
||||||
|
<text class="notes-title">关于这份月报</text>
|
||||||
|
<text>统计整个工作室,暂不区分老师。已上课程按已完成预约的时段去重;同一节课多人参加,只计 1 节课、多人次。授课时长按该时段排课时长计算。</text>
|
||||||
|
<text>“已完成”包含系统自动完成,不代表现场签到。其他状态不计入已上课程;没有日期的历史补录不计入月报。用卡分布按当前卡种名称归类。</text>
|
||||||
|
<text>更新于 {{ updatedLabel }} · 下拉可刷新</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
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 { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
|
||||||
|
const store = useAdminStore()
|
||||||
|
const currentMonth = ref(chinaMonth())
|
||||||
|
const month = ref(currentMonth.value)
|
||||||
|
const report = ref<TeachingAnalytics | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const selectedDate = ref('')
|
||||||
|
const selectedStudent = ref<{ id: string; name: string } | null>(null)
|
||||||
|
const status = ref('ALL')
|
||||||
|
const reviewOnly = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const studentLimit = ref(10)
|
||||||
|
const detailLimit = ref(20)
|
||||||
|
let requestId = 0
|
||||||
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
|
const statuses = [
|
||||||
|
{ value: 'ALL', label: '全部' }, { value: 'COMPLETED', label: '已完成' },
|
||||||
|
{ value: 'CONFIRMED', label: '已确认' }, { value: 'PENDING_CONFIRMATION', label: '待确认' },
|
||||||
|
{ value: 'CANCELLED', label: '已取消' }, { value: 'NO_SHOW', label: '未出席' },
|
||||||
|
]
|
||||||
|
const rows = computed(() => report.value?.records ?? [])
|
||||||
|
const completed = computed(() => rows.value.filter(row => row.status === 'COMPLETED'))
|
||||||
|
const average = computed(() => report.value?.summary.students ? (report.value.summary.attendances / report.value.summary.students).toFixed(1) : '—')
|
||||||
|
const reviewCount = computed(() => rows.value.filter(row => row.needsReview).length)
|
||||||
|
const offset = computed(() => (new Date(`${month.value}-01T00:00:00Z`).getUTCDay() + 6) % 7)
|
||||||
|
const days = computed(() => {
|
||||||
|
const [year, number] = month.value.split('-').map(Number)
|
||||||
|
const counts = new Map<string, Set<string>>()
|
||||||
|
completed.value.forEach(row => {
|
||||||
|
if (!counts.has(row.date)) counts.set(row.date, new Set())
|
||||||
|
counts.get(row.date)!.add(row.slotId)
|
||||||
|
})
|
||||||
|
return Array.from({ length: new Date(Date.UTC(year, number, 0)).getUTCDate() }, (_, i) => {
|
||||||
|
const date = `${month.value}-${String(i + 1).padStart(2, '0')}`
|
||||||
|
return { date, day: i + 1, count: counts.get(date)?.size ?? 0 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const students = computed(() => {
|
||||||
|
const map = new Map<string, { id: string; name: string; count: number; dates: Set<string>; last: string }>()
|
||||||
|
completed.value.forEach(row => {
|
||||||
|
const student = map.get(row.userId) ?? { id: row.userId, name: row.nickname || '未命名学员', count: 0, dates: new Set<string>(), last: row.date }
|
||||||
|
student.count++
|
||||||
|
student.dates.add(row.date)
|
||||||
|
if (row.date > student.last) student.last = row.date
|
||||||
|
map.set(row.userId, student)
|
||||||
|
})
|
||||||
|
return [...map.values()].map(({ dates, ...student }) => ({ ...student, days: dates.size }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
|
||||||
|
})
|
||||||
|
const filteredStudents = computed(() => students.value.filter(student => student.name.toLowerCase().includes(search.value.trim().toLowerCase())))
|
||||||
|
const maxCount = computed(() => students.value[0]?.count || 1)
|
||||||
|
const cards = computed(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
completed.value.forEach(row => map.set(row.cardName, (map.get(row.cardName) ?? 0) + 1))
|
||||||
|
return [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
|
||||||
|
})
|
||||||
|
const scopedRecords = computed(() => rows.value.filter(row =>
|
||||||
|
(!selectedDate.value || row.date === selectedDate.value) &&
|
||||||
|
(!selectedStudent.value || row.userId === selectedStudent.value.id) &&
|
||||||
|
(!reviewOnly.value || row.needsReview)))
|
||||||
|
const filteredRecords = computed(() => scopedRecords.value.filter(row => status.value === 'ALL' || row.status === status.value).slice().reverse())
|
||||||
|
const updatedLabel = computed(() => report.value ? new Date(new Date(report.value.generatedAt).getTime() + 8 * 3600000).toISOString().slice(0, 16).replace('T', ' ') : '')
|
||||||
|
|
||||||
|
function chinaMonth() { return new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7) }
|
||||||
|
function hours(minutes: number) { return Number((minutes / 60).toFixed(1)) }
|
||||||
|
function countStatus(value: string) { return scopedRecords.value.filter(row => value === 'ALL' || row.status === value).length }
|
||||||
|
function statusName(value: string) { return statuses.find(item => item.value === value)?.label ?? value }
|
||||||
|
function clearFilters() {
|
||||||
|
selectedDate.value = ''; selectedStudent.value = null; reviewOnly.value = false
|
||||||
|
status.value = 'ALL'; detailLimit.value = 20
|
||||||
|
}
|
||||||
|
function setStatus(value: string) { status.value = value; detailLimit.value = 20 }
|
||||||
|
async function scrollDetails() {
|
||||||
|
await nextTick()
|
||||||
|
uni.pageScrollTo({ selector: '#lesson-details', offsetTop: -getSystemLayout().navBarHeight - 12, duration: 250 })
|
||||||
|
}
|
||||||
|
function selectDay(date: string) {
|
||||||
|
selectedDate.value = selectedDate.value === date ? '' : date
|
||||||
|
reviewOnly.value = false; detailLimit.value = 20; scrollDetails()
|
||||||
|
}
|
||||||
|
function selectStudent(student: { id: string; name: string }) {
|
||||||
|
selectedStudent.value = { id: student.id, name: student.name }
|
||||||
|
selectedDate.value = ''; reviewOnly.value = false; setStatus('COMPLETED'); scrollDetails()
|
||||||
|
}
|
||||||
|
function showReview() { clearFilters(); reviewOnly.value = true; scrollDetails() }
|
||||||
|
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${encodeURIComponent(id)}` }) }
|
||||||
|
function shiftMonth(amount: number) {
|
||||||
|
const [year, number] = month.value.split('-').map(Number)
|
||||||
|
changeMonth(new Date(Date.UTC(year, number - 1 + amount, 1)).toISOString().slice(0, 7))
|
||||||
|
}
|
||||||
|
function changeMonth(value: string) {
|
||||||
|
if (value === month.value || value < '2000-01' || value > '2099-12') return
|
||||||
|
month.value = value; clearFilters(); search.value = ''; studentLimit.value = 10; load()
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
const id = ++requestId
|
||||||
|
report.value = null; error.value = ''; loading.value = false
|
||||||
|
if (!loggedIn.value || !isAdmin.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await store.fetchTeachingAnalytics(month.value)
|
||||||
|
if (id === requestId) report.value = data
|
||||||
|
} catch (err) {
|
||||||
|
if (id === requestId) error.value = getErrorMessage(err, '统计暂时无法加载,请重试')
|
||||||
|
} finally {
|
||||||
|
if (id === requestId) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onShow(() => { currentMonth.value = chinaMonth(); load() })
|
||||||
|
onPullDownRefresh(async () => { try { await load() } finally { uni.stopPullDownRefresh() } })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.report { min-height: 100vh; box-sizing: border-box; background: #f7f5ef; color: #35483e; padding: 0 30rpx calc(48rpx + env(safe-area-inset-bottom)); }
|
||||||
|
button { margin: 0; padding: 0; background: transparent; border-radius: 0; font-size: inherit; color: inherit; line-height: 1.5; &::after { border: 0; } &:active { opacity: .65; } }
|
||||||
|
.intro { padding: 40rpx 4rpx 28rpx; display: flex; flex-direction: column; gap: 14rpx; }
|
||||||
|
.eyebrow { font-size: 18rpx; letter-spacing: 4rpx; color: #687d6e; }
|
||||||
|
.title { font-family: 'Songti SC', 'STSong', serif; font-size: 40rpx; line-height: 1.6; }
|
||||||
|
.muted { color: #73796f; font-size: 23rpx; }
|
||||||
|
.small { font-size: 22rpx; }
|
||||||
|
.month-nav { display: flex; justify-content: space-between; align-items: center; border-top: 1rpx solid #d9ddd2; border-bottom: 1rpx solid #d9ddd2; button { width: 80rpx; line-height: 88rpx; font-size: 40rpx; } }
|
||||||
|
.month-label { padding: 20rpx; font-size: 33rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; font-size: 22rpx; color: #677765; min-height: 76rpx; button { padding: 16rpx 0; } }
|
||||||
|
.hero { background: #344f42; color: #faf7e9; border-radius: 12rpx 12rpx 48rpx 12rpx; padding: 36rpx; }
|
||||||
|
.hero-label { font-size: 25rpx; letter-spacing: 3rpx; }
|
||||||
|
.hero-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 116rpx; line-height: 1.25; text { font-size: 26rpx; padding-left: 18rpx; } }
|
||||||
|
.hero-note { font-size: 21rpx; color: #d1dbc9; }
|
||||||
|
.hero-footer { display: flex; margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #6d8070; gap: 18rpx; > view { flex: 1; display: flex; flex-direction: column; font-size: 21rpx; gap: 8rpx; } }
|
||||||
|
.metric { font-size: 40rpx; font-family: 'Baskerville', 'Times New Roman', serif; font-variant-numeric: tabular-nums; }
|
||||||
|
.unit { font-size: 22rpx; margin-left: 14rpx; }
|
||||||
|
.metrics { display: flex; padding: 30rpx 0; border-bottom: 1rpx solid #d9ddd2; > view { flex: 1; display: flex; flex-direction: column; gap: 12rpx; font-size: 24rpx; padding-left: 30rpx; &:last-child { border-left: 1rpx solid #d9ddd2; } } }
|
||||||
|
.notice { width: 100%; text-align: left; margin-top: 26rpx; padding: 24rpx; display: flex; flex-direction: column; gap: 12rpx; background: #f0e5d2; color: #805c30; font-size: 23rpx; border-radius: 12rpx; }
|
||||||
|
.section { margin-top: 28rpx; background: #fffefa; border: 1rpx solid #e3e5da; border-radius: 16rpx; padding: 28rpx; }
|
||||||
|
.heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 12rpx; margin-bottom: 24rpx; font-size: 30rpx; }
|
||||||
|
.heading > text:first-child { font-family: 'Songti SC', 'STSong', serif; }
|
||||||
|
.calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8rpx; }
|
||||||
|
.weekday { text-align: center; font-size: 21rpx; color: #73796f; padding-bottom: 14rpx; }
|
||||||
|
.day { display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92rpx; border-radius: 10rpx; font-size: 25rpx; border: 2rpx solid transparent; }
|
||||||
|
.day-count { font-size: 18rpx; margin-top: 7rpx; color: #66785f; }
|
||||||
|
.taught { background: #e8eee2; }
|
||||||
|
.selected { border-color: #344f42; background: #344f42; color: #fff; .day-count { color: #fff; } }
|
||||||
|
.footnote { display: block; color: #73796f; font-size: 22rpx; line-height: 1.8; padding-top: 24rpx; }
|
||||||
|
.search { background: #f2f3ec; border-radius: 10rpx; padding: 20rpx; font-size: 25rpx; margin-bottom: 14rpx; }
|
||||||
|
.student-row { display: flex; align-items: center; gap: 20rpx; width: 100%; padding: 24rpx 0; text-align: left; border-bottom: 1rpx solid #eeeee5; }
|
||||||
|
.rank { color: #77836e; font-family: 'Baskerville', serif; font-size: 26rpx; }
|
||||||
|
.student-main { flex: 1; min-width: 0; }
|
||||||
|
.student-name { display: block; font-size: 28rpx; word-break: break-all; }
|
||||||
|
.track { height: 5rpx; background: #eef0e6; margin: 14rpx 0 8rpx; }
|
||||||
|
.fill { height: 100%; background: #91a180; }
|
||||||
|
.student-count { flex-shrink: 0; font-size: 34rpx; }
|
||||||
|
.more { padding: 24rpx 0 0; width: 100%; font-size: 24rpx; color: #52714e; }
|
||||||
|
.distribution { display: flex; justify-content: space-between; gap: 20rpx; padding: 20rpx 0; border-bottom: 1rpx solid #eeeee5; font-size: 24rpx; > text:first-child { flex: 1; word-break: break-all; } }
|
||||||
|
.status-scroll { width: 100%; }
|
||||||
|
.status-tabs { display: flex; gap: 12rpx; padding-bottom: 12rpx; white-space: nowrap; button { flex-shrink: 0; padding: 16rpx 20rpx; border-radius: 8rpx; background: #f1f2eb; font-size: 22rpx; } .active { color: white; background: #344f42; } }
|
||||||
|
.filters { margin-top: 16rpx; font-size: 22rpx; color: #647b58; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; button { padding: 16rpx 0; } }
|
||||||
|
.record { display: flex; flex-direction: column; gap: 12rpx; border-bottom: 1rpx solid #e9ebdf; padding: 24rpx 0; }
|
||||||
|
.record-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
|
||||||
|
.record-name { font-size: 28rpx; text-align: left; word-break: break-all; }
|
||||||
|
.badge { flex-shrink: 0; font-size: 20rpx; color: #867257; background: #f3eee4; padding: 6rpx 12rpx; border-radius: 6rpx; }
|
||||||
|
.completed { color: #53704b; background: #eaf0e2; }
|
||||||
|
.record-date { font-size: 25rpx; }
|
||||||
|
.notes { padding: 32rpx 8rpx; display: flex; flex-direction: column; gap: 16rpx; font-size: 22rpx; line-height: 1.9; color: #73796f; }
|
||||||
|
.notes-title { color: #455d49; font-size: 25rpx; }
|
||||||
|
.empty { padding: 80rpx 28rpx; text-align: center; font-size: 26rpx; line-height: 1.8; color: #737e70; }
|
||||||
|
.compact { padding: 36rpx 20rpx 0; }
|
||||||
|
.outline { padding: 20rpx; margin-top: 24rpx; border: 1rpx solid #b2bfaa; border-radius: 12rpx; }
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<!-- Section header: 课程管理 -->
|
<!-- Section header: 课程管理 -->
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">课程管理</text>
|
<text class="section-title">课程管理</text>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<CustomNavBar title="会员管理" show-back />
|
<CustomNavBar title="会员管理" show-back />
|
||||||
|
|
||||||
<view class="filter-bar">
|
<view class="filter-bar">
|
||||||
|
<view class="search-field">
|
||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
<view v-if="searchQuery" class="search-clear" @tap="onClear">
|
<view v-if="searchQuery" class="search-clear" @tap="onClear">
|
||||||
<text class="search-clear-icon">×</text>
|
<text class="search-clear-icon">×</text>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<picker
|
<picker
|
||||||
class="type-picker"
|
class="type-picker"
|
||||||
mode="selector"
|
mode="selector"
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
<view class="stats-row">
|
<view class="stats-row">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-value">{{ total }}</text>
|
<text class="stat-value">{{ total }}</text>
|
||||||
<text class="stat-label">位会员</text>
|
<text class="stat-label">位用户</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
<view class="empty-icon-wrap">
|
<view class="empty-icon-wrap">
|
||||||
<view class="empty-icon-person" />
|
<view class="empty-icon-person" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text>
|
<text class="empty-text">{{ searchQuery ? '未找到匹配的用户' : '当前筛选下暂无用户' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="member-list">
|
<view v-else class="member-list">
|
||||||
@@ -116,17 +118,25 @@ const hasMore = ref(false)
|
|||||||
|
|
||||||
const LIMIT = 20
|
const LIMIT = 20
|
||||||
const cardTypeOptions = [
|
const cardTypeOptions = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '会员用户', value: 'ACTIVE' },
|
||||||
|
{ label: '全部用户', value: '' },
|
||||||
{ label: '体验卡', value: 'TRIAL' },
|
{ label: '体验卡', value: 'TRIAL' },
|
||||||
{ label: '次卡', value: 'TIMES' },
|
{ label: '次卡', value: 'TIMES' },
|
||||||
{ label: '月卡', value: 'DURATION' },
|
{ label: '月卡', value: 'DURATION' },
|
||||||
{ label: '无卡', value: 'NONE' },
|
{ label: '无卡用户', value: 'NONE' },
|
||||||
]
|
]
|
||||||
const cardTypeIndex = ref(0)
|
const cardTypeIndex = ref(0)
|
||||||
|
let requestId = 0
|
||||||
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onCardTypeChange(e: { detail: { value: number } }) {
|
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||||
cardTypeIndex.value = Number(e.detail.value)
|
cardTypeIndex.value = Number(e.detail.value)
|
||||||
|
// Invalidate the old filter immediately, including during the debounce window.
|
||||||
|
requestId++
|
||||||
|
loading.value = true
|
||||||
|
members.value = []
|
||||||
|
total.value = 0
|
||||||
|
hasMore.value = false
|
||||||
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
||||||
cardTypeDebounceTimer = setTimeout(() => {
|
cardTypeDebounceTimer = setTimeout(() => {
|
||||||
loadMembers(true)
|
loadMembers(true)
|
||||||
@@ -135,25 +145,32 @@ function onCardTypeChange(e: { detail: { value: number } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
requestId++
|
||||||
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadMembers(reset = false) {
|
async function loadMembers(reset = false) {
|
||||||
if (loading.value) return
|
if (loading.value && !reset) return
|
||||||
|
const id = ++requestId
|
||||||
|
const requestedPage = reset ? 1 : page.value + 1
|
||||||
if (reset) {
|
if (reset) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
members.value = []
|
members.value = []
|
||||||
|
total.value = 0
|
||||||
|
hasMore.value = false
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const search = searchQuery.value.trim()
|
const search = searchQuery.value.trim()
|
||||||
const cardType = cardTypeOptions[cardTypeIndex.value].value
|
const cardType = cardTypeOptions[cardTypeIndex.value].value
|
||||||
const result = await adminStore.fetchMembers({
|
const result = await adminStore.fetchMembers({
|
||||||
page: page.value,
|
page: requestedPage,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
...(cardType ? { cardType } : {}),
|
...(cardType ? { cardType } : {}),
|
||||||
})
|
})
|
||||||
|
if (id !== requestId) return
|
||||||
|
page.value = requestedPage
|
||||||
if (reset) {
|
if (reset) {
|
||||||
members.value = [...result.items]
|
members.value = [...result.items]
|
||||||
} else {
|
} else {
|
||||||
@@ -162,14 +179,15 @@ async function loadMembers(reset = false) {
|
|||||||
total.value = result.total
|
total.value = result.total
|
||||||
hasMore.value = members.value.length < result.total
|
hasMore.value = members.value.length < result.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (id === requestId) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshVisibleMembers() {
|
async function refreshVisibleMembers() {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
const id = ++requestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const search = searchQuery.value.trim()
|
const search = searchQuery.value.trim()
|
||||||
@@ -181,14 +199,15 @@ async function refreshVisibleMembers() {
|
|||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
...(cardType ? { cardType } : {}),
|
...(cardType ? { cardType } : {}),
|
||||||
})
|
})
|
||||||
|
if (id !== requestId) return
|
||||||
members.value = [...result.items]
|
members.value = [...result.items]
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
|
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
|
||||||
hasMore.value = members.value.length < result.total
|
hasMore.value = members.value.length < result.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (id === requestId) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +222,6 @@ function onClear() {
|
|||||||
|
|
||||||
onReachBottom(() => {
|
onReachBottom(() => {
|
||||||
if (!hasMore.value || loading.value) return
|
if (!hasMore.value || loading.value) return
|
||||||
page.value++
|
|
||||||
loadMembers(false)
|
loadMembers(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -244,19 +262,27 @@ onShow(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
background: $bg-page;
|
background: $bg-page;
|
||||||
border-radius: 36rpx;
|
border-radius: 36rpx;
|
||||||
padding: 0 28rpx;
|
padding: 0 60rpx 0 24rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-clear {
|
.search-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 260rpx;
|
right: 12rpx;
|
||||||
|
top: 14rpx;
|
||||||
width: 44rpx;
|
width: 44rpx;
|
||||||
height: 44rpx;
|
height: 44rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -297,7 +323,7 @@ onShow(() => {
|
|||||||
.type-picker-text {
|
.type-picker-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
max-width: 80rpx;
|
max-width: 120rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1003,7 +1003,8 @@ function updateLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--dock-tall {
|
&--dock-tall {
|
||||||
height: 320rpx;
|
height: calc(280rpx + env(safe-area-inset-bottom));
|
||||||
|
min-height: calc(128px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,7 +1048,13 @@ function updateLayout() {
|
|||||||
|
|
||||||
.dock-row {
|
.dock-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12rpx;
|
flex-shrink: 0;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
.dock-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dock--slot .dock-btn {
|
.dock--slot .dock-btn {
|
||||||
@@ -1057,9 +1064,13 @@ function updateLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dock-btn {
|
.dock-btn {
|
||||||
flex: 1;
|
// The dock stacks vertically; only horizontal rows should distribute space.
|
||||||
height: 80rpx;
|
flex: none;
|
||||||
border-radius: 999rpx;
|
box-sizing: border-box;
|
||||||
|
height: 96rpx;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
background: #6b8276;
|
background: #6b8276;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1071,7 +1082,7 @@ function updateLayout() {
|
|||||||
|
|
||||||
&--ghost {
|
&--ghost {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1rpx solid #eee8e3;
|
border: 1rpx solid #d8cec5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
&--danger {
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
:style="{ paddingTop: navBarHeight }"
|
:style="{ paddingTop: navBarHeight }"
|
||||||
>
|
>
|
||||||
<CustomNavBar :title="pageTitle" show-back />
|
<CustomNavBar :title="pageTitle" show-back />
|
||||||
|
<view class="invite-banner" @tap="inviteVisible = true; inviteInput = invite.pendingCode">
|
||||||
|
<text>{{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}</text>
|
||||||
|
<text class="invite-banner-note">{{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ›' }}</text>
|
||||||
|
</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>
|
||||||
|
<text class="sheet-title">朋友送你一份 95 折礼遇</text>
|
||||||
|
<text class="invite-banner-note">确认邀请码后,购卡自动减免 5%。体验卡也可享受。</text>
|
||||||
|
<input v-model="inviteInput" class="invite-input" maxlength="6" placeholder="填写 6 位好友邀请码" :disabled="inviteBusy" />
|
||||||
|
<text v-if="inviteError" class="invite-error">{{ inviteError }}</text>
|
||||||
|
<text class="sheet-note-text">每人仅绑定一位好友。首次成功购买非体验卡,好友获得 1 节免费课;体验卡不触发赠课。</text>
|
||||||
|
<view class="sheet-actions"><button class="sheet-cancel" :disabled="inviteBusy" @tap="inviteVisible = false">稍后再说</button><button class="sheet-confirm" :disabled="inviteBusy || inviteInput.length !== 6" @tap="confirmInvite">{{ inviteBusy ? '领取中…' : '确认领取' }}</button></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="loading" class="loading-wrap">
|
<view v-if="loading" class="loading-wrap">
|
||||||
<view class="skeleton-pass" />
|
<view class="skeleton-pass" />
|
||||||
@@ -64,10 +79,11 @@
|
|||||||
<text class="card-benefit">{{ cardAccessLabel(c) }}</text>
|
<text class="card-benefit">{{ cardAccessLabel(c) }}</text>
|
||||||
<view class="card-price-row">
|
<view class="card-price-row">
|
||||||
<view class="price-stack">
|
<view class="price-stack">
|
||||||
<text class="card-price">¥{{ formatPrice(c.price) }}</text>
|
<text class="card-price">¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}</text>
|
||||||
<text v-if="getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
|
<text v-if="invite.pendingCode && !invite.eligible" class="save-tag">领取后 95 折</text>
|
||||||
|
<text v-if="(!invite.pendingCode || invite.eligible) && getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="card-unit">{{ getUnitPriceLabel(c) }}</text>
|
<text v-if="!invite.pendingCode || invite.eligible" class="card-unit">{{ getUnitPriceLabel(c) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -129,7 +145,7 @@
|
|||||||
|
|
||||||
<view class="hero-price-row">
|
<view class="hero-price-row">
|
||||||
<text class="hero-currency">¥</text>
|
<text class="hero-currency">¥</text>
|
||||||
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
|
<text class="hero-price">{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||||
<text
|
<text
|
||||||
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
|
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
|
||||||
class="hero-original"
|
class="hero-original"
|
||||||
@@ -239,7 +255,7 @@
|
|||||||
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
|
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
|
||||||
<view class="price-summary">
|
<view class="price-summary">
|
||||||
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
|
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
|
||||||
<text class="price-summary-value">¥{{ formatPrice(cardData.price) }}</text>
|
<text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||||
<text class="price-summary-hint">{{ bottomPriceHint }}</text>
|
<text class="price-summary-hint">{{ bottomPriceHint }}</text>
|
||||||
</view>
|
</view>
|
||||||
<button
|
<button
|
||||||
@@ -270,7 +286,7 @@
|
|||||||
<text class="sheet-card-type">{{ typeLabel }}</text>
|
<text class="sheet-card-type">{{ typeLabel }}</text>
|
||||||
<text class="sheet-card-name">{{ cardData.name }}</text>
|
<text class="sheet-card-name">{{ cardData.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="sheet-card-price">¥{{ formatPrice(cardData.price) }}</text>
|
<text class="sheet-card-price">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="sheet-card-meta">
|
<view class="sheet-card-meta">
|
||||||
<text>{{ cardAccessLabel(cardData) }}</text>
|
<text>{{ cardAccessLabel(cardData) }}</text>
|
||||||
@@ -319,8 +335,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useInviteStore } from '../../stores/invite'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||||
import {
|
import {
|
||||||
CardTypeCategory,
|
CardTypeCategory,
|
||||||
@@ -348,6 +365,30 @@ interface MyOrderStatusResponse {
|
|||||||
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
|
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
|
||||||
type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication'
|
type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication'
|
||||||
|
|
||||||
|
const invite = useInviteStore()
|
||||||
|
const inviteVisible = ref(false)
|
||||||
|
const inviteInput = ref('')
|
||||||
|
const inviteBusy = ref(false)
|
||||||
|
const inviteError = ref('')
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options?.inviteCode) {
|
||||||
|
invite.pendingCode = options.inviteCode.toUpperCase()
|
||||||
|
inviteInput.value = invite.pendingCode
|
||||||
|
inviteVisible.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async function confirmInvite() {
|
||||||
|
if (inviteBusy.value) return
|
||||||
|
inviteBusy.value = true
|
||||||
|
inviteError.value = ''
|
||||||
|
try {
|
||||||
|
if (!userStore.loggedIn) await userStore.login()
|
||||||
|
await invite.confirm(inviteInput.value.trim().toUpperCase())
|
||||||
|
inviteVisible.value = false
|
||||||
|
uni.showToast({ title: '95 折优惠已领取', icon: 'success' })
|
||||||
|
} catch (err) { inviteError.value = getErrorMessage(err, '领取失败,请重试') }
|
||||||
|
finally { inviteBusy.value = false }
|
||||||
|
}
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
const cardId = ref('')
|
const cardId = ref('')
|
||||||
@@ -578,7 +619,7 @@ function cardAccessLabel(cardType: CardType): string {
|
|||||||
function getUnitPrice(cardType: CardType): string {
|
function getUnitPrice(cardType: CardType): string {
|
||||||
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
|
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
|
||||||
if (!divisor) return '-'
|
if (!divisor) return '-'
|
||||||
return '¥' + String(Math.round(cardType.price / divisor / 100))
|
return '¥' + String(Math.round(invite.price(cardType.price) / divisor / 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUnitPriceLabel(cardType: CardType): string {
|
function getUnitPriceLabel(cardType: CardType): string {
|
||||||
@@ -586,6 +627,7 @@ function getUnitPriceLabel(cardType: CardType): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSavingsLabel(cardType: CardType): string {
|
function getSavingsLabel(cardType: CardType): string {
|
||||||
|
if (invite.eligible) return '好友价 · 95 折'
|
||||||
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
|
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
|
||||||
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
|
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
|
||||||
}
|
}
|
||||||
@@ -698,6 +740,7 @@ async function preparePurchaseConfirmation() {
|
|||||||
if (!card.value || isPurchaseBusy.value) return
|
if (!card.value || isPurchaseBusy.value) return
|
||||||
purchasePreparing.value = true
|
purchasePreparing.value = true
|
||||||
try {
|
try {
|
||||||
|
await invite.refresh()
|
||||||
const refreshed = await refreshMembershipContext()
|
const refreshed = await refreshMembershipContext()
|
||||||
if (!userStore.loggedIn) {
|
if (!userStore.loggedIn) {
|
||||||
showLoginPrompt()
|
showLoginPrompt()
|
||||||
@@ -708,6 +751,8 @@ async function preparePurchaseConfirmation() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
purchaseConfirmVisible.value = true
|
purchaseConfirmVisible.value = true
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
purchasePreparing.value = false
|
purchasePreparing.value = false
|
||||||
}
|
}
|
||||||
@@ -824,10 +869,8 @@ async function doPurchase() {
|
|||||||
uni.showLoading({ title: '创建订单...' })
|
uni.showLoading({ title: '创建订单...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
|
||||||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||||||
cardTypeId: card.value.id,
|
cardTypeId: card.value.id,
|
||||||
inviterId: isTrialCard.value && inviterId ? inviterId : undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
@@ -872,6 +915,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
if (userStore.loggedIn) void invite.refresh().catch(() => {})
|
||||||
if (!paymentConfirming.value && !paymentPending.value) {
|
if (!paymentConfirming.value && !paymentPending.value) {
|
||||||
void refreshMembershipContext()
|
void refreshMembershipContext()
|
||||||
}
|
}
|
||||||
@@ -883,6 +927,11 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.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; }
|
||||||
|
.invite-error { display: block; color: #ad604c; font-size: 23rpx; margin-bottom: 16rpx; }
|
||||||
|
|
||||||
.card-detail-page {
|
.card-detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
<!-- Price row -->
|
<!-- Price row -->
|
||||||
<view class="hero-price-row">
|
<view class="hero-price-row">
|
||||||
<text class="hero-currency">¥</text>
|
<text class="hero-currency">¥</text>
|
||||||
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</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">
|
<view class="hero-original-wrap">
|
||||||
<text class="hero-original-label">原价</text>
|
<text class="hero-original-label">原价</text>
|
||||||
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
|
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
|
|
||||||
<!-- Discount badge -->
|
<!-- Discount badge -->
|
||||||
<view class="hero-discount-badge">
|
<view class="hero-discount-badge">
|
||||||
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
|
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - invite.price(detail.flashPrice)) }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Countdown -->
|
<!-- Countdown -->
|
||||||
@@ -186,7 +187,7 @@
|
|||||||
<text class="bar-price-label">秒杀价</text>
|
<text class="bar-price-label">秒杀价</text>
|
||||||
<view class="bar-price-row">
|
<view class="bar-price-row">
|
||||||
<text class="bar-currency">¥</text>
|
<text class="bar-currency">¥</text>
|
||||||
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
|
<text class="bar-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
@@ -202,6 +203,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useInviteStore } from "../../stores/invite"
|
||||||
|
const invite = useInviteStore()
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
FlashSalePhase,
|
FlashSalePhase,
|
||||||
@@ -295,7 +298,7 @@ const actionBtnText = computed(() => {
|
|||||||
if (!userStore.loggedIn) return '登录后参与'
|
if (!userStore.loggedIn) return '登录后参与'
|
||||||
if (!userStore.user?.phone) return '授权手机号后参与'
|
if (!userStore.user?.phone) return '授权手机号后参与'
|
||||||
if (buying.value) return '抢购中...'
|
if (buying.value) return '抢购中...'
|
||||||
return `¥${formatPrice(d.flashPrice)} 立即抢购`
|
return `¥${formatPrice(invite.price(d.flashPrice))} 立即抢购`
|
||||||
})
|
})
|
||||||
|
|
||||||
const actionBtnClass = computed(() => {
|
const actionBtnClass = computed(() => {
|
||||||
@@ -360,10 +363,15 @@ async function handleAction() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { await invite.refresh() } catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Confirm purchase
|
// Confirm purchase
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认抢购',
|
title: '确认抢购',
|
||||||
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
|
content: `确认以 ¥${formatPrice(invite.price(detail.value.flashPrice))} 抢购「${detail.value.title}」?`,
|
||||||
confirmText: '确认抢购',
|
confirmText: '确认抢购',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||||
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
||||||
|
|
||||||
|
<InviteCard />
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu
|
<ProfileMenu
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
@@ -29,6 +31,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InviteCard from '../../components/InviteCard.vue'
|
||||||
|
import { useInviteStore } from '../../stores/invite'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
@@ -40,6 +44,7 @@ import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
|||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
|
|
||||||
|
const invite = useInviteStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||||
@@ -61,8 +66,8 @@ const upcomingBookingCount = computed(
|
|||||||
// ─── 微信分享 ───────────────────────────────────────────────
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
return {
|
return {
|
||||||
title: '我的普拉提会所,记录每一次进步',
|
title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
|
||||||
path: '/pages/profile/index',
|
path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -82,6 +87,8 @@ onShow(async () => {
|
|||||||
activityRefreshKey.value += 1
|
activityRefreshKey.value += 1
|
||||||
if (loggedIn.value) {
|
if (loggedIn.value) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
invite.refresh().catch(() => {}),
|
||||||
|
invite.refreshActivity().catch(() => {}),
|
||||||
userStore.fetchProfile(),
|
userStore.fetchProfile(),
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
@@ -97,6 +104,7 @@ async function handleLogin() {
|
|||||||
const { isNewUser } = await userStore.loginWithSetup()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
invite.refreshActivity().catch(() => {}),
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
bookingStore.fetchUpcomingBookings(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,504 +1,19 @@
|
|||||||
<template>
|
<template><view :style="{ paddingTop: navBarHeight }"><CustomNavBar title="邀请好友" show-back /><InviteCard /></view></template>
|
||||||
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
|
|
||||||
<CustomNavBar title="邀请好友" show-back />
|
|
||||||
|
|
||||||
<scroll-view class="invite-scroll" scroll-y>
|
|
||||||
<view class="hero-card">
|
|
||||||
<view class="hero-glow hero-glow--one" />
|
|
||||||
<view class="hero-glow hero-glow--two" />
|
|
||||||
<text class="hero-badge">会员专享裂变活动</text>
|
|
||||||
<text class="hero-title">邀 3 位好友体验并核销</text>
|
|
||||||
<text class="hero-subtitle">好友购买体验课并完成上课后,会员卡立即奖励 1 节正课次数。</text>
|
|
||||||
|
|
||||||
<view class="hero-stats">
|
|
||||||
<view class="hero-stat">
|
|
||||||
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
|
|
||||||
<text class="hero-stat-label">已完成邀请</text>
|
|
||||||
</view>
|
|
||||||
<view class="hero-stat hero-stat--accent">
|
|
||||||
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
|
|
||||||
<text class="hero-stat-label">已得奖励</text>
|
|
||||||
</view>
|
|
||||||
<view class="hero-stat">
|
|
||||||
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
|
|
||||||
<text class="hero-stat-label">距下次奖励</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="progress-shell">
|
|
||||||
<view class="progress-track">
|
|
||||||
<view class="progress-fill" :style="{ width: progressWidth }" />
|
|
||||||
</view>
|
|
||||||
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<button class="share-btn" open-type="share">
|
|
||||||
立即邀请好友
|
|
||||||
</button>
|
|
||||||
<text class="share-hint">分享后,新用户登录并购买体验课即可自动绑定邀请关系。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="steps-card">
|
|
||||||
<text class="section-title">活动规则</text>
|
|
||||||
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
|
|
||||||
<view class="step-index">{{ item.index }}</view>
|
|
||||||
<view class="step-body">
|
|
||||||
<text class="step-title">{{ item.title }}</text>
|
|
||||||
<text class="step-desc">{{ item.desc }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="referrals-card">
|
|
||||||
<view class="section-head">
|
|
||||||
<text class="section-title">邀请进度</text>
|
|
||||||
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} 人</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="summary?.referrals?.length" class="referral-list">
|
|
||||||
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
|
|
||||||
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
|
|
||||||
<view v-else class="referral-avatar referral-avatar--placeholder">友</view>
|
|
||||||
<view class="referral-main">
|
|
||||||
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
|
|
||||||
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-else class="empty-block">
|
|
||||||
<text class="empty-title">还没有邀请记录</text>
|
|
||||||
<text class="empty-desc">先分享给 3 位好友,完成一次体验闭环就会在这里点亮进度。</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="reward-card">
|
|
||||||
<view class="section-head">
|
|
||||||
<text class="section-title">奖励记录</text>
|
|
||||||
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} 节</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="summary?.rewardGrants?.length" class="reward-list">
|
|
||||||
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
|
|
||||||
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
|
|
||||||
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
|
|
||||||
<text class="reward-item-tag">+{{ item.rewardTimes }} 节</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-else class="empty-block empty-block--warm">
|
|
||||||
<text class="empty-title">还未获得奖励</text>
|
|
||||||
<text class="empty-desc">每 3 位好友完成体验核销,系统自动增加 1 节真实会员课次。</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="bottom-space" />
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||||
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { InviteReferralStatus } from '@mp-pilates/shared'
|
import InviteCard from '../../components/InviteCard.vue'
|
||||||
import { useInviteStore } from '../../stores/invite'
|
import { useInviteStore } from '../../stores/invite'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { formatDateTime } from '../../utils/format'
|
const invite = useInviteStore()
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
const user = useUserStore()
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
const inviteStore = useInviteStore()
|
onShow(() => {
|
||||||
const userStore = useUserStore()
|
if (user.loggedIn) {
|
||||||
|
invite.refresh().catch(() => {})
|
||||||
const navBarHeight = ref('64px')
|
invite.refreshActivity().catch(() => {})
|
||||||
|
|
||||||
const summary = computed(() => inviteStore.activity)
|
|
||||||
const progressWidth = computed(() => {
|
|
||||||
const current = summary.value?.currentCycleQualifiedCount ?? 0
|
|
||||||
const total = summary.value?.rewardRuleInvitesRequired ?? 3
|
|
||||||
return `${Math.min(100, (current / total) * 100)}%`
|
|
||||||
})
|
|
||||||
|
|
||||||
const ruleSteps = [
|
|
||||||
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
|
|
||||||
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
|
|
||||||
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
|
|
||||||
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
|
|
||||||
]
|
|
||||||
|
|
||||||
onLoad((query) => {
|
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
|
||||||
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
|
|
||||||
if (inviterId) {
|
|
||||||
uni.setStorageSync('invite_inviter_id', inviterId)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onShareAppMessage(() => ({ title: '送你 95 折购卡礼,一起练普拉提', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index' }))
|
||||||
onShow(async () => {
|
|
||||||
if (!userStore.loggedIn) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
userStore.fetchProfile(),
|
|
||||||
inviteStore.fetchActivity(),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
onShareAppMessage(() => ({
|
|
||||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
|
||||||
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
|
|
||||||
imageUrl: '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
onShareTimeline(() => ({
|
|
||||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
|
||||||
query: `inviterId=${userStore.user?.id || ''}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
function statusLabel(status: InviteReferralStatus): string {
|
|
||||||
const map: Record<InviteReferralStatus, string> = {
|
|
||||||
[InviteReferralStatus.REGISTERED]: '已注册',
|
|
||||||
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
|
|
||||||
[InviteReferralStatus.QUALIFIED]: '已完成核销',
|
|
||||||
}
|
|
||||||
return map[status]
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status: InviteReferralStatus): string {
|
|
||||||
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
|
|
||||||
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
|
|
||||||
return 'referral-status--registered'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.invite-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
|
|
||||||
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
|
|
||||||
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-scroll {
|
|
||||||
height: 100vh;
|
|
||||||
padding: 24rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-card,
|
|
||||||
.steps-card,
|
|
||||||
.referrals-card,
|
|
||||||
.reward-card {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 36rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-card {
|
|
||||||
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-glow {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0.28;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-glow--one {
|
|
||||||
width: 260rpx;
|
|
||||||
height: 260rpx;
|
|
||||||
top: -90rpx;
|
|
||||||
right: -40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-glow--two {
|
|
||||||
width: 180rpx;
|
|
||||||
height: 180rpx;
|
|
||||||
bottom: -50rpx;
|
|
||||||
left: -40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 10rpx 18rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.18);
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
margin-bottom: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 52rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.18;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 18rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 18rpx;
|
|
||||||
margin-top: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stat {
|
|
||||||
padding: 24rpx 18rpx;
|
|
||||||
border-radius: 26rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
backdrop-filter: blur(10rpx);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stat--accent {
|
|
||||||
background: rgba(75, 16, 16, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stat-value {
|
|
||||||
display: block;
|
|
||||||
font-size: 46rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stat-label {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-shell {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-track {
|
|
||||||
height: 20rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-caption {
|
|
||||||
display: block;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
height: 96rpx;
|
|
||||||
line-height: 96rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ff5a3c;
|
|
||||||
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn::after {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 14rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-card {
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #30201a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-meta {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #9b6b55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 18rpx;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 22rpx 0;
|
|
||||||
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-index {
|
|
||||||
width: 64rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 64rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-body {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #36231d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-desc {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #7b5d52;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referrals-card {
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reward-card {
|
|
||||||
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-item,
|
|
||||||
.reward-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 22rpx 0;
|
|
||||||
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-item:last-child,
|
|
||||||
.reward-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-avatar {
|
|
||||||
width: 78rpx;
|
|
||||||
height: 78rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 18rpx;
|
|
||||||
background: #ffd9c8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-avatar--placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ff6f3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-name,
|
|
||||||
.reward-item-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #31211a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-time,
|
|
||||||
.reward-item-time {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #8b6d62;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-status,
|
|
||||||
.reward-item-tag {
|
|
||||||
padding: 12rpx 18rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-status--registered {
|
|
||||||
color: #9c5e2f;
|
|
||||||
background: #fff0de;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-status--paid {
|
|
||||||
color: #c44f1f;
|
|
||||||
background: #ffe0d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.referral-status--done,
|
|
||||||
.reward-item-tag {
|
|
||||||
color: #0f7a53;
|
|
||||||
background: #dff7ea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-block {
|
|
||||||
padding: 36rpx 0 10rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-block--warm {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #5f4337;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-desc {
|
|
||||||
display: block;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: #9c7d70;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-space {
|
|
||||||
height: 48rpx;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ function goStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canRenewMembership(m: MembershipWithCardType): boolean {
|
function canRenewMembership(m: MembershipWithCardType): boolean {
|
||||||
return m.cardType.type !== CardTypeCategory.TRIAL
|
return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
function goRenew(m: MembershipWithCardType) {
|
function goRenew(m: MembershipWithCardType) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { get, post, put, del } from '../utils/request'
|
import { get, post, put, del } from '../utils/request'
|
||||||
import type {
|
import type {
|
||||||
|
TeachingAnalytics,
|
||||||
CardType,
|
CardType,
|
||||||
CreateCardTypeDto,
|
CreateCardTypeDto,
|
||||||
UpdateCardTypeDto,
|
UpdateCardTypeDto,
|
||||||
@@ -287,7 +288,12 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
|
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
|
||||||
|
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
fetchTeachingAnalytics,
|
||||||
// State
|
// State
|
||||||
cardTypes,
|
cardTypes,
|
||||||
studioConfig,
|
studioConfig,
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||||
import { get } from '../utils/request'
|
import { get, post } from '../utils/request'
|
||||||
|
|
||||||
export const useInviteStore = defineStore('invite', () => {
|
export const useInviteStore = defineStore('invite', () => {
|
||||||
const activity = ref<InviteActivitySummary | null>(null)
|
const activity = ref<InviteActivitySummary | null>(null)
|
||||||
const loading = ref(false)
|
let activityRevision = 0
|
||||||
|
async function refreshActivity() {
|
||||||
async function fetchActivity() {
|
const requestRevision = ++activityRevision
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
activity.value = await get<InviteActivitySummary>('/invite/activity')
|
const result = await get<InviteActivitySummary>('/invite/activity')
|
||||||
return activity.value
|
if (requestRevision === activityRevision) activity.value = result
|
||||||
|
} catch (err) {
|
||||||
|
if (requestRevision === activityRevision) activity.value = null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const code = ref('')
|
||||||
|
const eligible = ref(false)
|
||||||
|
const pendingCode = ref('')
|
||||||
|
let revision = 0
|
||||||
|
let confirming = false
|
||||||
|
async function refresh() {
|
||||||
|
if (confirming) return
|
||||||
|
const requestRevision = ++revision
|
||||||
|
const result = await get<{ inviteCode: string; discountEligible: boolean }>('/invite/code')
|
||||||
|
if (requestRevision !== revision) return
|
||||||
|
code.value = result.inviteCode
|
||||||
|
eligible.value = result.discountEligible
|
||||||
|
}
|
||||||
|
async function confirm(value: string) {
|
||||||
|
const requestRevision = ++revision
|
||||||
|
confirming = true
|
||||||
|
try {
|
||||||
|
const result = await post<{ inviteCode: string; discountEligible: boolean }>('/invite/confirm', { code: value })
|
||||||
|
if (requestRevision !== revision) throw new Error('登录状态已变化,请重试')
|
||||||
|
code.value = result.inviteCode
|
||||||
|
eligible.value = result.discountEligible
|
||||||
|
pendingCode.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
confirming = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function reset() {
|
||||||
return {
|
activityRevision++
|
||||||
activity,
|
activity.value = null
|
||||||
loading,
|
revision++
|
||||||
fetchActivity,
|
code.value = ''
|
||||||
|
eligible.value = false
|
||||||
|
pendingCode.value = ''
|
||||||
}
|
}
|
||||||
|
function price(value: number) { return eligible.value ? Math.round(value * 95 / 100) : value }
|
||||||
|
return { activity, refreshActivity, code, eligible, pendingCode, refresh, confirm, reset, price }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useInviteStore } from './invite'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -46,6 +47,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const result = await wxLogin()
|
const result = await wxLogin()
|
||||||
token.value = result.token
|
token.value = result.token
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
|
await useInviteStore().refresh().catch(() => {})
|
||||||
syncSubscriptionTemplates(result.user)
|
syncSubscriptionTemplates(result.user)
|
||||||
return { user: result.user, isNewUser: result.isNewUser }
|
return { user: result.user, isNewUser: result.isNewUser }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -72,6 +74,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
|
await useInviteStore().refresh().catch(() => {})
|
||||||
syncSubscriptionTemplates(user.value)
|
syncSubscriptionTemplates(user.value)
|
||||||
return user.value
|
return user.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -120,6 +123,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
token.value = ''
|
token.value = ''
|
||||||
|
useInviteStore().reset()
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
memberships.value = []
|
memberships.value = []
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `users` ADD COLUMN `invite_code` VARCHAR(6) NULL;
|
||||||
|
CREATE UNIQUE INDEX `users_invite_code_key` ON `users` (`invite_code`);
|
||||||
|
ALTER TABLE `orders` ADD COLUMN `invite_inviter_id` VARCHAR(191) NULL, ADD COLUMN `purchased_category` VARCHAR(191) NULL;
|
||||||
|
INSERT INTO `card_types` (`id`, `name`, `type`, `total_times`, `duration_days`, `price`, `is_active`, `sort_order`, `updated_at`) VALUES ('invite-reward-card', '邀请好友赠课', 'TIMES', 1, 365, 0, false, 9999, NOW());
|
||||||
@@ -73,6 +73,7 @@ enum InviteReferralStatus {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
|
||||||
openid String @unique
|
openid String @unique
|
||||||
unionid String?
|
unionid String?
|
||||||
phone String?
|
phone String?
|
||||||
@@ -254,6 +255,8 @@ model Order {
|
|||||||
cardTypeId String @map("card_type_id")
|
cardTypeId String @map("card_type_id")
|
||||||
membershipId String? @map("membership_id")
|
membershipId String? @map("membership_id")
|
||||||
orderNo String @unique @map("order_no")
|
orderNo String @unique @map("order_no")
|
||||||
|
inviteInviterId String? @map("invite_inviter_id")
|
||||||
|
purchasedCategory String? @map("purchased_category")
|
||||||
amount Decimal @db.Decimal(10, 0)
|
amount Decimal @db.Decimal(10, 0)
|
||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
wxTransactionId String? @map("wx_transaction_id")
|
wxTransactionId String? @map("wx_transaction_id")
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'reflect-metadata'
|
||||||
|
import { BadRequestException } from '@nestjs/common'
|
||||||
|
import { BookingStatus, UserRole } from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { TeachingAnalyticsService } from '../teaching-analytics.service'
|
||||||
|
import { AdminController } from '../admin.controller'
|
||||||
|
import { ROLES_KEY } from '../../auth/roles.decorator'
|
||||||
|
|
||||||
|
function booking(id: string, userId: string, slotId: string, date: string, status = BookingStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
id, userId, status, user: { nickname: '同名学员' },
|
||||||
|
timeSlot: { id: slotId, date: new Date(`${date}T00:00:00Z`), startTime: '09:00', endTime: '10:30' },
|
||||||
|
membership: { cardType: { name: '次卡' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeachingAnalyticsService', () => {
|
||||||
|
const findMany = jest.fn()
|
||||||
|
const service = new TeachingAnalyticsService({ booking: { findMany } } as unknown as PrismaService)
|
||||||
|
beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T03:00:00Z')); findMany.mockReset() })
|
||||||
|
afterEach(() => jest.useRealTimers())
|
||||||
|
|
||||||
|
it.each(['2026-13', '2026-00', '2026-9', '', '2026-09-01', '1999-12', undefined])('rejects invalid month %s before querying', async month => {
|
||||||
|
await expect(service.getMonthly(month as string)).rejects.toBeInstanceOf(BadRequestException)
|
||||||
|
expect(findMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts shared sessions and duration once, students by identity, and excludes other statuses', async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
booking('previous', 'a', 'old', '2026-08-31'),
|
||||||
|
booking('1', 'a', 'one', '2026-09-01'), booking('2', 'b', 'one', '2026-09-01'),
|
||||||
|
booking('3', 'a', 'two', '2026-09-03'),
|
||||||
|
...[BookingStatus.CANCELLED, BookingStatus.NO_SHOW, BookingStatus.CONFIRMED, BookingStatus.PENDING_CONFIRMATION]
|
||||||
|
.map((status, index) => booking(`other${index}`, 'c', `other${index}`, '2026-09-04', status)),
|
||||||
|
])
|
||||||
|
const result = await service.getMonthly('2026-09')
|
||||||
|
expect(result.summary).toEqual({ sessions: 2, attendances: 3, students: 2, minutes: 180, teachingDays: 2 })
|
||||||
|
expect(result.previous.sessions).toBe(1)
|
||||||
|
expect(result.records).toHaveLength(7)
|
||||||
|
expect(result.records.every(row => row.date.startsWith('2026-09'))).toBe(true)
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2026-01', '2025-12-01', '2026-02-01'],
|
||||||
|
['2024-02', '2024-01-01', '2024-03-01'],
|
||||||
|
])('uses half-open course date boundaries for %s', async (month, from, to) => {
|
||||||
|
findMany.mockResolvedValue([])
|
||||||
|
const result = await service.getMonthly(month)
|
||||||
|
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: { timeSlot: { date: { gte: new Date(`${from}T00:00:00Z`), lt: new Date(`${to}T00:00:00Z`) } } },
|
||||||
|
}))
|
||||||
|
expect(result.previousMonth).toBe(from.slice(0, 7))
|
||||||
|
expect(result.summary).toEqual({ sessions: 0, attendances: 0, students: 0, minutes: 0, teachingDays: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags only unfinished bookings past their China-time end, without converting status', async () => {
|
||||||
|
const future = booking('future', 'b', 'future', '2026-09-09', BookingStatus.CONFIRMED)
|
||||||
|
future.timeSlot.endTime = '11:30'
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
booking('past', 'a', 'past', '2026-09-09', BookingStatus.CONFIRMED), future,
|
||||||
|
booking('cancel', 'c', 'cancel', '2026-09-09', BookingStatus.CANCELLED),
|
||||||
|
])
|
||||||
|
const result = await service.getMonthly('2026-09')
|
||||||
|
expect(result.records.map(row => row.needsReview)).toEqual([true, false, false])
|
||||||
|
expect(result.summary.sessions).toBe(0)
|
||||||
|
expect(result.records[0].status).toBe(BookingStatus.CONFIRMED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inherits the admin-only controller role and authentication guards', () => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, AdminController)).toEqual([UserRole.ADMIN])
|
||||||
|
const guards = Reflect.getMetadata('__guards__', AdminController) as Array<{ name: string }>
|
||||||
|
expect(guards.map(guard => guard.name)).toEqual(['JwtAuthGuard', 'RolesGuard'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
import { TeachingAnalyticsService } from './teaching-analytics.service'
|
||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
import { Roles } from '../auth/roles.decorator'
|
import { Roles } from '../auth/roles.decorator'
|
||||||
import { RolesGuard } from '../auth/roles.guard'
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
@@ -15,7 +16,12 @@ interface AdminStats {
|
|||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService, private readonly analytics: TeachingAnalyticsService) {}
|
||||||
|
|
||||||
|
@Get('teaching-analytics')
|
||||||
|
getTeachingAnalytics(@Query('month') month: string) {
|
||||||
|
return this.analytics.getMonthly(month)
|
||||||
|
}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
async getStats(): Promise<AdminStats> {
|
async getStats(): Promise<AdminStats> {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { TeachingAnalyticsService } from './teaching-analytics.service'
|
||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { AdminController } from './admin.controller'
|
import { AdminController } from './admin.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
|
providers: [TeachingAnalyticsService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
58
packages/server/src/admin/teaching-analytics.service.ts
Normal file
58
packages/server/src/admin/teaching-analytics.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common'
|
||||||
|
import { BookingStatus, type TeachingAnalytics, type TeachingAnalyticsRecord, type TeachingAnalyticsSummary } from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeachingAnalyticsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getMonthly(month: string): Promise<TeachingAnalytics> {
|
||||||
|
if (typeof month !== 'string' || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) {
|
||||||
|
throw new BadRequestException('月份格式应为 YYYY-MM,范围为 2000—2099 年')
|
||||||
|
}
|
||||||
|
const [year, number] = month.split('-').map(Number)
|
||||||
|
const start = new Date(Date.UTC(year, number - 1, 1))
|
||||||
|
const previousStart = new Date(Date.UTC(year, number - 2, 1))
|
||||||
|
const end = new Date(Date.UTC(year, number, 1))
|
||||||
|
const now = new Date()
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: { timeSlot: { date: { gte: previousStart, lt: end } } },
|
||||||
|
select: {
|
||||||
|
id: true, userId: true, status: true,
|
||||||
|
user: { select: { nickname: true } },
|
||||||
|
timeSlot: { select: { id: true, date: true, startTime: true, endTime: true } },
|
||||||
|
membership: { select: { cardType: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
const rows: TeachingAnalyticsRecord[] = bookings.map((booking) => {
|
||||||
|
const slot = booking.timeSlot
|
||||||
|
const date = slot.date.toISOString().slice(0, 10)
|
||||||
|
const unfinished = booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING_CONFIRMATION
|
||||||
|
return {
|
||||||
|
id: booking.id, userId: booking.userId, nickname: booking.user.nickname,
|
||||||
|
slotId: slot.id, date, startTime: slot.startTime, endTime: slot.endTime,
|
||||||
|
cardName: booking.membership.cardType.name, status: booking.status as BookingStatus,
|
||||||
|
needsReview: unfinished && new Date(`${date}T${slot.endTime}:00+08:00`).getTime() < now.getTime(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const records = rows.filter((row) => row.date >= start.toISOString().slice(0, 10))
|
||||||
|
return {
|
||||||
|
month, generatedAt: now.toISOString(), records,
|
||||||
|
summary: this.summarize(records), previousMonth: previousStart.toISOString().slice(0, 7),
|
||||||
|
previous: this.summarize(rows.filter((row) => row.date < start.toISOString().slice(0, 10))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private summarize(rows: TeachingAnalyticsRecord[]): TeachingAnalyticsSummary {
|
||||||
|
const completed = rows.filter((row) => row.status === BookingStatus.COMPLETED)
|
||||||
|
const slots = new Map(completed.map((row) => [row.slotId, row]))
|
||||||
|
const minutes = [...slots.values()].reduce((total, slot) => {
|
||||||
|
const parse = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
|
||||||
|
return total + Math.max(0, parse(slot.endTime) - parse(slot.startTime))
|
||||||
|
}, 0)
|
||||||
|
return { sessions: slots.size, attendances: completed.length,
|
||||||
|
students: new Set(completed.map((row) => row.userId)).size,
|
||||||
|
teachingDays: new Set(completed.map((row) => row.date)).size, minutes }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -167,12 +167,15 @@ export class FlashSaleService {
|
|||||||
// ②-b: Create Order with flash sale price
|
// ②-b: Create Order with flash sale price
|
||||||
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
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({
|
const order = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
cardTypeId: sale.cardTypeId,
|
cardTypeId: sale.cardTypeId,
|
||||||
orderNo,
|
orderNo,
|
||||||
amount: sale.flashPrice,
|
amount: referral ? Math.round(Number(sale.flashPrice) * 95 / 100) : sale.flashPrice,
|
||||||
|
inviteInviterId: referral?.inviterId,
|
||||||
|
purchasedCategory: sale.cardType.type,
|
||||||
status: OrderStatus.PENDING,
|
status: OrderStatus.PENDING,
|
||||||
flashSaleId,
|
flashSaleId,
|
||||||
},
|
},
|
||||||
|
|||||||
71
packages/server/src/invite/__tests__/invite.service.spec.ts
Normal file
71
packages/server/src/invite/__tests__/invite.service.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { InviteService } from '../invite.service'
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
describe('InviteService marketing rules', () => {
|
||||||
|
function setup() {
|
||||||
|
const prisma = {
|
||||||
|
user: { findUniqueOrThrow: jest.fn(), updateMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
inviteReferral: { upsert: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
|
||||||
|
membership: { create: jest.fn() }, inviteRewardGrant: { create: jest.fn() },
|
||||||
|
}
|
||||||
|
return { prisma, service: new InviteService(prisma as unknown as PrismaService) }
|
||||||
|
}
|
||||||
|
it('lazily reuses existing short codes without writing', async () => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'ABC234' })
|
||||||
|
expect(await service.ensureCode('user')).toBe('ABC234')
|
||||||
|
expect(prisma.user.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('retries code collisions and only writes if still missing', async () => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: null })
|
||||||
|
prisma.user.updateMany.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ count: 1 })
|
||||||
|
expect(await service.ensureCode('user')).toMatch(/^[23456789A-HJ-NP-Z]{6}$/)
|
||||||
|
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2)
|
||||||
|
expect(prisma.user.updateMany.mock.calls[0][0].where).toEqual({ id: 'user', inviteCode: null })
|
||||||
|
})
|
||||||
|
it('reads the winning code when concurrent generation already filled it', async () => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.user.findUniqueOrThrow.mockResolvedValueOnce({ inviteCode: null }).mockResolvedValueOnce({ inviteCode: 'ABC234' })
|
||||||
|
prisma.user.updateMany.mockResolvedValue({ count: 0 })
|
||||||
|
expect(await service.ensureCode('user')).toBe('ABC234')
|
||||||
|
})
|
||||||
|
it('rejects invalid, self and changed inviter codes', async () => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'self' }).mockResolvedValueOnce({ id: 'friend' })
|
||||||
|
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('不存在')
|
||||||
|
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('自己')
|
||||||
|
prisma.inviteReferral.upsert.mockResolvedValue({ inviterId: 'original' })
|
||||||
|
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('其他好友')
|
||||||
|
})
|
||||||
|
it('accepts repeat confirmation and recovers a concurrent binding to the same inviter', async () => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 'friend' })
|
||||||
|
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'XYZ234' })
|
||||||
|
prisma.inviteReferral.upsert.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ inviterId: 'friend' })
|
||||||
|
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
|
||||||
|
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
|
||||||
|
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['TRIAL', null])('does not reward category %s', async (purchasedCategory) => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
await service.rewardPaidOrder(prisma as unknown as Prisma.TransactionClient, { userId: 'user', inviteInviterId: 'friend', purchasedCategory }, new Date())
|
||||||
|
expect(prisma.inviteReferral.updateMany).not.toHaveBeenCalled()
|
||||||
|
await service.recordQualifiedTrialBooking('booking')
|
||||||
|
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it.each(['TIMES', 'DURATION'])('grants one usable class for %s even without an existing membership', async (purchasedCategory) => {
|
||||||
|
const { prisma, service } = setup()
|
||||||
|
prisma.inviteReferral.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 })
|
||||||
|
prisma.membership.create.mockResolvedValue({ id: 'reward' })
|
||||||
|
const order = { userId: 'user', inviteInviterId: 'friend', purchasedCategory }
|
||||||
|
const tx = prisma as unknown as Prisma.TransactionClient
|
||||||
|
await service.rewardPaidOrder(tx, order, new Date())
|
||||||
|
await service.rewardPaidOrder(tx, order, new Date())
|
||||||
|
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(prisma.membership.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'friend', remainingTimes: 1, totalTimes: 1, status: 'ACTIVE' }) })
|
||||||
|
expect(prisma.inviteRewardGrant.create).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
export const INVITE_REWARD_REQUIRED_COUNT = 1
|
||||||
export const INVITE_REWARD_TIMES = 1
|
export const INVITE_REWARD_TIMES = 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
import { IsString, Matches } from 'class-validator'
|
||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||||
import { InviteService } from './invite.service'
|
import { InviteService } from './invite.service'
|
||||||
|
|
||||||
|
class ConfirmInviteDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[23456789A-HJ-NP-Z]{6}$/i)
|
||||||
|
code!: string
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('invite')
|
@Controller('invite')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class InviteController {
|
export class InviteController {
|
||||||
constructor(private readonly inviteService: InviteService) {}
|
constructor(private readonly inviteService: InviteService) {}
|
||||||
|
|
||||||
|
@Get('code')
|
||||||
|
getCode(@CurrentUser('sub') userId: string) {
|
||||||
|
return this.inviteService.getCodeStatus(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('confirm')
|
||||||
|
confirm(@CurrentUser('sub') userId: string, @Body() body: ConfirmInviteDto) {
|
||||||
|
return this.inviteService.confirmCode(userId, body.code)
|
||||||
|
}
|
||||||
|
|
||||||
@Get('activity')
|
@Get('activity')
|
||||||
getActivity(@CurrentUser('sub') userId: string) {
|
getActivity(@CurrentUser('sub') userId: string) {
|
||||||
return this.inviteService.getInviteActivitySummary(userId)
|
return this.inviteService.getInviteActivitySummary(userId)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
|
import { randomInt } from 'crypto'
|
||||||
|
import type { Prisma, InviteReferral, InviteRewardGrant } from '@prisma/client'
|
||||||
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
@@ -70,50 +71,72 @@ export class InviteService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
|
// Legacy booking callbacks must never qualify trial purchases.
|
||||||
const booking = await this.prisma.booking.findUnique({
|
async recordQualifiedTrialBooking(_bookingId: string): Promise<void> {}
|
||||||
where: { id: bookingId },
|
|
||||||
include: {
|
|
||||||
membership: { include: { cardType: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
|
async ensureCode(userId: string): Promise<string> {
|
||||||
return
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } })
|
||||||
|
if (user.inviteCode) return user.inviteCode
|
||||||
|
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||||
|
const code = Array.from({ length: 6 }, () => alphabet[randomInt(alphabet.length)]).join('')
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.user.updateMany({
|
||||||
|
where: { id: userId, inviteCode: null }, data: { inviteCode: code },
|
||||||
|
})
|
||||||
|
if (updated.count) return code
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BadRequestException('邀请码生成繁忙,请重试')
|
||||||
}
|
}
|
||||||
|
|
||||||
const referral = await this.prisma.inviteReferral.findFirst({
|
async getCodeStatus(userId: string) {
|
||||||
where: {
|
const inviteCode = await this.ensureCode(userId)
|
||||||
inviteeId: booking.userId,
|
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||||
status: {
|
return { inviteCode, discountEligible: !!referral }
|
||||||
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
|
|
||||||
},
|
|
||||||
qualifiedBookingId: null,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!referral) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.inviteReferral.update({
|
async confirmCode(userId: string, code: string) {
|
||||||
where: { id: referral.id },
|
const inviter = await this.prisma.user.findUnique({ where: { inviteCode: code.toUpperCase() } })
|
||||||
data: {
|
if (!inviter) throw new BadRequestException('邀请码不存在,请检查后重试')
|
||||||
status: InviteReferralStatus.QUALIFIED,
|
if (inviter.id === userId) throw new BadRequestException('不能使用自己的邀请码')
|
||||||
qualifiedBookingId: booking.id,
|
let referral: InviteReferral | null
|
||||||
qualifiedAt: booking.completedAt ?? new Date(),
|
try {
|
||||||
},
|
referral = await this.prisma.inviteReferral.upsert({
|
||||||
|
where: { inviteeId: userId }, update: {},
|
||||||
|
create: { inviterId: inviter.id, inviteeId: userId },
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// MySQL upserts may race on the unique invitee key. Read the winner.
|
||||||
|
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||||
|
referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||||
|
if (!referral) throw error
|
||||||
|
}
|
||||||
|
if (referral.inviterId !== inviter.id) throw new BadRequestException('你已绑定其他好友的邀请码,已享有 95 折优惠')
|
||||||
|
return this.getCodeStatus(userId)
|
||||||
|
}
|
||||||
|
|
||||||
await this.grantRewardsIfEligible(referral.inviterId)
|
async rewardPaidOrder(tx: Prisma.TransactionClient, order: { userId: string; inviteInviterId: string | null; purchasedCategory: string | null }, now: Date) {
|
||||||
|
if (!order.inviteInviterId || !order.purchasedCategory || order.purchasedCategory === 'TRIAL') return
|
||||||
|
const claimed = await tx.inviteReferral.updateMany({
|
||||||
|
where: { inviteeId: order.userId, inviterId: order.inviteInviterId, status: { not: 'QUALIFIED' } },
|
||||||
|
data: { status: 'QUALIFIED', qualifiedAt: now },
|
||||||
|
})
|
||||||
|
if (!claimed.count) return
|
||||||
|
const membership = await tx.membership.create({ data: {
|
||||||
|
userId: order.inviteInviterId, cardTypeId: 'invite-reward-card',
|
||||||
|
remainingTimes: 1, totalTimes: 1, startDate: now,
|
||||||
|
expireDate: new Date(now.getTime() + 365 * 86400000), status: 'ACTIVE',
|
||||||
|
} })
|
||||||
|
await tx.inviteRewardGrant.create({ data: {
|
||||||
|
inviterId: order.inviteInviterId, membershipId: membership.id,
|
||||||
|
qualifiedReferralCount: 1, rewardTimes: 1,
|
||||||
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> {
|
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> {
|
||||||
const memberships = await this.prisma.membership.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
|
|
||||||
})
|
|
||||||
const referrals = await this.prisma.inviteReferral.findMany({
|
const referrals = await this.prisma.inviteReferral.findMany({
|
||||||
where: { inviterId: userId },
|
where: { inviterId: userId },
|
||||||
include: {
|
include: {
|
||||||
@@ -132,7 +155,7 @@ export class InviteService {
|
|||||||
orderBy: { grantedAt: 'desc' },
|
orderBy: { grantedAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
|
const canInvite = true
|
||||||
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
|
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
|
||||||
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
|
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
|
||||||
const pendingRewardGrantCount = Math.max(
|
const pendingRewardGrantCount = Math.max(
|
||||||
@@ -144,7 +167,7 @@ export class InviteService {
|
|||||||
return {
|
return {
|
||||||
inviterId: userId,
|
inviterId: userId,
|
||||||
canInvite,
|
canInvite,
|
||||||
sharePath: `/pages/profile/invite?inviterId=${userId}`,
|
sharePath: `/pages/card/detail?showAll=1&inviteCode=${await this.ensureCode(userId)}`,
|
||||||
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
||||||
rewardRuleTimes: INVITE_REWARD_TIMES,
|
rewardRuleTimes: INVITE_REWARD_TIMES,
|
||||||
qualifiedInviteCount,
|
qualifiedInviteCount,
|
||||||
@@ -196,58 +219,4 @@ export class InviteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async grantRewardsIfEligible(inviterId: string): Promise<void> {
|
|
||||||
const [qualifiedCount, rewardGrantCount] = await Promise.all([
|
|
||||||
this.prisma.inviteReferral.count({
|
|
||||||
where: {
|
|
||||||
inviterId,
|
|
||||||
status: InviteReferralStatus.QUALIFIED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.inviteRewardGrant.count({ where: { inviterId } }),
|
|
||||||
])
|
|
||||||
|
|
||||||
const shouldGrantCount = Math.floor(qualifiedCount / INVITE_REWARD_REQUIRED_COUNT)
|
|
||||||
const missingGrantCount = shouldGrantCount - rewardGrantCount
|
|
||||||
|
|
||||||
if (missingGrantCount <= 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < missingGrantCount; index += 1) {
|
|
||||||
const targetQualifiedCount = (rewardGrantCount + index + 1) * INVITE_REWARD_REQUIRED_COUNT
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
const membership = await tx.membership.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: inviterId,
|
|
||||||
status: MembershipStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
orderBy: [{ expireDate: 'desc' }, { createdAt: 'desc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new BadRequestException('邀请人当前没有有效会员卡,无法发放奖励')
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.membership.update({
|
|
||||||
where: { id: membership.id },
|
|
||||||
data: {
|
|
||||||
remainingTimes: membership.remainingTimes === null
|
|
||||||
? null
|
|
||||||
: membership.remainingTimes + INVITE_REWARD_TIMES,
|
|
||||||
status: MembershipStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.inviteRewardGrant.create({
|
|
||||||
data: {
|
|
||||||
inviterId,
|
|
||||||
membershipId: membership.id,
|
|
||||||
qualifiedReferralCount: targetQualifiedCount,
|
|
||||||
rewardTimes: INVITE_REWARD_TIMES,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const mockUser = {
|
|||||||
const mockInviteService = {
|
const mockInviteService = {
|
||||||
validateInviterForTrialOrder: jest.fn(),
|
validateInviterForTrialOrder: jest.fn(),
|
||||||
recordTrialOrderPaid: jest.fn(),
|
recordTrialOrderPaid: jest.fn(),
|
||||||
|
rewardPaidOrder: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||||
@@ -69,6 +70,7 @@ const mockPaymentParams = {
|
|||||||
|
|
||||||
function buildPrismaMock() {
|
function buildPrismaMock() {
|
||||||
return {
|
return {
|
||||||
|
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
cardType: {
|
cardType: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -76,6 +78,7 @@ function buildPrismaMock() {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -131,6 +134,16 @@ describe('PaymentService', () => {
|
|||||||
// ─── createOrder ────────────────────────────────────────────────────────────
|
// ─── createOrder ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createOrder', () => {
|
describe('createOrder', () => {
|
||||||
|
it.each(['TRIAL', 'TIMES', 'DURATION'])('applies 95 percent server-side to %s and snapshots attribution', async (type) => {
|
||||||
|
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type, price: new Decimal(999) })
|
||||||
|
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
|
||||||
|
prisma.order.create.mockResolvedValue(buildMockOrder({ amount: new Decimal(949) }))
|
||||||
|
await service.createOrder(mockUser.id, mockCardType.id)
|
||||||
|
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: 949, inviteInviterId: 'friend', purchasedCategory: type }) })
|
||||||
|
expect(wechat.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: 949 }))
|
||||||
|
})
|
||||||
|
|
||||||
it('creates a PENDING order with correct amount and formatted orderNo', async () => {
|
it('creates a PENDING order with correct amount and formatted orderNo', async () => {
|
||||||
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
||||||
prisma.user.findUnique.mockResolvedValue(mockUser)
|
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||||
@@ -146,7 +159,7 @@ describe('PaymentService', () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
userId: mockUser.id,
|
userId: mockUser.id,
|
||||||
cardTypeId: mockCardType.id,
|
cardTypeId: mockCardType.id,
|
||||||
amount: mockCardType.price,
|
amount: Number(mockCardType.price),
|
||||||
status: OrderStatus.PENDING,
|
status: OrderStatus.PENDING,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -246,6 +259,18 @@ describe('PaymentService', () => {
|
|||||||
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not grant membership or referral rewards when another callback claimed the order', async () => {
|
||||||
|
prisma.order.updateMany.mockResolvedValue({ count: 0 })
|
||||||
|
await service.handleWxNotify(headers, successBody)
|
||||||
|
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||||
|
expect(mockInviteService.rewardPaidOrder).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates reward issuance failure out of the payment transaction', async () => {
|
||||||
|
mockInviteService.rewardPaidOrder.mockRejectedValueOnce(new Error('reward failed'))
|
||||||
|
await expect(service.handleWxNotify(headers, successBody)).rejects.toThrow('reward failed')
|
||||||
|
})
|
||||||
|
|
||||||
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
||||||
const result = await service.handleWxNotify(headers, successBody)
|
const result = await service.handleWxNotify(headers, successBody)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export class PaymentService {
|
|||||||
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
|
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||||
|
const amount = referral ? Math.round(Number(cardType.price) * 95 / 100) : Number(cardType.price)
|
||||||
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||||
|
|
||||||
const order = await this.prisma.order.create({
|
const order = await this.prisma.order.create({
|
||||||
@@ -62,14 +64,16 @@ export class PaymentService {
|
|||||||
userId,
|
userId,
|
||||||
cardTypeId,
|
cardTypeId,
|
||||||
orderNo,
|
orderNo,
|
||||||
amount: cardType.price,
|
amount,
|
||||||
|
inviteInviterId: referral?.inviterId,
|
||||||
|
purchasedCategory: cardType.type,
|
||||||
status: OrderStatus.PENDING,
|
status: OrderStatus.PENDING,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const paymentParams = await this.wechatPayService.createUnifiedOrder({
|
const paymentParams = await this.wechatPayService.createUnifiedOrder({
|
||||||
orderNo,
|
orderNo,
|
||||||
amount: Number(cardType.price),
|
amount,
|
||||||
openid: user.openid,
|
openid: user.openid,
|
||||||
description: cardType.name,
|
description: cardType.name,
|
||||||
})
|
})
|
||||||
@@ -122,6 +126,11 @@ export class PaymentService {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.order.updateMany({
|
||||||
|
where: { id: existingOrder.id, status: OrderStatus.PENDING },
|
||||||
|
data: { status: OrderStatus.PAID },
|
||||||
|
})
|
||||||
|
if (!claimed.count) return
|
||||||
const membership = await this.membershipService.grantPurchasedCard(
|
const membership = await this.membershipService.grantPurchasedCard(
|
||||||
tx,
|
tx,
|
||||||
existingOrder.userId,
|
existingOrder.userId,
|
||||||
@@ -138,6 +147,7 @@ export class PaymentService {
|
|||||||
membershipId: membership.id,
|
membershipId: membership.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing'
|
import { Test, TestingModule } from '@nestjs/testing'
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common'
|
import { BadRequestException, NotFoundException } from '@nestjs/common'
|
||||||
import { UserService } from '../user.service'
|
import { UserService } from '../user.service'
|
||||||
|
import { UserController } from '../user.controller'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import {
|
import {
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
@@ -457,6 +458,24 @@ describe('UserService', () => {
|
|||||||
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
|
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('member filters', () => {
|
||||||
|
it.each([
|
||||||
|
['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
|
||||||
|
['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
|
||||||
|
['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
|
||||||
|
['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
|
||||||
|
['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
|
||||||
|
[undefined, {}],
|
||||||
|
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValue([])
|
||||||
|
mockPrisma.user.count.mockResolvedValue(0)
|
||||||
|
const controller = new UserController(service)
|
||||||
|
await controller.getMembers('2', '20', undefined, filter as string | undefined)
|
||||||
|
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 }))
|
||||||
|
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('getMemberDetail', () => {
|
describe('getMemberDetail', () => {
|
||||||
const cardType = {
|
const cardType = {
|
||||||
id: 'ct-1',
|
id: 'ct-1',
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class UserController {
|
|||||||
@Query('cardType') cardType?: string,
|
@Query('cardType') cardType?: string,
|
||||||
) {
|
) {
|
||||||
const validCardType =
|
const validCardType =
|
||||||
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE')
|
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE' || cardType === 'ACTIVE')
|
||||||
? cardType
|
? cardType
|
||||||
: undefined
|
: undefined
|
||||||
return this.userService.getMembers(
|
return this.userService.getMembers(
|
||||||
|
|||||||
@@ -382,9 +382,11 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
// cardType filter: NONE = no active membership, otherwise filter by card type category
|
// ACTIVE and NONE are complementary membership-status filters.
|
||||||
if (cardType === 'NONE') {
|
if (cardType === 'NONE') {
|
||||||
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
|
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
|
||||||
|
} else if (cardType === 'ACTIVE') {
|
||||||
|
where.memberships = { some: { status: MembershipStatus.ACTIVE } }
|
||||||
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
|
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
|
||||||
where.memberships = {
|
where.memberships = {
|
||||||
some: {
|
some: {
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export type {
|
|||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
|
TeachingAnalytics,
|
||||||
|
TeachingAnalyticsRecord,
|
||||||
|
TeachingAnalyticsSummary,
|
||||||
User,
|
User,
|
||||||
UserProfileResponse,
|
UserProfileResponse,
|
||||||
UpdateProfileDto,
|
UpdateProfileDto,
|
||||||
|
|||||||
@@ -59,3 +59,5 @@ export type {
|
|||||||
InviteActivitySummary,
|
InviteActivitySummary,
|
||||||
} from './invite'
|
} from './invite'
|
||||||
export { FlashSalePhase } from './flash-sale'
|
export { FlashSalePhase } from './flash-sale'
|
||||||
|
|
||||||
|
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
|
||||||
|
|||||||
31
packages/shared/src/types/teaching-analytics.ts
Normal file
31
packages/shared/src/types/teaching-analytics.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { BookingStatus } from '../enums'
|
||||||
|
|
||||||
|
export interface TeachingAnalyticsRecord {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
nickname: string
|
||||||
|
slotId: string
|
||||||
|
date: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
cardName: string
|
||||||
|
status: BookingStatus
|
||||||
|
needsReview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeachingAnalyticsSummary {
|
||||||
|
sessions: number
|
||||||
|
attendances: number
|
||||||
|
students: number
|
||||||
|
minutes: number
|
||||||
|
teachingDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeachingAnalytics {
|
||||||
|
month: string
|
||||||
|
generatedAt: string
|
||||||
|
summary: TeachingAnalyticsSummary
|
||||||
|
previousMonth: string
|
||||||
|
previous: TeachingAnalyticsSummary
|
||||||
|
records: TeachingAnalyticsRecord[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user