diff --git a/docs/schedule-exercise-api-examples.md b/docs/schedule-exercise-api-examples.md index 9fed878..a0c4d82 100644 --- a/docs/schedule-exercise-api-examples.md +++ b/docs/schedule-exercise-api-examples.md @@ -1,4 +1,6 @@ -# 训练计划项目管理 API 使用示例 +# 训练计划项目管理 API 使用示例(优化版) + +这个版本展示了优化后的 API 设计,重点是与动作库的智能关联和简化的接口操作。 ## 创建一个完整的训练计划项目 @@ -35,139 +37,169 @@ Content-Type: application/json } ``` -### 2. 批量添加训练项目 +### 2. 逐个添加训练项目(关联动作库) +#### 添加热身项目 ```bash -POST /training-plans/plan_1705123456789_abc123/exercises/batch +POST /training-plans/plan_1705123456789_abc123/exercises 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": "充分拉伸训练过的肌群" - } - ] + "exerciseKey": "dynamic_warmup", // 关联动作库中的动态热身 + "name": "动态热身", + "durationSec": 300, + "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" - }, - // ... 其他项目 -] +#### 添加安全提醒 +```bash +POST /training-plans/plan_1705123456789_abc123/exercises +Authorization: Bearer {token} +Content-Type: application/json + +{ + "name": "安全提醒", + "note": "如感到不适请立即停止,保持正确呼吸", + "itemType": "note" +} ``` -### 3. 获取训练项目列表 +#### 添加深蹲训练 +```bash +POST /training-plans/plan_1705123456789_abc123/exercises +Authorization: Bearer {token} +Content-Type: application/json + +{ + "exerciseKey": "squat", // 关联动作库中的深蹲 + "name": "深蹲训练", + "sets": 3, + "reps": 15, + "restSec": 60, + "itemType": "exercise", + "note": "下蹲时膝盖不超过脚尖" +} +``` + +#### 添加休息时间 +```bash +POST /training-plans/plan_1705123456789_abc123/exercises +Authorization: Bearer {token} +Content-Type: application/json + +{ + "name": "组间休息", + "durationSec": 90, + "itemType": "rest" +} +``` + +#### 添加俯卧撑训练 +```bash +POST /training-plans/plan_1705123456789_abc123/exercises +Authorization: Bearer {token} +Content-Type: application/json + +{ + "exerciseKey": "pushup", // 关联动作库中的俯卧撑 + "name": "俯卧撑训练", + "sets": 3, + "reps": 12, + "restSec": 60, + "itemType": "exercise", + "note": "保持身体一条直线" +} +``` +``` + +每个添加请求的响应示例: +```json +{ + "id": "ex_1705123456790_def456", + "trainingPlanId": "plan_1705123456789_abc123", + "exerciseKey": "squat", + "name": "深蹲训练", + "sets": 3, + "reps": 15, + "restSec": 60, + "note": "下蹲时膝盖不超过脚尖", + "itemType": "exercise", + "completed": false, + "sortOrder": 3, + "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排序的完整项目列表 +响应:按sortOrder排序的完整项目列表,包含关联的动作信息 ```json [ { "id": "ex_1705123456790_def456", "trainingPlanId": "plan_1705123456789_abc123", - "key": "warmup_dynamic", + "exerciseKey": "dynamic_warmup", "name": "动态热身", - "category": "热身", - "sets": 0, "durationSec": 300, "note": "轻松活动关节,准备训练", "itemType": "exercise", "completed": false, - "sortOrder": 1 + "sortOrder": 1, + "exercise": { + "key": "dynamic_warmup", + "name": "动态热身", + "description": "通过各种动态动作激活身体,为训练做准备", + "categoryKey": "warmup", + "categoryName": "热身" + } }, { "id": "ex_1705123456791_ghi789", "trainingPlanId": "plan_1705123456789_abc123", - "key": "note_safety", "name": "安全提醒", "note": "如感到不适请立即停止,保持正确呼吸", "itemType": "note", "completed": false, "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 } - // ... 更多项目 ] ``` diff --git a/docs/training-plans-optimization-summary.md b/docs/training-plans-optimization-summary.md new file mode 100644 index 0000000..3512e0c --- /dev/null +++ b/docs/training-plans-optimization-summary.md @@ -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 }) => ( +
+

