优化训练计划项目管理功能
- 更新训练项目文档,增加与动作库的智能关联和简化接口操作的说明 - 移除批量操作接口,专注于单项操作,提升用户体验 - 增强数据模型,确保训练项目与动作库的关联性,提升数据一致性和查询性能 - 更新服务逻辑,支持动作存在性验证,确保数据的准确性和完整性
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
# 训练计划项目管理 API 使用示例
|
# 训练计划项目管理 API 使用示例(优化版)
|
||||||
|
|
||||||
|
这个版本展示了优化后的 API 设计,重点是与动作库的智能关联和简化的接口操作。
|
||||||
|
|
||||||
## 创建一个完整的训练计划项目
|
## 创建一个完整的训练计划项目
|
||||||
|
|
||||||
@@ -35,139 +37,169 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 批量添加训练项目
|
### 2. 逐个添加训练项目(关联动作库)
|
||||||
|
|
||||||
|
#### 添加热身项目
|
||||||
```bash
|
```bash
|
||||||
POST /training-plans/plan_1705123456789_abc123/exercises/batch
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"exercises": [
|
"exerciseKey": "dynamic_warmup", // 关联动作库中的动态热身
|
||||||
{
|
|
||||||
"key": "warmup_dynamic",
|
|
||||||
"name": "动态热身",
|
"name": "动态热身",
|
||||||
"category": "热身",
|
|
||||||
"durationSec": 300,
|
"durationSec": 300,
|
||||||
"itemType": "exercise",
|
"itemType": "exercise",
|
||||||
"note": "轻松活动关节,准备训练"
|
"note": "轻松活动关节,准备训练"
|
||||||
},
|
}
|
||||||
{
|
```
|
||||||
"key": "note_safety",
|
|
||||||
|
#### 添加安全提醒
|
||||||
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
"name": "安全提醒",
|
"name": "安全提醒",
|
||||||
"note": "如感到不适请立即停止,保持正确呼吸",
|
"note": "如感到不适请立即停止,保持正确呼吸",
|
||||||
"itemType": "note"
|
"itemType": "note"
|
||||||
},
|
}
|
||||||
{
|
```
|
||||||
"key": "squat_exercise",
|
|
||||||
|
#### 添加深蹲训练
|
||||||
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"exerciseKey": "squat", // 关联动作库中的深蹲
|
||||||
"name": "深蹲训练",
|
"name": "深蹲训练",
|
||||||
"category": "下肢力量",
|
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": 15,
|
"reps": 15,
|
||||||
"restSec": 60,
|
"restSec": 60,
|
||||||
"itemType": "exercise",
|
"itemType": "exercise",
|
||||||
"note": "下蹲时膝盖不超过脚尖"
|
"note": "下蹲时膝盖不超过脚尖"
|
||||||
},
|
}
|
||||||
{
|
```
|
||||||
"key": "rest_squat",
|
|
||||||
|
#### 添加休息时间
|
||||||
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
"name": "组间休息",
|
"name": "组间休息",
|
||||||
"durationSec": 90,
|
"durationSec": 90,
|
||||||
"itemType": "rest"
|
"itemType": "rest"
|
||||||
},
|
}
|
||||||
{
|
```
|
||||||
"key": "pushup_exercise",
|
|
||||||
"name": "俯卧撑",
|
#### 添加俯卧撑训练
|
||||||
"category": "上肢力量",
|
```bash
|
||||||
|
POST /training-plans/plan_1705123456789_abc123/exercises
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"exerciseKey": "pushup", // 关联动作库中的俯卧撑
|
||||||
|
"name": "俯卧撑训练",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": 12,
|
"reps": 12,
|
||||||
"restSec": 60,
|
"restSec": 60,
|
||||||
"itemType": "exercise",
|
"itemType": "exercise",
|
||||||
"note": "保持身体一条直线"
|
"note": "保持身体一条直线"
|
||||||
},
|
}
|
||||||
{
|
```
|
||||||
"key": "rest_pushup",
|
```
|
||||||
"name": "组间休息",
|
|
||||||
"durationSec": 90,
|
每个添加请求的响应示例:
|
||||||
"itemType": "rest"
|
```json
|
||||||
},
|
{
|
||||||
{
|
"id": "ex_1705123456790_def456",
|
||||||
"key": "plank_exercise",
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
"name": "平板支撑",
|
"exerciseKey": "squat",
|
||||||
"category": "核心力量",
|
"name": "深蹲训练",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"durationSec": 60,
|
"reps": 15,
|
||||||
"restSec": 45,
|
"restSec": 60,
|
||||||
|
"note": "下蹲时膝盖不超过脚尖",
|
||||||
"itemType": "exercise",
|
"itemType": "exercise",
|
||||||
"note": "腹部收紧,不要塌腰"
|
"completed": false,
|
||||||
},
|
"sortOrder": 3,
|
||||||
{
|
"createdAt": "2024-01-10T10:35:00.000Z",
|
||||||
"key": "cooldown_stretch",
|
"updatedAt": "2024-01-10T10:35:00.000Z"
|
||||||
"name": "拉伸放松",
|
|
||||||
"category": "拉伸",
|
|
||||||
"durationSec": 600,
|
|
||||||
"itemType": "exercise",
|
|
||||||
"note": "充分拉伸训练过的肌群"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
### 3. 获取训练项目列表(包含动作库信息)
|
||||||
```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
|
```bash
|
||||||
GET /training-plans/plan_1705123456789_abc123/exercises
|
GET /training-plans/plan_1705123456789_abc123/exercises
|
||||||
Authorization: Bearer {token}
|
Authorization: Bearer {token}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:按sortOrder排序的完整项目列表
|
响应:按sortOrder排序的完整项目列表,包含关联的动作信息
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "ex_1705123456790_def456",
|
"id": "ex_1705123456790_def456",
|
||||||
"trainingPlanId": "plan_1705123456789_abc123",
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
"key": "warmup_dynamic",
|
"exerciseKey": "dynamic_warmup",
|
||||||
"name": "动态热身",
|
"name": "动态热身",
|
||||||
"category": "热身",
|
|
||||||
"sets": 0,
|
|
||||||
"durationSec": 300,
|
"durationSec": 300,
|
||||||
"note": "轻松活动关节,准备训练",
|
"note": "轻松活动关节,准备训练",
|
||||||
"itemType": "exercise",
|
"itemType": "exercise",
|
||||||
"completed": false,
|
"completed": false,
|
||||||
"sortOrder": 1
|
"sortOrder": 1,
|
||||||
|
"exercise": {
|
||||||
|
"key": "dynamic_warmup",
|
||||||
|
"name": "动态热身",
|
||||||
|
"description": "通过各种动态动作激活身体,为训练做准备",
|
||||||
|
"categoryKey": "warmup",
|
||||||
|
"categoryName": "热身"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ex_1705123456791_ghi789",
|
"id": "ex_1705123456791_ghi789",
|
||||||
"trainingPlanId": "plan_1705123456789_abc123",
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
"key": "note_safety",
|
|
||||||
"name": "安全提醒",
|
"name": "安全提醒",
|
||||||
"note": "如感到不适请立即停止,保持正确呼吸",
|
"note": "如感到不适请立即停止,保持正确呼吸",
|
||||||
"itemType": "note",
|
"itemType": "note",
|
||||||
"completed": false,
|
"completed": false,
|
||||||
"sortOrder": 2
|
"sortOrder": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456792_jkl012",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"exerciseKey": "squat",
|
||||||
|
"name": "深蹲训练",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 15,
|
||||||
|
"restSec": 60,
|
||||||
|
"note": "下蹲时膝盖不超过脚尖",
|
||||||
|
"itemType": "exercise",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 3,
|
||||||
|
"exercise": {
|
||||||
|
"key": "squat",
|
||||||
|
"name": "深蹲",
|
||||||
|
"description": "下肢力量训练的基础动作,主要锻炼大腿和臀部肌肉",
|
||||||
|
"categoryKey": "strength",
|
||||||
|
"categoryName": "力量训练"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_1705123456793_mno345",
|
||||||
|
"trainingPlanId": "plan_1705123456789_abc123",
|
||||||
|
"name": "组间休息",
|
||||||
|
"durationSec": 90,
|
||||||
|
"itemType": "rest",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 4
|
||||||
}
|
}
|
||||||
// ... 更多项目
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
200
docs/training-plans-optimization-summary.md
Normal file
200
docs/training-plans-optimization-summary.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 训练计划项目管理系统优化总结
|
||||||
|
|
||||||
|
## 优化背景
|
||||||
|
|
||||||
|
原始设计存在以下问题:
|
||||||
|
1. 训练项目与系统动作库缺乏关联
|
||||||
|
2. 接口复杂,包含过多批量操作
|
||||||
|
3. 数据结构冗余,缺乏标准化
|
||||||
|
|
||||||
|
## 核心优化
|
||||||
|
|
||||||
|
### 1. 数据库模型优化
|
||||||
|
|
||||||
|
#### 之前的设计
|
||||||
|
```typescript
|
||||||
|
// 独立的项目标识,与动作库无关联
|
||||||
|
interface ScheduleExercise {
|
||||||
|
key: string; // 自定义项目key
|
||||||
|
name: string; // 自定义名称
|
||||||
|
category?: string; // 自定义分类
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 优化后的设计
|
||||||
|
```typescript
|
||||||
|
// 智能关联动作库的设计
|
||||||
|
interface ScheduleExercise {
|
||||||
|
exerciseKey?: string; // 关联动作库(外键)
|
||||||
|
name: string; // 可自定义的名称
|
||||||
|
// ...
|
||||||
|
exercise?: { // 关联的动作信息
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryKey: string;
|
||||||
|
categoryName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据库关系
|
||||||
|
- `ScheduleExercise.exerciseKey` → `Exercise.key` (外键关联)
|
||||||
|
- 支持左连接查询,获取完整动作信息
|
||||||
|
- exercise类型项目必须关联动作库,rest/note类型可选
|
||||||
|
|
||||||
|
### 2. 接口设计简化
|
||||||
|
|
||||||
|
#### 去除的批量操作接口
|
||||||
|
- ❌ `POST /exercises/batch` - 批量创建
|
||||||
|
- ❌ `PUT /exercises/batch` - 批量更新
|
||||||
|
- ❌ `DELETE /exercises` - 批量删除
|
||||||
|
|
||||||
|
#### 保留的核心接口
|
||||||
|
- ✅ `POST /exercises` - 单个创建
|
||||||
|
- ✅ `GET /exercises` - 列表查询
|
||||||
|
- ✅ `GET /exercises/:id` - 详情查询
|
||||||
|
- ✅ `PUT /exercises/:id` - 单个更新
|
||||||
|
- ✅ `DELETE /exercises/:id` - 单个删除
|
||||||
|
- ✅ `PUT /exercises/order` - 排序调整
|
||||||
|
- ✅ `PUT /exercises/:id/complete` - 完成状态
|
||||||
|
- ✅ `GET /exercises/stats/completion` - 统计信息
|
||||||
|
|
||||||
|
### 3. 业务逻辑优化
|
||||||
|
|
||||||
|
#### 智能验证机制
|
||||||
|
```typescript
|
||||||
|
// 动作存在性验证
|
||||||
|
private async validateExercise(exerciseKey: string) {
|
||||||
|
const exercise = await this.exerciseModel.findByPk(exerciseKey);
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException(`动作 "${exerciseKey}" 不存在`);
|
||||||
|
}
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建时的验证逻辑
|
||||||
|
if (dto.itemType === 'exercise' && dto.exerciseKey) {
|
||||||
|
await this.validateExercise(dto.exerciseKey);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 关联数据获取
|
||||||
|
```typescript
|
||||||
|
// 查询时自动包含动作信息
|
||||||
|
const exercises = await this.scheduleExerciseModel.findAll({
|
||||||
|
include: [{ model: Exercise, required: false }],
|
||||||
|
order: [['sortOrder', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化返回数据
|
||||||
|
return exercises.map(exercise => ({
|
||||||
|
...exercise.toJSON(),
|
||||||
|
exercise: exercise.exercise ? {
|
||||||
|
key: exercise.exercise.key,
|
||||||
|
name: exercise.exercise.name,
|
||||||
|
description: exercise.exercise.description,
|
||||||
|
categoryKey: exercise.exercise.categoryKey,
|
||||||
|
categoryName: exercise.exercise.categoryName,
|
||||||
|
} : undefined
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术收益
|
||||||
|
|
||||||
|
### 1. 数据一致性提升
|
||||||
|
- ✅ 动作信息标准化,避免重复维护
|
||||||
|
- ✅ 外键约束确保数据完整性
|
||||||
|
- ✅ 动作库统一管理,便于更新
|
||||||
|
|
||||||
|
### 2. 接口复杂度降低
|
||||||
|
- ✅ 去除批量操作,专注单项操作
|
||||||
|
- ✅ 接口数量从11个减少到8个
|
||||||
|
- ✅ 参数验证逻辑简化
|
||||||
|
|
||||||
|
### 3. 查询性能优化
|
||||||
|
- ✅ 左连接一次性获取完整信息
|
||||||
|
- ✅ 减少前端多次API调用
|
||||||
|
- ✅ 数据库索引优化
|
||||||
|
|
||||||
|
### 4. 扩展性增强
|
||||||
|
- ✅ 新增动作自动可用于训练计划
|
||||||
|
- ✅ 动作分类变更自动同步
|
||||||
|
- ✅ 支持动作版本管理
|
||||||
|
|
||||||
|
## 业务价值
|
||||||
|
|
||||||
|
### 1. 用户体验改善
|
||||||
|
- **专业性提升**: 基于标准动作库,确保训练的专业性
|
||||||
|
- **操作简化**: 单项操作更直观,降低学习成本
|
||||||
|
- **信息完整**: 自动获取动作描述、分类等详细信息
|
||||||
|
|
||||||
|
### 2. 内容管理优化
|
||||||
|
- **集中管理**: 动作库统一维护,避免内容重复
|
||||||
|
- **质量控制**: 标准化的动作描述和分类
|
||||||
|
- **版本控制**: 支持动作信息的统一更新
|
||||||
|
|
||||||
|
### 3. 开发效率提升
|
||||||
|
- **接口简化**: 减少API数量,降低维护成本
|
||||||
|
- **类型安全**: 强类型验证,减少运行时错误
|
||||||
|
- **测试简化**: 单项操作更容易编写测试用例
|
||||||
|
|
||||||
|
## 前端适配建议
|
||||||
|
|
||||||
|
### 1. 动作选择组件
|
||||||
|
```typescript
|
||||||
|
// 获取可用动作列表
|
||||||
|
const exercises = await api.get('/exercises/config');
|
||||||
|
|
||||||
|
// 创建训练项目时选择动作
|
||||||
|
const createExercise = {
|
||||||
|
exerciseKey: selectedExercise.key, // 从动作库选择
|
||||||
|
name: customName || selectedExercise.name, // 可自定义名称
|
||||||
|
sets: 3,
|
||||||
|
reps: 15,
|
||||||
|
itemType: 'exercise'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据显示组件
|
||||||
|
```typescript
|
||||||
|
// 显示训练项目时同时展示动作详情
|
||||||
|
const ExerciseItem = ({ item }) => (
|
||||||
|
<div>
|
||||||
|
<h3>{item.name}</h3>
|
||||||
|
{item.exercise && (
|
||||||
|
<div>
|
||||||
|
<p>分类: {item.exercise.categoryName}</p>
|
||||||
|
<p>说明: {item.exercise.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p>组数: {item.sets} | 次数: {item.reps}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 操作流程优化
|
||||||
|
```typescript
|
||||||
|
// 简化的操作流程
|
||||||
|
const addExercise = async (exerciseKey, customParams) => {
|
||||||
|
const exercise = await api.post(`/training-plans/${planId}/exercises`, {
|
||||||
|
exerciseKey,
|
||||||
|
...customParams
|
||||||
|
});
|
||||||
|
|
||||||
|
// 无需额外查询,响应已包含完整信息
|
||||||
|
updateExerciseList(exercise);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
这次优化重点解决了数据关联和接口复杂度两大核心问题:
|
||||||
|
|
||||||
|
1. **智能关联**: 通过外键关联确保训练项目与动作库的数据一致性
|
||||||
|
2. **接口简化**: 去除批量操作,专注于单项操作的用户体验
|
||||||
|
3. **性能优化**: 通过关联查询减少API调用次数
|
||||||
|
4. **扩展性**: 为未来的功能扩展提供了良好的架构基础
|
||||||
|
|
||||||
|
这个优化后的设计既保持了功能的完整性,又显著提升了系统的可维护性和用户体验。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 训练计划项目管理 API 文档
|
# 训练计划项目管理 API 文档
|
||||||
|
|
||||||
这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序、批量操作和完成状态跟踪。
|
这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序和完成状态跟踪。项目与系统动作库智能关联,提供了训练、休息、提醒三种类型的灵活支持。
|
||||||
|
|
||||||
## 数据模型
|
## 数据模型
|
||||||
|
|
||||||
@@ -11,9 +11,8 @@ interface ScheduleExercise {
|
|||||||
id: string; // 项目ID
|
id: string; // 项目ID
|
||||||
trainingPlanId: string; // 所属训练计划ID
|
trainingPlanId: string; // 所属训练计划ID
|
||||||
userId: string; // 用户ID
|
userId: string; // 用户ID
|
||||||
key: string; // 项目标识key (唯一)
|
exerciseKey?: string; // 关联的动作key(仅exercise类型)
|
||||||
name: string; // 项目名称
|
name: string; // 项目名称
|
||||||
category?: string; // 项目分类
|
|
||||||
sets?: number; // 组数
|
sets?: number; // 组数
|
||||||
reps?: number; // 重复次数
|
reps?: number; // 重复次数
|
||||||
durationSec?: number; // 持续时长(秒)
|
durationSec?: number; // 持续时长(秒)
|
||||||
@@ -25,6 +24,15 @@ interface ScheduleExercise {
|
|||||||
createdAt: Date; // 创建时间
|
createdAt: Date; // 创建时间
|
||||||
updatedAt: Date; // 更新时间
|
updatedAt: Date; // 更新时间
|
||||||
deleted: boolean; // 是否已删除
|
deleted: boolean; // 是否已删除
|
||||||
|
|
||||||
|
// 关联的动作信息(仅exercise类型时存在)
|
||||||
|
exercise?: {
|
||||||
|
key: string; // 动作key
|
||||||
|
name: string; // 动作名称
|
||||||
|
description: string; // 动作描述
|
||||||
|
categoryKey: string; // 分类key
|
||||||
|
categoryName: string; // 分类名称
|
||||||
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,102 +44,95 @@ interface ScheduleExercise {
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"key": "warm_up_1",
|
"exerciseKey": "squat", // 关联到动作库中的深蹲
|
||||||
"name": "热身运动",
|
"name": "深蹲训练",
|
||||||
"category": "热身",
|
|
||||||
"sets": 1,
|
|
||||||
"durationSec": 300,
|
|
||||||
"itemType": "exercise"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 批量添加训练项目
|
|
||||||
|
|
||||||
**POST** `/training-plans/:id/exercises/batch`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"exercises": [
|
|
||||||
{
|
|
||||||
"key": "exercise_1",
|
|
||||||
"name": "深蹲",
|
|
||||||
"category": "力量训练",
|
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": 15,
|
"reps": 15,
|
||||||
"itemType": "exercise"
|
"restSec": 60,
|
||||||
},
|
"itemType": "exercise",
|
||||||
{
|
"note": "注意膝盖不要超过脚尖"
|
||||||
"key": "rest_1",
|
|
||||||
"name": "休息",
|
|
||||||
"itemType": "rest",
|
|
||||||
"durationSec": 60
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "note_1",
|
|
||||||
"name": "注意事项",
|
|
||||||
"note": "保持呼吸平稳",
|
|
||||||
"itemType": "note"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 获取训练计划的所有项目
|
或者添加休息项目:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "组间休息",
|
||||||
|
"durationSec": 90,
|
||||||
|
"itemType": "rest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或者添加提醒项目:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "安全提醒",
|
||||||
|
"note": "如感到不适请立即停止",
|
||||||
|
"itemType": "note"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取训练计划的所有项目
|
||||||
|
|
||||||
**GET** `/training-plans/:id/exercises`
|
**GET** `/training-plans/:id/exercises`
|
||||||
|
|
||||||
返回按排序顺序排列的所有训练项目。
|
返回按排序顺序排列的所有训练项目,包含关联的动作信息:
|
||||||
|
|
||||||
### 4. 获取训练项目详情
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ex_123",
|
||||||
|
"trainingPlanId": "plan_456",
|
||||||
|
"exerciseKey": "squat",
|
||||||
|
"name": "深蹲训练",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 15,
|
||||||
|
"restSec": 60,
|
||||||
|
"itemType": "exercise",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 1,
|
||||||
|
"exercise": {
|
||||||
|
"key": "squat",
|
||||||
|
"name": "深蹲",
|
||||||
|
"description": "下肢力量训练的基础动作",
|
||||||
|
"categoryKey": "strength",
|
||||||
|
"categoryName": "力量训练"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ex_124",
|
||||||
|
"name": "组间休息",
|
||||||
|
"durationSec": 90,
|
||||||
|
"itemType": "rest",
|
||||||
|
"completed": false,
|
||||||
|
"sortOrder": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取训练项目详情
|
||||||
|
|
||||||
**GET** `/training-plans/:id/exercises/:exerciseId`
|
**GET** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
### 5. 更新训练项目
|
### 4. 更新训练项目
|
||||||
|
|
||||||
**PUT** `/training-plans/:id/exercises/:exerciseId`
|
**PUT** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "修改后的名称",
|
"exerciseKey": "pushup", // 更换为俯卧撑
|
||||||
|
"name": "俯卧撑训练",
|
||||||
"sets": 4,
|
"sets": 4,
|
||||||
"reps": 12,
|
"reps": 12,
|
||||||
"completed": true
|
"completed": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 批量更新训练项目
|
### 5. 删除训练项目
|
||||||
|
|
||||||
**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`
|
**DELETE** `/training-plans/:id/exercises/:exerciseId`
|
||||||
|
|
||||||
### 8. 批量删除训练项目
|
### 6. 更新训练项目排序
|
||||||
|
|
||||||
**DELETE** `/training-plans/:id/exercises`
|
|
||||||
|
|
||||||
```json
|
|
||||||
["exercise_id_1", "exercise_id_2", "exercise_id_3"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. 更新训练项目排序
|
|
||||||
|
|
||||||
**PUT** `/training-plans/:id/exercises/order`
|
**PUT** `/training-plans/:id/exercises/order`
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ interface ScheduleExercise {
|
|||||||
|
|
||||||
重新排列项目顺序,数组中的顺序即为新的排序。
|
重新排列项目顺序,数组中的顺序即为新的排序。
|
||||||
|
|
||||||
### 10. 标记训练项目完成状态
|
### 7. 标记训练项目完成状态
|
||||||
|
|
||||||
**PUT** `/training-plans/:id/exercises/:exerciseId/complete`
|
**PUT** `/training-plans/:id/exercises/:exerciseId/complete`
|
||||||
|
|
||||||
@@ -153,51 +154,52 @@ interface ScheduleExercise {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11. 获取训练计划完成统计
|
### 8. 获取训练计划完成统计
|
||||||
|
|
||||||
**GET** `/training-plans/:id/exercises/stats/completion`
|
**GET** `/training-plans/:id/exercises/stats/completion`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total": 10,
|
"total": 5, // 总共5个运动项目(不包括休息和提醒)
|
||||||
"completed": 6,
|
"completed": 3, // 已完成3个
|
||||||
"percentage": 60
|
"percentage": 60 // 完成率60%
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 1. 智能排序
|
### 1. 动作库集成
|
||||||
|
- **智能关联**: exercise类型自动关联系统动作库
|
||||||
|
- **标准化**: 确保动作的准确性和专业性
|
||||||
|
- **分类管理**: 通过动作分类快速筛选和组织
|
||||||
|
|
||||||
|
### 2. 灵活的项目类型
|
||||||
|
- **exercise**: 运动项目 (关联动作库,支持组数、次数、时长等)
|
||||||
|
- **rest**: 休息项目 (设置休息时长)
|
||||||
|
- **note**: 提醒项目 (添加注意事项和指导)
|
||||||
|
|
||||||
|
### 3. 智能排序和管理
|
||||||
- 新增项目自动添加到列表末尾
|
- 新增项目自动添加到列表末尾
|
||||||
- 支持拖拽重新排序
|
- 支持拖拽重新排序
|
||||||
- 批量操作时保持排序逻辑
|
- 简洁的单项操作设计
|
||||||
|
|
||||||
### 2. 项目类型支持
|
### 4. 参数配置丰富
|
||||||
- **exercise**: 运动项目 (支持组数、次数、时长等)
|
- `exerciseKey`: 关联系统动作库
|
||||||
- **rest**: 休息项目 (主要设置休息时长)
|
- `sets`: 训练组数
|
||||||
- **note**: 提示项目 (主要用于注意事项)
|
|
||||||
|
|
||||||
### 3. 灵活的参数配置
|
|
||||||
- `sets`: 组数
|
|
||||||
- `reps`: 每组重复次数
|
- `reps`: 每组重复次数
|
||||||
- `durationSec`: 持续时长(秒),适用于有氧运动或休息
|
- `durationSec`: 持续时长(秒),适用于有氧运动或休息
|
||||||
- `restSec`: 组间休息时长
|
- `restSec`: 组间休息时长
|
||||||
- `note`: 备注信息
|
- `note`: 个性化备注信息
|
||||||
|
|
||||||
### 4. 完成状态跟踪
|
### 5. 完成状态跟踪
|
||||||
- 每个项目都有完成状态
|
- 实时跟踪每个项目的完成状态
|
||||||
- 支持统计整体完成进度
|
- 智能统计整体完成进度
|
||||||
- 只有运动类型项目计入统计
|
- 只有运动类型项目计入完成率统计
|
||||||
|
|
||||||
### 5. 批量操作
|
### 6. 数据安全与验证
|
||||||
- 批量创建:一次性添加多个项目
|
- 用户权限验证,确保数据安全
|
||||||
- 批量更新:同时修改多个项目
|
- 动作存在性验证,防止无效关联
|
||||||
- 批量删除:一次性删除多个项目
|
- 软删除机制,数据可恢复
|
||||||
|
|
||||||
### 6. 数据安全
|
|
||||||
- 所有操作都验证用户权限
|
|
||||||
- 项目key在同一训练计划内唯一
|
|
||||||
- 支持软删除,数据可恢复
|
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
@@ -207,34 +209,73 @@ interface ScheduleExercise {
|
|||||||
// 1. 先创建训练计划
|
// 1. 先创建训练计划
|
||||||
const plan = await createTrainingPlan({...});
|
const plan = await createTrainingPlan({...});
|
||||||
|
|
||||||
// 2. 批量添加训练项目
|
// 2. 逐个添加训练项目(关联动作库)
|
||||||
await batchCreateExercises(plan.id, {
|
// 热身阶段
|
||||||
exercises: [
|
await createExercise(plan.id, {
|
||||||
// 热身阶段
|
exerciseKey: "dynamic_warmup",
|
||||||
{ key: "warmup", name: "热身", category: "热身", durationSec: 300, itemType: "exercise" },
|
name: "动态热身",
|
||||||
|
durationSec: 300,
|
||||||
|
itemType: "exercise"
|
||||||
|
});
|
||||||
|
|
||||||
// 主要训练
|
// 主要训练 - 深蹲
|
||||||
{ key: "squat", name: "深蹲", category: "力量", sets: 3, reps: 15, itemType: "exercise" },
|
await createExercise(plan.id, {
|
||||||
{ key: "rest_1", name: "休息", itemType: "rest", durationSec: 60 },
|
exerciseKey: "squat",
|
||||||
|
name: "深蹲训练",
|
||||||
|
sets: 3,
|
||||||
|
reps: 15,
|
||||||
|
restSec: 60,
|
||||||
|
itemType: "exercise",
|
||||||
|
note: "保持膝盖不超过脚尖"
|
||||||
|
});
|
||||||
|
|
||||||
{ key: "pushup", name: "俯卧撑", category: "力量", sets: 3, reps: 12, itemType: "exercise" },
|
// 休息
|
||||||
{ key: "rest_2", name: "休息", itemType: "rest", durationSec: 60 },
|
await createExercise(plan.id, {
|
||||||
|
name: "组间休息",
|
||||||
|
durationSec: 90,
|
||||||
|
itemType: "rest"
|
||||||
|
});
|
||||||
|
|
||||||
// 注意事项
|
// 俯卧撑
|
||||||
{ key: "note_form", name: "注意动作标准", note: "保持核心紧张,动作缓慢控制", itemType: "note" },
|
await createExercise(plan.id, {
|
||||||
|
exerciseKey: "pushup",
|
||||||
|
name: "俯卧撑训练",
|
||||||
|
sets: 3,
|
||||||
|
reps: 12,
|
||||||
|
restSec: 60,
|
||||||
|
itemType: "exercise"
|
||||||
|
});
|
||||||
|
|
||||||
// 放松阶段
|
// 安全提醒
|
||||||
{ key: "cooldown", name: "拉伸放松", category: "拉伸", durationSec: 300, itemType: "exercise" }
|
await createExercise(plan.id, {
|
||||||
]
|
name: "注意动作标准",
|
||||||
|
note: "保持核心紧张,动作缓慢控制",
|
||||||
|
itemType: "note"
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 用户完成训练项目
|
// 3. 用户完成训练项目
|
||||||
await markExerciseComplete(plan.id, "squat", { completed: true });
|
await markExerciseComplete(plan.id, squatId, { completed: true });
|
||||||
await markExerciseComplete(plan.id, "pushup", { completed: true });
|
await markExerciseComplete(plan.id, pushupId, { completed: true });
|
||||||
|
|
||||||
// 4. 查看完成进度
|
// 4. 查看完成进度
|
||||||
const stats = await getCompletionStats(plan.id);
|
const stats = await getCompletionStats(plan.id);
|
||||||
// { total: 4, completed: 2, percentage: 50 }
|
// { total: 3, completed: 2, percentage: 67 }
|
||||||
|
|
||||||
|
// 5. 获取包含动作详情的训练列表
|
||||||
|
const exercises = await listExercises(plan.id);
|
||||||
|
// 返回的数据包含关联的动作库信息
|
||||||
```
|
```
|
||||||
|
|
||||||
这个实现提供了与前端 `ScheduleExercise` 接口完全匹配的后端支持,用户可以灵活地管理训练计划的具体内容。
|
## 关键优化
|
||||||
|
|
||||||
|
### 数据库设计优化
|
||||||
|
- **外键关联**: ScheduleExercise.exerciseKey → Exercise.key
|
||||||
|
- **数据一致性**: 动作存在性验证
|
||||||
|
- **查询优化**: 左连接获取动作详情
|
||||||
|
|
||||||
|
### 接口设计简化
|
||||||
|
- **去除批量操作**: 专注单项操作,降低复杂度
|
||||||
|
- **智能关联**: 自动验证和获取动作信息
|
||||||
|
- **类型安全**: 强类型验证确保数据准确性
|
||||||
|
|
||||||
|
这个优化后的实现确保了训练计划项目与系统动作库的正确关联,提供了更加专业和标准化的训练管理体验。
|
||||||
|
|||||||
@@ -3,21 +3,16 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Mi
|
|||||||
import { ScheduleItemType } from '../models/schedule-exercise.model';
|
import { ScheduleItemType } from '../models/schedule-exercise.model';
|
||||||
|
|
||||||
export class CreateScheduleExerciseDto {
|
export class CreateScheduleExerciseDto {
|
||||||
@ApiProperty({ description: '项目标识key' })
|
@ApiProperty({ description: '动作key(仅exercise类型需要)', required: false })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
key: string;
|
exerciseKey?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '项目名称' })
|
@ApiProperty({ description: '项目名称' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '项目分类', required: false })
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '组数', required: false })
|
@ApiProperty({ description: '组数', required: false })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@@ -65,18 +60,6 @@ export class CreateScheduleExerciseDto {
|
|||||||
|
|
||||||
export class UpdateScheduleExerciseDto extends PartialType(CreateScheduleExerciseDto) { }
|
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 {
|
export class UpdateScheduleExerciseOrderDto {
|
||||||
@ApiProperty({ description: '项目ID列表,按新的顺序排列' })
|
@ApiProperty({ description: '项目ID列表,按新的顺序排列' })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -93,9 +76,8 @@ export class CompleteScheduleExerciseDto {
|
|||||||
export class ScheduleExerciseResponseDto {
|
export class ScheduleExerciseResponseDto {
|
||||||
@ApiProperty() id: string;
|
@ApiProperty() id: string;
|
||||||
@ApiProperty() trainingPlanId: string;
|
@ApiProperty() trainingPlanId: string;
|
||||||
@ApiProperty() key: string;
|
@ApiProperty() exerciseKey?: string;
|
||||||
@ApiProperty() name: string;
|
@ApiProperty() name: string;
|
||||||
@ApiProperty() category?: string;
|
|
||||||
@ApiProperty() sets?: number;
|
@ApiProperty() sets?: number;
|
||||||
@ApiProperty() reps?: number;
|
@ApiProperty() reps?: number;
|
||||||
@ApiProperty() durationSec?: number;
|
@ApiProperty() durationSec?: number;
|
||||||
@@ -106,4 +88,14 @@ export class ScheduleExerciseResponseDto {
|
|||||||
@ApiProperty() sortOrder: number;
|
@ApiProperty() sortOrder: number;
|
||||||
@ApiProperty() createdAt: Date;
|
@ApiProperty() createdAt: Date;
|
||||||
@ApiProperty() updatedAt: Date;
|
@ApiProperty() updatedAt: Date;
|
||||||
|
|
||||||
|
// 关联的动作信息(仅exercise类型时存在)
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
exercise?: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryKey: string;
|
||||||
|
categoryName: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
|
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
|
||||||
import { TrainingPlan } from './training-plan.model';
|
import { TrainingPlan } from './training-plan.model';
|
||||||
|
import { Exercise } from '../../exercises/models/exercise.model';
|
||||||
|
|
||||||
export type ScheduleItemType = 'exercise' | 'rest' | 'note';
|
export type ScheduleItemType = 'exercise' | 'rest' | 'note';
|
||||||
|
|
||||||
@@ -25,15 +26,17 @@ export class ScheduleExercise extends Model {
|
|||||||
@Column({ type: DataType.STRING, allowNull: false })
|
@Column({ type: DataType.STRING, allowNull: false })
|
||||||
declare userId: string;
|
declare userId: string;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING, allowNull: false, comment: '项目标识key' })
|
// 关联到动作库(仅exercise类型需要)
|
||||||
declare key: string;
|
@ForeignKey(() => Exercise)
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true, comment: '关联的动作key(仅exercise类型)' })
|
||||||
|
declare exerciseKey: string;
|
||||||
|
|
||||||
|
@BelongsTo(() => Exercise, { foreignKey: 'exerciseKey', targetKey: 'key' })
|
||||||
|
declare exercise: Exercise;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
|
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
|
||||||
declare name: string;
|
declare name: string;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING, allowNull: true, comment: '项目分类' })
|
|
||||||
declare category: string;
|
|
||||||
|
|
||||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '组数' })
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '组数' })
|
||||||
declare sets: number;
|
declare sets: number;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import { Inject, Injectable, NotFoundException, BadRequestException } from '@nes
|
|||||||
import { InjectModel } from '@nestjs/sequelize';
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
import { ScheduleExercise } from './models/schedule-exercise.model';
|
import { ScheduleExercise } from './models/schedule-exercise.model';
|
||||||
import { TrainingPlan } from './models/training-plan.model';
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
|
import { Exercise } from '../exercises/models/exercise.model';
|
||||||
import {
|
import {
|
||||||
CreateScheduleExerciseDto,
|
CreateScheduleExerciseDto,
|
||||||
UpdateScheduleExerciseDto,
|
UpdateScheduleExerciseDto,
|
||||||
BatchCreateScheduleExerciseDto,
|
|
||||||
BatchUpdateScheduleExerciseDto,
|
|
||||||
UpdateScheduleExerciseOrderDto,
|
UpdateScheduleExerciseOrderDto,
|
||||||
CompleteScheduleExerciseDto
|
CompleteScheduleExerciseDto
|
||||||
} from './dto/schedule-exercise.dto';
|
} from './dto/schedule-exercise.dto';
|
||||||
@@ -25,6 +24,8 @@ export class ScheduleExerciseService {
|
|||||||
private scheduleExerciseModel: typeof ScheduleExercise,
|
private scheduleExerciseModel: typeof ScheduleExercise,
|
||||||
@InjectModel(TrainingPlan)
|
@InjectModel(TrainingPlan)
|
||||||
private trainingPlanModel: typeof TrainingPlan,
|
private trainingPlanModel: typeof TrainingPlan,
|
||||||
|
@InjectModel(Exercise)
|
||||||
|
private exerciseModel: typeof Exercise,
|
||||||
private readonly activityLogsService: ActivityLogsService,
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -48,16 +49,22 @@ export class ScheduleExerciseService {
|
|||||||
return lastExercise ? lastExercise.sortOrder + 1 : 1;
|
return lastExercise ? lastExercise.sortOrder + 1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证动作是否存在
|
||||||
|
private async validateExercise(exerciseKey: string): Promise<Exercise> {
|
||||||
|
const exercise = await this.exerciseModel.findByPk(exerciseKey);
|
||||||
|
if (!exercise) {
|
||||||
|
throw new NotFoundException(`动作 "${exerciseKey}" 不存在`);
|
||||||
|
}
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
// 创建单个训练项目
|
// 创建单个训练项目
|
||||||
async create(userId: string, trainingPlanId: string, dto: CreateScheduleExerciseDto) {
|
async create(userId: string, trainingPlanId: string, dto: CreateScheduleExerciseDto) {
|
||||||
await this.validateTrainingPlan(userId, trainingPlanId);
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
// 检查key是否已存在
|
// 如果是exercise类型,验证动作是否存在
|
||||||
const existingExercise = await this.scheduleExerciseModel.findOne({
|
if (dto.itemType === 'exercise' && dto.exerciseKey) {
|
||||||
where: { trainingPlanId, key: dto.key, deleted: false }
|
await this.validateExercise(dto.exerciseKey);
|
||||||
});
|
|
||||||
if (existingExercise) {
|
|
||||||
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOrder = await this.getNextSortOrder(trainingPlanId);
|
const sortOrder = await this.getNextSortOrder(trainingPlanId);
|
||||||
@@ -65,9 +72,8 @@ export class ScheduleExerciseService {
|
|||||||
const exercise = await this.scheduleExerciseModel.create({
|
const exercise = await this.scheduleExerciseModel.create({
|
||||||
trainingPlanId,
|
trainingPlanId,
|
||||||
userId,
|
userId,
|
||||||
key: dto.key,
|
exerciseKey: dto.exerciseKey,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
category: dto.category || '',
|
|
||||||
sets: dto.sets || 0,
|
sets: dto.sets || 0,
|
||||||
reps: dto.reps,
|
reps: dto.reps,
|
||||||
durationSec: dto.durationSec,
|
durationSec: dto.durationSec,
|
||||||
@@ -96,61 +102,7 @@ export class ScheduleExerciseService {
|
|||||||
return 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) {
|
async list(userId: string, trainingPlanId: string) {
|
||||||
@@ -158,10 +110,23 @@ export class ScheduleExerciseService {
|
|||||||
|
|
||||||
const exercises = await this.scheduleExerciseModel.findAll({
|
const exercises = await this.scheduleExerciseModel.findAll({
|
||||||
where: { trainingPlanId, userId, deleted: false },
|
where: { trainingPlanId, userId, deleted: false },
|
||||||
|
include: [{ model: Exercise, required: false }],
|
||||||
order: [['sortOrder', 'ASC']],
|
order: [['sortOrder', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return exercises.map(exercise => exercise.toJSON());
|
return exercises.map(exercise => {
|
||||||
|
const data = exercise.toJSON();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
exercise: exercise.exercise ? {
|
||||||
|
key: exercise.exercise.key,
|
||||||
|
name: exercise.exercise.name,
|
||||||
|
description: exercise.exercise.description,
|
||||||
|
categoryKey: exercise.exercise.categoryKey,
|
||||||
|
categoryName: exercise.exercise.categoryName,
|
||||||
|
} : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取单个训练项目详情
|
// 获取单个训练项目详情
|
||||||
@@ -169,14 +134,25 @@ export class ScheduleExerciseService {
|
|||||||
await this.validateTrainingPlan(userId, trainingPlanId);
|
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||||
|
|
||||||
const exercise = await this.scheduleExerciseModel.findOne({
|
const exercise = await this.scheduleExerciseModel.findOne({
|
||||||
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
|
where: { id: exerciseId, trainingPlanId, userId, deleted: false },
|
||||||
|
include: [{ model: Exercise, required: false }]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!exercise) {
|
if (!exercise) {
|
||||||
throw new NotFoundException('训练项目不存在');
|
throw new NotFoundException('训练项目不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
return exercise.toJSON();
|
const data = exercise.toJSON();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
exercise: exercise.exercise ? {
|
||||||
|
key: exercise.exercise.key,
|
||||||
|
name: exercise.exercise.name,
|
||||||
|
description: exercise.exercise.description,
|
||||||
|
categoryKey: exercise.exercise.categoryKey,
|
||||||
|
categoryName: exercise.exercise.categoryName,
|
||||||
|
} : undefined
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新训练项目
|
// 更新训练项目
|
||||||
@@ -191,22 +167,16 @@ export class ScheduleExerciseService {
|
|||||||
throw new NotFoundException('训练项目不存在');
|
throw new NotFoundException('训练项目不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果更新key,检查是否冲突
|
// 如果更新exerciseKey,验证动作是否存在
|
||||||
if (dto.key && dto.key !== exercise.key) {
|
if (dto.exerciseKey && dto.exerciseKey !== exercise.exerciseKey) {
|
||||||
const existingExercise = await this.scheduleExerciseModel.findOne({
|
await this.validateExercise(dto.exerciseKey);
|
||||||
where: { trainingPlanId, key: dto.key, deleted: false, id: { [Op.ne]: exerciseId } }
|
|
||||||
});
|
|
||||||
if (existingExercise) {
|
|
||||||
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = exercise.toJSON();
|
const before = exercise.toJSON();
|
||||||
|
|
||||||
// 更新字段
|
// 更新字段
|
||||||
if (dto.key !== undefined) exercise.key = dto.key;
|
if (dto.exerciseKey !== undefined) exercise.exerciseKey = dto.exerciseKey;
|
||||||
if (dto.name !== undefined) exercise.name = dto.name;
|
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.sets !== undefined) exercise.sets = dto.sets || 0;
|
||||||
if (dto.reps !== undefined) exercise.reps = dto.reps;
|
if (dto.reps !== undefined) exercise.reps = dto.reps;
|
||||||
if (dto.durationSec !== undefined) exercise.durationSec = dto.durationSec;
|
if (dto.durationSec !== undefined) exercise.durationSec = dto.durationSec;
|
||||||
@@ -244,73 +214,7 @@ export class ScheduleExerciseService {
|
|||||||
return after;
|
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) {
|
async remove(userId: string, trainingPlanId: string, exerciseId: string) {
|
||||||
@@ -343,31 +247,7 @@ export class ScheduleExerciseService {
|
|||||||
return { success: true };
|
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) {
|
async updateOrder(userId: string, trainingPlanId: string, dto: UpdateScheduleExerciseOrderDto) {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-pla
|
|||||||
import {
|
import {
|
||||||
CreateScheduleExerciseDto,
|
CreateScheduleExerciseDto,
|
||||||
UpdateScheduleExerciseDto,
|
UpdateScheduleExerciseDto,
|
||||||
BatchCreateScheduleExerciseDto,
|
|
||||||
BatchUpdateScheduleExerciseDto,
|
|
||||||
UpdateScheduleExerciseOrderDto,
|
UpdateScheduleExerciseOrderDto,
|
||||||
CompleteScheduleExerciseDto,
|
CompleteScheduleExerciseDto,
|
||||||
ScheduleExerciseResponseDto
|
ScheduleExerciseResponseDto
|
||||||
@@ -89,17 +87,7 @@ export class TrainingPlansController {
|
|||||||
return this.scheduleExerciseService.create(user.sub, trainingPlanId, dto);
|
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')
|
@Get(':id/exercises')
|
||||||
@ApiOperation({ summary: '获取训练计划的所有项目' })
|
@ApiOperation({ summary: '获取训练计划的所有项目' })
|
||||||
@@ -137,17 +125,7 @@ export class TrainingPlansController {
|
|||||||
return this.scheduleExerciseService.update(user.sub, trainingPlanId, exerciseId, dto);
|
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')
|
@Delete(':id/exercises/:exerciseId')
|
||||||
@ApiOperation({ summary: '删除训练项目' })
|
@ApiOperation({ summary: '删除训练项目' })
|
||||||
@@ -161,17 +139,7 @@ export class TrainingPlansController {
|
|||||||
return this.scheduleExerciseService.remove(user.sub, trainingPlanId, exerciseId);
|
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')
|
@Put(':id/exercises/order')
|
||||||
@ApiOperation({ summary: '更新训练项目排序' })
|
@ApiOperation({ summary: '更新训练项目排序' })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { ScheduleExercise } from './models/schedule-exercise.model';
|
||||||
|
import { Exercise } from '../exercises/models/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';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
|||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ActivityLogsModule,
|
ActivityLogsModule,
|
||||||
SequelizeModule.forFeature([TrainingPlan, ScheduleExercise]),
|
SequelizeModule.forFeature([TrainingPlan, ScheduleExercise, Exercise]),
|
||||||
],
|
],
|
||||||
controllers: [TrainingPlansController],
|
controllers: [TrainingPlansController],
|
||||||
providers: [TrainingPlansService, ScheduleExerciseService],
|
providers: [TrainingPlansService, ScheduleExerciseService],
|
||||||
|
|||||||
Reference in New Issue
Block a user