Author SHA1 Message Date
liwentao 6cc72ae0f7 移除前端中的会员卡和支付相关,新增微信一键登录 2026-07-14 18:50:35 +08:00
liwentao 13b99428de 修正多处问题,添加骨架屏 2026-06-29 16:41:36 +08:00
liwentao e85f39ab83 修正多处问题 2026-06-29 13:35:29 +08:00
future 09e587cb65 完成一键登录和支付功能 2026-06-28 14:49:21 +08:00
future 74876e0923 完成登录功能 2026-06-24 22:25:36 +08:00
liwentao 0bebce3dc1 修正布局,删改无用页面 2026-06-24 08:13:12 +08:00
future 8d8c823616 整合api请求、添加购买会员卡页面、登陆页面 2026-06-23 22:17:53 +08:00
liwentao 1c547a717e 修复跳转功能 2026-06-15 18:10:39 +08:00
liwentao d7961694f9 完善团课推荐页面 2026-06-15 18:03:42 +08:00
liwentao 4e69185c48 完善团课前后端交互 2026-06-15 15:49:21 +08:00
liwentao 96b8fd2534 完善团课相关页面交互,完成团课列表页基础后端交互。(后端连接至服务器,版本为DEV分支版本) 2026-06-13 17:11:36 +08:00
future b345ceeb42 加入加载东湖 2026-06-12 15:43:01 +08:00
future 0402a1e82d 签到、课程搜索页面调整为测试模式 2026-06-12 12:36:21 +08:00
future c2a09a5057 tabbar适配安卓端 2026-06-11 14:51:07 +08:00
liwentao c94be9bc38 Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app
# Conflicts:
#	gym-manage-uniapp/pages.json
2026-06-11 14:33:39 +08:00
futureandliwentao bcf3bba3c7 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-11 14:32:38 +08:00
futureandliwentao 268284cf32 新增首页骨架屏并优化页面体验 2026-06-11 14:32:19 +08:00
futureandliwentao 5176083139 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-11 14:32:03 +08:00
liwentao 9f184d4c2a 添加团课详细页 2026-06-11 14:31:40 +08:00
liwentao 77ed3b890f 更新团课列表页面,修复路径代理导致的错误识别 2026-06-11 14:31:22 +08:00
时舟年andliwentao 5e58d9a0dc 修复tabber 2026-06-11 14:31:01 +08:00
liwentao a19c9470c5 更新page.json,添加团课列表路由 2026-06-11 14:30:50 +08:00
futureandliwentao 261a90bdcd 添加扫码签到功能和相关配置 2026-06-11 14:30:47 +08:00
liwentao ac710952b2 修复丢失的跳转功能 2026-06-11 14:30:26 +08:00
liwentao 8be304417d 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-11 14:29:45 +08:00
时舟年andliwentao b36c6133cb 会员个人中心页面初步完成 2026-06-11 14:28:37 +08:00
futureandliwentao b2b3782aa2 修改主题色 2026-06-11 14:26:50 +08:00
futureandliwentao 244b40025e 修复 SCSS 变量未定义错误和滚动监听问题 2026-06-11 14:26:49 +08:00
futureandliwentao 1912d62f21 优化多个页面样式及组件功能,优化课程、发现、训练、首页等核心页面样式 2026-06-11 14:26:49 +08:00
futureandliwentao abb806de5e 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-11 14:26:49 +08:00
futureandliwentao c22595b33a 新增首页骨架屏并优化页面体验 2026-06-11 14:26:49 +08:00
futureandliwentao ec89d1459a 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-11 14:26:49 +08:00
liwentao c9aabf23f0 添加团课详细页 2026-06-11 14:26:49 +08:00
liwentao c8fa427379 更新团课列表页面,修复路径代理导致的错误识别 2026-06-11 14:26:49 +08:00
时舟年andliwentao fc5f094f8c 修复tabber 2026-06-11 14:26:49 +08:00
liwentao 8114809102 更新page.json,添加团课列表路由 2026-06-11 14:26:49 +08:00
futureandliwentao 26e185a804 添加扫码签到功能和相关配置 2026-06-11 14:26:49 +08:00
liwentao 68733acc72 补充遗失的文件 2026-06-11 14:26:49 +08:00
liwentao b454d5d940 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-11 14:26:49 +08:00
时舟年andliwentao 11b03e8e62 个人中心页面初步实现 2026-06-11 14:26:49 +08:00
时舟年andliwentao a0026b1da5 会员个人中心页面初步完成 2026-06-11 14:26:49 +08:00
futureandliwentao 1fa2fbd3f3 完成首页布局 2026-06-11 14:26:48 +08:00
时舟年andliwentao 125492f6f1 微信小程序APPID配置 2026-06-11 14:26:48 +08:00
liwentao 8d56fc8e9e 初始化uni-app 2026-06-11 14:26:48 +08:00
liwentao 7a94145819 新增团课类型,类型标签,以及相关功能 2026-06-11 13:57:46 +08:00
179 changed files with 19682 additions and 6209 deletions
+793 -3
View File
@@ -26,11 +26,32 @@
- [查询会员预约记录](#查询会员预约记录) - [查询会员预约记录](#查询会员预约记录)
- [查询预约详情](#查询预约详情) - [查询预约详情](#查询预约详情)
- [查询课程预约记录](#查询课程预约记录) - [查询课程预约记录](#查询课程预约记录)
5. [数据模型](#数据模型) 5. [团课类型管理接口](#团课类型管理接口)
- [获取所有团课类型](#获取所有团课类型)
- [根据ID获取团课类型](#根据ID获取团课类型)
- [搜索团课类型](#搜索团课类型)
- [根据分类获取团课类型](#根据分类获取团课类型)
- [获取所有分类](#获取所有分类)
- [创建团课类型](#创建团课类型)
- [更新团课类型](#更新团课类型)
- [删除团课类型](#删除团课类型)
6. [团课标签管理接口](#团课标签管理接口)
- [获取所有标签](#获取所有标签)
- [根据ID获取标签](#根据ID获取标签)
- [搜索标签](#搜索标签)
- [获取类型的标签](#获取类型的标签)
- [创建标签](#创建标签)
- [更新标签](#更新标签)
- [删除标签](#删除标签)
- [为类型添加标签](#为类型添加标签)
- [从类型移除标签](#从类型移除标签)
- [清空类型标签](#清空类型标签)
7. [数据模型](#数据模型)
- [GroupCourse(团课)](#GroupCourse团课) - [GroupCourse(团课)](#GroupCourse团课)
- [GroupCourseBooking(团课预约)](#GroupCourseBooking团课预约) - [GroupCourseBooking(团课预约)](#GroupCourseBooking团课预约)
6. [状态码说明](#状态码说明) - [GroupCourseType(团课类型)](#GroupCourseType团课类型)
7. [业务规则](#业务规则) 7. [状态码说明](#状态码说明)
8. [业务规则](#业务规则)
--- ---
@@ -178,6 +199,78 @@
--- ---
### 根据ID获取团课完整信息
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/{id}/detail` |
| **所属文件** | `GroupCourseHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-02T09:00:00",
"endTime": "2026-06-02T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程",
"pointCardAmount": 1,
"storedValueAmount": 50.00,
"typeName": "瑜伽入门",
"typeCategory": "柔韧与平衡类",
"baseDifficulty": 2,
"difficultyLevel": "初级",
"calculatedDifficulty": 2,
"typeInfo": {
"id": 1,
"typeName": "瑜伽入门",
"baseDifficulty": 2,
"calculatedDifficulty": 2,
"difficultyLevel": "初级",
"description": "适合初学者的瑜伽课程,注重基础体式",
"category": "柔韧与平衡类",
"labels": [
{"id": 1, "labelName": "适合新手", "color": "#52c41a"},
{"id": 3, "labelName": "减压放松", "color": "#1890ff"}
]
},
"labels": [
{"id": 1, "labelName": "适合新手", "color": "#52c41a"},
{"id": 3, "labelName": "减压放松", "color": "#1890ff"}
],
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
**说明**: 此接口返回团课的完整信息,包括:
- 团课基础信息
- 团课对应的类型信息(包含基础难度、综合难度、难度等级等)
- 该类型的所有标签信息
---
### 创建团课 ### 创建团课
| 属性 | 值 | | 属性 | 值 |
@@ -628,6 +721,625 @@
--- ---
## 团课类型管理接口
### 获取所有团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| includeDeleted | boolean | 否 | false | 是否包含已删除的类型 |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"typeName": "瑜伽入门",
"baseDifficulty": 2,
"calculatedDifficulty": 2,
"difficultyLevel": "初级",
"description": "适合初学者的瑜伽课程,注重基础体式",
"category": "柔韧与平衡类",
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
]
```
---
### 根据ID获取团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types/{id}` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课类型ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"typeName": "瑜伽入门",
"baseDifficulty": 2,
"calculatedDifficulty": 2,
"difficultyLevel": "初级",
"description": "适合初学者的瑜伽课程,注重基础体式",
"category": "柔韧与平衡类",
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 搜索团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types/search` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| keyword | string | 否 | - | 搜索关键词(匹配类型名称) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"typeName": "瑜伽入门",
"baseDifficulty": 2,
"difficultyLevel": "初级",
"category": "柔韧与平衡类"
}
]
```
---
### 根据分类获取团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types/category/{category}` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| category | String | 是 | 分类名称 |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| keyword | string | 否 | - | 搜索关键词 |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"typeName": "瑜伽入门",
"baseDifficulty": 2,
"difficultyLevel": "初级",
"category": "柔韧与平衡类"
}
]
```
---
### 获取所有分类
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types/categories` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**成功响应** (200 OK):
```json
["基础有氧与热身", "固定器械训练", "自重基础动作", "自由重量杠铃/哑铃", "高强度与爆发力", "柔韧与平衡类"]
```
---
### 创建团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/types` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**请求体**:
```json
{
"typeName": "核心力量训练",
"baseDifficulty": 4,
"description": "针对核心肌群的专项训练课程",
"category": "自重基础动作"
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| typeName | String | **是** | - | 类型名称 |
| baseDifficulty | Integer | 否 | 1 | 基础难度(1-10 |
| description | String | 否 | - | 类型描述 |
| category | String | 否 | - | 分类 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课类型创建成功",
"data": {
"id": 40,
"typeName": "核心力量训练",
"baseDifficulty": 4,
"calculatedDifficulty": 4,
"difficultyLevel": "中级",
"description": "针对核心肌群的专项训练课程",
"category": "自重基础动作"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "类型名称不能为空"
}
```
---
### 更新团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/types/{id}` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课类型ID |
**请求体**:
```json
{
"typeName": "核心力量训练进阶",
"baseDifficulty": 6,
"description": "进阶核心训练课程"
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| typeName | String | 否 | 类型名称 |
| baseDifficulty | Integer | 否 | 基础难度(1-10 |
| description | String | 否 | 类型描述 |
| category | String | 否 | 分类 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课类型更新成功",
"data": {
"id": 40,
"typeName": "核心力量训练进阶",
"baseDifficulty": 6,
"calculatedDifficulty": 6,
"difficultyLevel": "中高级",
"description": "进阶核心训练课程",
"category": "自重基础动作"
}
}
```
---
### 删除团课类型
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/types/{id}` |
| **所属文件** | `GroupCourseTypeHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课类型ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课类型删除成功"
}
```
---
## 团课标签管理接口
### 获取所有标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/labels` |
| **所属文件** | `CourseLabelHandler.java` |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"labelName": "适合新手",
"color": "#52c41a",
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
},
{
"id": 2,
"labelName": "中级过渡",
"color": "#faad14",
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
]
```
---
### 根据ID获取标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/labels/{id}` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 标签ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"labelName": "适合新手",
"color": "#52c41a",
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 搜索标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/labels/search` |
| **所属文件** | `CourseLabelHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| keyword | string | 否 | - | 搜索关键词(匹配标签名称) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"labelName": "适合新手",
"color": "#52c41a"
}
]
```
---
### 获取类型的标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/types/{typeId}/labels` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| typeId | Long | 是 | 团课类型ID |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"labelName": "适合新手",
"color": "#52c41a"
},
{
"id": 3,
"labelName": "减压放松",
"color": "#1890ff"
}
]
```
---
### 创建标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/labels` |
| **所属文件** | `CourseLabelHandler.java` |
**请求体**:
```json
{
"labelName": "燃脂塑形",
"color": "#f5222d"
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| labelName | String | **是** | - | 标签名称(最大50字符) |
| color | String | 否 | #1890ff | 标签颜色(十六进制颜色值) |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签创建成功",
"data": {
"id": 15,
"labelName": "燃脂塑形",
"color": "#f5222d"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "标签名称已存在"
}
```
---
### 更新标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/labels/{id}` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 标签ID |
**请求体**:
```json
{
"labelName": "燃脂塑形进阶",
"color": "#fa541c"
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| labelName | String | 否 | 标签名称(最大50字符) |
| color | String | 否 | 标签颜色(十六进制颜色值) |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签更新成功",
"data": {
"id": 15,
"labelName": "燃脂塑形进阶",
"color": "#fa541c"
}
}
```
---
### 删除标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/labels/{id}` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 标签ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签删除成功"
}
```
---
### 为类型添加标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/types/{typeId}/labels` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| typeId | Long | 是 | 团课类型ID |
**请求体**:
```json
{
"labelIds": [1, 3, 5]
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| labelIds | List\<Long\> | **是** | 要添加的标签ID列表 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签添加成功"
}
```
---
### 从类型移除标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/types/{typeId}/labels/{labelId}` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| typeId | Long | 是 | 团课类型ID |
| labelId | Long | 是 | 标签ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签移除成功"
}
```
---
### 清空类型标签
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/types/{typeId}/labels` |
| **所属文件** | `CourseLabelHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| typeId | Long | 是 | 团课类型ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "标签清空成功"
}
```
---
## 数据模型 ## 数据模型
### GroupCourse(团课) ### GroupCourse(团课)
@@ -675,6 +1387,84 @@
| updatedAt | LocalDateTime | 更新时间 | | updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) | | deletedAt | LocalDateTime | 删除时间(软删除) |
### GroupCourseType(团课类型)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| typeName | String | 类型名称 |
| baseDifficulty | Integer | 基础难度(1-10 |
| calculatedDifficulty | Integer | 综合难度系数(预留扩展字段) |
| difficultyLevel | String | 难度等级描述(初级/中级/中高级/高级/专家级) |
| description | String | 类型描述 |
| category | String | 分类(如:有氧、力量、柔韧等) |
| labels | List\<CourseLabel\> | 标签列表 |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
### CourseLabel(团课标签)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| labelName | String | 标签名称(最大50字符) |
| color | String | 标签颜色(十六进制颜色值,默认#1890ff |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
### GroupCourseDetail(团课完整信息)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| courseName | String | 课程名称 |
| coachId | Long | 教练ID |
| courseType | Long | 课程类型ID |
| startTime | LocalDateTime | 开始时间 |
| endTime | LocalDateTime | 结束时间 |
| maxMembers | Integer | 最大参与人数 |
| currentMembers | Integer | 当前参与人数 |
| status | Long | 状态(0-正常,1-已取消,2-已结束) |
| location | String | 上课地点 |
| coverImage | String | 封面图URL |
| description | String | 课程描述 |
| pointCardAmount | Integer | 点卡额度(消耗次数) |
| storedValueAmount | BigDecimal | 储值卡额度(消耗金额) |
| typeName | String | 类型名称(快捷访问) |
| typeCategory | String | 类型分类(快捷访问) |
| baseDifficulty | Integer | 基础难度(快捷访问) |
| difficultyLevel | String | 难度等级描述(快捷访问) |
| calculatedDifficulty | Integer | 综合难度系数(快捷访问) |
| typeInfo | GroupCourseType | 类型信息 |
| labels | List\<CourseLabel\> | 标签列表 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
**难度等级对应关系**:
| 基础难度 | 难度等级 |
|----------|----------|
| 1-2 | 初级 |
| 3-4 | 中级 |
| 5-6 | 中高级 |
| 7-8 | 高级 |
| 9-10 | 专家级 |
**难度扩展说明**:
`calculatedDifficulty` 字段为预留扩展字段,当前实现仅返回 `baseDifficulty`。未来可扩展的影响因素包括:
1. **课程时长系数**:时长越长难度越高
2. **教练难度调整系数**:教练可根据实际情况微调
3. **会员等级适配系数**:根据会员等级动态调整显示难度
4. **课程强度系数**:高强度课程难度加成
--- ---
## 状态码说明 ## 状态码说明
+87
View File
@@ -0,0 +1,87 @@
一、基础有氧与热身(难度 1-3
主要是低冲击、低技巧,用于建立运动基础。
慢走/椭圆机轻松模式:1(几乎无难度,适合所有人)
固定自行车(低阻力):2(注意座椅高度调节即可)
跑步机慢跑:3(需要基本协调性,膝盖有压力)
跳绳(连续基础跳):3(需要手脚配合,心肺要求明显)
二、固定器械训练(难度 2-5
轨迹固定,主要考验力量和耐力,技巧要求低。
坐姿腿屈伸/腿弯举:2(很容易找到发力感)
坐姿推胸机:3(需注意肩胛后收,避免耸肩)
高位下拉(坐姿):3(需控制不要过度后仰)
史密斯机深蹲:4(轨迹固定,但需保持核心稳定)
蝴蝶机夹胸:3(易用肘关节代偿,需锁定肩关节)
三、自重基础动作(难度 3-7
需要一定的力量-体重比和身体控制能力。
平板支撑:3(耐力考验,技巧低)
跪姿俯卧撑:3(上肢力量较弱者首选)
标准俯卧撑:5(需核心收紧,身体成直线)
引体向上(弹力带辅助):6(背部和手臂力量要求高)
标准引体向上:8(力量-体重比极高,多数男性无法完成1次)
徒手深蹲:3(注意膝盖方向与背部直立)
单腿深蹲(手枪蹲):8(需要极高下肢力量、柔韧性和平衡)
四、自由重量杠铃/哑铃(难度 5-9)
技巧风险最高,需要神经系统协调和长期动作打磨。
哑铃二头弯举:4(容易晃动借力,但较安全)
哑铃侧平举:5(极易用斜方肌代偿,真正练到三角肌中束很难)
杠铃卧推:7(肩关节压力大,起桥、沉肩、稳定手腕均有技巧,有压伤风险)
杠铃深蹲(颈后):8(全身协调性、核心抗压、杠位放置、呼吸模式,学习曲线陡峭)
传统硬拉:9(风险极高,需要精确的脊柱中立、髋铰链、背阔肌收紧,错误时伤腰)
高翻/抓举(奥运举重):10(需要爆发力、柔韧、精准衔接,非数月训练不能掌握)
五、高强度与爆发力(难度 6-10
对心肺、神经系统和恢复能力要求极高。
波比跳(标准版):6(连续做时心肺压力极大)
冲刺跑(短跑):7(对腘绳肌和脚踝爆发力要求高)
跳箱(合理高度):6(需要落地缓冲技巧)
负重雪橇推:6(主要考验腿部耐力和意志力)
双力臂(引体向上后翻腕上杠):9(需要爆发引体 + 极高相对力量)
六、柔韧与平衡类(难度 3-8
考验本体感觉和关节活动度。
静态拉伸(坐姿体前屈):2(无风险,但需要坚持)
瑜伽下犬式:3(常见,但需背部与手臂对齐)
单腿罗马尼亚硬拉(徒手):6(极考验平衡和髋稳定)
全深蹲(脚跟贴地,亚洲蹲):5(踝关节灵活度限制多数人)
竖叉/横叉:8(需要数月甚至数年拉伸)
+5
View File
@@ -35,6 +35,11 @@
<artifactId>manage-common</artifactId> <artifactId>manage-common</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>cn.novalon.gym.manage</groupId> <groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId> <artifactId>manage-db</artifactId>
@@ -4,8 +4,10 @@ package cn.novalon.gym.manage.groupcourse.converter;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -124,4 +126,33 @@ public class GroupCourseConverter {
.map(this::toBookingEntity) .map(this::toBookingEntity)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 将团课类型实体转换为领域模型
*/
public GroupCourseType toGroupCourseType(GroupCourseTypeEntity entity){
if(entity == null){
return null;
}
GroupCourseType groupCourseType = new GroupCourseType();
BeanUtil.copyProperties(entity, groupCourseType);
log.debug("转换团课类型实体到领域模型:typeId={}", entity.getId());
return groupCourseType;
}
/**
* 将团课类型领域模型转换为实体
*/
public GroupCourseTypeEntity toGroupCourseTypeEntity(GroupCourseType domain){
if(domain == null){
return null;
}
GroupCourseTypeEntity entity = new GroupCourseTypeEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
log.debug("转换团课类型领域模型到实体:typeId={}", domain.getId());
return entity;
}
} }
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface CourseLabelDao extends R2dbcRepository<CourseLabelEntity, Long> {
Mono<CourseLabelEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<CourseLabelEntity> findAllByDeletedAtIsNull();
Flux<CourseLabelEntity> findByLabelNameContainingAndDeletedAtIsNull(String labelName);
Mono<CourseLabelEntity> findByLabelNameAndDeletedAtIsNull(String labelName);
@Modifying
@Query("UPDATE course_label SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -0,0 +1,38 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CourseTypeLabelDao extends R2dbcRepository<CourseTypeLabelEntity, Long> {
Flux<CourseTypeLabelEntity> findByTypeIdAndDeletedAtIsNull(Long typeId);
Flux<CourseTypeLabelEntity> findByLabelIdAndDeletedAtIsNull(Long labelId);
Mono<CourseTypeLabelEntity> findByTypeIdAndLabelIdAndDeletedAtIsNull(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> deleteByTypeIdAndLabelId(Long typeId, Long labelId, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId")
Mono<Integer> deleteByTypeId(Long typeId, LocalDateTime deletedAt);
@Modifying
@Query("DELETE FROM course_type_label WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> physicalDeleteByTypeIdAndLabelId(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE label_id = :labelId")
Mono<Integer> deleteByLabelId(Long labelId, LocalDateTime deletedAt);
}
@@ -36,4 +36,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Modifying @Modifying
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
} }
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntity, Long> {
Mono<GroupCourseTypeEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseTypeEntity> findByTypeNameContainingAndDeletedAtIsNull(String typeName);
Flux<GroupCourseTypeEntity> findByCategoryAndDeletedAtIsNull(String category);
Mono<GroupCourseTypeEntity> findByTypeNameAndDeletedAtIsNull(String typeName);
@Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class CourseLabel extends BaseDomain {
//标签名称
@Schema(description = "标签名称", example = "适合新手")
private String labelName;
//标签颜色(十六进制)
@Schema(description = "标签颜色(十六进制)", example = "#52c41a")
private String color;
//标签描述
@Schema(description = "标签描述", example = "适合健身初学者")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,254 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 团课完整信息领域模型
* 包含团课基础信息、关联的类型信息以及类型的标签信息
*/
public class GroupCourseDetail extends BaseDomain {
// ===== 团课基础信息 =====
@Schema(description = "课程名称", example = "瑜伽入门")
private String courseName;
@Schema(description = "教练ID", example = "1")
private Long coachId;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "开始时间", example = "2026-06-02T09:00:00")
private LocalDateTime startTime;
@Schema(description = "结束时间", example = "2026-06-02T10:00:00")
private LocalDateTime endTime;
@Schema(description = "最大参与人数", example = "20")
private Integer maxMembers;
@Schema(description = "当前参与人数", example = "15")
private Integer currentMembers;
@Schema(description = "课程状态", example = "0")
private Long status;
@Schema(description = "上课地点", example = "健身房A区")
private String location;
@Schema(description = "封面图URL", example = "https://example.com/yoga.jpg")
private String coverImage;
@Schema(description = "课程描述", example = "适合初学者的瑜伽课程")
private String description;
@Schema(description = "点卡额度(消耗次数)", example = "1")
private Integer pointCardAmount;
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private BigDecimal storedValueAmount;
// ===== 关联的类型信息 =====
@Schema(description = "类型信息")
private GroupCourseType typeInfo;
// ===== 快捷访问属性(从类型信息派生)=====
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
@Schema(description = "类型分类", example = "柔韧与平衡类")
private String typeCategory;
@Schema(description = "基础难度", example = "2")
private Integer baseDifficulty;
@Schema(description = "难度等级描述", example = "初级")
private String difficultyLevel;
@Schema(description = "综合难度系数", example = "2")
private Integer calculatedDifficulty;
// ===== 标签信息(从类型标签派生)=====
@Schema(description = "标签列表")
private List<CourseLabel> labels;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCoachId() {
return coachId;
}
public void setCoachId(Long coachId) {
this.coachId = coachId;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartTime() {
return startTime;
}
public void setStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
public LocalDateTime getEndTime() {
return endTime;
}
public void setEndTime(LocalDateTime endTime) {
this.endTime = endTime;
}
public Integer getMaxMembers() {
return maxMembers;
}
public void setMaxMembers(Integer maxMembers) {
this.maxMembers = maxMembers;
}
public Integer getCurrentMembers() {
return currentMembers;
}
public void setCurrentMembers(Integer currentMembers) {
this.currentMembers = currentMembers;
}
public Long getStatus() {
return status;
}
public void setStatus(Long status) {
this.status = status;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getPointCardAmount() {
return pointCardAmount;
}
public void setPointCardAmount(Integer pointCardAmount) {
this.pointCardAmount = pointCardAmount;
}
public BigDecimal getStoredValueAmount() {
return storedValueAmount;
}
public void setStoredValueAmount(BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount;
}
public GroupCourseType getTypeInfo() {
return typeInfo;
}
public void setTypeInfo(GroupCourseType typeInfo) {
this.typeInfo = typeInfo;
// 同步派生属性
if (typeInfo != null) {
this.typeName = typeInfo.getTypeName();
this.typeCategory = typeInfo.getCategory();
this.baseDifficulty = typeInfo.getBaseDifficulty();
this.difficultyLevel = typeInfo.getDifficultyLevel();
this.calculatedDifficulty = typeInfo.getCalculatedDifficulty();
this.labels = typeInfo.getLabels();
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public String getTypeCategory() {
return typeCategory;
}
public void setTypeCategory(String typeCategory) {
this.typeCategory = typeCategory;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDifficultyLevel() {
return difficultyLevel;
}
public void setDifficultyLevel(String difficultyLevel) {
this.difficultyLevel = difficultyLevel;
}
public Integer getCalculatedDifficulty() {
return calculatedDifficulty;
}
public void setCalculatedDifficulty(Integer calculatedDifficulty) {
this.calculatedDifficulty = calculatedDifficulty;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,113 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.List;
public class GroupCourseType extends BaseDomain {
//类型名称
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
//基础难度(1-10
@Schema(description = "基础难度(1-10", example = "2")
private Integer baseDifficulty;
//类型描述
@Schema(description = "类型描述", example = "适合初学者的瑜伽课程")
private String description;
//分类(如:有氧、力量、柔韧等)
@Schema(description = "分类", example = "柔韧与平衡类")
private String category;
//标签列表
@Schema(description = "标签列表")
private List<CourseLabel> labels = new ArrayList<>();
/**
* 计算综合难度系数
*
* 当前实现仅返回基础难度,为后续扩展预留空间。
* 未来可扩展的影响因素包括:
* 1. 课程时长系数(时长越长难度越高)
* 2. 教练难度调整系数(教练可根据实际情况微调)
* 3. 会员等级适配系数(根据会员等级动态调整显示难度)
* 4. 课程强度系数(高强度课程难度加成)
*
* @return 综合难度系数(1-10
*/
@Schema(description = "综合难度系数(预留扩展字段)", example = "2")
public Integer getCalculatedDifficulty() {
// TODO: 预留扩展点 - 未来可在此处添加更多难度计算逻辑
// 例如:return calculateDynamicDifficulty(baseDifficulty, additionalFactors...);
return this.baseDifficulty != null ? this.baseDifficulty : 1;
}
/**
* 获取难度等级描述
* 将数字难度转换为友好的文字描述
*
* @return 难度等级描述
*/
@Schema(description = "难度等级描述", example = "初级")
public String getDifficultyLevel() {
if (baseDifficulty == null) {
return "未知";
}
if (baseDifficulty <= 2) {
return "初级";
} else if (baseDifficulty <= 4) {
return "中级";
} else if (baseDifficulty <= 6) {
return "中高级";
} else if (baseDifficulty <= 8) {
return "高级";
} else {
return "专家级";
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,45 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_label")
public class CourseLabelEntity extends BaseEntity {
//标签名称
@Column("label_name")
private String labelName;
//标签颜色(十六进制)
@Column("color")
private String color;
//标签描述
@Column("description")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,33 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_type_label")
public class CourseTypeLabelEntity extends BaseEntity {
//团课类型ID
@Column("type_id")
private Long typeId;
//标签ID
@Column("label_id")
private Long labelId;
public Long getTypeId() {
return typeId;
}
public void setTypeId(Long typeId) {
this.typeId = typeId;
}
public Long getLabelId() {
return labelId;
}
public void setLabelId(Long labelId) {
this.labelId = labelId;
}
}
@@ -0,0 +1,57 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("group_course_type")
public class GroupCourseTypeEntity extends BaseEntity {
//类型名称
@Column("type_name")
private String typeName;
//基础难度(1-10
@Column("base_difficulty")
private Integer baseDifficulty;
//类型描述
@Column("description")
private String description;
//分类(如:有氧、力量、柔韧等)
@Column("category")
private String category;
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
@@ -0,0 +1,217 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
@Tag(name = "团课标签管理", description = "团课标签相关操作")
public class CourseLabelHandler {
private final ICourseLabelService courseLabelService;
public CourseLabelHandler(ICourseLabelService courseLabelService) {
this.courseLabelService = courseLabelService;
}
@Operation(summary = "获取所有标签", description = "获取系统中所有标签列表")
public Mono<ServerResponse> getAllLabels(ServerRequest request) {
return ServerResponse.ok()
.body(courseLabelService.findAll(), CourseLabel.class);
}
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.findById(id)
.flatMap(label -> ServerResponse.ok().bodyValue(label))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "搜索标签", description = "根据关键词搜索标签")
public Mono<ServerResponse> searchLabels(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(courseLabelService.findByKeyword(keyword), CourseLabel.class);
}
@Operation(summary = "创建标签", description = "创建新的标签")
public Mono<ServerResponse> createLabel(ServerRequest request) {
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() == null || courseLabel.getLabelName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getColor() == null || courseLabel.getColor().isEmpty()) {
courseLabel.setColor("#1890ff");
}
return courseLabelService.create(courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签创建成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新标签", description = "更新指定标签信息")
public Mono<ServerResponse> updateLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() != null && courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
return courseLabelService.update(id, courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签更新成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "删除标签", description = "删除指定标签(软删除)")
public Mono<ServerResponse> deleteLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "获取类型的标签", description = "获取指定团课类型的所有标签")
public Mono<ServerResponse> getLabelsByTypeId(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.findByTypeId(typeId)
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "为类型添加标签", description = "为指定团课类型添加标签")
public Mono<ServerResponse> addLabelsToType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
@SuppressWarnings("unchecked")
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = labelIdsInt.stream()
.map(Integer::longValue)
.collect(java.util.stream.Collectors.toList());
return courseLabelService.addLabelsToType(typeId, labelIds)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签添加成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "从类型移除标签", description = "从指定团课类型移除标签")
public Mono<ServerResponse> removeLabelFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
Long labelId = Long.valueOf(request.pathVariable("labelId"));
return courseLabelService.removeLabelFromType(typeId, labelId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签移除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "清空类型标签", description = "清空指定团课类型的所有标签")
public Mono<ServerResponse> clearLabelsFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.clearLabelsFromType(typeId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签清空成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -4,6 +4,7 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -76,6 +77,14 @@ public class GroupCourseHandler {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
@Operation(summary = "根据ID获取团课完整信息", description = "根据ID获取团课完整信息,包括团课基础信息、类型信息和标签信息")
public Mono<ServerResponse> getGroupCourseDetailById(ServerRequest request){
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseService.findDetailById(id)
.flatMap(detail -> ServerResponse.ok().bodyValue(detail))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建团课", description = "创建新的团课") @Operation(summary = "创建团课", description = "创建新的团课")
public Mono<ServerResponse> createGroupCourse(ServerRequest request) { public Mono<ServerResponse> createGroupCourse(ServerRequest request) {
return request.bodyToMono(GroupCourse.class) return request.bodyToMono(GroupCourse.class)
@@ -0,0 +1,137 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "团课类型管理", description = "团课类型及难度相关操作")
public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) {
this.groupCourseTypeService = groupCourseTypeService;
}
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
public Mono<ServerResponse> getAllGroupCourseTypes(ServerRequest request) {
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return ServerResponse.ok()
.body(groupCourseTypeService.findAll(includeDeleted), GroupCourseType.class);
}
@Operation(summary = "根据ID获取团课类型", description = "根据ID获取团课类型详情")
public Mono<ServerResponse> getGroupCourseTypeById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.findById(id)
.flatMap(type -> ServerResponse.ok().bodyValue(type))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据关键词搜索团课类型", description = "根据类型名称关键词搜索团课类型")
public Mono<ServerResponse> searchGroupCourseTypes(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByKeyword(keyword), GroupCourseType.class);
}
@Operation(summary = "根据分类获取团课类型", description = "根据分类获取团课类型列表")
public Mono<ServerResponse> getGroupCourseTypesByCategory(ServerRequest request) {
String category = request.pathVariable("category");
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByCategoryAndKeyword(category, keyword), GroupCourseType.class);
}
@Operation(summary = "获取所有分类", description = "获取所有团课类型分类(去重)")
public Mono<ServerResponse> getCategories(ServerRequest request) {
return groupCourseTypeService.findCategories()
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "创建团课类型", description = "创建新的团课类型")
public Mono<ServerResponse> createGroupCourseType(ServerRequest request) {
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
if (groupCourseType.getTypeName() == null || groupCourseType.getTypeName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "类型名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
// 默认基础难度为1
if (groupCourseType.getBaseDifficulty() == null) {
groupCourseType.setBaseDifficulty(1);
}
return groupCourseTypeService.create(groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型创建成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
groupCourseType.setId(id);
return groupCourseTypeService.update(id, groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型更新成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelRepository {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> findByLabelName(String labelName);
Mono<CourseLabel> save(CourseLabel courseLabel);
Mono<CourseLabel> update(CourseLabel courseLabel);
Mono<Void> deleteById(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
}
@@ -27,4 +27,6 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta); Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType);
} }
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> findByTypeName(String typeName);
Mono<GroupCourseType> save(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id);
}
@@ -0,0 +1,161 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@Transactional
public class CourseLabelRepository implements ICourseLabelRepository {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelRepository.class);
private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter) {
this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter;
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(id)
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelDao.findAllByDeletedAtIsNull()
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll();
}
return courseLabelDao.findByLabelNameContainingAndDeletedAtIsNull(keyword)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> findByLabelName(String labelName) {
return courseLabelDao.findByLabelNameAndDeletedAtIsNull(labelName)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> save(CourseLabel courseLabel) {
CourseLabelEntity entity = toCourseLabelEntity(courseLabel);
return courseLabelDao.save(entity)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> update(CourseLabel courseLabel) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(courseLabel.getId())
.switchIfEmpty(Mono.error(new RuntimeException("标签不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (courseLabel.getLabelName() != null) {
existing.setLabelName(courseLabel.getLabelName());
}
if (courseLabel.getColor() != null) {
existing.setColor(courseLabel.getColor());
}
if (courseLabel.getDescription() != null) {
existing.setDescription(courseLabel.getDescription());
}
existing.setUpdatedAt(LocalDateTime.now());
return courseLabelDao.save(existing);
})
.map(this::toCourseLabel);
}
@Override
public Mono<Void> deleteById(Long id) {
return courseLabelDao.softDelete(id, LocalDateTime.now())
.then(courseTypeLabelDao.deleteByLabelId(id, LocalDateTime.now()))
.then();
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseTypeLabelDao.findByTypeIdAndDeletedAtIsNull(typeId)
.flatMap(typeLabel -> courseLabelDao.findByIdIsAndDeletedAtIsNull(typeLabel.getLabelId()))
.map(this::toCourseLabel);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return Flux.fromIterable(labelIds)
.flatMap(labelId -> {
return courseTypeLabelDao.physicalDeleteByTypeIdAndLabelId(typeId, labelId)
.then(Mono.defer(() -> {
CourseTypeLabelEntity entity = new CourseTypeLabelEntity();
entity.setTypeId(typeId);
entity.setLabelId(labelId);
return courseTypeLabelDao.save(entity).then(Mono.empty());
}));
})
.then();
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseTypeLabelDao.deleteByTypeIdAndLabelId(typeId, labelId, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseTypeLabelDao.deleteByTypeId(typeId, LocalDateTime.now())
.then();
}
private CourseLabel toCourseLabel(CourseLabelEntity entity) {
if (entity == null) {
return null;
}
CourseLabel label = new CourseLabel();
label.setId(entity.getId());
label.setLabelName(entity.getLabelName());
label.setColor(entity.getColor());
label.setDescription(entity.getDescription());
label.setCreatedAt(entity.getCreatedAt());
label.setUpdatedAt(entity.getUpdatedAt());
return label;
}
private CourseLabelEntity toCourseLabelEntity(CourseLabel domain) {
if (domain == null) {
return null;
}
CourseLabelEntity entity = new CourseLabelEntity();
entity.setId(domain.getId());
entity.setLabelName(domain.getLabelName());
entity.setColor(domain.getColor());
entity.setDescription(domain.getDescription());
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -178,4 +178,10 @@ public class GroupCourseRepository implements IGroupCourseRepository {
return Mono.empty(); return Mono.empty();
}); });
} }
@Override
public Flux<GroupCourse> findByCourseType(Long courseType) {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
.map(groupCourseConverter::toDomain);
}
} }
@@ -0,0 +1,132 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
@Transactional
public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeRepository.class);
private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter;
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
this.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(id)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
if (includeDeleted) {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
} else {
return groupCourseTypeDao.findAllByDeletedAtIsNull()
.map(converter::toGroupCourseType);
}
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByTypeNameContainingAndDeletedAtIsNull(keyword)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
if (category == null || category.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByCategoryAndDeletedAtIsNull(category)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
Flux<GroupCourseType> result;
if (category != null && !category.isEmpty()) {
result = findByCategory(category);
} else {
result = findAll(false);
}
if (keyword != null && !keyword.isEmpty()) {
result = result.filter(type -> type.getTypeName() != null &&
type.getTypeName().toLowerCase().contains(keyword.toLowerCase()));
}
return result;
}
@Override
public Mono<GroupCourseType> findByTypeName(String typeName) {
return groupCourseTypeDao.findByTypeNameAndDeletedAtIsNull(typeName)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> save(GroupCourseType groupCourseType) {
GroupCourseTypeEntity entity = converter.toGroupCourseTypeEntity(groupCourseType);
return groupCourseTypeDao.save(entity)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> update(GroupCourseType groupCourseType) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (groupCourseType.getTypeName() != null) {
existing.setTypeName(groupCourseType.getTypeName());
}
if (groupCourseType.getBaseDifficulty() != null) {
existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
}
if (groupCourseType.getDescription() != null) {
existing.setDescription(groupCourseType.getDescription());
}
if (groupCourseType.getCategory() != null) {
existing.setCategory(groupCourseType.getCategory());
}
existing.setUpdatedAt(LocalDateTime.now());
return groupCourseTypeDao.save(existing);
})
.map(converter::toGroupCourseType);
}
@Override
public Mono<Void> deleteById(Long id) {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then();
}
}
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelService {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> create(CourseLabel courseLabel);
Mono<CourseLabel> update(Long id, CourseLabel courseLabel);
Mono<Void> delete(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
}
@@ -4,11 +4,13 @@ package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
public interface IGroupCourseService { public interface IGroupCourseService {
Mono<GroupCourse> findById(Long id); Mono<GroupCourse> findById(Long id);
Mono<GroupCourseDetail> findDetailById(Long id);
Flux<GroupCourse> findAll(); Flux<GroupCourse> findAll();
Flux<GroupCourse> findAll(boolean includeDeleted); Flux<GroupCourse> findAll(boolean includeDeleted);
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeService {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> create(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType);
Mono<Void> delete(Long id);
/**
* 获取分类列表(去重)
* @return 分类名称列表
*/
Flux<String> findCategories();
}
@@ -0,0 +1,112 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
@Service
public class CourseLabelService implements ICourseLabelService {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelService.class);
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private final ICourseLabelRepository courseLabelRepository;
private final IGroupCourseRepository groupCourseRepository;
private final RedisUtil redisUtil;
public CourseLabelService(ICourseLabelRepository courseLabelRepository,
IGroupCourseRepository groupCourseRepository,
RedisUtil redisUtil) {
this.courseLabelRepository = courseLabelRepository;
this.groupCourseRepository = groupCourseRepository;
this.redisUtil = redisUtil;
}
private Mono<Void> invalidateGroupCourseDetailCache(Long typeId) {
return groupCourseRepository.findByCourseType(typeId)
.flatMap(course -> {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + course.getId();
return redisUtil.delete(cacheKey)
.doOnSuccess(deleted -> logger.debug("清除团课详情缓存 - courseId={}", course.getId()));
})
.then();
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelRepository.findById(id);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelRepository.findAll();
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
return courseLabelRepository.findByKeyword(keyword);
}
@Override
public Mono<CourseLabel> create(CourseLabel courseLabel) {
return courseLabelRepository.findByLabelName(courseLabel.getLabelName())
.flatMap(existing -> Mono.<CourseLabel>error(new RuntimeException("标签名称已存在")))
.switchIfEmpty(courseLabelRepository.save(courseLabel))
.doOnSuccess(label -> logger.info("标签创建成功 - id={}, name={}", label.getId(), label.getLabelName()))
.doOnError(error -> logger.error("标签创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<CourseLabel> update(Long id, CourseLabel courseLabel) {
courseLabel.setId(id);
return courseLabelRepository.update(courseLabel)
.doOnSuccess(label -> logger.info("标签更新成功 - id={}", id))
.doOnError(error -> logger.error("标签更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return courseLabelRepository.deleteById(id)
.doOnSuccess(v -> logger.info("标签删除成功 - id={}", id))
.doOnError(error -> logger.error("标签删除失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseLabelRepository.findByTypeId(typeId);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return courseLabelRepository.addLabelsToType(typeId, labelIds)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("标签添加到类型成功 - typeId={}, labelIds={}", typeId, labelIds))
.doOnError(error -> logger.error("标签添加到类型失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseLabelRepository.removeLabelFromType(typeId, labelId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("从类型移除标签成功 - typeId={}, labelId={}", typeId, labelId))
.doOnError(error -> logger.error("从类型移除标签失败 - typeId={}, labelId={}, error: {}", typeId, labelId, error.getMessage()));
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseLabelRepository.clearLabelsFromType(typeId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
}
@@ -4,13 +4,18 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent; import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus; import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
@@ -33,6 +38,8 @@ public class GroupCourseService implements IGroupCourseService {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final ICourseLabelRepository courseLabelRepository;
private final IMemberCardRecordService memberCardRecordService; private final IMemberCardRecordService memberCardRecordService;
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
@@ -41,12 +48,15 @@ public class GroupCourseService implements IGroupCourseService {
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private static final long CACHE_EXPIRE_SECONDS = 300; private static final long CACHE_EXPIRE_SECONDS = 300;
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
public GroupCourseService(IGroupCourseRepository groupCourseRepository, public GroupCourseService(IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository, IGroupCourseBookingRepository bookingRepository,
IGroupCourseTypeRepository groupCourseTypeRepository,
ICourseLabelRepository courseLabelRepository,
IMemberCardRecordService memberCardRecordService, IMemberCardRecordService memberCardRecordService,
MemberCardRepository memberCardRepository, MemberCardRepository memberCardRepository,
RedisUtil redisUtil, RedisUtil redisUtil,
@@ -54,6 +64,8 @@ public class GroupCourseService implements IGroupCourseService {
GroupCourseStateMachine stateMachine){ GroupCourseStateMachine stateMachine){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.courseLabelRepository = courseLabelRepository;
this.memberCardRecordService = memberCardRecordService; this.memberCardRecordService = memberCardRecordService;
this.memberCardRepository = memberCardRepository; this.memberCardRepository = memberCardRepository;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
@@ -61,6 +73,93 @@ public class GroupCourseService implements IGroupCourseService {
this.stateMachine = stateMachine; this.stateMachine = stateMachine;
} }
@Override
public Mono<GroupCourseDetail> findDetailById(Long id) {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + id;
return redisUtil.get(cacheKey, String.class)
.flatMap(cachedJson -> {
if (cachedJson != null) {
try {
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
logger.info("缓存命中 - findDetailById: id={}", id);
return Mono.just(detail);
} catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty());
}
}
return Mono.empty();
})
.switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.flatMap(course -> {
// 查询类型信息
Long courseTypeId = course.getCourseType();
if (courseTypeId == null) {
// 没有类型,直接构建详情
return Mono.just(buildDetail(course, null));
}
// 有类型,查询类型信息
return groupCourseTypeRepository.findById(courseTypeId)
.flatMap(type -> {
// 查询标签
return courseLabelRepository.findByTypeId(type.getId())
.collectList()
.map(labels -> {
type.setLabels(labels);
return buildDetail(course, type);
});
})
.switchIfEmpty(Mono.just(buildDetail(course, null)));
})
.flatMap(detail -> {
try {
String jsonData = objectMapper.writeValueAsString(detail);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(detail)
.doOnSuccess(d -> logger.debug("缓存已设置 - findDetailById: id={}", id));
} catch (JsonProcessingException e) {
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
return Mono.just(detail);
}
})
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findDetailById: id={}", id))
);
}
/**
* 构建团课完整信息对象
*/
private GroupCourseDetail buildDetail(GroupCourse course, GroupCourseType type) {
GroupCourseDetail detail = new GroupCourseDetail();
detail.setId(course.getId());
detail.setCourseName(course.getCourseName());
detail.setCoachId(course.getCoachId());
detail.setCourseType(course.getCourseType());
detail.setStartTime(course.getStartTime());
detail.setEndTime(course.getEndTime());
detail.setMaxMembers(course.getMaxMembers());
detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation());
detail.setCoverImage(course.getCoverImage());
detail.setDescription(course.getDescription());
detail.setPointCardAmount(course.getPointCardAmount());
detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setCreatedAt(course.getCreatedAt());
detail.setUpdatedAt(course.getUpdatedAt());
// 设置类型信息
if (type != null) {
detail.setTypeInfo(type);
}
return detail;
}
@Override @Override
public Mono<GroupCourse> findById(Long id) { public Mono<GroupCourse> findById(Long id) {
String cacheKey = CACHE_KEY_ID_PREFIX + id; String cacheKey = CACHE_KEY_ID_PREFIX + id;
@@ -391,6 +490,7 @@ public class GroupCourseService implements IGroupCourseService {
private Mono<Void> clearCache() { private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*") return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")).then(); .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
} }
} }
@@ -0,0 +1,86 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.HashSet;
import java.util.Set;
@Service
public class GroupCourseTypeService implements IGroupCourseTypeService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
private final IGroupCourseTypeRepository groupCourseTypeRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeRepository.findById(id);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeRepository.findAll(false);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
return groupCourseTypeRepository.findAll(includeDeleted);
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
return groupCourseTypeRepository.findByKeyword(keyword);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
return groupCourseTypeRepository.findByCategory(category);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
return groupCourseTypeRepository.findByCategoryAndKeyword(category, keyword);
}
@Override
public Mono<GroupCourseType> create(GroupCourseType groupCourseType) {
return groupCourseTypeRepository.findByTypeName(groupCourseType.getTypeName())
.flatMap(existing -> Mono.<GroupCourseType>error(new RuntimeException("团课类型名称已存在")))
.switchIfEmpty(groupCourseTypeRepository.save(groupCourseType))
.doOnSuccess(type -> logger.info("团课类型创建成功 - id={}, name={}", type.getId(), type.getTypeName()))
.doOnError(error -> logger.error("团课类型创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType) {
return groupCourseTypeRepository.update(groupCourseType)
.doOnSuccess(type -> logger.info("团课类型更新成功 - id={}", id))
.doOnError(error -> logger.error("团课类型更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return groupCourseTypeRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Flux<String> findCategories() {
return groupCourseTypeRepository.findAll(false)
.map(GroupCourseType::getCategory)
.filter(category -> category != null && !category.isEmpty())
.distinct();
}
}
+223 -152
View File
File diff suppressed because one or more lines are too long
@@ -6,6 +6,8 @@ import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler;
import cn.novalon.gym.manage.file.handler.SysFileHandler; import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
@@ -69,6 +71,8 @@ public class SystemRouter {
MemberCardTransactionHandler memberCardTransactionHandler, MemberCardTransactionHandler memberCardTransactionHandler,
GroupCourseHandler groupCourseHandler, GroupCourseHandler groupCourseHandler,
GroupCourseBookingHandler groupCourseBookingHandler, GroupCourseBookingHandler groupCourseBookingHandler,
GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler) { DataStatisticsHandler dataStatisticsHandler) {
@@ -265,12 +269,28 @@ public class SystemRouter {
// ===== 团课课程管理 ===== // ===== 团课课程管理 =====
.GET("/api/groupCourse/list", groupCourseHandler::getAllGroupCourse) .GET("/api/groupCourse/list", groupCourseHandler::getAllGroupCourse)
.POST("/api/groupCourse/page", groupCourseHandler::getGroupCoursesByPage) .POST("/api/groupCourse/page", groupCourseHandler::getGroupCoursesByPage)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) // ===== 团课类型管理 =====
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse) .GET("/api/groupCourse/types", groupCourseTypeHandler::getAllGroupCourseTypes)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse) .GET("/api/groupCourse/types/search", groupCourseTypeHandler::searchGroupCourseTypes)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .GET("/api/groupCourse/types/categories", groupCourseTypeHandler::getCategories)
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn) .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
// ===== 团课标签管理 =====
.GET("/api/groupCourse/labels", courseLabelHandler::getAllLabels)
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
.POST("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::addLabelsToType)
.DELETE("/api/groupCourse/types/{typeId}/labels/{labelId}", courseLabelHandler::removeLabelFromType)
.DELETE("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::clearLabelsFromType)
// ===== 团课预约管理 ===== // ===== 团课预约管理 =====
.POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse) .POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse)
@@ -279,6 +299,15 @@ public class SystemRouter {
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId) .GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById) .GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
// ===== 团课课程管理(需要放在具体路由之后)=====
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn)
// ========= 签到模块路由 ========== // ========= 签到模块路由 ==========
// ===== 签到核心功能 ===== // ===== 签到核心功能 =====
.POST("/api/checkIn", checkInHandler::checkIn) .POST("/api/checkIn", checkInHandler::checkIn)
@@ -0,0 +1,76 @@
-- ============================================
-- 团课类型表
-- ============================================
-- 团课类型表
CREATE TABLE IF NOT EXISTS group_course_type (
id BIGSERIAL PRIMARY KEY,
type_name VARCHAR(100) NOT NULL,
base_difficulty INTEGER DEFAULT 1,
description TEXT,
category VARCHAR(50),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE group_course_type IS '团课类型表';
COMMENT ON COLUMN group_course_type.id IS '主键ID';
COMMENT ON COLUMN group_course_type.type_name IS '类型名称';
COMMENT ON COLUMN group_course_type.base_difficulty IS '基础难度(1-10';
COMMENT ON COLUMN group_course_type.description IS '类型描述';
COMMENT ON COLUMN group_course_type.category IS '分类(如:有氧、力量、柔韧等)';
COMMENT ON COLUMN group_course_type.create_by IS '创建人';
COMMENT ON COLUMN group_course_type.update_by IS '更新人';
COMMENT ON COLUMN group_course_type.created_at IS '创建时间';
COMMENT ON COLUMN group_course_type.updated_at IS '更新时间';
COMMENT ON COLUMN group_course_type.deleted_at IS '删除时间(软删除)';
-- 创建索引
CREATE INDEX idx_group_course_type_type_name ON group_course_type(type_name);
CREATE INDEX idx_group_course_type_category ON group_course_type(category);
-- 插入初始团课类型数据(参考exmp.txt)
INSERT INTO group_course_type (type_name, base_difficulty, description, category) VALUES
('慢走/椭圆机轻松模式', 1, '几乎无难度,适合所有人', '基础有氧与热身'),
('固定自行车(低阻力)', 2, '注意座椅高度调节即可', '基础有氧与热身'),
('跑步机慢跑', 3, '需要基本协调性,膝盖有压力', '基础有氧与热身'),
('跳绳(连续基础跳)', 3, '需要手脚配合,心肺要求明显', '基础有氧与热身'),
('坐姿腿屈伸/腿弯举', 2, '很容易找到发力感', '固定器械训练'),
('坐姿推胸机', 3, '需注意肩胛后收,避免耸肩', '固定器械训练'),
('高位下拉(坐姿)', 3, '需控制不要过度后仰', '固定器械训练'),
('史密斯机深蹲', 4, '轨迹固定,但需保持核心稳定', '固定器械训练'),
('蝴蝶机夹胸', 3, '易用肘关节代偿,需锁定肩关节', '固定器械训练'),
('平板支撑', 3, '耐力考验,技巧低', '自重基础动作'),
('跪姿俯卧撑', 3, '上肢力量较弱者首选', '自重基础动作'),
('标准俯卧撑', 5, '需核心收紧,身体成直线', '自重基础动作'),
('引体向上(弹力带辅助)', 6, '背部和手臂力量要求高', '自重基础动作'),
('标准引体向上', 8, '力量-体重比极高,多数男性无法完成1次', '自重基础动作'),
('徒手深蹲', 3, '注意膝盖方向与背部直立', '自重基础动作'),
('单腿深蹲(手枪蹲)', 8, '需要极高下肢力量、柔韧性和平衡', '自重基础动作'),
('哑铃二头弯举', 4, '容易晃动借力,但较安全', '自由重量杠铃/哑铃'),
('哑铃侧平举', 5, '极易用斜方肌代偿,真正练到三角肌中束很难', '自由重量杠铃/哑铃'),
('杠铃卧推', 7, '肩关节压力大,起桥、沉肩、稳定手腕均有技巧,有压伤风险', '自由重量杠铃/哑铃'),
('杠铃深蹲(颈后)', 8, '全身协调性、核心抗压、杠位放置、呼吸模式,学习曲线陡峭', '自由重量杠铃/哑铃'),
('传统硬拉', 9, '风险极高,需要精确的脊柱中立、髋铰链、背阔肌收紧,错误时伤腰', '自由重量杠铃/哑铃'),
('高翻/抓举(奥运举重)', 10, '需要爆发力、柔韧、精准衔接,非数月训练不能掌握', '自由重量杠铃/哑铃'),
('波比跳(标准版)', 6, '连续做时心肺压力极大', '高强度与爆发力'),
('冲刺跑(短跑)', 7, '对腘绳肌和脚踝爆发力要求高', '高强度与爆发力'),
('跳箱(合理高度)', 6, '需要落地缓冲技巧', '高强度与爆发力'),
('负重雪橇推', 6, '主要考验腿部耐力和意志力', '高强度与爆发力'),
('双力臂(引体向上后翻腕上杠)', 9, '需要爆发引体 + 极高相对力量', '高强度与爆发力'),
('静态拉伸(坐姿体前屈)', 2, '无风险,但需要坚持', '柔韧与平衡类'),
('瑜伽下犬式', 3, '常见,但需背部与手臂对齐', '柔韧与平衡类'),
('单腿罗马尼亚硬拉(徒手)', 6, '极考验平衡和髋稳定', '柔韧与平衡类'),
('全深蹲(脚跟贴地,亚洲蹲)', 5, '踝关节灵活度限制多数人', '柔韧与平衡类'),
('竖叉/横叉', 8, '需要数月甚至数年拉伸', '柔韧与平衡类'),
('瑜伽入门', 2, '适合初学者的瑜伽课程,注重基础体式', '柔韧与平衡类'),
('瑜伽进阶', 5, '针对有一定基础的学员,包含更复杂体式', '柔韧与平衡类'),
('普拉提', 4, '注重核心力量和身体控制', '柔韧与平衡类'),
('动感单车', 4, '高强度有氧运动,节奏感强', '基础有氧与热身'),
('搏击操', 5, '结合拳击动作的有氧运动', '高强度与爆发力'),
('HIIT训练', 7, '高强度间歇训练,对心肺要求极高', '高强度与爆发力'),
('核心训练', 4, '针对核心肌群的专项训练', '自重基础动作')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,73 @@
-- ============================================
-- 团课标签相关表
-- ============================================
-- 团课标签表
CREATE TABLE IF NOT EXISTS course_label (
id BIGSERIAL PRIMARY KEY,
label_name VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT '#1890ff',
description VARCHAR(200),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 团课类型-标签关联表
CREATE TABLE IF NOT EXISTS course_type_label (
id BIGSERIAL PRIMARY KEY,
type_id BIGINT NOT NULL,
label_id BIGINT NOT NULL,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (type_id, label_id)
);
COMMENT ON TABLE course_label IS '团课标签表';
COMMENT ON COLUMN course_label.id IS '主键ID';
COMMENT ON COLUMN course_label.label_name IS '标签名称';
COMMENT ON COLUMN course_label.color IS '标签颜色(十六进制)';
COMMENT ON COLUMN course_label.description IS '标签描述';
COMMENT ON COLUMN course_label.create_by IS '创建人';
COMMENT ON COLUMN course_label.update_by IS '更新人';
COMMENT ON COLUMN course_label.created_at IS '创建时间';
COMMENT ON COLUMN course_label.updated_at IS '更新时间';
COMMENT ON COLUMN course_label.deleted_at IS '删除时间(软删除)';
COMMENT ON TABLE course_type_label IS '团课类型-标签关联表';
COMMENT ON COLUMN course_type_label.id IS '主键ID';
COMMENT ON COLUMN course_type_label.type_id IS '团课类型ID';
COMMENT ON COLUMN course_type_label.label_id IS '标签ID';
COMMENT ON COLUMN course_type_label.create_by IS '创建人';
COMMENT ON COLUMN course_type_label.update_by IS '更新人';
COMMENT ON COLUMN course_type_label.created_at IS '创建时间';
COMMENT ON COLUMN course_type_label.updated_at IS '更新时间';
COMMENT ON COLUMN course_type_label.deleted_at IS '删除时间(软删除)';
-- 创建索引
CREATE INDEX idx_course_label_label_name ON course_label(label_name);
CREATE INDEX idx_course_type_label_type_id ON course_type_label(type_id);
CREATE INDEX idx_course_type_label_label_id ON course_type_label(label_id);
-- 插入初始标签数据
INSERT INTO course_label (label_name, color, description) VALUES
('适合新手', '#52c41a', '适合健身初学者'),
('中级过渡', '#faad14', '适合有一定基础的学员'),
('高级进阶', '#f5222d', '适合高级学员'),
('减脂塑形', '#722ed1', '有助于减脂塑形'),
('增肌强化', '#13c2c2', '有助于增肌强化'),
('柔韧性训练', '#eb2f96', '注重柔韧性提升'),
('核心训练', '#1890ff', '注重核心力量'),
('心肺训练', '#fa8c16', '提升心肺功能'),
('低冲击', '#52c41a', '低冲击运动,适合关节保护'),
('高强度', '#f5222d', '高强度间歇训练'),
('团体互动', '#722ed1', '注重团队协作'),
('私教推荐', '#13c2c2', '私教推荐课程'),
('热门课程', '#ff1493', '人气较高的课程'),
('新课上线', '#00ced1', '新上线的课程')
ON CONFLICT DO NOTHING;
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="gym.manage.uniapp">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<application>
<activity
android:name="com.mobile.auth.gatewayauth.LoginAuthActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
<activity
android:name="com.mobile.auth.gatewayauth.activity.AuthWebVeiwActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind" />
<activity
android:name="com.mobile.auth.gatewayauth.PrivacyDialogActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
</application>
</manifest>
+72 -64
View File
@@ -1,77 +1,85 @@
<template> <template>
<view> <view>
<GlobalLoading /> <GlobalLoading />
</view> </view>
</template> </template>
<script>
import GlobalLoading from '@/components/global/GlobalLoading.vue' <script setup>
export default { import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch: function() { import GlobalLoading from '@/components/global/GlobalLoading.vue'
console.log('App Launch')
this.preloadTabData() // 隐藏原生 TabBar - 这里是核心修复
const hideNativeTabBar = () => {
// 尝试多次执行,确保执行成功
const tryHide = (times = 0) => {
if (times > 10) return // 最多尝试10次
uni.hideTabBar({
animation: false,
success: () => {
console.log('✅ 原生TabBar隐藏成功')
// 强制 CSS 覆盖(双重保险)
forceCSSHide()
}, },
onShow: function() { fail: (err) => {
console.log('App Show') console.log(`❌ 第${times+1}次隐藏失败,1秒后重试`, err)
}, setTimeout(() => tryHide(times + 1), 1000)
onHide: function() { }
console.log('App Hide') })
}, }
methods: {
// 预加载所有 Tab 页面的核心数据 // 延迟 300ms 执行,确保页面挂载完成
preloadTabData() { setTimeout(() => tryHide(), 300)
}
// 强制 CSS 覆盖(最终保险)
const forceCSSHide = () => {
// #ifdef APP-PLUS
const style = document.createElement('style')
style.innerHTML = `
uni-tabbar,
uni-tabbar .uni-tabbar,
.uni-tabbar,
uni-tabbar > .uni-tabbar,
[class*="uni-tabbar"] {
display: none !important;
height: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
`
document.head.appendChild(style)
console.log('✅ CSS 强制覆盖已注入')
// #endif
}
// 预加载所有 Tab 页面的核心数据
const preloadTabData = () => {
// 延迟执行,不阻塞首屏 // 延迟执行,不阻塞首屏
setTimeout(() => { setTimeout(() => {
// 预加载课程数据 // 预加载课程数据等...
// #ifdef MP-WEIXIN
// 小程序端预请求数据
// #endif
// 预加载训练数据
}, 1000) }, 1000)
}
}
} }
onLaunch(() => {
console.log('App Launch')
preloadTabData()
})
onShow(() => {
console.log('App Show')
// #ifdef APP-PLUS
hideNativeTabBar()
// #endif
})
onHide(() => {
console.log('App Hide')
})
</script> </script>
<style lang="scss"> <style lang="scss">
@import 'common/style/base.css'; /* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni.scss";
/* 全局骨架屏样式 */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 页面切换动画 */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateX(30rpx);
}
.page-leave-to {
opacity: 0;
transform: translateX(-30rpx);
}
.app-container {
width: 100%;
min-height: 100vh;
max-width: 430px;
margin: 0 auto;
background-color: var(--bg-light);
position: relative;
overflow-x: hidden;
}
</style> </style>
+132
View File
@@ -0,0 +1,132 @@
import request from "@/utils/request.js"
export function getGroupCourseList(params = {}, options = {}) {
return request.get('/groupCourse/list', params, options)
}
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
export function getGroupCourseById(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}`, {}, options)
}
export function getGroupCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}/detail`, {}, options)
}
export function createGroupCourse(params) {
return request.post('/groupCourse', params)
}
export function updateGroupCourse(id, params) {
return request.put(`/groupCourse/${id}`, params)
}
export function cancelGroupCourse(id) {
return request.post(`/groupCourse/${id}/cancel`)
}
export function deleteGroupCourse(id) {
return request.delete(`/groupCourse/${id}`)
}
export function getGroupCourseTypes(params = {}, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/groupCourse/types', params, options)
}
export function getGroupCourseTypeById(id, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${id}`, {}, options)
}
export function getTypeLabels(typeId, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${typeId}/labels`, {}, options)
}
export function searchGroupCourse(params = {}, options = {}) {
const {
courseName,
courseType,
startDate,
endDate,
timePeriod,
priceSort,
remainingMost,
isRecurring,
page = 0,
size = 10
} = params
const requestBody = { page, size }
if (courseName) requestBody.courseName = courseName
if (courseType) requestBody.courseType = courseType
if (startDate) requestBody.startDate = formatDateTime(startDate)
if (endDate) requestBody.endDate = formatDateTime(endDate, true)
if (timePeriod) requestBody.timePeriod = timePeriod
if (priceSort) requestBody.priceSort = priceSort
if (remainingMost !== undefined) requestBody.remainingMost = remainingMost
if (isRecurring !== undefined) requestBody.isRecurring = isRecurring
return request.post('/groupCourse/search', requestBody, options)
}
function formatDateTime(dateStr, isEnd = false) {
if (!dateStr) return dateStr
if (dateStr.includes('T')) return dateStr
return isEnd
? `${dateStr}T23:59:59`
: `${dateStr}T00:00:00`
}
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
export function cancelBooking(bookingId, params) {
return request.post(`/groupCourse/booking/${bookingId}/cancel`, params)
}
export function getMemberBookings(memberId, options = {}) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
export function getActiveRecommendCourses(options = { cache: false }) {
return request.get('/groupCourse/recommend/active', {}, options)
}
export function getGroupCourseRecommendList(params = {}, options = { cache: false }) {
return request.get('/groupCourse/recommend/list', params, options)
}
/**
* 扫码签到
* 扫描团课二维码后签到,将预约状态更新为已出席
* @param {number} courseId - 团课ID
* @param {number} memberId - 会员ID
*/
export function qrSignInGroupCourse(courseId, memberId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
export default {
getGroupCourseList,
getGroupCoursePage,
searchGroupCourse,
getGroupCourseById,
getGroupCourseDetail,
createGroupCourse,
updateGroupCourse,
cancelGroupCourse,
deleteGroupCourse,
getGroupCourseTypes,
getGroupCourseTypeById,
getTypeLabels,
bookGroupCourse,
cancelBooking,
getMemberBookings,
getActiveRecommendCourses,
getGroupCourseRecommendList
}
+246 -2
View File
@@ -1,50 +1,294 @@
import request from "@/utils/request.js" import request from "@/utils/request.js"
/**
* 微信小程序登录
* @param {object} params - 登录参数 { code: string }
*/
export function login(params) { export function login(params) {
return request.post('/member/auth/miniapp/login', params) return request.post('/member/auth/miniapp/login', params)
} }
/**
* 发送短信验证码
* @param {object} params - { phone: string }
*/
export function sendCode(params) {
return request.post('/auth/phone/send-code', params)
}
/**
* 手机号验证码登录
* @param {object} params - { phone: string, code: string }
*/
export function loginWithPhone(params) {
return request.post('/auth/phone/code-login', params)
}
/**
* 手机号一键登录
* @param {object} params - { accessToken: string, openid: string, nickname?: string, avatar?: string }
*/
export function oneClickLogin(params) {
return request.post('/auth/phone/one-click-login', params)
}
/**
* 退出登录
*/
export function logout() { export function logout() {
return request.post('/member/auth/logout') return request.post('/member/auth/logout')
} }
/**
* 获取签到二维码
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getQRCode(options = { cache: true, cacheTime: 5 * 60 * 1000 }) { export function getQRCode(options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get('/checkIn/qrcode', {}, options) return request.get('/checkIn/qrcode', {}, options)
} }
/**
* 扫码签到
* @param {object} params - { qrcode: string, memberId: number }
*/
export function checkIn(params) { export function checkIn(params) {
return request.post('/checkIn/scan', params) return request.post('/checkIn', params)
} }
/**
* 获取签到记录(分页)
* @param {object} params - { page: number, size: number }
* @param {object} options - 请求选项
*/
export function getCheckInRecords(params = {}, options = {}) {
const { page = 0, size = 5 } = params
return request.post('/checkIn/page', { page, size }, options)
}
/**
* 获取用户未读消息数量
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
*/
export function getUnreadMessageCount(userId, options = {}) {
return request.get(`/messages/user/${userId}/unread`, {}, options)
}
// ========== 消息相关API ==========
/**
* 获取用户消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 消息列表
*/
export function getUserMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/page`, { page, size }, options)
}
/**
* 获取未读消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 未读消息列表
*/
export function getUnreadMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/unread/page`, { page, size }, options)
}
/**
* 标记消息为已读
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markMessageAsRead(id, options = {}) {
return request.put(`/messages/${id}/read`, {}, options)
}
/**
* 标记所有消息为已读
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markAllMessagesAsRead(userId, options = {}) {
return request.put(`/messages/user/${userId}/read`, {}, options)
}
/**
* 删除消息
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function deleteMessage(id, options = {}) {
return request.delete(`/messages/${id}`, {}, options)
}
/**
* 获取用户信息(基础信息缓存)
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getUserInfo(options = { cache: true, cacheTime: 30 * 60 * 1000 }) { export function getUserInfo(options = { cache: true, cacheTime: 30 * 60 * 1000 }) {
return request.get('/member/info', {}, options) return request.get('/member/info', {}, options)
} }
/**
* 获取会员详细信息(包含会员卡、积分等)
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getMemberDetail(options = { cache: false }) {
return request.get('/member/info', {}, options)
}
/**
* 获取签到统计
* @param {object} params - 查询参数 { startDate: string, endDate: string }
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getCheckInStats(params = {}, options = { cache: false }) {
return request.get('/checkIn/statistics', params, options)
}
/**
* 更新用户信息
* @param {object} params - 用户信息参数
*/
export function updateUserInfo(params) { export function updateUserInfo(params) {
return request.put('/member/info', params) return request.put('/member/info', params)
} }
// ========== 系统配置相关API ==========
/**
* 根据配置键获取配置值
* @param {string} configKey - 配置键
* @param {object} options - 请求选项
*/
export function getConfigByKey(configKey, options = {}) {
return request.get(`/config/key/${configKey}`, {}, options)
}
// ========== 课程相关API ==========
/**
* 获取推荐课程列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getRecommendCourses(options = { cache: true, cacheTime: 10 * 60 * 1000 }) { export function getRecommendCourses(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/course/recommend', {}, options) return request.get('/course/recommend', {}, options)
} }
/**
* 获取课程详情
* @param {number} id - 课程ID
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) { export function getCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/course/${id}`, {}, options) return request.get(`/course/${id}`, {}, options)
} }
/**
* 获取团课列表(分页)
* @param {object} params - 查询参数 { page: number, size: number, sort: string, order: string, keyword: string }
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) { export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options) return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
} }
// ========== 团课预约相关API ==========
/**
* 预约团课
* @param {object} params - 预约参数
* @param {number} params.courseId - 团课ID
* @param {number} params.memberId - 会员ID
*/
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
/**
* 取消预约
* @param {number} bookingId - 预约ID
*/
export function cancelBooking(bookingId) {
return request.delete(`/groupCourse/book/${bookingId}`, {})
}
/**
* 团课签到
* @param {number} memberId - 会员ID
* @param {number} courseId - 团课ID
*/
export function signinGroupCourse(memberId, courseId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
/**
* 查询会员预约记录
* @param {number} memberId - 会员ID
*/
export function getMemberBookings(memberId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
/**
* 查询课程预约记录
* @param {number} courseId - 团课ID
*/
export function getCourseBookings(courseId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/course/${courseId}`, {}, options)
}
// ========== 轮播图相关API ==========
/**
* 获取启用的轮播图列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getActiveBanners(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/banner/active', {}, options)
}
export default { export default {
login, login,
sendCode,
loginWithPhone,
oneClickLogin,
logout, logout,
getQRCode, getQRCode,
checkIn, checkIn,
getCheckInRecords,
getCheckInStats,
getUnreadMessageCount,
getUserMessages,
getUnreadMessages,
markMessageAsRead,
markAllMessagesAsRead,
deleteMessage,
getUserInfo, getUserInfo,
getMemberDetail,
updateUserInfo, updateUserInfo,
getConfigByKey,
getRecommendCourses, getRecommendCourses,
getCourseDetail, getCourseDetail,
getGroupCoursePage getGroupCoursePage,
bookGroupCourse,
cancelBooking,
signinGroupCourse,
getMemberBookings,
getCourseBookings,
getActiveBanners
} }
+9 -10
View File
@@ -4,11 +4,8 @@
export const PAGE = { export const PAGE = {
INDEX: '/pages/index/index', INDEX: '/pages/index/index',
COURSE: '/pages/course/index', COURSE: '/pages/course/index',
TRAIN: '/pages/train/index',
DISCOVER: '/pages/discover/index',
MEMBER: '/pages/memberInfo/memberInfo', MEMBER: '/pages/memberInfo/memberInfo',
BOOKING: '/pages/memberInfo/booking', BOOKING: '/pages/memberInfo/booking',
MEMBER_CARD: '/pages/memberInfo/memberCard',
USER_INFO: '/pages/memberInfo/userInfo', USER_INFO: '/pages/memberInfo/userInfo',
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome', BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect', BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
@@ -39,8 +36,6 @@ export const PAGE = {
export const TAB_ROUTES = [ export const TAB_ROUTES = [
PAGE.INDEX, PAGE.INDEX,
PAGE.COURSE, PAGE.COURSE,
PAGE.TRAIN,
PAGE.DISCOVER,
PAGE.MEMBER PAGE.MEMBER
] ]
@@ -81,6 +76,7 @@ export function navigateToPage(url) {
// 这种情况应该使用 switchToTabPage(会清空页面栈) // 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) { if (TAB_PAGES.has(path)) {
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage') console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
uni.hideLoading()
switchToTabPage(path) switchToTabPage(path)
return return
} }
@@ -91,6 +87,7 @@ export function navigateToPage(url) {
url, url,
fail: (err) => { fail: (err) => {
console.error('[navigateTo]', url, err) console.error('[navigateTo]', url, err)
uni.hideLoading()
// 页面栈满时降级使用 redirectTo // 页面栈满时降级使用 redirectTo
if (err.errMsg && err.errMsg.includes('limit')) { if (err.errMsg && err.errMsg.includes('limit')) {
uni.redirectTo({ url }) uni.redirectTo({ url })
@@ -98,10 +95,10 @@ export function navigateToPage(url) {
uni.showToast({ title: '页面跳转失败', icon: 'none' }) uni.showToast({ title: '页面跳转失败', icon: 'none' })
} }
}, },
success: () => { complete: () => {
setTimeout(() => { // 页面已发起跳转,隐藏 loading
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
uni.hideLoading() uni.hideLoading()
},3000)
} }
}) })
} }
@@ -123,19 +120,21 @@ export function switchToTabPage(url) {
console.log('[switchToTabPage] 跳转到 TabBar:', path) console.log('[switchToTabPage] 跳转到 TabBar:', path)
tabNavigating = true tabNavigating = true
uni.switchTab({ // ✅ 改用 switchTab,而不是 reLaunch uni.switchTab({
url: path, url: path,
complete: () => { complete: () => {
uni.hideLoading()
setTimeout(() => { setTimeout(() => {
tabNavigating = false tabNavigating = false
}, 320) }, 320)
}, },
fail: (err) => { fail: (err) => {
console.error('[switchTab]', path, err) console.error('[switchTab]', path, err)
// 降级使用 reLaunch uni.hideLoading()
uni.reLaunch({ uni.reLaunch({
url: path, url: path,
complete: () => { complete: () => {
uni.hideLoading()
setTimeout(() => { setTimeout(() => {
tabNavigating = false tabNavigating = false
}, 320) }, 320)
@@ -0,0 +1,247 @@
/**
* 登录拦截Hook
* 用于在需要登录的操作前检查用户登录状态,未登录则弹出登录框
*
* 使用方式:
* import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
*
* const { checkLogin, requireLogin } = useLoginGuard()
*
* // 方式1:检查登录状态(返回布尔值)
* if (!checkLogin()) return
*
* // 方式2:需要登录才执行(推荐)
* await requireLogin()
* // 之后的代码只有在登录后才执行
*
* // 方式3:在点击事件中使用
* async function handleClick() {
* await requireLogin()
* // 执行需要登录的操作
* }
*/
import { getToken } from '@/utils/request.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
// 保存当前页面的登录弹窗引用
let loginModalRef = null
/**
* 注册登录弹窗组件
* 在App.vue或页面onMounted中调用
* @param {Object} modalRef - loginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 获取当前登录状态
* @returns {Boolean}
*/
export function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 获取登录用户信息
* @returns {Object|null}
*/
export function getLoginUser() {
const store = loadMemberStore()
return store.profile || null
}
/**
* 使用登录拦截Hook
* @returns {Object}
*/
export function useLoginGuard() {
/**
* 检查登录状态(同步)
* @returns {Boolean} 是否已登录
*/
function checkLogin() {
return isLoggedIn()
}
/**
* 需要登录才继续执行(异步)
* 如果未登录,弹出登录框,等待用户登录或取消
* @param {Object} options - 配置选项
* @param {String} options.title - 弹窗标题
* @param {String} options.subtitle - 弹窗副标题
* @param {Function} options.onSuccess - 登录成功回调
* @param {Function} options.onCancel - 取消登录回调
* @returns {Promise<Boolean>} 是否已登录(用户登录成功返回true,取消返回false)
*/
async function requireLogin(options = {}) {
// 如果已经登录,直接返回true
if (isLoggedIn()) {
return true
}
// 如果传入了onSuccess回调,先检查
if (options.onSuccess) {
const store = loadMemberStore()
if (store.profile?.id) {
options.onSuccess(store.profile)
return true
}
}
// 显示登录弹窗
return new Promise((resolve) => {
if (!loginModalRef) {
console.warn('[useLoginGuard] 登录弹窗未注册,请先调用 registerLoginModal')
// 尝试跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login',
fail: () => {
uni.showToast({
title: '请先登录',
icon: 'none'
})
}
})
resolve(false)
return
}
// 保存resolve函数
let resolvePromise = resolve
// 显示登录弹窗
loginModalRef.show()
// 监听登录成功
const loginSuccessHandler = (result) => {
if (options.onSuccess) {
options.onSuccess(result)
}
resolvePromise(true)
}
// 监听登录失败/取消
const closeHandler = () => {
if (options.onCancel) {
options.onCancel()
}
resolvePromise(false)
}
// 注册事件监听
uni.$once('loginModal:success', loginSuccessHandler)
uni.$once('loginModal:close', closeHandler)
// 设置超时
setTimeout(() => {
uni.$off('loginModal:success', loginSuccessHandler)
uni.$off('loginModal:close', closeHandler)
}, 60000) // 60秒超时
})
}
/**
* 安全执行函数(仅登录后执行)
* @param {Function} fn - 要执行的函数
* @param {Object} options - 配置选项
* @returns {Promise<any>} 函数返回值或null
*/
async function safeExecute(fn, options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof fn === 'function') {
return await fn()
}
return null
}
/**
* 包装需要登录的API调用
* @param {Function} apiFn - API函数
* @param {Array} args - API参数
* @param {Object} options - 配置选项
* @returns {Promise<any>}
*/
async function callLoggedInApi(apiFn, args = [], options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof apiFn === 'function') {
return await apiFn(...args)
}
throw new Error('用户未登录')
}
return {
checkLogin,
requireLogin,
safeExecute,
callLoggedInApi,
isLoggedIn,
getLoginUser
}
}
/**
* 创建登录拦截Mixin(用于选项式API)
* @returns {Object}
*/
export function loginGuardMixin() {
return {
data() {
return {
isLogin: false
}
},
onLoad() {
this.checkLoginStatus()
},
onShow() {
this.checkLoginStatus()
},
methods: {
checkLoginStatus() {
this.isLogin = isLoggedIn()
},
async ensureLogin() {
if (!this.isLogin) {
if (!loginModalRef) {
uni.navigateTo({
url: '/pages/memberInfo/login'
})
return false
}
loginModalRef.show()
return false
}
return true
}
}
}
}
/**
* 路由守卫配置
* 配置需要登录的页面路径
*/
export const protectedRoutes = [
'/pages/memberInfo/memberInfo',
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo'
]
/**
* 检查当前页面是否需要登录
* @param {String} currentPage - 当前页面路径
* @returns {Boolean}
*/
export function isProtectedRoute(currentPage) {
return protectedRoutes.some(route => currentPage.includes(route))
}
@@ -1,4 +1,4 @@
const COLORS = { const COLORS = {
primary: '#0B2B4B', primary: '#0B2B4B',
accent: '#FF6B35', accent: '#FF6B35',
accentLight: 'rgba(255, 107, 53, 0.25)', accentLight: 'rgba(255, 107, 53, 0.25)',
@@ -1,10 +1,4 @@
import { bodyTestMock } from './mockData.js' function formatRecordTime(date) {
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
function formatRecordTime(date) {
const y = date.getFullYear() const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0') const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0')
@@ -28,9 +22,9 @@ function formatTime(date) {
export function getDefaultBodyTestState() { export function getDefaultBodyTestState() {
return { return {
settings: { ...bodyTestMock.settings }, settings: {},
device: { ...bodyTestMock.device }, device: { connected: false, battery: 80 },
records: clone(bodyTestMock.records) records: []
} }
} }
@@ -167,14 +161,26 @@ export function getBodyTestTrendData(store, metricKey, limit = 6) {
})) }))
} }
function getMetricDefs() {
return [
{ key: 'weight', label: '体重' },
{ key: 'bmi', label: 'BMI' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'visceralFat', label: '内脏脂肪' },
{ key: 'bmr', label: '基础代谢' }
]
}
export function getCompareData(store, idA, idB) { export function getCompareData(store, idA, idB) {
const a = getBodyTestRecordById(store, idA) const a = getBodyTestRecordById(store, idA)
const b = getBodyTestRecordById(store, idB) const b = getBodyTestRecordById(store, idB)
if (!a || !b) return null if (!a || !b) return null
const metricDefs = getMetricDefs()
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr'] const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
const metrics = keys.map((key) => ({ const metrics = keys.map((key) => ({
key, key,
label: bodyTestMock.metricDefs.find((m) => m.key === key)?.label || key, label: metricDefs.find((m) => m.key === key)?.label || key,
valueA: a.metrics[key], valueA: a.metrics[key],
valueB: b.metrics[key], valueB: b.metrics[key],
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10 diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
@@ -183,8 +189,7 @@ export function getCompareData(store, idA, idB) {
} }
export function getRecommendedCourses(record) { export function getRecommendedCourses(record) {
const ids = record?.recommendedCourseIds || [] return []
return bodyTestMock.recommendedCourses.filter((c) => ids.includes(c.id))
} }
export function updateBodyTestSettings(store, patch) { export function updateBodyTestSettings(store, patch) {
@@ -243,9 +248,9 @@ export function saveSimulatedBodyTestRecord(store, finalMetrics) {
status, status,
metrics, metrics,
radar, radar,
bodySegments: clone(bodyTestMock.records[0].bodySegments), bodySegments: [],
advice: clone(bodyTestMock.records[0].advice), advice: [],
recommendedCourseIds: [1, 2] recommendedCourseIds: []
} }
if (previous) { if (previous) {
@@ -279,5 +284,3 @@ export function interpolateMeasuringMetrics(progress, profile) {
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10 bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
} }
} }
export { bodyTestMock }
@@ -1,4 +1,4 @@
import { courseCatalogMock } from './mockData.js' import { courseCatalogMock } from './mockData.js'
function clone(value) { function clone(value) {
return JSON.parse(JSON.stringify(value)) return JSON.parse(JSON.stringify(value))
@@ -57,6 +57,14 @@ export function canCancelBooking(item) {
return diff >= 2 * 3600000 return diff >= 2 * 3600000
} }
export function canSigninCourse(item) {
if (!item?.courseDate || !item?.startTime) return false
if (item.status !== 'booked' && item.status !== 0) return false
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
const diff = start - Date.now()
return diff <= 2 * 3600000 && diff >= -2 * 3600000
}
export function bookCourse(store, courseId) { export function bookCourse(store, courseId) {
const course = store.courseCatalog.find((c) => c.id === Number(courseId)) const course = store.courseCatalog.find((c) => c.id === Number(courseId))
if (!course) return { ok: false, message: '课程不存在' } if (!course) return { ok: false, message: '课程不存在' }
@@ -1,4 +1,4 @@
/** 手机号展示脱敏(中间四位 ****) */ /** 手机号展示脱敏(中间四位 ****) */
export function maskPhone(phone) { export function maskPhone(phone) {
if (phone == null || phone === '') return '' if (phone == null || phone === '') return ''
+8 -9
View File
@@ -1,5 +1,4 @@
export { memberCenterMock, userInfoMock, fitnessGoalOptions, bookingMock, memberCardMock, bodyTestMock, moduleMock, courseCatalogMock } from './mockData.js' export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export { export {
loadMemberStore, loadMemberStore,
saveMemberStore, saveMemberStore,
@@ -13,7 +12,10 @@ export {
cancelOngoingBooking, cancelOngoingBooking,
renewMemberCard, renewMemberCard,
parseLocalDate, parseLocalDate,
saveUserProfile saveUserProfile,
getCurrentMemberId,
getLoginMemberInfo,
getToken
} from './store.js' } from './store.js'
export { export {
getLatestBodyTestRecord, getLatestBodyTestRecord,
@@ -31,8 +33,7 @@ export {
connectBodyTestDevice, connectBodyTestDevice,
disconnectBodyTestDevice, disconnectBodyTestDevice,
saveSimulatedBodyTestRecord, saveSimulatedBodyTestRecord,
interpolateMeasuringMetrics, interpolateMeasuringMetrics
bodyTestMock
} from './bodyTestStore.js' } from './bodyTestStore.js'
export { export {
getTrainingReportData, getTrainingReportData,
@@ -52,8 +53,7 @@ export {
getMyCoursesByTab, getMyCoursesByTab,
getOnlineCourseById, getOnlineCourseById,
updateOnlineProgress, updateOnlineProgress,
getCheckInHistory, getCheckInHistory
moduleMock
} from './moduleStore.js' } from './moduleStore.js'
export { export {
filterCourses, filterCourses,
@@ -61,8 +61,7 @@ export {
bookCourse, bookCourse,
canCancelBooking, canCancelBooking,
enrichCourseForDisplay, enrichCourseForDisplay,
getWeekDates, getWeekDates
courseCatalogMock
} from './bookingStore.js' } from './bookingStore.js'
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js' export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js' export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
+1 -1
View File
@@ -1,4 +1,4 @@
/** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */ /** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */
function buildStaticPathCandidates(url) { function buildStaticPathCandidates(url) {
const list = [url] const list = [url]
@@ -1,4 +1,4 @@
import { backToMemberCenter } from '../constants/routes.js' import { backToMemberCenter } from '../constants/routes.js'
/** 状态栏时间(Pixso 顶栏占位) */ /** 状态栏时间(Pixso 顶栏占位) */
export const statusBarTimeMixin = { export const statusBarTimeMixin = {
@@ -1,6 +1,9 @@
/** 个人中心模块 mock 数据(后续可替换为 API) */ /** 个人中心模块 mock 数据(后续可替换为 API) */
export const memberCenterMock = { // Mock 数据开关 - 设为 false 可关闭所有 mock 数据
export const MOCK_ENABLED = false
export const memberCenterMock = MOCK_ENABLED ? {
userInfo: { userInfo: {
name: '张小芳', name: '张小芳',
phone: '13812345678 已绑定微信', phone: '13812345678 已绑定微信',
@@ -65,7 +68,7 @@ export const memberCenterMock = {
registered: 3, registered: 3,
purchased: 2 purchased: 2
} }
} } : null
export const userInfoMock = { export const userInfoMock = {
name: '张小芳', name: '张小芳',
@@ -1,4 +1,4 @@
import { moduleMock } from './mockData.js' import { moduleMock } from './mockData.js'
+94 -50
View File
@@ -1,11 +1,4 @@
import { import {
memberCenterMock,
userInfoMock,
memberCardMock,
bookingMock
} from './mockData.js'
import { formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
import {
getDefaultBodyTestState, getDefaultBodyTestState,
mergeBodyTestState, mergeBodyTestState,
getLatestBodyTestRecord, getLatestBodyTestRecord,
@@ -16,16 +9,34 @@ import {
mergeModuleState, mergeModuleState,
finalizeModules finalizeModules
} from './moduleStore.js' } from './moduleStore.js'
import { import { getDefaultCourseCatalog,
getDefaultCourseCatalog,
mergeCourseCatalog, mergeCourseCatalog,
canCancelBooking canCancelBooking
} from './bookingStore.js' } from './bookingStore.js'
import { getMemberId as requestGetMemberId } from '@/utils/request.js'
const STORAGE_KEY = 'gym_member_info_v1' const STORAGE_KEY = 'gym_member_info_v1'
function clone(value) { /**
return JSON.parse(JSON.stringify(value)) * 获取当前登录会员ID
* @deprecated 请使用 @/utils/request.js 中的 getMemberId
*/
export function getCurrentMemberId() {
return requestGetMemberId()
}
/**
* 获取登录信息
*/
export function getLoginMemberInfo() {
return uni.getStorageSync('loginMemberInfo') || null
}
/**
* 获取token
*/
export function getToken() {
return uni.getStorageSync('token') || null
} }
export function buildCardTip(remainingDays) { export function buildCardTip(remainingDays) {
@@ -43,7 +54,9 @@ function applyCardInfo(store) {
export function syncStats(store) { export function syncStats(store) {
store.stats = { store.stats = {
...store.stats, ...store.stats,
pointsBalance: store.stats.pointsBalance ?? 1250 checkInCount: store.stats.checkInCount ?? 0,
courseCount: store.stats.courseCount ?? 0,
pointsBalance: store.stats.pointsBalance ?? 0
} }
return store return store
} }
@@ -51,12 +64,6 @@ export function syncStats(store) {
function finalizeStore(store) { function finalizeStore(store) {
syncStats(store) syncStats(store)
applyCardInfo(store) applyCardInfo(store)
if (store.profile?.avatar) {
store.memberProfile.avatar = store.profile.avatar
}
if (store.profile?.phone) {
store.memberProfile.phone = formatMemberCenterPhone(store.profile.phone)
}
const latestBodyTest = getLatestBodyTestRecord(store) const latestBodyTest = getLatestBodyTestRecord(store)
if (latestBodyTest) { if (latestBodyTest) {
const previous = store.bodyTest.records[1] const previous = store.bodyTest.records[1]
@@ -70,32 +77,36 @@ function finalizeStore(store) {
function getDefaultStore() { function getDefaultStore() {
return finalizeStore({ return finalizeStore({
profile: { ...userInfoMock, avatar: memberCenterMock.userInfo.avatar }, stats: {},
memberProfile: { ...memberCenterMock.userInfo }, cardInfo: {},
stats: { ...memberCenterMock.stats }, card: {},
cardInfo: { ...memberCenterMock.cardInfo }, cards: [],
card: { ...memberCardMock.card }, records: [],
records: clone(memberCardMock.records), ongoingBookings: [],
ongoingBookings: clone(bookingMock.ongoing), historyBookings: [],
historyBookings: clone(bookingMock.history), checkIns: [],
checkIns: clone(memberCenterMock.checkIns), bodyReport: {},
bodyReport: { ...memberCenterMock.bodyReport },
bodyTest: getDefaultBodyTestState(), bodyTest: getDefaultBodyTestState(),
modules: getDefaultModuleState(), modules: getDefaultModuleState(),
courseCatalog: getDefaultCourseCatalog(), courseCatalog: getDefaultCourseCatalog(),
couponPoints: { ...memberCenterMock.couponPoints }, couponPoints: {},
referral: { ...memberCenterMock.referral } referral: {},
storedCard: {
balance: 0,
zeroVerified: false
}
}) })
} }
export { getDefaultStore }
function mergeDefaults(saved) { function mergeDefaults(saved) {
const defaults = getDefaultStore() const defaults = getDefaultStore()
return finalizeStore({ return finalizeStore({
profile: { ...defaults.profile, ...(saved.profile || {}) },
memberProfile: { ...defaults.memberProfile, ...(saved.memberProfile || {}) },
stats: { ...defaults.stats, ...(saved.stats || {}) }, stats: { ...defaults.stats, ...(saved.stats || {}) },
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) }, cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
card: { ...defaults.card, ...(saved.card || {}) }, card: { ...defaults.card, ...(saved.card || {}) },
cards: saved.cards?.length ? saved.cards : defaults.cards,
records: saved.records?.length ? saved.records : defaults.records, records: saved.records?.length ? saved.records : defaults.records,
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings, ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
historyBookings: saved.historyBookings ?? defaults.historyBookings, historyBookings: saved.historyBookings ?? defaults.historyBookings,
@@ -105,7 +116,8 @@ function mergeDefaults(saved) {
modules: mergeModuleState(saved.modules), modules: mergeModuleState(saved.modules),
courseCatalog: mergeCourseCatalog(saved.courseCatalog), courseCatalog: mergeCourseCatalog(saved.courseCatalog),
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) }, couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
referral: { ...defaults.referral, ...(saved.referral || {}) } referral: { ...defaults.referral, ...(saved.referral || {}) },
storedCard: { ...defaults.storedCard, ...(saved.storedCard || {}) }
}) })
} }
@@ -201,17 +213,33 @@ export function getBookingPreview(store, limit = 2) {
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem) return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
} }
export function getExpiringCards(store, daysThreshold = 30) {
if (!store.cards || store.cards.length === 0) {
return []
}
return store.cards.filter(card => {
const remainingDays = computeRemainingDays(card.validityEnd || card.expireTime)
return remainingDays > 0 && remainingDays <= daysThreshold
}).sort((a, b) => {
const daysA = computeRemainingDays(a.validityEnd || a.expireTime)
const daysB = computeRemainingDays(b.validityEnd || b.expireTime)
return daysA - daysB
})
}
export function getCenterPageData(store) { export function getCenterPageData(store) {
const expiringCards = getExpiringCards(store, 30)
return { return {
userInfo: { ...store.memberProfile }, stats: {
stats: { ...store.stats }, checkInCount: store.stats?.checkInCount ?? 0,
courseCount: store.stats?.courseCount ?? 0,
pointsBalance: store.stats?.pointsBalance ?? 0
},
cardInfo: { ...store.cardInfo }, cardInfo: { ...store.cardInfo },
expiringCards: expiringCards,
bookingPreview: getBookingPreview(store), bookingPreview: getBookingPreview(store),
checkIns: store.checkIns.map((item) => ({ ...item })), checkIns: store.checkIns.map((item) => ({ ...item })),
bodyReport: { bodyReport: { ...store.bodyReport },
...store.bodyReport,
weight: store.profile.weight || store.bodyReport.weight
},
couponPoints: { couponPoints: {
...store.couponPoints, ...store.couponPoints,
points: store.stats.pointsBalance points: store.stats.pointsBalance
@@ -234,6 +262,15 @@ export function cancelOngoingBooking(store, id) {
const course = store.courseCatalog.find((c) => c.id === removed.courseId) const course = store.courseCatalog.find((c) => c.id === removed.courseId)
if (course && course.enrolled > 0) course.enrolled -= 1 if (course && course.enrolled > 0) course.enrolled -= 1
} }
// 同步从 myCourses.group.ongoing 中移除
if (store.modules?.myCourses?.group?.ongoing) {
const myCoursesIndex = store.modules.myCourses.group.ongoing.findIndex((c) => c.id === id)
if (myCoursesIndex >= 0) {
store.modules.myCourses.group.ongoing.splice(myCoursesIndex, 1)
}
}
store.historyBookings.unshift({ store.historyBookings.unshift({
...removed, ...removed,
status: 'cancelled', status: 'cancelled',
@@ -285,16 +322,9 @@ export function renewMemberCard(store, addDays = 90) {
} }
export function saveUserProfile(store, profile) { export function saveUserProfile(store, profile) {
const phone = normalizePhoneForStore(profile.phone ?? store.profile.phone) // 不再保存profile到store,用户信息从loginMemberInfo获取
store.profile = { ...store.profile, ...profile, phone } if (profile.weight) {
store.memberProfile = { store.bodyReport.weight = profile.weight
...store.memberProfile,
name: store.profile.name,
phone: formatMemberCenterPhone(store.profile.phone),
avatar: store.profile.avatar || store.memberProfile.avatar
}
if (store.profile.weight) {
store.bodyReport.weight = store.profile.weight
} }
finalizeStore(store) finalizeStore(store)
saveMemberStore(store) saveMemberStore(store)
@@ -306,3 +336,17 @@ export function persistMemberStore(store) {
saveMemberStore(store) saveMemberStore(store)
return store return store
} }
export function getStoredCardBalance() {
console.log('[storedCard] 余额不使用缓存,每次从后端读取')
return null
}
export function saveStoredCardBalance(balance) {
console.log('[storedCard] 余额不使用缓存,跳过保存')
return Number(balance) || 0
}
export function clearStoredCardCache() {
console.log('[storedCard] 余额不使用缓存,无需清除')
}
@@ -1,4 +1,4 @@
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$ */ /** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$ */
const PHONE_REG = /^1[3-9]\d{9}$/ const PHONE_REG = /^1[3-9]\d{9}$/
const MIN_NAME_LEN = 2 const MIN_NAME_LEN = 2
+302
View File
@@ -0,0 +1,302 @@
/**
* 路由权限拦截器
* 自动拦截配置好的路径,未登录时自动弹出登录弹窗
*
* 使用方式:在 App.vue 中 import 并调用 init() 即可
* 无需在每个页面单独配置
*/
import { getToken } from '@/utils/request.js'
/**
* 白名单 - 不需要登录即可访问的页面
*/
const WHITE_LIST = [
'/pages/index/index',
'/pages/course/course',
'/pages/memberInfo/login',
'/pages/memberInfo/register',
'/pages/memberInfo/forgetPassword',
'/pages/about/about',
'/pages/help/help',
'/pages/notice/notice',
'/pages/product/product',
'/pages/store/store',
'/pages/article/article',
'/pages/search/search'
]
/**
* 需要登录的页面路径前缀
* 匹配以下前缀的页面都需要登录
*/
const LOGIN_REQUIRED_PREFIXES = [
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo',
'/pages/memberInfo/order',
'/pages/memberInfo/settings',
'/pages/memberInfo/feedback',
'/pages/memberInfo/invite',
'/pages/memberInfo/recharge'
]
/**
* 需要登录的具体页面路径
*/
const LOGIN_REQUIRED_PATHS = [
'/pages/memberInfo/memberInfo'
]
/**
* 检查当前路径是否需要登录
* @param {String} url - 页面路径(可能包含query参数)
* @returns {Boolean}
*/
function isNeedLogin(url) {
// 去掉query参数
const path = url.split('?')[0]
// 检查白名单
if (WHITE_LIST.includes(path)) {
return false
}
// 检查具体路径
if (LOGIN_REQUIRED_PATHS.includes(path)) {
return true
}
// 检查路径前缀
if (LOGIN_REQUIRED_PREFIXES.some(prefix => path.startsWith(prefix))) {
return true
}
return false
}
/**
* 检查用户是否已登录
* @returns {Boolean}
*/
function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 登录弹窗组件引用(需要在App.vue中注册)
*/
let loginModalRef = null
/**
* 注册登录弹窗组件
* @param {Object} modalRef - LoginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 显示登录弹窗
*/
function showLoginModal() {
if (loginModalRef) {
loginModalRef.show()
} else {
// 如果没有注册弹窗,跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login'
})
}
}
/**
* 记录用户原本要访问的页面
* @param {String} url - 页面路径
*/
function saveRedirectUrl(url) {
uni.setStorageSync('redirectUrl', url)
}
/**
* 获取用户原本要访问的页面
* @returns {String|null}
*/
export function getRedirectUrl() {
const url = uni.getStorageSync('redirectUrl')
uni.removeStorageSync('redirectUrl')
return url
}
/**
* 跳转到用户原本要访问的页面
*/
export function redirectToSavedUrl() {
const url = getRedirectUrl()
if (url) {
uni.navigateTo({
url: url,
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
}
/**
* 初始化路由拦截器
*/
export function initPermissionInterceptor() {
console.log('[PermissionInterceptor] 初始化路由拦截器')
// 拦截 navigateTo
uni.addInterceptor('navigateTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 navigateTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {
// 跳转成功
},
fail(err) {
console.error('[PermissionInterceptor] navigateTo 失败:', err)
}
})
// 拦截 redirectTo
uni.addInterceptor('redirectTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 redirectTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] redirectTo 失败:', err)
}
})
// 拦截 reLaunch
uni.addInterceptor('reLaunch', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 reLaunch:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] reLaunch 失败:', err)
}
})
// 拦截 switchTab
uni.addInterceptor('switchTab', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 switchTab:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] switchTab 失败:', err)
}
})
// 拦截 navigateBack
uni.addInterceptor('navigateBack', {
invoke() {
return true
},
success() {},
fail() {}
})
console.log('[PermissionInterceptor] 路由拦截器初始化完成')
}
/**
* 添加需要登录的页面路径
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addLoginRequiredPaths(paths) {
if (Array.isArray(paths)) {
LOGIN_REQUIRED_PATHS.push(...paths)
} else {
LOGIN_REQUIRED_PATHS.push(paths)
}
}
/**
* 添加需要登录的页面路径前缀
* @param {String|Array} prefixes - 路径前缀或前缀数组
*/
export function addLoginRequiredPrefixes(prefixes) {
if (Array.isArray(prefixes)) {
LOGIN_REQUIRED_PREFIXES.push(...prefixes)
} else {
LOGIN_REQUIRED_PREFIXES.push(prefixes)
}
}
/**
* 添加到白名单
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addWhiteList(paths) {
if (Array.isArray(paths)) {
WHITE_LIST.push(...paths)
} else {
WHITE_LIST.push(paths)
}
}
/**
* 获取当前配置的白名单
* @returns {Array}
*/
export function getWhiteList() {
return [...WHITE_LIST]
}
/**
* 获取当前配置的需要登录的路径
* @returns {Array}
*/
export function getLoginRequiredPaths() {
return [...LOGIN_REQUIRED_PATHS]
}
/**
* 获取当前配置的需要登录的路径前缀
* @returns {Array}
*/
export function getLoginRequiredPrefixes() {
return [...LOGIN_REQUIRED_PREFIXES]
}
+18 -13
View File
@@ -8,11 +8,12 @@
*/ */
:root { :root {
/* ========== 主品牌色(清新浅蓝色系)========== */ /* ========== 主品牌色(首页主题蓝绿色系)========== */
--primary-dark: #0B2B4B; /* 深蓝主色 - 用于重要文字、品牌标识,体现专业信赖感 */ --primary-dark: #2D4A5A; /* 深蓝绿主色 - 用于重要文字、品牌标识,体现专业信赖感 */
--primary-deep: #1A4A6F; /* 中深蓝色 - 用于hover状态、次级按钮、图标点缀,增加层次感 */ --primary-deep: #7AB5CC; /* 蓝绿主色 - 用于按钮、图标、标签背景,首页核心主题色 */
--primary-light: #9CCFDF; /* 浅蓝绿 - 用于hover状态、渐变辅助,增加层次感 */
/* 主页主题蓝渐变色系 */ /* 主页主题蓝绿渐变色系 */
--primary-sky-100: #D6EEF8; /* 最浅蓝 - 渐变起始色,清新自然 */ --primary-sky-100: #D6EEF8; /* 最浅蓝 - 渐变起始色,清新自然 */
--primary-sky-200: #E4F2FA; /* 浅蓝 - 渐变第二层 */ --primary-sky-200: #E4F2FA; /* 浅蓝 - 渐变第二层 */
--primary-sky-300: #EEF6FB; /* 淡蓝 - 渐变第三层 */ --primary-sky-300: #EEF6FB; /* 淡蓝 - 渐变第三层 */
@@ -24,10 +25,13 @@
--glow-blue-2: rgba(180, 220, 240, 0.3); /* 浅蓝色光晕 */ --glow-blue-2: rgba(180, 220, 240, 0.3); /* 浅蓝色光晕 */
--glow-blue-3: rgba(170, 215, 238, 0.25); /* 浅蓝绿色光晕 */ --glow-blue-3: rgba(170, 215, 238, 0.25); /* 浅蓝绿色光晕 */
/* ========== 强调/行动色(活力========== */ /* ========== 强调/行动色(活力绿========== */
--accent-orange: #FF6B35; /* 活力 - 主要CTA按钮、会员标识、高亮徽章、关键数据,刺激行动 */ --accent-green: rgba(130, 220, 130, 0.9); /* 活力绿 - 主要CTA按钮、图标背景,首页核心行动 */
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助,带来温暖运动感 */ --accent-green-light: rgba(150, 230, 150, 0.8); /* 浅绿 - hover轻量背景 */
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告,保持色彩体系完整 */ --accent-green-dark: rgba(100, 200, 100, 1); /* 深绿 - 按压状态 */
--accent-orange: #FF6B35; /* 活力橙 - 辅助CTA按钮、会员标识、高亮徽章、关键数据 */
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助 */
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告 */
/* ========== 背景色系(主页主题)========== */ /* ========== 背景色系(主页主题)========== */
--bg-gradient-primary: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页主渐变背景 */ --bg-gradient-primary: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页主渐变背景 */
@@ -52,19 +56,20 @@
--info-blue: #3498DB; /* 信息蓝 - 提示气泡、帮助文字 */ --info-blue: #3498DB; /* 信息蓝 - 提示气泡、帮助文字 */
/* ========== 渐变色 (提升活力感) ========== */ /* ========== 渐变色 (提升活力感) ========== */
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 色渐变 - 会员按钮、重要徽章 */ --gradient-green: linear-gradient(135deg, rgba(130, 220, 130, 0.9) 0%, rgba(150, 230, 150, 0.8) 100%); /* 绿色渐变 - 主要CTA按钮、图标背景 */
--gradient-blue: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%); /* 深蓝渐变 - 头部banner或特别卡片 */ --gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 橙色渐变 - 辅助按钮、重要徽章 */
--gradient-blue: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%); /* 蓝绿渐变 - 头部banner或特别卡片,首页核心渐变 */
--gradient-sky: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页天空渐变 - 全局背景 */ --gradient-sky: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页天空渐变 - 全局背景 */
--gradient-subtle: linear-gradient(120deg, #F9FAFE 0%, #FFFFFF 100%); /* 微弱渐变 - 增加细节精致度 */ --gradient-subtle: linear-gradient(120deg, #F9FAFE 0%, #FFFFFF 100%); /* 微弱渐变 - 增加细节精致度 */
/* ========== TabBar 配色(清新蓝风格)========== */ /* ========== TabBar 配色(清新蓝绿风格)========== */
/* 引用位置:components/TabBar.vue */ /* 引用位置:components/TabBar.vue */
--tabbar-bg: rgba(200, 225, 238, 0.8); /* TabBar背景色 - 半透明浅蓝色毛玻璃效果 */ --tabbar-bg: rgba(200, 225, 238, 0.8); /* TabBar背景色 - 半透明浅蓝色毛玻璃效果 */
--tabbar-shadow: rgba(120, 185, 215, 0.2); /* TabBar阴影色 - 蓝色系柔和阴影 */ --tabbar-shadow: rgba(120, 185, 215, 0.2); /* TabBar阴影色 - 蓝色系柔和阴影 */
--tabbar-icon-inactive: gray; /* 未选中图标颜色 - 灰色 */ --tabbar-icon-inactive: gray; /* 未选中图标颜色 - 灰色 */
--tabbar-icon-active: #5A98B0; /* 选中图标颜色 - 蓝绿色 */ --tabbar-icon-active: #7AB5CC; /* 选中图标颜色 - 蓝绿色(首页主题色) */
--tabbar-text-inactive: #8AABBB; /* 未选中文字颜色 - 浅灰蓝 */ --tabbar-text-inactive: #8AABBB; /* 未选中文字颜色 - 浅灰蓝 */
--tabbar-text-active: #5A98B0; /* 选中文字颜色 - 蓝绿色(与图标一致) */ --tabbar-text-active: #7AB5CC; /* 选中文字颜色 - 蓝绿色(与图标一致,首页主题色 */
/* ========== 通用蓝色系阴影(用于卡片、按钮等)========== */ /* ========== 通用蓝色系阴影(用于卡片、按钮等)========== */
/* 引用位置:components/index/RecommendCourses.vue, QuickEntry.vue, TodayRecommend.vue */ /* 引用位置:components/index/RecommendCourses.vue, QuickEntry.vue, TodayRecommend.vue */
@@ -269,3 +269,63 @@
flex-shrink: 0; flex-shrink: 0;
background-color: rgba(255, 255, 255, 0.31); background-color: rgba(255, 255, 255, 0.31);
} }
.profile-header__user-inner--guest {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
width: 100%;
}
.profile-header__avatar-wrap--guest {
width: 72px;
height: 72px;
flex-shrink: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
}
.profile-header__avatar--guest {
width: 100%;
height: 100%;
display: block;
}
.profile-header__user-meta--guest {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-header__name--guest {
font-size: 18px;
font-weight: 600;
color: black;
white-space: nowrap;
}
.profile-header__phone--guest {
font-size: 14px;
font-weight: 400;
color: #999999;
white-space: nowrap;
}
.profile-header__user-inner--guest {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
width: 100%;
margin: 0;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
line-height: inherit;
}
@@ -360,3 +360,84 @@
flex-grow: 1; flex-grow: 1;
flex-basis: 0; flex-basis: 0;
} }
.member-card-section__count {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 4rpx;
padding: 4rpx 14rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
flex-shrink: 0;
}
.member-card-section__count-num {
font-size: 26rpx;
font-weight: 700;
color: #333;
line-height: 1;
}
.member-card-section__count-text {
font-size: 22rpx;
font-weight: 400;
color: #333;
line-height: 1;
}
.member-card-preview__tag--expiring {
background-color: #FFE4E4;
}
.member-card-preview__tag--expiring .member-card-preview__tag-text {
color: #E74C3C;
}
.member-card-preview__tag--expired {
background-color: rgba(0, 0, 0, 0.2);
}
.member-card-preview__tag--expired .member-card-preview__tag-text {
color: rgba(255, 255, 255, 0.7);
}
.member-card-preview__tag--active {
background-color: rgba(255, 255, 255, 0.19);
}
.member-card-preview__tag--active .member-card-preview__tag-text {
color: var(--text-inverse);
}
.member-card-preview__days-num--expiring {
color: #FFE4E4;
}
.member-card-preview__days-num--expired {
color: #FF6B6B;
}
.member-card-preview__purchase {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 20px;
border-radius: 20px;
background-color: var(--bg-white);
}
.member-card-preview__purchase-text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -239,6 +239,29 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.bk-card__actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.bk-card__signin {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 8px;
background-color: var(--accent-orange, #FF6B35);
box-sizing: border-box;
}
.bk-card__signin-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.bk-card__cancel { .bk-card__cancel {
flex-shrink: 0; flex-shrink: 0;
padding: 6px 14px; padding: 6px 14px;
@@ -1,4 +1,4 @@
<template> <template>
<view class="qr-status"> <view class="qr-status">
<!-- 加载中状态 --> <!-- 加载中状态 -->
<view v-if="status === 'loading'" class="status-loading"> <view v-if="status === 'loading'" class="status-loading">
@@ -1,4 +1,4 @@
<template> <template>
<SkeletonBase> <SkeletonBase>
<view class="skeleton-banner"></view> <view class="skeleton-banner"></view>
@@ -0,0 +1,169 @@
<!-- components/Skeleton/ListSkeleton.vue -->
<template>
<SkeletonBase>
<!-- Tab区域 -->
<view v-if="showTabs" class="skeleton-tabs">
<view v-for="i in tabCount" :key="i" class="skeleton-tab skeleton-shimmer"></view>
</view>
<!-- 列表项 -->
<view class="skeleton-list" :style="{ padding: listPadding }">
<view v-for="i in count" :key="i" class="skeleton-item">
<!-- 卡片式布局默认 -->
<template v-if="layout === 'card'">
<view class="skeleton-card-item">
<view class="skeleton-card-icon skeleton-shimmer"></view>
<view class="skeleton-card-body">
<view class="skeleton-card-title skeleton-shimmer"></view>
<view class="skeleton-card-desc skeleton-shimmer"></view>
<view class="skeleton-card-footer">
<view class="skeleton-card-tag skeleton-shimmer"></view>
<view class="skeleton-card-time skeleton-shimmer"></view>
</view>
</view>
</view>
</template>
<!-- 简洁列表布局 -->
<template v-else-if="layout === 'simple'">
<view class="skeleton-simple-item">
<view class="skeleton-simple-title skeleton-shimmer"></view>
<view class="skeleton-simple-meta">
<view class="skeleton-simple-line skeleton-shimmer"></view>
<view class="skeleton-simple-line skeleton-shimmer short"></view>
</view>
</view>
</template>
</view>
</view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
defineProps({
count: { type: Number, default: 6 },
layout: { type: String, default: 'card' },
showTabs: { type: Boolean, default: false },
tabCount: { type: Number, default: 3 },
listPadding: { type: String, default: '0 24rpx' }
})
</script>
<style lang="scss" scoped>
.skeleton-tabs {
display: flex;
gap: 16rpx;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.skeleton-tab {
height: 60rpx;
width: 140rpx;
border-radius: 12rpx;
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: 16rpx;
padding-top: 16rpx;
}
/* 卡片式 */
.skeleton-card-item {
display: flex;
align-items: center;
padding: 24rpx 20rpx;
background: #fff;
border-radius: 16rpx;
gap: 20rpx;
}
.skeleton-card-icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.skeleton-card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
}
.skeleton-card-title {
height: 32rpx;
width: 60%;
border-radius: 8rpx;
}
.skeleton-card-desc {
height: 28rpx;
width: 80%;
border-radius: 8rpx;
}
.skeleton-card-footer {
display: flex;
justify-content: space-between;
margin-top: 4rpx;
}
.skeleton-card-tag {
height: 24rpx;
width: 80rpx;
border-radius: 6rpx;
}
.skeleton-card-time {
height: 24rpx;
width: 120rpx;
border-radius: 6rpx;
}
/* 简洁式 */
.skeleton-simple-item {
padding: 28rpx 24rpx;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-simple-title {
height: 34rpx;
width: 55%;
border-radius: 8rpx;
}
.skeleton-simple-meta {
display: flex;
gap: 24rpx;
}
.skeleton-simple-line {
height: 26rpx;
width: 180rpx;
border-radius: 6rpx;
&.short {
width: 100rpx;
}
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -0,0 +1,179 @@
<!-- components/Skeleton/MemberCenterSkeleton.vue -->
<template>
<SkeletonBase>
<!-- 头像和个人信息 -->
<view class="skeleton-header">
<view class="skeleton-avatar skeleton-shimmer"></view>
<view class="skeleton-header-info">
<view class="skeleton-name skeleton-shimmer"></view>
<view class="skeleton-level skeleton-shimmer"></view>
</view>
</view>
<!-- 统计数据 -->
<view class="skeleton-stats">
<view v-for="i in 3" :key="i" class="skeleton-stat-item">
<view class="skeleton-stat-num skeleton-shimmer"></view>
<view class="skeleton-stat-label skeleton-shimmer"></view>
</view>
</view>
<!-- 会员卡 -->
<view class="skeleton-card">
<view class="skeleton-card-title skeleton-shimmer"></view>
<view class="skeleton-card-body">
<view class="skeleton-card-line skeleton-shimmer"></view>
<view class="skeleton-card-line skeleton-shimmer short"></view>
<view class="skeleton-card-btn skeleton-shimmer"></view>
</view>
</view>
<!-- 快捷入口 -->
<view class="skeleton-entry">
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
<view class="skeleton-entry-icon skeleton-shimmer"></view>
<view class="skeleton-entry-label skeleton-shimmer"></view>
</view>
</view>
<!-- 底部占位 -->
<view class="skeleton-spacer"></view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
</script>
<style lang="scss" scoped>
.skeleton-header {
display: flex;
align-items: center;
padding: 40rpx 32rpx 24rpx;
}
.skeleton-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-header-info {
margin-left: 24rpx;
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-name {
height: 36rpx;
width: 160rpx;
border-radius: 8rpx;
}
.skeleton-level {
height: 28rpx;
width: 120rpx;
border-radius: 8rpx;
}
.skeleton-stats {
display: flex;
justify-content: space-around;
padding: 24rpx 32rpx;
}
.skeleton-stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-stat-num {
height: 40rpx;
width: 80rpx;
border-radius: 8rpx;
}
.skeleton-stat-label {
height: 24rpx;
width: 60rpx;
border-radius: 8rpx;
}
.skeleton-card {
margin: 0 24rpx 24rpx;
padding: 32rpx;
background: #fff;
border-radius: 20rpx;
}
.skeleton-card-title {
height: 32rpx;
width: 140rpx;
border-radius: 8rpx;
margin-bottom: 24rpx;
}
.skeleton-card-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-card-line {
height: 28rpx;
width: 70%;
border-radius: 8rpx;
&.short {
width: 45%;
}
}
.skeleton-card-btn {
height: 56rpx;
width: 160rpx;
border-radius: 12rpx;
margin-top: 8rpx;
}
.skeleton-entry {
display: flex;
justify-content: space-around;
padding: 24rpx 32rpx;
}
.skeleton-entry-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-entry-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
}
.skeleton-entry-label {
height: 24rpx;
width: 60rpx;
border-radius: 8rpx;
}
.skeleton-spacer {
height: 200rpx;
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -0,0 +1,89 @@
<template>
<view class="message-skeleton">
<view v-for="i in 6" :key="i" class="skeleton-item">
<view class="skeleton-icon skeleton-shimmer"></view>
<view class="skeleton-content">
<view class="skeleton-title skeleton-shimmer"></view>
<view class="skeleton-desc skeleton-shimmer"></view>
<view class="skeleton-footer">
<view class="skeleton-tag skeleton-shimmer"></view>
<view class="skeleton-time skeleton-shimmer"></view>
</view>
</view>
</view>
</view>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.message-skeleton {
padding: 8rpx 24rpx;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 0;
margin-bottom: 12rpx;
background: var(--bg-white);
border-radius: 16rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
min-width: 0;
}
.skeleton-title {
height: 32rpx;
width: 60%;
border-radius: 8rpx;
margin-bottom: 12rpx;
}
.skeleton-desc {
height: 28rpx;
width: 85%;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.skeleton-tag {
height: 24rpx;
width: 80rpx;
border-radius: 8rpx;
}
.skeleton-time {
height: 24rpx;
width: 100rpx;
border-radius: 8rpx;
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="skeleton" :style="{ padding: padding }"> <view class="skeleton" :style="{ padding: padding }">
<slot /> <slot />
</view> </view>
+45 -33
View File
@@ -1,6 +1,7 @@
<!-- components/TabBar.vue --> <!-- components/TabBar.vue -->
<template> <template>
<view v-if="shouldShowTabBar" class="tab-bar"> <view v-if="shouldShowTabBar" class="tab-bar-wrapper">
<view class="tab-bar">
<view <view
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="tab.path" :key="tab.path"
@@ -18,6 +19,7 @@
<text class="tab-label">{{ tab.label }}</text> <text class="tab-label">{{ tab.label }}</text>
</view> </view>
</view> </view>
</view>
</template> </template>
<script setup> <script setup>
@@ -46,7 +48,6 @@ const HIDE_TABBAR_PAGES = [
'pages/memberInfo/bodyTestReport', 'pages/memberInfo/bodyTestReport',
'pages/groupCourse/list', 'pages/groupCourse/list',
'pages/groupCourse/detail', 'pages/groupCourse/detail',
'pages/searchCourse/searchCourse',
'pages/checkIn/checkIn', 'pages/checkIn/checkIn',
'pages/memberInfo/myCourses', 'pages/memberInfo/myCourses',
'pages/memberInfo/coupons', 'pages/memberInfo/coupons',
@@ -54,13 +55,11 @@ const HIDE_TABBAR_PAGES = [
'pages/memberInfo/pointsMall', 'pages/memberInfo/pointsMall',
'pages/memberInfo/referral', 'pages/memberInfo/referral',
'pages/memberInfo/userInfo', 'pages/memberInfo/userInfo',
'pages/memberInfo/memberCard',
] ]
function getActiveIndexFromRoute() { function getActiveIndexFromRoute() {
const routePath = getCurrentRoutePath() const routePath = getCurrentRoutePath()
const index = getTabIndexByRoute(routePath) const index = getTabIndexByRoute(routePath)
console.log('从路由获取索引:', routePath, '->', index)
return index >= 0 ? index : 0 return index >= 0 ? index : 0
} }
@@ -89,24 +88,26 @@ function checkShouldShow() {
} }
const shouldHide = HIDE_TABBAR_PAGES.includes(routePath) const shouldHide = HIDE_TABBAR_PAGES.includes(routePath)
shouldShowTabBar.value = !shouldHide shouldShowTabBar.value = !shouldHide
console.log('=== TabBar 显示控制 ===')
console.log('原始路径:', getCurrentRoutePath())
console.log('标准化路径:', routePath)
console.log('是否隐藏:', shouldHide)
console.log('是否显示 TabBar:', shouldShowTabBar.value)
} }
let routeWatcher = null let routeWatcher = null
let appRouteCallback = null let appRouteCallback = null
let isNavigating = false
onMounted(() => { onMounted(() => {
syncActiveState() syncActiveState()
checkShouldShow() checkShouldShow()
// #ifdef APP-PLUS // #ifndef MP-WEIXIN
// H5使
routeWatcher = setInterval(() => { routeWatcher = setInterval(() => {
syncActiveState() //
if (isNavigating) return
const newIndex = getActiveIndexFromRoute()
if (newIndex !== currentActiveIndex.value) {
currentActiveIndex.value = newIndex
}
checkShouldShow() checkShouldShow()
}, 300) }, 200)
// #endif // #endif
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (typeof uni.onAppRoute === 'function') { if (typeof uni.onAppRoute === 'function') {
@@ -122,12 +123,17 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
// #ifdef APP-PLUS // #ifndef MP-WEIXIN
if (routeWatcher) { clearInterval(routeWatcher) } // H5
if (routeWatcher) {
clearInterval(routeWatcher)
routeWatcher = null
}
// #endif // #endif
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (appRouteCallback && typeof uni.offAppRoute === 'function') { if (appRouteCallback && typeof uni.offAppRoute === 'function') {
uni.offAppRoute(appRouteCallback) uni.offAppRoute(appRouteCallback)
appRouteCallback = null
} }
// #endif // #endif
}) })
@@ -153,20 +159,6 @@ const tabs = [
useFontIcon: true, useFontIcon: true,
fontSize:"36rpx" fontSize:"36rpx"
}, },
{
path: PAGE.TRAIN,
icon: 'icon-train',
label: '训练',
useFontIcon: true,
fontSize:"48rpx"
},
{
path: PAGE.DISCOVER,
icon: 'icon-discover',
label: '发现',
useFontIcon: true,
fontSize:"48rpx"
},
{ {
path: PAGE.MEMBER, path: PAGE.MEMBER,
icon: 'icon-profile', icon: 'icon-profile',
@@ -184,12 +176,13 @@ function onTabTap(index) {
const currentPath = TAB_ROUTES[currentActiveIndex.value] const currentPath = TAB_ROUTES[currentActiveIndex.value]
if (targetPath === currentPath) return if (targetPath === currentPath) return
console.log('Tab 点击:', index, targetPath) console.log('Tab 点击:', index, targetPath)
//
currentActiveIndex.value = index currentActiveIndex.value = index
emit('update:active', index) emit('update:active', index)
emit('tab-change', index) emit('tab-change', index)
let timer = setTimeout(() => { //
isNavigating = true
uni.showLoading({ title: '加载中...', mask: true }) uni.showLoading({ title: '加载中...', mask: true })
}, 50)
isSwitching = true isSwitching = true
uni.switchTab({ uni.switchTab({
url: targetPath, url: targetPath,
@@ -199,11 +192,13 @@ function onTabTap(index) {
uni.reLaunch({ url: targetPath }) uni.reLaunch({ url: targetPath })
}, },
complete: () => { complete: () => {
clearTimeout(timer)
uni.hideLoading() uni.hideLoading()
setTimeout(() => { setTimeout(() => {
isSwitching = false isSwitching = false
isNavigating = false
// #ifdef MP-WEIXIN
syncActiveState() syncActiveState()
// #endif
checkShouldShow() checkShouldShow()
}, 100) }, 100)
} }
@@ -215,11 +210,21 @@ function onTabTap(index) {
// CSS @font-face // CSS @font-face
@import '/common/style/tabbar_icon/tabbar.css'; @import '/common/style/tabbar_icon/tabbar.css';
.tab-bar { // - TabBar
.tab-bar-wrapper {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 9999;
pointer-events: none;
}
.tab-bar-wrapper .tab-bar {
pointer-events: auto;
}
.tab-bar {
height: 120rpx; height: 120rpx;
background: white; background: white;
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
@@ -231,7 +236,14 @@ function onTabTap(index) {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 24rpx var(--tabbar-shadow); box-shadow: 0 -4rpx 24rpx var(--tabbar-shadow);
border-radius: 32rpx 32rpx 0 0; border-radius: 32rpx 32rpx 0 0;
z-index: 999; /* 防闪烁优化 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000;
-webkit-perspective: 1000;
} }
.tab-item { .tab-item {
@@ -1,4 +1,4 @@
<!-- components/GlobalLoading.vue --> <!-- components/GlobalLoading.vue -->
<template> <template>
<view v-if="visible" class="global-loading"> <view v-if="visible" class="global-loading">
<view class="loading-mask"></view> <view class="loading-mask"></view>
@@ -0,0 +1,233 @@
/**
* 登录弹窗组件使用示例
*
* 该组件提供了统一的登录拦截功能,可以在任何页面使用
*/
## 方式一:在页面中直接使用LoginModal组件
```vue
<template>
<view>
<!-- 其他内容 -->
<!-- 引入登录弹窗 -->
<LoginModal
ref="loginModalRef"
v-model="showLoginModal"
title="登录后享受更多服务"
subtitle="登录即表示同意相关协议"
:show-close="true"
:mask-closable="true"
@loginSuccess="onLoginSuccess"
@loginError="onLoginError"
/>
</view>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
import LoginModal from '@/components/global/LoginModal.vue'
const showLoginModal = ref(false)
const loginModalRef = ref(null)
// 注册到全局
onMounted(() => {
nextTick(() => {
if (loginModalRef.value) {
registerLoginModal(loginModalRef.value)
}
})
})
// 显示登录弹窗
function showLogin() {
showLoginModal.value = true
}
// 登录成功回调
function onLoginSuccess(result) {
console.log('登录成功', result)
// 刷新页面数据等
}
// 登录失败回调
function onLoginError(error) {
console.log('登录失败', error)
}
</script>
```
## 方式二:使用 useLoginGuard Hook
```vue
<script setup>
import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
const { requireLogin, checkLogin } = useLoginGuard()
// 示例1:需要登录才能执行的操作
async function handleBookCourse() {
// 检查是否已登录
if (!checkLogin()) {
return
}
// 执行预约操作...
}
// 示例2:弹窗式登录(推荐)
async function handlePurchase() {
const loggedIn = await requireLogin({
title: '请先登录',
subtitle: '登录后即可购买会员卡',
onSuccess: (user) => {
console.log('登录成功,用户信息:', user)
}
})
if (loggedIn) {
// 执行购买操作
await purchaseCard()
}
}
// 示例3:安全执行函数
async function handleCheckIn() {
const result = await requireLogin().safeExecute(async () => {
// 只有登录后才能执行这里的代码
return await checkInApi()
})
if (result) {
uni.showToast({ title: '签到成功' })
}
}
// 示例4:包装API调用
async function handleGetCoupons() {
const { callLoggedInApi } = useLoginGuard()
try {
const coupons = await callLoggedInApi(getCouponsApi, [], {
title: '领取优惠券'
})
console.log('优惠券列表:', coupons)
} catch (e) {
if (e.message === '用户未登录') {
// 用户未登录,弹窗已显示
}
}
}
</script>
```
## 方式三:在组件中使用登录守卫Mixin
```vue
<script>
import { loginGuardMixin } from '@/common/hooks/useLoginGuard.js'
export default {
mixins: [loginGuardMixin()],
methods: {
async onQuickAction(type) {
// 自动检查登录状态
if (!await this.ensureLogin()) {
return
}
// 已登录,执行操作
if (type === 'book') {
this.goToBooking()
}
}
}
}
</script>
```
## 登录弹窗组件Props说明
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | Boolean | false | 控制显示/隐藏 |
| title | String | '登录后享受更多服务' | 弹窗标题 |
| subtitle | String | '登录即表示同意相关协议' | 弹窗副标题 |
| showClose | Boolean | true | 是否显示关闭按钮 |
| modalStyle | String | 'center' | 弹窗样式:'center'居中,'bottom'底部 |
| maskClosable | Boolean | true | 点击遮罩是否关闭 |
| onLoginSuccess | Function | null | 登录成功回调 |
| onLoginError | Function | null | 登录失败回调 |
## 登录弹窗组件Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| update:modelValue | 显示状态变化 | Boolean |
| loginSuccess | 登录成功 | Object: 登录结果 |
| loginError | 登录失败 | Error: 错误对象 |
| close | 弹窗关闭 | - |
## 登录弹窗组件暴露的方法
| 方法名 | 说明 | 参数 |
|--------|------|------|
| show() | 显示弹窗 | - |
| hide() | 隐藏弹窗 | - |
| switchCard(card) | 切换登录方式 | 'phone'一键登录 / 'sms'验证码登录 |
## 全局登录拦截示例
`App.vue` 中注册全局登录弹窗:
```vue
<script>
import LoginModal from '@/components/global/LoginModal.vue'
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
export default {
components: { LoginModal },
onLaunch() {
// 注册全局登录弹窗
this.$nextTick(() => {
if (this.$refs.globalLoginModal) {
registerLoginModal(this.$refs.globalLoginModal)
}
})
}
}
</script>
<template>
<view id="app">
<router-view />
<LoginModal ref="globalLoginModal" />
</view>
</template>
```
## API路径配置
登录弹窗组件使用的API路径定义在 `@/api/main.js`
```javascript
// 一键登录
export function phoneLogin(data) {
return request.post('/auth/phone/login', data)
}
// 短信验证码登录
export function smsLogin(data) {
return request.post('/auth/phone/sms/login', data)
}
// 发送验证码
export function sendSmsCode(phone) {
return request.post('/auth/phone/sms/send', { phone })
}
```
如需修改API路径,请编辑 `main.js` 文件中的相应函数。
@@ -0,0 +1,691 @@
<template>
<!-- 登录弹窗组件 -->
<view class="login-modal-mask" v-if="visible" @tap="handleMaskTap">
<view class="login-modal" :class="{ 'login-modal--center': modalStyle === 'center' }" @tap.stop>
<!-- 关闭按钮 -->
<view class="login-modal__close" v-if="showClose" @tap="handleClose">
<text class="icon-close">×</text>
</view>
<!-- 弹窗内容 -->
<view class="login-modal__content">
<!-- 头部 -->
<view class="login-modal__header">
<view class="logo-wrapper">
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">活氧舱</text>
</view>
<text class="modal-title">{{ title }}</text>
<text class="modal-subtitle">{{ subtitle }}</text>
</view>
<!-- 一键登录卡片 -->
<view class="login-card" :class="{ active: activeCard === 'phone' }">
<view class="card-body">
<text class="card-title">本机号码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<button class="login-btn primary" :class="{ disabled: isLoading, loading: isLoading }"
@tap="handlePhoneLogin" :disabled="isLoading">
<text>{{ isLoading ? '登录中...' : '一键登录' }}</text>
</button>
<button class="login-btn switch-btn" @tap="switchToSms">
<text>短信验证码登录</text>
</button>
</view>
</view>
<!-- 短信验证码登录卡片 -->
<view class="login-card sms-card" :class="{ active: activeCard === 'sms' }">
<view class="card-body">
<text class="card-title">验证码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<view class="form-group">
<view class="input-wrapper" :class="{ focused: isPhoneFocused }">
<input v-model="smsPhone" type="number" placeholder="请输入手机号" maxlength="11"
class="phone-input" confirm-type="next" @focus="isPhoneFocused = true"
@blur="isPhoneFocused = false" />
</view>
</view>
<view class="form-group">
<view class="input-wrapper code-input" :class="{ focused: isCodeFocused }">
<input v-model="smsCode" type="number" placeholder="请输入验证码" maxlength="6"
class="phone-input" confirm-type="done" @focus="isCodeFocused = true"
@blur="isCodeFocused = false" />
<button class="send-code-btn" :class="{ disabled: countdown > 0 || isLoading }"
@tap="handleSendCode" :disabled="countdown > 0 || isLoading">
<text>{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}</text>
</button>
</view>
</view>
<button class="login-btn primary" :class="{ disabled: !canSmsSubmit, loading: isLoading }"
@tap="handleSmsLogin" :disabled="!canSmsSubmit || isLoading">
<text>{{ isLoading ? '登录中...' : '登录' }}</text>
</button>
<button class="login-btn switch-btn" @tap="switchToPhone">
<text>一键登录</text>
</button>
</view>
</view>
<!-- 协议 -->
<view class="agreement-text">
<checkbox-group @change="onAgreementChange">
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
</checkbox-group>
<text>登录即表示同意</text>
<text class="link" @tap="showAgreement">用户协议</text>
<text></text>
<text class="link" @tap="showPrivacy">隐私政策</text>
</view>
</view>
</view>
<!-- Toast提示 -->
<view class="custom-toast" :class="{ show: toastShow }">
<text class="toast-text">{{ toastMessage }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { oneClickLogin, loginWithPhone, sendCode } from '@/api/main.js'
// Props
const props = defineProps({
//
modelValue: {
type: Boolean,
default: false
},
//
title: {
type: String,
default: '登录后享受更多服务'
},
//
subtitle: {
type: String,
default: '登录即表示同意相关协议'
},
//
showClose: {
type: Boolean,
default: true
},
// : 'center', 'bottom'
modalStyle: {
type: String,
default: 'center'
},
//
maskClosable: {
type: Boolean,
default: true
},
//
onLoginSuccess: {
type: Function,
default: null
},
//
onLoginError: {
type: Function,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'loginSuccess', 'loginError', 'close'])
//
const visible = ref(false)
const activeCard = ref('phone') // 'phone', 'sms'
const isLoading = ref(false)
const isPhoneFocused = ref(false)
const isCodeFocused = ref(false)
const smsPhone = ref('')
const smsCode = ref('')
const countdown = ref(0)
const agreed = ref(false)
const toastShow = ref(false)
const toastMessage = ref('')
//
const canSmsSubmit = computed(() => {
return smsPhone.value.length === 11 && smsCode.value.length === 6 && agreed.value
})
// modelValue
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
//
resetState()
}
}, { immediate: true })
//
function resetState() {
activeCard.value = 'phone'
isLoading.value = false
smsPhone.value = ''
smsCode.value = ''
countdown.value = 0
agreed.value = false
}
// Toast
function showToast(message, duration = 2000) {
toastMessage.value = message
toastShow.value = true
setTimeout(() => {
toastShow.value = false
}, duration)
}
//
function showAgreement() {
uni.showModal({
title: '用户协议',
content: '这里是用户协议内容...',
showCancel: false
})
}
//
function showPrivacy() {
uni.showModal({
title: '隐私政策',
content: '这里隐私政策内容...',
showCancel: false
})
}
//
function switchToSms() {
activeCard.value = 'sms'
}
//
function switchToPhone() {
activeCard.value = 'phone'
}
//
function onAgreementChange(e) {
agreed.value = e.detail.value.includes('agreed')
}
//
async function handleSendCode() {
if (countdown.value > 0 || isLoading.value) return
if (!smsPhone.value || smsPhone.value.length !== 11) {
showToast('请输入正确的手机号')
return
}
if (!agreed.value) {
showToast('请先同意用户协议')
return
}
isLoading.value = true
try {
await sendCode({ phone: smsPhone.value })
showToast('验证码已发送')
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} catch (e) {
console.error('发送验证码失败:', e)
showToast(e.message || '发送验证码失败')
} finally {
isLoading.value = false
}
}
//
async function handlePhoneLogin() {
if (isLoading.value) return
if (!agreed.value) {
showToast('请先同意用户协议')
return
}
isLoading.value = true
try {
// #ifdef APP-PLUS
// App使
await new Promise((resolve, reject) => {
uni.login({
provider: 'univerify',
univerifyStyle: {
fullScreen: false,
backgroundColor: '#ffffff',
icon: {
path: '/static/logo.png'
},
closeIcon: {
path: '/static/close.png'
},
phoneNum: {
color: '#333333',
fontSize: '18px'
},
slogan: {
color: '#999999',
fontSize: '12px'
},
authButton: {
normalColor: '#FF8C42',
highlightColor: '#FF7A2C',
disabledColor: '#CCCCCC',
textColor: '#FFFFFF',
title: '本机号码一键登录'
},
otherLoginButton: {
visible: false
},
protocols: []
},
success: async (res) => {
try {
// API
const result = await oneClickLogin({
accessToken: res.authResult.access_token,
openid: res.authResult.openid
})
if (result.code === 200 || result.data) {
handleLoginSuccess(result)
resolve(result)
} else {
reject(new Error(result.message || '登录失败'))
}
} catch (e) {
reject(e)
}
},
fail: (err) => {
console.error('一键登录失败:', err)
reject(new Error(err.errMsg || '一键登录失败'))
}
})
})
// #endif
// #ifndef APP-PLUS
// AppH5使
uni.showToast({
title: '请使用短信验证码登录',
icon: 'none'
})
activeCard.value = 'sms'
// #endif
} catch (e) {
console.error('一键登录失败:', e)
showToast(e.message || '一键登录失败')
if (props.onLoginError) {
props.onLoginError(e)
}
emit('loginError', e)
} finally {
isLoading.value = false
}
}
//
async function handleSmsLogin() {
if (!canSmsSubmit.value || isLoading.value) return
isLoading.value = true
try {
const result = await loginWithPhone({
phone: smsPhone.value,
code: smsCode.value
})
handleLoginSuccess(result)
} catch (e) {
console.error('短信登录失败:', e)
showToast(e.message || '登录失败')
if (props.onLoginError) {
props.onLoginError(e)
}
emit('loginError', e)
} finally {
isLoading.value = false
}
}
//
function handleLoginSuccess(result) {
console.log('登录成功:', result)
// token
if (result.data?.token) {
uni.setStorageSync('token', result.data.token)
}
//
if (result.data) {
uni.setStorageSync('loginMemberInfo', result.data)
}
showToast('登录成功')
if (props.onLoginSuccess) {
props.onLoginSuccess(result)
}
emit('loginSuccess', result)
uni.$emit('loginModal:success', result)
setTimeout(() => {
handleClose()
}, 1500)
}
//
function handleClose() {
visible.value = false
emit('update:modelValue', false)
emit('close')
uni.$emit('loginModal:close')
}
//
function handleMaskTap() {
if (props.maskClosable) {
handleClose()
}
}
//
defineExpose({
//
show() {
visible.value = true
emit('update:modelValue', true)
},
//
hide() {
handleClose()
},
//
switchCard(card) {
activeCard.value = card
}
})
</script>
<style lang="scss" scoped>
.login-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.login-modal {
position: relative;
width: 90%;
max-width: 650rpx;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
&--center {
display: flex;
flex-direction: column;
align-items: center;
}
}
.login-modal__close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.icon-close {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
.login-modal__content {
width: 100%;
padding: 40rpx 50rpx 50rpx;
}
.login-modal__header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
}
.logo-wrapper {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
.logo-image {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
}
.app-name {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.modal-subtitle {
font-size: 24rpx;
color: #999;
}
.login-card {
display: none;
&.active {
display: block;
}
}
.sms-card {
display: none;
&.active {
display: block;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
text-align: center;
}
.auth-tip {
font-size: 22rpx;
color: #999;
text-align: center;
}
.form-group {
width: 100%;
}
.input-wrapper {
width: 100%;
height: 88rpx;
background: #F5F7FA;
border-radius: 16rpx;
border: 2rpx solid transparent;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
transition: all 0.3s;
&.focused {
border-color: #FF8C42;
background: #fff;
}
}
.phone-input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #333;
}
.code-input {
padding-right: 0;
.phone-input {
width: 55%;
}
}
.send-code-btn {
width: 40%;
height: 64rpx;
line-height: 64rpx;
background: #FF8C42;
color: #fff;
font-size: 24rpx;
border-radius: 8rpx;
padding: 0;
margin: 0;
&.disabled {
background: #ccc;
}
&::after {
border: none;
}
}
.login-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
padding: 0;
margin: 0;
&.primary {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
color: #fff;
border: none;
}
&.disabled {
background: #ccc;
color: #fff;
}
&.loading {
opacity: 0.8;
}
&.switch-btn {
background: transparent;
color: #FF8C42;
border: 2rpx solid #FF8C42;
}
&::after {
border: none;
}
}
.agreement-text {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 24rpx;
font-size: 22rpx;
color: #999;
.link {
color: #FF8C42;
}
}
.custom-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 24rpx 48rpx;
border-radius: 12rpx;
font-size: 28rpx;
z-index: 99999;
opacity: 0;
transition: all 0.3s;
&.show {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
</style>
@@ -0,0 +1,278 @@
<template>
<view
class="verify-code-input"
@tap="focusInput"
:style="{
'--vci-active-color': activeColor,
'--vci-filled-color': filledColor,
'--vci-border-color': borderColor
}"
>
<view class="vci-title" v-if="title">
<text class="vci-title__text">{{ title }}</text>
</view>
<view class="vci-desc" v-if="desc">
<text class="vci-desc__text">{{ desc }}</text>
</view>
<view class="vci-code-box">
<view
v-for="(item, index) in codeLength"
:key="index"
class="vci-code-item"
:class="{
'vci-code-item--active': isFocused && currentIndex === index,
'vci-code-item--filled': code.length > index,
'vci-code-item--underline': type === 'underline',
'vci-code-item--box': type === 'box'
}"
>
<text v-if="code[index] && !mask" class="vci-code-text">{{ code[index] }}</text>
<view v-else-if="code[index] && mask" class="vci-code-dot"></view>
<view v-if="isFocused && currentIndex === index && code.length === index" class="vci-code-cursor"></view>
</view>
</view>
<view class="vci-tips" v-if="errorMsg">
<text class="vci-tips__text">{{ errorMsg }}</text>
</view>
<input
ref="inputRef"
class="vci-hidden-input"
type="number"
:maxlength="codeLength"
:value="code"
:focus="isFocused"
:adjust-position="false"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
/>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
},
length: {
type: Number,
default: 6
},
mask: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'box'
},
errorMsg: {
type: String,
default: ''
},
autoFocus: {
type: Boolean,
default: false
},
activeColor: {
type: String,
default: '#1677FF'
},
filledColor: {
type: String,
default: '#1A202C'
},
borderColor: {
type: String,
default: '#E2E8F0'
}
})
const emit = defineEmits(['update:modelValue', 'complete', 'focus', 'blur'])
const code = ref(props.modelValue)
const isFocused = ref(false)
const currentIndex = ref(0)
const inputRef = ref(null)
const codeLength = computed(() => props.length)
watch(() => props.modelValue, (val) => {
code.value = val
currentIndex.value = Math.min(val.length, codeLength.value - 1)
})
function focusInput() {
isFocused.value = true
}
function onInput(e) {
let val = e.detail.value.replace(/\D/g, '').slice(0, codeLength.value)
code.value = val
currentIndex.value = Math.min(val.length, codeLength.value - 1)
emit('update:modelValue', val)
if (val.length === codeLength.value) {
emit('complete', val)
}
}
function onFocus() {
isFocused.value = true
currentIndex.value = code.value.length
emit('focus')
}
function onBlur() {
isFocused.value = false
emit('blur')
}
function clear() {
code.value = ''
currentIndex.value = 0
emit('update:modelValue', '')
}
function setFocus(val) {
isFocused.value = val
}
defineExpose({
clear,
focusInput,
setFocus
})
</script>
<style lang="scss" scoped>
.verify-code-input {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.vci-title {
margin-bottom: 8px;
&__text {
font-size: 18px;
font-weight: 600;
color: #1A202C;
}
}
.vci-desc {
margin-bottom: 32px;
&__text {
font-size: 13px;
color: #718096;
}
}
.vci-code-box {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.vci-code-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&--box {
width: 44px;
height: 44px;
background: #FFFFFF;
border: 1px solid var(--vci-border-color, #E2E8F0);
border-radius: 8px;
&.vci-code-item--active {
border-color: var(--vci-active-color, #1677FF);
}
&.vci-code-item--filled {
border-color: var(--vci-filled-color, #1A202C);
}
}
&--underline {
width: 40px;
height: 50px;
border-bottom: 2px solid var(--vci-border-color, #E2E8F0);
&.vci-code-item--active {
border-bottom-color: var(--vci-active-color, #FF8C42);
}
&.vci-code-item--filled {
border-bottom-color: var(--vci-filled-color, #1A202C);
}
}
}
.vci-code-text {
font-size: 24px;
font-weight: 600;
color: var(--vci-filled-color, #1A202C);
line-height: 1;
}
.vci-code-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--vci-filled-color, #1A202C);
}
.vci-code-cursor {
position: absolute;
width: 2px;
height: 24px;
background: var(--vci-active-color, #1677FF);
animation: vci-blink 1s infinite;
}
@keyframes vci-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.vci-tips {
text-align: center;
margin-bottom: 16px;
&__text {
font-size: 13px;
color: #E53E3E;
}
}
.vci-hidden-input {
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0;
}
</style>
@@ -1,4 +1,4 @@
<template> <template>
<!-- 团课卡片容器 --> <!-- 团课卡片容器 -->
<view class="course-card" @click="goDetail"> <view class="course-card" @click="goDetail">
<!-- 卡片顶部图片区域 --> <!-- 卡片顶部图片区域 -->
@@ -7,7 +7,7 @@
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view> <view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
<!-- 课程封面图片 --> <!-- 课程封面图片 -->
<image <image
:src="course.coverImage" :src="getCourseCoverUrl(course.coverImage)"
mode="aspectFill" mode="aspectFill"
class="cover-image" class="cover-image"
:class="{ hidden: !imageLoaded }" :class="{ hidden: !imageLoaded }"
@@ -47,10 +47,20 @@
</view> </view>
<!-- 课程时长 --> <!-- 课程时长 -->
<view class="course-duration"> <view class="course-tags">
<view class="tag-item duration-tag">
<uni-icons type="time" size="14" color="#8A99B4" /> <uni-icons type="time" size="14" color="#8A99B4" />
<text>{{ formatDuration(course.startTime, course.endTime) }}</text> <text>{{ formatDuration(course.startTime, course.endTime) }}</text>
</view> </view>
<view
v-for="label in courseTypeLabels"
:key="label.id"
class="tag-item type-tag"
:style="{ background: `${label.color}15`, color: label.color }"
>
<text>{{ label.labelName }}</text>
</view>
</view>
</view> </view>
<!-- 卡片底部操作区域 --> <!-- 卡片底部操作区域 -->
@@ -93,7 +103,7 @@
</view> </view>
<!-- 预约按钮 --> <!-- 预约按钮 -->
<view :class="['booking-btn', { disabled: !canBook }]" @click.stop="handleBooking"> <view :class="['booking-btn', { disabled: !canBook }]" @click.stop="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : '已结束') }}</text> <text>{{ buttonText }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -101,6 +111,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { getCourseCoverUrl } from '@/utils/request.js'
// //
const imageLoaded = ref(false) const imageLoaded = ref(false)
@@ -109,6 +120,14 @@ const props = defineProps({
course: { course: {
type: Object, type: Object,
required: true required: true
},
courseTypeLabels: {
type: Array,
default: () => []
},
booked: {
type: Boolean,
default: false
} }
}) })
@@ -133,7 +152,14 @@ const statusClass = computed(() => {
const canBook = computed(() => { const canBook = computed(() => {
const status = props.course.status const status = props.course.status
const isFull = props.course.currentMembers >= props.course.maxMembers const isFull = props.course.currentMembers >= props.course.maxMembers
return status === '0' && !isFull return status === '0' && !isFull && !props.booked
})
const buttonText = computed(() => {
if (props.booked) return '已预约'
if (!canBook.value && props.course.currentMembers >= props.course.maxMembers) return '已满员'
if (!canBook.value && Number(props.course.status) !== 0) return '已结束'
return '立即预约'
}) })
const formatTime = (dateStr) => { const formatTime = (dateStr) => {
@@ -150,14 +176,34 @@ const formatTime = (dateStr) => {
const formatDuration = (startStr, endStr) => { const formatDuration = (startStr, endStr) => {
if (!startStr || !endStr) return '' if (!startStr || !endStr) return ''
const start = new Date(startStr) const start = new Date(startStr)
const end = new Date(endStr) const end = new Date(endStr)
const minutes = Math.floor((end.getTime() - start.getTime()) / 60000)
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
console.warn(`[CourseCard] 无效的时间格式: startTime=${startStr}, endTime=${endStr}`)
return ''
}
const diffMs = end.getTime() - start.getTime()
if (diffMs <= 0) {
console.warn(`[CourseCard] 结束时间小于或等于开始时间: startTime=${startStr}, endTime=${endStr}`)
return ''
}
const maxDurationMinutes = 24 * 60
const minutes = Math.floor(diffMs / 60000)
if (minutes > maxDurationMinutes) {
console.warn(`[CourseCard] 课程时长超过24小时,已修正: ${minutes}分钟 -> ${maxDurationMinutes}分钟`)
return `${maxDurationMinutes}分钟以上`
}
return `${minutes}分钟` return `${minutes}分钟`
} }
const goDetail = () => { const goDetail = () => {
emit('detail', props.course.id) emit('detail', Number(props.course.id))
} }
const handleBooking = () => { const handleBooking = () => {
@@ -340,19 +386,35 @@ const handleBooking = () => {
flex: 1; flex: 1;
} }
/* 课程时长 */ /* 课程标签区域 */
.course-duration { .course-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
/* 标签项 */
.tag-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8rpx; gap: 6rpx;
padding: 10rpx 20rpx; padding: 10rpx 20rpx;
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-radius: 20rpx; border-radius: 20rpx;
font-size: 24rpx; font-size: 24rpx;
color: #3B82F6;
font-weight: 500; font-weight: 500;
} }
/* 时长标签 */
.duration-tag {
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
color: #3B82F6;
}
/* 类型标签 */
.type-tag {
font-weight: 600;
}
/* 卡片底部区域 */ /* 卡片底部区域 */
.card-footer { .card-footer {
padding: 24rpx 32rpx 28rpx; padding: 24rpx 32rpx 28rpx;
@@ -1,5 +1,20 @@
<template> <template>
<view class="filter-section"> <view class="filter-section">
<!-- 课程类型筛选 -->
<picker
mode="selector"
:range="courseTypeOptions"
range-key="label"
:value="courseTypeIndex"
@change="onCourseTypeChange"
>
<view class="filter-item">
<uni-icons type="apps" size="18" color="#5E6F8D" class="filter-icon" />
<text class="filter-text">{{ courseTypeOptions[courseTypeIndex]?.label || '全部类型' }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
</picker>
<!-- 时间区间筛选 --> <!-- 时间区间筛选 -->
<view class="filter-item" @click="handleTimePick"> <view class="filter-item" @click="handleTimePick">
<uni-icons type="calendar" size="18" color="#5E6F8D" class="filter-icon" /> <uni-icons type="calendar" size="18" color="#5E6F8D" class="filter-icon" />
@@ -25,7 +40,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch, computed } from 'vue'
const props = defineProps({ const props = defineProps({
timeRangeText: { timeRangeText: {
@@ -35,46 +50,90 @@ const props = defineProps({
sortOptions: { sortOptions: {
type: Array, type: Array,
default: () => [ default: () => [
{ label: '默认排序', value: 'default' }, { label: '默认排序', value: 'default', priceSort: null, remainingMost: false },
{ label: '价格从低到高', value: 'priceAsc' }, { label: '价格从低到高', value: 'priceAsc', priceSort: 'asc', remainingMost: false },
{ label: '价格从高到低', value: 'priceDesc' }, { label: '价格从高到低', value: 'priceDesc', priceSort: 'desc', remainingMost: false },
{ label: '剩余名额最多', value: 'spotsDesc' }, { label: '剩余名额最多', value: 'remainingMost', priceSort: null, remainingMost: true }
{ label: '仅次数卡', value: 'pointCardOnly' },
{ label: '仅储值卡', value: 'storedValueOnly' },
{ label: '两种支付', value: 'bothPayment' }
] ]
}, },
sortIndex: { sortIndex: {
type: Number, type: Number,
default: 0 default: 0
},
courseTypes: {
type: Array,
default: () => []
},
currentCourseTypeId: {
type: [Number, String, null],
default: null,
validator: (val) => val === null || val === '' || !isNaN(Number(val))
} }
}) })
const emit = defineEmits(['update:sortIndex', 'timePick']) const emit = defineEmits(['update:sortIndex', 'timePick', 'courseTypeChange'])
const localSortIndex = ref(props.sortIndex) const localSortIndex = ref(props.sortIndex)
const courseTypeOptions = computed(() => {
return [{ id: null, label: '全部类型' }, ...props.courseTypes]
})
const courseTypeIndex = computed(() => {
if (props.currentCourseTypeId === null || props.currentCourseTypeId === '') return 0
const currentId = Number(props.currentCourseTypeId)
const index = courseTypeOptions.value.findIndex(item => Number(item.id) === currentId)
return index >= 0 ? index : 0
})
watch(() => props.sortIndex, (val) => { watch(() => props.sortIndex, (val) => {
localSortIndex.value = val localSortIndex.value = val
}) })
const onSortChange = (e) => { const onSortChange = (e) => {
localSortIndex.value = e.detail.value localSortIndex.value = e.detail.value
const sortOption = props.sortOptions[localSortIndex.value]
console.log('[FilterSection] 排序方式变更:', { console.log('[FilterSection] 排序方式变更:', {
index: localSortIndex.value, index: localSortIndex.value,
value: props.sortOptions[localSortIndex.value] value: sortOption
}) })
if (sortOption.priceSort && sortOption.remainingMost) {
console.warn('[FilterSection] 排序参数冲突警告: priceSort和remainingMost不能同时设置')
}
emit('update:sortIndex', localSortIndex.value) emit('update:sortIndex', localSortIndex.value)
} }
const onCourseTypeChange = (e) => {
const index = e.detail.value
const selectedType = courseTypeOptions.value[index]
//
const typeId = selectedType.id !== null ? Number(selectedType.id) : null
console.log('[FilterSection] 课程类型变更:', {
index,
id: typeId,
label: selectedType.label
})
emit('courseTypeChange', typeId)
}
const handleTimePick = () => { const handleTimePick = () => {
console.log('[FilterSection] 触发时间选择器') console.log('[FilterSection] 触发时间选择器')
emit('timePick') emit('timePick')
} }
const getFilterParams = () => { const getFilterParams = () => {
const sortOption = props.sortOptions[localSortIndex.value]
const selectedType = courseTypeOptions.value[courseTypeIndex.value]
const params = { const params = {
sortType: props.sortOptions[localSortIndex.value].value, sortType: sortOption.value,
priceSort: sortOption.priceSort,
remainingMost: sortOption.remainingMost,
courseTypeId: selectedType.id !== null ? Number(selectedType.id) : null,
courseTypeName: selectedType.label,
timeRangeText: props.timeRangeText timeRangeText: props.timeRangeText
} }
console.log('[FilterSection] 获取筛选参数:', params) console.log('[FilterSection] 获取筛选参数:', params)
@@ -173,18 +232,20 @@ defineExpose({
<style lang="scss" scoped> <style lang="scss" scoped>
.filter-section { .filter-section {
display: flex; display: flex;
flex-wrap: wrap;
gap: 16rpx; gap: 16rpx;
.filter-item { .filter-item {
flex: 1; flex: 1;
min-width: calc(33.33% - 12rpx);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12rpx; gap: 8rpx;
padding: 20rpx 24rpx; padding: 18rpx 16rpx;
background: #F5F7FA; background: #F5F7FA;
border-radius: 16rpx; border-radius: 16rpx;
font-size: 26rpx; font-size: 24rpx;
color: #5E6F8D; color: #5E6F8D;
.filter-icon { .filter-icon {
@@ -197,6 +258,13 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
} }
.filter-text {
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
} }
</style> </style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="search-bar-wrapper"> <view class="search-bar-wrapper">
<!-- 搜索框 --> <!-- 搜索框 -->
<view class="search-bar"> <view class="search-bar">
@@ -1,4 +1,4 @@
<template> <template>
<view class="time-period-selector"> <view class="time-period-selector">
<view class="sort-header"> <view class="sort-header">
<uni-icons type="calendar" size="16" color="#FF6B35" class="sort-icon" /> <uni-icons type="calendar" size="16" color="#FF6B35" class="sort-icon" />
@@ -1,4 +1,4 @@
<template> <template>
<view> <view>
<!-- 时间范围选择弹窗 --> <!-- 时间范围选择弹窗 -->
<Transition name="modal"> <Transition name="modal">
@@ -121,6 +121,8 @@ const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const quickOptions = [ const quickOptions = [
{ label: '近7天', value: '7d' }, { label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' }, { label: '近30天', value: '30d' },
{ label: '未来7天', value: 'future7d' },
{ label: '未来30天', value: 'future30d' },
{ label: '本月', value: 'month' }, { label: '本月', value: 'month' },
{ label: '上月', value: 'lastMonth' } { label: '上月', value: 'lastMonth' }
] ]
@@ -218,12 +220,14 @@ function isToday(year, month, day) {
day === today.getDate() day === today.getDate()
} }
// // 90
function isFuture(year, month, day) { function isFuture(year, month, day) {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const maxFuture = new Date(today)
maxFuture.setDate(maxFuture.getDate() + 90) // 90
const date = new Date(year, month - 1, day) const date = new Date(year, month - 1, day)
return date > today return date > maxFuture
} }
// //
@@ -273,7 +277,7 @@ const selectDate = (day) => {
console.log(`[TimeRangePicker] ${pickerType.value === 'start' ? '开始' : '结束'}日期变更:`, day.date) console.log(`[TimeRangePicker] ${pickerType.value === 'start' ? '开始' : '结束'}日期变更:`, day.date)
} }
// //
const applyQuickOption = (value) => { const applyQuickOption = (value) => {
quickSelected.value = value quickSelected.value = value
const today = new Date() const today = new Date()
@@ -281,18 +285,32 @@ const applyQuickOption = (value) => {
switch (value) { switch (value) {
case '7d': case '7d':
// 77
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000) startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
endDate = today endDate = today
break break
case '30d': case '30d':
// 3030
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000) startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate = today endDate = today
break break
case 'future7d':
// 77
startDate = today
endDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
break
case 'future30d':
// 3030
startDate = today
endDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)
break
case 'month': case 'month':
// 1
startDate = new Date(today.getFullYear(), today.getMonth(), 1) startDate = new Date(today.getFullYear(), today.getMonth(), 1)
endDate = today endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
break break
case 'lastMonth': case 'lastMonth':
// 1
startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1) startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1)
endDate = new Date(today.getFullYear(), today.getMonth(), 0) endDate = new Date(today.getFullYear(), today.getMonth(), 0)
break break
@@ -45,9 +45,11 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { getActiveBanners } from '@/api/main.js'
const banners = [ //
const fallbackBanners = [
{ {
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80', image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
title: '突破自我', title: '突破自我',
@@ -68,9 +70,49 @@ const banners = [
} }
] ]
// 退 swiper
const banners = ref([...fallbackBanners])
const currentIndex = ref(0) const currentIndex = ref(0)
// const imageLoaded = ref(fallbackBanners.map(() => false))
const imageLoaded = ref(banners.map(() => false)) const isFetching = ref(false)
async function fetchBanners() {
isFetching.value = true
try {
//
const res = await getActiveBanners({ cache: false })
// { data: [...] }
const data = Array.isArray(res) ? res : (res?.data || [])
if (data.length > 0) {
// 退
banners.value = data.map(item => ({
image: item.imageUrl || '',
title: item.title || '',
subtitle: item.subtitle || '',
desc: item.description || ''
}))
// imageLoaded
imageLoaded.value = banners.value.map(() => false)
console.log('[BannerSwiper] 轮播图数据加载成功,共', data.length, '条')
} else {
console.log('[BannerSwiper] 后端无轮播图数据,使用回退数据')
// fallbackBanners
imageLoaded.value = banners.value.map(() => false)
}
} catch (err) {
console.error('[BannerSwiper] 轮播图数据加载失败,使用回退数据:', err)
// fallbackBanners
imageLoaded.value = banners.value.map(() => false)
} finally {
isFetching.value = false
}
}
onMounted(() => {
fetchBanners()
})
const onSwiperChange = (e) => { const onSwiperChange = (e) => {
currentIndex.value = e.detail.current currentIndex.value = e.detail.current
@@ -86,30 +128,28 @@ const previewImage = (index) => {
}) })
} }
//
const onImageLoad = (index) => { const onImageLoad = (index) => {
imageLoaded.value[index] = true imageLoaded.value[index] = true
console.log(`图片 ${index} 加载完成`) console.log(`图片 ${index} 加载完成`)
} }
//
const onImageError = (index) => { const onImageError = (index) => {
console.error(`图片 ${index} 加载失败`) console.error(`图片 ${index} 加载失败, URL:`, banners.value[index]?.image?.substring(0, 120))
//
// banners[index].image = 'URL'
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.banner-container { .banner-container {
position: relative;
z-index: 2;
width: 100%; width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
} }
.banner-swiper { .banner-swiper {
width: 100%; width: 100%;
height: 480rpx; height: 360rpx;
border-radius: 20rpx;
overflow: hidden;
} }
.banner-content { .banner-content {
@@ -124,7 +164,6 @@ const onImageError = (index) => {
height: 100%; height: 100%;
} }
/* 添加图片占位符样式 */
.image-placeholder { .image-placeholder {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -158,10 +197,10 @@ const onImageError = (index) => {
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 2; /* 确保遮罩层在占位符上面 */ background: linear-gradient(135deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.25) 100%);
z-index: 2;
} }
/* 其余样式保持不变 */
.banner-text { .banner-text {
position: absolute; position: absolute;
left: 36rpx; left: 36rpx;
@@ -198,9 +237,7 @@ const onImageError = (index) => {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 16rpx; gap: 16rpx;
margin-top: -100rpx; padding: 20rpx 0 8rpx;
position: relative;
z-index: 3;
} }
.dot { .dot {
@@ -0,0 +1,273 @@
<template>
<!-- 课程卡片 -->
<view class="course-card" :style="cardStyle">
<!-- 课程图片区域 -->
<view class="course-image" :style="imageStyle">
<!-- 课程封面图片 -->
<image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 -->
<view class="course-overlay"></view>
<!-- 课程标签 -->
<text :class="['course-tag', course.tagType]">{{ course.tag }}</text>
<!-- 课程信息区域 -->
<view class="course-info">
<!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 -->
<view class="course-meta">
<!-- 时长信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png"/>
</text>
<text>{{ course.duration }}</text>
</view>
<!-- 难度信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png"/>
</text>
<text>{{ course.level }}</text>
</view>
</view>
</view>
</view>
<!-- 课程底部区域 -->
<view class="course-footer">
<!-- 参与人数信息 -->
<view class="participants">
<text class="fire-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png"/>
</text>
<text>{{ course.participants }}人参与</text>
</view>
<!-- 去参与按钮 -->
<view class="join-btn" @click="handleJoinCourse">
<text>去参与</text>
</view>
</view>
</view>
</template>
<script setup>
import { defineProps, computed } from 'vue'
// props
const props = defineProps({
course: {
type: Object,
required: true,
default: () => ({
id: '',
image: '',
tag: '',
tagType: 'default',
name: '',
duration: '',
level: '',
participants: 0,
rawData: null
})
},
width: {
type: [Number, String],
default: 320,
description: '卡片宽度,单位 rpx'
},
height: {
type: [Number, String],
default: null,
description: '卡片高度,单位 rpx,不传则自适应'
},
imageHeight: {
type: [Number, String],
default: 280,
description: '图片区域高度,单位 rpx'
},
borderRadius: {
type: [Number, String],
default: 24,
description: '卡片圆角,单位 rpx'
}
})
//
const cardStyle = computed(() => {
const style = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}rpx` : props.width
}
if (props.height) {
style.height = typeof props.height === 'number' ? `${props.height}rpx` : props.height
}
if (props.borderRadius) {
style.borderRadius = typeof props.borderRadius === 'number' ? `${props.borderRadius}rpx` : props.borderRadius
}
return style
})
//
const imageStyle = computed(() => {
const style = {}
if (props.imageHeight) {
style.height = typeof props.imageHeight === 'number' ? `${props.imageHeight}rpx` : props.imageHeight
}
return style
})
//
const handleJoinCourse = () => {
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${props.course.id}` })
}
</script>
<style lang="scss">
.course-card {
min-width: 320rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
min-height: 400rpx;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
border: 1rpx solid rgba(255, 255, 255, 0.6);
display: block;
}
.course-image {
min-height: 280rpx;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20rpx;
}
.img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.course-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(45, 74, 90, 0.7) 0%, transparent 60%);
}
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
z-index: 2;
&.hot {
background: linear-gradient(135deg, #6BA8C0, #8CC5D5);
}
&.new {
background: linear-gradient(135deg, #6DB5C8, #90CEDD);
}
&.free {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #B0C0CC, #C4D2DC);
}
&.default {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
}
.course-info {
position: relative;
z-index: 2;
}
.course-name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
}
.course-meta {
display: flex;
gap: 16rpx;
align-items: center;
}
.meta-item {
display: flex;
align-items: end;
gap: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.meta-icon {
font-size: 20rpx;
image{
display: flex;
align-items: center;
width: 25rpx;
height: 25rpx;
}
}
.course-footer {
padding: 16rpx 10rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.participants {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 22rpx;
color: var(--tabbar-text-inactive);
}
.fire-icon {
font-size: 24rpx;
image{
display: flex;
align-items: center;
width: 30rpx;
height: 30rpx;
}
}
.join-btn {
padding: 12rpx 28rpx;
background: rgba(130, 220, 130, 0.9);
border: none;
border-radius: 9999rpx;
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
box-shadow: 0 6rpx 16rpx rgba(130, 220, 130, 0.35);
}
</style>
@@ -0,0 +1,142 @@
<template>
<!-- 状态栏占位区为刘海屏和安全区域留出空间 -->
<view class="tab-page__status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 导航栏主体 -->
<view class="tab-page__header">
<view v-if="showBack" :class="['tab-page__back-btn', { 'tab-page__back-btn--animate': isAnimating }]" @tap="goBack">
<text class="tab-page__back-icon"></text>
</view>
<view class="tab-page__title-wrap">
<text class="tab-page__title">{{ title }}</text>
<text v-if="subtitle" class="tab-page__subtitle">{{ subtitle }}</text>
</view>
<view v-if="$slots.right" class="tab-page__right">
<slot name="right"></slot>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: '课程'
},
subtitle: {
type: String,
default: '精品团课 · 私教 · 线上课'
},
showBack: {
type: Boolean,
default: false
}
})
const isAnimating = ref(false)
const statusBarHeight = ref(0)
onMounted(() => {
// /
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
if (props.showBack) {
isAnimating.value = true
}
})
function goBack() {
uni.navigateBack({
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
</script>
<style lang="scss" scoped>
/* 状态栏占位:适配所有平台,包含刘海屏安全区域 */
.tab-page__status-bar {
width: 100%;
/* iOS 刘海屏额外安全区域 */
padding-top: constant(safe-area-inset-top); /* iOS 11.0-11.2 */
padding-top: env(safe-area-inset-top); /* iOS 11.2+ */
box-sizing: content-box;
}
.tab-page__header {
padding: 0 32rpx 24rpx 32rpx;
display: flex;
align-items: center;
height: 88rpx;
position: relative;
}
.tab-page__back-btn {
width: 48rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 16rpx;
opacity: 0;
transform: translateX(120rpx);
transition: none;
z-index: 1;
}
.tab-page__back-btn--animate {
animation: slideInFromRight 0.4s ease-out forwards;
}
@keyframes slideInFromRight {
0% {
opacity: 0;
transform: translateX(120rpx);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.tab-page__back-icon {
font-size: 36rpx;
color: $text-dark;
font-weight: bold;
line-height: 28rpx;
}
.tab-page__title-wrap {
flex: 1;
position: relative;
z-index: 2;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: $font-weight-bold;
color: $text-dark;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $text-muted;
}
.tab-page__right {
margin-left: 16rpx;
flex-shrink: 0;
display: flex;
align-items: center;
}
</style>
+113 -29
View File
@@ -4,7 +4,7 @@
v-for="(item, index) in entries" v-for="(item, index) in entries"
:key="index" :key="index"
class="entry-item" class="entry-item"
@tap="QEClick(item.path)" @tap="handleEntryClick(item)"
> >
<view :class="['entry-icon', { accent: item.accent }]"> <view :class="['entry-icon', { accent: item.accent }]">
<image :src="item.icon" mode="aspectFit" class="icon-img" /> <image :src="item.icon" mode="aspectFit" class="icon-img" />
@@ -16,37 +16,112 @@
</template> </template>
<script setup> <script setup>
import { qrSignInGroupCourse } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
const QEClick = path => { const handleEntryClick = (item) => {
if (item.title === '签到') {
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '请登录',
content: '是否跳转到登录页面?',
confirmText: '确定',
cancelText: '取消',
confirmColor: '#FF6B35',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ uni.navigateTo({
url:path url: '/pages/login/login'
})
}
}
})
return
}
//
qrScanSignIn()
} else if (item.path) {
if (item.isTabBar) {
uni.switchTab({
url: item.path
})
} else {
uni.navigateTo({
url: item.path
})
}
}
}
const qrScanSignIn = () => {
uni.scanCode({
onlyFromCamera: true,
success: async (res) => {
console.log('扫码结果:', res)
const qrContent = res.result
// courseId
let courseId = null
let courseName = ''
try {
const parsed = JSON.parse(qrContent)
courseId = Number(parsed.id || parsed.courseId)
courseName = parsed.courseName || ''
} catch {
const num = Number(qrContent)
if (!isNaN(num)) {
courseId = num
}
}
if (!courseId) {
uni.showToast({ title: '无效的签到二维码', icon: 'none' })
return
}
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showLoading({ title: '签到中...' })
try {
const result = await qrSignInGroupCourse(courseId, memberId)
uni.hideLoading()
if (result.success) {
uni.showToast({ title: courseName ? `${courseName}」签到成功` : '签到成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({ url: '/pages/memberInfo/myCourses' })
}, 1200)
} else {
uni.showToast({ title: result.message || '签到失败', icon: 'none', duration: 3000 })
}
} catch (e) {
uni.hideLoading()
console.error('团课签到失败:', e)
let errMsg = e?.message || e?.data?.message || '签到失败'
//
if (courseName && !errMsg.includes(courseName)) {
errMsg = `${courseName}${errMsg}`
}
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
},
fail: (err) => {
console.error('扫码失败:', err)
}
}) })
} }
const entries = [ const entries = [
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
title: '找课程', title: '找课程',
desc: '精品课程', desc: '精品课程',
accent: false, accent: false,
path: "/pages/searchCourse/searchCourse" path: "/pages/groupCourse/list"
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
title: '训练计划',
desc: '个性定制',
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
title: '健身数据',
desc: '记录分析',
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png',
title: '消息',
desc: '通知消息',
accent: true
}, },
{ {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
@@ -63,15 +138,10 @@ const entries = [
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 32rpx 24rpx; padding: 32rpx 24rpx;
background: rgba(255, 255, 255, 0.55); background: #FFFFFF;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
margin: 24rpx; margin: 24rpx;
border-radius: 28rpx; border-radius: 28rpx;
box-shadow: 0 8rpx 32rpx var(--shadow-blue-light); box-shadow: 0 8rpx 32rpx rgba(45,74,90,0.08);
border: 1rpx solid rgba(255, 255, 255, 0.7);
position: relative;
z-index: 3;
} }
.entry-item { .entry-item {
@@ -91,6 +161,20 @@ const entries = [
justify-content: center; justify-content: center;
margin-bottom: 16rpx; margin-bottom: 16rpx;
box-shadow: 0 6rpx 20rpx rgba(130, 220, 130, 0.35); box-shadow: 0 6rpx 20rpx rgba(130, 220, 130, 0.35);
position: relative;
}
.badge-dot {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 16rpx;
height: 16rpx;
background: linear-gradient(135deg, #FF4757, #FF2D55);
border-radius: 50%;
box-shadow: 0 2rpx 8rpx rgba(255, 71, 87, 0.5);
z-index: 99;
animation: pulse 2s infinite;
} }
.icon-img { .icon-img {
@@ -6,7 +6,7 @@
<!-- 区域标题 --> <!-- 区域标题 -->
<text class="section-title">推荐课程</text> <text class="section-title">推荐课程</text>
<!-- 查看更多按钮 --> <!-- 查看更多按钮 -->
<view class="view-more"> <view class="view-more" @click="handleViewMore">
<text>查看更多</text> <text>查看更多</text>
<text class="arrow"> <text class="arrow">
<uni-icons type="right" size="20" color="#8CA0B0"/> <uni-icons type="right" size="20" color="#8CA0B0"/>
@@ -23,6 +23,7 @@
v-for="(course, index) in courses" v-for="(course, index) in courses"
:key="course.id || index" :key="course.id || index"
class="course-card" class="course-card"
@click="handleJoinCourse(course)"
> >
<!-- 课程图片区域 --> <!-- 课程图片区域 -->
<view class="course-image"> <view class="course-image">
@@ -30,42 +31,36 @@
<image :src="course.image" mode="aspectFill" class="img" /> <image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 --> <!-- 图片渐变遮罩 -->
<view class="course-overlay"></view> <view class="course-overlay"></view>
<!-- 课程标签 -->
<text :class="['course-tag', course.tagType]">{{ course.tag }}</text>
<!-- 课程信息区域 --> <!-- 课程信息区域 -->
<view class="course-info"> <view class="course-info">
<!-- 课程名称 --> <!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text> <text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 --> <!-- 课程元信息时长难度 -->
<view class="course-meta"> <view class="course-meta">
<view class="meta-left">
<!-- 时长信息 --> <!-- 时长信息 -->
<view class="meta-item"> <view class="meta-item time-tag">
<text class="meta-icon"> <image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png" class="meta-icon" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png"/>
</text>
<text>{{ course.duration }}</text> <text>{{ course.duration }}</text>
</view> </view>
<!-- 难度信息 --> <!-- 难度信息 -->
<view class="meta-item"> <view class="meta-item level-tag">
<text class="meta-icon"> <image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png" class="meta-icon" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png"/>
</text>
<text>{{ course.level }}</text> <text>{{ course.level }}</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view>
<!-- 课程底部区域 --> <!-- 课程底部区域 -->
<view class="course-footer"> <view class="course-footer">
<!-- 参与人数信息 --> <!-- 参与人数信息 -->
<view class="participants"> <view class="participants">
<text class="fire-icon"> <image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png" class="fire-icon" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png"/>
</text>
<text>{{ course.participants }}人参与</text> <text>{{ course.participants }}人参与</text>
</view> </view>
<!-- 去参与按钮 --> <!-- 去参与按钮 -->
<view class="join-btn" @click="handleJoinCourse(course)"> <view class="join-btn" @click.stop="handleJoinCourse(course)">
<text>去参与</text> <text>去参与</text>
</view> </view>
</view> </view>
@@ -77,46 +72,24 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getGroupCoursePage } from '@/api/main.js' import { onShow } from '@dcloudio/uni-app'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
// true 使false 使API import { getCourseCoverUrl } from '@/utils/request.js'
const USE_MOCK_DATA = true
console.log('[RecommendCourses] 组件脚本已加载')
// //
const courses = ref([]) const courses = ref([])
//
const getCourseTypeName = (type) => {
const typeMap = {
'1': '瑜伽',
'2': '搏击',
'3': '塑形'
}
return typeMap[type] || '课程'
}
//
const getTag = (course) => {
if (course.currentMembers >= course.maxMembers) return '已满员'
if (course.status === '2') return '已结束'
if (course.currentMembers / course.maxMembers >= 0.8) return '热门'
return getCourseTypeName(course.courseType)
}
//
const getTagType = (course) => {
if (course.currentMembers >= course.maxMembers) return 'full'
if (course.status === '2') return 'ended'
if (course.currentMembers / course.maxMembers >= 0.8) return 'hot'
return 'default'
}
// //
const calculateDuration = (startTime, endTime) => { const calculateDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '60分钟' if (!startTime || !endTime) return '60分钟'
const start = new Date(startTime) const start = new Date(startTime)
const end = new Date(endTime) const end = new Date(endTime)
const durationMinutes = Math.floor((end - start) / (1000 * 60)) const diffMs = end - start
if (diffMs <= 0) return ''
const durationMinutes = Math.floor(diffMs / (1000 * 60))
return `${durationMinutes}分钟` return `${durationMinutes}分钟`
} }
@@ -128,59 +101,77 @@ const getCourseLevel = (course) => {
return '初级' return '初级'
} }
// URL // URL - getOssUrl
const getImageUrl = (coverImage) => { // 使 @/utils/request.js getOssUrl
if (!coverImage) return 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
if (coverImage.startsWith('http')) return coverImage
return `https://your-domain.com${coverImage}`
}
// //
const fetchRecommendCourses = async () => { const fetchRecommendCourses = async () => {
// 使 console.log('[RecommendCourses] fetchRecommendCourses 开始请求')
if (USE_MOCK_DATA) {
useFallbackData()
return
}
try { try {
const res = await getGroupCoursePage({ const res = await getActiveRecommendCourses()
page: 0, size: 5, sort: 'current_members', order: 'desc' console.log('[RecommendCourses] API 返回:', JSON.stringify(res).substring(0, 200))
}, { cache: true, cacheTime: 5 * 60 * 1000 }) if (res && Array.isArray(res) && res.length > 0) {
if (res && res.content && Array.isArray(res.content)) { console.log('[RecommendCourses] 第一项 groupCourse.coverImage:', JSON.stringify(res[0]?.groupCourse?.coverImage))
courses.value = res.content.map(course => ({ console.log('[RecommendCourses] 第一项完整 groupCourse:', JSON.stringify(res[0]?.groupCourse))
id: course.id, image: getImageUrl(course.coverImage), tag: getTag(course), }
tagType: getTagType(course), name: course.courseName || '未知课程', if (res && Array.isArray(res)) {
duration: calculateDuration(course.startTime, course.endTime), // groupCourse
level: getCourseLevel(course), participants: course.currentMembers || 0, rawData: course const filteredCourses = res.filter(recommend => {
})) const course = recommend.groupCourse
} else { useFallbackData() } if (!course) return false
} catch (err) { useFallbackData() } const current = Number(course.currentMembers) || 0
} const max = Number(course.maxMembers) || 999
return current < max
})
const useFallbackData = () => { // 3
const fallbackContent = [ courses.value = filteredCourses.slice(0, 3).map(recommend => {
{ id: "3", courseName: "燃脂搏击", courseType: "2", startTime: "2026-06-10T18:30:00", endTime: "2026-06-10T19:30:00", maxMembers: 20, currentMembers: 20, status: "0", coverImage: "https://picsum.photos/id/100/800/600", description: "高强度间歇训练" }, const course = recommend.groupCourse
{ id: "2", courseName: "清晨流瑜伽", courseType: "1", startTime: "2026-06-12T09:00:00", endTime: "2026-06-12T10:30:00", maxMembers: 15, currentMembers: 5, status: "0", coverImage: "https://picsum.photos/id/101/800/600", description: "流畅体式" }, const rawCover = course.coverImage
{ id: "4", courseName: "哈他瑜伽", courseType: "1", startTime: "2026-06-01T15:20:00", endTime: "2026-06-01T16:50:00", maxMembers: 12, currentMembers: 3, status: "0", coverImage: "https://picsum.photos/id/102/800/600", description: "基础瑜伽" }, const finalImage = getCourseCoverUrl(rawCover) || 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
{ id: "6", courseName: "蜜桃臀塑造", courseType: "3", startTime: "2026-05-30T19:00:00", endTime: "2026-05-30T20:00:00", maxMembers: 10, currentMembers: 8, status: "2", coverImage: "https://picsum.photos/id/103/800/600", description: "臀部训练" }, console.log('[RecommendCourses] 封面URL映射:', {
{ id: "7", courseName: "午间冥想放松", courseType: "1", startTime: "2026-05-31T12:00:00", endTime: "2026-05-31T13:00:00", maxMembers: 15, currentMembers: 6, status: "2", coverImage: "https://picsum.photos/id/104/800/600", description: "冥想" } 'courseName': course.courseName,
] '原始coverImage': rawCover,
courses.value = fallbackContent.map(course => ({ '转换后URL': finalImage,
id: course.id, image: getImageUrl(course.coverImage), tag: getTag(course), '是否为完整URL': finalImage.startsWith('http')
tagType: getTagType(course), name: course.courseName || '未知课程', })
return {
id: course.id,
image: finalImage,
name: course.courseName || '未知课程',
duration: calculateDuration(course.startTime, course.endTime), duration: calculateDuration(course.startTime, course.endTime),
level: getCourseLevel(course), participants: course.currentMembers || 0, rawData: course level: getCourseLevel(course),
})) participants: course.currentMembers || 0,
rawData: course
}
})
console.log('[RecommendCourses] 最终展示 courses.value 数量:', courses.value.length)
} else {
console.warn('[RecommendCourses] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取推荐课程失败:', err)
}
} }
const handleJoinCourse = (course) => { const handleJoinCourse = (course) => {
if (course.rawData.status === '2') { uni.showToast({ title: '课程已结束', icon: 'none' }); return } if (course.rawData.status === '2') { uni.showToast({ title: '课程已结束', icon: 'none' }); return }
if (course.rawData.currentMembers >= course.rawData.maxMembers) { uni.showToast({ title: '课程已满员', icon: 'none' }); return } if (course.rawData.currentMembers >= course.rawData.maxMembers) { uni.showToast({ title: '课程已满员', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/course/detail?id=${course.id}` }) uni.navigateTo({ url: `/pages/groupCourse/detail?id=${course.id}` })
} }
onMounted(() => { fetchRecommendCourses() }) const handleViewMore = () => {
uni.navigateTo({ url: '/pages/recommendCourses/index' })
}
onMounted(() => { console.log('[RecommendCourses] onMounted 触发'); fetchRecommendCourses() })
onShow(() => {
console.log('[RecommendCourses] onShow 触发,刷新推荐课程')
fetchRecommendCourses()
})
defineExpose({ fetchRecommendCourses })
</script> </script>
<style lang="scss"> <style lang="scss">
@@ -222,14 +213,13 @@ onMounted(() => { fetchRecommendCourses() })
.courses-list { .courses-list {
display: inline-flex; display: inline-flex;
gap: 48rpx; gap: 24rpx;
padding-right: 24rpx;
} }
.course-card { .course-card {
width: 320rpx; width: 338rpx;
background: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 24rpx; border-radius: 24rpx;
overflow: hidden; overflow: hidden;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light); box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
@@ -239,18 +229,11 @@ onMounted(() => { fetchRecommendCourses() })
} }
.course-image { .course-image {
height: 280rpx; height: 260rpx;
position: relative; position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20rpx;
} }
.img { .img {
position: absolute;
left: 0;
top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@@ -264,39 +247,12 @@ onMounted(() => { fetchRecommendCourses() })
background: linear-gradient(to top, rgba(45, 74, 90, 0.7) 0%, transparent 60%); background: linear-gradient(to top, rgba(45, 74, 90, 0.7) 0%, transparent 60%);
} }
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
z-index: 2;
&.hot {
background: linear-gradient(135deg, #6BA8C0, #8CC5D5);
}
&.new {
background: linear-gradient(135deg, #6DB5C8, #90CEDD);
}
&.free {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #B0C0CC, #C4D2DC);
}
&.default {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
}
.course-info { .course-info {
position: relative; position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 20rpx;
z-index: 2; z-index: 2;
} }
@@ -306,34 +262,56 @@ onMounted(() => { fetchRecommendCourses() })
font-weight: 600; font-weight: 600;
color: #ffffff; color: #ffffff;
margin-bottom: 8rpx; margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.course-meta { .course-meta {
display: flex; display: flex;
gap: 16rpx; justify-content: space-between;
align-items: center; align-items: center;
gap: 8rpx;
}
.meta-left {
display: flex;
gap: 8rpx;
} }
.meta-item { .meta-item {
display: flex; display: flex;
align-items: end;
gap: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.meta-icon {
font-size: 20rpx;
image{
display: flex;
align-items: center; align-items: center;
width: 25rpx; gap: 6rpx;
height: 25rpx; font-size: 20rpx;
color: rgba(255, 255, 255, 0.9);
padding: 6rpx 10rpx;
border-radius: 6rpx;
background: rgba(255, 255, 255, 0.2);
line-height: 1;
.meta-icon {
vertical-align: middle;
} }
} }
.time-tag {
background: rgba(255, 255, 255, 0.25);
}
.level-tag {
background: rgba(255, 255, 255, 0.25);
}
.meta-icon {
width: 22rpx;
height: 22rpx;
display: inline-block;
vertical-align: middle;
}
.course-footer { .course-footer {
padding: 16rpx 10rpx; padding: 16rpx 20rpx;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -348,13 +326,8 @@ onMounted(() => { fetchRecommendCourses() })
} }
.fire-icon { .fire-icon {
font-size: 24rpx; width: 32rpx;
image{ height: 32rpx;
display: flex;
align-items: center;
width: 30rpx;
height: 30rpx;
}
} }
.join-btn { .join-btn {
@@ -5,13 +5,6 @@
<view class="section-header"> <view class="section-header">
<!-- 区域标题 --> <!-- 区域标题 -->
<text class="section-title">今日推荐</text> <text class="section-title">今日推荐</text>
<!-- 查看更多按钮 -->
<view class="view-more">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#8CA0B0"></uni-icons>
</text>
</view>
</view> </view>
<!-- 推荐列表 --> <!-- 推荐列表 -->
@@ -19,8 +12,9 @@
<!-- 推荐项 --> <!-- 推荐项 -->
<view <view
v-for="(item, index) in recommends" v-for="(item, index) in recommends"
:key="index" :key="item.id || index"
class="recommend-item" class="recommend-item"
@click="handleItemClick(item)"
> >
<!-- 推荐项图片 --> <!-- 推荐项图片 -->
<image :src="item.image" mode="aspectFill" class="item-image" /> <image :src="item.image" mode="aspectFill" class="item-image" />
@@ -38,7 +32,7 @@
<!-- 推荐项操作区域 --> <!-- 推荐项操作区域 -->
<view class="item-action"> <view class="item-action">
<!-- 开始训练按钮 --> <!-- 开始训练按钮 -->
<view class="start-btn"> <view class="start-btn" @click.stop="handleItemClick(item)">
<text class="start-btn-text">开始训练</text> <text class="start-btn-text">开始训练</text>
</view> </view>
<!-- 参与人数 --> <!-- 参与人数 -->
@@ -50,30 +44,99 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
import { getCourseCoverUrl } from '@/utils/request.js'
console.log('[TodayRecommend] 组件脚本已加载')
// //
const recommends = [ const recommends = ref([])
{
image: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=300&q=80', //
title: '晨间活力唤醒跑', const calculateDuration = (startTime, endTime) => {
tags: ['20分钟', '初级'], if (!startTime || !endTime) return '60分钟'
desc: '唤醒身体,开启活力一天', const start = new Date(startTime)
participants: '2784' const end = new Date(endTime)
}, const diffMs = end - start
{ if (diffMs <= 0) return ''
image: 'https://images.unsplash.com/photo-1581009146145-b5ef050c149a?w=300&q=80', const durationMinutes = Math.floor(diffMs / (1000 * 60))
title: '全身力量塑形', return `${durationMinutes}分钟`
tags: ['50分钟', '中级'], }
desc: '全身综合训练,塑造完美线条',
participants: '4126' //
}, const getCourseLevel = (course) => {
{ if (course.courseType === '2') return '中级'
image: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=300&q=80', if (course.courseType === '3') return '高级'
title: '蛋白增肌饮食指南', if (course.courseType === '1') return '初级'
tags: ['营养饮食', '12分钟'], return '初级'
desc: '科学饮食搭配,助力肌肉增长', }
participants: '1865'
// URL - getOssUrl
//
const fetchTodayRecommend = async () => {
console.log('[TodayRecommend] fetchTodayRecommend 开始请求')
try {
const res = await getActiveRecommendCourses()
console.log('[TodayRecommend] API 返回:', JSON.stringify(res).substring(0, 200))
if (res && Array.isArray(res) && res.length > 0) {
console.log('[TodayRecommend] 第一项 groupCourse.coverImage:', JSON.stringify(res[0]?.groupCourse?.coverImage))
console.log('[TodayRecommend] 第一项完整 groupCourse:', JSON.stringify(res[0]?.groupCourse))
} }
] if (res && Array.isArray(res)) {
// groupCourse
const filteredCourses = res.filter(recommend => {
const course = recommend.groupCourse
if (!course) return false
const current = Number(course.currentMembers) || 0
const max = Number(course.maxMembers) || 999
return current < max
})
recommends.value = filteredCourses.slice(0, 3).map(recommend => {
const course = recommend.groupCourse
const rawCover = course.coverImage
const finalImage = getCourseCoverUrl(rawCover) || 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=300&q=80'
console.log('[TodayRecommend] 封面URL映射:', {
'courseName': course.courseName,
'原始coverImage': rawCover,
'转换后URL': finalImage,
'是否为完整URL': finalImage.startsWith('http')
})
return {
id: course.id,
image: finalImage,
title: course.courseName || '未知课程',
tags: [calculateDuration(course.startTime, course.endTime), getCourseLevel(course)],
desc: course.description || '精彩课程,不容错过',
participants: String(course.currentMembers || 0)
}
})
console.log('[TodayRecommend] 最终展示 recommends.value 数量:', recommends.value.length)
} else {
console.warn('[TodayRecommend] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取今日推荐失败:', err)
}
}
const handleItemClick = (item) => {
if (!item.id) { uni.showToast({ title: '课程信息不完整', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${item.id}` })
}
onMounted(() => { console.log('[TodayRecommend] onMounted 触发'); fetchTodayRecommend() })
onShow(() => {
console.log('[TodayRecommend] onShow 触发,刷新今日推荐')
fetchTodayRecommend()
})
defineExpose({ fetchTodayRecommend })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -96,18 +159,6 @@ const recommends = [
color: #2D4A5A; color: #2D4A5A;
} }
.view-more {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 26rpx;
color: var(--tabbar-text-inactive);
}
.arrow {
font-size: 32rpx;
}
.recommend-list { .recommend-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,4 +1,4 @@
<template> <template>
<view class="bt-radar"> <view class="bt-radar">
<canvas <canvas
:id="canvasId" :id="canvasId"
@@ -10,62 +10,50 @@
</view> </view>
</template> </template>
<script> <script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawRadarChart } from '@/common/memberInfo/bodyTestChart.js' import { drawRadarChart } from '@/common/memberInfo/bodyTestChart.js'
export default { const props = defineProps({
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
labels: { type: Array, default: () => [] }, labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] }, values: { type: Array, default: () => [] },
width: { type: Number, default: 280 }, width: { type: Number, default: 280 },
height: { type: Number, default: 240 } height: { type: Number, default: 240 }
}, })
data() {
return { const canvasId = ref(`bt-radar-${Math.random().toString(36).slice(2, 9)}`)
canvasId: `bt-radar-${Math.random().toString(36).slice(2, 9)}`
} function renderChart() {
}, nextTick(() => {
watch: { const query = uni.createSelectorQuery().in(getCurrentInstance())
values: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query query
.select(`#${this.canvasId}`) .select(`#${canvasId.value}`)
.fields({ node: true, size: true }) .fields({ node: true, size: true })
.exec((res) => { .exec((res) => {
const node = res?.[0]?.node const node = res?.[0]?.node
if (!node) return if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1 const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawRadarChart(node, { drawRadarChart(node, {
width: this.width, width: props.width,
height: this.height, height: props.height,
labels: this.labels, labels: props.labels,
values: this.values, values: props.values,
dpr dpr
}) })
}) })
}) })
}
}
} }
watch(() => props.values, () => {
renderChart()
}, { deep: true })
onMounted(() => {
renderChart()
})
</script> </script>
<style> <style lang="scss">
.bt-radar { .bt-radar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,4 +1,4 @@
<template> <template>
<view class="bt-trend"> <view class="bt-trend">
<canvas <canvas
:id="canvasId" :id="canvasId"
@@ -10,62 +10,50 @@
</view> </view>
</template> </template>
<script> <script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawTrendChart } from '@/common/memberInfo/bodyTestChart.js' import { drawTrendChart } from '@/common/memberInfo/bodyTestChart.js'
export default { const props = defineProps({
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
points: { type: Array, default: () => [] }, points: { type: Array, default: () => [] },
unit: { type: String, default: '' }, unit: { type: String, default: '' },
width: { type: Number, default: 300 }, width: { type: Number, default: 300 },
height: { type: Number, default: 160 } height: { type: Number, default: 160 }
}, })
data() {
return { const canvasId = ref(`bt-trend-${Math.random().toString(36).slice(2, 9)}`)
canvasId: `bt-trend-${Math.random().toString(36).slice(2, 9)}`
} function renderChart() {
}, nextTick(() => {
watch: { const query = uni.createSelectorQuery().in(getCurrentInstance())
points: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query query
.select(`#${this.canvasId}`) .select(`#${canvasId.value}`)
.fields({ node: true, size: true }) .fields({ node: true, size: true })
.exec((res) => { .exec((res) => {
const node = res?.[0]?.node const node = res?.[0]?.node
if (!node) return if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1 const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawTrendChart(node, { drawTrendChart(node, {
width: this.width, width: props.width,
height: this.height, height: props.height,
points: this.points, points: props.points,
unit: this.unit, unit: props.unit,
dpr dpr
}) })
}) })
}) })
}
}
} }
watch(() => props.points, () => {
renderChart()
}, { deep: true })
onMounted(() => {
renderChart()
})
</script> </script>
<style> <style lang="scss">
.bt-trend { .bt-trend {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,4 +1,4 @@
<template> <template>
<view class="body-report-section"> <view class="body-report-section">
<view class="body-report-section__inner"> <view class="body-report-section__inner">
<view class="body-report-section__header"> <view class="body-report-section__header">
@@ -102,13 +102,8 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { defineProps({
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
report: { report: {
type: Object, type: Object,
default: () => ({ default: () => ({
@@ -121,12 +116,12 @@ export default {
change: '-1.2kg' change: '-1.2kg'
}) })
} }
}, })
emits: ['view-history', 'view-report']
} defineEmits(['view-history', 'view-report'])
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-body-report.css'; @import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template> <template>
<view class="booking-section"> <view class="booking-section">
<view class="booking-section__inner"> <view class="booking-section__inner">
<view class="booking-section__header"> <view class="booking-section__header">
@@ -10,59 +10,51 @@
:hover-stay-time="150" :hover-stay-time="150"
@tap="$emit('view-all')" @tap="$emit('view-all')"
> >
<text class="booking-section__view-all">预约记录</text> <text class="booking-section__view-all">查看全部</text>
<image <image
class="booking-section__link-arrow" class="booking-section__link-arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright4.png" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright2.png"
mode="aspectFit" mode="aspectFit"
/> />
</view> </view>
</view> </view>
</view> </view>
<view class="booking-section__list">
<!-- 无数据 -->
<view v-if="!displayItems || displayItems.length === 0" class="booking-section__empty">
<text class="booking-section__empty-text">暂无预约记录</text>
</view>
<view v-else class="booking-section__list-inner">
<view <view
v-for="item in previewItems" v-for="(item, index) in displayItems"
:key="item.id" :key="item.id"
class="booking-section__row"
>
<view v-if="index > 0" class="booking-section__divider"></view>
<view
class="booking-section__item" class="booking-section__item"
hover-class="mi-tap-row--hover" hover-class="mi-tap-row--hover"
:hover-stay-time="150" :hover-stay-time="150"
@tap="$emit('item-tap', item)" @tap="$emit('item-tap', item)"
> >
<view class="booking-section__item-inner"> <view class="booking-section__item-inner">
<view class="booking-section__date"> <view
<view class="booking-section__date-inner"> class="booking-section__dot"
<text class="booking-section__num">{{ item.dateDay }}</text> :class="'booking-section__dot--' + item.status"
<text class="booking-section__date-sub">{{ item.dateMonth }}</text> ></view>
</view>
</view>
<view class="booking-section__content"> <view class="booking-section__content">
<view class="booking-section__content-inner"> <view class="booking-section__content-inner">
<text class="booking-section__desc">{{ item.desc }}</text> <text class="booking-section__desc">{{ item.title }}</text>
<view class="booking-section__meta"> <text class="booking-section__text">{{ item.time }}</text>
<view class="booking-section__meta-inner">
<image
class="booking-section__icon-coach"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png"
mode="aspectFit"
/>
<text class="booking-section__coach">教练{{ item.coach }}</text>
<image
class="booking-section__icon-location"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin1.png"
mode="aspectFit"
/>
<text class="booking-section__text">{{ item.location }}</text>
</view> </view>
</view> </view>
</view>
</view>
<view class="booking-section__status-wrap">
<view <view
class="booking-section__status-badge" class="booking-section__tag-badge"
:class="'booking-section__status-badge--' + item.status" :class="'booking-section__tag-badge--' + item.status"
> >
<text <text
class="booking-section__status-text" class="booking-section__tag-text"
:class="{ 'booking-section__status-text--pending': item.status === 'pending' }" :class="'booking-section__tag-text--' + item.status"
> >
{{ item.statusLabel }} {{ item.statusLabel }}
</text> </text>
@@ -70,48 +62,204 @@
</view> </view>
</view> </view>
</view> </view>
<view v-if="!previewItems.length" class="booking-section__empty"> </view>
<text class="booking-section__empty-text">暂无进行中的预约</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script> <script setup>
export default { import { computed } from 'vue'
options: {
virtualHost: false, const props = defineProps({
styleIsolation: 'apply-shared'
},
props: {
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
} }
}, })
emits: ['view-all', 'item-tap'],
computed: { defineEmits(['view-all', 'item-tap'])
previewItems() {
return this.items const displayItems = computed(() => {
} return props.items || []
} })
}
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
.booking-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.booking-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.booking-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.booking-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.booking-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
color: var(--text-dark);
}
.booking-section__list {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px;
box-shadow: var(--shadow-sm);
background-color: var(--bg-white);
border: 1px solid var(--border-light);
overflow: hidden;
}
.booking-section__list-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.booking-section__empty { .booking-section__empty {
padding: 48rpx 32rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px 16px;
} }
.booking-section__empty-text { .booking-section__empty-text {
font-size: 14px; font-size: 26rpx;
color: #8A99B4; color: #999;
}
.booking-section__row {
width: 100%;
}
.booking-section__divider {
height: 1px;
background: var(--border-light);
margin: 0 16rpx;
}
.booking-section__item {
padding: 24rpx 16rpx;
}
.booking-section__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.booking-section__dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.booking-section__dot--ongoing {
background: #4CAF50;
}
.booking-section__dot--completed {
background: #2196F3;
}
.booking-section__dot--cancelled {
background: var(--text-muted);
}
.booking-section__content {
flex: 1;
min-width: 0;
}
.booking-section__content-inner {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.booking-section__desc {
font-size: var(--font-size-md);
font-family: var(--font-family);
color: var(--text-dark);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.booking-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
color: var(--text-muted);
}
.booking-section__tag-badge {
padding: 4rpx 14rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.booking-section__tag-badge--ongoing {
background: rgba(76, 175, 80, 0.1);
}
.booking-section__tag-badge--completed {
background: rgba(33, 150, 243, 0.1);
}
.booking-section__tag-badge--cancelled {
background: rgba(153, 153, 153, 0.1);
}
.booking-section__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
}
.booking-section__tag-text--ongoing {
color: #4CAF50;
}
.booking-section__tag-text--completed {
color: #2196F3;
}
.booking-section__tag-text--cancelled {
color: var(--text-muted);
} }
</style> </style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="checkin-section"> <view class="checkin-section">
<view class="checkin-section__inner"> <view class="checkin-section__inner">
<view class="checkin-section__header"> <view class="checkin-section__header">
@@ -20,7 +20,11 @@
</view> </view>
</view> </view>
<view class="checkin-section__list"> <view class="checkin-section__list">
<view class="checkin-section__list-inner"> <!-- 无数据 -->
<view v-if="!items || items.length === 0" class="checkin-section__empty">
<text class="checkin-section__empty-text">暂无签到记录</text>
</view>
<view v-else class="checkin-section__list-inner">
<view <view
v-for="(item, index) in items" v-for="(item, index) in items"
:key="item.id" :key="item.id"
@@ -64,24 +68,31 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { defineProps({
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
} }
}, })
emits: ['view-all', 'item-tap']
} defineEmits(['view-all', 'item-tap'])
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css'; @import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
.checkin-section__empty {
padding: 48rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.checkin-section__empty-text {
font-size: 26rpx;
color: #999;
}
</style> </style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="coupon-section"> <view class="coupon-section">
<view class="coupon-section__inner"> <view class="coupon-section__inner">
<view class="coupon-section__header"> <view class="coupon-section__header">
@@ -55,13 +55,8 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { defineProps({
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
data: { data: {
type: Object, type: Object,
default: () => ({ default: () => ({
@@ -73,12 +68,12 @@ export default {
pointsAction: '去兑换' pointsAction: '去兑换'
}) })
} }
}, })
emits: ['view-all', 'use-coupon', 'redeem-points']
} defineEmits(['view-all', 'use-coupon', 'redeem-points'])
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css'; @import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,151 +1,186 @@
<template> <template>
<view class="profile-header"> <view class="profile-header">
<!-- 顶栏白底居中标题左侧放通知/设置右侧留胶囊安全区 --> <!-- 登录状态 -->
<view class="profile-header__toolbar" :style="toolbarStyle"> <view class="profile-card" v-if="isLogin" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view class="profile-header__nav"> <view class="profile-card__main">
<view class="profile-header__nav-left"> <view class="profile-avatar">
<image <image
class="profile-header__icon-bell" class="profile-avatar__img"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bell.png"
mode="aspectFit"
/>
<image
class="profile-header__icon-settings"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/settings.png"
mode="aspectFit"
/>
</view>
<text class="profile-header__title">个人中心</text>
<view class="profile-header__nav-right" :style="navRightStyle"></view>
</view>
</view>
<view class="profile-header__toolbar-spacer" :style="toolbarSpacerStyle"></view>
<!-- 用户信息区深蓝渐变 -->
<view class="profile-header__hero">
<view class="profile-header__inner">
<view class="profile-header__user" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="$emit('user-info')">
<view class="profile-header__user-inner">
<view class="profile-header__avatar-wrap">
<view class="profile-header__avatar-ring">
<image
class="profile-header__avatar"
:key="userInfo.avatar" :key="userInfo.avatar"
:src="displayAvatar" :src="displayAvatar"
mode="aspectFill" mode="aspectFill"
/> />
</view> </view>
<view class="profile-header__avatar-badge"> <view class="profile-info">
<image <view class="profile-info__row">
class="profile-header__avatar-badge-icon" <text class="profile-info__name">{{ userInfo.name || "活氧舱用户" }}</text>
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/camera.png" <view class="profile-info__badge">
mode="aspectFit" <image class="profile-info__badge-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" mode="aspectFit" />
/> <text class="profile-info__level">{{ userInfo.memberLevel }}</text>
</view> </view>
</view> </view>
<view class="profile-header__user-meta"> <text class="profile-info__phone">{{ userInfo.phone }}</text>
<view class="profile-header__user-meta-inner"> </view>
<text class="profile-header__name">{{ userInfo.name }}</text> </view>
<text class="profile-header__phone">{{ userInfo.phone }}</text> <view class="profile-card__stats">
<view class="profile-header__badge"> <view class="profile-stat">
<image <text class="profile-stat__value">{{ stats.checkInCount ?? 0 }}</text>
class="profile-header__badge-icon" <text class="profile-stat__label">累计签到</text>
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" </view>
mode="aspectFit" <view class="profile-stat">
/> <text class="profile-stat__value">{{ stats.courseCount ?? 0 }}</text>
<text class="profile-header__level">{{ userInfo.memberLevel }}</text> <text class="profile-stat__label">报课次数</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 未登录状态 -->
<view class="profile-card profile-card--guest" v-else @tap="handleGuestLogin">
<view class="profile-card__main">
<view class="profile-avatar">
<image class="profile-avatar__img" src="/static/OIP-C.png" mode="aspectFill" />
</view> </view>
</view> <view class="profile-info">
<view class="profile-header__stats"> <text class="profile-info__name">小伙伴点我登录</text>
<view class="profile-header__stats-inner"> <text class="profile-info__phone">陪你遇见更好的自己 ~</text>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.checkInCount }}</text>
<text class="profile-header__stat-label">累计签到</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.trainingHours }}</text>
<text class="profile-header__stat-label">训练时长</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.pointsBalance }}</text>
<text class="profile-header__stat-label">累计积分</text>
</view>
</view>
</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script> <script setup>
export default { import { computed } from 'vue'
options: {
virtualHost: false, const props = defineProps({
styleIsolation: 'apply-shared'
},
props: {
userInfo: { type: Object, required: true }, userInfo: { type: Object, required: true },
stats: { type: Object, required: true } stats: { type: Object, required: true },
}, isLogin: { type: Boolean }
emits: ['user-info'], })
computed: {
displayAvatar() {
return this.userInfo.avatar || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
}
},
data() {
return {
toolbarStyle: {},
toolbarSpacerStyle: {},
navRightStyle: {}
}
},
mounted() {
this.syncNavSafeArea()
},
methods: {
syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const menu = uni.getMenuButtonBoundingClientRect?.()
this.toolbarStyle = { const emit = defineEmits(['user-info', 'guest-login'])
paddingTop: `${statusBarHeight}px`
}
this.toolbarSpacerStyle = { const displayAvatar = computed(() => {
height: `${statusBarHeight + navHeight}px` return props.userInfo.avatar || '/static/OC-default-headIamge.png'
} })
if (menu && menu.width) { function handleUserTap() {
const capsuleGap = sys.windowWidth - menu.left + 8 if (props.isLogin) {
this.navRightStyle = { emit('user-info')
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
}
} catch (e) {
this.toolbarSpacerStyle = { height: '44px' }
}
}
} }
} }
function handleGuestLogin() {
emit('guest-login')
}
</script> </script>
<style> <style lang="scss" scoped>
@import '@/common/style/memberInfo/member-info-component-reset.css'; .profile-header {
@import '@/common/style/memberInfo/member-info-header.css'; padding: 24rpx 24rpx 0;
@import '@/common/style/memberInfo/member-info-tap.css'; }
.profile-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 28rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(45, 74, 90, 0.06);
display: flex;
flex-direction: column;
gap: 24rpx;
&--guest {
cursor: pointer;
}
}
.profile-card__main {
display: flex;
align-items: center;
gap: 20rpx;
}
.profile-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 3rpx solid #E8F4F8;
}
.profile-avatar__img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.profile-info {
flex: 1;
min-width: 0;
}
.profile-info__row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.profile-info__name {
font-size: 32rpx;
font-weight: 700;
color: #1A202C;
line-height: 1.2;
}
.profile-info__badge {
display: flex;
align-items: center;
gap: 4rpx;
background: linear-gradient(135deg, #FFF7ED, #FFF1E6);
border-radius: 20rpx;
padding: 4rpx 14rpx;
}
.profile-info__badge-icon {
width: 24rpx;
height: 24rpx;
}
.profile-info__level {
font-size: 20rpx;
font-weight: 600;
color: #ED8936;
}
.profile-info__phone {
font-size: 24rpx;
color: #A0AEC0;
}
.profile-card__stats {
display: flex;
justify-content: space-around;
padding-top: 20rpx;
border-top: 1rpx solid #F0F4F8;
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.profile-stat__value {
font-size: 36rpx;
font-weight: 700;
color: #2D4A5A;
}
.profile-stat__label {
font-size: 22rpx;
color: #A0AEC0;
}
</style> </style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="logout-section"> <view class="logout-section">
<view <view
class="logout-section__btn" class="logout-section__btn"
@@ -16,16 +16,10 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { defineEmits(['logout'])
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['logout']
}
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-logout.css'; @import '@/common/style/memberInfo/member-info-logout.css';
</style> </style>
@@ -1,135 +0,0 @@
<template>
<view class="member-card-section">
<view class="member-card-section__inner">
<view class="member-card-section__head">
<view class="member-card-section__head-inner">
<text class="member-card-section__title">
我的会员卡
</text>
<view
class="member-card-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text
class="member-card-section__link-text"
>
查看全部
</text>
<image class="member-card-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright12.png" mode="aspectFit" />
</view>
</view>
</view>
<view
class="member-card-preview"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<view class="member-card-preview__inner">
<view class="member-card-preview__head">
<view class="member-card-preview__head-inner">
<view
class="member-card-preview__type-row"
>
<view
class="member-card-preview__icon-wrap"
>
<view
class="member-card-preview__icon-border"
>
<view
class="member-card-preview__icon-bg"
></view>
<view
class="member-card-preview__icon-stroke"
></view>
</view>
<image class="member-card-preview__icon-line" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Line_2_468.png" mode="aspectFill" />
</view>
<text
class="member-card-preview__name"
>
{{ cardInfo.name }}
</text>
</view>
<view
class="member-card-preview__tag"
>
<text class="member-card-preview__tag-text">
{{ cardInfo.detailTag || '详情' }}
</text>
</view>
</view>
</view>
<text class="member-card-preview__expire">
{{ cardInfo.expireDate }}
</text>
<view class="member-card-preview__footer">
<view class="member-card-preview__footer-inner">
<view
class="member-card-preview__days"
>
<text
class="member-card-preview__days-num"
>
{{ cardInfo.remainingDays }}
</text>
<text
class="member-card-preview__days-unit"
>
天剩余
</text>
</view>
<view
class="member-card-preview__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="$emit('renew')"
>
<text
class="member-card-preview__renew-text"
>
续费
</text>
</view>
</view>
</view>
</view>
</view>
<view class="member-card-tip">
<view class="member-card-tip__inner">
<view class="member-card-tip__content">
<image class="member-card-tip__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock1.png" mode="aspectFit" />
<text
class="member-card-tip__text"
>
{{ cardInfo.tip }}
</text>
</view>
</view>
<view class="member-card-tip__border"></view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
cardInfo: { type: Object, required: true }
},
emits: ['view-all', 'renew'],
}
</script>
<style>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-tap.css';
</style>
@@ -1,63 +1,10 @@
<template> <template>
<view class="quick-actions"> <view class="quick-actions">
<view class="quick-actions__inner"> <view class="quick-actions__inner">
<view class="quick-actions__grid"> <view class="quick-actions__grid">
<view class="quick-actions__grid-inner"> <view class="quick-actions__grid-inner">
<view <view
v-for="item in row1" v-for="item in actions"
:key="item.key"
class="quick-actions__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('action', item.key)"
>
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<view v-if="item.key === 'booking'" class="quick-actions__icon">
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_490.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_491.png"
mode="aspectFit"
/>
<view class="quick-actions__border-wrap">
<view class="quick-actions__rect"></view>
<view class="quick-actions__border"></view>
</view>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_493.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_494.png"
mode="aspectFit"
/>
</view>
<image
v-else
class="quick-actions__icon-img"
:src="item.icon"
mode="aspectFit"
/>
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
<view class="quick-actions__divider"></view>
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view
v-for="item in row2"
:key="item.key" :key="item.key"
class="quick-actions__item" class="quick-actions__item"
hover-class="mi-tap--hover" hover-class="mi-tap--hover"
@@ -83,33 +30,18 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue'
options: {
virtualHost: false, defineEmits(['action'])
styleIsolation: 'apply-shared'
}, const actions = ref([
emits: ['action'], { key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__title', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
data() { { key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' }
return { ])
row1: [
{ key: 'booking', label: '预约课程', textClass: 'quick-actions__title', icon: '' },
{ key: 'bodyTest', label: '智能体测', textClass: 'quick-actions__title-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin2.png' },
{ key: 'bodyReport', label: '体测报告', textClass: 'quick-actions__title-3', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'trainReport', label: '训练报告', textClass: 'quick-actions__coach', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' }
],
row2: [
{ key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__text', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' },
{ key: 'referral', label: '邀请好友', textClass: 'quick-actions__title-4', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/share2.png' },
{ key: 'course', label: '我的课程', textClass: 'quick-actions__text-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/play.png' }
]
}
}
}
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css'; @import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template> <template>
<view class="referral-section"> <view class="referral-section">
<view class="referral-section__inner"> <view class="referral-section__inner">
<view class="referral-section__header"> <view class="referral-section__header">
@@ -63,13 +63,10 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue'
options: {
virtualHost: false, const props = defineProps({
styleIsolation: 'apply-shared'
},
props: {
data: { data: {
type: Object, type: Object,
default: () => ({ default: () => ({
@@ -79,22 +76,21 @@ export default {
purchased: 2 purchased: 2
}) })
} }
}, })
emits: ['view-rules'],
methods: { defineEmits(['view-rules'])
copyCode() {
function copyCode() {
uni.setClipboardData({ uni.setClipboardData({
data: this.data.code, data: props.data.code,
success: () => { success: () => {
uni.showToast({ title: '已复制', icon: 'success' }) uni.showToast({ title: '已复制', icon: 'success' })
} }
}) })
}
}
} }
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-referral.css'; @import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-tap.css'; @import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template> <template>
<view class="settings-section"> <view class="settings-section">
<view class="settings-section__inner"> <view class="settings-section__inner">
<text class="settings-section__title">设置与安全</text> <text class="settings-section__title">设置与安全</text>
@@ -55,22 +55,12 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue'
options: {
virtualHost: false, defineEmits(['setting'])
styleIsolation: 'apply-shared'
}, const items = ref([
emits: ['setting'],
data() {
return {
items: [
{
key: 'notify',
label: '通知设置',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bell.png',
iconWrapClass: ''
},
{ {
key: 'password', key: 'password',
label: '修改密码', label: '修改密码',
@@ -83,13 +73,6 @@ export default {
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png',
iconWrapClass: 'settings-section__item-icon-wrap--green' iconWrapClass: 'settings-section__item-icon-wrap--green'
}, },
{
key: 'nfc',
label: 'NFC 门禁卡',
subtitle: '已绑定',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png',
iconWrapClass: ''
},
{ {
key: 'delete', key: 'delete',
label: '注销账户', label: '注销账户',
@@ -97,13 +80,10 @@ export default {
iconWrapClass: 'settings-section__item-icon-wrap--red', iconWrapClass: 'settings-section__item-icon-wrap--red',
labelClass: 'settings-section__item-label--danger' labelClass: 'settings-section__item-label--danger'
} }
] ])
}
}
}
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css'; @import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-settings.css'; @import '@/common/style/memberInfo/member-info-settings.css';
</style> </style>
@@ -1,4 +1,4 @@
<template> <template>
<view class="status-bar"> <view class="status-bar">
<view class="status-bar__inner"> <view class="status-bar__inner">
<text class="status-bar__time">{{ statusBarTime }}</text> <text class="status-bar__time">{{ statusBarTime }}</text>
@@ -7,14 +7,8 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { defineProps({
options: {
virtualHost: true,
styleIsolation: 'apply-shared'
},
props: {
statusBarTime: { type: String, default: '9:41' } statusBarTime: { type: String, default: '9:41' }
}, })
}
</script> </script>
@@ -1,4 +1,4 @@
<template> <template>
<view class="sub-nav"> <view class="sub-nav">
<view class="sub-nav__toolbar" :style="toolbarStyle"> <view class="sub-nav__toolbar" :style="toolbarStyle">
<view class="sub-nav__nav"> <view class="sub-nav__nav">
@@ -27,80 +27,75 @@
</view> </view>
</template> </template>
<script> <script setup>
export default { import { ref, onMounted, nextTick } from 'vue'
options: {
virtualHost: false, const props = defineProps({
styleIsolation: 'apply-shared'
},
props: {
title: { type: String, required: true }, title: { type: String, required: true },
rightText: { type: String, default: '' }, rightText: { type: String, default: '' },
actionButton: { type: Boolean, default: false } actionButton: { type: Boolean, default: false }
}, })
emits: ['back', 'right-action'],
data() { defineEmits(['back', 'right-action'])
return {
toolbarStyle: {}, const toolbarStyle = ref({})
toolbarSpacerStyle: {}, const toolbarSpacerStyle = ref({})
capsuleStyle: {}, const capsuleStyle = ref({})
isH5: false const isH5 = ref(false)
}
}, function syncNavSafeArea() {
mounted() {
this.syncNavSafeArea()
this.$nextTick(() => {
setTimeout(() => this.syncNavSafeArea(), 50)
})
},
methods: {
syncNavSafeArea() {
try { try {
const sys = uni.getSystemInfoSync() const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0 const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44 const navHeight = 44
const extraGap = 4 const extraGap = 4
const menu = uni.getMenuButtonBoundingClientRect?.() const menu = uni.getMenuButtonBoundingClientRect?.()
// #ifdef H5 // #ifdef H5
this.isH5 = true isH5.value = true
// #endif // #endif
// #ifndef H5 // #ifndef H5
this.isH5 = false isH5.value = false
// #endif // #endif
if (!this.isH5 && typeof window !== 'undefined' && !menu?.width) { if (!isH5.value && typeof window !== 'undefined' && !menu?.width) {
this.isH5 = sys.uniPlatform === 'web' || sys.platform === 'web' isH5.value = sys.uniPlatform === 'web' || sys.platform === 'web'
} }
this.toolbarStyle = { toolbarStyle.value = {
paddingTop: `${statusBarHeight}px` paddingTop: `${statusBarHeight}px`
} }
this.toolbarSpacerStyle = { toolbarSpacerStyle.value = {
height: `${statusBarHeight + navHeight + extraGap}px` height: `${statusBarHeight + navHeight + extraGap}px`
} }
if (!this.isH5 && menu && menu.width) { if (!isH5.value && menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8 const capsuleGap = sys.windowWidth - menu.left + 8
this.capsuleStyle = { capsuleStyle.value = {
width: `${capsuleGap}px`, width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px` minWidth: `${capsuleGap}px`
} }
} else { } else {
this.capsuleStyle = { capsuleStyle.value = {
width: '0px', width: '0px',
minWidth: '0px' minWidth: '0px'
} }
} }
} catch (e) { } catch (e) {
this.toolbarSpacerStyle = { height: '44px' } toolbarSpacerStyle.value = { height: '44px' }
this.isH5 = true isH5.value = true
this.capsuleStyle = { width: '0px', minWidth: '0px' } capsuleStyle.value = { width: '0px', minWidth: '0px' }
}
}
} }
} }
onMounted(() => {
syncNavSafeArea()
nextTick(() => {
setTimeout(() => syncNavSafeArea(), 50)
})
})
</script> </script>
<style> <style lang="scss">
@import '@/common/style/memberInfo/member-info-sub-nav.css'; @import '@/common/style/memberInfo/member-info-sub-nav.css';
</style> </style>
@@ -0,0 +1,326 @@
<template>
<!-- 推荐团课卡片 -->
<view class="recommend-card" :style="cardStyle" @click="handleCardClick">
<!-- 卡片头部图片 + 推荐标题 -->
<view class="card-header">
<!-- 团课封面 -->
<view class="course-image-wrapper">
<image :src="courseImage" mode="aspectFill" class="course-image" />
<view class="image-overlay"></view>
<!-- 状态标签 -->
<text :class="['status-tag', tagType]">{{ tagText }}</text>
</view>
<!-- 推荐标题区 -->
<view class="header-info">
<text class="recommend-title">{{ recommend.recommendTitle || '推荐课程' }}</text>
<text class="recommend-content">{{ recommend.recommendContent }}</text>
</view>
</view>
<!-- 课程信息栏 -->
<view class="info-bar">
<view class="info-item">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png" class="info-icon" />
<text class="info-text">{{ formattedTime }}</text>
</view>
<view class="info-item" v-if="courseLocation">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png" class="info-icon" />
<text class="info-text">{{ courseLocation }}</text>
</view>
<view class="info-item">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png" class="info-icon" />
<text class="info-text">{{ courseCurrentMembers || 0 }}/{{ courseMaxMembers || 0 }}</text>
</view>
</view>
<!-- 推荐理由 -->
<view class="card-reason" v-if="recommend.recommendReason">
<text class="reason-text">{{ recommend.recommendReason }}</text>
</view>
<!-- 卡片底部操作按钮 -->
<view class="card-footer" @click.stop="handleJoinCourse">
<view class="action-btn">
<text>立即预约</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { getCourseCoverUrl } from '@/utils/request.js'
const props = defineProps({
recommend: {
type: Object,
required: true,
default: () => ({})
},
width: {
type: [Number, String],
default: '100%',
description: '卡片宽度'
},
borderRadius: {
type: [Number, String],
default: 24,
description: '卡片圆角,单位 rpx'
}
})
//
const cardStyle = computed(() => {
const style = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}rpx` : props.width
}
if (props.borderRadius) {
style.borderRadius = typeof props.borderRadius === 'number' ? `${props.borderRadius}rpx` : props.borderRadius
}
return style
})
//
const courseStartTime = computed(() => props.recommend.groupCourse?.startTime)
const courseEndTime = computed(() => props.recommend.groupCourse?.endTime)
const courseMaxMembers = computed(() => props.recommend.groupCourse?.maxMembers)
const courseCurrentMembers = computed(() => props.recommend.groupCourse?.currentMembers)
const courseLocation = computed(() => props.recommend.groupCourse?.location)
const courseId = computed(() => props.recommend.groupCourse?.id)
//
const courseImage = computed(() => {
const coverImage = props.recommend.groupCourse?.coverImage
const url = getCourseCoverUrl(coverImage) || 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
console.log('[RecommendCourseCard] 封面URL:', {
'courseName': props.recommend.groupCourse?.courseName,
'原始coverImage': coverImage,
'转换后URL': url,
'是否完整URL': url && url.startsWith('http')
})
return url
})
//
const tagText = computed(() => {
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) return '已满员'
if (props.recommend.groupCourse?.status === 2) return '已结束'
if (props.recommend.groupCourse?.status === 1) return '已取消'
if (max > 0 && current / max >= 0.8) return '热门'
return '推荐'
})
//
const tagType = computed(() => {
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) return 'full'
if (props.recommend.groupCourse?.status === 2) return 'ended'
if (props.recommend.groupCourse?.status === 1) return 'ended'
if (max > 0 && current / max >= 0.8) return 'hot'
return 'default'
})
//
const formattedTime = computed(() => {
if (!courseStartTime.value) return ''
const start = new Date(courseStartTime.value)
const date = `${start.getMonth() + 1}${start.getDate()}`
const time = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`
if (courseEndTime.value) {
const end = new Date(courseEndTime.value)
const endTime = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
return `${date} ${time}-${endTime}`
}
return `${date} ${time}`
})
//
const handleCardClick = () => {
if (!props.recommend.groupCourse) {
uni.showToast({ title: '课程信息不完整', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${courseId.value}` })
}
//
const handleJoinCourse = () => {
if (props.recommend.groupCourse?.status === 2) {
uni.showToast({ title: '课程已结束', icon: 'none' })
return
}
if (props.recommend.groupCourse?.status === 1) {
uni.showToast({ title: '课程已取消', icon: 'none' })
return
}
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) {
uni.showToast({ title: '课程已满员', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${courseId.value}` })
}
</script>
<style lang="scss">
.recommend-card {
width: 100%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(45, 74, 90, 0.1);
border: 1rpx solid rgba(124, 181, 204, 0.2);
margin-bottom: 24rpx;
}
//
.card-header {
display: flex;
gap: 24rpx;
padding: 24rpx;
}
.course-image-wrapper {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
position: relative;
flex-shrink: 0;
}
.course-image {
width: 100%;
height: 100%;
}
.image-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(45, 74, 90, 0.3) 0%, transparent 60%);
}
//
.status-tag {
position: absolute;
left: 8rpx;
bottom: 8rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 18rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7CB5CC, #9CCFDF);
&.hot {
background: linear-gradient(135deg, #FF8C69, #FFA07A);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #C0CCD0, #D0D8DC);
}
}
//
.header-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.recommend-title {
font-size: 32rpx;
font-weight: 700;
color: #2D4A5A;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-content {
font-size: 24rpx;
color: #5A7A8A;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// | |
.info-bar {
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 24rpx 16rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.info-icon {
width: 28rpx;
height: 28rpx;
}
.info-text {
font-size: 22rpx;
color: #5A7A8A;
}
//
.card-reason {
margin: 0 24rpx;
padding: 16rpx;
background: rgba(124, 181, 204, 0.06);
border-radius: 12rpx;
border-left: 4rpx solid #7CB5CC;
}
.reason-text {
font-size: 22rpx;
color: #6A8A9A;
line-height: 1.5;
}
//
.card-footer {
padding: 16rpx 24rpx 24rpx;
}
.action-btn {
width: 100%;
height: 72rpx;
background: linear-gradient(135deg, #82DC82, #66CC66);
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(130, 220, 130, 0.35);
text {
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
}
}
</style>
@@ -1,32 +1,30 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { groupCourseService } from '@/request_api/groupCourse.mock.js' import { getGroupCoursePage, getTypeLabels, searchGroupCourse } from '@/api/groupCourse.js'
export function useGroupCourseList() { export function useGroupCourseList() {
// 分页相关 const pageNum = ref(0)
const pageNum = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const totalPages = ref(0) const totalPages = ref(0)
const loading = ref(false) const loading = ref(false)
const hasMore = ref(true) const hasMore = ref(true)
// 团课列表数据
const courseList = ref([]) const courseList = ref([])
// 搜索相关
const searchKeyword = ref('') const searchKeyword = ref('')
const hotKeywords = ref(['燃脂', '瑜伽', '单车', '普拉提', '高强度']) const hotKeywords = ref(['燃脂', '瑜伽', '单车', '普拉提', '高强度'])
// 排序相关 const courseType = ref(null)
const courseTypes = ref([])
const sortOptions = ref([ const sortOptions = ref([
{ label: '默认排序', value: 'default' }, { label: '默认排序', value: 'default', priceSort: null, remainingMost: false },
{ label: '价格从低到高', value: 'priceAsc' }, { label: '价格从低到高', value: 'priceAsc', priceSort: 'asc', remainingMost: false },
{ label: '价格从高到低', value: 'priceDesc' }, { label: '价格从高到低', value: 'priceDesc', priceSort: 'desc', remainingMost: false },
{ label: '剩余名额最多', value: 'spotsDesc' } { label: '剩余名额最多', value: 'remainingMost', priceSort: null, remainingMost: true }
]) ])
const sortIndex = ref(0) const sortIndex = ref(0)
// 时间段选择相关
const timePeriodOptions = ref([ const timePeriodOptions = ref([
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
{ label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 }, { label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 },
@@ -35,24 +33,25 @@ export function useGroupCourseList() {
]) ])
const timePeriodIndex = ref(0) const timePeriodIndex = ref(0)
// 时间筛选相关 const isRecurring = ref(false) // 常态化团课筛选
const showTimePicker = ref(false) const showTimePicker = ref(false)
const startDate = ref('') const startDate = ref('')
const endDate = ref('') const endDate = ref('')
const timeRangeText = ref('') const timeRangeText = ref('')
// 筛选后的课程列表
const filteredCourseList = computed(() => { const filteredCourseList = computed(() => {
let result = [...courseList.value] let result = [...courseList.value]
// 关键词搜索 // 只显示可预约的团课(状态码为0,兼容字符串和数字)
result = result.filter(course => Number(course.status) === 0)
if (searchKeyword.value) { if (searchKeyword.value) {
result = result.filter(course => result = result.filter(course =>
course.courseName.includes(searchKeyword.value) course.courseName.includes(searchKeyword.value)
) )
} }
// 时间范围筛选
if (startDate.value || endDate.value) { if (startDate.value || endDate.value) {
result = result.filter(course => { result = result.filter(course => {
const courseDate = course.startTime.split('T')[0] const courseDate = course.startTime.split('T')[0]
@@ -62,7 +61,6 @@ export function useGroupCourseList() {
}) })
} }
// 时间段筛选
const timePeriod = timePeriodOptions.value[timePeriodIndex.value] const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
if (timePeriod.value !== 'all') { if (timePeriod.value !== 'all') {
result = result.filter(course => { result = result.filter(course => {
@@ -71,20 +69,25 @@ export function useGroupCourseList() {
}) })
} }
// 排序 const sortType = sortOptions.value[sortIndex.value]
const sortType = sortOptions.value[sortIndex.value].value if (sortType.value !== 'id' || sortType.order !== 'asc') {
if (sortType === 'priceAsc') { result.sort((a, b) => {
result.sort((a, b) => (a.storedValueAmount || a.pointCardAmount) - (b.storedValueAmount || b.pointCardAmount)) const valA = a[sortType.value] || 0
} else if (sortType === 'priceDesc') { const valB = b[sortType.value] || 0
result.sort((a, b) => (b.storedValueAmount || b.pointCardAmount) - (a.storedValueAmount || a.pointCardAmount)) return sortType.order === 'asc' ? valA - valB : valB - valA
} else if (sortType === 'spotsDesc') { })
result.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
} }
return result return result
}) })
// 获取所有搜索参数 // 监听排序方式变化,重新获取数据
watch(sortIndex, () => {
console.log('[useGroupCourseList] 排序方式变化,触发重新查询')
pageNum.value = 0
fetchCourseList()
})
const getAllSearchParams = (searchBarRef, filterSectionRef, timePeriodRef, timeRangePickerRef) => { const getAllSearchParams = (searchBarRef, filterSectionRef, timePeriodRef, timeRangePickerRef) => {
const searchParams = searchBarRef?.getSearchParams?.() || { keyword: searchKeyword.value } const searchParams = searchBarRef?.getSearchParams?.() || { keyword: searchKeyword.value }
const filterParams = filterSectionRef?.getFilterParams?.() || { sortType: sortOptions.value[sortIndex.value].value } const filterParams = filterSectionRef?.getFilterParams?.() || { sortType: sortOptions.value[sortIndex.value].value }
@@ -102,36 +105,46 @@ export function useGroupCourseList() {
return allParams return allParams
} }
// 搜索处理
const handleSearch = (params) => { const handleSearch = (params) => {
console.log('[useGroupCourseList] 搜索触发:', params) console.log('[useGroupCourseList] 搜索触发:', params)
uni.showToast({ searchKeyword.value = params.keyword || ''
title: params.keyword ? `搜索:${params.keyword}` : '请输入关键词', pageNum.value = 0
icon: 'none' fetchCourseList()
})
} }
// 时间段变化处理
const onTimePeriodChange = (option) => { const onTimePeriodChange = (option) => {
console.log('[useGroupCourseList] 时间段选择:', option) console.log('[useGroupCourseList] 时间段选择:', option)
pageNum.value = 0
fetchCourseList()
} }
// 时间范围确认处理
const onTimeRangeConfirm = (params) => { const onTimeRangeConfirm = (params) => {
console.log('[useGroupCourseList] 时间范围确认:', params) console.log('[useGroupCourseList] 时间范围确认:', params)
startDate.value = params.startDate || ''
endDate.value = params.endDate || ''
timeRangeText.value = params.timeRangeText timeRangeText.value = params.timeRangeText
} }
// 预约处理 const onCourseTypeChange = (typeId) => {
console.log('[useGroupCourseList] 课程类型选择:', typeId)
courseType.value = typeId
pageNum.value = 0
fetchCourseList()
}
const clearCourseType = () => {
courseType.value = null
pageNum.value = 0
fetchCourseList()
}
const handleBooking = (course) => { const handleBooking = (course) => {
console.log('[useGroupCourseList] 预约课程:', course) console.log('[useGroupCourseList] 预约课程:', course)
uni.showToast({ uni.navigateTo({
title: `预约课程:${course.courseName}`, url: `/pages/groupCourse/detail?id=${course.id}`
icon: 'success'
}) })
} }
// 跳转详情
const goDetail = (courseId) => { const goDetail = (courseId) => {
console.log('[useGroupCourseList] 跳转到课程详情:', courseId) console.log('[useGroupCourseList] 跳转到课程详情:', courseId)
uni.navigateTo({ uni.navigateTo({
@@ -139,20 +152,82 @@ export function useGroupCourseList() {
}) })
} }
// 获取团课列表 const buildSearchParams = () => {
const sortOption = sortOptions.value[sortIndex.value]
const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
const params = {
page: pageNum.value,
size: pageSize.value
}
if (searchKeyword.value) {
params.courseName = searchKeyword.value
}
if (courseType.value) {
params.courseType = courseType.value
}
if (startDate.value) {
params.startDate = startDate.value
}
if (endDate.value) {
params.endDate = endDate.value
}
if (timePeriod.value && timePeriod.value !== 'all') {
params.timePeriod = timePeriod.value
}
if (isRecurring.value) {
params.isRecurring = true
}
if (sortOption.priceSort) {
params.priceSort = sortOption.priceSort
}
if (sortOption.remainingMost) {
params.remainingMost = sortOption.remainingMost
}
return params
}
const hasActiveFilters = () => {
return searchKeyword.value ||
courseType.value ||
startDate.value ||
endDate.value ||
timePeriodOptions.value[timePeriodIndex.value].value !== 'all' ||
sortIndex.value !== 0
}
const fetchCourseList = async (isLoadMore = false) => { const fetchCourseList = async (isLoadMore = false) => {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const result = await groupCourseService.getList({ const currentPage = isLoadMore ? pageNum.value + 1 : pageNum.value
pageNum: pageNum.value, pageNum.value = currentPage
pageSize: pageSize.value
})
if (result.code === 0 && result.data) { const searchParams = buildSearchParams()
const { list, total: totalCount, pageNum: currentPage, totalPages: pages } = result.data searchParams.page = currentPage
console.log('[useGroupCourseList] 请求参数:', JSON.stringify(searchParams, null, 2))
const result = await searchGroupCourse(searchParams)
console.log('[useGroupCourseList] 响应结果:', JSON.stringify(result, null, 2))
// 兼容后端统一响应格式 { data/result, message, success }
const wrapped = result?.result || result?.data || result
if (wrapped && wrapped.content) {
const { content: list, totalElements: totalCount, totalPages: pages } = wrapped
if (isLoadMore) { if (isLoadMore) {
courseList.value = [...courseList.value, ...list] courseList.value = [...courseList.value, ...list]
@@ -161,9 +236,8 @@ export function useGroupCourseList() {
} }
total.value = totalCount total.value = totalCount
pageNum.value = currentPage
totalPages.value = pages totalPages.value = pages
hasMore.value = pageNum.value < totalPages.value hasMore.value = currentPage < pages - 1
console.log('[useGroupCourseList] 团课列表获取成功:', { console.log('[useGroupCourseList] 团课列表获取成功:', {
total: total.value, total: total.value,
@@ -172,29 +246,41 @@ export function useGroupCourseList() {
hasMore: hasMore.value hasMore: hasMore.value
}) })
} else { } else {
console.error('[useGroupCourseList] 获取团课列表失败:', result.message) console.error('[useGroupCourseList] 获取团课列表失败:', {
result: result,
message: result?.message || '未知错误',
code: result?.code,
success: result?.success
})
} }
} catch (error) { } catch (error) {
console.error('[useGroupCourseList] 获取团课列表异常:', error) console.error('[useGroupCourseList] 获取团课列表异常 - 错误详情:', {
error: error,
message: error?.message || '无错误信息',
code: error?.code,
statusCode: error?.statusCode,
response: error?.response,
stack: error?.stack
})
uni.showToast({
title: `获取课程列表失败: ${error?.message || '网络错误'}`,
icon: 'none'
})
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// 加载更多
const loadMore = () => { const loadMore = () => {
if (!hasMore.value || loading.value) return if (!hasMore.value || loading.value) return
pageNum.value++
fetchCourseList(true) fetchCourseList(true)
} }
// 滚动到底部触发
const onScrollToLower = () => { const onScrollToLower = () => {
loadMore() loadMore()
} }
return { return {
// 状态
pageNum, pageNum,
pageSize, pageSize,
total, total,
@@ -204,24 +290,30 @@ export function useGroupCourseList() {
courseList, courseList,
searchKeyword, searchKeyword,
hotKeywords, hotKeywords,
courseType,
courseTypes,
sortOptions, sortOptions,
sortIndex, sortIndex,
timePeriodOptions, timePeriodOptions,
timePeriodIndex, timePeriodIndex,
isRecurring,
showTimePicker, showTimePicker,
startDate, startDate,
endDate, endDate,
timeRangeText, timeRangeText,
filteredCourseList, filteredCourseList,
// 方法
getAllSearchParams, getAllSearchParams,
handleSearch, handleSearch,
onTimePeriodChange, onTimePeriodChange,
onTimeRangeConfirm, onTimeRangeConfirm,
onCourseTypeChange,
clearCourseType,
handleBooking, handleBooking,
goDetail, goDetail,
fetchCourseList, fetchCourseList,
buildSearchParams,
hasActiveFilters,
loadMore, loadMore,
onScrollToLower onScrollToLower
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
# 团课推荐模块 API 文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-15
> **作者**: 张翔
> **状态**: 正式发布
---
## 📋 目录
1. [概述](#概述)
2. [基础路径](#基础路径)
3. [团课推荐管理接口](#团课推荐管理接口)
- [获取所有团课推荐](#获取所有团课推荐)
- [获取所有启用的团课推荐](#获取所有启用的团课推荐)
- [根据ID获取团课推荐](#根据ID获取团课推荐)
- [根据团课ID获取推荐](#根据团课ID获取推荐)
- [创建团课推荐](#创建团课推荐)
- [更新团课推荐](#更新团课推荐)
- [删除团课推荐](#删除团课推荐)
- [启用团课推荐](#启用团课推荐)
- [禁用团课推荐](#禁用团课推荐)
4. [数据模型](#数据模型)
- [GroupCourseRecommend(团课推荐)](#GroupCourseRecommend团课推荐)
5. [状态码说明](#状态码说明)
6. [业务规则](#业务规则)
---
## 概述
团课推荐模块提供团课推荐信息的创建、编辑、查询、删除和状态管理功能。推荐信息包含团课ID、推荐标题、推荐内容、推荐理由、优先级等必要信息,支持按优先级排序展示。
## 基础路径
所有接口的基础路径为: `http://{host}:{port}/api/groupCourse/recommend`
---
## 团课推荐管理接口
### 获取所有团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/list` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| sortBy | string | 否 | priority | 排序字段(支持:priority、createdAt、updatedAt |
| sortOrder | string | 否 | desc | 排序方式(asc-升序,desc-降序) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 获取所有启用的团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/active` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**功能说明**: 获取系统中所有已启用的团课推荐列表,按优先级从高到低排序。
**成功响应** (200 OK):
```json
[
{
"id": 2,
"courseId": 3,
"recommendTitle": "新学员推荐",
"recommendContent": "专为新学员设计的入门课程,轻松上手",
"recommendReason": "零基础友好,教练耐心指导",
"priority": 20,
"isActive": true,
"groupCourse": {
"id": 3,
"courseName": "基础有氧",
"coachId": 2,
"courseType": 2,
"startTime": "2026-06-16T18:00:00",
"endTime": "2026-06-16T19:00:00",
"maxMembers": 30,
"currentMembers": 8,
"status": 0,
"location": "健身房B区",
"coverImage": "https://example.com/aerobic.jpg",
"description": "适合所有健身水平的有氧课程"
},
"createdAt": "2026-06-15T11:00:00",
"updatedAt": "2026-06-15T11:00:00"
}
]
```
---
### 根据ID获取团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 根据团课ID获取推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/course/{courseId}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 是 | 团课ID |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 创建团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求体**:
```json
{
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseId | Long | **是** | - | 团课ID(必须是有效的团课) |
| recommendTitle | String | 否 | - | 推荐标题 |
| recommendContent | String | 否 | - | 推荐内容 |
| recommendReason | String | 否 | - | 推荐理由 |
| priority | Integer | 否 | 0 | 优先级(数字越大优先级越高) |
| isActive | Boolean | 否 | true | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐创建成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课ID不能为空"
}
```
---
### 更新团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**请求体**:
```json
{
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 否 | 团课ID |
| recommendTitle | String | 否 | 推荐标题 |
| recommendContent | String | 否 | 推荐内容 |
| recommendReason | String | 否 | 推荐理由 |
| priority | Integer | 否 | 优先级 |
| isActive | Boolean | 否 | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐更新成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true,
"updatedAt": "2026-06-15T12:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 删除团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐删除成功"
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 启用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/enable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐启用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": true
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 禁用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/disable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐禁用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
## 数据模型
### GroupCourseRecommend(团课推荐)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| courseId | Long | 团课ID(关联group_course.id |
| recommendTitle | String | 推荐标题 |
| recommendContent | String | 推荐内容 |
| recommendReason | String | 推荐理由 |
| priority | Integer | 优先级(数字越大优先级越高),默认0 |
| isActive | Boolean | 是否启用,默认true |
| groupCourse | GroupCourse | 关联的团课信息(查询时自动填充) |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
---
## 状态码说明
### 推荐状态
| 状态值 | 含义 |
|--------|------|
| true | 启用 |
| false | 禁用 |
---
## 业务规则
### 团课推荐管理
1. **创建推荐**:团课ID为必填项,且必须是有效的团课
2. **优先级排序**:获取启用的推荐列表时,按优先级从高到低排序
3. **删除推荐**:采用软删除机制,数据保留可恢复
4. **状态管理**:支持启用/禁用推荐状态,禁用的推荐不会在推荐列表中显示
---
## 附录:错误响应格式
所有接口的错误响应统一格式:
```json
{
"success": false,
"message": "错误描述信息"
}
```
---
*文档结束*
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
import App from './App' import App from './App'
// #ifndef VUE3 // #ifndef VUE3
import Vue from 'vue' import Vue from 'vue'
+64 -7
View File
@@ -1,9 +1,9 @@
{ {
"name" : "gym-manage-uniapp", "name" : "活氧舱",
"appid" : "__UNI__1F1874C", "appid" : "__UNI__52E2F0D",
"description" : "", "description" : "",
"versionName" : "1.0.0", "versionName" : "1.0.1",
"versionCode" : "100", "versionCode" : 101,
"transformPx" : false, "transformPx" : false,
/* 5+App */ /* 5+App */
"app-plus" : { "app-plus" : {
@@ -17,7 +17,10 @@
"delay" : 0 "delay" : 0
}, },
/* */ /* */
"modules" : {}, "modules" : {
"Barcode" : {},
"OAuth" : {}
},
/* */ /* */
"distribute" : { "distribute" : {
/* android */ /* android */
@@ -41,9 +44,63 @@
] ]
}, },
/* ios */ /* ios */
"ios" : {}, "ios" : {
"dSYMs" : false
},
/* SDK */ /* SDK */
"sdkConfigs" : {} "sdkConfigs" : {
"oauth" : {
"univerify" : {}
}
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
},
"nativePlugins" : {
"AliCloud-NirvanaPns" : {
"__plugin_info__" : {
"name" : "阿里云号码认证SDK",
"description" : "阿里云号码认证SDK,包含一键登录和本机号码校验两个功能。",
"platforms" : "Android,iOS",
"url" : "https://ext.dcloud.net.cn/plugin?id=4297",
"android_package_name" : "gym.manage.uniapp",
"ios_bundle_id" : "",
"isCloud" : true,
"bought" : 1,
"pid" : "4297",
"parameters" : {}
}
}
} }
}, },
/* */ /* */
+29 -32
View File
@@ -1,5 +1,6 @@
{ {
"pages": [ "pages": [
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
@@ -11,9 +12,17 @@
} }
} }
}, },
{
"path": "pages/memberInfo/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{ {
"path": "pages/course/index", "path": "pages/course/index",
"style": { "style": {
"navigationStyle": "custom",
"navigationBarTitleText": "课程", "navigationBarTitleText": "课程",
"app-plus": { "app-plus": {
"animationType": "fade-in", "animationType": "fade-in",
@@ -21,26 +30,7 @@
} }
} }
}, },
{
"path": "pages/train/index",
"style": {
"navigationBarTitleText": "训练",
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
}
},
{
"path": "pages/discover/index",
"style": {
"navigationBarTitleText": "发现",
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
}
},
{ {
"path": "pages/memberInfo/memberInfo", "path": "pages/memberInfo/memberInfo",
"style": { "style": {
@@ -52,13 +42,6 @@
} }
} }
}, },
{
"path": "pages/memberInfo/memberCard",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的会员卡"
}
},
{ {
"path": "pages/memberInfo/userInfo", "path": "pages/memberInfo/userInfo",
"style": { "style": {
@@ -237,12 +220,14 @@
{ {
"path": "pages/checkIn/checkIn", "path": "pages/checkIn/checkIn",
"style": { "style": {
"navigationBarTitleText": "会员签到" "navigationBarTitleText": "会员签到"
} }
}, },
{ {
"path": "pages/groupCourse/list", "path": "pages/groupCourse/list",
"style": { "style": {
"navigationStyle": "custom",
"navigationBarTitleText": "团课列表" "navigationBarTitleText": "团课列表"
} }
}, },
@@ -260,9 +245,10 @@
} }
}, },
{ {
"path": "pages/searchCourse/searchCourse", "path": "pages/recommendCourses/index",
"style": { "style": {
"navigationBarTitleText": "搜索课程" "navigationBarTitleText": "推荐课程",
"navigationStyle": "custom"
} }
}, },
{ {
@@ -270,6 +256,19 @@
"style": { "style": {
"navigationBarTitleText": "" "navigationBarTitleText": ""
} }
},
{
"path": "pages/message/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/message/detail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "消息详情"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -284,12 +283,10 @@
}, },
"uniIdRouter": {}, "uniIdRouter": {},
"tabBar": { "tabBar": {
"custom": true, // tabBar "custom": true,
"list": [ "list": [
{ "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/course/index", "text": "课程" }, { "pagePath": "pages/course/index", "text": "课程" },
{ "pagePath": "pages/train/index", "text": "训练" },
{ "pagePath": "pages/discover/index", "text": "发现" },
{ "pagePath": "pages/memberInfo/memberInfo", "text": "我的" } { "pagePath": "pages/memberInfo/memberInfo", "text": "我的" }
] ]
} }
@@ -1,4 +1,4 @@
<!-- components/LoadingOverlay.vue --> <!-- components/LoadingOverlay.vue -->
<template> <template>
<view v-if="visible" class="loading-overlay"> <view v-if="visible" class="loading-overlay">
<view class="loading-content"> <view class="loading-content">

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