diff --git a/docs/schedule-exercise-api-examples.md b/docs/schedule-exercise-api-examples.md new file mode 100644 index 0000000..9fed878 --- /dev/null +++ b/docs/schedule-exercise-api-examples.md @@ -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设计提供了完整的训练项目管理功能,支持用户创建个性化的训练流程,实时跟踪完成进度,并且具有良好的扩展性。 diff --git a/src/training-plans/README.md b/src/training-plans/README.md new file mode 100644 index 0000000..2c97b53 --- /dev/null +++ b/src/training-plans/README.md @@ -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` 接口完全匹配的后端支持,用户可以灵活地管理训练计划的具体内容。 diff --git a/src/training-plans/dto/schedule-exercise.dto.ts b/src/training-plans/dto/schedule-exercise.dto.ts new file mode 100644 index 0000000..d6594ce --- /dev/null +++ b/src/training-plans/dto/schedule-exercise.dto.ts @@ -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; +} diff --git a/src/training-plans/models/schedule-exercise.model.ts b/src/training-plans/models/schedule-exercise.model.ts new file mode 100644 index 0000000..e36c04b --- /dev/null +++ b/src/training-plans/models/schedule-exercise.model.ts @@ -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; +} diff --git a/src/training-plans/schedule-exercise.service.ts b/src/training-plans/schedule-exercise.service.ts new file mode 100644 index 0000000..1110e52 --- /dev/null +++ b/src/training-plans/schedule-exercise.service.ts @@ -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 { + 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 { + 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 = {}; + 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 + }; + } +} diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts index 6b5939c..289dac3 100644 --- a/src/training-plans/training-plans.controller.ts +++ b/src/training-plans/training-plans.controller.ts @@ -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 { TrainingPlansService } from './training-plans.service'; +import { ScheduleExerciseService } from './schedule-exercise.service'; 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 { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.service'; @@ -10,7 +20,10 @@ import { AccessTokenPayload } from '../users/services/apple-auth.service'; @Controller('training-plans') @UseGuards(JwtAuthGuard) export class TrainingPlansController { - constructor(private readonly service: TrainingPlansService) { } + constructor( + private readonly service: TrainingPlansService, + private readonly scheduleExerciseService: ScheduleExerciseService, + ) { } @Post() @ApiOperation({ summary: '新增训练计划' }) @@ -61,6 +74,140 @@ export class TrainingPlansController { async activate(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) { 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); + } } diff --git a/src/training-plans/training-plans.module.ts b/src/training-plans/training-plans.module.ts index 3f34389..0e084d8 100644 --- a/src/training-plans/training-plans.module.ts +++ b/src/training-plans/training-plans.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { TrainingPlansService } from './training-plans.service'; +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 { UsersModule } from '../users/users.module'; import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; @@ -10,11 +12,11 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; imports: [ UsersModule, ActivityLogsModule, - SequelizeModule.forFeature([TrainingPlan]), + SequelizeModule.forFeature([TrainingPlan, ScheduleExercise]), ], controllers: [TrainingPlansController], - providers: [TrainingPlansService], - exports: [TrainingPlansService], + providers: [TrainingPlansService, ScheduleExerciseService], + exports: [TrainingPlansService, ScheduleExerciseService], }) export class TrainingPlansModule { }