Compare commits
27 Commits
v0.0.1
...
richarjian
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d91282ecbd | ||
|
|
ab6602e41d | ||
|
|
b8c0dd6781 | ||
|
|
806f3ee770 | ||
|
|
9af558286b | ||
|
|
99814f3720 | ||
|
|
014685ed04 | ||
|
|
9f5608f13c | ||
|
|
3e049d2c1d | ||
|
|
d32f592e54 | ||
|
|
d793749134 | ||
|
|
57edd8dcc0 | ||
|
|
51dea488f6 | ||
|
|
22407a7ff9 | ||
|
|
d941f1b6a9 | ||
|
|
6fab1155c7 | ||
|
|
139882d7a1 | ||
|
|
c3e46f7ffa | ||
|
|
75ef5e94a6 | ||
|
|
57107c02dc | ||
|
|
726c65f0f0 | ||
|
|
6e8fc45138 | ||
|
|
fcd9531b3c | ||
|
|
f4cee127ce | ||
|
|
99c5c211ad | ||
|
|
30ebc1c344 | ||
|
|
5a4d2c7a1b |
15
.deploy-tmp/check-fk.js
Normal file
15
.deploy-tmp/check-fk.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// One-off diagnostic — list FK constraints on orders.flash_sale_id
|
||||||
|
// Run from packages/server so .env is loaded.
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
;(async () => {
|
||||||
|
const rows = await p.$queryRawUnsafe(`
|
||||||
|
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ('orders', 'flash_sales', 'flash_sale_orders')
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
`)
|
||||||
|
console.log('FK rows:', JSON.stringify(rows, null, 2))
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
33
.deploy-tmp/resolve-migration.js
Normal file
33
.deploy-tmp/resolve-migration.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Mark the failed drop_flash_sale migration as rolled_back so deploy can retry.
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
const dump = (x) => JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2)
|
||||||
|
;(async () => {
|
||||||
|
const before = await p.$queryRawUnsafe(
|
||||||
|
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = ?`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('before:', dump(before))
|
||||||
|
|
||||||
|
const result = await p.$executeRawUnsafe(
|
||||||
|
`UPDATE _prisma_migrations
|
||||||
|
SET rolled_back_at = NOW()
|
||||||
|
WHERE migration_name = ?
|
||||||
|
AND finished_at IS NULL
|
||||||
|
AND rolled_back_at IS NULL`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('rows updated:', result)
|
||||||
|
|
||||||
|
const after = await p.$queryRawUnsafe(
|
||||||
|
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = ?`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('after:', dump(after))
|
||||||
|
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
27
.deploy-tmp/verify-migration.js
Normal file
27
.deploy-tmp/verify-migration.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Verify migration result on production
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
;(async () => {
|
||||||
|
const tables = await p.$queryRawUnsafe(`
|
||||||
|
SELECT TABLE_NAME
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME LIKE '%flash%'
|
||||||
|
`)
|
||||||
|
const ordersCol = await p.$queryRawUnsafe(`
|
||||||
|
SELECT COLUMN_NAME
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'orders'
|
||||||
|
AND COLUMN_NAME = 'flash_sale_id'
|
||||||
|
`)
|
||||||
|
const mig = await p.$queryRawUnsafe(`
|
||||||
|
SELECT migration_name, finished_at, rolled_back_at
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = '20260910030338_drop_flash_sale'
|
||||||
|
`)
|
||||||
|
console.log('flash tables:', tables)
|
||||||
|
console.log('orders.flash_sale_id col:', ordersCol)
|
||||||
|
console.log('migration row:', mig)
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
33
CLAUDE.md
33
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,32 @@ 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。
|
||||||
|
|
||||||
|
### 个人中心会员卡包
|
||||||
|
- 个人中心资料区域以单行展示持有卡种和张数,下方每张卡以单行浅色进度槽承载卡名和用量文字,不使用大卡片或轮播;点击进入我的会员卡。`OwnedMembershipCard.vue` 在我的会员卡页面展示余额、已用进度和到期日。
|
||||||
|
- 累计上课、本月上课、剩余课时集中在我的会员卡页面,个人资料卡不重复展示汇总。
|
||||||
|
- 次数限制以 remainingTimes 是否为 null 判断,不能按卡种推断。次数进度表示已用占总次数,已用包括预约占用;不限次卡不伪造耗课数。
|
||||||
|
- 会员卡加载失败显示重试,不当作无卡;会话变化时丢弃旧请求结果。
|
||||||
|
|
||||||
|
### 课后评价与成长档案
|
||||||
|
- 评价归属 booking 模块,档案归属 user 模块;共享契约放 shared/src/types/member-care.ts,页面沿用 booking/profile/admin 目录,不新增顶层业务目录。
|
||||||
|
- COMPLETED 后立即可评价,无 24 小时截止;完成后 24 小时仅提醒未评价预约。唯一 bookingId 防重复,提醒状态保存在 Booking 上,定时任务原子领取,未知发送结果不自动重发。
|
||||||
|
- 星级均分与 NPS 分开:NPS 仅使用可选 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||||
|
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0;累计课时包含有效补录,里程碑为 10/30/50 节。
|
||||||
|
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL,读取用短时签名,禁止落库公共链接;上传凭证绑定学员及照片记录。
|
||||||
|
- 新增迁移目录只放 migration.sql,测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
|
||||||
|
|
||||||
|
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API,避免主包引用 admin 分包 Store;馆主评价页面仍通过 admin Store 访问。
|
||||||
|
|
||||||
|
### 个人身体画像
|
||||||
|
- 线上获客归属独立 `body-portrait` 模块,不是成长档案的一个页面。共享契约在 `packages/shared/src/types/body-portrait.ts`,规格在 `docs/body-portrait.md`。
|
||||||
|
- 评分只在服务端规则引擎计算;匿名测评用访问令牌哈希,不用可枚举 ID 做权限。
|
||||||
|
- 安全分流不计分。完整报告需登录认领,体验预约需手机号。现有 TRIAL 体验卡承接转化。
|
||||||
|
- 馆主「今日经营助手」只展示待办,不做成通用 CRM。主包不得引用 admin store。
|
||||||
|
|||||||
118
docs/body-portrait.md
Normal file
118
docs/body-portrait.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 个人身体画像增长系统
|
||||||
|
|
||||||
|
入口:小程序「3 分钟身体状态评估」、报告页、体验预约,以及管理中心「今日经营助手」。线上获客归属独立 `body-portrait` 模块;线下专业评估与改善计划复用成长档案口径,不把问卷结果写成医疗诊断。
|
||||||
|
|
||||||
|
## 产品口径
|
||||||
|
|
||||||
|
- 对外名称:**3 分钟身体状态评估 / 个人身体画像**。只使用「关注度、可能表现、建议进一步评估」。禁止「诊断、疾病检测、患病风险、准确率」。
|
||||||
|
- 分数 0~100 只表示**问卷关注度**,不表示身体能力、疼痛程度或患病概率。
|
||||||
|
- 陌生用户可匿名完成问卷。完成后先看摘要;微信登录并认领后看完整报告。点击体验预约时必须授权手机号。
|
||||||
|
- 安全分流不计分。命中持续明显疼痛、近期受伤、手脚明显麻木、医生限制运动或医疗恢复期时,不展示动作建议和强销售 CTA。
|
||||||
|
- 评分只在服务端用版本化规则引擎计算。客户端不得提交分数、画像类型或线索阶段。LLM 不决定结论。
|
||||||
|
- 现有 `TRIAL` 体验卡就是「评估 + 普拉提体验」。画像只补来源关联,不新增商品类型。
|
||||||
|
- 成长照片沿用私有 COS、短时签名和学员授权。馆主可见不等于可公开分享。
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
各入口固定文案:
|
||||||
|
|
||||||
|
> 本评估不是医疗诊断或体态疾病检测,仅用于运动训练参考。若你正处于明显疼痛、伤病或医生限制运动的阶段,请先由医生或相应专业人士确认运动条件。
|
||||||
|
|
||||||
|
保存说明:匿名未认领草稿默认 30 天后清理。认领后的健康相关数据仅用于训练评估与跟进;学员可申请删除,审计事件保留必要边界。
|
||||||
|
|
||||||
|
## 问卷版本 `portrait-q-v1`
|
||||||
|
|
||||||
|
每屏一个决策,可多选的题目明确标注。不询问「骨盆前倾」等专业病名。
|
||||||
|
|
||||||
|
| 步骤 | ID | 题干 | 类型 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `concerns` | 最近身体哪里最困扰你? | 人体图多选 |
|
||||||
|
| 2 | `goal` | 如果训练有效果,你最希望看到什么变化? | 单选 |
|
||||||
|
| 3 | `sittingHours` | 每天坐多久? | 单选 |
|
||||||
|
| 4 | `exerciseFreq` | 平时运动频率? | 单选 |
|
||||||
|
| 5 | `workPosture` | 工作时最常见状态? | 单选 |
|
||||||
|
| 6 | `endOfDayFatigue` | 一天结束后,身体哪里最累? | 人体图多选 |
|
||||||
|
| 7 | `afterSitting` | 长时间坐着后,你会出现? | 多选 |
|
||||||
|
| 8 | `standingNotice` | 自然站立时,你有没有注意过? | 多选 |
|
||||||
|
| 9 | `safety` | 最近是否存在以下情况? | 多选,不计分 |
|
||||||
|
|
||||||
|
部位:头颈 `neck`、肩 `shoulder`、上背 `upperBack`、腰 `lowBack`、骨盆 `pelvis`、髋 `hip`、膝 `knee`、腿 `leg`。
|
||||||
|
|
||||||
|
目标:看起来更挺拔 `posture`、肩颈没那么累 `neckRelief`、久坐更舒服 `backComfort`、腹部更有力量 `core`、身体线条更好 `shaping`、腿型/髋部状态改善 `hipLeg`、动作更稳定 `stability`、我有自己的目标 `other`。
|
||||||
|
|
||||||
|
## 五维与关注度
|
||||||
|
|
||||||
|
维度:肩颈状态 `cervicalShoulder`、脊柱活动 `spinalMobility`、核心控制 `coreControl`、髋骨盆状态 `hipPelvis`、下肢稳定 `lowerLimb`。
|
||||||
|
|
||||||
|
| 分数 | 关注度 |
|
||||||
|
| ---: | --- |
|
||||||
|
| 0~30 | 低关注 |
|
||||||
|
| 31~50 | 轻度关注 |
|
||||||
|
| 51~70 | 中度关注 |
|
||||||
|
| 71~85 | 高关注 |
|
||||||
|
| 86~100 | 重点关注 |
|
||||||
|
|
||||||
|
规则版本 `portrait-r-v1`:先按答案累加,再把每一维 clamp 到 0~100。安全选项不加分。
|
||||||
|
|
||||||
|
## 画像模板(8 种)
|
||||||
|
|
||||||
|
主类型 1 个,次类型最多 1 个。
|
||||||
|
|
||||||
|
| ID | 标签 |
|
||||||
|
| --- | --- |
|
||||||
|
| `sedentaryNeckTension` | 久坐肩颈紧张型 |
|
||||||
|
| `sedentaryCoreWeak` | 久坐核心弱化型 |
|
||||||
|
| `neckCompensation` | 肩颈代偿型 |
|
||||||
|
| `hipTight` | 髋部紧张型 |
|
||||||
|
| `upperLowerCross` | 上下交叉混合型 |
|
||||||
|
| `lowerLimbUnstable` | 下肢稳定不足型 |
|
||||||
|
| `mixedFatigue` | 全身疲劳累积型 |
|
||||||
|
| `lowOverallConcern` | 整体关注度较低型 |
|
||||||
|
|
||||||
|
## 报告结构
|
||||||
|
|
||||||
|
1. 一句话结论 + 主/次类型 + 问卷依据(充分/一般,不是准确率)
|
||||||
|
2. 最多 3 个优先关注点,必须引用用户原话/选项
|
||||||
|
3. 问题之间的关联(例如久坐 → 胸椎活动减少 → 肩颈承担更多)
|
||||||
|
4. 三条日常建议;安全分流不下发动作。动作建议含停止条件
|
||||||
|
5. 线上局限 → 到店评估项目 → 现有体验卡
|
||||||
|
|
||||||
|
匹配度只展示「问卷依据:充分 / 一般」,由有效回答数和证据条数计算。
|
||||||
|
|
||||||
|
## 漏斗事件
|
||||||
|
|
||||||
|
服务端事实:`assessment_started` `assessment_completed` `assessment_claimed` `phone_bound` `trial_purchased` `trial_booked` `trial_attended` `plan_purchased`。
|
||||||
|
|
||||||
|
客户端可报但需幂等:`report_viewed` `advice_viewed` `trial_clicked` `report_shared`。
|
||||||
|
|
||||||
|
`trial_attended` 只由体验卡预约被核销(COMPLETED)产生,不等于自动完成,也不含补录。
|
||||||
|
|
||||||
|
渠道:`xhs` `wechat` `dianping` `member_share` `organic` `other`。分享链接只用邀请码/活动码,不暴露内部用户 ID。
|
||||||
|
|
||||||
|
## 线索阶段
|
||||||
|
|
||||||
|
只允许前进:`VISIT` → `STARTED` → `COMPLETED` → `CLAIMED` → `PHONE_BOUND` → `TRIAL_PURCHASED` → `TRIAL_BOOKED` → `TRIAL_ATTENDED` → `PLAN_PURCHASED` → `TRAINING` → `RENEWAL_DUE` → `RENEWED`。馆主可记跟进备注,不能把阶段任意倒退覆盖历史。
|
||||||
|
|
||||||
|
## 线下评估与改善计划(V2)
|
||||||
|
|
||||||
|
到店 60 分钟:沟通目标 → 静态体态 → 活动度 → 动作控制 → 普拉提体验 → 解释结果。协议版本 `offline-v1`。operatorId 不是授课老师。
|
||||||
|
|
||||||
|
12 周计划三阶段:1–4 重新建立控制,5–8 改善活动能力,9–12 整合身体动作。卖的是改善计划,不是「20 节课」。
|
||||||
|
|
||||||
|
## 复测与分享(V3)
|
||||||
|
|
||||||
|
默认可在第 4 / 8 / 12 节生成复测待办。对比必须同协议、同观察项。成长卡片默认不含照片;公开导出需学员单次确认。分享落地进入 `source=member_share` 的新测评。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
新增表见 `packages/server/prisma/migrations/20260911180000_body_portrait/migration.sql`。发布顺序:`prisma migrate deploy` → 后端 → 小程序。回退先回退应用,表可保留。
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- 匿名可完成、可续填;登录后同一会话只能认领一次。
|
||||||
|
- 完整报告需登录;体验预约需手机号。
|
||||||
|
- 安全分流不计分、无动作建议、无强销售。
|
||||||
|
- 分数与画像只来自服务端快照。
|
||||||
|
- 馆主今日待办能看到「测完未约 / 体验后未购 / 待复测」。
|
||||||
|
- 漏斗按渠道聚合;补录不进入获客转化。
|
||||||
|
- 照片与分享遵守成长档案隐私规则。
|
||||||
41
docs/flash-sale-removal.md
Normal file
41
docs/flash-sale-removal.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 移除秒杀功能
|
||||||
|
|
||||||
|
按业务调整下线限时秒杀活动。前后端代码、共享类型、Prisma schema 与迁移均已同步清理。
|
||||||
|
|
||||||
|
## 清理范围
|
||||||
|
|
||||||
|
| 层 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端模块 | `packages/server/src/flash-sale/`(service、controller、admin controller、dto、测试) |
|
||||||
|
| 调度任务 | `SchedulerService.handleExpireFlashSaleReservations` 及其依赖注入 |
|
||||||
|
| 支付流程 | `PaymentService` 中 `flashSaleOrder` 标记为 PAID 的分支 |
|
||||||
|
| 前端页面 | `pages/flash-sale/detail.vue`、`pages/admin/flash-sales.vue` |
|
||||||
|
| 前端组件 | `components/FlashSaleSection.vue` |
|
||||||
|
| 前端状态 | `stores/flash-sale.ts` |
|
||||||
|
| 前端入口 | `pages/admin/index.vue` 秒杀管理菜单与对应样式 |
|
||||||
|
| 首页 | `pages/home/index.vue` 中 `FlashSaleSection` 引用 |
|
||||||
|
| 共享类型 | `shared/src/types/flash-sale.ts`,`enums.ts` 中 `FlashSaleStatus`、`FlashSaleOrderStatus`,以及 `index.ts` 的 re-export |
|
||||||
|
| 数据库 | Prisma 删除 `FlashSale`、`FlashSaleOrder` 模型、相关枚举与 `Order.flash_sale_id` 字段 |
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
新增 `packages/server/prisma/migrations/20260910030338_drop_flash_sale/migration.sql`。
|
||||||
|
执行 `pnpm prisma migrate deploy` 后以下内容会被移除:
|
||||||
|
|
||||||
|
- `flash_sales`、`flash_sale_orders` 两张表
|
||||||
|
- `orders.flash_sale_id` 列与外键
|
||||||
|
- `FlashSaleStatus`、`FlashSaleOrderStatus` 枚举
|
||||||
|
|
||||||
|
迁移以 MySQL 方言编写;SQLite 开发环境由 Prisma shadow database 自动重建。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build:shared
|
||||||
|
pnpm build:server
|
||||||
|
pnpm test # 包含 payment / scheduler 套件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 回退说明
|
||||||
|
|
||||||
|
回退需要还原 schema 与源码,本项目不保留 git 自动恢复外的额外兜底。如需重新启用秒杀,按 git 历史恢复即可,并运行 `pnpm prisma migrate reset` 重新初始化数据库。
|
||||||
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`。
|
||||||
|
- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。
|
||||||
41
docs/member-care.md
Normal file
41
docs/member-care.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 课后评价与成长档案
|
||||||
|
|
||||||
|
入口:预约详情评价、个人中心「我的成长档案」、管理中心「课后评价」与会员档案「成长档案」。评价归属 booking 模块,档案归属 user 模块。共享契约在 `packages/shared/src/types/member-care.ts`。
|
||||||
|
|
||||||
|
## 产品口径
|
||||||
|
|
||||||
|
- 课程变为 COMPLETED 后即可评价,没有 24 小时截止。完成后 24 小时只提醒仍未评价的预约。
|
||||||
|
- 同一预约只能评价一次。提醒状态写在 Booking 上,定时任务原子领取;发送结果未知或失败不自动重发。
|
||||||
|
- 星级均分与 NPS 分开。NPS 只用可选的 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||||
|
- 教练私密笔记只在服务端过滤。课程批注必须属于该学员的已完成预约。
|
||||||
|
- 体测允许缺项,缺项不按 0 计算。累计课时含有效补录,里程碑为 10 / 30 / 50 节。
|
||||||
|
- 成长照片仅用于学员与馆主之间的档案,不用于公开宣传。学员本人授权或撤回,馆主不能代授权。误传或学员主动删除会同时删除数据库记录和 COS 对象。
|
||||||
|
|
||||||
|
## 部署配置
|
||||||
|
|
||||||
|
新增环境变量(见 `packages/server/.env.example`):
|
||||||
|
|
||||||
|
| 变量 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW` | 课后评价订阅消息模板 ID |
|
||||||
|
|
||||||
|
成长照片与馆图共用 `COS_BUCKET`、`COS_SECRET_ID`、`COS_SECRET_KEY`、`COS_REGION`。对象写在 `progress/{userId}/` 下,上传带私有 ACL,读取用约 60 秒签名 URL,不落库公共链接。小程序合法域名沿用现有 COS 域名即可。
|
||||||
|
|
||||||
|
评价提醒模板字段由服务端按预约填充:`thing1` 课程名称(工作室名)、`thing2` 课程教练(Iris)、`time3` 课程时间、`thing4` 温馨提示。未配置模板 ID 时定时任务不领取预约。
|
||||||
|
|
||||||
|
回退:先回退应用代码。新增表可保留;`progress/` 下对象不会被旧代码读取。不要在生产直接 `DROP TABLE`。
|
||||||
|
|
||||||
|
## 迁移
|
||||||
|
|
||||||
|
目录 `packages/server/prisma/migrations/20260909120000_member_care/migration.sql`。发布时先 `prisma migrate deploy`,再发布后端,最后发布小程序。
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- 学员在 CONFIRMED 或 COMPLETED 且未评价时都可以订阅提醒;核销后仍能订阅。
|
||||||
|
- 完成后可立即评价;重复提交返回已评价。
|
||||||
|
- 首页匿名均分不含评论文案;管理端趋势按中国自然月,NPS 与星级分开展示。
|
||||||
|
- 私密笔记学员不可见;馆主在学员授权前不能读取照片,授权后可看,撤回后不能再签发。
|
||||||
|
- 误传照片可删除,删除后档案和 COS 对象都不再保留。
|
||||||
|
- 体测可只填一项,柔韧度允许负值;累计课时含未撤销补录。
|
||||||
|
- 未配置评价模板时,定时任务不领取预约。
|
||||||
|
- 小程序真机确认可上传、可用签名链接预览成长照片。
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
45
packages/app/src/components/BodySilhouette.vue
Normal file
45
packages/app/src/components/BodySilhouette.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<view class="sil">
|
||||||
|
<text class="hint">点选身体区域,可多选</text>
|
||||||
|
<view class="figure">
|
||||||
|
<view class="head zone" :class="{ on: selected.includes(BodyRegion.NECK) }" @tap="toggle(BodyRegion.NECK)">头颈</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="shoulder zone" :class="{ on: selected.includes(BodyRegion.SHOULDER) }" @tap="toggle(BodyRegion.SHOULDER)">肩</view>
|
||||||
|
</view>
|
||||||
|
<view class="torso zone" :class="{ on: selected.includes(BodyRegion.UPPER_BACK) }" @tap="toggle(BodyRegion.UPPER_BACK)">上背</view>
|
||||||
|
<view class="waist zone" :class="{ on: selected.includes(BodyRegion.LOW_BACK) }" @tap="toggle(BodyRegion.LOW_BACK)">腰</view>
|
||||||
|
<view class="pelvis zone" :class="{ on: selected.includes(BodyRegion.PELVIS) }" @tap="toggle(BodyRegion.PELVIS)">骨盆</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="hip zone" :class="{ on: selected.includes(BodyRegion.HIP) }" @tap="toggle(BodyRegion.HIP)">髋</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="knee zone" :class="{ on: selected.includes(BodyRegion.KNEE) }" @tap="toggle(BodyRegion.KNEE)">膝</view>
|
||||||
|
</view>
|
||||||
|
<view class="leg zone" :class="{ on: selected.includes(BodyRegion.LEG) }" @tap="toggle(BodyRegion.LEG)">腿</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BodyRegion } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const props = defineProps<{ selected: readonly string[] }>()
|
||||||
|
const emit = defineEmits<{ (e: 'change', value: BodyRegion[]): void }>()
|
||||||
|
|
||||||
|
function toggle(region: BodyRegion) {
|
||||||
|
const current = new Set(props.selected)
|
||||||
|
if (current.has(region)) current.delete(region)
|
||||||
|
else current.add(region)
|
||||||
|
emit('change', [...current] as BodyRegion[])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sil { padding: 12rpx 0 8rpx; }
|
||||||
|
.hint { display: block; text-align: center; color: #8a7b6e; font-size: 22rpx; margin-bottom: 16rpx; }
|
||||||
|
.figure { display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
||||||
|
.zone { min-width: 160rpx; padding: 16rpx 28rpx; border-radius: 999rpx; background: #f3eee8; color: #6b5c50; font-size: 24rpx; text-align: center; }
|
||||||
|
.zone.on { background: #6b8276; color: #fff; }
|
||||||
|
.head { min-width: 120rpx; }
|
||||||
|
.row { display: flex; gap: 12rpx; }
|
||||||
|
</style>
|
||||||
@@ -96,6 +96,11 @@
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Subscribe note -->
|
||||||
|
<view class="subscribe-tip">
|
||||||
|
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果、课前1小时提醒与取消通知</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<view class="action-row">
|
<view class="action-row">
|
||||||
<view class="btn-outline" @tap="handleCancel">
|
<view class="btn-outline" @tap="handleCancel">
|
||||||
@@ -159,9 +164,7 @@ async function handleConfirm() {
|
|||||||
try {
|
try {
|
||||||
await requestBookingCreatedSubscriptionMessage()
|
await requestBookingCreatedSubscriptionMessage()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '订阅消息授权失败'
|
console.warn('[subscribe] booking confirm failed', err)
|
||||||
uni.showToast({ title: message, icon: 'none' })
|
|
||||||
return
|
|
||||||
} finally {
|
} finally {
|
||||||
requestingSubscribe.value = false
|
requestingSubscribe.value = false
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,8 @@ function handleMaskTap() {
|
|||||||
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
||||||
.deduction-tip { padding: 18rpx 4rpx; }
|
.deduction-tip { padding: 18rpx 4rpx; }
|
||||||
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
||||||
|
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
|
||||||
|
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
|
||||||
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
||||||
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
||||||
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
76
packages/app/src/components/ClassReviewForm.vue
Normal file
76
packages/app/src/components/ClassReviewForm.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<view class="review">
|
||||||
|
<text class="eyebrow">课后 · 留一点感受</text>
|
||||||
|
<text class="title">这节课,感觉怎么样?</text>
|
||||||
|
<text class="hint">文字与标签仅你和馆主可见;星级会计入首页匿名均分。</text>
|
||||||
|
<view v-if="loading" class="hint">正在读取评价…</view>
|
||||||
|
<view v-else-if="error" class="hint">{{ error }}<button class="secondary" @tap="load">重新加载</button></view>
|
||||||
|
<template v-else-if="review">
|
||||||
|
<text class="saved-stars">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</text>
|
||||||
|
<view class="tags"><text v-for="tag in review.tags" :key="tag" class="tag selected">{{ tag }}</text></view>
|
||||||
|
<text class="comment">{{ review.comment || '谢谢你留下这份反馈。' }}</text>
|
||||||
|
<text class="hint">已评价 · {{ formatChinaDate(review.createdAt) }}</text>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="canReview">
|
||||||
|
<view class="stars"><button v-for="n in 5" :key="n" :aria-label="n + ' 星'" :class="{ chosen: rating >= n }" @tap="rating = n">{{ rating >= n ? '★' : '☆' }}</button></view>
|
||||||
|
<text class="rating-label">{{ rating ? labels[rating - 1] : '轻触星星,为这节课评分' }}</text>
|
||||||
|
<view class="tags"><button v-for="tag in REVIEW_TAGS" :key="tag" class="tag" :class="{ selected: tags.includes(tag) }" @tap="toggle(tag)">{{ tag }}</button></view>
|
||||||
|
<text class="hint">可选,最多 3 个标签</text>
|
||||||
|
<textarea v-model="comment" maxlength="200" placeholder="哪里让你有收获?还有什么可以做得更好?" />
|
||||||
|
<text class="counter">{{ comment.length }} / 200</text>
|
||||||
|
<picker :range="recommendations" @change="recommendation = Number($event.detail.value) - 1"><view class="recommend">你有多愿意推荐我们?<text>{{ recommendation < 0 ? '选填 ›' : recommendation + ' / 10 ›' }}</text></view></picker>
|
||||||
|
<text class="hint">0 表示完全不愿意,10 表示非常愿意</text>
|
||||||
|
<button class="primary" :loading="saving" :disabled="saving || !rating" @tap="submit">提交评价</button>
|
||||||
|
</template>
|
||||||
|
<text v-else class="hint">课程完成后即可评价。</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { REVIEW_TAGS } from '@mp-pilates/shared'
|
||||||
|
import type { ClassReview } from '@mp-pilates/shared'
|
||||||
|
import { get, post } from '../utils/request'
|
||||||
|
import { formatChinaDate } from '../utils/format'
|
||||||
|
const props = defineProps<{ bookingId: string }>()
|
||||||
|
const review = ref<ClassReview | null>(null), canReview = ref(false), loading = ref(false), saving = ref(false), error = ref('')
|
||||||
|
const rating = ref(0), tags = ref<string[]>([]), comment = ref(''), recommendation = ref(-1)
|
||||||
|
const labels = ['不太满意', '有待改善', '整体还好', '很满意', '非常满意']
|
||||||
|
const recommendations = ['暂不填写', ...Array.from({ length: 11 }, (_, n) => String(n))]
|
||||||
|
let sequence = 0
|
||||||
|
async function load() {
|
||||||
|
const seq = ++sequence; loading.value = true; error.value = ''
|
||||||
|
try { const result = await get<{ review: ClassReview | null; canReview: boolean }>(`/booking/${props.bookingId}/review`); if (seq === sequence) { review.value = result.review; canReview.value = result.canReview } }
|
||||||
|
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '评价加载失败' }
|
||||||
|
finally { if (seq === sequence) loading.value = false }
|
||||||
|
}
|
||||||
|
function toggle(tag: string) {
|
||||||
|
if (tags.value.includes(tag)) tags.value = tags.value.filter(t => t !== tag)
|
||||||
|
else if (tags.value.length < 3) tags.value = [...tags.value, tag]
|
||||||
|
else uni.showToast({ title: '最多选择 3 个标签', icon: 'none' })
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
if (saving.value || !rating.value) return
|
||||||
|
saving.value = true
|
||||||
|
try { review.value = await post<ClassReview>(`/booking/${props.bookingId}/review`, { rating: rating.value, tags: tags.value, comment: comment.value, ...(recommendation.value >= 0 ? { recommendation: recommendation.value } : {}) }); uni.showToast({ title: '谢谢你的反馈', icon: 'success' }) }
|
||||||
|
catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '提交失败,请重试', icon: 'none' }) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
watch(() => props.bookingId, () => { review.value = null; rating.value = 0; tags.value = []; comment.value = ''; recommendation.value = -1; void load() }, { immediate: true })
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review { margin: 28rpx 32rpx; padding: 32rpx; border: 1rpx solid #e7e0d7; border-radius: 28rpx; background: #fffdf9; color: #514943; }
|
||||||
|
.eyebrow { display:block; color:#8c7867; font-size:22rpx; letter-spacing:3rpx; }
|
||||||
|
.title { display:block; margin:18rpx 0; font-family:'Songti SC','STSong',serif; font-size:38rpx; }
|
||||||
|
.hint { display:block; font-size:23rpx; line-height:1.8; color:#82796e; }
|
||||||
|
.stars { display:flex; justify-content:space-between; margin:24rpx 0 8rpx; }
|
||||||
|
.stars button { padding:0; margin:0; width:96rpx; height:96rpx; line-height:96rpx; font-size:60rpx; background:transparent; color:#b7ac9c; &::after {border:0;} &.chosen {color:#a5824b;} }
|
||||||
|
.rating-label {display:block; text-align:center; color:#8c7867; font-size:24rpx; margin-bottom:24rpx;}
|
||||||
|
.tags {display:flex;flex-wrap:wrap;gap:14rpx;margin:18rpx 0;}
|
||||||
|
.tag {margin:0;padding:15rpx 20rpx;font-size:24rpx;line-height:1.5;border-radius:16rpx;background:#f4f1eb;color:#72695f;&::after{border:0;} &.selected{background:#e7eee7;color:#4d685c;}}
|
||||||
|
textarea {margin-top:22rpx;padding:24rpx;width:100%;height:190rpx;box-sizing:border-box;background:#f6f3ed;border-radius:18rpx;font-size:26rpx;line-height:1.7;}
|
||||||
|
.counter{display:block;text-align:right;color:#8b817b;font-size:21rpx;margin:10rpx 0 20rpx;}
|
||||||
|
.recommend{display:flex;justify-content:space-between;gap:16rpx;align-items:center;min-height:88rpx;border-top:1rpx solid #eee8e0;font-size:24rpx;}
|
||||||
|
.primary,.secondary{margin-top:24rpx;min-height:88rpx;line-height:88rpx;border-radius:22rpx;background:#617d70;color:#fff;font-size:27rpx;&::after{border:0;}}
|
||||||
|
.secondary{background:#eee9df;color:#645c52;}.primary[disabled]{background:#d5dcd2;color:#697365;}
|
||||||
|
.saved-stars{display:block;font-size:46rpx;color:#a5824b;margin-top:24rpx;}.comment{display:block;line-height:1.8;font-size:27rpx;margin:20rpx 0;white-space:pre-wrap;}
|
||||||
|
</style>
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view v-if="flashSales.length" class="flash-sale-section">
|
|
||||||
<!-- Section header -->
|
|
||||||
<view class="section-header">
|
|
||||||
<view class="header-left">
|
|
||||||
<text class="section-title">限时秒杀</text>
|
|
||||||
<text v-if="hasOngoing" class="live-note">进行中</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Horizontal scroll cards -->
|
|
||||||
<scroll-view
|
|
||||||
scroll-x
|
|
||||||
:show-scrollbar="false"
|
|
||||||
class="flash-scroll"
|
|
||||||
>
|
|
||||||
<view class="flash-card-list">
|
|
||||||
<view
|
|
||||||
v-for="sale in flashSales"
|
|
||||||
:key="sale.id"
|
|
||||||
class="flash-card"
|
|
||||||
:class="cardPhaseClass(sale.phase)"
|
|
||||||
@tap="goToDetail(sale.id)"
|
|
||||||
>
|
|
||||||
<!-- Top gradient band -->
|
|
||||||
<view class="card-top">
|
|
||||||
<!-- Phase badge -->
|
|
||||||
<view class="phase-badge" :class="badgeClass(sale.phase)">
|
|
||||||
<text class="phase-badge-text">{{ phaseLabel(sale.phase) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Countdown / status text -->
|
|
||||||
<view class="countdown-row">
|
|
||||||
<text v-if="sale.phase === FlashSalePhase.UPCOMING" class="countdown-label">距开始</text>
|
|
||||||
<text v-else-if="sale.phase === FlashSalePhase.ONGOING" class="countdown-label">剩余</text>
|
|
||||||
<view v-if="sale.phase === FlashSalePhase.UPCOMING || sale.phase === FlashSalePhase.ONGOING" class="countdown-blocks">
|
|
||||||
<text class="cd-block">{{ getSaleCountdown(sale).h }}</text>
|
|
||||||
<text class="cd-sep">:</text>
|
|
||||||
<text class="cd-block">{{ getSaleCountdown(sale).m }}</text>
|
|
||||||
<text class="cd-sep">:</text>
|
|
||||||
<text class="cd-block">{{ getSaleCountdown(sale).s }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Card body -->
|
|
||||||
<view class="card-body">
|
|
||||||
<text class="card-title">{{ sale.title }}</text>
|
|
||||||
<text class="card-type-name">{{ sale.cardType.name }}</text>
|
|
||||||
|
|
||||||
<!-- Price area -->
|
|
||||||
<view class="price-area">
|
|
||||||
<view class="flash-price-row">
|
|
||||||
<text class="flash-currency">¥</text>
|
|
||||||
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Stock progress -->
|
|
||||||
<view class="stock-area">
|
|
||||||
<view class="stock-bar">
|
|
||||||
<view
|
|
||||||
class="stock-fill"
|
|
||||||
:class="{ 'stock-fill--hot': getStockRatio(sale.soldCount, sale.totalStock) > 0.6 }"
|
|
||||||
:style="{ width: stockPercent(sale) }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text class="stock-text">
|
|
||||||
{{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `剩 ${sale.remainingStock}/${sale.totalStock}` }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { FlashSalePhase } from '@mp-pilates/shared'
|
|
||||||
import type { FlashSaleListItem } from '@mp-pilates/shared'
|
|
||||||
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../utils/format'
|
|
||||||
import { get } from '../utils/request'
|
|
||||||
|
|
||||||
const flashSales = ref<FlashSaleListItem[]>([])
|
|
||||||
const tick = ref(0)
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const hasOngoing = computed(() =>
|
|
||||||
flashSales.value.some((s) => s.phase === FlashSalePhase.ONGOING),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function fetchFlashSales() {
|
|
||||||
try {
|
|
||||||
const data = await get<FlashSaleListItem[]>('/flash-sales')
|
|
||||||
flashSales.value = [...data]
|
|
||||||
} catch {
|
|
||||||
flashSales.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose for parent page refresh
|
|
||||||
defineExpose({ fetchFlashSales })
|
|
||||||
|
|
||||||
function phaseLabel(phase: FlashSalePhase): string {
|
|
||||||
return getFlashSalePhaseLabel(phase)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardPhaseClass(phase: FlashSalePhase): string {
|
|
||||||
if (phase === FlashSalePhase.ONGOING) return 'card--ongoing'
|
|
||||||
if (phase === FlashSalePhase.UPCOMING) return 'card--upcoming'
|
|
||||||
if (phase === FlashSalePhase.SOLD_OUT) return 'card--soldout'
|
|
||||||
return 'card--ended'
|
|
||||||
}
|
|
||||||
|
|
||||||
function badgeClass(phase: FlashSalePhase): string {
|
|
||||||
if (phase === FlashSalePhase.ONGOING) return 'badge--ongoing'
|
|
||||||
if (phase === FlashSalePhase.UPCOMING) return 'badge--upcoming'
|
|
||||||
return 'badge--inactive'
|
|
||||||
}
|
|
||||||
|
|
||||||
function stockPercent(sale: FlashSaleListItem): string {
|
|
||||||
return getStockPercent(sale.soldCount, sale.totalStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSaleCountdown(sale: FlashSaleListItem) {
|
|
||||||
void tick.value
|
|
||||||
const target = sale.phase === FlashSalePhase.UPCOMING ? sale.startTime : sale.endTime
|
|
||||||
return getCountdownParts(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToDetail(id: string) {
|
|
||||||
uni.navigateTo({ url: `/pages/flash-sale/detail?id=${id}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchFlashSales()
|
|
||||||
timer = setInterval(() => {
|
|
||||||
tick.value++
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.flash-sale-section { margin: 36rpx 32rpx 0; }
|
|
||||||
.section-header { margin-bottom: 20rpx; }
|
|
||||||
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
|
|
||||||
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
|
||||||
.live-note { font-size: 22rpx; color: #9b7b66; }
|
|
||||||
.flash-scroll { width: 100%; white-space: nowrap; }
|
|
||||||
.flash-card-list { display: inline-flex; gap: 20rpx; }
|
|
||||||
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
|
|
||||||
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
|
|
||||||
.card--upcoming .card-top { background: #eaf0e8; }
|
|
||||||
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
|
|
||||||
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
|
|
||||||
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
|
|
||||||
.countdown-label { font-size: 20rpx; color: #8b817b; }
|
|
||||||
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
|
|
||||||
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
|
||||||
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
|
|
||||||
.card-type-name { font-size: 22rpx; color: #8b817b; }
|
|
||||||
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
|
|
||||||
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
|
|
||||||
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
|
|
||||||
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
|
|
||||||
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
|
|
||||||
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
|
||||||
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
|
|
||||||
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
|
|
||||||
.stock-text { font-size: 20rpx; color: #8b817b; }
|
|
||||||
</style>
|
|
||||||
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>
|
||||||
211
packages/app/src/components/MemberProgress.vue
Normal file
211
packages/app/src/components/MemberProgress.vue
Normal file
File diff suppressed because one or more lines are too long
54
packages/app/src/components/OwnedMembershipCard.vue
Normal file
54
packages/app/src/components/OwnedMembershipCard.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<view class="pass" :class="[tone, { 'pass--compact': compact }]">
|
||||||
|
<view class="pass-heading">
|
||||||
|
<view class="pass-identity"><text class="pass-kind">{{ getCardTypeLabel(membership.cardType.type) }} · MEMBERSHIP</text><text class="pass-name">{{ membership.cardType.name }}</text></view>
|
||||||
|
<text class="pass-mark">{{ compact ? '查看 ›' : '有效' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pass-balance">
|
||||||
|
<text class="balance-label">{{ unlimited ? '有效期内' : '剩余' }}</text>
|
||||||
|
<text class="balance-number">{{ unlimited ? '不限次' : membership.remainingTimes }}</text>
|
||||||
|
<text v-if="!unlimited" class="balance-unit">次</text>
|
||||||
|
<text v-if="!unlimited && total !== null" class="usage-label">已用 {{ used }} / {{ total }} 次</text>
|
||||||
|
<text v-else-if="unlimited" class="usage-label">{{ days }} 天后到期</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
|
||||||
|
<view v-else class="pass-rule" />
|
||||||
|
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view>
|
||||||
|
<slot />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
|
||||||
|
const unlimited = computed(() => props.membership.remainingTimes === null)
|
||||||
|
const total = computed(() => getMembershipTotalTimes(props.membership))
|
||||||
|
const used = computed(() => getMembershipUsedTimes(props.membership))
|
||||||
|
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
|
||||||
|
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000)))
|
||||||
|
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pass { --pass-bg: #f0e6d8; --pass-ink: #655240; --pass-line: #d7c3a9; --pass-track: #e3d4c1; box-sizing: border-box; padding: 30rpx; border-radius: 24rpx; background: var(--pass-bg); color: var(--pass-ink); border: 1rpx solid var(--pass-line); }
|
||||||
|
.pass--sage { --pass-bg: #e7eee4; --pass-ink: #465f4d; --pass-line: #b8c9b3; --pass-track: #d4dfce; }
|
||||||
|
.pass--clay { --pass-bg: #f2e5df; --pass-ink: #845c4a; --pass-line: #d9b9aa; --pass-track: #e7d0c4; }
|
||||||
|
.pass-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
||||||
|
.pass-identity { min-width: 0; flex: 1; }
|
||||||
|
.pass-kind { display: block; font-size: 18rpx; letter-spacing: 2rpx; }
|
||||||
|
.pass-name { display: block; margin-top: 12rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
.pass-mark { flex-shrink: 0; font-size: 21rpx; padding-top: 4rpx; }
|
||||||
|
.pass-balance { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10rpx; margin-top: 30rpx; }
|
||||||
|
.balance-label, .balance-unit { font-size: 23rpx; }
|
||||||
|
.balance-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 60rpx; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||||
|
.usage-label { font-size: 23rpx; margin-left: auto; }
|
||||||
|
.usage-track { height: 7rpx; border-radius: 8rpx; background: var(--pass-track); overflow: hidden; margin-top: 22rpx; }
|
||||||
|
.usage-fill { height: 100%; background: var(--pass-ink); border-radius: 8rpx; }
|
||||||
|
.pass-rule { height: 1rpx; margin-top: 22rpx; background: var(--pass-line); }
|
||||||
|
.pass-dates { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; font-size: 22rpx; font-variant-numeric: tabular-nums; }
|
||||||
|
.expiry-soon { font-weight: 600; }
|
||||||
|
.pass--compact { height: 100%; padding: 24rpx; border-radius: 20rpx; .pass-name { font-size: 30rpx; margin-top: 8rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pass-balance { margin-top: 18rpx; } .balance-number { font-size: 42rpx; } .usage-label { font-size: 21rpx; } .pass-dates { justify-content: flex-end; font-size: 21rpx; margin-top: 14rpx; } .usage-track, .pass-rule { margin-top: 16rpx; } }
|
||||||
|
</style>
|
||||||
19
packages/app/src/components/PortraitProgress.vue
Normal file
19
packages/app/src/components/PortraitProgress.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<view class="progress">
|
||||||
|
<view class="bar"><view class="fill" :style="{ width: `${percent}%` }" /></view>
|
||||||
|
<text class="label">{{ step }}/{{ total }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
const props = defineProps<{ step: number; total: number }>()
|
||||||
|
const percent = computed(() => Math.round((props.step / props.total) * 100))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.progress { display: flex; align-items: center; gap: 16rpx; margin: 8rpx 0 24rpx; }
|
||||||
|
.bar { flex: 1; height: 8rpx; background: #efe8e1; border-radius: 8rpx; overflow: hidden; }
|
||||||
|
.fill { height: 100%; background: #6b8276; }
|
||||||
|
.label { font-size: 22rpx; color: #8a7b6e; }
|
||||||
|
</style>
|
||||||
69
packages/app/src/components/PortraitRadar.vue
Normal file
69
packages/app/src/components/PortraitRadar.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<canvas canvas-id="portraitRadar" class="radar" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getCurrentInstance, onMounted, watch } from 'vue'
|
||||||
|
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const props = defineProps<{ scores: BodyPortraitScores }>()
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
|
||||||
|
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
|
||||||
|
const w = 280
|
||||||
|
const h = 280
|
||||||
|
const cx = w / 2
|
||||||
|
const cy = h / 2
|
||||||
|
const radius = 96
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
ctx.setStrokeStyle('#d9cfc5')
|
||||||
|
ctx.setFillStyle('#edf2ec')
|
||||||
|
for (let ring = 1; ring <= 4; ring++) {
|
||||||
|
ctx.beginPath()
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||||
|
const x = cx + Math.cos(angle) * radius * (ring / 4)
|
||||||
|
const y = cy + Math.sin(angle) * radius * (ring / 4)
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
ctx.setFillStyle('rgba(107,130,118,0.35)')
|
||||||
|
ctx.setStrokeStyle('#6b8276')
|
||||||
|
ctx.beginPath()
|
||||||
|
keys.forEach((key, i) => {
|
||||||
|
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||||
|
const value = Math.max(8, props.scores[key]) / 100
|
||||||
|
const x = cx + Math.cos(angle) * radius * value
|
||||||
|
const y = cy + Math.sin(angle) * radius * value
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
})
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setFillStyle('#5d5148')
|
||||||
|
ctx.setFontSize(11)
|
||||||
|
ctx.setTextAlign('center')
|
||||||
|
labels.forEach((label, i) => {
|
||||||
|
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
|
||||||
|
const x = cx + Math.cos(angle) * (radius + 22)
|
||||||
|
const y = cy + Math.sin(angle) * (radius + 22)
|
||||||
|
ctx.fillText(label, x, y)
|
||||||
|
})
|
||||||
|
ctx.draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(draw)
|
||||||
|
watch(() => props.scores, draw, { deep: true })
|
||||||
|
void BODY_DIMENSION_LABELS
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.radar { width: 560rpx; height: 560rpx; margin: 0 auto; }
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-menu">
|
<view class="profile-menu">
|
||||||
<view class="profile-menu__shortcuts">
|
|
||||||
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
|
|
||||||
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
|
|
||||||
<view class="profile-menu__shortcut-head">
|
|
||||||
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
|
|
||||||
<text class="profile-menu__arrow">›</text>
|
|
||||||
</view>
|
|
||||||
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
|
|
||||||
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<view class="profile-menu__links">
|
<view class="profile-menu__links">
|
||||||
<template v-for="item in secondaryItems" :key="item.key">
|
<template v-for="item in menuItems" :key="item.key">
|
||||||
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
||||||
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
|
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
|
||||||
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
|
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
|
||||||
@@ -36,49 +24,26 @@ interface MenuItem {
|
|||||||
title?: string
|
title?: string
|
||||||
path?: string
|
path?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
badge?: string
|
action?: 'clear' | 'notifications'
|
||||||
action?: 'clear'
|
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
activeMembershipCount?: number
|
|
||||||
upcomingBookingCount?: number
|
|
||||||
inviteShareEligible?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'clear-cache'): void
|
(e: 'clear-cache'): void
|
||||||
(e: 'require-login'): void
|
(e: 'require-login'): void
|
||||||
|
(e: 'open-notifications'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
|
|
||||||
? `${props.activeMembershipCount}张`
|
|
||||||
: undefined
|
|
||||||
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
|
|
||||||
? `${props.upcomingBookingCount}`
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{
|
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
||||||
key: 'membership',
|
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
|
||||||
type: 'item',
|
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
|
||||||
title: '我的会员卡',
|
|
||||||
path: '/pages/profile/membership',
|
|
||||||
badge: membershipBadge,
|
|
||||||
requireAuth: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookings',
|
|
||||||
type: 'item',
|
|
||||||
title: '我的预约',
|
|
||||||
path: '/pages/profile/bookings',
|
|
||||||
badge: bookingBadge,
|
|
||||||
requireAuth: true,
|
|
||||||
},
|
|
||||||
...(props.isAdmin
|
...(props.isAdmin
|
||||||
? [{
|
? [{
|
||||||
key: 'teaching-schedule',
|
key: 'teaching-schedule',
|
||||||
@@ -88,16 +53,13 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}]
|
}]
|
||||||
: []),
|
: []),
|
||||||
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
|
{
|
||||||
// ...(props.inviteShareEligible
|
key: 'bookings',
|
||||||
// ? [{
|
type: 'item',
|
||||||
// key: 'invite',
|
title: '我的预约',
|
||||||
// type: 'item' as const,
|
path: '/pages/profile/bookings',
|
||||||
// title: '邀请好友',
|
requireAuth: true,
|
||||||
// path: '/pages/profile/invite',
|
},
|
||||||
// requireAuth: true,
|
|
||||||
// }]
|
|
||||||
// : []),
|
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -105,6 +67,13 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
path: '/pages/profile/info',
|
path: '/pages/profile/info',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
type: 'item',
|
||||||
|
title: '消息提醒设置',
|
||||||
|
action: 'notifications',
|
||||||
|
requireAuth: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sep1',
|
key: 'sep1',
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
@@ -132,9 +101,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
|
|
||||||
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
|
|
||||||
|
|
||||||
function handleTap(item: MenuItem) {
|
function handleTap(item: MenuItem) {
|
||||||
if (item.requireAuth && !props.requireAuth) {
|
if (item.requireAuth && !props.requireAuth) {
|
||||||
emit('require-login')
|
emit('require-login')
|
||||||
@@ -142,6 +108,8 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
if (item.action === 'clear') {
|
if (item.action === 'clear') {
|
||||||
emit('clear-cache')
|
emit('clear-cache')
|
||||||
|
} else if (item.action === 'notifications') {
|
||||||
|
emit('open-notifications')
|
||||||
} else if (item.path) {
|
} else if (item.path) {
|
||||||
uni.navigateTo({ url: item.path })
|
uni.navigateTo({ url: item.path })
|
||||||
}
|
}
|
||||||
@@ -150,14 +118,6 @@ function handleTap(item: MenuItem) {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-menu {
|
.profile-menu {
|
||||||
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
|
|
||||||
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
|
||||||
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
|
|
||||||
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
|
|
||||||
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
|
|
||||||
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
|
|
||||||
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
|
|
||||||
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
|
|
||||||
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
|
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
|
||||||
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
|
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
|
||||||
&__item--hover { background: #f4f1ec; }
|
&__item--hover { background: #f4f1ec; }
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="quick-entry">
|
<view class="quick-entry">
|
||||||
<!-- ① Not logged in -->
|
<!-- ① Not logged in -->
|
||||||
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
|
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
|
||||||
<text class="pill-label">欢迎来到工作室</text>
|
<text class="pill-label">3 分钟了解自己的身体状态</text>
|
||||||
<view class="pill-action action-login">
|
<view class="pill-action action-login">
|
||||||
<text class="pill-action-text">微信登录</text>
|
<text class="pill-action-text">开始评估</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ② Logged in, no memberships → new user -->
|
<!-- Has valid active card -->
|
||||||
<view
|
|
||||||
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
|
||||||
class="entry-pill pill-trial"
|
|
||||||
@tap="handleTrialEntry"
|
|
||||||
>
|
|
||||||
|
|
||||||
<text class="pill-label">首次体验专属课程</text>
|
|
||||||
<view class="pill-action action-trial">
|
|
||||||
<text class="pill-action-text">预约体验课</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ③ Has valid active card -->
|
|
||||||
<template v-else-if="userStore.hasValidMembership">
|
<template v-else-if="userStore.hasValidMembership">
|
||||||
<view class="entry-pill pill-active" @tap="handleBooking">
|
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||||
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||||
@@ -75,6 +62,10 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePortrait() {
|
||||||
|
uni.navigateTo({ url: '/pages/portrait/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function handleTrialEntry() {
|
function handleTrialEntry() {
|
||||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/app/src/components/ReviewSummaryCard.vue
Normal file
9
packages/app/src/components/ReviewSummaryCard.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template><view v-if="summary && summary.count > 0" class="summary"><view><text class="label">来自真实课后反馈</text><text class="caption">每一次被听见,让练习更贴近你。</text></view><view class="score"><text>{{ summary.average }}<text class="star">★</text></text><text class="count">{{ summary.count }} 份评价</text></view></view></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import { get } from '../utils/request'
|
||||||
|
const summary = ref<{ count: number; average: number | null } | null>(null)
|
||||||
|
onShow(async () => { try { summary.value = await get('/reviews/summary') } catch { summary.value = null } })
|
||||||
|
</script>
|
||||||
|
<style scoped>.summary{margin:24rpx 32rpx;padding:26rpx;display:flex;justify-content:space-between;align-items:center;gap:20rpx;border:1rpx solid #e2e5d9;border-radius:24rpx;background:#f1f3e9;}.label{display:block;font-size:26rpx;color:#65735b;}.caption{display:block;font-size:22rpx;color:#8a8d7c;line-height:1.7;margin-top:10rpx;}.score{flex-shrink:0;text-align:right;font-size:46rpx;color:#6b785c;font-family:'Baskerville',serif;}.star{font-size:27rpx;margin-left:8rpx;color:#a1874b;}.count{display:block;font-size:21rpx;font-family:inherit;line-height:1.8;color:#858874;}</style>
|
||||||
16
packages/app/src/components/SafetyNotice.vue
Normal file
16
packages/app/src/components/SafetyNotice.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<view class="notice">
|
||||||
|
<text class="title">建议先确认运动条件</text>
|
||||||
|
<text class="body">{{ message }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.notice { margin: 24rpx 0; padding: 28rpx; border-radius: 20rpx; background: #f7eee8; }
|
||||||
|
.title { display: block; font-size: 30rpx; color: #7a4e3e; margin-bottom: 12rpx; }
|
||||||
|
.body { display: block; font-size: 26rpx; line-height: 1.7; color: #8a6458; }
|
||||||
|
</style>
|
||||||
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="modal-mask" @tap="handleClose">
|
||||||
|
<view class="modal-panel" @tap.stop>
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">微信消息提醒设置</text>
|
||||||
|
<view class="close-btn" @tap="handleClose">
|
||||||
|
<text class="close-icon">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="notice-box">
|
||||||
|
<text class="notice-title">💡 为什么需要增加订阅次数?</text>
|
||||||
|
<text class="notice-desc">
|
||||||
|
微信订阅消息每授权 1 次可接收 1 条通知。建议点击下方按钮,并在弹出的微信授权窗中勾选<text class="notice-highlight">「总是保持以上选择」</text>,即可永久无感自动接收课程变动与上课提醒。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="quota-list">
|
||||||
|
<view v-if="loading" class="loading-wrap">
|
||||||
|
<text class="loading-text">加载提醒状态中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="quotas.length === 0" class="empty-wrap">
|
||||||
|
<text class="empty-text">未检测到可用提醒模板配置</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else v-for="item in quotas" :key="item.scene" class="quota-item">
|
||||||
|
<view class="quota-info">
|
||||||
|
<view class="quota-title-row">
|
||||||
|
<text class="quota-icon">{{ getSceneIcon(item.scene) }}</text>
|
||||||
|
<text class="quota-name">{{ getSceneName(item.scene) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="quota-desc">{{ item.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="quota-badge" :class="getBadgeClass(item.remainingQuota)">
|
||||||
|
<text class="badge-text">
|
||||||
|
{{ item.remainingQuota > 0 ? `余 ${item.remainingQuota} 次` : '待补充' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:loading="subscribing"
|
||||||
|
:disabled="subscribing"
|
||||||
|
@tap="handleTopUp"
|
||||||
|
>
|
||||||
|
一键补充全部提醒次数 (+3)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view class="btn-secondary" @tap="handleOpenSettings">
|
||||||
|
<text class="btn-secondary-text">微信权限与通知设置</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||||
|
import type { SubscriptionQuotaItem } from '@mp-pilates/shared'
|
||||||
|
import {
|
||||||
|
fetchUserSubscriptionQuotas,
|
||||||
|
requestBookingBundleSubscriptionMessage,
|
||||||
|
openSubscribeSettings,
|
||||||
|
} from '../utils/wechat-subscription'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', val: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const quotas = ref<SubscriptionQuotaItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const subscribing = ref(false)
|
||||||
|
|
||||||
|
async function loadQuotas() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
quotas.value = await fetchUserSubscriptionQuotas()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
void loadQuotas()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSceneIcon(scene: SubscriptionMessageScene): string {
|
||||||
|
switch (scene) {
|
||||||
|
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||||
|
return '📅'
|
||||||
|
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||||
|
return '⏰'
|
||||||
|
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||||
|
return '📋'
|
||||||
|
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||||
|
return '⭐'
|
||||||
|
default:
|
||||||
|
return '🔔'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSceneName(scene: SubscriptionMessageScene): string {
|
||||||
|
switch (scene) {
|
||||||
|
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||||
|
return '约课成功通知'
|
||||||
|
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||||
|
return '上课前 1 小时提醒'
|
||||||
|
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||||
|
return '约课取消通知'
|
||||||
|
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||||
|
return '课后评价提醒'
|
||||||
|
default:
|
||||||
|
return '课程服务通知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeClass(quota: number): string {
|
||||||
|
if (quota >= 3) return 'badge--healthy'
|
||||||
|
if (quota > 0) return 'badge--warning'
|
||||||
|
return 'badge--danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTopUp() {
|
||||||
|
if (subscribing.value) return
|
||||||
|
subscribing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await requestBookingBundleSubscriptionMessage()
|
||||||
|
const acceptedCount = results.filter((r) => r.result === 'accept').length
|
||||||
|
if (acceptedCount > 0) {
|
||||||
|
uni.showToast({ title: `已成功补充 ${acceptedCount} 项提醒额度`, icon: 'success' })
|
||||||
|
await loadQuotas()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '未增加额度,可再次点击尝试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[subscribe] top-up failed', error)
|
||||||
|
uni.showToast({ title: '授权未完成,可进入微信设置检查', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
subscribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenSettings() {
|
||||||
|
await openSubscribeSettings()
|
||||||
|
await loadQuotas()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(56, 48, 42, 0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fbf9f6;
|
||||||
|
border-radius: 36rpx 36rpx 0 0;
|
||||||
|
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #514943;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-box {
|
||||||
|
background: #f0f4ee;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #476d54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-desc {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #657568;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-highlight {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b5a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
max-height: 480rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap,
|
||||||
|
.empty-wrap {
|
||||||
|
padding: 40rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 2rpx solid #eee8e3;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-icon {
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #514943;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-desc {
|
||||||
|
font-size: 21rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-badge {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--healthy {
|
||||||
|
background: #edf5eb;
|
||||||
|
.badge-text {
|
||||||
|
color: #4f7957;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--warning {
|
||||||
|
background: #fdf5ea;
|
||||||
|
.badge-text {
|
||||||
|
color: #b07d39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--danger {
|
||||||
|
background: #fbeee9;
|
||||||
|
.badge-text {
|
||||||
|
color: #bc5e4c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #6b8276;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
line-height: 1;
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background: #597264;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-text {
|
||||||
|
font-size: 25rpx;
|
||||||
|
color: #7b8974;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -49,36 +49,35 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Stats row: shown only when profile is loaded -->
|
<button v-if="loggedIn && hasProfile" class="membership-row" @tap="handleMembershipTap">
|
||||||
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
<text class="membership-row__label">会员卡</text>
|
||||||
<view class="user-card__stat-item">
|
<text class="membership-row__value">{{ membershipLabel }}</text>
|
||||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
|
<text v-if="!membershipsError && membershipsLoaded && activeMemberships.length > 1" class="membership-row__count">{{ activeMemberships.length }} 张</text>
|
||||||
<text class="user-card__stat-label">累计上课 · 节</text>
|
<text class="membership-row__arrow">›</text>
|
||||||
</view>
|
</button>
|
||||||
<view class="user-card__stat-divider" />
|
<view v-if="loggedIn && hasProfile && membershipsLoaded && !membershipsError && activeMemberships.length" class="mini-progress-list">
|
||||||
<view class="user-card__stat-item">
|
<button v-for="item in cardProgress" :key="item.id" class="mini-progress" :aria-label="`${item.name},${item.label}`" @tap="handleMembershipTap">
|
||||||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
|
<view v-if="item.percent !== null" class="mini-progress__fill" :style="{ width: `${item.percent}%` }" />
|
||||||
<text class="user-card__stat-label">本月上课 · 节</text>
|
<view class="mini-progress__heading"><text class="mini-progress__name">{{ item.name }}</text><text class="mini-progress__usage">{{ item.label }}</text></view>
|
||||||
</view>
|
</button>
|
||||||
<view class="user-card__stat-divider" />
|
|
||||||
<view class="user-card__stat-item">
|
|
||||||
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
|
||||||
<text class="user-card__stat-label">剩余课时 · 节</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus } from '@mp-pilates/shared'
|
import { MembershipStatus } from '@mp-pilates/shared'
|
||||||
|
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loggedIn: boolean
|
loggedIn: boolean
|
||||||
hasProfile: boolean
|
hasProfile: boolean
|
||||||
user: UserProfileResponse | null
|
user: UserProfileResponse | null
|
||||||
stats: UserStatsResponse | null
|
now: number
|
||||||
|
membershipsLoading?: boolean
|
||||||
|
membershipsLoaded?: boolean
|
||||||
|
membershipsError?: boolean
|
||||||
memberships?: readonly MembershipWithCardType[]
|
memberships?: readonly MembershipWithCardType[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -86,6 +85,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'login'): void
|
(e: 'login'): void
|
||||||
(e: 'edit'): void
|
(e: 'edit'): void
|
||||||
|
(e: 'refresh-memberships'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const avatarFailed = ref(false)
|
const avatarFailed = ref(false)
|
||||||
@@ -123,16 +123,32 @@ const activeMembershipCount = computed(
|
|||||||
|
|
||||||
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||||
|
|
||||||
function toSafeCount(value: number | null | undefined): number {
|
const membershipLabel = computed(() => {
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
if (props.membershipsError) return '暂未更新,点击重试'
|
||||||
|
if (!props.membershipsLoaded) return '加载中…'
|
||||||
|
if (!activeMemberships.value.length) return '暂无有效卡'
|
||||||
|
return [...new Set(activeMemberships.value.map(m => getCardTypeLabel(m.cardType.type)))].join(' · ')
|
||||||
|
})
|
||||||
|
const cardProgress = computed(() => activeMemberships.value.map(m => {
|
||||||
|
const total = getMembershipTotalTimes(m)
|
||||||
|
if (m.remainingTimes !== null) {
|
||||||
|
const used = getMembershipUsedTimes(m)
|
||||||
|
return { id: m.id, name: m.cardType.name,
|
||||||
|
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes} 次`,
|
||||||
|
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
|
||||||
}
|
}
|
||||||
|
const start = new Date(m.startDate).getTime()
|
||||||
|
const end = new Date(m.expireDate).getTime()
|
||||||
|
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
|
||||||
|
return { id: m.id, name: m.cardType.name,
|
||||||
|
label: `不限次 · 有效期剩 ${remainingDays} 天`,
|
||||||
|
percent: end > start ? Math.max(0, Math.min(100, (end - props.now) / (end - start) * 100)) : null }
|
||||||
|
}))
|
||||||
|
|
||||||
// Sum remaining sessions from all active count-limited memberships.
|
function handleMembershipTap() {
|
||||||
const remainingSessions = computed(() =>
|
if (props.membershipsError) { emit('refresh-memberships'); return }
|
||||||
activeMemberships.value
|
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||||
.filter((m) => m.remainingTimes !== null)
|
}
|
||||||
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
function onAvatarError() {
|
function onAvatarError() {
|
||||||
avatarFailed.value = true
|
avatarFailed.value = true
|
||||||
@@ -155,11 +171,6 @@ function handleLogin() {
|
|||||||
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
|
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
|
||||||
&__phone { font-size: 24rpx; color: #8b7b70; }
|
&__phone { font-size: 24rpx; color: #8b7b70; }
|
||||||
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
|
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
|
||||||
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
|
|
||||||
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
|
||||||
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
|
||||||
&__stat-label { font-size: 20rpx; color: #8b7b70; }
|
|
||||||
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
|
|
||||||
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
|
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
|
||||||
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||||
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||||
@@ -169,4 +180,15 @@ function handleLogin() {
|
|||||||
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
|
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
|
||||||
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
|
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
|
||||||
}
|
}
|
||||||
|
.membership-row { display: flex; align-items: center; gap: 12rpx; width: 100%; margin: 20rpx 0 0; padding: 18rpx 0 0; min-height: 62rpx; border-radius: 0; border-top: 1rpx solid #e3d7ce; background: transparent; text-align: left; line-height: 1.5; font-size: 23rpx; color: #796759; &::after { border: none; } }
|
||||||
|
.membership-row__label { flex-shrink: 0; color: #8b7b70; }
|
||||||
|
.membership-row__value { flex: 1; min-width: 0; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.membership-row__count { flex-shrink: 0; font-size: 21rpx; color: #8b7b70; }
|
||||||
|
.membership-row__arrow { flex-shrink: 0; font-size: 28rpx; color: #8b7b70; }
|
||||||
|
.mini-progress-list { margin-top: 12rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.mini-progress { position: relative; display: block; width: 100%; height: 48rpx; margin: 0; padding: 0 16rpx; overflow: hidden; border: 1rpx solid #d6d9ca; border-radius: 10rpx; background: #eeece4; text-align: left; line-height: 46rpx; &::after { border: none; } &:active { opacity: .8; } }
|
||||||
|
.mini-progress__heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 12rpx; height: 100%; font-size: 20rpx; color: #46513f; }
|
||||||
|
.mini-progress__name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.mini-progress__usage { flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.mini-progress__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #dce3d3, #c9d7bf); border-right: 1rpx solid rgba(109, 135, 91, .16); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"minified": true
|
"minified": true
|
||||||
},
|
},
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
|
"optimization": {
|
||||||
|
"subPackages": true
|
||||||
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"scope.userLocation": {
|
"scope.userLocation": {
|
||||||
"desc": "用于获取工作室位置导航"
|
"desc": "用于获取工作室位置导航"
|
||||||
|
|||||||
@@ -12,13 +12,19 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/booking/index",
|
"path": "pages/booking/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom",
|
||||||
|
"componentPlaceholder": {
|
||||||
|
"booking-confirm-popup": "view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/booking/detail",
|
"path": "pages/booking/detail",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom",
|
||||||
|
"componentPlaceholder": {
|
||||||
|
"booking-confirm-popup": "view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,86 +76,179 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/index",
|
"path": "pages/profile/progress",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "pages/admin",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/bookings",
|
"path": "analytics",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "bookings",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/schedule",
|
"path": "schedule",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/slot-adjust",
|
"path": "slot-adjust",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/members",
|
"path": "members",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/member-detail",
|
"path": "member-detail",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/member-edit",
|
"path": "member-edit",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/member-supplement",
|
"path": "member-supplement",
|
||||||
"style": { "navigationStyle": "custom" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "pages/admin/member-arrange",
|
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/orders",
|
"path": "member-arrange",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/card-types",
|
"path": "orders",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/studio",
|
"path": "card-types",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/flash-sales",
|
"path": "studio",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/flash-sale/detail",
|
"path": "member-progress",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "reviews",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "portrait-today",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "portrait-leads",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "portrait-lead-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "portrait-assessment",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "portrait-plan",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/portrait",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assessment",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "report",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "advice",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "trial",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plan",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -239,9 +239,9 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
import { uploadStudioAsset } from '../../utils/studio-upload'
|
import { uploadStudioAsset } from './utils/studio-upload'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
|||||||
@@ -1,863 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
|
||||||
<CustomNavBar title="秒杀管理" show-back />
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<view class="toolbar">
|
|
||||||
<text class="toolbar-hint">共 {{ total }} 个秒杀活动</text>
|
|
||||||
<view class="add-btn" @tap="openAdd">
|
|
||||||
<text class="add-btn-text">+ 新建秒杀</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<view v-if="pageLoading" class="skeleton-list">
|
|
||||||
<view v-for="i in 3" :key="i" class="skeleton-item" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<view v-else-if="!items.length" class="empty-state">
|
|
||||||
<text class="empty-icon">◈</text>
|
|
||||||
<text class="empty-text">暂无秒杀活动,点击右上角新建</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Flash sale list -->
|
|
||||||
<view v-else class="fs-list">
|
|
||||||
<view
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.id"
|
|
||||||
class="fs-card"
|
|
||||||
>
|
|
||||||
<!-- Header band -->
|
|
||||||
<view class="fs-header" :class="headerStatusClass(item)">
|
|
||||||
<view class="fs-header-left">
|
|
||||||
<text class="fs-title">{{ item.title }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="fs-status-tag" :class="phaseTagClass(item.phase)">
|
|
||||||
<text class="fs-status-text">{{ phaseLabel(item.phase) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<view class="fs-body">
|
|
||||||
<view class="fs-info-row">
|
|
||||||
<text class="fs-card-type">关联卡种: {{ item.cardType.name }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="fs-price-row">
|
|
||||||
<view class="fs-price-block">
|
|
||||||
<text class="fs-price-label">秒杀价</text>
|
|
||||||
<text class="fs-price-value flash">¥{{ formatPrice(item.flashPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="fs-price-block">
|
|
||||||
<text class="fs-price-label">原价</text>
|
|
||||||
<text class="fs-price-value original">¥{{ formatPrice(item.originalPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="fs-price-block">
|
|
||||||
<text class="fs-price-label">库存</text>
|
|
||||||
<text class="fs-price-value">{{ item.soldCount }}/{{ item.totalStock }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Stock progress bar -->
|
|
||||||
<view class="fs-stock-bar">
|
|
||||||
<view
|
|
||||||
class="fs-stock-fill"
|
|
||||||
:style="{ width: stockPercent(item) }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="fs-time-row">
|
|
||||||
<text class="fs-time">{{ formatDateTime(item.startTime) }} — {{ formatDateTime(item.endTime) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<view class="fs-actions">
|
|
||||||
<view class="fs-action-btn edit-btn" @tap.stop="openEdit(item)">
|
|
||||||
<text class="fs-action-text">编辑</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="item.status === 'DRAFT'"
|
|
||||||
class="fs-action-btn activate-btn"
|
|
||||||
@tap.stop="confirmActivate(item)"
|
|
||||||
>
|
|
||||||
<text class="fs-action-text">上线</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-else-if="item.status === 'ACTIVE'"
|
|
||||||
class="fs-action-btn end-btn"
|
|
||||||
@tap.stop="confirmEnd(item)"
|
|
||||||
>
|
|
||||||
<text class="fs-action-text">结束</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="item.soldCount === 0"
|
|
||||||
class="fs-action-btn delete-btn"
|
|
||||||
@tap.stop="confirmDelete(item)"
|
|
||||||
>
|
|
||||||
<text class="fs-action-text">删除</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ──────── Add / Edit modal ──────── -->
|
|
||||||
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
|
|
||||||
<view class="modal-container" @tap.stop>
|
|
||||||
<scroll-view scroll-y class="modal-scroll">
|
|
||||||
<!-- Header -->
|
|
||||||
<view class="modal-header">
|
|
||||||
<text class="modal-title">{{ editTarget ? '编辑秒杀' : '新建秒杀' }}</text>
|
|
||||||
<view class="modal-close" @tap="closeModal">
|
|
||||||
<text class="modal-close-icon">✕</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Form fields -->
|
|
||||||
<view class="modal-body">
|
|
||||||
<!-- Card type picker -->
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">关联卡种</text>
|
|
||||||
<picker
|
|
||||||
mode="selector"
|
|
||||||
:range="cardTypeOptions"
|
|
||||||
range-key="label"
|
|
||||||
:value="form.cardTypeIdx"
|
|
||||||
@change="onCardTypeChange"
|
|
||||||
:disabled="!!editTarget"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ cardTypeOptions[form.cardTypeIdx]?.label || '请选择' }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">活动标题</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
v-model="form.title"
|
|
||||||
placeholder="如:新春限时秒杀"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">原价(元)</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="digit"
|
|
||||||
v-model="form.originalPriceStr"
|
|
||||||
placeholder="展示划线价"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">秒杀价(元)</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="digit"
|
|
||||||
v-model="form.flashPriceStr"
|
|
||||||
placeholder="实际支付价格"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">库存数量</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="number"
|
|
||||||
v-model="form.totalStockStr"
|
|
||||||
placeholder="秒杀总量"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">开始时间</text>
|
|
||||||
<view class="datetime-picker-group">
|
|
||||||
<picker
|
|
||||||
mode="date"
|
|
||||||
:value="form.startDate"
|
|
||||||
@change="onStartDateChange"
|
|
||||||
>
|
|
||||||
<text class="datetime-text">{{ form.startDate || '选择日期' }}</text>
|
|
||||||
</picker>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.startTimeStr"
|
|
||||||
@change="onStartTimeChange"
|
|
||||||
>
|
|
||||||
<text class="datetime-text">{{ form.startTimeStr || '选择时间' }}</text>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">结束时间</text>
|
|
||||||
<view class="datetime-picker-group">
|
|
||||||
<picker
|
|
||||||
mode="date"
|
|
||||||
:value="form.endDate"
|
|
||||||
@change="onEndDateChange"
|
|
||||||
>
|
|
||||||
<text class="datetime-text">{{ form.endDate || '选择日期' }}</text>
|
|
||||||
</picker>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.endTimeStr"
|
|
||||||
@change="onEndTimeChange"
|
|
||||||
>
|
|
||||||
<text class="datetime-text">{{ form.endTimeStr || '选择时间' }}</text>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">排序值</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="number"
|
|
||||||
v-model="form.sortOrderStr"
|
|
||||||
placeholder="越小越靠前"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field modal-field--last">
|
|
||||||
<text class="modal-label">活动说明</text>
|
|
||||||
<textarea
|
|
||||||
class="modal-textarea"
|
|
||||||
v-model="form.description"
|
|
||||||
placeholder="可选,向用户展示"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
:maxlength="500"
|
|
||||||
auto-height
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<view class="modal-actions">
|
|
||||||
<view class="modal-cancel" @tap="closeModal">
|
|
||||||
<text class="modal-cancel-text">取消</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
class="modal-confirm"
|
|
||||||
:class="{ 'modal-confirm--loading': submitting }"
|
|
||||||
@tap="submitForm"
|
|
||||||
>
|
|
||||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
||||||
import { getSystemLayout } from '../../utils/system'
|
|
||||||
import { useAdminStore } from '../../stores/admin'
|
|
||||||
import { formatPrice, formatDateTime, getFlashSalePhaseLabel, getStockPercent, formatDateLocal, formatTimeLocal } from '../../utils/format'
|
|
||||||
import { FlashSaleStatus, FlashSalePhase } from '@mp-pilates/shared'
|
|
||||||
import type { FlashSaleAdminItem, CardType } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
|
||||||
onMounted(() => {
|
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Data ────────────────────────────────────────────
|
|
||||||
const items = ref<FlashSaleAdminItem[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const pageLoading = ref(false)
|
|
||||||
const showModal = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const editTarget = ref<FlashSaleAdminItem | null>(null)
|
|
||||||
const cardTypes = ref<CardType[]>([])
|
|
||||||
|
|
||||||
const cardTypeOptions = computed(() =>
|
|
||||||
cardTypes.value.map((ct) => ({
|
|
||||||
label: `${ct.name}(¥${formatPrice(ct.price)})`,
|
|
||||||
value: ct.id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultForm = () => ({
|
|
||||||
cardTypeIdx: 0,
|
|
||||||
title: '',
|
|
||||||
originalPriceStr: '',
|
|
||||||
flashPriceStr: '',
|
|
||||||
totalStockStr: '',
|
|
||||||
startDate: '',
|
|
||||||
startTimeStr: '',
|
|
||||||
endDate: '',
|
|
||||||
endTimeStr: '',
|
|
||||||
sortOrderStr: '0',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = ref(defaultForm())
|
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────
|
|
||||||
async function loadData() {
|
|
||||||
pageLoading.value = true
|
|
||||||
try {
|
|
||||||
const [salesResult, cardTypesResult] = await Promise.all([
|
|
||||||
adminStore.fetchFlashSales(),
|
|
||||||
adminStore.fetchCardTypes(),
|
|
||||||
])
|
|
||||||
items.value = [...salesResult.items]
|
|
||||||
total.value = salesResult.total
|
|
||||||
cardTypes.value = [...cardTypesResult]
|
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadSales() {
|
|
||||||
try {
|
|
||||||
const result = await adminStore.fetchFlashSales()
|
|
||||||
items.value = [...result.items]
|
|
||||||
total.value = result.total
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────
|
|
||||||
function phaseLabel(phase: FlashSalePhase): string {
|
|
||||||
return getFlashSalePhaseLabel(phase)
|
|
||||||
}
|
|
||||||
|
|
||||||
function phaseTagClass(phase: FlashSalePhase): string {
|
|
||||||
if (phase === FlashSalePhase.ONGOING) return 'tag--ongoing'
|
|
||||||
if (phase === FlashSalePhase.UPCOMING) return 'tag--upcoming'
|
|
||||||
if (phase === FlashSalePhase.SOLD_OUT) return 'tag--soldout'
|
|
||||||
return 'tag--ended'
|
|
||||||
}
|
|
||||||
|
|
||||||
function headerStatusClass(item: FlashSaleAdminItem): string {
|
|
||||||
if (item.status === FlashSaleStatus.DRAFT) return 'header--draft'
|
|
||||||
if (item.status === FlashSaleStatus.ENDED) return 'header--ended'
|
|
||||||
return 'header--active'
|
|
||||||
}
|
|
||||||
|
|
||||||
function stockPercent(item: FlashSaleAdminItem): string {
|
|
||||||
return getStockPercent(item.soldCount, item.totalStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Modal ────────────────────────────────────────────
|
|
||||||
function openAdd() {
|
|
||||||
editTarget.value = null
|
|
||||||
form.value = defaultForm()
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(item: FlashSaleAdminItem) {
|
|
||||||
editTarget.value = item
|
|
||||||
const startDt = new Date(item.startTime)
|
|
||||||
const endDt = new Date(item.endTime)
|
|
||||||
const ctIdx = cardTypes.value.findIndex((ct) => ct.id === item.cardTypeId)
|
|
||||||
|
|
||||||
form.value = {
|
|
||||||
cardTypeIdx: ctIdx >= 0 ? ctIdx : 0,
|
|
||||||
title: item.title,
|
|
||||||
originalPriceStr: String(item.originalPrice / 100),
|
|
||||||
flashPriceStr: String(item.flashPrice / 100),
|
|
||||||
totalStockStr: String(item.totalStock),
|
|
||||||
startDate: formatDateLocal(startDt),
|
|
||||||
startTimeStr: formatTimeLocal(startDt),
|
|
||||||
endDate: formatDateLocal(endDt),
|
|
||||||
endTimeStr: formatTimeLocal(endDt),
|
|
||||||
sortOrderStr: String(item.sortOrder),
|
|
||||||
description: item.description ?? '',
|
|
||||||
}
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
showModal.value = false
|
|
||||||
editTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCardTypeChange(e: { detail: { value: number } }) {
|
|
||||||
const idx = Number(e.detail.value)
|
|
||||||
form.value.cardTypeIdx = idx
|
|
||||||
// Auto-fill original price from card type
|
|
||||||
const ct = cardTypes.value[idx]
|
|
||||||
if (ct && !form.value.originalPriceStr) {
|
|
||||||
form.value.originalPriceStr = String(Number(ct.price) / 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStartDateChange(e: { detail: { value: string } }) {
|
|
||||||
form.value.startDate = e.detail.value
|
|
||||||
}
|
|
||||||
function onStartTimeChange(e: { detail: { value: string } }) {
|
|
||||||
form.value.startTimeStr = e.detail.value
|
|
||||||
}
|
|
||||||
function onEndDateChange(e: { detail: { value: string } }) {
|
|
||||||
form.value.endDate = e.detail.value
|
|
||||||
}
|
|
||||||
function onEndTimeChange(e: { detail: { value: string } }) {
|
|
||||||
form.value.endTimeStr = e.detail.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Form submit ──────────────────────────────────────
|
|
||||||
async function submitForm() {
|
|
||||||
if (submitting.value) return
|
|
||||||
|
|
||||||
if (!form.value.title.trim()) {
|
|
||||||
uni.showToast({ title: '请填写活动标题', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
const originalPrice = parseFloat(form.value.originalPriceStr)
|
|
||||||
if (isNaN(originalPrice) || originalPrice <= 0) {
|
|
||||||
uni.showToast({ title: '请填写有效原价', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
const flashPrice = parseFloat(form.value.flashPriceStr)
|
|
||||||
if (isNaN(flashPrice) || flashPrice <= 0) {
|
|
||||||
uni.showToast({ title: '请填写有效秒杀价', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
const totalStock = parseInt(form.value.totalStockStr, 10)
|
|
||||||
if (isNaN(totalStock) || totalStock < 1) {
|
|
||||||
uni.showToast({ title: '请填写有效库存', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
if (!form.value.startDate || !form.value.startTimeStr) {
|
|
||||||
uni.showToast({ title: '请选择开始时间', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
if (!form.value.endDate || !form.value.endTimeStr) {
|
|
||||||
uni.showToast({ title: '请选择结束时间', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = `${form.value.startDate}T${form.value.startTimeStr}:00`
|
|
||||||
const endTime = `${form.value.endDate}T${form.value.endTimeStr}:00`
|
|
||||||
|
|
||||||
if (new Date(endTime) <= new Date(startTime)) {
|
|
||||||
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
if (editTarget.value) {
|
|
||||||
await adminStore.updateFlashSale(editTarget.value.id, {
|
|
||||||
title: form.value.title.trim(),
|
|
||||||
originalPrice: Math.round(originalPrice * 100),
|
|
||||||
flashPrice: Math.round(flashPrice * 100),
|
|
||||||
totalStock,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
description: form.value.description.trim() || undefined,
|
|
||||||
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const selectedCardType = cardTypes.value[form.value.cardTypeIdx]
|
|
||||||
if (!selectedCardType) {
|
|
||||||
uni.showToast({ title: '请选择卡种', icon: 'none' }); return
|
|
||||||
}
|
|
||||||
await adminStore.createFlashSale({
|
|
||||||
cardTypeId: selectedCardType.id,
|
|
||||||
title: form.value.title.trim(),
|
|
||||||
originalPrice: Math.round(originalPrice * 100),
|
|
||||||
flashPrice: Math.round(flashPrice * 100),
|
|
||||||
totalStock,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
description: form.value.description.trim() || undefined,
|
|
||||||
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
closeModal()
|
|
||||||
await reloadSales()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : '保存失败'
|
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Actions ──────────────────────────────────────────
|
|
||||||
function confirmActivate(item: FlashSaleAdminItem) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认上线',
|
|
||||||
content: `上线后「${item.title}」将对用户可见,到达秒杀时间后用户可抢购。`,
|
|
||||||
confirmText: '上线',
|
|
||||||
confirmColor: '#27ae60',
|
|
||||||
success: async (res) => {
|
|
||||||
if (!res.confirm) return
|
|
||||||
uni.showLoading({ title: '上线中...' })
|
|
||||||
try {
|
|
||||||
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ACTIVE })
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '已上线', icon: 'success' })
|
|
||||||
await reloadSales()
|
|
||||||
} catch {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '上线失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmEnd(item: FlashSaleAdminItem) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认结束',
|
|
||||||
content: `结束后「${item.title}」将停止售卖,已购买的不受影响。`,
|
|
||||||
confirmText: '结束',
|
|
||||||
confirmColor: '#e67e22',
|
|
||||||
success: async (res) => {
|
|
||||||
if (!res.confirm) return
|
|
||||||
uni.showLoading({ title: '结束中...' })
|
|
||||||
try {
|
|
||||||
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ENDED })
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '已结束', icon: 'success' })
|
|
||||||
await reloadSales()
|
|
||||||
} catch {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(item: FlashSaleAdminItem) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除「${item.title}」?此操作不可恢复。`,
|
|
||||||
confirmText: '删除',
|
|
||||||
confirmColor: '#c0392b',
|
|
||||||
success: async (res) => {
|
|
||||||
if (!res.confirm) return
|
|
||||||
uni.showLoading({ title: '删除中...' })
|
|
||||||
try {
|
|
||||||
await adminStore.deleteFlashSale(item.id)
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
|
||||||
await reloadSales()
|
|
||||||
} catch {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────
|
|
||||||
onMounted(loadData)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f5f3f0;
|
|
||||||
padding-bottom: 40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toolbar ─────────────────────────────── */
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 24rpx 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-hint { font-size: 24rpx; color: #999; }
|
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
background: linear-gradient(135deg, #D4A59A, #C08B7E);
|
|
||||||
border-radius: 32rpx;
|
|
||||||
padding: 12rpx 28rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #fff; }
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
|
||||||
|
|
||||||
.skeleton-item {
|
|
||||||
height: 300rpx;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 100rpx 0;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
|
||||||
|
|
||||||
/* ── Flash sale list ─────────────────────── */
|
|
||||||
.fs-list { padding: 0 24rpx; }
|
|
||||||
|
|
||||||
.fs-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-header {
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header--active { background: linear-gradient(90deg, #D4A59A, #C08B7E); }
|
|
||||||
.header--draft { background: linear-gradient(90deg, #AEA49A, #9E948A); }
|
|
||||||
.header--ended { background: linear-gradient(90deg, #B0A898, #9A928A); }
|
|
||||||
|
|
||||||
.fs-header-left { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.fs-title {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-status-tag {
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 4rpx 16rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag--ongoing { background: rgba(255, 255, 255, 0.3); }
|
|
||||||
.tag--upcoming { background: rgba(255, 255, 255, 0.2); }
|
|
||||||
.tag--soldout { background: rgba(0, 0, 0, 0.2); }
|
|
||||||
.tag--ended { background: rgba(0, 0, 0, 0.3); }
|
|
||||||
|
|
||||||
.fs-status-text { font-size: 20rpx; color: #fff; font-weight: 600; }
|
|
||||||
|
|
||||||
.fs-body { padding: 24rpx; }
|
|
||||||
|
|
||||||
.fs-info-row { margin-bottom: 16rpx; }
|
|
||||||
|
|
||||||
.fs-card-type { font-size: 24rpx; color: #888; }
|
|
||||||
|
|
||||||
.fs-price-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 32rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-price-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-price-label { font-size: 20rpx; color: #aaa; }
|
|
||||||
|
|
||||||
.fs-price-value {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
|
|
||||||
&.flash { color: #B5725E; }
|
|
||||||
&.original { color: #aaa; text-decoration: line-through; font-weight: 400; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stock progress bar */
|
|
||||||
.fs-stock-bar {
|
|
||||||
height: 8rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 4rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-stock-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #D4A59A, #C08B7E);
|
|
||||||
border-radius: 4rpx;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-time-row { margin-top: 4rpx; }
|
|
||||||
|
|
||||||
.fs-time { font-size: 22rpx; color: #999; }
|
|
||||||
|
|
||||||
/* ── Actions ─────────────────────────────── */
|
|
||||||
.fs-actions {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1rpx solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-action-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20rpx 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-right: 1rpx solid #f5f5f5;
|
|
||||||
|
|
||||||
&:last-child { border-right: none; }
|
|
||||||
&:active { background: #f9f9f9; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-action-text { font-size: 26rpx; font-weight: 600; }
|
|
||||||
|
|
||||||
.edit-btn .fs-action-text { color: #1a1a2e; }
|
|
||||||
.activate-btn .fs-action-text { color: #27ae60; }
|
|
||||||
.end-btn .fs-action-text { color: #e67e22; }
|
|
||||||
.delete-btn .fs-action-text { color: #c0392b; }
|
|
||||||
|
|
||||||
/* ── Modal ───────────────────────────────── */
|
|
||||||
.modal-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 85vh;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-scroll { flex: 1; max-height: 85vh; }
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 32rpx 32rpx 16rpx;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #fff;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
width: 56rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-icon { font-size: 24rpx; color: #999; }
|
|
||||||
|
|
||||||
.modal-body { padding: 0 32rpx; }
|
|
||||||
|
|
||||||
.modal-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
gap: 16rpx;
|
|
||||||
|
|
||||||
&--last { border-bottom: none; align-items: flex-start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-label { font-size: 26rpx; color: #555; width: 160rpx; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
|
||||||
|
|
||||||
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
|
||||||
.picker-text { font-size: 26rpx; color: #222; }
|
|
||||||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
|
||||||
|
|
||||||
.datetime-picker-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datetime-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #222;
|
|
||||||
padding: 8rpx 16rpx;
|
|
||||||
background: #f8f8f8;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-textarea {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #222;
|
|
||||||
min-height: 80rpx;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1;
|
|
||||||
height: 88rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active { background: #e8e8e8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
|
||||||
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 2;
|
|
||||||
height: 88rpx;
|
|
||||||
background: linear-gradient(90deg, #D4A59A, #C08B7E);
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active { opacity: 0.85; }
|
|
||||||
&--loading { opacity: 0.6; pointer-events: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #fff; }
|
|
||||||
</style>
|
|
||||||
@@ -2,174 +2,85 @@
|
|||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="管理中心" show-back />
|
<CustomNavBar title="管理中心" show-back />
|
||||||
|
|
||||||
<!-- Stats summary card -->
|
<!-- Section: 课务运营 -->
|
||||||
<view class="stats-card-wrap">
|
|
||||||
<view class="stats-card">
|
|
||||||
<view v-if="statsLoading" class="stats-loading">
|
|
||||||
<view v-for="i in 3" :key="i" class="stat-skeleton" />
|
|
||||||
</view>
|
|
||||||
<template v-else>
|
|
||||||
<view class="stat-block">
|
|
||||||
<text class="stat-num">{{ stats.todayBookings }}</text>
|
|
||||||
<text class="stat-sub">今日预约</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-sep" />
|
|
||||||
<view class="stat-block">
|
|
||||||
<text class="stat-num">{{ stats.totalOrders }}</text>
|
|
||||||
<text class="stat-sub">总订单</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-sep" />
|
|
||||||
<view class="stat-block">
|
|
||||||
<text class="stat-num">{{ stats.totalBookings }}</text>
|
|
||||||
<text class="stat-sub">总预约</text>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Section header: 课程管理 -->
|
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">课程管理</text>
|
<text class="section-title">今日经营</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- List: schedule -->
|
|
||||||
<view class="list">
|
<view class="list">
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
|
<view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
|
||||||
<view class="item-left">
|
<text class="item-title">今日经营助手</text>
|
||||||
<view class="item-icon-wrap icon--bookings">
|
<text class="arrow-text">›</text>
|
||||||
<text class="item-icon-text">▣</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="item-text-group">
|
<view class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
|
||||||
<text class="item-title">预约管理</text>
|
<text class="item-title">身体画像线索</text>
|
||||||
<text class="item-desc">查看/确认/核销学员预约</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Section: 课务运营 -->
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">课务运营</text>
|
||||||
|
</view>
|
||||||
|
<view class="list">
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--schedule">
|
|
||||||
<text class="item-icon-text">◇</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">排课管理</text>
|
<text class="item-title">排课管理</text>
|
||||||
<text class="item-desc">管理每周课程时段</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
|
||||||
<view class="item-arrow">
|
<text class="item-title">预约管理</text>
|
||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Section: 教学复盘 -->
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">教学复盘</text>
|
||||||
|
</view>
|
||||||
|
<view class="list">
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
|
||||||
|
<text class="item-title">统计分析</text>
|
||||||
|
<text class="arrow-text">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
|
||||||
|
<text class="item-title">课后评价</text>
|
||||||
|
<text class="arrow-text">›</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Section header: 会员与订单 -->
|
<!-- Section: 会员与订单 -->
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">会员与订单</text>
|
<text class="section-title">会员与订单</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- List: members & orders -->
|
|
||||||
<view class="list">
|
<view class="list">
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/members')">
|
<view class="list-item" @tap="navigate('/pages/admin/members')">
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--members">
|
|
||||||
<text class="item-icon-text">◎</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">会员管理</text>
|
<text class="item-title">会员管理</text>
|
||||||
<text class="item-desc">查看所有会员信息</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/orders')">
|
<view class="list-item" @tap="navigate('/pages/admin/orders')">
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--orders">
|
|
||||||
<text class="item-icon-text">▣</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">订单管理</text>
|
<text class="item-title">订单管理</text>
|
||||||
<text class="item-desc">查看所有订单记录</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
|
<!-- Section: 系统设置 -->
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--card">
|
|
||||||
<text class="item-icon-text">▤</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">卡种管理</text>
|
|
||||||
<text class="item-desc">设置会员卡类型</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
|
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--flash-sale">
|
|
||||||
<text class="item-icon-text">◈</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">秒杀管理</text>
|
|
||||||
<text class="item-desc">创建和管理限时秒杀活动</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Section header: 系统 -->
|
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">系统</text>
|
<text class="section-title">系统设置</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- List: settings -->
|
|
||||||
<view class="list">
|
<view class="list">
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/studio')">
|
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
|
||||||
<view class="item-left">
|
<text class="item-title">卡种管理</text>
|
||||||
<view class="item-icon-wrap icon--studio">
|
|
||||||
<text class="item-icon-text">◉</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">工作室设置</text>
|
|
||||||
<text class="item-desc">工作室信息与配置</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/studio')">
|
||||||
|
<text class="item-title">工作室设置</text>
|
||||||
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
|
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--subscribe">
|
|
||||||
<text class="item-icon-text">✦</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">增加订阅次数</text>
|
<text class="item-title">增加订阅次数</text>
|
||||||
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>
|
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '›' }}</text>
|
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '›' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view style="height: 40rpx" />
|
<view style="height: 40rpx" />
|
||||||
</view>
|
</view>
|
||||||
@@ -180,37 +91,21 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import type { AdminStats } from '../../stores/admin'
|
|
||||||
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { user } = storeToRefs(userStore)
|
const { user } = storeToRefs(userStore)
|
||||||
|
|
||||||
const statsLoading = ref(false)
|
|
||||||
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
|
||||||
const adminSubscribeLoading = ref(false)
|
const adminSubscribeLoading = ref(false)
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
uni.navigateTo({ url: path })
|
uni.navigateTo({ url: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
statsLoading.value = true
|
|
||||||
try {
|
|
||||||
stats.value = await adminStore.fetchDashboardStats()
|
|
||||||
} catch {
|
|
||||||
// fail silently — stats are non-critical
|
|
||||||
} finally {
|
|
||||||
statsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleIncreaseSubscriptionCount() {
|
async function handleIncreaseSubscriptionCount() {
|
||||||
if (adminSubscribeLoading.value) {
|
if (adminSubscribeLoading.value) {
|
||||||
return
|
return
|
||||||
@@ -235,78 +130,18 @@ async function handleIncreaseSubscriptionCount() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
loadStats()
|
|
||||||
userStore.fetchProfile()
|
userStore.fetchProfile()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ── Page ───────────────────────────────────── */
|
/* ── Page ───────────────────────── */
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-bottom: 40rpx;
|
padding-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Stats card ─────────────────────────────── */
|
/* ── Section header ───────────────── */
|
||||||
.stats-card-wrap {
|
|
||||||
padding: 24rpx 24rpx 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 32rpx 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
|
|
||||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-loading {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-skeleton {
|
|
||||||
width: 100rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.6s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-block {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-num {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #4A4035;
|
|
||||||
line-height: 1;
|
|
||||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-sub {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #A09080;
|
|
||||||
letter-spacing: 0.5rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-sep {
|
|
||||||
width: 1rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
background: rgba(180, 160, 130, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section header ─────────────────────────── */
|
|
||||||
.section-header {
|
.section-header {
|
||||||
padding: 32rpx 24rpx 12rpx;
|
padding: 32rpx 24rpx 12rpx;
|
||||||
}
|
}
|
||||||
@@ -319,7 +154,7 @@ onMounted(() => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── List ───────────────────────────────────── */
|
/* ── List ───────────────────────── */
|
||||||
.list {
|
.list {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
margin: 0 24rpx;
|
margin: 0 24rpx;
|
||||||
@@ -332,8 +167,8 @@ onMounted(() => {
|
|||||||
.list-item {
|
.list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 16rpx;
|
||||||
padding: 28rpx 24rpx;
|
padding: 32rpx 28rpx;
|
||||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
@@ -346,64 +181,23 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-icon-wrap {
|
|
||||||
width: 72rpx;
|
|
||||||
height: 72rpx;
|
|
||||||
border-radius: 18rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-icon-text {
|
|
||||||
font-size: 32rpx;
|
|
||||||
color: #FFFFFF;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon variants — warm muted tones */
|
|
||||||
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
|
|
||||||
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
|
|
||||||
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
|
|
||||||
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
|
||||||
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
|
||||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
|
||||||
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
|
|
||||||
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
|
||||||
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
|
||||||
|
|
||||||
.item-text-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: #4A4035;
|
color: #4A4035;
|
||||||
letter-spacing: 0.5rpx;
|
letter-spacing: 0.5rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-desc {
|
.item-extra {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #A09080;
|
color: #A09080;
|
||||||
}
|
|
||||||
|
|
||||||
.item-arrow {
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-left: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow-text {
|
.arrow-text {
|
||||||
font-size: 40rpx;
|
flex-shrink: 0;
|
||||||
|
font-size: 36rpx;
|
||||||
color: rgba(180, 160, 130, 0.5);
|
color: rgba(180, 160, 130, 0.5);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
|||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { formatDate, isSlotPast } from '../../utils/format'
|
import { formatDate, isSlotPast } from '../../utils/format'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
|
|
||||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 ›</button></view>
|
||||||
|
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
|
||||||
|
<template v-if="activeTab === 'overview'">
|
||||||
<view class="section section--practice">
|
<view class="section section--practice">
|
||||||
<view class="section-heading">
|
<view class="section-heading">
|
||||||
<text class="section-label">上课情况</text>
|
<text class="section-label">上课情况</text>
|
||||||
@@ -138,8 +141,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<view class="dock">
|
<view v-if="activeTab === 'overview'" class="dock">
|
||||||
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||||||
<text class="dock-btn-text">编辑资料</text>
|
<text class="dock-btn-text">编辑资料</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -159,6 +163,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import MemberProgress from '../../components/MemberProgress.vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import {
|
import {
|
||||||
@@ -170,12 +175,13 @@ import {
|
|||||||
} from '../../utils/format'
|
} from '../../utils/format'
|
||||||
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
const userId = ref('')
|
const userId = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const activeTab = ref('overview'), progressRefreshKey = ref(0)
|
||||||
const detail = ref<AdminMemberDetail | null>(null)
|
const detail = ref<AdminMemberDetail | null>(null)
|
||||||
|
|
||||||
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||||||
@@ -224,6 +230,7 @@ async function loadDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
|
||||||
function goSupplement() {
|
function goSupplement() {
|
||||||
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
||||||
}
|
}
|
||||||
@@ -250,6 +257,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
progressRefreshKey.value++
|
||||||
if (userId.value) {
|
if (userId.value) {
|
||||||
loadDetail()
|
loadDetail()
|
||||||
}
|
}
|
||||||
@@ -257,6 +265,9 @@ onShow(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
|
||||||
|
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
--ink: #514943;
|
--ink: #514943;
|
||||||
--muted: #8b817b;
|
--muted: #8b817b;
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
|||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { formatDateLocal } from '../../utils/format'
|
import { formatDateLocal } from '../../utils/format'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|||||||
12
packages/app/src/pages/admin/member-progress.vue
Normal file
12
packages/app/src/pages/admin/member-progress.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress admin :user-id="userId" :booking-id="bookingId" /></view></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import MemberProgress from '../../components/MemberProgress.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
const navBarHeight = getSystemLayout().navBarHeight
|
||||||
|
const userId = ref(''), bookingId = ref('')
|
||||||
|
onLoad(query => { userId.value = String(query?.userId || ''); bookingId.value = String(query?.bookingId || '') })
|
||||||
|
</script>
|
||||||
|
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>
|
||||||
@@ -60,7 +60,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
|||||||
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
|
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { HttpRequestError } from '../../utils/request'
|
import { HttpRequestError } from '../../utils/request'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -101,8 +103,8 @@ import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
|||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { getCardTypeLabel } from '../../utils/format'
|
import { getCardTypeLabel } from '../../utils/format'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import type { MemberSummary } from '../../stores/admin'
|
import type { MemberSummary } from './stores/admin'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { formatPrice, formatDateTime } from '../../utils/format'
|
import { formatPrice, formatDateTime } from '../../utils/format'
|
||||||
import { OrderStatus } from '@mp-pilates/shared'
|
import { OrderStatus } from '@mp-pilates/shared'
|
||||||
import type { OrderWithDetails } from '@mp-pilates/shared'
|
import type { OrderWithDetails } from '@mp-pilates/shared'
|
||||||
|
|||||||
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal file
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="专业评估" show-back />
|
||||||
|
<view class="card" v-for="field in fields" :key="field.key">
|
||||||
|
<text class="name">{{ field.label }} {{ form.observations[field.key] }}/5</text>
|
||||||
|
<slider :min="1" :max="5" :value="form.observations[field.key]" @change="onObserve(field.key, $event)" />
|
||||||
|
</view>
|
||||||
|
<view class="card">
|
||||||
|
<text class="name">主观紧张 0-10</text>
|
||||||
|
<slider :min="0" :max="10" :value="form.subjectiveTension || 0" @change="onTension" />
|
||||||
|
</view>
|
||||||
|
<view class="card">
|
||||||
|
<textarea v-model="form.coachSummary" placeholder="教练观察" class="area" />
|
||||||
|
<textarea v-model="form.trainingFocus" placeholder="训练重点" class="area" />
|
||||||
|
<textarea v-model="form.phaseGoal" placeholder="第一阶段目标" class="area" />
|
||||||
|
</view>
|
||||||
|
<view class="cta" @tap="save">保存评估报告</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const admin = useAdminStore()
|
||||||
|
const userId = ref('')
|
||||||
|
const form = reactive({
|
||||||
|
kind: ProfessionalAssessmentKind.INITIAL,
|
||||||
|
recordedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
observations: { headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3, breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3 },
|
||||||
|
subjectiveTension: 5,
|
||||||
|
coachSummary: '',
|
||||||
|
trainingFocus: '',
|
||||||
|
phaseGoal: '',
|
||||||
|
})
|
||||||
|
const fields = [
|
||||||
|
{ key: 'headPosition', label: '头部位置' },
|
||||||
|
{ key: 'shoulderPosition', label: '肩部位置' },
|
||||||
|
{ key: 'thoracicExtension', label: '胸椎活动' },
|
||||||
|
{ key: 'shoulderFlexion', label: '肩屈活动' },
|
||||||
|
{ key: 'breathing', label: '呼吸模式' },
|
||||||
|
{ key: 'pelvis', label: '骨盆位置' },
|
||||||
|
{ key: 'coreControl', label: '核心控制' },
|
||||||
|
{ key: 'hipMobility', label: '髋部活动' },
|
||||||
|
{ key: 'singleLeg', label: '单腿稳定' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
onLoad((query) => { userId.value = String(query?.userId || '') })
|
||||||
|
|
||||||
|
function onObserve(key: keyof typeof form.observations, event: { detail: { value: number } }) {
|
||||||
|
form.observations[key] = Number(event.detail.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTension(event: { detail: { value: number } }) {
|
||||||
|
form.subjectiveTension = Number(event.detail.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
await admin.createProfessionalAssessment(userId.value, form)
|
||||||
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 400)
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
|
||||||
|
.card { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-top: 16rpx; }
|
||||||
|
.name { display: block; font-size: 26rpx; color: #4a4035; margin-bottom: 8rpx; }
|
||||||
|
.area { width: 100%; min-height: 120rpx; font-size: 26rpx; margin-top: 12rpx; }
|
||||||
|
.cta { margin-top: 32rpx; height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal file
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="线索详情" show-back />
|
||||||
|
<view v-if="lead" class="card">
|
||||||
|
<text class="name">{{ lead.nickname }} · {{ lead.phone || '未留手机' }}</text>
|
||||||
|
<text class="detail">来源 {{ lead.source || 'organic' }} · {{ lead.campaignId || '无活动' }}</text>
|
||||||
|
<text class="detail">画像 {{ lead.report?.headline || '尚未生成' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="lead?.report" class="card">
|
||||||
|
<text class="name">五维关注度</text>
|
||||||
|
<text class="detail">肩颈 {{ lead.report.scores.cervicalShoulder }} · 脊柱 {{ lead.report.scores.spinalMobility }} · 核心 {{ lead.report.scores.coreControl }} · 髋骨盆 {{ lead.report.scores.hipPelvis }} · 下肢 {{ lead.report.scores.lowerLimb }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="lead?.safetyFlagged" class="card"><text class="name">安全分流</text><text class="detail">不要强推体验课,先确认运动条件。</text></view>
|
||||||
|
<view class="card">
|
||||||
|
<text class="name">首次评估建议</text>
|
||||||
|
<text v-for="hint in lead?.firstAssessmentHints || []" :key="hint" class="detail">□ {{ hint }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card">
|
||||||
|
<text class="name">跟进文案</text>
|
||||||
|
<text class="draft">{{ lead?.followUpDraft }}</text>
|
||||||
|
<view class="cta" @tap="copy">复制 → 微信发送</view>
|
||||||
|
</view>
|
||||||
|
<view class="cta ghost" @tap="goAssess">记录线下评估</view>
|
||||||
|
<view class="cta ghost" @tap="goPlan">生成 12 周计划</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import type { GrowthLeadDetail } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const admin = useAdminStore()
|
||||||
|
const lead = ref<GrowthLeadDetail | null>(null)
|
||||||
|
const leadId = ref('')
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
leadId.value = String(query?.id || '')
|
||||||
|
lead.value = await admin.fetchGrowthLead(leadId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
if (!lead.value) return
|
||||||
|
uni.setClipboardData({ data: lead.value.followUpDraft })
|
||||||
|
}
|
||||||
|
function goAssess() {
|
||||||
|
if (!lead.value) return
|
||||||
|
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${lead.value.userId}` })
|
||||||
|
}
|
||||||
|
function goPlan() {
|
||||||
|
if (!lead.value) return
|
||||||
|
uni.navigateTo({ url: `/pages/admin/portrait-plan?userId=${lead.value.userId}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
|
||||||
|
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.detail, .draft { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.7; }
|
||||||
|
.cta { margin-top: 20rpx; height: 84rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.ghost { background: #efe8e1; color: #5d5148; }
|
||||||
|
</style>
|
||||||
41
packages/app/src/pages/admin/portrait-leads.vue
Normal file
41
packages/app/src/pages/admin/portrait-leads.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="身体画像线索" show-back />
|
||||||
|
<view v-for="item in items" :key="item.id" class="card" @tap="open(item.id)">
|
||||||
|
<text class="name">{{ item.nickname }} · {{ stageLabel(item.stage) }}</text>
|
||||||
|
<text class="detail">{{ item.phone || '未留手机号' }} · {{ item.source || 'organic' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import { GROWTH_LEAD_STAGE_LABELS, type GrowthLeadStage, type GrowthLeadSummary } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const admin = useAdminStore()
|
||||||
|
const items = ref<GrowthLeadSummary[]>([])
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
const result = await admin.fetchGrowthLeads()
|
||||||
|
items.value = [...result.items]
|
||||||
|
})
|
||||||
|
|
||||||
|
function stageLabel(stage: GrowthLeadStage) {
|
||||||
|
return GROWTH_LEAD_STAGE_LABELS[stage] || stage
|
||||||
|
}
|
||||||
|
function open(id: string) {
|
||||||
|
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 40rpx; }
|
||||||
|
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.detail { display: block; margin-top: 8rpx; color: #7a6a5a; font-size: 24rpx; }
|
||||||
|
</style>
|
||||||
46
packages/app/src/pages/admin/portrait-plan.vue
Normal file
46
packages/app/src/pages/admin/portrait-plan.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="12 周改善计划" show-back />
|
||||||
|
<text class="lead">生成后学员可在小程序看到三阶段计划,而不是“买 12 节课”。</text>
|
||||||
|
<view class="cta" @tap="create">生成计划</view>
|
||||||
|
<view v-if="plan" class="card">
|
||||||
|
<text class="name">{{ plan.title }}</text>
|
||||||
|
<text v-for="phase in plan.phases" :key="phase.id" class="detail">{{ phase.name }} · 第 {{ phase.lessonStart }}-{{ phase.lessonEnd }} 节</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import type { TrainingPlanRecord } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const admin = useAdminStore()
|
||||||
|
const userId = ref('')
|
||||||
|
const plan = ref<TrainingPlanRecord | null>(null)
|
||||||
|
|
||||||
|
onLoad((query) => { userId.value = String(query?.userId || '') })
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
try {
|
||||||
|
plan.value = await admin.createTrainingPlan(userId.value, { title: '你的 12 周身体改善计划', weeks: 12 })
|
||||||
|
uni.showToast({ title: '已生成', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '生成失败'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
|
||||||
|
.lead { display: block; padding: 24rpx 8rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
|
||||||
|
.cta { height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.card { margin-top: 24rpx; background: #fff; border-radius: 20rpx; padding: 28rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; }
|
||||||
|
</style>
|
||||||
55
packages/app/src/pages/admin/portrait-today.vue
Normal file
55
packages/app/src/pages/admin/portrait-today.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="今日经营助手" show-back />
|
||||||
|
<view class="hero">
|
||||||
|
<text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="handleTask(task)">
|
||||||
|
<text class="name">{{ task.title }}</text>
|
||||||
|
<text class="detail">{{ task.detail }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card">
|
||||||
|
<text class="name">渠道转化</text>
|
||||||
|
<text v-for="row in dashboard.funnel" :key="row.source" class="detail">
|
||||||
|
{{ row.source }} · 测评 {{ row.completed }} · 预约 {{ row.booked }} · 到店 {{ row.attended }} · 成交 {{ row.purchased }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="link" @tap="navigate('/pages/admin/portrait-leads')">全部线索 ›</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
import type { GrowthTodayDashboard, GrowthTodayTask } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const admin = useAdminStore()
|
||||||
|
const dashboard = ref<GrowthTodayDashboard>({ tasks: [], funnel: [] })
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
dashboard.value = await admin.fetchGrowthToday()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTask(task: GrowthTodayTask) {
|
||||||
|
if (task.kind === 'reassessment_due') {
|
||||||
|
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${task.userId}` })
|
||||||
|
} else if (task.leadId && task.leadId.length >= 20) {
|
||||||
|
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${task.leadId}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function navigate(path: string) { uni.navigateTo({ url: path }) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
|
||||||
|
.hero { padding: 24rpx 8rpx; }
|
||||||
|
.title { font-size: 36rpx; color: #4a4035; }
|
||||||
|
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 16rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.6; }
|
||||||
|
.link { padding: 24rpx 8rpx; color: #6b8276; }
|
||||||
|
</style>
|
||||||
53
packages/app/src/pages/admin/reviews.vue
Normal file
53
packages/app/src/pages/admin/reviews.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||||
|
<CustomNavBar :title="userId ? '学员评价历史' : '课后评价'" show-back />
|
||||||
|
<view class="content">
|
||||||
|
<text class="kicker">LISTEN & GROW</text><text class="title">听见每一次练习。</text><text class="intro">真实的感受,是下一次更好教学的起点。</text>
|
||||||
|
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="!userId && trend.length">
|
||||||
|
<picker mode="date" fields="month" :value="month" :end="today" @change="changeMonth($event.detail.value)"><view class="month-picker">{{ month }} 教学反馈 <text>切换月份 ›</text></view></picker>
|
||||||
|
<view class="summary"><view><text class="number">{{ current?.average ?? '—' }}</text><text>星级均分 / 5</text><text class="muted">{{ current?.count || 0 }} 份评价</text></view><view><text class="number">{{ current?.nps ?? '—' }}</text><text>净推荐值 NPS</text><text class="muted">{{ current?.npsCount || 0 }} 份推荐评分</text></view></view>
|
||||||
|
<view class="panel"><text class="section-title">近六个月 · 星级均分</text><view class="chart"><view v-for="item in trend" :key="item.month" class="column"><text>{{ item.average ?? '—' }}</text><view class="track"><view class="bar" :style="{ height: ((item.average || 0) / 5 * 100) + '%' }" /></view><text class="muted">{{ item.month.slice(5) }} 月</text></view></view></view>
|
||||||
|
<view class="panel"><text class="section-title">推荐意愿趋势</text><view v-for="item in trend" :key="item.month" class="trend-row"><text>{{ item.month }}</text><text>{{ item.nps === null ? '暂无样本' : 'NPS ' + item.nps }}</text><text class="muted">{{ item.npsCount }} 人</text></view><text class="footnote">推荐意愿 9–10 分为推荐者,0–6 分为贬损者;NPS = 推荐者占比 − 贬损者占比。仅统计填写推荐分的评价,星级不参与 NPS。</text></view>
|
||||||
|
</template>
|
||||||
|
<view class="list-heading"><text>评价原声</text><text class="muted">{{ total }} 份 · 按提交时间</text></view>
|
||||||
|
<view v-if="!rows.length && !loading" class="state">还没有评价<text class="intro">学员完成课程后,可在预约详情留下反馈。</text></view>
|
||||||
|
<view v-for="row in rows" :key="row.id" class="panel">
|
||||||
|
<view class="list-heading"><button class="member" @tap="openMember(row.booking.userId)">{{ row.booking.user.nickname || '学员' }} ›</button><text class="stars">{{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}</text></view>
|
||||||
|
<text class="lesson">{{ formatChinaDate(row.booking.timeSlot.date) }} · {{ row.booking.timeSlot.startTime }}–{{ row.booking.timeSlot.endTime }}</text>
|
||||||
|
<view class="tags"><text v-for="tag in row.tags" :key="tag">{{ tag }}</text></view><text class="comment">{{ row.comment || '这位学员留下了星级评分。' }}</text>
|
||||||
|
<view class="list-heading"><text class="muted">{{ formatChinaDate(row.createdAt) }} 提交</text><text v-if="row.recommendation !== null" class="muted">推荐意愿 {{ row.recommendation }} / 10</text></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="loading" class="state">正在读取反馈…</view><button v-else-if="rows.length < total" class="more" @tap="loadMore">加载更多</button>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { formatChinaDate } from '../../utils/format'
|
||||||
|
import { useAdminStore } from './stores/admin'
|
||||||
|
const store = useAdminStore(), navBarHeight = getSystemLayout().navBarHeight
|
||||||
|
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), month = ref(today.slice(0, 7)), userId = ref('')
|
||||||
|
const rows = ref<ReviewEntry[]>([]), trend = ref<ReviewSummary[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref('')
|
||||||
|
const current = computed(() => trend.value[trend.value.length - 1])
|
||||||
|
let sequence = 0
|
||||||
|
async function reload() {
|
||||||
|
const seq = ++sequence; loading.value = true; error.value = ''
|
||||||
|
try { const [list, summary] = await Promise.all([store.fetchReviews(userId.value, 1), userId.value ? Promise.resolve([]) : store.fetchReviewTrend(month.value)]); if (seq !== sequence) return; rows.value = list.data; total.value = list.total; page.value = 1; trend.value = summary }
|
||||||
|
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '加载失败' } finally { if (seq === sequence) loading.value = false }
|
||||||
|
}
|
||||||
|
async function loadMore() { if (loading.value) return; loading.value = true; const seq = sequence; try { const list = await store.fetchReviews(userId.value, page.value + 1); if (seq !== sequence) return; rows.value = [...rows.value, ...list.data]; total.value = list.total; page.value++ } catch { uni.showToast({ title: '加载失败,请重试', icon: 'none' }) } finally { if (seq === sequence) loading.value = false } }
|
||||||
|
function changeMonth(value: string) { month.value = value.slice(0, 7); void reload() }
|
||||||
|
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${id}` }) }
|
||||||
|
onLoad(query => { userId.value = String(query?.userId || '') })
|
||||||
|
onShow(() => { void reload() })
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;color:#514943;}.content{padding:36rpx 32rpx 80rpx;}.kicker{display:block;font-size:21rpx;letter-spacing:4rpx;color:#8b806e;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;margin:22rpx 0 14rpx;}.intro{display:block;font-size:25rpx;color:#8b8174;line-height:1.8;margin-bottom:30rpx;}.month-picker{display:flex;justify-content:space-between;align-items:center;min-height:88rpx;font-size:28rpx;}.month-picker text{font-size:23rpx;color:#728065;}.summary{display:flex;padding:34rpx 0;background:#e9eee3;border:1rpx solid #dce3d3;border-radius:24rpx;margin:12rpx 0 24rpx;}.summary>view{flex:1;text-align:center;border-right:1rpx solid #d2dac9;}.summary>view:last-child{border:0;}.summary text{display:block;font-size:24rpx;line-height:1.8;}.summary .number{font-size:64rpx;font-family:'Baskerville',serif;color:#576b50;line-height:1.2;margin-bottom:10rpx;}.summary .muted,.muted{font-size:22rpx;color:#8b8276;line-height:1.7;}.panel{border:1rpx solid #e6dfd4;border-radius:24rpx;background:#fffdf9;padding:28rpx;margin-bottom:22rpx;}.section-title{font-size:28rpx;}.chart{display:flex;gap:12rpx;margin-top:25rpx;}.column{flex:1;text-align:center;font-size:23rpx;color:#62715c;}.track{height:140rpx;display:flex;align-items:flex-end;justify-content:center;margin:14rpx 0;}.bar{width:34rpx;background:#97aa89;border-radius:6rpx 6rpx 0 0;}.trend-row{display:flex;justify-content:space-between;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid #eee8df;font-size:24rpx;}.footnote{display:block;font-size:22rpx;line-height:1.8;color:#938575;margin-top:22rpx;}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:50rpx;font-size:29rpx;}.content>.list-heading{margin:34rpx 0 20rpx;}.member{margin:0;padding:0;background:transparent;color:#5f6856;font-size:28rpx;line-height:64rpx;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.member::after,.more::after{border:0;}.stars{font-size:29rpx;color:#ab8951;}.lesson{display:block;font-size:23rpx;color:#928575;margin:10rpx 0 22rpx;}.tags{display:flex;flex-wrap:wrap;gap:12rpx;}.tags text{padding:7rpx 16rpx;font-size:22rpx;border-radius:10rpx;background:#eff1e8;color:#748064;}.comment{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:28rpx;line-height:1.9;margin:22rpx 0;}.state{padding:50rpx 15rpx;text-align:center;font-size:26rpx;color:#8b806f;line-height:1.8;}.more{font-size:26rpx;background:#ebece2;color:#69745b;border-radius:20rpx;line-height:88rpx;}
|
||||||
|
</style>
|
||||||
@@ -160,9 +160,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||||
|
import { TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
import {
|
import {
|
||||||
@@ -170,7 +171,7 @@ import {
|
|||||||
timeToPickerIndex,
|
timeToPickerIndex,
|
||||||
pickerIndexToTime,
|
pickerIndexToTime,
|
||||||
addOneHourCapped,
|
addOneHourCapped,
|
||||||
} from '../../utils/schedule-time'
|
} from './utils/schedule-time'
|
||||||
|
|
||||||
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||||
|
|
||||||
@@ -181,6 +182,7 @@ interface EditableSlot {
|
|||||||
endTime: string
|
endTime: string
|
||||||
capacity: number
|
capacity: number
|
||||||
bookedCount: number
|
bookedCount: number
|
||||||
|
status: TimeSlotStatus
|
||||||
isPublished: boolean
|
isPublished: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
isRemoved: boolean
|
isRemoved: boolean
|
||||||
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
|
|||||||
endTime: p.endTime,
|
endTime: p.endTime,
|
||||||
capacity: p.capacity,
|
capacity: p.capacity,
|
||||||
bookedCount: p.bookedCount,
|
bookedCount: p.bookedCount,
|
||||||
|
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
|
||||||
isPublished: p.isPublished,
|
isPublished: p.isPublished,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
isRemoved: false,
|
isRemoved: false,
|
||||||
@@ -323,6 +326,7 @@ function submitAdd() {
|
|||||||
endTime: addForm.value.endTime,
|
endTime: addForm.value.endTime,
|
||||||
capacity,
|
capacity,
|
||||||
bookedCount: 0,
|
bookedCount: 0,
|
||||||
|
status: TimeSlotStatus.OPEN,
|
||||||
isPublished: false,
|
isPublished: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
isRemoved: false,
|
isRemoved: false,
|
||||||
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
|
|||||||
// ── Style helpers ─────────────────────────────────────────
|
// ── Style helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
function slotCardClass(slot: EditableSlot): string {
|
function slotCardClass(slot: EditableSlot): string {
|
||||||
|
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
|
||||||
if (slot.isNew) return 'slot-card--new'
|
if (slot.isNew) return 'slot-card--new'
|
||||||
if (slot.isPublished) return 'slot-card--published'
|
if (slot.isPublished) return 'slot-card--published'
|
||||||
return 'slot-card--template'
|
return 'slot-card--template'
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotBadgeClass(slot: EditableSlot): string {
|
function slotBadgeClass(slot: EditableSlot): string {
|
||||||
|
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
|
||||||
if (slot.isNew) return 'badge--new'
|
if (slot.isNew) return 'badge--new'
|
||||||
if (slot.isPublished) return 'badge--published'
|
if (slot.isPublished) return 'badge--published'
|
||||||
return 'badge--template'
|
return 'badge--template'
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotBadgeText(slot: EditableSlot): string {
|
function slotBadgeText(slot: EditableSlot): string {
|
||||||
|
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||||
if (slot.isNew) return '新增'
|
if (slot.isNew) return '新增'
|
||||||
if (slot.isPublished) return '已发布'
|
if (slot.isPublished) return '已发布'
|
||||||
return '默认时段'
|
return '默认时段'
|
||||||
@@ -495,6 +502,12 @@ onMounted(() => {
|
|||||||
border-color: #3498db;
|
border-color: #3498db;
|
||||||
background: rgba(52, 152, 219, 0.04);
|
background: rgba(52, 152, 219, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--closed {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: #fafafa;
|
||||||
|
border-color: #e5e5e5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Slot header ─────────────────────────── */
|
/* ── Slot header ─────────────────────────── */
|
||||||
@@ -516,6 +529,8 @@ onMounted(() => {
|
|||||||
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
|
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
|
||||||
.badge--new { background: rgba(52, 152, 219, 0.1); }
|
.badge--new { background: rgba(52, 152, 219, 0.1); }
|
||||||
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
|
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
|
||||||
|
.badge--closed { background: rgba(0, 0, 0, 0.06); }
|
||||||
|
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
|
||||||
|
|
||||||
.booked-info { }
|
.booked-info { }
|
||||||
.booked-text { font-size: 22rpx; color: #e67e22; }
|
.booked-text { font-size: 22rpx; color: #e67e22; }
|
||||||
|
|||||||
@@ -152,14 +152,14 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import type { TimeSlot } from '@mp-pilates/shared'
|
import type { TimeSlot } from '@mp-pilates/shared'
|
||||||
import {
|
import {
|
||||||
SCHEDULE_TIME_PICKER_RANGE,
|
SCHEDULE_TIME_PICKER_RANGE,
|
||||||
timeToPickerIndex,
|
timeToPickerIndex,
|
||||||
pickerIndexToTime,
|
pickerIndexToTime,
|
||||||
} from '../../utils/schedule-time'
|
} from './utils/schedule-time'
|
||||||
|
|
||||||
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
|
||||||
import { defineStore } from 'pinia'
|
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,
|
||||||
@@ -13,9 +15,6 @@ import type {
|
|||||||
PaginatedData,
|
PaginatedData,
|
||||||
ScheduleSlotPreview,
|
ScheduleSlotPreview,
|
||||||
PublishDaySlotsDto,
|
PublishDaySlotsDto,
|
||||||
FlashSaleAdminItem,
|
|
||||||
CreateFlashSaleDto,
|
|
||||||
UpdateFlashSaleDto,
|
|
||||||
CreateStudioUploadCredentialDto,
|
CreateStudioUploadCredentialDto,
|
||||||
StudioUploadCredential,
|
StudioUploadCredential,
|
||||||
AdminMemberSummary,
|
AdminMemberSummary,
|
||||||
@@ -26,6 +25,13 @@ import type {
|
|||||||
AdminArrangeBookingDto,
|
AdminArrangeBookingDto,
|
||||||
MembershipWithCardType,
|
MembershipWithCardType,
|
||||||
BookingWithDetails,
|
BookingWithDetails,
|
||||||
|
GrowthTodayDashboard,
|
||||||
|
GrowthLeadSummary,
|
||||||
|
GrowthLeadDetail,
|
||||||
|
ProfessionalAssessmentSessionRecord,
|
||||||
|
CreateProfessionalAssessmentDto,
|
||||||
|
TrainingPlanRecord,
|
||||||
|
CreateTrainingPlanDto,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
interface LegacyPaginatedData<T> {
|
interface LegacyPaginatedData<T> {
|
||||||
@@ -55,12 +61,6 @@ function normalizePaginatedData<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
|
||||||
todayBookings: number
|
|
||||||
totalOrders: number
|
|
||||||
totalBookings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MemberSummary = AdminMemberSummary
|
export type MemberSummary = AdminMemberSummary
|
||||||
|
|
||||||
export interface UserMembership {
|
export interface UserMembership {
|
||||||
@@ -83,6 +83,9 @@ export interface UserMembership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminStore = defineStore('admin', () => {
|
export const useAdminStore = defineStore('admin', () => {
|
||||||
|
async function fetchReviews(userId = '', page = 1) { return get<{ data: ReviewEntry[]; total: number }>('/admin/reviews', { ...(userId ? { userId } : {}), page }) }
|
||||||
|
async function fetchReviewTrend(month: string) { return get<ReviewSummary[]>('/admin/reviews/trend', { month }) }
|
||||||
|
|
||||||
// ── Card types ───────────────────────────────────────────────────
|
// ── Card types ───────────────────────────────────────────────────
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|
||||||
@@ -262,32 +265,40 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
await fetchSchedulePreview(dto.date)
|
await fetchSchedulePreview(dto.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard stats ──────────────────────────────────────────────
|
// ── Teaching analytics ─────────────────────────────────────────
|
||||||
async function fetchDashboardStats(): Promise<AdminStats> {
|
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
|
||||||
return get<AdminStats>('/admin/stats')
|
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Flash sales ─────────────────────────────────────────────────
|
async function fetchGrowthToday() {
|
||||||
async function fetchFlashSales(params?: {
|
return get<GrowthTodayDashboard>('/admin/growth/today')
|
||||||
page?: number
|
|
||||||
limit?: number
|
|
||||||
}): Promise<PaginatedData<FlashSaleAdminItem>> {
|
|
||||||
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
|
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
|
||||||
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
|
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
|
async function fetchGrowthLead(id: string) {
|
||||||
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
|
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
|
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
|
||||||
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
|
return post<ProfessionalAssessmentSessionRecord>(
|
||||||
|
`/admin/members/${userId}/professional-assessments`,
|
||||||
|
dto as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
|
||||||
|
return post<TrainingPlanRecord>(
|
||||||
|
`/admin/members/${userId}/training-plans`,
|
||||||
|
dto as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
fetchReviews, fetchReviewTrend,
|
||||||
|
fetchTeachingAnalytics,
|
||||||
// State
|
// State
|
||||||
cardTypes,
|
cardTypes,
|
||||||
studioConfig,
|
studioConfig,
|
||||||
@@ -326,12 +337,10 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
fetchSchedulePreview,
|
fetchSchedulePreview,
|
||||||
previewScheduleByDate,
|
previewScheduleByDate,
|
||||||
publishDaySlots,
|
publishDaySlots,
|
||||||
// Stats
|
fetchGrowthToday,
|
||||||
fetchDashboardStats,
|
fetchGrowthLeads,
|
||||||
// Flash sales
|
fetchGrowthLead,
|
||||||
fetchFlashSales,
|
createProfessionalAssessment,
|
||||||
createFlashSale,
|
createTrainingPlan,
|
||||||
updateFlashSale,
|
|
||||||
deleteFlashSale,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -197,9 +197,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from './stores/admin'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { uploadStudioAsset } from '../../utils/studio-upload'
|
import { uploadStudioAsset } from './utils/studio-upload'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
|
|||||||
@@ -89,6 +89,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
|
||||||
|
<view v-if="canSubscribeClassReminder" class="panel">
|
||||||
|
<button class="review-subscribe" @tap="subscribeClassReminder">🔔 订阅开课前 1 小时微信提醒</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="canSubscribeReview" class="panel">
|
||||||
|
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评,24 小时后提醒我' : '订阅课后评价提醒' }}</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="isAdmin && booking?.status === BookingStatus.COMPLETED" class="panel">
|
||||||
|
<button class="review-subscribe" @tap="openProgress">为这次课写评语</button>
|
||||||
|
</view>
|
||||||
<view v-if="showReminders" class="panel">
|
<view v-if="showReminders" class="panel">
|
||||||
<text class="panel-title">上课前</text>
|
<text class="panel-title">上课前</text>
|
||||||
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
|
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
|
||||||
@@ -179,8 +189,8 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<BookingConfirmPopup
|
<BookingConfirmPopup
|
||||||
v-if="isSlotMode"
|
v-if="showConfirmPopup"
|
||||||
:visible="showConfirmPopup"
|
:visible="true"
|
||||||
:time-slot="slotData"
|
:time-slot="slotData"
|
||||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||||
@confirm="onConfirmBooking"
|
@confirm="onConfirmBooking"
|
||||||
@@ -199,7 +209,7 @@ import type {
|
|||||||
TimeSlotWithBookingStatus,
|
TimeSlotWithBookingStatus,
|
||||||
MembershipWithCardType,
|
MembershipWithCardType,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
@@ -212,8 +222,42 @@ import {
|
|||||||
bookingTimelineDotClass,
|
bookingTimelineDotClass,
|
||||||
} from '../../utils/booking-helpers'
|
} from '../../utils/booking-helpers'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import ClassReviewForm from '../../components/ClassReviewForm.vue'
|
||||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
requestSubscriptionMessage,
|
||||||
|
requestBookingCancelSubscriptionMessage,
|
||||||
|
requestClassReminderSubscriptionMessage,
|
||||||
|
cacheSubscriptionMessageTemplateConfig,
|
||||||
|
} from '../../utils/wechat-subscription'
|
||||||
|
import { get } from '../../utils/request'
|
||||||
|
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const canSubscribeClassReminder = computed(() => {
|
||||||
|
if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
|
||||||
|
if (booking.value.userId !== userStore.user?.id) return false
|
||||||
|
const slot = booking.value.timeSlot
|
||||||
|
if (!slot) return false
|
||||||
|
return !isSlotPast(slot.date, slot.startTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function subscribeClassReminder() {
|
||||||
|
try {
|
||||||
|
const results = await requestClassReminderSubscriptionMessage()
|
||||||
|
uni.showToast({
|
||||||
|
title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeReview() {
|
||||||
|
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
|
||||||
|
}
|
||||||
|
function openProgress() { if (booking.value) uni.navigateTo({ url: `/pages/admin/member-progress?userId=${booking.value.userId}&bookingId=${booking.value.id}` }) }
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@@ -232,6 +276,12 @@ const slotData = ref<TimeSlotWithBookingStatus | null>(null)
|
|||||||
const showConfirmPopup = ref(false)
|
const showConfirmPopup = ref(false)
|
||||||
|
|
||||||
const isAdmin = computed(() => userStore.isAdmin)
|
const isAdmin = computed(() => userStore.isAdmin)
|
||||||
|
const canSubscribeReview = computed(() => {
|
||||||
|
const current = booking.value
|
||||||
|
if (!current || current.userId !== userStore.user?.id) return false
|
||||||
|
if (current.status === BookingStatus.CONFIRMED) return true
|
||||||
|
return current.status === BookingStatus.COMPLETED && !current.review
|
||||||
|
})
|
||||||
const showActions = computed(() =>
|
const showActions = computed(() =>
|
||||||
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
||||||
booking.value?.status === BookingStatus.CONFIRMED,
|
booking.value?.status === BookingStatus.CONFIRMED,
|
||||||
@@ -584,6 +634,12 @@ async function handleNoShow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel() {
|
async function handleCancel() {
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
content: '确定要取消该预约?',
|
content: '确定要取消该预约?',
|
||||||
@@ -614,6 +670,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
|
void get<SubscriptionMessageTemplateConfig>('/user/subscription-messages/templates').then(cacheSubscriptionMessageTemplateConfig).catch(() => {})
|
||||||
updateLayout()
|
updateLayout()
|
||||||
const q = query as Record<string, string>
|
const q = query as Record<string, string>
|
||||||
|
|
||||||
@@ -637,6 +694,7 @@ function updateLayout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.review-subscribe {font-size:26rpx;background:#eaf0e8;color:#536f60;border-radius:20rpx;min-height:88rpx;line-height:88rpx;&::after{border:0;}}
|
||||||
.page {
|
.page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -1003,7 +1061,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 +1106,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 +1122,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 +1140,7 @@ function updateLayout() {
|
|||||||
|
|
||||||
&--ghost {
|
&--ghost {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1rpx solid #eee8e3;
|
border: 1rpx solid #d8cec5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
&--danger {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<scroll-view
|
<scroll-view
|
||||||
class="slot-scroll"
|
class="slot-scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
|
:scroll-into-view="targetSlotId"
|
||||||
|
scroll-with-animation
|
||||||
refresher-enabled
|
refresher-enabled
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
@@ -52,15 +54,19 @@
|
|||||||
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
|
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<SlotCard
|
<view
|
||||||
v-for="item in filteredSlots"
|
v-for="item in filteredSlots"
|
||||||
|
:id="`slot-${item.id}`"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<SlotCard
|
||||||
:time-slot="item"
|
:time-slot="item"
|
||||||
@book="onBookTap"
|
@book="onBookTap"
|
||||||
@cancel="onCancelTap"
|
@cancel="onCancelTap"
|
||||||
@card-tap="onSlotCardTap"
|
@card-tap="onSlotCardTap"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Bottom padding spacer -->
|
<!-- Bottom padding spacer -->
|
||||||
<view class="scroll-bottom-spacer" />
|
<view class="scroll-bottom-spacer" />
|
||||||
@@ -68,7 +74,8 @@
|
|||||||
|
|
||||||
<!-- ──────────── Confirm popup ──────────── -->
|
<!-- ──────────── Confirm popup ──────────── -->
|
||||||
<BookingConfirmPopup
|
<BookingConfirmPopup
|
||||||
:visible="showConfirmPopup"
|
v-if="showConfirmPopup"
|
||||||
|
:visible="true"
|
||||||
:time-slot="pendingSlot"
|
:time-slot="pendingSlot"
|
||||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||||
@confirm="onConfirmBooking"
|
@confirm="onConfirmBooking"
|
||||||
@@ -78,19 +85,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
|
||||||
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate, isSlotPast } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
import SlotCard from '../../components/SlotCard.vue'
|
import SlotCard from '../../components/SlotCard.vue'
|
||||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
|
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
|
|
||||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||||
|
|
||||||
@@ -104,6 +112,10 @@ const selectedPeriod = ref<PeriodKey>(null)
|
|||||||
const showConfirmPopup = ref(false)
|
const showConfirmPopup = ref(false)
|
||||||
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
const targetSlotId = ref('')
|
||||||
|
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后,
|
||||||
|
// 切换日期/时段或下拉刷新后不再重置位置,避免打断用户的浏览位置。
|
||||||
|
const hasAutoScrolled = ref(false)
|
||||||
|
|
||||||
// ─── 微信分享 ───────────────────────────────────────────────
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
@@ -167,6 +179,94 @@ async function onRefresh() {
|
|||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
let isAutoScrolling = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询等待目标节点在视图层完成挂载与排版
|
||||||
|
*/
|
||||||
|
function waitForElement(selector: string, maxRetries = 10, interval = 50): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let retries = 0
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
const query = instance?.proxy
|
||||||
|
? uni.createSelectorQuery().in(instance.proxy)
|
||||||
|
: uni.createSelectorQuery()
|
||||||
|
|
||||||
|
const q = query
|
||||||
|
.select(selector)
|
||||||
|
.boundingClientRect((data) => {
|
||||||
|
const node = Array.isArray(data) ? data[0] : data
|
||||||
|
if (node && node.top !== undefined) {
|
||||||
|
resolve(true)
|
||||||
|
} else if (retries < maxRetries) {
|
||||||
|
retries++
|
||||||
|
setTimeout(check, interval)
|
||||||
|
} else {
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 触发查询
|
||||||
|
q['exec']()
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次进入时滚动到当天第一个未开始的课程("本时段及以后")
|
||||||
|
async function scrollToUpcoming() {
|
||||||
|
if (hasAutoScrolled.value || isAutoScrolling) return
|
||||||
|
if (bookingStore.loadingSlots) return
|
||||||
|
|
||||||
|
const slots = filteredSlots.value
|
||||||
|
if (slots.length === 0) {
|
||||||
|
// 列表还没加载出来(加载中或当天无课),不要把「仅一次」用掉。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingIndex = slots.findIndex((slot) => !isSlotPast(slot.date, slot.startTime))
|
||||||
|
if (upcomingIndex === -1) {
|
||||||
|
// 当天所有课程均已结束,标记已滚动过,留在当前位置
|
||||||
|
hasAutoScrolled.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcomingIndex === 0) {
|
||||||
|
// 本时段及以后的第一个课程正好是列表第 1 项,页面已经在顶部,无需额外滚动
|
||||||
|
hasAutoScrolled.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoScrolling = true
|
||||||
|
const dateWhenStarted = selectedDate.value
|
||||||
|
const upcoming = slots[upcomingIndex]
|
||||||
|
const targetId = `slot-${upcoming.id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 等待 Vue 虚拟 DOM 提交并分发 setData
|
||||||
|
await nextTick()
|
||||||
|
// 确保原生视图层已完成该节点的挂载与布局排版
|
||||||
|
const isReady = await waitForElement(`#${targetId}`, 10, 50)
|
||||||
|
if (!isReady || selectedDate.value !== dateWhenStarted) {
|
||||||
|
// 节点尚未在视图层就绪(例如 Tab 处于后台未完成渲染)或用户已切换日期,不锁定 hasAutoScrolled,留待 onShow 或后续就绪时执行
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetSlotId.value === targetId) {
|
||||||
|
targetSlotId.value = ''
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
if (selectedDate.value !== dateWhenStarted) return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSlotId.value = targetId
|
||||||
|
hasAutoScrolled.value = true
|
||||||
|
} finally {
|
||||||
|
isAutoScrolling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Event handlers ───────────────────────────────────────
|
// ─── Event handlers ───────────────────────────────────────
|
||||||
function onDateSelect(date: string) {
|
function onDateSelect(date: string) {
|
||||||
selectedDate.value = date
|
selectedDate.value = date
|
||||||
@@ -269,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
|
|||||||
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
||||||
if (!slot.myBookingId) return
|
if (!slot.myBookingId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
content: '确定要取消这个预约吗?',
|
content: '确定要取消这个预约吗?',
|
||||||
@@ -295,12 +401,21 @@ async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const tasks: Promise<unknown>[] = [loadSlots(selectedDate.value)]
|
||||||
// Load memberships if logged in but not yet fetched
|
// Load memberships if logged in but not yet fetched
|
||||||
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
||||||
await userStore.fetchMemberships()
|
tasks.push(userStore.fetchMemberships())
|
||||||
|
}
|
||||||
|
await Promise.all(tasks)
|
||||||
|
// 首次进入:自动定位到当天本时段及以后的第一个课程
|
||||||
|
await scrollToUpcoming()
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
// 如果首次进入时页面在后台预加载完成,或从其他 Tab 切入时定位未生效,在页面可见时触发定位
|
||||||
|
if (!hasAutoScrolled.value && filteredSlots.value.length > 0 && !bookingStore.loadingSlots) {
|
||||||
|
await scrollToUpcoming()
|
||||||
}
|
}
|
||||||
// Load today's slots
|
|
||||||
await loadSlots(selectedDate.value)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,26 @@
|
|||||||
: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="!loading && (card || allCards.length)" class="card-share-row">
|
||||||
|
<button class="card-share-button" open-type="share" aria-label="分享会员卡给微信好友或群聊">
|
||||||
|
<text class="card-share-icon">↗</text><text>分享给好友 / 群聊</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="inviteVisible" class="purchase-sheet-layer" @touchmove.stop.prevent>
|
||||||
|
<view class="purchase-sheet">
|
||||||
|
<text class="sheet-kicker">A GIFT FROM YOUR FRIEND</text>
|
||||||
|
<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 +84,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 +150,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 +260,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 +291,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 +340,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, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||||
import {
|
import {
|
||||||
CardTypeCategory,
|
CardTypeCategory,
|
||||||
@@ -335,7 +357,7 @@ import { get, post } from '../../utils/request'
|
|||||||
import { formatPrice, getCardCoverClass } from '../../utils/format'
|
import { formatPrice, getCardCoverClass } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
import { requestBookingCreatedSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
interface MyOrderStatusResponse {
|
interface MyOrderStatusResponse {
|
||||||
@@ -348,6 +370,31 @@ 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) => {
|
||||||
|
uni.showShareMenu({ menus: ['shareAppMessage'] })
|
||||||
|
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('')
|
||||||
@@ -369,6 +416,22 @@ const paymentRedirecting = ref(false)
|
|||||||
const paymentConfirmationSession = ref(0)
|
const paymentConfirmationSession = ref(0)
|
||||||
const failedCoverIds = ref<Set<string>>(new Set())
|
const failedCoverIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
const sharedCard = showAll.value ? null : card.value
|
||||||
|
const id = sharedCard?.id || (!showAll.value ? cardId.value : '')
|
||||||
|
const path = id
|
||||||
|
? `/pages/card/detail?id=${encodeURIComponent(id)}`
|
||||||
|
: isTrialEntry.value && !showAll.value
|
||||||
|
? '/pages/card/detail?trial=1'
|
||||||
|
: '/pages/card/detail?showAll=1'
|
||||||
|
return {
|
||||||
|
title: sharedCard ? `${sharedCard.name} · 一起练普拉提` : '选择你的普拉提会员卡,一起开始练习',
|
||||||
|
path,
|
||||||
|
...(sharedCard && hasCardCover(sharedCard) ? { imageUrl: sharedCard.coverUrl! } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
if (showAll.value) return '选择会员卡'
|
if (showAll.value) return '选择会员卡'
|
||||||
return isRenewal.value ? '续卡' : '购买会员卡'
|
return isRenewal.value ? '续卡' : '购买会员卡'
|
||||||
@@ -578,7 +641,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 +649,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 +762,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 +773,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
|
||||||
}
|
}
|
||||||
@@ -821,13 +888,16 @@ async function doPurchase() {
|
|||||||
paymentRedirecting.value = false
|
paymentRedirecting.value = false
|
||||||
paymentConfirmationSession.value++
|
paymentConfirmationSession.value++
|
||||||
pendingOrderId.value = ''
|
pendingOrderId.value = ''
|
||||||
uni.showLoading({ title: '创建订单...' })
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
// 必须在 tap 同步栈里调起订阅框;失败不打断支付。
|
||||||
|
await requestBookingCreatedSubscriptionMessage().catch((error) => {
|
||||||
|
console.warn('[subscribe] purchase pre-subscribe failed', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
uni.showLoading({ title: '创建订单...' })
|
||||||
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()
|
||||||
@@ -845,7 +915,6 @@ async function doPurchase() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
pendingOrderId.value = result.order.id
|
pendingOrderId.value = result.order.id
|
||||||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
|
||||||
await settlePaidOrder(result.order.id)
|
await settlePaidOrder(result.order.id)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
@@ -872,6 +941,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 +953,15 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; }
|
||||||
|
.card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } }
|
||||||
|
.card-share-icon { font-size: 28rpx; }
|
||||||
|
|
||||||
|
.invite-banner { margin: 24rpx 32rpx 0; padding: 24rpx; border-radius: 20rpx; background: #eaf0e5; color: #526e58; font-size: 27rpx; }
|
||||||
|
.invite-banner-note { display: block; margin-top: 10rpx; color: #7b8974; font-size: 22rpx; line-height: 1.6; }
|
||||||
|
.invite-input { margin: 26rpx 0; padding: 24rpx; border: 1rpx dashed #a9b99e; border-radius: 16rpx; font: 34rpx monospace; letter-spacing: 6rpx; height: 55rpx; text-align: center; }
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -1,840 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
|
||||||
<CustomNavBar title="限时秒杀" show-back />
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<view v-if="loading" class="loading-wrap">
|
|
||||||
<view class="skeleton-hero" />
|
|
||||||
<view class="skeleton-body">
|
|
||||||
<view class="skeleton-line w80" />
|
|
||||||
<view class="skeleton-line w60" />
|
|
||||||
<view class="skeleton-line w40" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<view v-else-if="!detail" class="error-wrap">
|
|
||||||
<text class="error-icon">◈</text>
|
|
||||||
<text class="error-text">活动信息加载失败</text>
|
|
||||||
<view class="retry-btn" @tap="loadDetail">
|
|
||||||
<text class="retry-text">点击重试</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- ═══ Hero Section ═══ -->
|
|
||||||
<view class="hero" :class="heroPhaseClass">
|
|
||||||
<!-- Decorative elements -->
|
|
||||||
<view class="hero-deco hero-deco--1" />
|
|
||||||
<view class="hero-deco hero-deco--2" />
|
|
||||||
<view class="hero-deco hero-deco--3" />
|
|
||||||
|
|
||||||
<!-- Phase badge -->
|
|
||||||
<view class="hero-phase-badge" :class="phaseBadgeClass">
|
|
||||||
<text class="hero-phase-text">{{ phaseLabel }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<text class="hero-title">{{ detail.title }}</text>
|
|
||||||
|
|
||||||
<!-- Price row -->
|
|
||||||
<view class="hero-price-row">
|
|
||||||
<text class="hero-currency">¥</text>
|
|
||||||
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</text>
|
|
||||||
<view class="hero-original-wrap">
|
|
||||||
<text class="hero-original-label">原价</text>
|
|
||||||
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Discount badge -->
|
|
||||||
<view class="hero-discount-badge">
|
|
||||||
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Countdown -->
|
|
||||||
<view
|
|
||||||
v-if="detail.phase === FlashSalePhase.UPCOMING || detail.phase === FlashSalePhase.ONGOING"
|
|
||||||
class="hero-countdown"
|
|
||||||
>
|
|
||||||
<text class="cd-label">
|
|
||||||
{{ detail.phase === FlashSalePhase.UPCOMING ? '距开始' : '距结束' }}
|
|
||||||
</text>
|
|
||||||
<view class="cd-blocks">
|
|
||||||
<text class="cd-block">{{ countdown.h }}</text>
|
|
||||||
<text class="cd-colon">:</text>
|
|
||||||
<text class="cd-block">{{ countdown.m }}</text>
|
|
||||||
<text class="cd-colon">:</text>
|
|
||||||
<text class="cd-block">{{ countdown.s }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ═══ Stock Bar ═══ -->
|
|
||||||
<view class="stock-section">
|
|
||||||
<view class="stock-info">
|
|
||||||
<text class="stock-label">抢购进度</text>
|
|
||||||
<text class="stock-count">
|
|
||||||
{{ detail.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `已抢 ${detail.soldCount}/${detail.totalStock}` }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
<view class="stock-bar">
|
|
||||||
<view
|
|
||||||
class="stock-fill"
|
|
||||||
:class="{ 'stock-fill--hot': stockRatio > 0.6 }"
|
|
||||||
:style="{ width: stockPercent }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ═══ Phone Auth Prompt ═══ -->
|
|
||||||
<view
|
|
||||||
v-if="userStore.loggedIn && !userStore.user?.phone"
|
|
||||||
class="phone-prompt-card"
|
|
||||||
>
|
|
||||||
<view class="phone-prompt-content">
|
|
||||||
<view class="phone-prompt-icon">📱</view>
|
|
||||||
<view class="phone-prompt-text">
|
|
||||||
<text class="phone-prompt-title">提前授权手机号</text>
|
|
||||||
<text class="phone-prompt-desc">授权后抢购更快,也方便馆主联系您</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<button
|
|
||||||
class="phone-auth-btn"
|
|
||||||
open-type="getPhoneNumber"
|
|
||||||
@getphonenumber="handleGetPhone"
|
|
||||||
>
|
|
||||||
<text class="phone-auth-text">立即授权</text>
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ═══ Card Info ═══ -->
|
|
||||||
<view class="detail-section">
|
|
||||||
<view class="info-card">
|
|
||||||
<view class="section-header-row">
|
|
||||||
<view class="section-dot" />
|
|
||||||
<text class="section-label">会员卡信息</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-grid">
|
|
||||||
<view class="info-cell">
|
|
||||||
<text class="cell-value">{{ detail.cardType.name }}</text>
|
|
||||||
<text class="cell-label">卡种</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="detail.cardType.totalTimes" class="info-cell">
|
|
||||||
<text class="cell-value">{{ detail.cardType.totalTimes }}</text>
|
|
||||||
<text class="cell-label">课时次数</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-cell">
|
|
||||||
<text class="cell-value">{{ detail.cardType.durationDays }}</text>
|
|
||||||
<text class="cell-label">有效天数</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<view v-if="detail.description" class="desc-card">
|
|
||||||
<view class="section-header-row">
|
|
||||||
<view class="section-dot" />
|
|
||||||
<text class="section-label">活动说明</text>
|
|
||||||
</view>
|
|
||||||
<text class="desc-content">{{ detail.description }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Purchase Notes -->
|
|
||||||
<view class="notes-card">
|
|
||||||
<view class="section-header-row">
|
|
||||||
<view class="section-dot" />
|
|
||||||
<text class="section-label">参与须知</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">每位用户同一秒杀活动仅限参与一次</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">购买后立即生效,有效期 {{ detail.cardType.durationDays }} 天</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="detail.cardType.totalTimes" class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">共 {{ detail.cardType.totalTimes }} 次课时,可灵活预约</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">需登录并授权手机号后方可参与秒杀</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">建议提前完善账号信息及手机号授权,方便馆主联系</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">秒杀卡不可退款,到期或课时用完后自动失效</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item">
|
|
||||||
<text class="note-dot">•</text>
|
|
||||||
<text class="note-text">支持微信支付,安全便捷</text>
|
|
||||||
</view>
|
|
||||||
<view class="note-item note-item--disclaimer">
|
|
||||||
<text class="note-text disclaimer-text">* 本活动最终解释权归普拉提馆所有</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ═══ Bottom Action Bar ═══ -->
|
|
||||||
<view class="bottom-bar">
|
|
||||||
<view class="bar-price-area">
|
|
||||||
<text class="bar-price-label">秒杀价</text>
|
|
||||||
<view class="bar-price-row">
|
|
||||||
<text class="bar-currency">¥</text>
|
|
||||||
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
class="action-btn"
|
|
||||||
:class="actionBtnClass"
|
|
||||||
@tap="handleAction"
|
|
||||||
>
|
|
||||||
<text class="action-btn-text">{{ actionBtnText }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import {
|
|
||||||
FlashSalePhase,
|
|
||||||
FlashSaleOrderStatus,
|
|
||||||
} from '@mp-pilates/shared'
|
|
||||||
import type { FlashSaleDetail } from '@mp-pilates/shared'
|
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
|
||||||
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
|
|
||||||
import { getSystemLayout } from '../../utils/system'
|
|
||||||
import { useUserStore } from '../../stores/user'
|
|
||||||
import { useFlashSaleStore } from '../../stores/flash-sale'
|
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
||||||
import { post } from '../../utils/request'
|
|
||||||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const flashSaleStore = useFlashSaleStore()
|
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
|
||||||
const loading = ref(false)
|
|
||||||
const buying = ref(false)
|
|
||||||
const detail = ref<FlashSaleDetail | null>(null)
|
|
||||||
const flashSaleId = ref('')
|
|
||||||
const tick = ref(0)
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────
|
|
||||||
const phaseLabel = computed(() => {
|
|
||||||
if (!detail.value) return ''
|
|
||||||
return getFlashSalePhaseLabel(detail.value.phase)
|
|
||||||
})
|
|
||||||
|
|
||||||
const heroPhaseClass = computed(() => {
|
|
||||||
if (!detail.value) return ''
|
|
||||||
if (detail.value.phase === FlashSalePhase.ONGOING) return 'hero--ongoing'
|
|
||||||
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'hero--upcoming'
|
|
||||||
return 'hero--inactive'
|
|
||||||
})
|
|
||||||
|
|
||||||
const phaseBadgeClass = computed(() => {
|
|
||||||
if (!detail.value) return ''
|
|
||||||
if (detail.value.phase === FlashSalePhase.ONGOING) return 'pbadge--ongoing'
|
|
||||||
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'pbadge--upcoming'
|
|
||||||
return 'pbadge--inactive'
|
|
||||||
})
|
|
||||||
|
|
||||||
const stockRatio = computed(() => {
|
|
||||||
if (!detail.value) return 0
|
|
||||||
return getStockRatio(detail.value.soldCount, detail.value.totalStock)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stockPercent = computed(() => {
|
|
||||||
if (!detail.value) return '0%'
|
|
||||||
return getStockPercent(detail.value.soldCount, detail.value.totalStock)
|
|
||||||
})
|
|
||||||
|
|
||||||
const countdown = computed(() => {
|
|
||||||
void tick.value
|
|
||||||
if (!detail.value) return { h: '00', m: '00', s: '00' }
|
|
||||||
const target = detail.value.phase === FlashSalePhase.UPCOMING
|
|
||||||
? detail.value.startTime
|
|
||||||
: detail.value.endTime
|
|
||||||
return getCountdownParts(target)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDisabled = computed(() => {
|
|
||||||
if (!detail.value) return true
|
|
||||||
const d = detail.value
|
|
||||||
if (d.hasParticipated) return true
|
|
||||||
if (d.phase === FlashSalePhase.SOLD_OUT) return true
|
|
||||||
if (d.phase === FlashSalePhase.ENDED) return true
|
|
||||||
if (d.phase === FlashSalePhase.UPCOMING) return true
|
|
||||||
if (buying.value) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const actionBtnText = computed(() => {
|
|
||||||
if (!detail.value) return ''
|
|
||||||
const d = detail.value
|
|
||||||
|
|
||||||
if (d.hasParticipated) {
|
|
||||||
if (d.userOrderStatus === FlashSaleOrderStatus.PAID) return '已成功抢购'
|
|
||||||
if (d.userOrderStatus === FlashSaleOrderStatus.RESERVED) return '待支付'
|
|
||||||
return '已参与'
|
|
||||||
}
|
|
||||||
if (d.phase === FlashSalePhase.SOLD_OUT) return '已售罄'
|
|
||||||
if (d.phase === FlashSalePhase.ENDED) return '活动已结束'
|
|
||||||
if (d.phase === FlashSalePhase.UPCOMING) return `距开始 ${countdown.value.h}:${countdown.value.m}:${countdown.value.s}`
|
|
||||||
|
|
||||||
if (!userStore.loggedIn) return '登录后参与'
|
|
||||||
if (!userStore.user?.phone) return '授权手机号后参与'
|
|
||||||
if (buying.value) return '抢购中...'
|
|
||||||
return `¥${formatPrice(d.flashPrice)} 立即抢购`
|
|
||||||
})
|
|
||||||
|
|
||||||
const actionBtnClass = computed(() => {
|
|
||||||
if (isDisabled.value) return 'action-btn--disabled'
|
|
||||||
return 'action-btn--active'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Data loading ────────────────────────────────────
|
|
||||||
async function loadDetail() {
|
|
||||||
if (!flashSaleId.value) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
detail.value = await flashSaleStore.fetchDetail(flashSaleId.value)
|
|
||||||
} catch {
|
|
||||||
detail.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phone auth ──────────────────────────────────────
|
|
||||||
async function handleGetPhone(e: { detail: { code?: string; errMsg?: string } }) {
|
|
||||||
if (!e.detail.code) return
|
|
||||||
try {
|
|
||||||
await post('/auth/phone', { code: e.detail.code })
|
|
||||||
await userStore.fetchProfile()
|
|
||||||
uni.showToast({ title: '授权成功', icon: 'success' })
|
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '授权失败,请重试', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Action handler ──────────────────────────────────
|
|
||||||
async function handleAction() {
|
|
||||||
if (!detail.value || isDisabled.value) return
|
|
||||||
|
|
||||||
// Check login
|
|
||||||
if (!userStore.loggedIn) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '提示',
|
|
||||||
content: '请先登录后再参与秒杀',
|
|
||||||
confirmText: '去登录',
|
|
||||||
success: async (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
try {
|
|
||||||
const { isNewUser } = await userStore.loginWithSetup()
|
|
||||||
if (!isNewUser) {
|
|
||||||
await loadDetail() // refresh participation status
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check phone
|
|
||||||
if (!userStore.user?.phone) {
|
|
||||||
uni.showToast({ title: '请先授权手机号', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm purchase
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认抢购',
|
|
||||||
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
|
|
||||||
confirmText: '确认抢购',
|
|
||||||
success: async (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
await doPurchase()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doPurchase() {
|
|
||||||
if (!detail.value || buying.value) return
|
|
||||||
buying.value = true
|
|
||||||
uni.showLoading({ title: '抢购中...' })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await flashSaleStore.purchase(detail.value.id)
|
|
||||||
|
|
||||||
uni.hideLoading()
|
|
||||||
|
|
||||||
// Launch WeChat Pay
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
uni.requestPayment({
|
|
||||||
provider: 'wxpay',
|
|
||||||
timeStamp: result.paymentParams.timeStamp,
|
|
||||||
nonceStr: result.paymentParams.nonceStr,
|
|
||||||
package: result.paymentParams.package,
|
|
||||||
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
|
|
||||||
paySign: result.paymentParams.paySign,
|
|
||||||
success: () => resolve(),
|
|
||||||
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
|
||||||
uni.showToast({ title: '抢购成功!', icon: 'success' })
|
|
||||||
await userStore.fetchMemberships()
|
|
||||||
await loadDetail() // refresh status
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.navigateTo({ url: '/pages/profile/membership' })
|
|
||||||
}, 1500)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
uni.hideLoading()
|
|
||||||
const msg = err instanceof Error ? err.message : '抢购失败'
|
|
||||||
if (!msg.includes('取消') && !msg.includes('cancel')) {
|
|
||||||
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
|
|
||||||
}
|
|
||||||
// Refresh detail to show updated status
|
|
||||||
await loadDetail()
|
|
||||||
} finally {
|
|
||||||
buying.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ───────────────────────────────────────
|
|
||||||
onMounted(() => {
|
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
|
||||||
|
|
||||||
const pages = getCurrentPages()
|
|
||||||
const current = pages[pages.length - 1]
|
|
||||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
|
||||||
flashSaleId.value = options.id ?? ''
|
|
||||||
loadDetail()
|
|
||||||
|
|
||||||
timer = setInterval(() => { tick.value++ }, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: $bg-page;
|
|
||||||
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading ────────────────────────────── */
|
|
||||||
.loading-wrap { padding: 0; }
|
|
||||||
|
|
||||||
.skeleton-hero {
|
|
||||||
height: 420rpx;
|
|
||||||
background: linear-gradient(90deg, #ede8e3 25%, #e4dfd9 50%, #ede8e3 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-body { padding: 32rpx 24rpx; display: flex; flex-direction: column; gap: 20rpx; }
|
|
||||||
|
|
||||||
.skeleton-line {
|
|
||||||
height: 28rpx;
|
|
||||||
border-radius: 14rpx;
|
|
||||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
&.w80 { width: 80%; }
|
|
||||||
&.w60 { width: 60%; }
|
|
||||||
&.w40 { width: 40%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error ───────────────────────────────── */
|
|
||||||
.error-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 160rpx 40rpx;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon { font-size: 80rpx; }
|
|
||||||
.error-text { font-size: 30rpx; color: $text-hint; }
|
|
||||||
|
|
||||||
.retry-btn {
|
|
||||||
padding: 20rpx 48rpx;
|
|
||||||
border-radius: 40rpx;
|
|
||||||
background: linear-gradient(135deg, #D4A59A, #C08B7E);
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-text { font-size: 28rpx; color: #fff; font-weight: 600; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
|
||||||
HERO — warm blush tones
|
|
||||||
═══════════════════════════════════════════ */
|
|
||||||
.hero {
|
|
||||||
padding: 56rpx 36rpx 48rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero--ongoing {
|
|
||||||
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 35%, #B5836E 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero--upcoming {
|
|
||||||
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero--inactive {
|
|
||||||
background: linear-gradient(135deg, #C4BAB0 0%, #AEA49A 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-deco {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&--1 { width: 300rpx; height: 300rpx; top: -60rpx; right: -40rpx; }
|
|
||||||
&--2 { width: 200rpx; height: 200rpx; bottom: -60rpx; left: 30rpx; }
|
|
||||||
&--3 { width: 120rpx; height: 120rpx; top: 40rpx; left: -30rpx; background: rgba(255, 255, 255, 0.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-phase-badge {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 8rpx 24rpx;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pbadge--ongoing { background: rgba(255, 255, 255, 0.3); }
|
|
||||||
.pbadge--upcoming { background: rgba(255, 255, 255, 0.25); }
|
|
||||||
.pbadge--inactive { background: rgba(0, 0, 0, 0.12); }
|
|
||||||
|
|
||||||
.hero-phase-text { font-size: 24rpx; color: #fff; font-weight: 600; letter-spacing: 1rpx; }
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
z-index: 1;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-price-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4rpx;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-currency { font-size: 30rpx; font-weight: 700; color: rgba(255, 255, 255, 0.9); }
|
|
||||||
.hero-price { font-size: 72rpx; font-weight: 800; color: #fff; line-height: 1; }
|
|
||||||
|
|
||||||
.hero-original-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-left: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-original-label { font-size: 18rpx; color: rgba(255, 255, 255, 0.65); }
|
|
||||||
.hero-original { font-size: 26rpx; color: rgba(255, 255, 255, 0.55); text-decoration: line-through; }
|
|
||||||
|
|
||||||
.hero-discount-badge {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 6rpx 20rpx;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.22);
|
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-discount-text { font-size: 22rpx; color: #fff; font-weight: 600; }
|
|
||||||
|
|
||||||
/* Countdown */
|
|
||||||
.hero-countdown {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cd-label { font-size: 24rpx; color: rgba(255, 255, 255, 0.85); }
|
|
||||||
|
|
||||||
.cd-blocks { display: flex; align-items: center; gap: 6rpx; }
|
|
||||||
|
|
||||||
.cd-block {
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 8rpx 14rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
font-family: 'DIN Alternate', monospace;
|
|
||||||
min-width: 48rpx;
|
|
||||||
text-align: center;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cd-colon { color: #fff; font-size: 28rpx; font-weight: 700; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
|
||||||
STOCK
|
|
||||||
═══════════════════════════════════════════ */
|
|
||||||
.stock-section {
|
|
||||||
margin: 0 24rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
margin-top: -20rpx;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-label { font-size: 26rpx; color: $text-secondary; font-weight: 600; }
|
|
||||||
.stock-count { font-size: 24rpx; color: #B5725E; font-weight: 600; }
|
|
||||||
|
|
||||||
.stock-bar {
|
|
||||||
height: 16rpx;
|
|
||||||
background: #f5f0ed;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #D4A59A, #C08B7E);
|
|
||||||
border-radius: 8rpx;
|
|
||||||
transition: width 0.3s;
|
|
||||||
|
|
||||||
&--hot { animation: stockPulse 2s ease infinite; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stockPulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
|
||||||
PHONE PROMPT
|
|
||||||
═══════════════════════════════════════════ */
|
|
||||||
.phone-prompt-card {
|
|
||||||
margin: 20rpx 24rpx 0;
|
|
||||||
background: linear-gradient(135deg, #FBF5F3, #F5ECEA);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border: 1rpx solid rgba(192, 139, 126, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-prompt-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-prompt-icon { font-size: 40rpx; }
|
|
||||||
|
|
||||||
.phone-prompt-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-prompt-title { font-size: 26rpx; font-weight: 700; color: #B5725E; }
|
|
||||||
.phone-prompt-desc { font-size: 22rpx; color: $text-hint; }
|
|
||||||
|
|
||||||
.phone-auth-btn {
|
|
||||||
background: linear-gradient(135deg, #D4A59A, #C08B7E) !important;
|
|
||||||
border-radius: 32rpx !important;
|
|
||||||
padding: 12rpx 28rpx !important;
|
|
||||||
border: none !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
font-size: 24rpx !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
flex-shrink: 0;
|
|
||||||
&::after { border: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-auth-text { font-size: 24rpx; color: #fff; font-weight: 600; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
|
||||||
DETAIL SECTION
|
|
||||||
═══════════════════════════════════════════ */
|
|
||||||
.detail-section {
|
|
||||||
padding: 20rpx 24rpx 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-dot {
|
|
||||||
width: 6rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
border-radius: 3rpx;
|
|
||||||
background: #C08B7E;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label { font-size: 30rpx; font-weight: 700; color: $text-primary; }
|
|
||||||
|
|
||||||
/* Info card */
|
|
||||||
.info-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 28rpx 24rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& + & { border-left: 1rpx solid #f0ece8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-value { font-size: 36rpx; font-weight: 800; color: $text-primary; line-height: 1.1; }
|
|
||||||
.cell-label { font-size: 22rpx; color: $text-hint; }
|
|
||||||
|
|
||||||
/* Description */
|
|
||||||
.desc-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 28rpx 24rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc-content { font-size: 27rpx; color: $text-secondary; line-height: 1.75; }
|
|
||||||
|
|
||||||
/* Notes */
|
|
||||||
.notes-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 28rpx 24rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12rpx;
|
|
||||||
padding: 6rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-dot { font-size: 26rpx; color: #C08B7E; line-height: 1.65; flex-shrink: 0; }
|
|
||||||
.note-text { font-size: 26rpx; color: $text-secondary; line-height: 1.65; }
|
|
||||||
|
|
||||||
.note-item--disclaimer { margin-top: 12rpx; padding-top: 16rpx; border-top: 1rpx solid #f0ece8; }
|
|
||||||
.disclaimer-text { color: #bbb; font-size: 22rpx; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
|
||||||
BOTTOM BAR
|
|
||||||
═══════════════════════════════════════════ */
|
|
||||||
.bottom-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: #fff;
|
|
||||||
border-top: 1rpx solid #f0ece8;
|
|
||||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24rpx;
|
|
||||||
box-shadow: 0 -4rpx 20rpx rgba(180, 160, 130, 0.08);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-price-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-price-label { font-size: 20rpx; color: $text-hint; }
|
|
||||||
|
|
||||||
.bar-price-row { display: flex; align-items: baseline; }
|
|
||||||
|
|
||||||
.bar-currency { font-size: 24rpx; font-weight: 700; color: #B5725E; }
|
|
||||||
.bar-price { font-size: 44rpx; font-weight: 800; color: #B5725E; line-height: 1; }
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 88rpx;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn--active {
|
|
||||||
background: linear-gradient(90deg, #D4A59A, #B5836E);
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(192, 139, 126, 0.35);
|
|
||||||
|
|
||||||
&:active { opacity: 0.85; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn--disabled {
|
|
||||||
background: #d0cac4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
<view class="card-handle"><view class="card-handle-bar" /></view>
|
<view class="card-handle"><view class="card-handle-bar" /></view>
|
||||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||||
<UpcomingBooking />
|
<UpcomingBooking />
|
||||||
|
<ReviewSummaryCard />
|
||||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||||
<FlashSaleSection ref="flashSaleRef" />
|
|
||||||
<view :id="cardShopAnchorId">
|
<view :id="cardShopAnchorId">
|
||||||
<CardShop ref="cardShopRef" />
|
<CardShop ref="cardShopRef" />
|
||||||
</view>
|
</view>
|
||||||
@@ -28,10 +28,10 @@ import { ref, nextTick, onUnmounted } from 'vue'
|
|||||||
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import BrandBanner from '../../components/BrandBanner.vue'
|
import BrandBanner from '../../components/BrandBanner.vue'
|
||||||
|
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
|
||||||
import StudioInfo from '../../components/StudioInfo.vue'
|
import StudioInfo from '../../components/StudioInfo.vue'
|
||||||
import QuickEntry from '../../components/QuickEntry.vue'
|
import QuickEntry from '../../components/QuickEntry.vue'
|
||||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||||
import FlashSaleSection from '../../components/FlashSaleSection.vue'
|
|
||||||
import CardShop from '../../components/CardShop.vue'
|
import CardShop from '../../components/CardShop.vue'
|
||||||
import AboutSection from '../../components/AboutSection.vue'
|
import AboutSection from '../../components/AboutSection.vue'
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ onShareTimeline(() => {
|
|||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||||
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
|
|
||||||
const cardShopAnchorId = 'card-shop-anchor'
|
const cardShopAnchorId = 'card-shop-anchor'
|
||||||
const scrollTarget = ref('')
|
const scrollTarget = ref('')
|
||||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
@@ -103,10 +102,9 @@ async function refreshData() {
|
|||||||
|
|
||||||
await Promise.allSettled(tasks)
|
await Promise.allSettled(tasks)
|
||||||
|
|
||||||
// Also refresh card shop and flash sales
|
// Also refresh card shop
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
cardShopRef.value?.fetchCardTypes(),
|
cardShopRef.value?.fetchCardTypes(),
|
||||||
flashSaleRef.value?.fetchFlashSales(),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
packages/app/src/pages/portrait/advice.vue
Normal file
45
packages/app/src/pages/portrait/advice.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="日常建议" show-back />
|
||||||
|
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
|
||||||
|
<view v-else-if="report?.advice">
|
||||||
|
<text class="title">给你的三个日常建议</text>
|
||||||
|
<view v-for="item in report.advice.items" :key="item.title" class="card">
|
||||||
|
<text class="name">{{ item.title }}</text>
|
||||||
|
<text class="body">{{ item.detail }}</text>
|
||||||
|
<text class="stop">如出现:{{ item.stopIf }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="disclaimer">这些建议用于日常活动参考,不能替代现场评估。</text>
|
||||||
|
<view class="cta" @tap="next">了解线上画像的局限</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import SafetyNotice from '../../components/SafetyNotice.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useBodyPortraitStore } from '../../stores/body-portrait'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const portrait = useBodyPortraitStore()
|
||||||
|
const { report } = storeToRefs(portrait)
|
||||||
|
|
||||||
|
onShow(() => { portrait.track('advice_viewed') })
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
uni.navigateTo({ url: '/pages/portrait/trial' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
|
||||||
|
.title { display: block; font-size: 40rpx; color: #4a4035; margin: 12rpx 0 24rpx; }
|
||||||
|
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.body, .stop, .disclaimer { display: block; margin-top: 12rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
|
||||||
|
.cta { margin-top: 32rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
156
packages/app/src/pages/portrait/assessment.vue
Normal file
156
packages/app/src/pages/portrait/assessment.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="身体状态评估" show-back />
|
||||||
|
<PortraitProgress :step="step + 1" :total="steps.length" />
|
||||||
|
<text class="q">{{ current.title }}</text>
|
||||||
|
<text class="d">{{ current.hint }}</text>
|
||||||
|
|
||||||
|
<BodySilhouette v-if="current.body" :selected="bodyValue" @change="onBody" />
|
||||||
|
|
||||||
|
<view v-else class="options">
|
||||||
|
<view
|
||||||
|
v-for="option in current.options"
|
||||||
|
:key="option.value"
|
||||||
|
class="opt"
|
||||||
|
:class="{ on: isOn(option.value) }"
|
||||||
|
@tap="pick(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="disclaimer">非医疗诊断,仅用于运动训练参考。</text>
|
||||||
|
<view class="nav">
|
||||||
|
<view v-if="step > 0" class="ghost" @tap="step -= 1">上一步</view>
|
||||||
|
<view class="next" @tap="next">{{ step === steps.length - 1 ? '生成画像' : '继续' }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import BodySilhouette from '../../components/BodySilhouette.vue'
|
||||||
|
import PortraitProgress from '../../components/PortraitProgress.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { useBodyPortraitStore } from '../../stores/body-portrait'
|
||||||
|
import {
|
||||||
|
AFTER_SITTING_LABELS,
|
||||||
|
AfterSitting,
|
||||||
|
BodyRegion,
|
||||||
|
EXERCISE_FREQ_LABELS,
|
||||||
|
ExerciseFreq,
|
||||||
|
PORTRAIT_GOAL_LABELS,
|
||||||
|
PortraitGoal,
|
||||||
|
SAFETY_FLAG_LABELS,
|
||||||
|
SafetyFlag,
|
||||||
|
SITTING_HOURS_LABELS,
|
||||||
|
SittingHours,
|
||||||
|
STANDING_NOTICE_LABELS,
|
||||||
|
StandingNotice,
|
||||||
|
WORK_POSTURE_LABELS,
|
||||||
|
WorkPosture,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const portrait = useBodyPortraitStore()
|
||||||
|
const step = ref(0)
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'concerns', title: '最近身体哪里最困扰你?', hint: '可以多选,我们会据此调整后面的问题。', body: true },
|
||||||
|
{ key: 'goal', title: '如果训练有效果,你最希望看到什么变化?', hint: '后面的报告会用你自己的目标来写。', options: enumOptions(PORTRAIT_GOAL_LABELS) },
|
||||||
|
{ key: 'sittingHours', title: '每天坐多久?', hint: '这能帮助理解当前身体状态是怎么形成的。', options: enumOptions(SITTING_HOURS_LABELS) },
|
||||||
|
{ key: 'exerciseFreq', title: '平时运动频率?', hint: '没有对错,如实选择即可。', options: enumOptions(EXERCISE_FREQ_LABELS) },
|
||||||
|
{ key: 'workPosture', title: '工作时最常见状态?', hint: '选最常出现的一种。', options: enumOptions(WORK_POSTURE_LABELS) },
|
||||||
|
{ key: 'endOfDayFatigue', title: '一天结束后,身体哪里最累?', hint: '可以和困扰区域不同。', body: true },
|
||||||
|
{ key: 'afterSitting', title: '长时间坐着后,你会出现?', hint: '可多选。', options: enumOptions(AFTER_SITTING_LABELS), multi: true },
|
||||||
|
{ key: 'standingNotice', title: '自然站立时,你有没有注意过?', hint: '用日常语言描述,不需要专业判断。', options: enumOptions(STANDING_NOTICE_LABELS), multi: true },
|
||||||
|
{ key: 'safety', title: '最近是否存在以下情况?', hint: '这一题不计入关注度,只用于安全分流。', options: enumOptions(SAFETY_FLAG_LABELS), multi: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const current = computed(() => steps[step.value])
|
||||||
|
const bodyValue = computed(() => (current.value.key === 'concerns' ? portrait.answers.concerns : portrait.answers.endOfDayFatigue) as string[])
|
||||||
|
|
||||||
|
function enumOptions(labels: Record<string, string>) {
|
||||||
|
return Object.entries(labels).map(([value, label]) => ({ value, label }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOn(value: string) {
|
||||||
|
const answers = portrait.answers as unknown as Record<string, unknown>
|
||||||
|
const currentValue = answers[current.value.key]
|
||||||
|
return Array.isArray(currentValue) ? currentValue.includes(value) : currentValue === value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(patch: Record<string, unknown>) {
|
||||||
|
await portrait.saveAnswers(patch as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBody(value: BodyRegion[]) {
|
||||||
|
await persist({ [current.value.key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pick(value: string) {
|
||||||
|
if (current.value.multi) {
|
||||||
|
const answers = portrait.answers as unknown as Record<string, unknown>
|
||||||
|
const list = [...((answers[current.value.key] as string[]) || [])]
|
||||||
|
const exclusiveNone = current.value.key === 'safety' || current.value.key === 'afterSitting' || current.value.key === 'standingNotice'
|
||||||
|
if (value === 'none' || value === 'unnoticed') {
|
||||||
|
await persist({ [current.value.key]: [value] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = list.includes(value) ? list.filter((item) => item !== value) : [...list.filter((item) => item !== 'none' && item !== 'unnoticed'), value]
|
||||||
|
if (exclusiveNone && !next.length) return
|
||||||
|
await persist({ [current.value.key]: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await persist({ [current.value.key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
try {
|
||||||
|
if (step.value < steps.length - 1) {
|
||||||
|
step.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showLoading({ title: '生成画像...' })
|
||||||
|
await portrait.complete()
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.redirectTo({ url: '/pages/portrait/report' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '请先完成必答题'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(async () => {
|
||||||
|
try {
|
||||||
|
await portrait.ensureSession()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '测评暂时无法开始'), icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
void AfterSitting
|
||||||
|
void ExerciseFreq
|
||||||
|
void PortraitGoal
|
||||||
|
void SafetyFlag
|
||||||
|
void SittingHours
|
||||||
|
void StandingNotice
|
||||||
|
void WorkPosture
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 160rpx; }
|
||||||
|
.q { display: block; font-size: 40rpx; line-height: 1.4; color: #4a4035; }
|
||||||
|
.d { display: block; margin: 16rpx 0 28rpx; color: #8a7b6e; font-size: 26rpx; line-height: 1.6; }
|
||||||
|
.options { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.opt { padding: 28rpx; border-radius: 20rpx; background: #fff; color: #5d5148; font-size: 28rpx; }
|
||||||
|
.opt.on { background: #6b8276; color: #fff; }
|
||||||
|
.disclaimer { display: block; margin-top: 36rpx; color: #a09080; font-size: 22rpx; }
|
||||||
|
.nav { position: fixed; left: 0; right: 0; bottom: 0; padding: 24rpx 40rpx 48rpx; display: flex; gap: 16rpx; background: #fbf9f6; }
|
||||||
|
.ghost, .next { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.ghost { background: #efe8e1; color: #6b5c50; }
|
||||||
|
.next { background: #6b8276; color: #fff; }
|
||||||
|
</style>
|
||||||
50
packages/app/src/pages/portrait/index.vue
Normal file
50
packages/app/src/pages/portrait/index.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="身体状态评估" :show-back="false" />
|
||||||
|
<view class="hero">
|
||||||
|
<text class="kicker">3 分钟身体状态评估</text>
|
||||||
|
<text class="title">最近身体哪里最让你困扰?</text>
|
||||||
|
<text class="lead">用一份对话式问卷,生成你的身体画像。不是医疗诊断,只用于运动训练参考。</text>
|
||||||
|
<text v-if="stats?.completedCount" class="count">已有 {{ stats.completedCount }} 人完成评估</text>
|
||||||
|
<view class="cta" @tap="start">开始测试</view>
|
||||||
|
</view>
|
||||||
|
<view class="trust">
|
||||||
|
<text>STOTT 认证教练</text>
|
||||||
|
<text>一对一专业评估</text>
|
||||||
|
<text>非医疗诊断,仅用于运动训练参考</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useBodyPortraitStore } from '../../stores/body-portrait'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const portrait = useBodyPortraitStore()
|
||||||
|
const { stats } = storeToRefs(portrait)
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
portrait.captureAttribution((query || {}) as Record<string, string>)
|
||||||
|
portrait.fetchStats().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
uni.navigateTo({ url: '/pages/portrait/assessment' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
|
||||||
|
.hero { padding-top: 48rpx; }
|
||||||
|
.kicker { display: block; color: #8a7b6e; font-size: 22rpx; letter-spacing: 3rpx; }
|
||||||
|
.title { display: block; margin-top: 20rpx; font-size: 48rpx; line-height: 1.35; color: #4a4035; }
|
||||||
|
.lead { display: block; margin-top: 20rpx; font-size: 28rpx; line-height: 1.7; color: #7a6a5a; }
|
||||||
|
.count { display: block; margin-top: 24rpx; color: #6b8276; font-size: 24rpx; }
|
||||||
|
.cta { margin-top: 48rpx; height: 96rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30rpx; }
|
||||||
|
.trust { margin-top: 56rpx; padding: 28rpx; border-radius: 24rpx; background: #fff; display: flex; flex-direction: column; gap: 12rpx; color: #7a6a5a; font-size: 24rpx; }
|
||||||
|
</style>
|
||||||
70
packages/app/src/pages/portrait/plan.vue
Normal file
70
packages/app/src/pages/portrait/plan.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="改善计划" show-back />
|
||||||
|
<view v-if="plan" class="card">
|
||||||
|
<text class="title">{{ plan.title }}</text>
|
||||||
|
<view v-for="phase in plan.phases" :key="phase.id" class="phase">
|
||||||
|
<text class="name">{{ phase.name }}</text>
|
||||||
|
<text class="meta">第 {{ phase.lessonStart }}–{{ phase.lessonEnd }} 节 · {{ phase.focus.join(' / ') }}</text>
|
||||||
|
<text class="body">{{ phase.summary }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="todos.length" class="card">
|
||||||
|
<text class="name">阶段复测</text>
|
||||||
|
<text v-for="todo in todos" :key="todo.id" class="body">第 {{ todo.lessonCheckpoint }} 节 {{ todo.completedAt ? '已完成' : '待安排' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="share" class="card">
|
||||||
|
<text class="title">{{ share.title }}</text>
|
||||||
|
<text class="body">{{ share.caption }}</text>
|
||||||
|
<text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first === row.latest ? row.first : `${row.first} → ${row.latest}` }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
|
||||||
|
<view v-else class="body">完成到店评估后,教练会为你生成 12 周改善计划。</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { get, post } from '../../utils/request'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import type { GrowthShareCardRecord, ReassessmentTodoRecord, TrainingPlanRecord } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const plan = ref<TrainingPlanRecord | null>(null)
|
||||||
|
const todos = ref<ReassessmentTodoRecord[]>([])
|
||||||
|
const share = ref<GrowthShareCardRecord | null>(null)
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
try {
|
||||||
|
const plans = await get<TrainingPlanRecord[]>('/body-portrait/plans')
|
||||||
|
plan.value = plans[0] || null
|
||||||
|
todos.value = await get<ReassessmentTodoRecord[]>('/body-portrait/todos')
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeShare() {
|
||||||
|
try {
|
||||||
|
share.value = await post<GrowthShareCardRecord>('/body-portrait/share-cards', { includePhotos: false })
|
||||||
|
uni.showToast({ title: '已生成,可分享给朋友', icon: 'none' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '暂时无法生成'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShareAppMessage(() => ({
|
||||||
|
title: share.value?.title || '我的普拉提变化',
|
||||||
|
path: share.value ? `/pages/portrait/index?source=member_share&shareCode=${share.value.shareCode}` : '/pages/portrait/index?source=member_share',
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
|
||||||
|
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
|
||||||
|
.title { display: block; font-size: 36rpx; color: #4a4035; }
|
||||||
|
.name { display: block; margin-top: 16rpx; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.meta, .body { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
|
||||||
|
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
75
packages/app/src/pages/portrait/report.vue
Normal file
75
packages/app/src/pages/portrait/report.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="你的身体画像" show-back />
|
||||||
|
<view v-if="teaser" class="card">
|
||||||
|
<text class="kicker">你的身体画像</text>
|
||||||
|
<text class="title">{{ teaser.headline }}</text>
|
||||||
|
<text class="summary">{{ claimed && report ? report.summary : '登录后查看完整个性化报告。问卷画像只能反映生活习惯和主观感受。' }}</text>
|
||||||
|
<text class="match">问卷依据:{{ teaser.matchQuality === 'full' ? '充分' : '一般' }}。这表示回答是否足够形成解释,不是诊断准确率。</text>
|
||||||
|
</view>
|
||||||
|
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
|
||||||
|
<PortraitRadar v-if="claimed && report" :scores="report.scores" />
|
||||||
|
<view v-if="claimed && report" class="block">
|
||||||
|
<view v-for="(item, index) in report.evidence" :key="item.title" class="evidence">
|
||||||
|
<text class="etitle">{{ index + 1 }} {{ item.title }}</text>
|
||||||
|
<text class="eans">根据你的答案:{{ item.answers.join('、') }}</text>
|
||||||
|
<text class="ebody">{{ item.explanation }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="claimed && report" class="block">
|
||||||
|
<text class="etitle">这些问题是有关联的</text>
|
||||||
|
<text v-for="stepItem in report.chain.steps" :key="stepItem" class="chain">{{ stepItem }}</text>
|
||||||
|
<text class="ebody">{{ report.chain.takeaway }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="disclaimer">非医疗诊断,仅用于运动训练参考。</text>
|
||||||
|
<view class="cta" @tap="continueFlow">{{ claimed ? '查看建议' : '登录查看完整报告' }}</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import PortraitRadar from '../../components/PortraitRadar.vue'
|
||||||
|
import SafetyNotice from '../../components/SafetyNotice.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { useBodyPortraitStore } from '../../stores/body-portrait'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const portrait = useBodyPortraitStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { teaser, report, claimed } = storeToRefs(portrait)
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
if (!teaser.value && !portrait.session) {
|
||||||
|
await portrait.loadMine().catch(() => {})
|
||||||
|
}
|
||||||
|
if (teaser.value) await portrait.track('report_viewed')
|
||||||
|
})
|
||||||
|
|
||||||
|
async function continueFlow() {
|
||||||
|
try {
|
||||||
|
if (!claimed.value) {
|
||||||
|
if (!userStore.loggedIn) await userStore.login()
|
||||||
|
await portrait.claim()
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: '/pages/portrait/advice' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '请先登录后查看完整报告'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
|
||||||
|
.card, .block, .evidence { background: #fff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; }
|
||||||
|
.kicker { color: #8a7b6e; font-size: 22rpx; }
|
||||||
|
.title { display: block; margin-top: 12rpx; font-size: 40rpx; color: #4a4035; line-height: 1.4; }
|
||||||
|
.summary, .match, .ebody, .eans, .chain { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
|
||||||
|
.etitle { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.disclaimer { display: block; color: #a09080; font-size: 22rpx; margin: 12rpx 0 24rpx; }
|
||||||
|
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
68
packages/app/src/pages/portrait/trial.vue
Normal file
68
packages/app/src/pages/portrait/trial.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="到店评估" show-back />
|
||||||
|
<text class="title">线上画像只能看到一部分</text>
|
||||||
|
<text class="lead">问卷能够帮助我们了解你的生活习惯和主观感受。但身体真正如何运动,需要通过现场观察进一步判断。</text>
|
||||||
|
<view class="card">
|
||||||
|
<text class="name">到店专业评估会进一步看</text>
|
||||||
|
<text class="row">静态体态:头、肩、脊柱、骨盆、腿</text>
|
||||||
|
<text class="row">呼吸、活动度、稳定性与基础动作</text>
|
||||||
|
<text class="row">明确个人训练重点</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="report?.safety.flagged" class="card">
|
||||||
|
<text class="name">当前不适合直接预约体验训练</text>
|
||||||
|
<text class="lead">建议先确认运动条件。你也可以联系工作室,我们会帮你判断下一步。</text>
|
||||||
|
</view>
|
||||||
|
<button v-else-if="!userStore.user?.phone" class="cta" open-type="getPhoneNumber" @getphonenumber="book">预约身体评估体验</button>
|
||||||
|
<view v-else class="cta" @tap="goTrial">预约身体评估体验</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage, wxBindPhone } from '../../utils/auth'
|
||||||
|
import { useBodyPortraitStore } from '../../stores/body-portrait'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
const portrait = useBodyPortraitStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { report } = storeToRefs(portrait)
|
||||||
|
|
||||||
|
async function goTrial() {
|
||||||
|
try {
|
||||||
|
if (!userStore.loggedIn) await userStore.login()
|
||||||
|
await portrait.track('trial_clicked')
|
||||||
|
uni.navigateTo({ url: '/pages/card/detail?trial=1&fromPortrait=1' })
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '请先登录'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function book(e: { detail: { encryptedData: string; iv: string; errMsg: string } }) {
|
||||||
|
try {
|
||||||
|
if (!userStore.loggedIn) await userStore.login()
|
||||||
|
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||||
|
uni.showToast({ title: '预约体验需要授权手机号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
||||||
|
await userStore.fetchProfile()
|
||||||
|
await goTrial()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '请先授权手机号'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
|
||||||
|
.title { display: block; font-size: 40rpx; color: #4a4035; margin-top: 12rpx; }
|
||||||
|
.lead, .row { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
|
||||||
|
.card { margin-top: 28rpx; background: #fff; border-radius: 24rpx; padding: 28rpx; }
|
||||||
|
.name { display: block; font-size: 30rpx; color: #4a4035; }
|
||||||
|
.cta { margin-top: 48rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<text class="row-end">— {{ endTime(booking) }}</text>
|
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="row-stamp" :class="stampClass(booking.status)">
|
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||||
{{ bookingStatusLabel(booking.status) }}
|
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="row-bottom">
|
<view class="row-bottom">
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<text class="row-end">— {{ endTime(booking) }}</text>
|
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="row-stamp" :class="stampClass(booking.status)">
|
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||||
{{ bookingStatusLabel(booking.status) }}
|
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
|
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
|
||||||
@@ -154,6 +154,7 @@ import {
|
|||||||
} from '../../utils/booking-helpers'
|
} from '../../utils/booking-helpers'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||||
|
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
|
|
||||||
type TabKey = 'upcoming' | 'history'
|
type TabKey = 'upcoming' | 'history'
|
||||||
|
|
||||||
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(booking: BookingWithDetails) {
|
async function handleCancel(booking: BookingWithDetails) {
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||||
const timeLabel = startTime(booking)
|
const timeLabel = startTime(booking)
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,19 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- User card -->
|
<!-- User card -->
|
||||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
|
||||||
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
:memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
|
||||||
|
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
|
||||||
|
|
||||||
|
<InviteCard />
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu
|
<ProfileMenu
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:require-auth="loggedIn"
|
:require-auth="loggedIn"
|
||||||
:active-membership-count="activeMembershipCount"
|
|
||||||
:upcoming-booking-count="upcomingBookingCount"
|
|
||||||
:invite-share-eligible="!!user?.inviteShareEligible"
|
|
||||||
@clear-cache="handleClearCache"
|
@clear-cache="handleClearCache"
|
||||||
@require-login="handleLogin"
|
@require-login="handleLogin"
|
||||||
|
@open-notifications="showNotificationsModal = true"
|
||||||
>
|
>
|
||||||
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||||
</ProfileMenu>
|
</ProfileMenu>
|
||||||
@@ -25,44 +26,42 @@
|
|||||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||||
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Notification Settings Modal -->
|
||||||
|
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import InviteCard from '../../components/InviteCard.vue'
|
||||||
|
import { useInviteStore } from '../../stores/invite'
|
||||||
|
import { ref, 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'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { getErrorMessage } from '../../utils/auth'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
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'
|
||||||
|
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
|
||||||
|
|
||||||
|
const invite = useInviteStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const bookingStore = useBookingStore()
|
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
|
||||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
|
||||||
const { upcomingBookings } = storeToRefs(bookingStore)
|
|
||||||
|
|
||||||
|
const showNotificationsModal = ref(false)
|
||||||
const activityRefreshKey = ref(0)
|
const activityRefreshKey = ref(0)
|
||||||
|
const membershipNow = ref(Date.now())
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
const navBarHeight = ref(getSystemLayout().navBarHeight)
|
const navBarHeight = ref(getSystemLayout().navBarHeight)
|
||||||
const statusBarHeight = getSystemLayout().statusBarHeight
|
const statusBarHeight = getSystemLayout().statusBarHeight
|
||||||
|
|
||||||
const activeMembershipCount = computed(
|
|
||||||
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
|
||||||
)
|
|
||||||
|
|
||||||
const upcomingBookingCount = computed(
|
|
||||||
() => (loggedIn.value ? upcomingBookings.value.length : 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── 微信分享 ───────────────────────────────────────────────
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
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: '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -79,13 +78,14 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
membershipNow.value = Date.now()
|
||||||
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.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
bookingStore.fetchUpcomingBookings(),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -96,10 +96,7 @@ async function handleLogin() {
|
|||||||
try {
|
try {
|
||||||
const { isNewUser } = await userStore.loginWithSetup()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
await Promise.all([
|
await invite.refreshActivity().catch(() => {})
|
||||||
userStore.fetchStats(),
|
|
||||||
bookingStore.fetchUpcomingBookings(),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
|
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
|
||||||
<CustomNavBar title="我的会员卡" show-back />
|
<CustomNavBar title="我的会员卡" show-back />
|
||||||
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||||
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
|
<view v-if="userStore.loggedIn" class="practice-summary">
|
||||||
|
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习,都在积累。</text></view>
|
||||||
|
<view class="summary-grid">
|
||||||
|
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.totalBookings ?? '—' }}</text><text class="summary-label">累计上课 · 节</text></view>
|
||||||
|
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · 节</text></view>
|
||||||
|
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
|
||||||
|
</view>
|
||||||
|
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡,可在有效期内预约</text>
|
||||||
|
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新,点击重试 ›</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="!userStore.loggedIn" class="empty-wrap"><view class="empty-card"><text class="empty-title">登录后查看会员卡</text><button class="empty-btn" @tap="goProfile">前往个人中心</button></view></view>
|
||||||
|
<view v-else-if="userStore.membershipsError" class="empty-wrap"><view class="empty-card"><text class="empty-title">会员卡暂时未能更新</text><text class="empty-sub">请重试后查看最新余额和有效期。</text><button class="empty-btn" @tap="loadMemberships">重新加载</button></view></view>
|
||||||
|
<view v-else-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
|
||||||
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -20,40 +32,15 @@
|
|||||||
<text class="group-title">正在使用</text>
|
<text class="group-title">正在使用</text>
|
||||||
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
|
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
|
<view v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
|
||||||
<view class="mc-top">
|
<OwnedMembershipCard :membership="m" :now="membershipNow">
|
||||||
<view class="mc-name-area">
|
|
||||||
<text class="mc-name">{{ m.cardType.name }}</text>
|
|
||||||
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="mc-status mc-status--active">有效</text>
|
|
||||||
</view>
|
|
||||||
<view class="mc-balance">
|
|
||||||
<view class="mc-number-row">
|
|
||||||
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
|
|
||||||
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
|
|
||||||
</view>
|
|
||||||
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
|
|
||||||
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
|
|
||||||
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
|
|
||||||
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} 次</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="mc-bottom">
|
|
||||||
<view class="mc-date-item">
|
|
||||||
<text class="mc-date-label">开始日期</text>
|
|
||||||
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="mc-date-item mc-date-item--end">
|
|
||||||
<text class="mc-date-label">有效期至</text>
|
|
||||||
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="mc-actions">
|
<view class="mc-actions">
|
||||||
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
|
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
|
||||||
<button class="mc-book" @tap="goBooking">预约课程</button>
|
<button class="mc-book" @tap="goBooking">预约课程</button>
|
||||||
</view>
|
</view>
|
||||||
|
</OwnedMembershipCard>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="usage-note">已用次数包含预约扣次,不等同于已完成上课;进度条表示已用次数占比。</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="inactiveMemberships.length" class="group-section">
|
<view v-if="inactiveMemberships.length" class="group-section">
|
||||||
@@ -81,7 +68,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="scroll-bottom-spacer" />
|
<view class="scroll-bottom-spacer" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
<view v-if="allMemberships.length" class="purchase-dock">
|
<view v-if="userStore.loggedIn && allMemberships.length" class="purchase-dock">
|
||||||
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
|
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -94,15 +81,17 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
|
|||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
|
import { getCardTypeLabel } from '../../utils/format'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||||
const loading = ref(false)
|
const loading = computed(() => userStore.membershipsLoading)
|
||||||
|
const membershipNow = ref(Date.now())
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
||||||
@@ -129,36 +118,23 @@ function inactiveStatusClass(status: MembershipStatus): string {
|
|||||||
return 'mc-status--expired'
|
return 'mc-status--expired'
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardBgClass(type: CardTypeCategory): string {
|
const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
|
||||||
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
|
const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
|
||||||
if (type === CardTypeCategory.DURATION) return 'mc--duration'
|
const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
|
||||||
return 'mc--times'
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysRemaining(m: MembershipWithCardType): number {
|
|
||||||
const diff = new Date(m.expireDate).getTime() - Date.now()
|
|
||||||
return Math.max(0, Math.ceil(diff / 86_400_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMemberships() {
|
async function loadMemberships() {
|
||||||
loading.value = true
|
membershipNow.value = Date.now()
|
||||||
try {
|
if (!userStore.loggedIn) return
|
||||||
await userStore.fetchMemberships()
|
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
|
if (refreshing.value) return
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
try {
|
try { await loadMemberships() }
|
||||||
await userStore.fetchMemberships()
|
finally { refreshing.value = false }
|
||||||
} finally {
|
|
||||||
refreshing.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
|
||||||
|
|
||||||
function goBooking() {
|
function goBooking() {
|
||||||
uni.switchTab({ url: '/pages/booking/index' })
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
@@ -170,7 +146,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) {
|
||||||
@@ -196,28 +172,12 @@ onShow(loadMemberships)
|
|||||||
.group-title { font-size: 28rpx; font-weight: 500; }
|
.group-title { font-size: 28rpx; font-weight: 500; }
|
||||||
.group-count { font-size: 22rpx; color: #8b817b; }
|
.group-count { font-size: 22rpx; color: #8b817b; }
|
||||||
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
|
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
|
||||||
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
|
|
||||||
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
|
|
||||||
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
||||||
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
.mc-type-text { font-size: 22rpx; color: #8b817b; }
|
.mc-type-text { font-size: 22rpx; color: #8b817b; }
|
||||||
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
|
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
|
||||||
.mc-status--active { background: #edf2ec; color: #617d73; }
|
|
||||||
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
|
|
||||||
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
|
|
||||||
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
|
|
||||||
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
|
|
||||||
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
|
|
||||||
.mc-progress { margin-top: 18rpx; }
|
|
||||||
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
|
|
||||||
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
|
|
||||||
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
|
|
||||||
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
|
|
||||||
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
|
||||||
.mc-date-item--end { text-align: right; }
|
|
||||||
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
|
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
|
||||||
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
|
||||||
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
|
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
|
||||||
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
||||||
.mc-renew { background: #f3eee8; color: #8b7160; }
|
.mc-renew { background: #f3eee8; color: #8b7160; }
|
||||||
@@ -229,4 +189,16 @@ onShow(loadMemberships)
|
|||||||
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
|
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
|
||||||
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
|
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
|
||||||
.scroll-bottom-spacer { height: 20rpx; }
|
.scroll-bottom-spacer { height: 20rpx; }
|
||||||
|
.practice-summary { padding: 32rpx; margin: 24rpx 32rpx 0; border-radius: 24rpx; background: #eef1e9; border: 1rpx solid #dce3d4; color: #465c4a; }
|
||||||
|
.summary-heading { display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.summary-kicker { font-size: 18rpx; letter-spacing: 3rpx; color: #6d7d64; }
|
||||||
|
.summary-title { font-size: 32rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||||
|
.summary-grid { display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid #d4dec9; }
|
||||||
|
.summary-grid > view { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; text-align: center; }
|
||||||
|
.summary-grid > view + view { border-left: 1rpx solid #d4dec9; }
|
||||||
|
.summary-value { font-size: 42rpx; font-family: 'Baskerville', 'Times New Roman', serif; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
||||||
|
.summary-label { font-size: 20rpx; color: #6d7d64; }
|
||||||
|
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
|
||||||
|
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
|
||||||
|
.owned-card-wrap { margin-bottom: 20rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
8
packages/app/src/pages/profile/progress.vue
Normal file
8
packages/app/src/pages/profile/progress.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress /></view></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import MemberProgress from '../../components/MemberProgress.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
const navBarHeight = getSystemLayout().navBarHeight
|
||||||
|
</script>
|
||||||
|
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>
|
||||||
@@ -53,19 +53,20 @@
|
|||||||
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="agenda">
|
<view v-else class="agenda">
|
||||||
<view v-for="slot in slots" :key="slot.slotId" class="session">
|
<view v-for="slot in slots" :key="slot.slotId" class="session" hover-class="session--hover"
|
||||||
|
:aria-label="`${slot.startTime.slice(0, 5)} 至 ${slot.endTime.slice(0, 5)},${slot.students.length} 人`" @tap="openSlot(slot.slotId)">
|
||||||
<view class="session__time">
|
<view class="session__time">
|
||||||
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||||||
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="session__roster">
|
<view class="session__roster">
|
||||||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人</text></view>
|
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人 ›</text></view>
|
||||||
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||||||
<view class="student__headline">
|
<view class="student__headline">
|
||||||
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||||||
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
|
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap.stop="contactStudent(student.phone)">
|
||||||
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||||||
</button>
|
</button>
|
||||||
<text v-else class="student__no-phone">未留手机号</text>
|
<text v-else class="student__no-phone">未留手机号</text>
|
||||||
@@ -175,6 +176,9 @@ function formatPhone(phone: string) {
|
|||||||
function contactStudent(phone: string) {
|
function contactStudent(phone: string) {
|
||||||
uni.makePhoneCall({ phoneNumber: phone })
|
uni.makePhoneCall({ phoneNumber: phone })
|
||||||
}
|
}
|
||||||
|
function openSlot(slotId: string) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
|
||||||
|
}
|
||||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
[BookingStatus.CONFIRMED]: '已确认',
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
@@ -219,6 +223,7 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
|
|||||||
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
||||||
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
||||||
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
||||||
|
.session--hover { background: #f4f1ec; }
|
||||||
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
||||||
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||||
.session__end { font-size: 24rpx; color: #687367; }
|
.session__end { font-size: 24rpx; color: #687367; }
|
||||||
@@ -232,6 +237,8 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
|
|||||||
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
||||||
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
||||||
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
||||||
|
.student__status--completed { background: #e8e8e1; color: #5b6660; }
|
||||||
|
.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; }
|
||||||
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
||||||
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
||||||
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
||||||
|
|||||||
143
packages/app/src/stores/body-portrait.ts
Normal file
143
packages/app/src/stores/body-portrait.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type {
|
||||||
|
BodyPortraitAnswers,
|
||||||
|
BodyPortraitSessionResponse,
|
||||||
|
BodyPortraitSource,
|
||||||
|
BodyPortraitStats,
|
||||||
|
BodyPortraitTeaser,
|
||||||
|
BodyPortraitReport,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { EMPTY_ANSWERS } from './portrait-empty'
|
||||||
|
import { get, post, put } from '../utils/request'
|
||||||
|
|
||||||
|
const VISIT_KEY = 'portrait_visit_token'
|
||||||
|
const ACCESS_KEY = 'portrait_access_token'
|
||||||
|
const SOURCE_KEY = 'portrait_source'
|
||||||
|
const CAMPAIGN_KEY = 'portrait_campaign'
|
||||||
|
const REFERRAL_KEY = 'portrait_referral'
|
||||||
|
|
||||||
|
export const useBodyPortraitStore = defineStore('body-portrait', () => {
|
||||||
|
const visitToken = ref(String(uni.getStorageSync(VISIT_KEY) || ''))
|
||||||
|
const accessToken = ref(String(uni.getStorageSync(ACCESS_KEY) || ''))
|
||||||
|
const session = ref<BodyPortraitSessionResponse | null>(null)
|
||||||
|
const stats = ref<BodyPortraitStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const answers = computed(() => session.value?.answers || EMPTY_ANSWERS)
|
||||||
|
const teaser = computed<BodyPortraitTeaser | null>(() => session.value?.teaser || null)
|
||||||
|
const report = computed<BodyPortraitReport | null>(() => session.value?.report || null)
|
||||||
|
const claimed = computed(() => !!session.value?.claimed && !!session.value?.report)
|
||||||
|
const assessmentId = computed(() => session.value?.assessmentId || '')
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (visitToken.value) uni.setStorageSync(VISIT_KEY, visitToken.value)
|
||||||
|
if (accessToken.value) uni.setStorageSync(ACCESS_KEY, accessToken.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAttribution(query: Record<string, string | undefined> = {}) {
|
||||||
|
const source = query.source || query.utm_source
|
||||||
|
const campaign = query.campaign || query.campaign_id
|
||||||
|
const referral = query.inviteCode || query.ref || query.shareCode
|
||||||
|
if (source) uni.setStorageSync(SOURCE_KEY, source)
|
||||||
|
if (campaign) uni.setStorageSync(CAMPAIGN_KEY, campaign)
|
||||||
|
if (referral) uni.setStorageSync(REFERRAL_KEY, referral)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
stats.value = await get<BodyPortraitStats>('/body-portrait/stats')
|
||||||
|
return stats.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
if (session.value?.accessToken) return session.value
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (!visitToken.value) {
|
||||||
|
const visit = await post<{ visitId: string; visitToken: string; source: BodyPortraitSource }>('/body-portrait/visits', {
|
||||||
|
source: String(uni.getStorageSync(SOURCE_KEY) || 'organic'),
|
||||||
|
campaignId: String(uni.getStorageSync(CAMPAIGN_KEY) || '') || undefined,
|
||||||
|
referralCode: String(uni.getStorageSync(REFERRAL_KEY) || '') || undefined,
|
||||||
|
landingPath: '/pages/portrait/index',
|
||||||
|
})
|
||||||
|
visitToken.value = visit.visitToken
|
||||||
|
}
|
||||||
|
const started = await post<BodyPortraitSessionResponse>('/body-portrait/assessments', { visitToken: visitToken.value })
|
||||||
|
accessToken.value = started.accessToken
|
||||||
|
session.value = started
|
||||||
|
persist()
|
||||||
|
return started
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnswers(next: Partial<BodyPortraitAnswers>) {
|
||||||
|
if (!accessToken.value) await ensureSession()
|
||||||
|
const updated = await put<BodyPortraitSessionResponse>('/body-portrait/assessments', {
|
||||||
|
accessToken: accessToken.value,
|
||||||
|
answers: { ...answers.value, ...next },
|
||||||
|
})
|
||||||
|
accessToken.value = updated.accessToken || accessToken.value
|
||||||
|
session.value = { ...updated, accessToken: accessToken.value }
|
||||||
|
persist()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete() {
|
||||||
|
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/complete', { accessToken: accessToken.value })
|
||||||
|
session.value = { ...updated, accessToken: accessToken.value }
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claim() {
|
||||||
|
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/claim', { accessToken: accessToken.value })
|
||||||
|
session.value = { ...updated, accessToken: accessToken.value, claimed: true }
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMine() {
|
||||||
|
const latest = await get<BodyPortraitSessionResponse | null>('/body-portrait/me')
|
||||||
|
if (latest) session.value = { ...latest, accessToken: accessToken.value }
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
async function track(name: string) {
|
||||||
|
try {
|
||||||
|
const scopeKey = assessmentId.value || visitToken.value || `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
await post('/body-portrait/events', {
|
||||||
|
name,
|
||||||
|
accessToken: accessToken.value || undefined,
|
||||||
|
idempotencyKey: `${name}:${scopeKey}`.slice(0, 80),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// tracking must never block the user flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepAnonymousOnLogout() {
|
||||||
|
session.value = session.value?.claimed ? null : session.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visitToken,
|
||||||
|
accessToken,
|
||||||
|
session,
|
||||||
|
stats,
|
||||||
|
loading,
|
||||||
|
answers,
|
||||||
|
teaser,
|
||||||
|
report,
|
||||||
|
claimed,
|
||||||
|
assessmentId,
|
||||||
|
captureAttribution,
|
||||||
|
fetchStats,
|
||||||
|
ensureSession,
|
||||||
|
saveAnswers,
|
||||||
|
complete,
|
||||||
|
claim,
|
||||||
|
loadMine,
|
||||||
|
track,
|
||||||
|
keepAnonymousOnLogout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TeachingScheduleSlot,
|
TeachingScheduleSlot,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { get, post, put } from '../utils/request'
|
import { get, post, put } from '../utils/request'
|
||||||
|
import { useBodyPortraitStore } from './body-portrait'
|
||||||
|
|
||||||
/** Server paginated responses use `data` field, not `items` from the shared type */
|
/** Server paginated responses use `data` field, not `items` from the shared type */
|
||||||
interface ServerPaginatedResult<T> {
|
interface ServerPaginatedResult<T> {
|
||||||
@@ -40,7 +41,12 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createBooking(dto: CreateBookingDto) {
|
async function createBooking(dto: CreateBookingDto) {
|
||||||
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>)
|
const portraitStore = useBodyPortraitStore()
|
||||||
|
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
|
||||||
|
const result = await post<BookingWithDetails>('/booking', {
|
||||||
|
...dto,
|
||||||
|
...(originAssessmentId ? { originAssessmentId } : {}),
|
||||||
|
} as unknown as Record<string, unknown>)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import type {
|
|
||||||
FlashSaleListItem,
|
|
||||||
FlashSaleDetail,
|
|
||||||
FlashSalePurchaseResponse,
|
|
||||||
} from '@mp-pilates/shared'
|
|
||||||
import { get, post } from '../utils/request'
|
|
||||||
|
|
||||||
export const useFlashSaleStore = defineStore('flash-sale', () => {
|
|
||||||
const flashSales = ref<FlashSaleListItem[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchFlashSales(): Promise<FlashSaleListItem[]> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await get<FlashSaleListItem[]>('/flash-sales')
|
|
||||||
flashSales.value = [...data]
|
|
||||||
return data
|
|
||||||
} catch {
|
|
||||||
flashSales.value = []
|
|
||||||
return []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail(id: string): Promise<FlashSaleDetail> {
|
|
||||||
return get<FlashSaleDetail>(`/flash-sales/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function purchase(id: string): Promise<FlashSalePurchaseResponse> {
|
|
||||||
return post<FlashSalePurchaseResponse>(`/flash-sales/${id}/purchase`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
flashSales,
|
|
||||||
loading,
|
|
||||||
fetchFlashSales,
|
|
||||||
fetchDetail,
|
|
||||||
purchase,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
13
packages/app/src/stores/portrait-empty.ts
Normal file
13
packages/app/src/stores/portrait-empty.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { BodyPortraitAnswers } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
|
||||||
|
concerns: [],
|
||||||
|
goal: null,
|
||||||
|
sittingHours: null,
|
||||||
|
exerciseFreq: null,
|
||||||
|
workPosture: null,
|
||||||
|
endOfDayFatigue: [],
|
||||||
|
afterSitting: [],
|
||||||
|
standingNotice: [],
|
||||||
|
safety: [],
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useInviteStore } from './invite'
|
||||||
|
import { useBodyPortraitStore } from './body-portrait'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +27,13 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const user = ref<UserProfileResponse | null>(null)
|
const user = ref<UserProfileResponse | null>(null)
|
||||||
const stats = ref<UserStatsResponse | null>(null)
|
const stats = ref<UserStatsResponse | null>(null)
|
||||||
const memberships = ref<readonly MembershipWithCardType[]>([])
|
const memberships = ref<readonly MembershipWithCardType[]>([])
|
||||||
|
const membershipsLoading = ref(false)
|
||||||
|
const membershipsLoaded = ref(false)
|
||||||
|
const membershipsError = ref(false)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const statsError = ref(false)
|
||||||
|
let membershipRequestId = 0
|
||||||
|
let statsRequestId = 0
|
||||||
const token = ref<string>(uni.getStorageSync('token') as string || '')
|
const token = ref<string>(uni.getStorageSync('token') as string || '')
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -46,6 +55,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 +82,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) {
|
||||||
@@ -79,23 +90,42 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats(): Promise<boolean> {
|
||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return false
|
||||||
|
const id = ++statsRequestId
|
||||||
|
const session = token.value
|
||||||
|
statsLoading.value = true
|
||||||
|
statsError.value = false
|
||||||
try {
|
try {
|
||||||
stats.value = await get<UserStatsResponse>('/user/stats')
|
const result = await get<UserStatsResponse>('/user/stats')
|
||||||
} catch (err) {
|
if (id !== statsRequestId || session !== token.value) return false
|
||||||
console.error('Fetch stats failed:', err)
|
stats.value = result
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
if (id === statsRequestId && session === token.value) statsError.value = true
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (id === statsRequestId && session === token.value) statsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMemberships(): Promise<boolean> {
|
async function fetchMemberships(): Promise<boolean> {
|
||||||
if (!isLoggedIn()) return false
|
if (!isLoggedIn()) return false
|
||||||
|
const id = ++membershipRequestId
|
||||||
|
const session = token.value
|
||||||
|
membershipsLoading.value = true
|
||||||
|
membershipsError.value = false
|
||||||
try {
|
try {
|
||||||
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
const result = await get<MembershipWithCardType[]>('/membership/my')
|
||||||
|
if (id !== membershipRequestId || session !== token.value) return false
|
||||||
|
memberships.value = [...result]
|
||||||
|
membershipsLoaded.value = true
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Fetch memberships failed:', err)
|
if (id === membershipRequestId && session === token.value) membershipsError.value = true
|
||||||
return false
|
return false
|
||||||
|
} finally {
|
||||||
|
if (id === membershipRequestId && session === token.value) membershipsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +149,20 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
|
membershipRequestId++
|
||||||
|
statsRequestId++
|
||||||
|
membershipsLoading.value = false
|
||||||
|
membershipsLoaded.value = false
|
||||||
|
membershipsError.value = false
|
||||||
|
statsLoading.value = false
|
||||||
|
statsError.value = false
|
||||||
token.value = ''
|
token.value = ''
|
||||||
|
useInviteStore().reset()
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
memberships.value = []
|
memberships.value = []
|
||||||
resetSubscriptionMessageTemplateCache()
|
resetSubscriptionMessageTemplateCache()
|
||||||
|
useBodyPortraitStore().keepAnonymousOnLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -134,6 +173,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
setUnauthorizedHandler(clearSession)
|
setUnauthorizedHandler(clearSession)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
membershipsLoading,
|
||||||
|
membershipsLoaded,
|
||||||
|
membershipsError,
|
||||||
|
statsLoading,
|
||||||
|
statsError,
|
||||||
user,
|
user,
|
||||||
stats,
|
stats,
|
||||||
memberships,
|
memberships,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { FlashSalePhase } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
/** Minimal membership shape needed by progress/usage helpers. */
|
/** Minimal membership shape needed by progress/usage helpers. */
|
||||||
interface MembershipLike {
|
interface MembershipLike {
|
||||||
@@ -13,6 +12,13 @@ export function formatPrice(cents: number): string {
|
|||||||
return (cents / 100).toFixed(2)
|
return (cents / 100).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 中国自然日 YYYY-MM-DD,用于时间戳与 UTC 日期列展示 */
|
||||||
|
export function formatChinaDate(value: Date | string): string {
|
||||||
|
const time = new Date(value).getTime()
|
||||||
|
if (!Number.isFinite(time)) return ''
|
||||||
|
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
/** 格式化日期为 YYYY-MM-DD */
|
/** 格式化日期为 YYYY-MM-DD */
|
||||||
export function formatDate(date: Date | string): string {
|
export function formatDate(date: Date | string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
@@ -121,17 +127,6 @@ export function getCountdownParts(targetTime: string): { readonly h: string; rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 秒杀阶段中文标签 */
|
|
||||||
export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
|
|
||||||
const map: Record<FlashSalePhase, string> = {
|
|
||||||
[FlashSalePhase.UPCOMING]: '即将开始',
|
|
||||||
[FlashSalePhase.ONGOING]: '抢购中',
|
|
||||||
[FlashSalePhase.SOLD_OUT]: '已售罄',
|
|
||||||
[FlashSalePhase.ENDED]: '已结束',
|
|
||||||
}
|
|
||||||
return map[phase]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 库存已售比例 */
|
/** 库存已售比例 */
|
||||||
export function getStockRatio(soldCount: number, totalStock: number): number {
|
export function getStockRatio(soldCount: number, totalStock: number): number {
|
||||||
if (totalStock === 0) return 0
|
if (totalStock === 0) return 0
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
SubscriptionMessageRequestItem,
|
SubscriptionMessageRequestItem,
|
||||||
SubscriptionMessageTemplate,
|
SubscriptionMessageTemplate,
|
||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
|
SubscriptionQuotaItem,
|
||||||
|
SubscriptionQuotasResponse,
|
||||||
UserProfileResponse,
|
UserProfileResponse,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { post } from './request'
|
import { get, post } from './request'
|
||||||
|
|
||||||
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||||
|
|
||||||
@@ -86,20 +88,27 @@ function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestIte
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
|
function getTemplateConfigSync(): SubscriptionMessageTemplateConfig | null {
|
||||||
if (cachedConfig) {
|
if (cachedConfig) {
|
||||||
return cachedConfig
|
return cachedConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
|
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
|
||||||
if (!stored || !Array.isArray(stored.templates)) {
|
if (!stored || !Array.isArray(stored.templates)) {
|
||||||
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: SubscriptionMessageTemplateConfig = {
|
cachedConfig = {
|
||||||
templates: stored.templates.filter((item) => item.templateId),
|
templates: stored.templates.filter((item) => item.templateId),
|
||||||
}
|
}
|
||||||
cachedConfig = config
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
|
||||||
|
const config = getTemplateConfigSync()
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,37 +145,11 @@ async function reportResults(requests: SubscriptionMessageRequestItem[]): Promis
|
|||||||
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
|
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
function normalizeSubscribeResults(
|
||||||
if (!isMpWeixin()) {
|
templates: SubscriptionMessageTemplate[],
|
||||||
return []
|
result: RequestSubscribeMessageSuccess,
|
||||||
}
|
): SubscriptionMessageRequestItem[] {
|
||||||
|
return templates
|
||||||
const config = await fetchTemplateConfig()
|
|
||||||
const templates = getTemplatesByScene(config, scene)
|
|
||||||
if (templates.length === 0) {
|
|
||||||
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateIds = templates.map((item) => item.templateId)
|
|
||||||
const debugContext = getSubscribeDebugContext()
|
|
||||||
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
|
||||||
|
|
||||||
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
|
||||||
uni.requestSubscribeMessage({
|
|
||||||
tmplIds: templateIds,
|
|
||||||
success: (res) => {
|
|
||||||
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
|
|
||||||
resolve(res as RequestSubscribeMessageSuccess)
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
|
||||||
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const requests = templates
|
|
||||||
.map<SubscriptionMessageRequestItem | null>((item) => {
|
.map<SubscriptionMessageRequestItem | null>((item) => {
|
||||||
const normalized = normalizeResult(result[item.templateId])
|
const normalized = normalizeResult(result[item.templateId])
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -180,19 +163,134 @@ export async function requestSubscriptionMessage(scene: SubscriptionMessageScene
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||||
|
|
||||||
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
|
|
||||||
|
|
||||||
await reportResults(requests)
|
|
||||||
return requests
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [
|
||||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [
|
||||||
|
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
|
||||||
|
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
|
||||||
|
*/
|
||||||
|
export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
if (!isMpWeixin()) {
|
||||||
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
const config = getTemplateConfigSync()
|
||||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
if (!config) {
|
||||||
|
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按场景收集模板,去重 templateId,微信单次最多支持 3 个模板
|
||||||
|
const templateMap = new Map<string, SubscriptionMessageTemplate>()
|
||||||
|
for (const scene of scenes) {
|
||||||
|
const list = getTemplatesByScene(config, scene)
|
||||||
|
for (const tpl of list) {
|
||||||
|
if (tpl.templateId && !templateMap.has(tpl.templateId)) {
|
||||||
|
templateMap.set(tpl.templateId, tpl)
|
||||||
|
}
|
||||||
|
if (templateMap.size >= 3) break
|
||||||
|
}
|
||||||
|
if (templateMap.size >= 3) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = Array.from(templateMap.values())
|
||||||
|
if (templates.length === 0) {
|
||||||
|
console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() }))
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIds = templates.map((item) => item.templateId)
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
|
||||||
|
|
||||||
|
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
|
||||||
|
uni.requestSubscribeMessage({
|
||||||
|
tmplIds: templateIds,
|
||||||
|
success: (res) => {
|
||||||
|
const response = res as RequestSubscribeMessageSuccess
|
||||||
|
const requests = normalizeSubscribeResults(templates, response)
|
||||||
|
console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext }))
|
||||||
|
console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext }))
|
||||||
|
void reportResults(requests)
|
||||||
|
.then(() => resolve(requests))
|
||||||
|
.catch(reject)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext }))
|
||||||
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle([scene])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 约课三合一组合授权:包含约课成功确认、开课前1小时提醒、课程取消通知
|
||||||
|
* 一次点击,三个场景额度同时 +1!
|
||||||
|
*/
|
||||||
|
export function requestBookingBundleSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容原有命名,直接升级为约课三合一组合授权
|
||||||
|
*/
|
||||||
|
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestBookingBundleSubscriptionMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消预约时的组合授权:课程取消通知 + 下次上课提醒
|
||||||
|
*/
|
||||||
|
export function requestBookingCancelSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上课前1小时提醒单项授权
|
||||||
|
*/
|
||||||
|
export function requestClassReminderSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户的各订阅场景额度水位
|
||||||
|
*/
|
||||||
|
export async function fetchUserSubscriptionQuotas(): Promise<SubscriptionQuotaItem[]> {
|
||||||
|
try {
|
||||||
|
const res = await get<SubscriptionQuotasResponse>('/user/subscription-messages/quotas')
|
||||||
|
return res.quotas || []
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引导打开微信系统设置页,供用户恢复通知授权
|
||||||
|
*/
|
||||||
|
export function openSubscribeSettings(): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
uni.openSetting({
|
||||||
|
success: (res) => {
|
||||||
|
resolve(!!res.authSetting)
|
||||||
|
},
|
||||||
|
fail: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ API_BASE_URL=https://focus.richarjiang.com/
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
|
||||||
|
|
||||||
# COS upload
|
# COS upload
|
||||||
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
|
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
|
||||||
|
|||||||
@@ -11,3 +11,9 @@ COS_REGION=ap-guangzhou
|
|||||||
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||||
COS_UPLOAD_PREFIX=mp/studio
|
COS_UPLOAD_PREFIX=mp/studio
|
||||||
COS_UPLOAD_DURATION_SECONDS=1800
|
COS_UPLOAD_DURATION_SECONDS=1800
|
||||||
|
|
||||||
|
# WeChat subscribe message for class review reminders (24h after completion)
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
|
||||||
|
|||||||
@@ -0,0 +1,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());
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `bookings` ADD COLUMN `review_reminder_claimed_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `review_reminder_due_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `review_reminder_sent_at` DATETIME(3) NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `booking_reviews` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`booking_id` VARCHAR(191) NOT NULL,
|
||||||
|
`rating` INTEGER NOT NULL,
|
||||||
|
`recommendation` INTEGER NULL,
|
||||||
|
`tags` JSON NOT NULL,
|
||||||
|
`comment` VARCHAR(200) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `booking_reviews_booking_id_key`(`booking_id`),
|
||||||
|
INDEX `booking_reviews_created_at_idx`(`created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `body_metrics` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`recorded_at` DATE NOT NULL,
|
||||||
|
`weight` DOUBLE NULL,
|
||||||
|
`body_fat` DOUBLE NULL,
|
||||||
|
`waist` DOUBLE NULL,
|
||||||
|
`hip` DOUBLE NULL,
|
||||||
|
`flexibility` DOUBLE NULL,
|
||||||
|
`remark` VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `body_metrics_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `member_notes` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`booking_id` VARCHAR(191) NULL,
|
||||||
|
`content` VARCHAR(1000) NOT NULL,
|
||||||
|
`shared` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `member_notes_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `progress_photos` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`object_key` VARCHAR(191) NOT NULL,
|
||||||
|
`caption` VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
`recorded_at` DATE NOT NULL,
|
||||||
|
`uploaded_at` DATETIME(3) NULL,
|
||||||
|
`consented_at` DATETIME(3) NULL,
|
||||||
|
`revoked_at` DATETIME(3) NULL,
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `progress_photos_object_key_key`(`object_key`),
|
||||||
|
INDEX `progress_photos_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `booking_reviews` ADD CONSTRAINT `booking_reviews_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `body_metrics` ADD CONSTRAINT `body_metrics_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Drop flash sale feature
|
||||||
|
-- See docs/flash-sale-removal.md for context
|
||||||
|
--
|
||||||
|
-- NOTE: production MySQL has no FK on orders.flash_sale_id, so we drop the
|
||||||
|
-- column directly without a prior DROP FOREIGN KEY.
|
||||||
|
|
||||||
|
-- Drop tables first
|
||||||
|
DROP TABLE IF EXISTS `flash_sale_orders`;
|
||||||
|
DROP TABLE IF EXISTS `flash_sales`;
|
||||||
|
|
||||||
|
-- Remove order.flash_sale_id column
|
||||||
|
ALTER TABLE `orders` DROP COLUMN `flash_sale_id`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `bookings` ADD COLUMN `class_reminder_claimed_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `class_reminder_due_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `class_reminder_sent_at` DATETIME(3) NULL;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `bookings` ADD COLUMN `origin_assessment_id` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `progress_photos` ADD COLUMN `angle` VARCHAR(16) NULL,
|
||||||
|
ADD COLUMN `session_id` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `body_portrait_visits` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`source` VARCHAR(32) NOT NULL DEFAULT 'organic',
|
||||||
|
`campaign_id` VARCHAR(40) NULL,
|
||||||
|
`referral_code` VARCHAR(12) NULL,
|
||||||
|
`referrer_user_id` VARCHAR(191) NULL,
|
||||||
|
`landing_path` VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
`user_id` VARCHAR(191) NULL,
|
||||||
|
`first_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `body_portrait_visits_token_hash_key`(`token_hash`),
|
||||||
|
INDEX `body_portrait_visits_source_created_at_idx`(`source`, `created_at`),
|
||||||
|
INDEX `body_portrait_visits_user_id_idx`(`user_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `body_portrait_assessments` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`visit_id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NULL,
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`questionnaire_version` VARCHAR(32) NOT NULL,
|
||||||
|
`rules_version` VARCHAR(32) NOT NULL,
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`answers` JSON NOT NULL,
|
||||||
|
`safety_flagged` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`report` JSON NULL,
|
||||||
|
`completed_at` DATETIME(3) NULL,
|
||||||
|
`claimed_at` DATETIME(3) NULL,
|
||||||
|
`expires_at` DATETIME(3) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `body_portrait_assessments_token_hash_key`(`token_hash`),
|
||||||
|
INDEX `body_portrait_assessments_visit_id_idx`(`visit_id`),
|
||||||
|
INDEX `body_portrait_assessments_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||||
|
INDEX `body_portrait_assessments_status_expires_at_idx`(`status`, `expires_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `growth_leads` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`latest_assessment_id` VARCHAR(191) NULL,
|
||||||
|
`stage` VARCHAR(32) NOT NULL DEFAULT 'CLAIMED',
|
||||||
|
`source` VARCHAR(32) NULL,
|
||||||
|
`campaign_id` VARCHAR(40) NULL,
|
||||||
|
`last_active_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`coach_note` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `growth_leads_user_id_key`(`user_id`),
|
||||||
|
INDEX `growth_leads_stage_last_active_at_idx`(`stage`, `last_active_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `growth_events` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(40) NOT NULL,
|
||||||
|
`idempotency_key` VARCHAR(120) NOT NULL,
|
||||||
|
`visit_id` VARCHAR(191) NULL,
|
||||||
|
`assessment_id` VARCHAR(191) NULL,
|
||||||
|
`user_id` VARCHAR(191) NULL,
|
||||||
|
`properties` JSON NOT NULL,
|
||||||
|
`occurred_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `growth_events_idempotency_key_key`(`idempotency_key`),
|
||||||
|
INDEX `growth_events_name_occurred_at_idx`(`name`, `occurred_at`),
|
||||||
|
INDEX `growth_events_user_id_occurred_at_idx`(`user_id`, `occurred_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `professional_assessment_sessions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`kind` VARCHAR(16) NOT NULL,
|
||||||
|
`protocol_version` VARCHAR(32) NOT NULL DEFAULT 'offline-v1',
|
||||||
|
`recorded_at` DATE NOT NULL,
|
||||||
|
`booking_id` VARCHAR(191) NULL,
|
||||||
|
`origin_assessment_id` VARCHAR(191) NULL,
|
||||||
|
`observations` JSON NOT NULL,
|
||||||
|
`subjective_tension` DOUBLE NULL,
|
||||||
|
`coach_summary` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
`training_focus` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`phase_goal` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`photo_angle` VARCHAR(16) NULL,
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `professional_assessment_sessions_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `training_plans` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`session_id` VARCHAR(191) NULL,
|
||||||
|
`title` VARCHAR(80) NOT NULL,
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
`weeks` INTEGER NOT NULL DEFAULT 12,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `training_plans_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `training_plan_phases` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`plan_id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(40) NOT NULL,
|
||||||
|
`lesson_start` INTEGER NOT NULL,
|
||||||
|
`lesson_end` INTEGER NOT NULL,
|
||||||
|
`focus` JSON NOT NULL,
|
||||||
|
`summary` VARCHAR(300) NOT NULL DEFAULT '',
|
||||||
|
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
INDEX `training_plan_phases_plan_id_idx`(`plan_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `reassessment_todos` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`plan_id` VARCHAR(191) NOT NULL,
|
||||||
|
`lesson_checkpoint` INTEGER NOT NULL,
|
||||||
|
`due_at` DATETIME(3) NOT NULL,
|
||||||
|
`completed_at` DATETIME(3) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `reassessment_todos_plan_id_lesson_checkpoint_key`(`plan_id`, `lesson_checkpoint`),
|
||||||
|
INDEX `reassessment_todos_user_id_completed_at_idx`(`user_id`, `completed_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `growth_share_cards` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`plan_id` VARCHAR(191) NULL,
|
||||||
|
`share_code` VARCHAR(12) NOT NULL,
|
||||||
|
`title` VARCHAR(80) NOT NULL,
|
||||||
|
`caption` VARCHAR(200) NOT NULL,
|
||||||
|
`completed_count` INTEGER NOT NULL,
|
||||||
|
`weeks` INTEGER NOT NULL DEFAULT 12,
|
||||||
|
`rows` JSON NOT NULL,
|
||||||
|
`include_photos` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `growth_share_cards_share_code_key`(`share_code`),
|
||||||
|
INDEX `growth_share_cards_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX `bookings_origin_assessment_id_idx` ON `bookings`(`origin_assessment_id`);
|
||||||
|
CREATE INDEX `progress_photos_session_id_idx` ON `progress_photos`(`session_id`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_referrer_user_id_fkey` FOREIGN KEY (`referrer_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_latest_assessment_id_fkey` FOREIGN KEY (`latest_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_assessment_id_fkey` FOREIGN KEY (`assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `bookings` ADD CONSTRAINT `bookings_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `training_plan_phases` ADD CONSTRAINT `training_plan_phases_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -51,18 +51,6 @@ enum OrderStatus {
|
|||||||
REFUNDED
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FlashSaleStatus {
|
|
||||||
DRAFT
|
|
||||||
ACTIVE
|
|
||||||
ENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FlashSaleOrderStatus {
|
|
||||||
RESERVED
|
|
||||||
PAID
|
|
||||||
EXPIRED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InviteReferralStatus {
|
enum InviteReferralStatus {
|
||||||
REGISTERED
|
REGISTERED
|
||||||
TRIAL_PURCHASED
|
TRIAL_PURCHASED
|
||||||
@@ -73,6 +61,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?
|
||||||
@@ -84,15 +73,26 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
bodyMetrics BodyMetric[]
|
||||||
|
memberNotes MemberNote[]
|
||||||
|
progressPhotos ProgressPhoto[]
|
||||||
lessonSupplements LessonSupplement[]
|
lessonSupplements LessonSupplement[]
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
flashSaleOrders FlashSaleOrder[]
|
|
||||||
subscriptionMessageConsents SubscriptionMessageConsent[]
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||||
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||||
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||||
|
bodyPortraitVisits BodyPortraitVisit[] @relation("PortraitVisitUser")
|
||||||
|
referredPortraitVisits BodyPortraitVisit[] @relation("PortraitReferrer")
|
||||||
|
bodyPortraitAssessments BodyPortraitAssessment[]
|
||||||
|
growthLead GrowthLead?
|
||||||
|
growthEvents GrowthEvent[]
|
||||||
|
professionalAssessments ProfessionalAssessmentSession[]
|
||||||
|
trainingPlans TrainingPlan[]
|
||||||
|
reassessmentTodos ReassessmentTodo[]
|
||||||
|
growthShareCards GrowthShareCard[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,6 @@ model CardType {
|
|||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
flashSales FlashSale[]
|
|
||||||
|
|
||||||
@@map("card_types")
|
@@map("card_types")
|
||||||
}
|
}
|
||||||
@@ -217,19 +216,31 @@ model Booking {
|
|||||||
confirmedAt DateTime? @map("confirmed_at")
|
confirmedAt DateTime? @map("confirmed_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
operatorId String? @map("operator_id")
|
operatorId String? @map("operator_id")
|
||||||
|
originAssessmentId String? @map("origin_assessment_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
membership Membership @relation(fields: [membershipId], references: [id])
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
|
||||||
qualifiedInviteReferrals InviteReferral[]
|
qualifiedInviteReferrals InviteReferral[]
|
||||||
|
professionalAssessments ProfessionalAssessmentSession[]
|
||||||
|
|
||||||
|
review BookingReview?
|
||||||
|
memberNotes MemberNote[]
|
||||||
|
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
|
||||||
|
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
|
||||||
|
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
|
||||||
|
classReminderDueAt DateTime? @map("class_reminder_due_at")
|
||||||
|
classReminderClaimedAt DateTime? @map("class_reminder_claimed_at")
|
||||||
|
classReminderSentAt DateTime? @map("class_reminder_sent_at")
|
||||||
statusHistory BookingStatusHistory[]
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@unique([userId, timeSlotId])
|
@@unique([userId, timeSlotId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([originAssessmentId])
|
||||||
@@map("bookings")
|
@@map("bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,18 +265,18 @@ 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")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
flashSaleId String? @map("flash_sale_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||||
flashSaleOrder FlashSaleOrder?
|
|
||||||
inviteReferrals InviteReferral[]
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -331,51 +342,6 @@ model StudioConfig {
|
|||||||
@@map("studio_config")
|
@@map("studio_config")
|
||||||
}
|
}
|
||||||
|
|
||||||
model FlashSale {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
cardTypeId String @map("card_type_id")
|
|
||||||
title String
|
|
||||||
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
|
|
||||||
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
|
|
||||||
totalStock Int @map("total_stock")
|
|
||||||
soldCount Int @default(0) @map("sold_count")
|
|
||||||
startTime DateTime @map("start_time")
|
|
||||||
endTime DateTime @map("end_time")
|
|
||||||
status FlashSaleStatus @default(DRAFT)
|
|
||||||
description String? @db.Text
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
|
||||||
orders FlashSaleOrder[]
|
|
||||||
|
|
||||||
@@index([status, startTime, endTime])
|
|
||||||
@@map("flash_sales")
|
|
||||||
}
|
|
||||||
|
|
||||||
model FlashSaleOrder {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
flashSaleId String @map("flash_sale_id")
|
|
||||||
userId String @map("user_id")
|
|
||||||
orderId String? @unique @map("order_id")
|
|
||||||
status FlashSaleOrderStatus @default(RESERVED)
|
|
||||||
reservedAt DateTime @default(now()) @map("reserved_at")
|
|
||||||
paidAt DateTime? @map("paid_at")
|
|
||||||
expiredAt DateTime? @map("expired_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
order Order? @relation(fields: [orderId], references: [id])
|
|
||||||
|
|
||||||
@@unique([flashSaleId, userId])
|
|
||||||
@@index([userId])
|
|
||||||
@@index([status])
|
|
||||||
@@map("flash_sale_orders")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Historical totals without fabricated scheduled dates.
|
// Historical totals without fabricated scheduled dates.
|
||||||
model LessonSupplement {
|
model LessonSupplement {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
@@ -399,3 +365,258 @@ model LessonSupplement {
|
|||||||
@@index([userId, revokedAt, createdAt])
|
@@index([userId, revokedAt, createdAt])
|
||||||
@@map("lesson_supplements")
|
@@map("lesson_supplements")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BookingReview {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bookingId String @unique @map("booking_id")
|
||||||
|
rating Int
|
||||||
|
recommendation Int?
|
||||||
|
tags Json
|
||||||
|
comment String @db.VarChar(200)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("booking_reviews")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BodyMetric {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
recordedAt DateTime @db.Date @map("recorded_at")
|
||||||
|
weight Float?
|
||||||
|
bodyFat Float? @map("body_fat")
|
||||||
|
waist Float?
|
||||||
|
hip Float?
|
||||||
|
flexibility Float?
|
||||||
|
remark String @default("") @db.VarChar(200)
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
@@index([userId, recordedAt])
|
||||||
|
@@map("body_metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberNote {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
bookingId String? @map("booking_id")
|
||||||
|
content String @db.VarChar(1000)
|
||||||
|
shared Boolean @default(false)
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@map("member_notes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProgressPhoto {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
objectKey String @unique @map("object_key")
|
||||||
|
caption String @default("") @db.VarChar(200)
|
||||||
|
recordedAt DateTime @db.Date @map("recorded_at")
|
||||||
|
uploadedAt DateTime? @map("uploaded_at")
|
||||||
|
consentedAt DateTime? @map("consented_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
angle String? @db.VarChar(16)
|
||||||
|
sessionId String? @map("session_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
|
||||||
|
@@index([userId, recordedAt])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@map("progress_photos")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BodyPortraitVisit {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tokenHash String @unique @map("token_hash") @db.VarChar(64)
|
||||||
|
source String @default("organic") @db.VarChar(32)
|
||||||
|
campaignId String? @map("campaign_id") @db.VarChar(40)
|
||||||
|
referralCode String? @map("referral_code") @db.VarChar(12)
|
||||||
|
referrerUserId String? @map("referrer_user_id")
|
||||||
|
landingPath String @default("") @map("landing_path") @db.VarChar(120)
|
||||||
|
userId String? @map("user_id")
|
||||||
|
firstSeenAt DateTime @default(now()) @map("first_seen_at")
|
||||||
|
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User? @relation("PortraitVisitUser", fields: [userId], references: [id])
|
||||||
|
referrer User? @relation("PortraitReferrer", fields: [referrerUserId], references: [id])
|
||||||
|
assessments BodyPortraitAssessment[]
|
||||||
|
events GrowthEvent[]
|
||||||
|
|
||||||
|
@@index([source, createdAt])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("body_portrait_visits")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BodyPortraitAssessment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
visitId String @map("visit_id")
|
||||||
|
userId String? @map("user_id")
|
||||||
|
tokenHash String @unique @map("token_hash") @db.VarChar(64)
|
||||||
|
questionnaireVersion String @map("questionnaire_version") @db.VarChar(32)
|
||||||
|
rulesVersion String @map("rules_version") @db.VarChar(32)
|
||||||
|
status String @default("DRAFT") @db.VarChar(16)
|
||||||
|
answers Json @default("{}")
|
||||||
|
safetyFlagged Boolean @default(false) @map("safety_flagged")
|
||||||
|
report Json?
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
claimedAt DateTime? @map("claimed_at")
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
visit BodyPortraitVisit @relation(fields: [visitId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
bookings Booking[]
|
||||||
|
growthLeads GrowthLead[]
|
||||||
|
events GrowthEvent[]
|
||||||
|
professionalAssessments ProfessionalAssessmentSession[]
|
||||||
|
|
||||||
|
@@index([visitId])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@map("body_portrait_assessments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GrowthLead {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique @map("user_id")
|
||||||
|
latestAssessmentId String? @map("latest_assessment_id")
|
||||||
|
stage String @default("CLAIMED") @db.VarChar(32)
|
||||||
|
source String? @db.VarChar(32)
|
||||||
|
campaignId String? @map("campaign_id") @db.VarChar(40)
|
||||||
|
lastActiveAt DateTime @default(now()) @map("last_active_at")
|
||||||
|
coachNote String @default("") @map("coach_note") @db.VarChar(500)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
latestAssessment BodyPortraitAssessment? @relation(fields: [latestAssessmentId], references: [id])
|
||||||
|
|
||||||
|
@@index([stage, lastActiveAt])
|
||||||
|
@@map("growth_leads")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GrowthEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @db.VarChar(40)
|
||||||
|
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(120)
|
||||||
|
visitId String? @map("visit_id")
|
||||||
|
assessmentId String? @map("assessment_id")
|
||||||
|
userId String? @map("user_id")
|
||||||
|
properties Json @default("{}")
|
||||||
|
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||||
|
|
||||||
|
visit BodyPortraitVisit? @relation(fields: [visitId], references: [id])
|
||||||
|
assessment BodyPortraitAssessment? @relation(fields: [assessmentId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@index([name, occurredAt])
|
||||||
|
@@index([userId, occurredAt])
|
||||||
|
@@map("growth_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProfessionalAssessmentSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
kind String @db.VarChar(16)
|
||||||
|
protocolVersion String @default("offline-v1") @map("protocol_version") @db.VarChar(32)
|
||||||
|
recordedAt DateTime @db.Date @map("recorded_at")
|
||||||
|
bookingId String? @map("booking_id")
|
||||||
|
originAssessmentId String? @map("origin_assessment_id")
|
||||||
|
observations Json
|
||||||
|
subjectiveTension Float? @map("subjective_tension")
|
||||||
|
coachSummary String @default("") @map("coach_summary") @db.VarChar(1000)
|
||||||
|
trainingFocus String @default("") @map("training_focus") @db.VarChar(500)
|
||||||
|
phaseGoal String @default("") @map("phase_goal") @db.VarChar(500)
|
||||||
|
photoAngle String? @map("photo_angle") @db.VarChar(16)
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||||
|
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
|
||||||
|
photos ProgressPhoto[]
|
||||||
|
trainingPlans TrainingPlan[]
|
||||||
|
|
||||||
|
@@index([userId, recordedAt])
|
||||||
|
@@map("professional_assessment_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrainingPlan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
sessionId String? @map("session_id")
|
||||||
|
title String @db.VarChar(80)
|
||||||
|
status String @default("ACTIVE") @db.VarChar(16)
|
||||||
|
weeks Int @default(12)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
|
||||||
|
phases TrainingPlanPhase[]
|
||||||
|
todos ReassessmentTodo[]
|
||||||
|
shares GrowthShareCard[]
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@map("training_plans")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrainingPlanPhase {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
planId String @map("plan_id")
|
||||||
|
name String @db.VarChar(40)
|
||||||
|
lessonStart Int @map("lesson_start")
|
||||||
|
lessonEnd Int @map("lesson_end")
|
||||||
|
focus Json
|
||||||
|
summary String @default("") @db.VarChar(300)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
|
||||||
|
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([planId])
|
||||||
|
@@map("training_plan_phases")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReassessmentTodo {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
planId String @map("plan_id")
|
||||||
|
lessonCheckpoint Int @map("lesson_checkpoint")
|
||||||
|
dueAt DateTime @map("due_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([planId, lessonCheckpoint])
|
||||||
|
@@index([userId, completedAt])
|
||||||
|
@@map("reassessment_todos")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GrowthShareCard {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
planId String? @map("plan_id")
|
||||||
|
shareCode String @unique @map("share_code") @db.VarChar(12)
|
||||||
|
title String @db.VarChar(80)
|
||||||
|
caption String @db.VarChar(200)
|
||||||
|
completedCount Int @map("completed_count")
|
||||||
|
weeks Int @default(12)
|
||||||
|
rows Json
|
||||||
|
includePhotos Boolean @default(false) @map("include_photos")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
plan TrainingPlan? @relation(fields: [planId], references: [id])
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@map("growth_share_cards")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,37 +1,18 @@
|
|||||||
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'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
|
||||||
|
|
||||||
interface AdminStats {
|
|
||||||
todayBookings: number
|
|
||||||
totalOrders: number
|
|
||||||
totalBookings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@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 analytics: TeachingAnalyticsService) {}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('teaching-analytics')
|
||||||
async getStats(): Promise<AdminStats> {
|
getTeachingAnalytics(@Query('month') month: string) {
|
||||||
const today = new Date()
|
return this.analytics.getMonthly(month)
|
||||||
today.setUTCHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
const [todayBookings, totalOrders, totalBookings] = await Promise.all([
|
|
||||||
this.prisma.booking.count({
|
|
||||||
where: {
|
|
||||||
timeSlot: { date: today },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.order.count(),
|
|
||||||
this.prisma.booking.count(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return { todayBookings, totalOrders, totalBookings }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import { BookingModule } from './booking/booking.module'
|
|||||||
import { SchedulerModule } from './scheduler/scheduler.module'
|
import { SchedulerModule } from './scheduler/scheduler.module'
|
||||||
import { PaymentModule } from './payment/payment.module'
|
import { PaymentModule } from './payment/payment.module'
|
||||||
import { AdminModule } from './admin/admin.module'
|
import { AdminModule } from './admin/admin.module'
|
||||||
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
|
||||||
import { InviteModule } from './invite/invite.module'
|
import { InviteModule } from './invite/invite.module'
|
||||||
|
import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,8 +30,8 @@ import { InviteModule } from './invite/invite.module'
|
|||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
FlashSaleModule,
|
|
||||||
InviteModule,
|
InviteModule,
|
||||||
|
BodyPortraitModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('AuthService', () => {
|
|||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||||
mockPrismaService.membership.count.mockResolvedValue(0)
|
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||||
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── login ──────────────────────────────────────────────────────────────────
|
// ── login ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module, forwardRef } from '@nestjs/common'
|
||||||
import { PassportModule } from '@nestjs/passport'
|
import { PassportModule } from '@nestjs/passport'
|
||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||||
@@ -10,6 +10,7 @@ import { JwtStrategy } from './jwt.strategy'
|
|||||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||||
import { RolesGuard } from './roles.guard'
|
import { RolesGuard } from './roles.guard'
|
||||||
import { InviteModule } from '../invite/invite.module'
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,6 +18,7 @@ import { InviteModule } from '../invite/invite.module'
|
|||||||
InviteModule,
|
InviteModule,
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
MembershipModule,
|
MembershipModule,
|
||||||
|
forwardRef(() => BodyPortraitModule),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
import { Inject, Injectable, Optional, UnauthorizedException, forwardRef } from '@nestjs/common'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { User } from '@prisma/client'
|
import { User } from '@prisma/client'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import { ConfigService } from '@nestjs/config'
|
|||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
import { InviteService } from '../invite/invite.service'
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
token: string
|
||||||
@@ -67,10 +68,14 @@ export class AuthService {
|
|||||||
private readonly inviteService: InviteService,
|
private readonly inviteService: InviteService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => BodyPortraitLifecycleService))
|
||||||
|
private readonly portraitLifecycle?: BodyPortraitLifecycleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||||
const templates = [
|
const templates = [
|
||||||
|
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
|
||||||
{
|
{
|
||||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
@@ -178,9 +183,11 @@ export class AuthService {
|
|||||||
iv,
|
iv,
|
||||||
)
|
)
|
||||||
|
|
||||||
return this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { phone: phoneInfo.phoneNumber },
|
data: { phone: phoneInfo.phoneNumber },
|
||||||
})
|
})
|
||||||
|
await this.portraitLifecycle?.onPhoneBound(userId)
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common'
|
||||||
|
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
|
||||||
|
import { BodyPortraitOfflineService } from '../body-portrait-offline.service'
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
|
||||||
|
describe('BodyPortraitOfflineService', () => {
|
||||||
|
const prisma: any = {
|
||||||
|
user: { findUnique: jest.fn() },
|
||||||
|
booking: { findFirst: jest.fn(), count: jest.fn() },
|
||||||
|
bodyPortraitAssessment: { findFirst: jest.fn() },
|
||||||
|
professionalAssessmentSession: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
|
||||||
|
reassessmentTodo: { findFirst: jest.fn(), update: jest.fn(), createMany: jest.fn(), findMany: jest.fn() },
|
||||||
|
trainingPlan: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
|
||||||
|
growthShareCard: { create: jest.fn(), findUnique: jest.fn() },
|
||||||
|
}
|
||||||
|
let service: BodyPortraitOfflineService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
||||||
|
service = new BodyPortraitOfflineService(prisma as unknown as PrismaService)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects assessment recorded in the future based on China timezone', async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 86400000 * 2).toISOString().slice(0, 10)
|
||||||
|
await expect(
|
||||||
|
service.createSession('u1', 'op1', {
|
||||||
|
kind: ProfessionalAssessmentKind.INITIAL,
|
||||||
|
recordedAt: futureDate,
|
||||||
|
observations: {
|
||||||
|
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||||
|
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||||
|
},
|
||||||
|
coachSummary: 'test',
|
||||||
|
trainingFocus: 'test',
|
||||||
|
phaseGoal: 'test',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes only the earliest pending reassessment todo on follow-up session', async () => {
|
||||||
|
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
|
||||||
|
prisma.professionalAssessmentSession.create.mockResolvedValue({
|
||||||
|
id: 'sess-2',
|
||||||
|
userId: 'u1',
|
||||||
|
kind: ProfessionalAssessmentKind.FOLLOW_UP,
|
||||||
|
protocolVersion: 'offline-v1',
|
||||||
|
recordedAt: new Date(`${today}T00:00:00.000Z`),
|
||||||
|
bookingId: null,
|
||||||
|
originAssessmentId: null,
|
||||||
|
observations: {
|
||||||
|
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||||
|
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||||
|
},
|
||||||
|
subjectiveTension: 4,
|
||||||
|
coachSummary: 'progress good',
|
||||||
|
trainingFocus: 'stability',
|
||||||
|
phaseGoal: 'next stage',
|
||||||
|
photoAngle: null,
|
||||||
|
})
|
||||||
|
prisma.reassessmentTodo.findFirst.mockResolvedValue({ id: 'todo-4', lessonCheckpoint: 4 })
|
||||||
|
prisma.reassessmentTodo.update.mockResolvedValue({ id: 'todo-4', completedAt: new Date() })
|
||||||
|
|
||||||
|
const result = await service.createSession('u1', 'op1', {
|
||||||
|
kind: ProfessionalAssessmentKind.FOLLOW_UP,
|
||||||
|
recordedAt: today,
|
||||||
|
observations: {
|
||||||
|
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||||
|
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||||
|
},
|
||||||
|
coachSummary: 'progress good',
|
||||||
|
trainingFocus: 'stability',
|
||||||
|
phaseGoal: 'next stage',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.id).toBe('sess-2')
|
||||||
|
expect(prisma.reassessmentTodo.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'u1', completedAt: null },
|
||||||
|
orderBy: { lessonCheckpoint: 'asc' },
|
||||||
|
})
|
||||||
|
expect(prisma.reassessmentTodo.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'todo-4' },
|
||||||
|
data: { completedAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates baseline share card for single session without false comparison', async () => {
|
||||||
|
const sessDate = new Date('2026-09-01T00:00:00.000Z')
|
||||||
|
prisma.professionalAssessmentSession.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'sess-1',
|
||||||
|
userId: 'u1',
|
||||||
|
kind: ProfessionalAssessmentKind.INITIAL,
|
||||||
|
protocolVersion: 'offline-v1',
|
||||||
|
recordedAt: sessDate,
|
||||||
|
bookingId: null,
|
||||||
|
originAssessmentId: null,
|
||||||
|
observations: {
|
||||||
|
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||||
|
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||||
|
},
|
||||||
|
subjectiveTension: 6,
|
||||||
|
coachSummary: 'initial summary',
|
||||||
|
trainingFocus: 'focus',
|
||||||
|
phaseGoal: 'goal',
|
||||||
|
photoAngle: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
prisma.booking.count.mockResolvedValue(1)
|
||||||
|
prisma.trainingPlan.findFirst.mockResolvedValue({ id: 'p1', weeks: 12 })
|
||||||
|
prisma.growthShareCard.create.mockImplementation(async ({ data }: { data: any }) => ({
|
||||||
|
id: 'card-1',
|
||||||
|
...data,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const card = await service.createShareCard('u1', false)
|
||||||
|
expect(card.title).toBe('我的第 1 节普拉提')
|
||||||
|
expect(card.caption).toContain('已建立初始身体基准状态')
|
||||||
|
expect(prisma.growthShareCard.create.mock.calls[0][0].data.caption).not.toContain('周前肩颈紧张')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common'
|
||||||
|
import { BodyPortraitAssessmentStatus, GrowthEventName, GrowthLeadStage, PortraitGoal, SafetyFlag, SittingHours } from '@mp-pilates/shared'
|
||||||
|
import { BodyPortraitService } from '../body-portrait.service'
|
||||||
|
import { BodyPortraitLifecycleService } from '../body-portrait-lifecycle.service'
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { hashToken } from '../body-portrait.token'
|
||||||
|
|
||||||
|
describe('BodyPortraitService', () => {
|
||||||
|
const prisma: any = {
|
||||||
|
bodyPortraitVisit: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
|
||||||
|
bodyPortraitAssessment: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findUniqueOrThrow: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
growthEvent: { create: jest.fn() },
|
||||||
|
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||||
|
user: { findFirst: jest.fn(), findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
}
|
||||||
|
const lifecycle = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
|
||||||
|
let service: BodyPortraitService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
prisma.growthEvent.create.mockResolvedValue({})
|
||||||
|
prisma.growthLead.findUnique.mockResolvedValue(null)
|
||||||
|
service = new BodyPortraitService(prisma as unknown as PrismaService, lifecycle)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates hashed visit tokens and does not store plaintext', async () => {
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'ref' })
|
||||||
|
prisma.bodyPortraitVisit.create.mockImplementation(async ({ data }: { data: { tokenHash: string } }) => ({ id: 'visit', ...data }))
|
||||||
|
const result = await service.createVisit('1.1.1.1', { source: 'xhs', referralCode: 'AB12CD' })
|
||||||
|
expect(result.visitToken).toHaveLength(64)
|
||||||
|
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).toBe(hashToken(result.visitToken))
|
||||||
|
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).not.toBe(result.visitToken)
|
||||||
|
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.referrerUserId).toBe('ref')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to claim another user assessment', async () => {
|
||||||
|
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
|
||||||
|
id: 'a1',
|
||||||
|
userId: 'other',
|
||||||
|
status: BodyPortraitAssessmentStatus.COMPLETED,
|
||||||
|
report: { scores: {} },
|
||||||
|
visitId: 'v1',
|
||||||
|
answers: { goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, safety: [SafetyFlag.NONE] },
|
||||||
|
})
|
||||||
|
await expect(service.claim('me', 'token-token-token-token-token-token-token')).rejects.toThrow(ForbiddenException)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes with server-side scores and ignores client-sent scores', async () => {
|
||||||
|
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
|
||||||
|
id: 'a1',
|
||||||
|
userId: null,
|
||||||
|
visitId: 'v1',
|
||||||
|
status: BodyPortraitAssessmentStatus.DRAFT,
|
||||||
|
expiresAt: new Date(Date.now() + 10000),
|
||||||
|
completedAt: null,
|
||||||
|
answers: {
|
||||||
|
concerns: ['neck'],
|
||||||
|
goal: 'neckRelief',
|
||||||
|
sittingHours: 'gt8',
|
||||||
|
exerciseFreq: 'none',
|
||||||
|
workPosture: 'computer',
|
||||||
|
endOfDayFatigue: ['neck'],
|
||||||
|
afterSitting: ['neckTight'],
|
||||||
|
standingNotice: ['headForward'],
|
||||||
|
safety: ['none'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
prisma.bodyPortraitAssessment.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||||
|
id: 'a1',
|
||||||
|
userId: null,
|
||||||
|
status: data.status,
|
||||||
|
answers: data.answers,
|
||||||
|
report: data.report,
|
||||||
|
}))
|
||||||
|
const session = await service.complete('token-token-token-token-token-token-token')
|
||||||
|
expect(session.teaser?.primaryType).toBeTruthy()
|
||||||
|
expect(session.report).toBeNull()
|
||||||
|
expect(prisma.bodyPortraitAssessment.update.mock.calls[0][0].data.report.scores.cervicalShoulder).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects saveAnswers on expired assessment', async () => {
|
||||||
|
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
|
||||||
|
id: 'a1',
|
||||||
|
userId: null,
|
||||||
|
visitId: 'v1',
|
||||||
|
status: BodyPortraitAssessmentStatus.EXPIRED,
|
||||||
|
expiresAt: new Date(Date.now() - 1000),
|
||||||
|
answers: {},
|
||||||
|
})
|
||||||
|
await expect(service.saveAnswers('expired-token', { goal: PortraitGoal.CORE })).rejects.toThrow('测评已过期')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BodyPortraitLifecycleService', () => {
|
||||||
|
const prisma: any = {
|
||||||
|
growthEvent: { create: jest.fn() },
|
||||||
|
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||||
|
bodyPortraitAssessment: { findUnique: jest.fn(), findFirst: jest.fn() },
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not move stages backwards', async () => {
|
||||||
|
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
|
||||||
|
prisma.growthLead.findUnique.mockResolvedValue({
|
||||||
|
userId: 'u1',
|
||||||
|
stage: GrowthLeadStage.PLAN_PURCHASED,
|
||||||
|
latestAssessmentId: 'a1',
|
||||||
|
})
|
||||||
|
await service.advanceLead('u1', GrowthLeadStage.CLAIMED, 'a1')
|
||||||
|
expect(prisma.growthLead.update.mock.calls[0][0].data.stage).toBe(GrowthLeadStage.PLAN_PURCHASED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses unique event keys for paid orders', async () => {
|
||||||
|
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
|
||||||
|
prisma.growthEvent.create.mockResolvedValue({})
|
||||||
|
prisma.bodyPortraitAssessment.findFirst.mockResolvedValue({ id: 'a1', visitId: 'v1' })
|
||||||
|
prisma.growthLead.findUnique.mockResolvedValue({ userId: 'u1', stage: GrowthLeadStage.CLAIMED, latestAssessmentId: 'a1' })
|
||||||
|
await service.onOrderPaid('u1', 'TRIAL', 'order-1')
|
||||||
|
expect(prisma.growthEvent.create.mock.calls[0][0].data.idempotencyKey).toBe('trial_purchased:order-1')
|
||||||
|
expect(prisma.growthEvent.create.mock.calls[0][0].data.name).toBe(GrowthEventName.TRIAL_PURCHASED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates customized follow-up drafts with nickname', () => {
|
||||||
|
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
|
||||||
|
const draft = service.followUpDraft({
|
||||||
|
nickname: '张同学',
|
||||||
|
report: null,
|
||||||
|
})
|
||||||
|
expect(draft).toContain('张同学你好,')
|
||||||
|
expect(draft).toContain('做一次实际的活动度和动作评估')
|
||||||
|
|
||||||
|
const trialDraft = service.followUpAfterTrialDraft({
|
||||||
|
nickname: '李同学',
|
||||||
|
report: null,
|
||||||
|
})
|
||||||
|
expect(trialDraft).toContain('李同学你好,前两天的普拉提体验课感觉怎么样?')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
packages/server/src/body-portrait/__tests__/score-v1.spec.ts
Normal file
85
packages/server/src/body-portrait/__tests__/score-v1.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
AfterSitting,
|
||||||
|
BodyRegion,
|
||||||
|
ExerciseFreq,
|
||||||
|
PortraitGoal,
|
||||||
|
PortraitType,
|
||||||
|
SafetyFlag,
|
||||||
|
SittingHours,
|
||||||
|
StandingNotice,
|
||||||
|
WorkPosture,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { isSafetyFlagged, scorePortrait } from '../scoring/score-v1'
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
concerns: [BodyRegion.SHOULDER],
|
||||||
|
goal: PortraitGoal.POSTURE,
|
||||||
|
sittingHours: SittingHours.H6_TO_8,
|
||||||
|
exerciseFreq: ExerciseFreq.W1_TO_2,
|
||||||
|
workPosture: WorkPosture.COMPUTER,
|
||||||
|
endOfDayFatigue: [BodyRegion.NECK],
|
||||||
|
afterSitting: [AfterSitting.NECK_TIGHT],
|
||||||
|
standingNotice: [StandingNotice.HEAD_FORWARD],
|
||||||
|
safety: [SafetyFlag.NONE],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scorePortrait v1', () => {
|
||||||
|
const fixtures: Array<{ name: string; answers: typeof base; type: PortraitType; safety?: boolean; minCervical?: number }> = [
|
||||||
|
{ name: '久坐肩颈紧张', answers: { ...base, sittingHours: SittingHours.GT8, standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_NECK_TENSION, minCervical: 70 },
|
||||||
|
{ name: '久坐核心弱化', answers: { ...base, concerns: [BodyRegion.LOW_BACK], goal: PortraitGoal.CORE, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.LOW_BACK] }, type: PortraitType.SEDENTARY_CORE_WEAK },
|
||||||
|
{ name: '肩颈代偿', answers: { ...base, sittingHours: SittingHours.LT4, workPosture: WorkPosture.WALKING, exerciseFreq: ExerciseFreq.W3_TO_4 }, type: PortraitType.NECK_COMPENSATION },
|
||||||
|
{ name: '髋部紧张', answers: { ...base, concerns: [BodyRegion.HIP, BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNNOTICED], endOfDayFatigue: [BodyRegion.HIP], workPosture: WorkPosture.WALKING }, type: PortraitType.HIP_TIGHT },
|
||||||
|
{ name: '下肢稳定不足', answers: { ...base, concerns: [BodyRegion.KNEE, BodyRegion.LEG], goal: PortraitGoal.STABILITY, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.KNEE], sittingHours: SittingHours.LT4, workPosture: WorkPosture.STANDING }, type: PortraitType.LOWER_LIMB_UNSTABLE },
|
||||||
|
{ name: '上下交叉', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER, BodyRegion.LOW_BACK, BodyRegion.HIP], goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, exerciseFreq: ExerciseFreq.NONE, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.NECK, BodyRegion.LOW_BACK] }, type: PortraitType.UPPER_LOWER_CROSS },
|
||||||
|
{ name: '整体关注较低', answers: { ...base, concerns: [], goal: PortraitGoal.OTHER, sittingHours: SittingHours.LT4, exerciseFreq: ExerciseFreq.W5_PLUS, workPosture: WorkPosture.WALKING, endOfDayFatigue: [], afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
|
||||||
|
{ name: '全身疲劳', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.LOW_BACK, BodyRegion.HIP, BodyRegion.KNEE], goal: PortraitGoal.OTHER, sittingHours: SittingHours.H4_TO_6, exerciseFreq: ExerciseFreq.NONE, workPosture: WorkPosture.HOLDING_CHILD, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNEVEN_SHOULDERS], endOfDayFatigue: [BodyRegion.SHOULDER, BodyRegion.LEG] }, type: PortraitType.NECK_COMPENSATION },
|
||||||
|
{ name: '低头手机', answers: { ...base, workPosture: WorkPosture.PHONE, sittingHours: SittingHours.GT8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
|
||||||
|
{ name: '抱娃', answers: { ...base, concerns: [BodyRegion.LOW_BACK, BodyRegion.SHOULDER], workPosture: WorkPosture.HOLDING_CHILD, goal: PortraitGoal.BACK_COMFORT, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
|
||||||
|
{ name: '塑形目标', answers: { ...base, goal: PortraitGoal.SHAPING, concerns: [BodyRegion.LOW_BACK], afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
|
||||||
|
{ name: '腰背久坐舒服', answers: { ...base, goal: PortraitGoal.BACK_COMFORT, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_CORE_WEAK },
|
||||||
|
{ name: '运动很少的肩颈', answers: { ...base, exerciseFreq: ExerciseFreq.NONE, sittingHours: SittingHours.H6_TO_8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
|
||||||
|
{ name: '膝腿久站', answers: { ...base, concerns: [BodyRegion.KNEE], goal: PortraitGoal.STABILITY, workPosture: WorkPosture.STANDING, sittingHours: SittingHours.LT4, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.LEG] }, type: PortraitType.LOWER_LIMB_UNSTABLE },
|
||||||
|
{ name: '骨盆困扰', answers: { ...base, concerns: [BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.PELVIS], workPosture: WorkPosture.WALKING, sittingHours: SittingHours.H4_TO_6 }, type: PortraitType.HIP_TIGHT },
|
||||||
|
{ name: '上背僵硬', answers: { ...base, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.NECK_COMPENSATION },
|
||||||
|
{ name: '肩颈目标明确', answers: { ...base, goal: PortraitGoal.NECK_RELIEF, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER] }, type: PortraitType.SEDENTARY_NECK_TENSION },
|
||||||
|
{ name: '安全分流不计分仍出类型', answers: { ...base, safety: [SafetyFlag.PERSISTENT_PAIN] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
|
||||||
|
{ name: '医生限制运动', answers: { ...base, safety: [SafetyFlag.DOCTOR_LIMIT, SafetyFlag.RECENT_INJURY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
|
||||||
|
{ name: '麻木分流', answers: { ...base, safety: [SafetyFlag.NUMBNESS] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
|
||||||
|
{ name: '医疗恢复期', answers: { ...base, safety: [SafetyFlag.MEDICAL_RECOVERY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
|
||||||
|
{ name: '高频运动降低关注', answers: { ...base, exerciseFreq: ExerciseFreq.W5_PLUS, sittingHours: SittingHours.H4_TO_6, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(fixtures)('$name', ({ answers, type, safety, minCervical }) => {
|
||||||
|
const report = scorePortrait(answers)
|
||||||
|
expect(report.primaryType).toBe(type)
|
||||||
|
expect(report.safety.flagged).toBe(!!safety)
|
||||||
|
expect(isSafetyFlagged(answers)).toBe(!!safety)
|
||||||
|
expect(report.advice === null).toBe(!!safety)
|
||||||
|
if (minCervical) expect(report.scores.cervicalShoulder).toBeGreaterThanOrEqual(minCervical)
|
||||||
|
expect(report.scores.cervicalShoulder).toBeLessThanOrEqual(100)
|
||||||
|
expect(report.headline.length).toBeGreaterThan(0)
|
||||||
|
expect(report.summary.includes('诊断')).toBe(false)
|
||||||
|
expect(report.summary.includes('准确率')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps scores and ignores unknown answers', () => {
|
||||||
|
const report = scorePortrait({
|
||||||
|
...base,
|
||||||
|
concerns: ['alien' as never, BodyRegion.NECK, BodyRegion.NECK],
|
||||||
|
sittingHours: SittingHours.GT8,
|
||||||
|
exerciseFreq: ExerciseFreq.NONE,
|
||||||
|
afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT],
|
||||||
|
standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD, StandingNotice.LOCKED_KNEES],
|
||||||
|
})
|
||||||
|
for (const value of Object.values(report.scores)) {
|
||||||
|
expect(value).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(value).toBeLessThanOrEqual(100)
|
||||||
|
}
|
||||||
|
expect(report.evidence.length).toBeLessThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat none safety as flagged', () => {
|
||||||
|
expect(scorePortrait(base).safety.flagged).toBe(false)
|
||||||
|
expect(scorePortrait({ ...base, safety: [] }).safety.flagged).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'
|
||||||
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
|
import { Roles } from '../auth/roles.decorator'
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||||
|
import { BodyPortraitAdminService } from './body-portrait-admin.service'
|
||||||
|
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
|
||||||
|
import { CreateProfessionalAssessmentDto, CreateTrainingPlanDto, UpdateLeadNoteDto } from './dto/body-portrait.dto'
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
export class BodyPortraitAdminController {
|
||||||
|
constructor(
|
||||||
|
private readonly admin: BodyPortraitAdminService,
|
||||||
|
private readonly offline: BodyPortraitOfflineService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('growth/today')
|
||||||
|
today() {
|
||||||
|
return this.admin.dashboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('growth/leads')
|
||||||
|
leads(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('stage') stage?: string,
|
||||||
|
@Query('source') source?: string,
|
||||||
|
) {
|
||||||
|
return this.admin.listLeads({
|
||||||
|
page: page ? Number(page) : 1,
|
||||||
|
limit: limit ? Number(limit) : 20,
|
||||||
|
search,
|
||||||
|
stage,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('growth/leads/:id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.admin.leadDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('growth/leads/:id/note')
|
||||||
|
note(@Param('id') id: string, @Body() dto: UpdateLeadNoteDto) {
|
||||||
|
return this.admin.updateNote(id, dto.coachNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('members/:userId/professional-assessments')
|
||||||
|
sessions(@Param('userId') userId: string) {
|
||||||
|
return this.offline.listSessions(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('members/:userId/professional-assessments')
|
||||||
|
createSession(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@CurrentUser('sub') operatorId: string,
|
||||||
|
@Body() dto: CreateProfessionalAssessmentDto,
|
||||||
|
) {
|
||||||
|
return this.offline.createSession(userId, operatorId, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('members/:userId/training-plans')
|
||||||
|
plans(@Param('userId') userId: string) {
|
||||||
|
return this.offline.listPlans(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('members/:userId/training-plans')
|
||||||
|
createPlan(@Param('userId') userId: string, @Body() dto: CreateTrainingPlanDto) {
|
||||||
|
return this.offline.createPlan(userId, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('members/:userId/reassessment-todos')
|
||||||
|
todos(@Param('userId') userId: string) {
|
||||||
|
return this.offline.todos(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
271
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal file
271
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||||
|
import {
|
||||||
|
BODY_DIMENSION_LABELS,
|
||||||
|
BodyDimension,
|
||||||
|
BodyPortraitAnswers,
|
||||||
|
BodyPortraitReport,
|
||||||
|
BodyPortraitSource,
|
||||||
|
GrowthEventName,
|
||||||
|
GrowthFunnelRow,
|
||||||
|
GrowthLeadDetail,
|
||||||
|
GrowthLeadStage,
|
||||||
|
GrowthLeadSummary,
|
||||||
|
GrowthTodayDashboard,
|
||||||
|
GrowthTodayTask,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
|
||||||
|
import { normalizeAnswers } from './scoring/score-v1'
|
||||||
|
|
||||||
|
const TERMINAL = new Set<string>([
|
||||||
|
GrowthLeadStage.TRIAL_BOOKED,
|
||||||
|
GrowthLeadStage.TRIAL_ATTENDED,
|
||||||
|
GrowthLeadStage.PLAN_PURCHASED,
|
||||||
|
GrowthLeadStage.TRAINING,
|
||||||
|
GrowthLeadStage.RENEWAL_DUE,
|
||||||
|
GrowthLeadStage.RENEWED,
|
||||||
|
])
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BodyPortraitAdminService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly lifecycle: BodyPortraitLifecycleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async dashboard(): Promise<GrowthTodayDashboard> {
|
||||||
|
const [leads, todos, funnel] = await Promise.all([
|
||||||
|
this.prisma.growthLead.findMany({
|
||||||
|
include: {
|
||||||
|
user: { select: { nickname: true, phone: true } },
|
||||||
|
latestAssessment: { include: { visit: true } },
|
||||||
|
},
|
||||||
|
orderBy: { lastActiveAt: 'desc' },
|
||||||
|
take: 80,
|
||||||
|
}),
|
||||||
|
this.prisma.reassessmentTodo.findMany({
|
||||||
|
where: { completedAt: null, dueAt: { lte: new Date() } },
|
||||||
|
include: { user: { select: { nickname: true } } },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
this.funnel(),
|
||||||
|
])
|
||||||
|
const tasks: GrowthTodayTask[] = []
|
||||||
|
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000
|
||||||
|
for (const lead of leads) {
|
||||||
|
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||||
|
const answers = normalizeAnswers(lead.latestAssessment?.answers as Partial<BodyPortraitAnswers>)
|
||||||
|
const draft = this.lifecycle.followUpDraft({
|
||||||
|
nickname: lead.user.nickname,
|
||||||
|
report,
|
||||||
|
sittingHours: answers.sittingHours,
|
||||||
|
workPosture: answers.workPosture,
|
||||||
|
})
|
||||||
|
if (!TERMINAL.has(lead.stage) && lead.latestAssessment?.completedAt && ['CLAIMED', 'PHONE_BOUND', 'COMPLETED'].includes(lead.stage)) {
|
||||||
|
const top = report ? this.topDimension(report) : null
|
||||||
|
tasks.push({
|
||||||
|
kind: 'completed_unbooked',
|
||||||
|
leadId: lead.id,
|
||||||
|
userId: lead.userId,
|
||||||
|
nickname: lead.user.nickname || '未命名',
|
||||||
|
title: `${lead.user.nickname || '学员'}完成身体测试但未预约`,
|
||||||
|
detail: top ? `${BODY_DIMENSION_LABELS[top[0]]}关注度 ${top[1]}` : '已完成线上画像',
|
||||||
|
happenedAt: lead.lastActiveAt.toISOString(),
|
||||||
|
followUpDraft: draft,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (lead.stage === GrowthLeadStage.TRIAL_ATTENDED && lead.lastActiveAt.getTime() <= twoDaysAgo) {
|
||||||
|
tasks.push({
|
||||||
|
kind: 'trial_unpurchased',
|
||||||
|
leadId: lead.id,
|
||||||
|
userId: lead.userId,
|
||||||
|
nickname: lead.user.nickname || '未命名',
|
||||||
|
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
|
||||||
|
detail: '建议回访,确认是否需要 12 周改善计划',
|
||||||
|
happenedAt: lead.lastActiveAt.toISOString(),
|
||||||
|
followUpDraft: this.lifecycle.followUpAfterTrialDraft({
|
||||||
|
nickname: lead.user.nickname,
|
||||||
|
report,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const todo of todos) {
|
||||||
|
tasks.push({
|
||||||
|
kind: 'reassessment_due',
|
||||||
|
leadId: todo.planId,
|
||||||
|
userId: todo.userId,
|
||||||
|
nickname: todo.user.nickname || '未命名',
|
||||||
|
title: `${todo.user.nickname || '学员'}到达第 ${todo.lessonCheckpoint} 节复测节点`,
|
||||||
|
detail: '建议安排阶段复测',
|
||||||
|
happenedAt: todo.dueAt.toISOString(),
|
||||||
|
followUpDraft: `训练已经走到第 ${todo.lessonCheckpoint} 节附近了,可以安排一次阶段复测,看看身体状态有没有变化。`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { tasks: tasks.slice(0, 12), funnel }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLeads(query: { page?: number; limit?: number; search?: string; stage?: string; source?: string }) {
|
||||||
|
const page = Math.max(1, Number(query.page) || 1)
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(query.limit) || 20))
|
||||||
|
const where: Record<string, unknown> = {}
|
||||||
|
if (query.stage) where.stage = query.stage
|
||||||
|
if (query.source) where.source = query.source
|
||||||
|
if (query.search) {
|
||||||
|
where.user = {
|
||||||
|
OR: [
|
||||||
|
{ nickname: { contains: query.search } },
|
||||||
|
{ phone: { contains: query.search } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.growthLead.findMany({
|
||||||
|
where,
|
||||||
|
include: { user: { select: { nickname: true, phone: true } }, latestAssessment: true },
|
||||||
|
orderBy: { lastActiveAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.growthLead.count({ where }),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
items: items.map((lead) => this.toSummary(lead)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async leadDetail(id: string): Promise<GrowthLeadDetail> {
|
||||||
|
const lead = await this.prisma.growthLead.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { nickname: true, phone: true } },
|
||||||
|
latestAssessment: { include: { visit: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!lead) throw new NotFoundException('线索不存在')
|
||||||
|
const events = await this.prisma.growthEvent.findMany({
|
||||||
|
where: { userId: lead.userId },
|
||||||
|
orderBy: { occurredAt: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||||
|
const answers = lead.latestAssessment
|
||||||
|
? normalizeAnswers(lead.latestAssessment.answers as Partial<BodyPortraitAnswers>)
|
||||||
|
: null
|
||||||
|
const summary = this.toSummary(lead)
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
goalLabel: report?.goalLabel || null,
|
||||||
|
scores: report?.scores || null,
|
||||||
|
levels: report?.levels || null,
|
||||||
|
evidence: report?.evidence || [],
|
||||||
|
answers,
|
||||||
|
report,
|
||||||
|
events: events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name as GrowthEventName,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
})),
|
||||||
|
coachNote: lead.coachNote,
|
||||||
|
firstAssessmentHints: report?.firstAssessmentHints || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id: string, coachNote: string) {
|
||||||
|
const lead = await this.prisma.growthLead.findUnique({ where: { id } })
|
||||||
|
if (!lead) throw new NotFoundException('线索不存在')
|
||||||
|
await this.prisma.growthLead.update({ where: { id }, data: { coachNote: coachNote.slice(0, 500) } })
|
||||||
|
return this.leadDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async funnel(): Promise<GrowthFunnelRow[]> {
|
||||||
|
const visits = await this.prisma.bodyPortraitVisit.findMany({
|
||||||
|
select: { id: true, source: true },
|
||||||
|
})
|
||||||
|
const byVisit = new Map(visits.map((visit) => [visit.id, visit.source as BodyPortraitSource]))
|
||||||
|
const events = await this.prisma.growthEvent.findMany({
|
||||||
|
select: { name: true, visitId: true, userId: true },
|
||||||
|
})
|
||||||
|
const rows = new Map<BodyPortraitSource, { source: BodyPortraitSource; started: number; completed: number; claimed: number; booked: number; attended: number; purchased: number }>()
|
||||||
|
const ensure = (source: BodyPortraitSource) => {
|
||||||
|
const current = rows.get(source) || {
|
||||||
|
source, started: 0, completed: 0, claimed: 0, booked: 0, attended: 0, purchased: 0,
|
||||||
|
}
|
||||||
|
rows.set(source, current)
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
for (const source of Object.values(BodyPortraitSource)) ensure(source)
|
||||||
|
for (const visit of visits) ensure(visit.source as BodyPortraitSource).started += 0
|
||||||
|
const counted = new Set<string>()
|
||||||
|
for (const event of events) {
|
||||||
|
const source = (event.visitId && byVisit.get(event.visitId)) || BodyPortraitSource.ORGANIC
|
||||||
|
const row = ensure(source)
|
||||||
|
const key = `${source}:${event.name}:${event.userId || event.visitId}`
|
||||||
|
if (counted.has(key)) continue
|
||||||
|
counted.add(key)
|
||||||
|
if (event.name === GrowthEventName.ASSESSMENT_STARTED) row.started += 1
|
||||||
|
if (event.name === GrowthEventName.ASSESSMENT_COMPLETED) row.completed += 1
|
||||||
|
if (event.name === GrowthEventName.ASSESSMENT_CLAIMED) row.claimed += 1
|
||||||
|
if (event.name === GrowthEventName.TRIAL_BOOKED) row.booked += 1
|
||||||
|
if (event.name === GrowthEventName.TRIAL_ATTENDED) row.attended += 1
|
||||||
|
if (event.name === GrowthEventName.PLAN_PURCHASED) row.purchased += 1
|
||||||
|
}
|
||||||
|
return [...rows.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(lead: {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
stage: string
|
||||||
|
source: string | null
|
||||||
|
campaignId: string | null
|
||||||
|
lastActiveAt: Date
|
||||||
|
user: { nickname: string; phone: string | null }
|
||||||
|
latestAssessment: { completedAt: Date | null; safetyFlagged: boolean; report: unknown } | null
|
||||||
|
}): GrowthLeadSummary {
|
||||||
|
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||||
|
const top = report ? this.topDimension(report) : null
|
||||||
|
const answers = lead.latestAssessment
|
||||||
|
? normalizeAnswers((lead.latestAssessment as { answers?: unknown }).answers as Partial<BodyPortraitAnswers>)
|
||||||
|
: EMPTY_SAFE
|
||||||
|
return {
|
||||||
|
id: lead.id,
|
||||||
|
userId: lead.userId,
|
||||||
|
nickname: lead.user.nickname || '未命名',
|
||||||
|
phone: lead.user.phone,
|
||||||
|
stage: lead.stage as GrowthLeadStage,
|
||||||
|
source: (lead.source as BodyPortraitSource | null) || null,
|
||||||
|
campaignId: lead.campaignId,
|
||||||
|
primaryType: report?.primaryType || null,
|
||||||
|
topDimension: top?.[0] || null,
|
||||||
|
safetyFlagged: lead.latestAssessment?.safetyFlagged || false,
|
||||||
|
lastActiveAt: lead.lastActiveAt.toISOString(),
|
||||||
|
completedAt: lead.latestAssessment?.completedAt?.toISOString() || null,
|
||||||
|
followUpDraft: this.lifecycle.followUpDraft({
|
||||||
|
nickname: lead.user.nickname,
|
||||||
|
report,
|
||||||
|
sittingHours: answers.sittingHours,
|
||||||
|
workPosture: answers.workPosture,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private topDimension(report: BodyPortraitReport): [BodyDimension, number] {
|
||||||
|
return (Object.entries(report.scores) as [BodyDimension, number][]).sort((a, b) => b[1] - a[1])[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_SAFE: BodyPortraitAnswers = {
|
||||||
|
concerns: [],
|
||||||
|
goal: null,
|
||||||
|
sittingHours: null,
|
||||||
|
exerciseFreq: null,
|
||||||
|
workPosture: null,
|
||||||
|
endOfDayFatigue: [],
|
||||||
|
afterSitting: [],
|
||||||
|
standingNotice: [],
|
||||||
|
safety: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import {
|
||||||
|
BODY_DIMENSION_LABELS,
|
||||||
|
BodyDimension,
|
||||||
|
BodyPortraitReport,
|
||||||
|
BodyPortraitSource,
|
||||||
|
CardTypeCategory,
|
||||||
|
GrowthEventName,
|
||||||
|
GrowthLeadStage,
|
||||||
|
PORTRAIT_TYPE_LABELS,
|
||||||
|
SITTING_HOURS_LABELS,
|
||||||
|
SittingHours,
|
||||||
|
WORK_POSTURE_LABELS,
|
||||||
|
WorkPosture,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
const STAGE_RANK: Record<GrowthLeadStage, number> = {
|
||||||
|
[GrowthLeadStage.VISIT]: 0,
|
||||||
|
[GrowthLeadStage.STARTED]: 1,
|
||||||
|
[GrowthLeadStage.COMPLETED]: 2,
|
||||||
|
[GrowthLeadStage.CLAIMED]: 3,
|
||||||
|
[GrowthLeadStage.PHONE_BOUND]: 4,
|
||||||
|
[GrowthLeadStage.TRIAL_PURCHASED]: 5,
|
||||||
|
[GrowthLeadStage.TRIAL_BOOKED]: 6,
|
||||||
|
[GrowthLeadStage.TRIAL_ATTENDED]: 7,
|
||||||
|
[GrowthLeadStage.PLAN_PURCHASED]: 8,
|
||||||
|
[GrowthLeadStage.TRAINING]: 9,
|
||||||
|
[GrowthLeadStage.RENEWAL_DUE]: 10,
|
||||||
|
[GrowthLeadStage.RENEWED]: 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_EVENT_PROPS = new Set(['source', 'campaignId', 'cardType', 'bookingId', 'orderId', 'checkpoint'])
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BodyPortraitLifecycleService {
|
||||||
|
private readonly logger = new Logger(BodyPortraitLifecycleService.name)
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async recordEvent(input: {
|
||||||
|
name: GrowthEventName
|
||||||
|
idempotencyKey: string
|
||||||
|
visitId?: string | null
|
||||||
|
assessmentId?: string | null
|
||||||
|
userId?: string | null
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
stage?: GrowthLeadStage
|
||||||
|
}) {
|
||||||
|
const properties = this.sanitize(input.properties)
|
||||||
|
try {
|
||||||
|
await this.prisma.growthEvent.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
idempotencyKey: input.idempotencyKey.slice(0, 120),
|
||||||
|
visitId: input.visitId || undefined,
|
||||||
|
assessmentId: input.assessmentId || undefined,
|
||||||
|
userId: input.userId || undefined,
|
||||||
|
properties: properties as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUnique(error)) return
|
||||||
|
this.logger.warn(`growth event failed: ${input.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (input.userId && input.stage) {
|
||||||
|
await this.advanceLead(input.userId, input.stage, input.assessmentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceLead(userId: string, stage: GrowthLeadStage, assessmentId?: string | null) {
|
||||||
|
const existing = await this.prisma.growthLead.findUnique({ where: { userId } })
|
||||||
|
if (!existing) {
|
||||||
|
if (STAGE_RANK[stage] < STAGE_RANK[GrowthLeadStage.CLAIMED]) return
|
||||||
|
const assessment = assessmentId
|
||||||
|
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId }, include: { visit: true } })
|
||||||
|
: await this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, include: { visit: true } })
|
||||||
|
await this.prisma.growthLead.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
latestAssessmentId: assessment?.id,
|
||||||
|
stage,
|
||||||
|
source: assessment?.visit.source,
|
||||||
|
campaignId: assessment?.visit.campaignId,
|
||||||
|
lastActiveAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextStage = STAGE_RANK[stage] > STAGE_RANK[existing.stage as GrowthLeadStage] ? stage : existing.stage
|
||||||
|
await this.prisma.growthLead.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
stage: nextStage,
|
||||||
|
lastActiveAt: new Date(),
|
||||||
|
latestAssessmentId: assessmentId || existing.latestAssessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPhoneBound(userId: string) {
|
||||||
|
await this.recordEvent({
|
||||||
|
name: GrowthEventName.PHONE_BOUND,
|
||||||
|
idempotencyKey: `phone_bound:${userId}`,
|
||||||
|
userId,
|
||||||
|
stage: GrowthLeadStage.PHONE_BOUND,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOrderPaid(userId: string, cardType: string, orderId: string) {
|
||||||
|
const assessment = await this.latestAssessment(userId)
|
||||||
|
const trial = cardType === CardTypeCategory.TRIAL
|
||||||
|
await this.recordEvent({
|
||||||
|
name: trial ? GrowthEventName.TRIAL_PURCHASED : GrowthEventName.PLAN_PURCHASED,
|
||||||
|
idempotencyKey: `${trial ? 'trial_purchased' : 'plan_purchased'}:${orderId}`,
|
||||||
|
userId,
|
||||||
|
assessmentId: assessment?.id,
|
||||||
|
visitId: assessment?.visitId,
|
||||||
|
properties: { cardType, orderId },
|
||||||
|
stage: trial ? GrowthLeadStage.TRIAL_PURCHASED : GrowthLeadStage.PLAN_PURCHASED,
|
||||||
|
})
|
||||||
|
if (!trial) {
|
||||||
|
await this.advanceLead(userId, GrowthLeadStage.TRAINING, assessment?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onBookingCreated(userId: string, bookingId: string, cardType: string, originAssessmentId?: string | null) {
|
||||||
|
if (cardType !== CardTypeCategory.TRIAL) return
|
||||||
|
const assessmentId = originAssessmentId || (await this.latestAssessment(userId))?.id
|
||||||
|
const assessment = assessmentId
|
||||||
|
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId } })
|
||||||
|
: null
|
||||||
|
await this.recordEvent({
|
||||||
|
name: GrowthEventName.TRIAL_BOOKED,
|
||||||
|
idempotencyKey: `trial_booked:${bookingId}`,
|
||||||
|
userId,
|
||||||
|
assessmentId: assessment?.id,
|
||||||
|
visitId: assessment?.visitId,
|
||||||
|
properties: { bookingId },
|
||||||
|
stage: GrowthLeadStage.TRIAL_BOOKED,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async onBookingCompleted(userId: string, bookingId: string, cardType: string) {
|
||||||
|
if (cardType !== CardTypeCategory.TRIAL) return
|
||||||
|
const assessment = await this.latestAssessment(userId)
|
||||||
|
await this.recordEvent({
|
||||||
|
name: GrowthEventName.TRIAL_ATTENDED,
|
||||||
|
idempotencyKey: `trial_attended:${bookingId}`,
|
||||||
|
userId,
|
||||||
|
assessmentId: assessment?.id,
|
||||||
|
visitId: assessment?.visitId,
|
||||||
|
properties: { bookingId },
|
||||||
|
stage: GrowthLeadStage.TRIAL_ATTENDED,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
followUpDraft(input: {
|
||||||
|
nickname: string
|
||||||
|
report: BodyPortraitReport | null
|
||||||
|
sittingHours?: SittingHours | null
|
||||||
|
workPosture?: WorkPosture | null
|
||||||
|
}): string {
|
||||||
|
const greeting = input.nickname ? `${input.nickname}你好,` : ''
|
||||||
|
if (!input.report) {
|
||||||
|
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
||||||
|
}
|
||||||
|
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
|
||||||
|
.sort((a, b) => b[1] - a[1])[0]
|
||||||
|
const lifestyle = [
|
||||||
|
input.workPosture ? WORK_POSTURE_LABELS[input.workPosture] : '',
|
||||||
|
input.sittingHours ? SITTING_HOURS_LABELS[input.sittingHours] : '',
|
||||||
|
].filter(Boolean).join('、')
|
||||||
|
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
|
||||||
|
const dimension = BODY_DIMENSION_LABELS[top[0]]
|
||||||
|
return `${greeting}看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
||||||
|
}
|
||||||
|
|
||||||
|
followUpAfterTrialDraft(input: {
|
||||||
|
nickname: string
|
||||||
|
report: BodyPortraitReport | null
|
||||||
|
}): string {
|
||||||
|
const greeting = input.nickname ? `${input.nickname}你好,` : ''
|
||||||
|
const type = input.report ? `结合你之前的「${PORTRAIT_TYPE_LABELS[input.report.primaryType]}」画像和` : ''
|
||||||
|
return `${greeting}前两天的普拉提体验课感觉怎么样?${type}现场教练观察的情况,我们为你准备了针对性的阶段改善建议。如果有时间可以聊聊你的感受,看看是否需要为你规划后续的训练。`
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSource(raw?: string | null): BodyPortraitSource {
|
||||||
|
const value = String(raw || '').toLowerCase()
|
||||||
|
return (Object.values(BodyPortraitSource) as string[]).includes(value)
|
||||||
|
? value as BodyPortraitSource
|
||||||
|
: BodyPortraitSource.ORGANIC
|
||||||
|
}
|
||||||
|
|
||||||
|
private async latestAssessment(userId: string) {
|
||||||
|
return this.prisma.bodyPortraitAssessment.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitize(properties?: Record<string, unknown>) {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
if (!properties) return result
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
if (!SAFE_EVENT_PROPS.has(key)) continue
|
||||||
|
if (value == null) continue
|
||||||
|
result[key] = String(value).slice(0, 80)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUnique(error: unknown) {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
|
import {
|
||||||
|
BODY_PORTRAIT_OFFLINE_PROTOCOL,
|
||||||
|
CreateProfessionalAssessmentDto,
|
||||||
|
CreateTrainingPlanDto,
|
||||||
|
GrowthShareCardRecord,
|
||||||
|
ProfessionalAssessmentKind,
|
||||||
|
ProfessionalAssessmentSessionRecord,
|
||||||
|
ProfessionalObservationScores,
|
||||||
|
ReassessmentTodoRecord,
|
||||||
|
TrainingPlanRecord,
|
||||||
|
TrainingPlanStatus,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
import { createShareCode } from './body-portrait.token'
|
||||||
|
|
||||||
|
const DEFAULT_PHASES = [
|
||||||
|
{ name: 'Phase 1 · 重新建立控制', lessonStart: 1, lessonEnd: 4, focus: ['呼吸', '骨盆控制', '核心激活'], summary: '先找回呼吸、骨盆和核心的基本控制。', sortOrder: 0 },
|
||||||
|
{ name: 'Phase 2 · 改善活动能力', lessonStart: 5, lessonEnd: 8, focus: ['胸椎', '肩带', '髋'], summary: '在控制基础上改善上背、肩和髋的活动。', sortOrder: 1 },
|
||||||
|
{ name: 'Phase 3 · 整合身体动作', lessonStart: 9, lessonEnd: 12, focus: ['全身控制', '稳定', '日常动作迁移'], summary: '把训练能力带回站、走和日常动作。', sortOrder: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BodyPortraitOfflineService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createSession(userId: string, operatorId: string, dto: CreateProfessionalAssessmentDto): Promise<ProfessionalAssessmentSessionRecord> {
|
||||||
|
await this.ensureUser(userId)
|
||||||
|
if (dto.bookingId) {
|
||||||
|
const booking = await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId } })
|
||||||
|
if (!booking) throw new BadRequestException('只能关联该学员的预约')
|
||||||
|
}
|
||||||
|
if (dto.originAssessmentId) {
|
||||||
|
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({ where: { id: dto.originAssessmentId, userId } })
|
||||||
|
if (!assessment) throw new BadRequestException('线上画像不属于该学员')
|
||||||
|
}
|
||||||
|
const recordedAt = this.date(dto.recordedAt)
|
||||||
|
const created = await this.prisma.professionalAssessmentSession.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
kind: dto.kind,
|
||||||
|
protocolVersion: BODY_PORTRAIT_OFFLINE_PROTOCOL,
|
||||||
|
recordedAt,
|
||||||
|
bookingId: dto.bookingId,
|
||||||
|
originAssessmentId: dto.originAssessmentId,
|
||||||
|
observations: dto.observations as unknown as Prisma.InputJsonValue,
|
||||||
|
subjectiveTension: dto.subjectiveTension ?? null,
|
||||||
|
coachSummary: dto.coachSummary.trim().slice(0, 1000),
|
||||||
|
trainingFocus: dto.trainingFocus.trim().slice(0, 500),
|
||||||
|
phaseGoal: dto.phaseGoal.trim().slice(0, 500),
|
||||||
|
photoAngle: dto.photoAngle || null,
|
||||||
|
operatorId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
|
||||||
|
const nextTodo = await this.prisma.reassessmentTodo.findFirst({
|
||||||
|
where: { userId, completedAt: null },
|
||||||
|
orderBy: { lessonCheckpoint: 'asc' },
|
||||||
|
})
|
||||||
|
if (nextTodo) {
|
||||||
|
await this.prisma.reassessmentTodo.update({
|
||||||
|
where: { id: nextTodo.id },
|
||||||
|
data: { completedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.mapSession(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(userId: string): Promise<ProfessionalAssessmentSessionRecord[]> {
|
||||||
|
const rows = await this.prisma.professionalAssessmentSession.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { recordedAt: 'asc' },
|
||||||
|
})
|
||||||
|
return rows.map((row) => this.mapSession(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlan(userId: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanRecord> {
|
||||||
|
await this.ensureUser(userId)
|
||||||
|
if (dto.sessionId) {
|
||||||
|
const session = await this.prisma.professionalAssessmentSession.findFirst({ where: { id: dto.sessionId, userId } })
|
||||||
|
if (!session) throw new BadRequestException('评估记录不属于该学员')
|
||||||
|
}
|
||||||
|
const plan = await this.prisma.trainingPlan.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
sessionId: dto.sessionId,
|
||||||
|
title: dto.title?.trim() || '你的 12 周身体改善计划',
|
||||||
|
status: TrainingPlanStatus.ACTIVE,
|
||||||
|
weeks: dto.weeks || 12,
|
||||||
|
phases: {
|
||||||
|
create: DEFAULT_PHASES.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
focus: phase.focus as unknown as Prisma.InputJsonValue,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { phases: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
await this.prisma.reassessmentTodo.createMany({
|
||||||
|
data: [4, 8, 12].map((checkpoint) => ({
|
||||||
|
userId,
|
||||||
|
planId: plan.id,
|
||||||
|
lessonCheckpoint: checkpoint,
|
||||||
|
dueAt: new Date(now.getTime() + checkpoint * 7 * 24 * 60 * 60 * 1000),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return this.mapPlan(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPlans(userId: string): Promise<TrainingPlanRecord[]> {
|
||||||
|
const plans = await this.prisma.trainingPlan.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { phases: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return plans.map((plan) => this.mapPlan(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
async todos(userId: string): Promise<ReassessmentTodoRecord[]> {
|
||||||
|
const rows = await this.prisma.reassessmentTodo.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { lessonCheckpoint: 'asc' },
|
||||||
|
})
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
planId: row.planId,
|
||||||
|
lessonCheckpoint: row.lessonCheckpoint,
|
||||||
|
dueAt: row.dueAt.toISOString(),
|
||||||
|
completedAt: row.completedAt?.toISOString() || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createShareCard(userId: string, includePhotos: boolean): Promise<GrowthShareCardRecord> {
|
||||||
|
const sessions = await this.listSessions(userId)
|
||||||
|
const first = sessions[0]
|
||||||
|
const latest = sessions[sessions.length - 1]
|
||||||
|
const hasComparison = sessions.length >= 2
|
||||||
|
const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
|
||||||
|
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } })
|
||||||
|
const rows = hasComparison ? this.compare(first, latest) : this.singleSnapshot(first)
|
||||||
|
let caption = '坚持有时候真的看得见。'
|
||||||
|
if (hasComparison && first?.subjectiveTension != null && latest?.subjectiveTension != null) {
|
||||||
|
caption = `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10,现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
|
||||||
|
} else if (!hasComparison && first?.subjectiveTension != null) {
|
||||||
|
caption = `已建立初始身体基准状态,肩颈紧张度 ${first.subjectiveTension}/10。开启专属改善计划。`
|
||||||
|
}
|
||||||
|
const created = await this.prisma.growthShareCard.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
planId: plan?.id,
|
||||||
|
shareCode: createShareCode(),
|
||||||
|
title: `我的第 ${completedCount || 1} 节普拉提`,
|
||||||
|
caption,
|
||||||
|
completedCount,
|
||||||
|
weeks: plan?.weeks || 12,
|
||||||
|
rows: rows as unknown as Prisma.InputJsonValue,
|
||||||
|
includePhotos: includePhotos === true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return this.mapShare(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicShare(shareCode: string) {
|
||||||
|
const card = await this.prisma.growthShareCard.findUnique({ where: { shareCode } })
|
||||||
|
if (!card) throw new NotFoundException('分享卡片不存在')
|
||||||
|
return {
|
||||||
|
...this.mapShare(card),
|
||||||
|
includePhotos: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private singleSnapshot(first?: ProfessionalAssessmentSessionRecord) {
|
||||||
|
if (!first) return []
|
||||||
|
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
|
||||||
|
{ key: 'shoulderFlexion', label: '肩屈活动' },
|
||||||
|
{ key: 'singleLeg', label: '单腿稳定' },
|
||||||
|
{ key: 'coreControl', label: '核心控制' },
|
||||||
|
{ key: 'thoracicExtension', label: '胸椎活动' },
|
||||||
|
]
|
||||||
|
const rows = labels.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
first: `${first.observations[item.key]}/5`,
|
||||||
|
latest: `${first.observations[item.key]}/5`,
|
||||||
|
}))
|
||||||
|
if (first.subjectiveTension != null) {
|
||||||
|
rows.push({
|
||||||
|
label: '肩颈主观紧张',
|
||||||
|
first: `${first.subjectiveTension}/10`,
|
||||||
|
latest: `${first.subjectiveTension}/10`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
|
||||||
|
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
|
||||||
|
{ key: 'shoulderFlexion', label: '肩屈活动' },
|
||||||
|
{ key: 'singleLeg', label: '单腿稳定' },
|
||||||
|
{ key: 'coreControl', label: '核心控制' },
|
||||||
|
{ key: 'thoracicExtension', label: '胸椎活动' },
|
||||||
|
]
|
||||||
|
const rows = labels.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
first: `${first.observations[item.key]}/5`,
|
||||||
|
latest: `${latest.observations[item.key]}/5`,
|
||||||
|
}))
|
||||||
|
if (first.subjectiveTension != null || latest.subjectiveTension != null) {
|
||||||
|
rows.push({
|
||||||
|
label: '肩颈主观紧张',
|
||||||
|
first: first.subjectiveTension != null ? `${first.subjectiveTension}/10` : '—',
|
||||||
|
latest: latest.subjectiveTension != null ? `${latest.subjectiveTension}/10` : '—',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSession(row: {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
kind: string
|
||||||
|
protocolVersion: string
|
||||||
|
recordedAt: Date
|
||||||
|
bookingId: string | null
|
||||||
|
originAssessmentId: string | null
|
||||||
|
observations: Prisma.JsonValue
|
||||||
|
subjectiveTension: number | null
|
||||||
|
coachSummary: string
|
||||||
|
trainingFocus: string
|
||||||
|
phaseGoal: string
|
||||||
|
photoAngle: string | null
|
||||||
|
}): ProfessionalAssessmentSessionRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
kind: row.kind as ProfessionalAssessmentKind,
|
||||||
|
protocolVersion: row.protocolVersion,
|
||||||
|
recordedAt: row.recordedAt.toISOString().slice(0, 10),
|
||||||
|
bookingId: row.bookingId,
|
||||||
|
originAssessmentId: row.originAssessmentId,
|
||||||
|
observations: row.observations as unknown as ProfessionalObservationScores,
|
||||||
|
subjectiveTension: row.subjectiveTension,
|
||||||
|
coachSummary: row.coachSummary,
|
||||||
|
trainingFocus: row.trainingFocus,
|
||||||
|
phaseGoal: row.phaseGoal,
|
||||||
|
photoAngle: row.photoAngle as ProfessionalAssessmentSessionRecord['photoAngle'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapPlan(plan: {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
weeks: number
|
||||||
|
sessionId: string | null
|
||||||
|
phases: Array<{ id: string; name: string; lessonStart: number; lessonEnd: number; focus: Prisma.JsonValue; summary: string }>
|
||||||
|
}): TrainingPlanRecord {
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
userId: plan.userId,
|
||||||
|
title: plan.title,
|
||||||
|
status: plan.status as TrainingPlanStatus,
|
||||||
|
weeks: plan.weeks,
|
||||||
|
sessionId: plan.sessionId,
|
||||||
|
phases: plan.phases.map((phase) => ({
|
||||||
|
id: phase.id,
|
||||||
|
name: phase.name,
|
||||||
|
lessonStart: phase.lessonStart,
|
||||||
|
lessonEnd: phase.lessonEnd,
|
||||||
|
focus: phase.focus as string[],
|
||||||
|
summary: phase.summary,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapShare(card: {
|
||||||
|
id: string
|
||||||
|
shareCode: string
|
||||||
|
title: string
|
||||||
|
caption: string
|
||||||
|
completedCount: number
|
||||||
|
weeks: number
|
||||||
|
rows: Prisma.JsonValue
|
||||||
|
includePhotos: boolean
|
||||||
|
}): GrowthShareCardRecord {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
shareCode: card.shareCode,
|
||||||
|
title: card.title,
|
||||||
|
caption: card.caption,
|
||||||
|
completedCount: card.completedCount,
|
||||||
|
weeks: card.weeks,
|
||||||
|
rows: card.rows as unknown as GrowthShareCardRecord['rows'],
|
||||||
|
includePhotos: card.includePhotos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUser(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
|
||||||
|
if (!user) throw new NotFoundException('学员不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
private date(value: string) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效')
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`)
|
||||||
|
if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效')
|
||||||
|
const todayChina = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
|
||||||
|
if (value > todayChina) throw new BadRequestException('日期不能晚于今天')
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user