{item.name}

+ {item.exercise && ( +
+

分类: {item.exercise.categoryName}

+

说明: {item.exercise.description}

+
+ )} +

组数: {item.sets} | 次数: {item.reps}

+
+); +``` + +### 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. **扩展性**: 为未来的功能扩展提供了良好的架构基础 + +这个优化后的设计既保持了功能的完整性,又显著提升了系统的可维护性和用户体验。 diff --git a/src/training-plans/README.md b/src/training-plans/README.md index 2c97b53..9bd5caf 100644 --- a/src/training-plans/README.md +++ b/src/training-plans/README.md @@ -1,6 +1,6 @@ # 训练计划项目管理 API 文档 -这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序、批量操作和完成状态跟踪。 +这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序和完成状态跟踪。项目与系统动作库智能关联,提供了训练、休息、提醒三种类型的灵活支持。 ## 数据模型 @@ -11,9 +11,8 @@ interface ScheduleExercise { id: string; // 项目ID trainingPlanId: string; // 所属训练计划ID userId: string; // 用户ID - key: string; // 项目标识key (唯一) + exerciseKey?: string; // 关联的动作key(仅exercise类型) name: string; // 项目名称 - category?: string; // 项目分类 sets?: number; // 组数 reps?: number; // 重复次数 durationSec?: number; // 持续时长(秒) @@ -25,6 +24,15 @@ interface ScheduleExercise { createdAt: Date; // 创建时间 updatedAt: Date; // 更新时间 deleted: boolean; // 是否已删除 + + // 关联的动作信息(仅exercise类型时存在) + exercise?: { + key: string; // 动作key + name: string; // 动作名称 + description: string; // 动作描述 + categoryKey: string; // 分类key + categoryName: string; // 分类名称 + }; } ``` @@ -36,102 +44,95 @@ interface ScheduleExercise { ```json { - "key": "warm_up_1", - "name": "热身运动", - "category": "热身", - "sets": 1, - "durationSec": 300, - "itemType": "exercise" + "exerciseKey": "squat", // 关联到动作库中的深蹲 + "name": "深蹲训练", + "sets": 3, + "reps": 15, + "restSec": 60, + "itemType": "exercise", + "note": "注意膝盖不要超过脚尖" } ``` -### 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" - } - ] + "name": "组间休息", + "durationSec": 90, + "itemType": "rest" } ``` -### 3. 获取训练计划的所有项目 +或者添加提醒项目: +```json +{ + "name": "安全提醒", + "note": "如感到不适请立即停止", + "itemType": "note" +} +``` + +### 2. 获取训练计划的所有项目 **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` -### 5. 更新训练项目 +### 4. 更新训练项目 **PUT** `/training-plans/:id/exercises/:exerciseId` ```json { - "name": "修改后的名称", + "exerciseKey": "pushup", // 更换为俯卧撑 + "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. 删除训练项目 +### 5. 删除训练项目 **DELETE** `/training-plans/:id/exercises/:exerciseId` -### 8. 批量删除训练项目 - -**DELETE** `/training-plans/:id/exercises` - -```json -["exercise_id_1", "exercise_id_2", "exercise_id_3"] -``` - -### 9. 更新训练项目排序 +### 6. 更新训练项目排序 **PUT** `/training-plans/:id/exercises/order` @@ -143,7 +144,7 @@ interface ScheduleExercise { 重新排列项目顺序,数组中的顺序即为新的排序。 -### 10. 标记训练项目完成状态 +### 7. 标记训练项目完成状态 **PUT** `/training-plans/:id/exercises/:exerciseId/complete` @@ -153,51 +154,52 @@ interface ScheduleExercise { } ``` -### 11. 获取训练计划完成统计 +### 8. 获取训练计划完成统计 **GET** `/training-plans/:id/exercises/stats/completion` ```json { - "total": 10, - "completed": 6, - "percentage": 60 + "total": 5, // 总共5个运动项目(不包括休息和提醒) + "completed": 3, // 已完成3个 + "percentage": 60 // 完成率60% } ``` ## 功能特性 -### 1. 智能排序 +### 1. 动作库集成 +- **智能关联**: exercise类型自动关联系统动作库 +- **标准化**: 确保动作的准确性和专业性 +- **分类管理**: 通过动作分类快速筛选和组织 + +### 2. 灵活的项目类型 +- **exercise**: 运动项目 (关联动作库,支持组数、次数、时长等) +- **rest**: 休息项目 (设置休息时长) +- **note**: 提醒项目 (添加注意事项和指导) + +### 3. 智能排序和管理 - 新增项目自动添加到列表末尾 - 支持拖拽重新排序 -- 批量操作时保持排序逻辑 +- 简洁的单项操作设计 -### 2. 项目类型支持 -- **exercise**: 运动项目 (支持组数、次数、时长等) -- **rest**: 休息项目 (主要设置休息时长) -- **note**: 提示项目 (主要用于注意事项) - -### 3. 灵活的参数配置 -- `sets`: 组数 +### 4. 参数配置丰富 +- `exerciseKey`: 关联系统动作库 +- `sets`: 训练组数 - `reps`: 每组重复次数 - `durationSec`: 持续时长(秒),适用于有氧运动或休息 - `restSec`: 组间休息时长 -- `note`: 备注信息 +- `note`: 个性化备注信息 -### 4. 完成状态跟踪 -- 每个项目都有完成状态 -- 支持统计整体完成进度 -- 只有运动类型项目计入统计 +### 5. 完成状态跟踪 +- 实时跟踪每个项目的完成状态 +- 智能统计整体完成进度 +- 只有运动类型项目计入完成率统计 -### 5. 批量操作 -- 批量创建:一次性添加多个项目 -- 批量更新:同时修改多个项目 -- 批量删除:一次性删除多个项目 - -### 6. 数据安全 -- 所有操作都验证用户权限 -- 项目key在同一训练计划内唯一 -- 支持软删除,数据可恢复 +### 6. 数据安全与验证 +- 用户权限验证,确保数据安全 +- 动作存在性验证,防止无效关联 +- 软删除机制,数据可恢复 ## 使用示例 @@ -207,34 +209,73 @@ interface ScheduleExercise { // 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" } - ] +// 2. 逐个添加训练项目(关联动作库) +// 热身阶段 +await createExercise(plan.id, { + exerciseKey: "dynamic_warmup", + name: "动态热身", + durationSec: 300, + itemType: "exercise" +}); + +// 主要训练 - 深蹲 +await createExercise(plan.id, { + exerciseKey: "squat", + name: "深蹲训练", + sets: 3, + reps: 15, + restSec: 60, + itemType: "exercise", + note: "保持膝盖不超过脚尖" +}); + +// 休息 +await createExercise(plan.id, { + name: "组间休息", + durationSec: 90, + itemType: "rest" +}); + +// 俯卧撑 +await createExercise(plan.id, { + exerciseKey: "pushup", + name: "俯卧撑训练", + sets: 3, + reps: 12, + restSec: 60, + itemType: "exercise" +}); + +// 安全提醒 +await createExercise(plan.id, { + name: "注意动作标准", + note: "保持核心紧张,动作缓慢控制", + itemType: "note" }); // 3. 用户完成训练项目 -await markExerciseComplete(plan.id, "squat", { completed: true }); -await markExerciseComplete(plan.id, "pushup", { completed: true }); +await markExerciseComplete(plan.id, squatId, { completed: true }); +await markExerciseComplete(plan.id, pushupId, { completed: true }); // 4. 查看完成进度 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 +- **数据一致性**: 动作存在性验证 +- **查询优化**: 左连接获取动作详情 + +### 接口设计简化 +- **去除批量操作**: 专注单项操作,降低复杂度 +- **智能关联**: 自动验证和获取动作信息 +- **类型安全**: 强类型验证确保数据准确性 + +这个优化后的实现确保了训练计划项目与系统动作库的正确关联,提供了更加专业和标准化的训练管理体验。 diff --git a/src/training-plans/dto/schedule-exercise.dto.ts b/src/training-plans/dto/schedule-exercise.dto.ts index d6594ce..b9ff154 100644 --- a/src/training-plans/dto/schedule-exercise.dto.ts +++ b/src/training-plans/dto/schedule-exercise.dto.ts @@ -3,21 +3,16 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Mi import { ScheduleItemType } from '../models/schedule-exercise.model'; export class CreateScheduleExerciseDto { - @ApiProperty({ description: '项目标识key' }) + @ApiProperty({ description: '动作key(仅exercise类型需要)', required: false }) @IsString() - @IsNotEmpty() - key: string; + @IsOptional() + exerciseKey?: string; @ApiProperty({ description: '项目名称' }) @IsString() @IsNotEmpty() name: string; - @ApiProperty({ description: '项目分类', required: false }) - @IsString() - @IsOptional() - category?: string; - @ApiProperty({ description: '组数', required: false }) @IsInt() @Min(0) @@ -65,18 +60,6 @@ export class 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 { @ApiProperty({ description: '项目ID列表,按新的顺序排列' }) @IsArray() @@ -93,9 +76,8 @@ export class CompleteScheduleExerciseDto { export class ScheduleExerciseResponseDto { @ApiProperty() id: string; @ApiProperty() trainingPlanId: string; - @ApiProperty() key: string; + @ApiProperty() exerciseKey?: string; @ApiProperty() name: string; - @ApiProperty() category?: string; @ApiProperty() sets?: number; @ApiProperty() reps?: number; @ApiProperty() durationSec?: number; @@ -106,4 +88,14 @@ export class ScheduleExerciseResponseDto { @ApiProperty() sortOrder: number; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; + + // 关联的动作信息(仅exercise类型时存在) + @ApiProperty({ required: false }) + exercise?: { + key: string; + name: string; + description: string; + categoryKey: string; + categoryName: string; + }; } diff --git a/src/training-plans/models/schedule-exercise.model.ts b/src/training-plans/models/schedule-exercise.model.ts index e36c04b..7fba398 100644 --- a/src/training-plans/models/schedule-exercise.model.ts +++ b/src/training-plans/models/schedule-exercise.model.ts @@ -1,5 +1,6 @@ import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript'; import { TrainingPlan } from './training-plan.model'; +import { Exercise } from '../../exercises/models/exercise.model'; export type ScheduleItemType = 'exercise' | 'rest' | 'note'; @@ -25,15 +26,17 @@ export class ScheduleExercise extends Model { @Column({ type: DataType.STRING, allowNull: false }) declare userId: string; - @Column({ type: DataType.STRING, allowNull: false, comment: '项目标识key' }) - declare key: string; + // 关联到动作库(仅exercise类型需要) + @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: '项目名称' }) declare name: string; - @Column({ type: DataType.STRING, allowNull: true, comment: '项目分类' }) - declare category: string; - @Column({ type: DataType.INTEGER, allowNull: true, comment: '组数' }) declare sets: number; diff --git a/src/training-plans/schedule-exercise.service.ts b/src/training-plans/schedule-exercise.service.ts index 1110e52..9081001 100644 --- a/src/training-plans/schedule-exercise.service.ts +++ b/src/training-plans/schedule-exercise.service.ts @@ -2,11 +2,10 @@ import { Inject, Injectable, NotFoundException, BadRequestException } from '@nes import { InjectModel } from '@nestjs/sequelize'; import { ScheduleExercise } from './models/schedule-exercise.model'; import { TrainingPlan } from './models/training-plan.model'; +import { Exercise } from '../exercises/models/exercise.model'; import { CreateScheduleExerciseDto, UpdateScheduleExerciseDto, - BatchCreateScheduleExerciseDto, - BatchUpdateScheduleExerciseDto, UpdateScheduleExerciseOrderDto, CompleteScheduleExerciseDto } from './dto/schedule-exercise.dto'; @@ -25,6 +24,8 @@ export class ScheduleExerciseService { private scheduleExerciseModel: typeof ScheduleExercise, @InjectModel(TrainingPlan) private trainingPlanModel: typeof TrainingPlan, + @InjectModel(Exercise) + private exerciseModel: typeof Exercise, private readonly activityLogsService: ActivityLogsService, ) { } @@ -48,16 +49,22 @@ export class ScheduleExerciseService { return lastExercise ? lastExercise.sortOrder + 1 : 1; } + // 验证动作是否存在 + private async validateExercise(exerciseKey: string): Promise { + const exercise = await this.exerciseModel.findByPk(exerciseKey); + if (!exercise) { + throw new NotFoundException(`动作 "${exerciseKey}" 不存在`); + } + return exercise; + } + // 创建单个训练项目 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}" 已存在`); + // 如果是exercise类型,验证动作是否存在 + if (dto.itemType === 'exercise' && dto.exerciseKey) { + await this.validateExercise(dto.exerciseKey); } const sortOrder = await this.getNextSortOrder(trainingPlanId); @@ -65,9 +72,8 @@ export class ScheduleExerciseService { const exercise = await this.scheduleExerciseModel.create({ trainingPlanId, userId, - key: dto.key, + exerciseKey: dto.exerciseKey, name: dto.name, - category: dto.category || '', sets: dto.sets || 0, reps: dto.reps, durationSec: dto.durationSec, @@ -96,61 +102,7 @@ export class ScheduleExerciseService { 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) { @@ -158,10 +110,23 @@ export class ScheduleExerciseService { const exercises = await this.scheduleExerciseModel.findAll({ where: { trainingPlanId, userId, deleted: false }, + include: [{ model: Exercise, required: false }], 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); 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) { 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('训练项目不存在'); } - // 如果更新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}" 已存在`); - } + // 如果更新exerciseKey,验证动作是否存在 + if (dto.exerciseKey && dto.exerciseKey !== exercise.exerciseKey) { + await this.validateExercise(dto.exerciseKey); } 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.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; @@ -244,73 +214,7 @@ export class ScheduleExerciseService { 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) { @@ -343,31 +247,7 @@ export class ScheduleExerciseService { 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) { diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts index 289dac3..85aaf0e 100644 --- a/src/training-plans/training-plans.controller.ts +++ b/src/training-plans/training-plans.controller.ts @@ -6,8 +6,6 @@ import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-pla import { CreateScheduleExerciseDto, UpdateScheduleExerciseDto, - BatchCreateScheduleExerciseDto, - BatchUpdateScheduleExerciseDto, UpdateScheduleExerciseOrderDto, CompleteScheduleExerciseDto, ScheduleExerciseResponseDto @@ -89,17 +87,7 @@ export class TrainingPlansController { 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: '获取训练计划的所有项目' }) @@ -137,17 +125,7 @@ export class TrainingPlansController { 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: '删除训练项目' }) @@ -161,17 +139,7 @@ export class TrainingPlansController { 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: '更新训练项目排序' }) diff --git a/src/training-plans/training-plans.module.ts b/src/training-plans/training-plans.module.ts index 0e084d8..bdc87fb 100644 --- a/src/training-plans/training-plans.module.ts +++ b/src/training-plans/training-plans.module.ts @@ -5,6 +5,7 @@ import { ScheduleExerciseService } from './schedule-exercise.service'; import { TrainingPlansController } from './training-plans.controller'; import { TrainingPlan } from './models/training-plan.model'; import { ScheduleExercise } from './models/schedule-exercise.model'; +import { Exercise } from '../exercises/models/exercise.model'; import { UsersModule } from '../users/users.module'; import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; @@ -12,7 +13,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; imports: [ UsersModule, ActivityLogsModule, - SequelizeModule.forFeature([TrainingPlan, ScheduleExercise]), + SequelizeModule.forFeature([TrainingPlan, ScheduleExercise, Exercise]), ], controllers: [TrainingPlansController], providers: [TrainingPlansService, ScheduleExerciseService],