feat(training-plans): 添加训练项目管理功能
- 新增训练项目模型、DTO和服务,支持创建、更新、删除和批量操作训练项目 - 在控制器中实现训练项目的相关API,包括添加、批量添加、获取、更新、删除和标记完成状态 - 提供训练项目的完成统计功能,支持获取训练计划下所有项目的完成情况 - 更新训练计划模块以集成训练项目管理功能
This commit is contained in:
374
docs/schedule-exercise-api-examples.md
Normal file
374
docs/schedule-exercise-api-examples.md
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
# 训练计划项目管理 API 使用示例
|
||||||
|
|
||||||
|
## 创建一个完整的训练计划项目
|
||||||
|
|
||||||
|
### 1. 创建基础训练计划
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /training-plans
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "全身力量训练",
|
||||||
|
"startDate": "2024-01-15T00:00:00.000Z",
|
||||||
|
"mode": "daysOfWeek",
|
||||||
|
"daysOfWeek": [1, 3, 5],
|
||||||
|
"sessionsPerWeek": 3,
|
||||||
|
"goal": "core_strength"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "plan_1705123456789_abc123",
|
||||||
|
"name": "全身力量训练",
|
||||||
|
"isActive": true,
|
||||||
|
"userId": "user_123",
|
||||||
|
"startDate": "2024-01-15T00:00:00.000Z",
|
||||||
|
"mode": "daysOfWeek",
|
||||||
|
"daysOfWeek": [1, 3, 5],
|
||||||
|
"sessionsPerWeek": 3,
|
||||||
|
"goal": "core_strength",
|
||||||
|
"createdAt": "2024-01-10T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 批量添加训练项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises/batch
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"key": "warmup_dynamic",
|
||||||
|
"name": "动态热身",
|
||||||
|
"category": "热身",
|
||||||
|
"durationSec": 300,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"note": "轻松活动关节,准备训练"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "note_safety",
|
||||||
|
"name": "安全提醒",
|
||||||
|
"note": "如感到不适请立即停止,保持正确呼吸",
|
||||||
|
"itemType": "note"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "squat_exercise",
|
||||||
|
"name": "深蹲训练",
|
||||||
|
"category": "下肢力量",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 15,
|
||||||
|
"restSec": 60,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"note": "下蹲时膝盖不超过脚尖"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rest_squat",
|
||||||
|
"name": "组间休息",
|
||||||
|
"durationSec": 90,
|
||||||
|
"itemType": "rest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pushup_exercise",
|
||||||
|
"name": "俯卧撑",
|
||||||
|
"category": "上肢力量",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 12,
|
||||||
|
"restSec": 60,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"note": "保持身体一条直线"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rest_pushup",
|
||||||
|
"name": "组间休息",
|
||||||
|
"durationSec": 90,
|
||||||
|
"itemType": "rest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "plank_exercise",
|
||||||
|
"name": "平板支撑",
|
||||||
|
"category": "核心力量",
|
||||||
|
"sets": 3,
|
||||||
|
"durationSec": 60,
|
||||||
|
"restSec": 45,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"note": "腹部收紧,不要塌腰"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cooldown_stretch",
|
||||||
|
"name": "拉伸放松",
|
||||||
|
"category": "拉伸",
|
||||||
|
"durationSec": 600,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"note": "充分拉伸训练过的肌群"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456790_def456",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"key": "warmup_dynamic",
|
||||||
|
"name": "动态热身",
|
||||||
|
"category": "热身",
|
||||||
|
"sets": 0,
|
||||||
|
"durationSec": 300,
|
||||||
|
"note": "轻松活动关节,准备训练",
|
||||||
|
"itemType": "exercise",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 1,
|
||||||
|
"createdAt": "2024-01-10T10:35:00.000Z",
|
||||||
|
"updatedAt": "2024-01-10T10:35:00.000Z"
|
||||||
|
},
|
||||||
|
// ... 其他项目
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取训练项目列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:按sortOrder排序的完整项目列表
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456790_def456",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"key": "warmup_dynamic",
|
||||||
|
"name": "动态热身",
|
||||||
|
"category": "热身",
|
||||||
|
"sets": 0,
|
||||||
|
"durationSec": 300,
|
||||||
|
"note": "轻松活动关节,准备训练",
|
||||||
|
"itemType": "exercise",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456791_ghi789",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"key": "note_safety",
|
||||||
|
"name": "安全提醒",
|
||||||
|
"note": "如感到不适请立即停止,保持正确呼吸",
|
||||||
|
"itemType": "note",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 2
|
||||||
|
}
|
||||||
|
// ... 更多项目
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 用户完成训练项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /training-plans/plan_1705123456789_abc123/exercises/ex_1705123456792_jkl012/complete
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"completed": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456792_jkl012",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"key": "squat_exercise",
|
||||||
|
"name": "深蹲训练",
|
||||||
|
"category": "下肢力量",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 15,
|
||||||
|
"restSec": 60,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"completed": true,
|
||||||
|
"sortOrder": 3,
|
||||||
|
"updatedAt": "2024-01-10T11:15:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 调整训练项目顺序
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /training-plans/plan_1705123456789_abc123/exercises/order
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"exerciseIds": [
|
||||||
|
"ex_1705123456790_def456", // 热身
|
||||||
|
"ex_1705123456792_jkl012", // 深蹲 (提前)
|
||||||
|
"ex_1705123456791_ghi789", // 安全提醒 (延后)
|
||||||
|
"ex_1705123456793_mno345", // 休息
|
||||||
|
"ex_1705123456794_pqr678" // 其他项目...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 批量更新训练强度
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /training-plans/plan_1705123456789_abc123/exercises/batch
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456792_jkl012",
|
||||||
|
"sets": 4, // 增加组数
|
||||||
|
"reps": 18 // 增加次数
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456794_pqr678",
|
||||||
|
"sets": 4,
|
||||||
|
"reps": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456795_stu901",
|
||||||
|
"durationSec": 75 // 增加平板支撑时长
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 查看训练完成统计
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /training-plans/plan_1705123456789_abc123/exercises/stats/completion
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 5, // 总共5个运动项目(不包括休息和提醒)
|
||||||
|
"completed": 3, // 已完成3个
|
||||||
|
"percentage": 60 // 完成率60%
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 删除不需要的项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
[
|
||||||
|
"ex_1705123456793_mno345", // 删除某个休息项目
|
||||||
|
"ex_1705123456796_vwx234" // 删除某个提醒项目
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"deletedCount": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理示例
|
||||||
|
|
||||||
|
### 重复的key错误
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
{
|
||||||
|
"key": "squat_exercise", // 已存在的key
|
||||||
|
"name": "新的深蹲"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "项目key \"squat_exercise\" 已存在",
|
||||||
|
"error": "Bad Request"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 训练计划不存在
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /training-plans/nonexistent_plan/exercises
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 404,
|
||||||
|
"message": "训练计划不存在或不属于当前用户",
|
||||||
|
"error": "Not Found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端集成建议
|
||||||
|
|
||||||
|
### 1. 训练项目组件状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TrainingSessionState {
|
||||||
|
exercises: ScheduleExercise[];
|
||||||
|
currentExercise: number;
|
||||||
|
completedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载训练项目
|
||||||
|
const loadExercises = async (planId: string) => {
|
||||||
|
const exercises = await api.get(`/training-plans/${planId}/exercises`);
|
||||||
|
return exercises.filter(ex => ex.itemType === 'exercise');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
const markComplete = async (planId: string, exerciseId: string) => {
|
||||||
|
await api.put(`/training-plans/${planId}/exercises/${exerciseId}/complete`, {
|
||||||
|
completed: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 拖拽重新排序
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragEnd = async (result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const newOrder = Array.from(exercises);
|
||||||
|
const [reorderedItem] = newOrder.splice(result.source.index, 1);
|
||||||
|
newOrder.splice(result.destination.index, 0, reorderedItem);
|
||||||
|
|
||||||
|
const exerciseIds = newOrder.map(ex => ex.id);
|
||||||
|
await api.put(`/training-plans/${planId}/exercises/order`, {
|
||||||
|
exerciseIds
|
||||||
|
});
|
||||||
|
|
||||||
|
setExercises(newOrder);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
这个API设计提供了完整的训练项目管理功能,支持用户创建个性化的训练流程,实时跟踪完成进度,并且具有良好的扩展性。
|
||||||
240
src/training-plans/README.md
Normal file
240
src/training-plans/README.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# 训练计划项目管理 API 文档
|
||||||
|
|
||||||
|
这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序、批量操作和完成状态跟踪。
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### ScheduleExercise (训练项目)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScheduleExercise {
|
||||||
|
id: string; // 项目ID
|
||||||
|
trainingPlanId: string; // 所属训练计划ID
|
||||||
|
userId: string; // 用户ID
|
||||||
|
key: string; // 项目标识key (唯一)
|
||||||
|
name: string; // 项目名称
|
||||||
|
category?: string; // 项目分类
|
||||||
|
sets?: number; // 组数
|
||||||
|
reps?: number; // 重复次数
|
||||||
|
durationSec?: number; // 持续时长(秒)
|
||||||
|
restSec?: number; // 休息时长(秒)
|
||||||
|
note?: string; // 备注
|
||||||
|
itemType: 'exercise' | 'rest' | 'note'; // 项目类型
|
||||||
|
completed: boolean; // 是否已完成
|
||||||
|
sortOrder: number; // 排序顺序
|
||||||
|
createdAt: Date; // 创建时间
|
||||||
|
updatedAt: Date; // 更新时间
|
||||||
|
deleted: boolean; // 是否已删除
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 1. 添加训练项目
|
||||||
|
|
||||||
|
**POST** `/training-plans/:id/exercises`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "warm_up_1",
|
||||||
|
"name": "热身运动",
|
||||||
|
"category": "热身",
|
||||||
|
"sets": 1,
|
||||||
|
"durationSec": 300,
|
||||||
|
"itemType": "exercise"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 批量添加训练项目
|
||||||
|
|
||||||
|
**POST** `/training-plans/:id/exercises/batch`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"key": "exercise_1",
|
||||||
|
"name": "深蹲",
|
||||||
|
"category": "力量训练",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 15,
|
||||||
|
"itemType": "exercise"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rest_1",
|
||||||
|
"name": "休息",
|
||||||
|
"itemType": "rest",
|
||||||
|
"durationSec": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "note_1",
|
||||||
|
"name": "注意事项",
|
||||||
|
"note": "保持呼吸平稳",
|
||||||
|
"itemType": "note"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取训练计划的所有项目
|
||||||
|
|
||||||
|
**GET** `/training-plans/:id/exercises`
|
||||||
|
|
||||||
|
返回按排序顺序排列的所有训练项目。
|
||||||
|
|
||||||
|
### 4. 获取训练项目详情
|
||||||
|
|
||||||
|
**GET** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
|
### 5. 更新训练项目
|
||||||
|
|
||||||
|
**PUT** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "修改后的名称",
|
||||||
|
"sets": 4,
|
||||||
|
"reps": 12,
|
||||||
|
"completed": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 批量更新训练项目
|
||||||
|
|
||||||
|
**PUT** `/training-plans/:id/exercises/batch`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"id": "exercise_id_1",
|
||||||
|
"sets": 4,
|
||||||
|
"completed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "exercise_id_2",
|
||||||
|
"reps": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 删除训练项目
|
||||||
|
|
||||||
|
**DELETE** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
|
### 8. 批量删除训练项目
|
||||||
|
|
||||||
|
**DELETE** `/training-plans/:id/exercises`
|
||||||
|
|
||||||
|
```json
|
||||||
|
["exercise_id_1", "exercise_id_2", "exercise_id_3"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 更新训练项目排序
|
||||||
|
|
||||||
|
**PUT** `/training-plans/:id/exercises/order`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exerciseIds": ["id3", "id1", "id2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
重新排列项目顺序,数组中的顺序即为新的排序。
|
||||||
|
|
||||||
|
### 10. 标记训练项目完成状态
|
||||||
|
|
||||||
|
**PUT** `/training-plans/:id/exercises/:exerciseId/complete`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"completed": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. 获取训练计划完成统计
|
||||||
|
|
||||||
|
**GET** `/training-plans/:id/exercises/stats/completion`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 10,
|
||||||
|
"completed": 6,
|
||||||
|
"percentage": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 智能排序
|
||||||
|
- 新增项目自动添加到列表末尾
|
||||||
|
- 支持拖拽重新排序
|
||||||
|
- 批量操作时保持排序逻辑
|
||||||
|
|
||||||
|
### 2. 项目类型支持
|
||||||
|
- **exercise**: 运动项目 (支持组数、次数、时长等)
|
||||||
|
- **rest**: 休息项目 (主要设置休息时长)
|
||||||
|
- **note**: 提示项目 (主要用于注意事项)
|
||||||
|
|
||||||
|
### 3. 灵活的参数配置
|
||||||
|
- `sets`: 组数
|
||||||
|
- `reps`: 每组重复次数
|
||||||
|
- `durationSec`: 持续时长(秒),适用于有氧运动或休息
|
||||||
|
- `restSec`: 组间休息时长
|
||||||
|
- `note`: 备注信息
|
||||||
|
|
||||||
|
### 4. 完成状态跟踪
|
||||||
|
- 每个项目都有完成状态
|
||||||
|
- 支持统计整体完成进度
|
||||||
|
- 只有运动类型项目计入统计
|
||||||
|
|
||||||
|
### 5. 批量操作
|
||||||
|
- 批量创建:一次性添加多个项目
|
||||||
|
- 批量更新:同时修改多个项目
|
||||||
|
- 批量删除:一次性删除多个项目
|
||||||
|
|
||||||
|
### 6. 数据安全
|
||||||
|
- 所有操作都验证用户权限
|
||||||
|
- 项目key在同一训练计划内唯一
|
||||||
|
- 支持软删除,数据可恢复
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 创建完整的训练流程
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. 先创建训练计划
|
||||||
|
const plan = await createTrainingPlan({...});
|
||||||
|
|
||||||
|
// 2. 批量添加训练项目
|
||||||
|
await batchCreateExercises(plan.id, {
|
||||||
|
exercises: [
|
||||||
|
// 热身阶段
|
||||||
|
{ key: "warmup", name: "热身", category: "热身", durationSec: 300, itemType: "exercise" },
|
||||||
|
|
||||||
|
// 主要训练
|
||||||
|
{ key: "squat", name: "深蹲", category: "力量", sets: 3, reps: 15, itemType: "exercise" },
|
||||||
|
{ key: "rest_1", name: "休息", itemType: "rest", durationSec: 60 },
|
||||||
|
|
||||||
|
{ key: "pushup", name: "俯卧撑", category: "力量", sets: 3, reps: 12, itemType: "exercise" },
|
||||||
|
{ key: "rest_2", name: "休息", itemType: "rest", durationSec: 60 },
|
||||||
|
|
||||||
|
// 注意事项
|
||||||
|
{ key: "note_form", name: "注意动作标准", note: "保持核心紧张,动作缓慢控制", itemType: "note" },
|
||||||
|
|
||||||
|
// 放松阶段
|
||||||
|
{ key: "cooldown", name: "拉伸放松", category: "拉伸", durationSec: 300, itemType: "exercise" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 用户完成训练项目
|
||||||
|
await markExerciseComplete(plan.id, "squat", { completed: true });
|
||||||
|
await markExerciseComplete(plan.id, "pushup", { completed: true });
|
||||||
|
|
||||||
|
// 4. 查看完成进度
|
||||||
|
const stats = await getCompletionStats(plan.id);
|
||||||
|
// { total: 4, completed: 2, percentage: 50 }
|
||||||
|
```
|
||||||
|
|
||||||
|
这个实现提供了与前端 `ScheduleExercise` 接口完全匹配的后端支持,用户可以灵活地管理训练计划的具体内容。
|
||||||
109
src/training-plans/dto/schedule-exercise.dto.ts
Normal file
109
src/training-plans/dto/schedule-exercise.dto.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
import { ScheduleItemType } from '../models/schedule-exercise.model';
|
||||||
|
|
||||||
|
export class CreateScheduleExerciseDto {
|
||||||
|
@ApiProperty({ description: '项目标识key' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '项目名称' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '项目分类', required: false })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '组数', required: false })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
sets?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '重复次数', required: false })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
reps?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '持续时长(秒)', required: false })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
durationSec?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '休息时长(秒)', required: false })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
restSec?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '备注', required: false })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ['exercise', 'rest', 'note'],
|
||||||
|
description: '项目类型',
|
||||||
|
default: 'exercise',
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
@IsEnum(['exercise', 'rest', 'note'])
|
||||||
|
@IsOptional()
|
||||||
|
itemType?: ScheduleItemType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否已完成', default: false, required: false })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
completed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateScheduleExerciseDto extends PartialType(CreateScheduleExerciseDto) { }
|
||||||
|
|
||||||
|
export class BatchCreateScheduleExerciseDto {
|
||||||
|
@ApiProperty({ type: [CreateScheduleExerciseDto], description: '训练项目列表' })
|
||||||
|
@IsArray()
|
||||||
|
exercises: CreateScheduleExerciseDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchUpdateScheduleExerciseDto {
|
||||||
|
@ApiProperty({ type: [UpdateScheduleExerciseDto], description: '要更新的训练项目列表' })
|
||||||
|
@IsArray()
|
||||||
|
exercises: (UpdateScheduleExerciseDto & { id: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateScheduleExerciseOrderDto {
|
||||||
|
@ApiProperty({ description: '项目ID列表,按新的顺序排列' })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
exerciseIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteScheduleExerciseDto {
|
||||||
|
@ApiProperty({ description: '是否完成', default: true })
|
||||||
|
@IsBoolean()
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScheduleExerciseResponseDto {
|
||||||
|
@ApiProperty() id: string;
|
||||||
|
@ApiProperty() trainingPlanId: string;
|
||||||
|
@ApiProperty() key: string;
|
||||||
|
@ApiProperty() name: string;
|
||||||
|
@ApiProperty() category?: string;
|
||||||
|
@ApiProperty() sets?: number;
|
||||||
|
@ApiProperty() reps?: number;
|
||||||
|
@ApiProperty() durationSec?: number;
|
||||||
|
@ApiProperty() restSec?: number;
|
||||||
|
@ApiProperty() note?: string;
|
||||||
|
@ApiProperty({ enum: ['exercise', 'rest', 'note'] }) itemType: ScheduleItemType;
|
||||||
|
@ApiProperty() completed: boolean;
|
||||||
|
@ApiProperty() sortOrder: number;
|
||||||
|
@ApiProperty() createdAt: Date;
|
||||||
|
@ApiProperty() updatedAt: Date;
|
||||||
|
}
|
||||||
74
src/training-plans/models/schedule-exercise.model.ts
Normal file
74
src/training-plans/models/schedule-exercise.model.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
|
||||||
|
import { TrainingPlan } from './training-plan.model';
|
||||||
|
|
||||||
|
export type ScheduleItemType = 'exercise' | 'rest' | 'note';
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_schedule_exercises',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class ScheduleExercise extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@ForeignKey(() => TrainingPlan)
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false })
|
||||||
|
declare trainingPlanId: string;
|
||||||
|
|
||||||
|
@BelongsTo(() => TrainingPlan)
|
||||||
|
declare trainingPlan: TrainingPlan;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false })
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false, comment: '项目标识key' })
|
||||||
|
declare key: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
|
||||||
|
declare name: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true, comment: '项目分类' })
|
||||||
|
declare category: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '组数' })
|
||||||
|
declare sets: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '重复次数' })
|
||||||
|
declare reps: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '持续时长(秒)' })
|
||||||
|
declare durationSec: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '休息时长(秒)' })
|
||||||
|
declare restSec: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.TEXT, allowNull: true, comment: '备注' })
|
||||||
|
declare note: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.ENUM('exercise', 'rest', 'note'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'exercise',
|
||||||
|
comment: '项目类型'
|
||||||
|
})
|
||||||
|
declare itemType: ScheduleItemType;
|
||||||
|
|
||||||
|
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已完成' })
|
||||||
|
declare completed: boolean;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: false, comment: '排序顺序' })
|
||||||
|
declare sortOrder: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
|
||||||
|
declare deleted: boolean;
|
||||||
|
}
|
||||||
478
src/training-plans/schedule-exercise.service.ts
Normal file
478
src/training-plans/schedule-exercise.service.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import { Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
|
import { ScheduleExercise } from './models/schedule-exercise.model';
|
||||||
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
|
import {
|
||||||
|
CreateScheduleExerciseDto,
|
||||||
|
UpdateScheduleExerciseDto,
|
||||||
|
BatchCreateScheduleExerciseDto,
|
||||||
|
BatchUpdateScheduleExerciseDto,
|
||||||
|
UpdateScheduleExerciseOrderDto,
|
||||||
|
CompleteScheduleExerciseDto
|
||||||
|
} from './dto/schedule-exercise.dto';
|
||||||
|
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||||
|
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||||||
|
import { Logger as WinstonLogger } from 'winston';
|
||||||
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
|
import { Op, Transaction } from 'sequelize';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleExerciseService {
|
||||||
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ScheduleExercise)
|
||||||
|
private scheduleExerciseModel: typeof ScheduleExercise,
|
||||||
|
@InjectModel(TrainingPlan)
|
||||||
|
private trainingPlanModel: typeof TrainingPlan,
|
||||||
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// 验证训练计划是否属于用户
|
||||||
|
private async validateTrainingPlan(userId: string, trainingPlanId: string): Promise<TrainingPlan> {
|
||||||
|
const plan = await this.trainingPlanModel.findOne({
|
||||||
|
where: { id: trainingPlanId, userId, deleted: false }
|
||||||
|
});
|
||||||
|
if (!plan) {
|
||||||
|
throw new NotFoundException('训练计划不存在或不属于当前用户');
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取下一个排序顺序
|
||||||
|
private async getNextSortOrder(trainingPlanId: string): Promise<number> {
|
||||||
|
const lastExercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { trainingPlanId, deleted: false },
|
||||||
|
order: [['sortOrder', 'DESC']],
|
||||||
|
});
|
||||||
|
return lastExercise ? lastExercise.sortOrder + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单个训练项目
|
||||||
|
async create(userId: string, trainingPlanId: string, dto: CreateScheduleExerciseDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
// 检查key是否已存在
|
||||||
|
const existingExercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { trainingPlanId, key: dto.key, deleted: false }
|
||||||
|
});
|
||||||
|
if (existingExercise) {
|
||||||
|
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = await this.getNextSortOrder(trainingPlanId);
|
||||||
|
|
||||||
|
const exercise = await this.scheduleExerciseModel.create({
|
||||||
|
trainingPlanId,
|
||||||
|
userId,
|
||||||
|
key: dto.key,
|
||||||
|
name: dto.name,
|
||||||
|
category: dto.category || '',
|
||||||
|
sets: dto.sets || 0,
|
||||||
|
reps: dto.reps,
|
||||||
|
durationSec: dto.durationSec,
|
||||||
|
restSec: dto.restSec,
|
||||||
|
note: dto.note || '',
|
||||||
|
itemType: dto.itemType || 'exercise',
|
||||||
|
completed: dto.completed || false,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.winstonLogger.info(`创建训练项目 ${exercise.id}`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
exerciseId: exercise.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.CREATE,
|
||||||
|
entityId: exercise.id,
|
||||||
|
changes: exercise.toJSON(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercise.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建训练项目
|
||||||
|
async batchCreate(userId: string, trainingPlanId: string, dto: BatchCreateScheduleExerciseDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const transaction = await this.scheduleExerciseModel.sequelize?.transaction();
|
||||||
|
if (!transaction) throw new Error('Failed to start transaction');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exercises: ScheduleExercise[] = [];
|
||||||
|
let sortOrder = await this.getNextSortOrder(trainingPlanId);
|
||||||
|
|
||||||
|
for (const exerciseDto of dto.exercises) {
|
||||||
|
// 检查key是否已存在
|
||||||
|
const existingExercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { trainingPlanId, key: exerciseDto.key, deleted: false },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (existingExercise) {
|
||||||
|
throw new BadRequestException(`项目key "${exerciseDto.key}" 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercise = await this.scheduleExerciseModel.create({
|
||||||
|
trainingPlanId,
|
||||||
|
userId,
|
||||||
|
key: exerciseDto.key,
|
||||||
|
name: exerciseDto.name,
|
||||||
|
category: exerciseDto.category || '',
|
||||||
|
sets: exerciseDto.sets || 0,
|
||||||
|
reps: exerciseDto.reps,
|
||||||
|
durationSec: exerciseDto.durationSec,
|
||||||
|
restSec: exerciseDto.restSec,
|
||||||
|
note: exerciseDto.note || '',
|
||||||
|
itemType: exerciseDto.itemType || 'exercise',
|
||||||
|
completed: exerciseDto.completed || false,
|
||||||
|
sortOrder: sortOrder++,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
exercises.push(exercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
this.winstonLogger.info(`批量创建训练项目 ${exercises.length} 个`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
count: exercises.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises.map(exercise => exercise.toJSON());
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取训练计划的所有项目
|
||||||
|
async list(userId: string, trainingPlanId: string) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const exercises = await this.scheduleExerciseModel.findAll({
|
||||||
|
where: { trainingPlanId, userId, deleted: false },
|
||||||
|
order: [['sortOrder', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises.map(exercise => exercise.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个训练项目详情
|
||||||
|
async detail(userId: string, trainingPlanId: string, exerciseId: string) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const exercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException('训练项目不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercise.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新训练项目
|
||||||
|
async update(userId: string, trainingPlanId: string, exerciseId: string, dto: UpdateScheduleExerciseDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const exercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException('训练项目不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新key,检查是否冲突
|
||||||
|
if (dto.key && dto.key !== exercise.key) {
|
||||||
|
const existingExercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { trainingPlanId, key: dto.key, deleted: false, id: { [Op.ne]: exerciseId } }
|
||||||
|
});
|
||||||
|
if (existingExercise) {
|
||||||
|
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = exercise.toJSON();
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if (dto.key !== undefined) exercise.key = dto.key;
|
||||||
|
if (dto.name !== undefined) exercise.name = dto.name;
|
||||||
|
if (dto.category !== undefined) exercise.category = dto.category || '';
|
||||||
|
if (dto.sets !== undefined) exercise.sets = dto.sets || 0;
|
||||||
|
if (dto.reps !== undefined) exercise.reps = dto.reps;
|
||||||
|
if (dto.durationSec !== undefined) exercise.durationSec = dto.durationSec;
|
||||||
|
if (dto.restSec !== undefined) exercise.restSec = dto.restSec;
|
||||||
|
if (dto.note !== undefined) exercise.note = dto.note || '';
|
||||||
|
if (dto.itemType !== undefined) exercise.itemType = dto.itemType;
|
||||||
|
if (dto.completed !== undefined) exercise.completed = dto.completed;
|
||||||
|
|
||||||
|
await exercise.save();
|
||||||
|
|
||||||
|
const after = exercise.toJSON();
|
||||||
|
const changedKeys = Object.keys(after).filter((key) => (before as any)[key] !== (after as any)[key]);
|
||||||
|
const changes: Record<string, any> = {};
|
||||||
|
for (const key of changedKeys) {
|
||||||
|
changes[key] = { before: (before as any)[key], after: (after as any)[key] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: exerciseId,
|
||||||
|
changes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.winstonLogger.info(`更新训练项目 ${exerciseId}`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
exerciseId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新训练项目
|
||||||
|
async batchUpdate(userId: string, trainingPlanId: string, dto: BatchUpdateScheduleExerciseDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const transaction = await this.scheduleExerciseModel.sequelize?.transaction();
|
||||||
|
if (!transaction) throw new Error('Failed to start transaction');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedExercises: ScheduleExercise[] = [];
|
||||||
|
|
||||||
|
for (const exerciseDto of dto.exercises) {
|
||||||
|
const exercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { id: exerciseDto.id, trainingPlanId, userId, deleted: false },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException(`训练项目 ${exerciseDto.id} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新key,检查是否冲突
|
||||||
|
if (exerciseDto.key && exerciseDto.key !== exercise.key) {
|
||||||
|
const existingExercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: {
|
||||||
|
trainingPlanId,
|
||||||
|
key: exerciseDto.key,
|
||||||
|
deleted: false,
|
||||||
|
id: { [Op.ne]: exerciseDto.id }
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (existingExercise) {
|
||||||
|
throw new BadRequestException(`项目key "${exerciseDto.key}" 已存在`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if (exerciseDto.key !== undefined) exercise.key = exerciseDto.key;
|
||||||
|
if (exerciseDto.name !== undefined) exercise.name = exerciseDto.name;
|
||||||
|
if (exerciseDto.category !== undefined) exercise.category = exerciseDto.category || '';
|
||||||
|
if (exerciseDto.sets !== undefined) exercise.sets = exerciseDto.sets || 0;
|
||||||
|
if (exerciseDto.reps !== undefined) exercise.reps = exerciseDto.reps;
|
||||||
|
if (exerciseDto.durationSec !== undefined) exercise.durationSec = exerciseDto.durationSec;
|
||||||
|
if (exerciseDto.restSec !== undefined) exercise.restSec = exerciseDto.restSec;
|
||||||
|
if (exerciseDto.note !== undefined) exercise.note = exerciseDto.note || '';
|
||||||
|
if (exerciseDto.itemType !== undefined) exercise.itemType = exerciseDto.itemType;
|
||||||
|
if (exerciseDto.completed !== undefined) exercise.completed = exerciseDto.completed;
|
||||||
|
|
||||||
|
await exercise.save({ transaction });
|
||||||
|
updatedExercises.push(exercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
this.winstonLogger.info(`批量更新训练项目 ${updatedExercises.length} 个`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
count: updatedExercises.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedExercises.map(exercise => exercise.toJSON());
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除训练项目
|
||||||
|
async remove(userId: string, trainingPlanId: string, exerciseId: string) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const [count] = await this.scheduleExerciseModel.update(
|
||||||
|
{ deleted: true },
|
||||||
|
{ where: { id: exerciseId, trainingPlanId, userId, deleted: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
throw new NotFoundException('训练项目不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.DELETE,
|
||||||
|
entityId: exerciseId,
|
||||||
|
changes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.winstonLogger.info(`删除训练项目 ${exerciseId}`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
exerciseId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除训练项目
|
||||||
|
async batchRemove(userId: string, trainingPlanId: string, exerciseIds: string[]) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const [count] = await this.scheduleExerciseModel.update(
|
||||||
|
{ deleted: true },
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: { [Op.in]: exerciseIds },
|
||||||
|
trainingPlanId,
|
||||||
|
userId,
|
||||||
|
deleted: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.winstonLogger.info(`批量删除训练项目 ${count} 个`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, deletedCount: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新训练项目排序
|
||||||
|
async updateOrder(userId: string, trainingPlanId: string, dto: UpdateScheduleExerciseOrderDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const transaction = await this.scheduleExerciseModel.sequelize?.transaction();
|
||||||
|
if (!transaction) throw new Error('Failed to start transaction');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证所有ID都存在且属于该训练计划
|
||||||
|
const exercises = await this.scheduleExerciseModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: { [Op.in]: dto.exerciseIds },
|
||||||
|
trainingPlanId,
|
||||||
|
userId,
|
||||||
|
deleted: false
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exercises.length !== dto.exerciseIds.length) {
|
||||||
|
throw new BadRequestException('部分训练项目不存在或不属于该训练计划');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新排序
|
||||||
|
for (let i = 0; i < dto.exerciseIds.length; i++) {
|
||||||
|
await this.scheduleExerciseModel.update(
|
||||||
|
{ sortOrder: i + 1 },
|
||||||
|
{
|
||||||
|
where: { id: dto.exerciseIds[i] },
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
this.winstonLogger.info(`更新训练项目排序`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
exerciseCount: dto.exerciseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记完成状态
|
||||||
|
async markComplete(userId: string, trainingPlanId: string, exerciseId: string, dto: CompleteScheduleExerciseDto) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const exercise = await this.scheduleExerciseModel.findOne({
|
||||||
|
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException('训练项目不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = exercise.completed;
|
||||||
|
exercise.completed = dto.completed;
|
||||||
|
await exercise.save();
|
||||||
|
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: exerciseId,
|
||||||
|
changes: {
|
||||||
|
completed: { before, after: dto.completed }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.winstonLogger.info(`标记训练项目完成状态 ${exerciseId}: ${dto.completed}`, {
|
||||||
|
context: 'ScheduleExerciseService',
|
||||||
|
userId,
|
||||||
|
trainingPlanId,
|
||||||
|
exerciseId,
|
||||||
|
completed: dto.completed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercise.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取训练计划的完成统计
|
||||||
|
async getCompletionStats(userId: string, trainingPlanId: string) {
|
||||||
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
|
const [total, completed] = await Promise.all([
|
||||||
|
this.scheduleExerciseModel.count({
|
||||||
|
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise' }
|
||||||
|
}),
|
||||||
|
this.scheduleExerciseModel.count({
|
||||||
|
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise', completed: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
percentage: total > 0 ? Math.round((completed / total) * 100) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } from '@nestjs/common';
|
||||||
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
import { TrainingPlansService } from './training-plans.service';
|
import { TrainingPlansService } from './training-plans.service';
|
||||||
|
import { ScheduleExerciseService } from './schedule-exercise.service';
|
||||||
import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-plan.dto';
|
import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-plan.dto';
|
||||||
|
import {
|
||||||
|
CreateScheduleExerciseDto,
|
||||||
|
UpdateScheduleExerciseDto,
|
||||||
|
BatchCreateScheduleExerciseDto,
|
||||||
|
BatchUpdateScheduleExerciseDto,
|
||||||
|
UpdateScheduleExerciseOrderDto,
|
||||||
|
CompleteScheduleExerciseDto,
|
||||||
|
ScheduleExerciseResponseDto
|
||||||
|
} from './dto/schedule-exercise.dto';
|
||||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||||
@@ -10,7 +20,10 @@ import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
|||||||
@Controller('training-plans')
|
@Controller('training-plans')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class TrainingPlansController {
|
export class TrainingPlansController {
|
||||||
constructor(private readonly service: TrainingPlansService) { }
|
constructor(
|
||||||
|
private readonly service: TrainingPlansService,
|
||||||
|
private readonly scheduleExerciseService: ScheduleExerciseService,
|
||||||
|
) { }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '新增训练计划' })
|
@ApiOperation({ summary: '新增训练计划' })
|
||||||
@@ -61,6 +74,140 @@ export class TrainingPlansController {
|
|||||||
async activate(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) {
|
async activate(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) {
|
||||||
return this.service.activate(user.sub, id);
|
return this.service.activate(user.sub, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 训练项目管理 ====================
|
||||||
|
|
||||||
|
@Post(':id/exercises')
|
||||||
|
@ApiOperation({ summary: '添加训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiBody({ type: CreateScheduleExerciseDto })
|
||||||
|
async createExercise(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Body() dto: CreateScheduleExerciseDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.create(user.sub, trainingPlanId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/exercises/batch')
|
||||||
|
@ApiOperation({ summary: '批量添加训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiBody({ type: BatchCreateScheduleExerciseDto })
|
||||||
|
async batchCreateExercises(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Body() dto: BatchCreateScheduleExerciseDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.batchCreate(user.sub, trainingPlanId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/exercises')
|
||||||
|
@ApiOperation({ summary: '获取训练计划的所有项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
async listExercises(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.list(user.sub, trainingPlanId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/exercises/:exerciseId')
|
||||||
|
@ApiOperation({ summary: '获取训练项目详情' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
|
||||||
|
async getExerciseDetail(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Param('exerciseId') exerciseId: string,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.detail(user.sub, trainingPlanId, exerciseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/exercises/:exerciseId')
|
||||||
|
@ApiOperation({ summary: '更新训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
|
||||||
|
@ApiBody({ type: UpdateScheduleExerciseDto })
|
||||||
|
async updateExercise(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Param('exerciseId') exerciseId: string,
|
||||||
|
@Body() dto: UpdateScheduleExerciseDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.update(user.sub, trainingPlanId, exerciseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/exercises/batch')
|
||||||
|
@ApiOperation({ summary: '批量更新训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiBody({ type: BatchUpdateScheduleExerciseDto })
|
||||||
|
async batchUpdateExercises(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Body() dto: BatchUpdateScheduleExerciseDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.batchUpdate(user.sub, trainingPlanId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/exercises/:exerciseId')
|
||||||
|
@ApiOperation({ summary: '删除训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
|
||||||
|
async removeExercise(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Param('exerciseId') exerciseId: string,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.remove(user.sub, trainingPlanId, exerciseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/exercises')
|
||||||
|
@ApiOperation({ summary: '批量删除训练项目' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiBody({ type: [String], description: '训练项目ID列表' })
|
||||||
|
async batchRemoveExercises(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Body() exerciseIds: string[],
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.batchRemove(user.sub, trainingPlanId, exerciseIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/exercises/order')
|
||||||
|
@ApiOperation({ summary: '更新训练项目排序' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiBody({ type: UpdateScheduleExerciseOrderDto })
|
||||||
|
async updateExerciseOrder(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Body() dto: UpdateScheduleExerciseOrderDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.updateOrder(user.sub, trainingPlanId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/exercises/:exerciseId/complete')
|
||||||
|
@ApiOperation({ summary: '标记训练项目完成状态' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
|
||||||
|
@ApiBody({ type: CompleteScheduleExerciseDto })
|
||||||
|
async markExerciseComplete(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
@Param('exerciseId') exerciseId: string,
|
||||||
|
@Body() dto: CompleteScheduleExerciseDto,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.markComplete(user.sub, trainingPlanId, exerciseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/exercises/stats/completion')
|
||||||
|
@ApiOperation({ summary: '获取训练计划完成统计' })
|
||||||
|
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||||
|
async getExerciseCompletionStats(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Param('id') trainingPlanId: string,
|
||||||
|
) {
|
||||||
|
return this.scheduleExerciseService.getCompletionStats(user.sub, trainingPlanId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SequelizeModule } from '@nestjs/sequelize';
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
import { TrainingPlansService } from './training-plans.service';
|
import { TrainingPlansService } from './training-plans.service';
|
||||||
|
import { ScheduleExerciseService } from './schedule-exercise.service';
|
||||||
import { TrainingPlansController } from './training-plans.controller';
|
import { TrainingPlansController } from './training-plans.controller';
|
||||||
import { TrainingPlan } from './models/training-plan.model';
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
|
import { ScheduleExercise } from './models/schedule-exercise.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@@ -10,11 +12,11 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
|||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ActivityLogsModule,
|
ActivityLogsModule,
|
||||||
SequelizeModule.forFeature([TrainingPlan]),
|
SequelizeModule.forFeature([TrainingPlan, ScheduleExercise]),
|
||||||
],
|
],
|
||||||
controllers: [TrainingPlansController],
|
controllers: [TrainingPlansController],
|
||||||
providers: [TrainingPlansService],
|
providers: [TrainingPlansService, ScheduleExerciseService],
|
||||||
exports: [TrainingPlansService],
|
exports: [TrainingPlansService, ScheduleExerciseService],
|
||||||
})
|
})
|
||||||
export class TrainingPlansModule { }
|
export class TrainingPlansModule { }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user