feat: init fitness-coach (backend + miniprogram)

- backend: NestJS service with exercise/plan data
- miniprogram: WeChat mini program client
- exclude node_modules, dist, runtime data-store, local env
This commit is contained in:
2026-07-22 00:35:40 +08:00
commit b3b58e66fb
103 changed files with 154759 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# OS / editor
.DS_Store
Thumbs.db
*.swp
*.swo
# dependencies
node_modules/
# build output
dist/
dist-ssr/
out/
build/
# runtime data (local JSON "database")
backend/data-store/
# env / secrets
.env
.env.*
!.env.example
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# wechat devtools private config (auto-generated)
miniprogram/project.private.config.json
# coverage / cache
coverage/
.cache/
.turbo/

57
OVERVIEW.md Normal file
View File

@@ -0,0 +1,57 @@
# FITCOACH 健身动作推荐小程序系统 — 交付总览
基于 GitHub `hasaneyldrm/exercises-dataset`**1324 条动作**,含中英文动作说明与分步指引)构建的完整体育健身小程序系统,包含 **NestJS 后端代理/数据层** + **微信小程序前端**
## 系统架构
```
fitness-coach/
├── backend/ NestJS 10 后端(唯一数据源 + API 代理层)
│ ├── src/exercises/
│ │ ├── exercises.service.ts 查询 / 多维筛选 / 智能推荐 / 训练组合
│ │ ├── exercises.controller.ts 12 个 REST 路由
│ │ ├── data/exercises.json 原始数据集17MB1324 条)
│ │ ├── data/labels.ts 中英标签映射(部位/器械/肌肉)
│ │ └── data/collections.ts 8 个智能训练组合定义
│ └── scripts/ ensure-data / copy-data / sync-media
└── miniprogram/ 微信小程序前端(深色金质「高端大气」设计)
├── pages/ index(首页) / recommend(智能推荐) / category / detail / collection / collection-detail / search
├── components/ exercise-card / chip / navbar / bottom-nav / section-header
├── services/exercise.js 统一 API 映射
└── utils/request.js Promise 化请求层
```
## 核心功能实现
| 需求 | 实现 |
|------|------|
| 动作分类体系(多维) | 5 个分类元数据接口:部位(10) / 器械(28) / 目标肌肉(19) / 肌群 / 类型(力量·有氧·柔韧) |
| 按器械筛选 | `GET /api/exercises?equipment=dumbbell` + 前端器械 chip 筛选 |
| 按肌肉群筛选 | `GET /api/exercises?bodyPart=chest` + `?target=biceps` |
| **核心推荐** | `GET /api/recommend?target=biceps&equipment=barbell` — 评分排序(主训+100 / 协同+40 / 器械匹配+30+ 中文推荐理由 |
| 发散功能 | 8 个智能训练组合(推/拉/腿/核心/上肢/全身/居家无器械/有氧)、搜索、统计、按类型筛选 |
| 优雅首页 | 渐变 hero + 问候 + 智能推荐 CTA + 2×3 快捷入口 + 精选动作轮播 + 训练组合 + 按部位浏览 + 热门器械 |
| 数据来源 | 数据集导入后端内存,统一经 NestJS 对外提供(含 GIF 演示CDN 直链) |
## 已验证curl 全通过)
- 列表分页与多维筛选、分类元数据target 含 `bodyPart` 分组)、详情(含中文步骤)
- 推荐逻辑修复后 **total 一致**(装备仅作加分排序,不再虚增无关动作)
- 错误码:坏 id → 404recommend 缺 target → 400
## 运行方式
```bash
# 后端
cd fitness-coach/backend
npm install # 弱网下用 --prefer-offline本机有 3.1G npm 缓存,秒装)
npm run build
PORT=3000 node dist/main.js # http://localhost:3000
# 小程序
微信开发者工具「导入项目」选择 fitness-coach/miniprogram/
appid 已设为 touristappidurlCheck:false免账号即可预览
```
> 生产发布需:① `config.js` 的 BASE_URL 改为 HTTPS 域名;② 在小程序后台配置 request/downloadFile 合法域名(媒体默认用 `cdn.jsdelivr.net`,建议换自有 CDN
## 备注
- 媒体GIF/图)默认经 jsDelivr CDN 直链加载,无需本地下载;亦可改 `MEDIA_BASE_URL` 指向自有 CDN 后执行 `npm run sync:media` 同步到 `backend/media/`
- 单文件 JSON 数据集在内存中查询,接口响应快;小程序侧仅取轻量 summary保证首屏性能。

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# FITCOACH · 健身动作推荐小程序系统
基于 [hasaneyldrm/exercises-dataset](https://github.com/hasaneyldrm/exercises-dataset)1,324 个健身动作,含中文说明)构建的**健身动作推荐小程序**全栈系统。
- **后端**NestJS 服务统一托管全部动作数据作为小程序的数据代理层REST API
- **前端**:微信小程序,包含首页、多维分类浏览、器械/肌肉群筛选、目标肌群智能推荐、动作详情、智能训练组合、搜索等完整功能。
---
## 系统架构
```
┌─────────────────────┐ HTTPS / wx.request ┌──────────────────────────┐
│ 微信小程序 (MP) │ ───────────────────────────────▶ │ NestJS 后端 (代理层) │
│ pages / components │ ◀─────────────────────────────── │ - 加载并托管数据集 │
│ utils/request.js │ JSON (筛选/推荐/分类/组合) │ - 多维筛选 + 智能推荐 │
└─────────────────────┘ │ - 静态媒体 /media │
└──────────────────────────┘
│ 读取
exercises-dataset (exercises.json)
```
> 所有锻炼动作数据资源**统一存储在后端**,小程序只通过后端 API 获取数据,不直接接触数据源。
---
## 目录结构
```
fitness-coach/
├── backend/ # NestJS 后端
│ ├── src/exercises/ # 动作模块service/controller/dto/data
│ │ ├── data/
│ │ │ ├── exercises.json # 数据集(构建时由脚本下载,见下)
│ │ │ ├── labels.ts # 中英文标签映射
│ │ │ └── collections.ts # 智能训练组合定义
│ │ └── utils/normalize.ts # 原始数据 → 结构化中文字段
│ ├── scripts/ # ensure-data.mjs(下载数据集) / sync-media.mjs(下载媒体) / analyze.mjs
│ └── README.md
└── miniprogram/ # 微信小程序前端
├── pages/ # index / category / recommend / detail / collection / collection-detail / search
├── components/ # exercise-card / section-header / chip / navbar / bottom-nav
├── services/exercise.js # 接口映射
├── utils/request.js # 统一请求封装
└── README.md
```
---
## 快速开始
### 1. 启动后端
```bash
cd backend
npm install
npm run build # prebuild 会自动下载 exercises.json 到 src/exercises/data/
npm run start:prod # 或 npm run start:dev 开发模式
# 服务默认 http://localhost:3000
# 可选:下载动作图片/动画到 backend/media/(供小程序显示 GIF
npm run sync:media
```
### 2. 配置并预览小程序
1. 用**微信开发者工具**导入 `miniprogram/` 目录AppID 可用测试号)。
2. 打开 `miniprogram/config.js`,将 `BASE_URL` 改为后端可达地址:
- 本地调试:同局域网用电脑 IP`http://192.168.1.10:3000`
- 真机/发布:**必须**改为已配置 request 合法域名的 HTTPS 地址
3. 编译预览。首页、分类、智能推荐、详情、训练组合、搜索均可使用。
> 小程序要求所有网络请求域名在 **小程序后台 → 开发管理 → 开发设置 → 服务器域名** 中配置request 合法域名)。本地开发者工具勾选「不校验合法域名」可临时跳过。
---
## 核心功能
| 功能 | 说明 |
|------|------|
| 首页 | 渐变 Hero + 智能推荐 CTA + 快捷入口 + 精选动作轮播 + 训练组合 + 按部位/器械浏览 |
| 多维分类 | 按**身体部位 / 器械 / 目标肌群 / 肌肉群 / 锻炼类型** 浏览 |
| 器械筛选 | 选可用器械,快速过滤适合的动作 |
| 肌肉群筛选 | 按目标肌肉部位浏览相关动作 |
| **智能推荐** | 选择目标肌群(可选器械),按相关性评分推荐动作并给出理由 |
| 训练组合 | 推日/拉日/腿日/核心/上肢/全身/居家无器械/有氧 等主题动作包 |
| 动作详情 | GIF 演示 + 中文分步说明 + 主要/协同肌群 + 相关推荐 + 分享 |
| 搜索 | 按名称/部位/器械/目标模糊搜索 |
---
## 后端 API 速览
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/exercises` | 列表(分页 + bodyPart/equipment/target/type/secondary/q 筛选 + sort |
| GET | `/api/exercises/:id` | 动作详情(含中文步骤) |
| GET | `/api/categories/body-parts` | 部位分类(含数量) |
| GET | `/api/categories/equipment` | 器械分类 |
| GET | `/api/categories/targets` | 目标肌群分类(含所属部位,用于分组) |
| GET | `/api/categories/muscle-groups` | 肌肉群分类 |
| GET | `/api/categories/types` | 锻炼类型(力量/有氧/柔韧) |
| GET | `/api/recommend?target=&equipment=&limit=` | 目标肌群智能推荐 |
| GET | `/api/collections` | 训练组合列表 |
| GET | `/api/collections/:slug/exercises` | 某组合下的动作 |
| GET | `/api/search?q=` | 搜索 |
| GET | `/api/stats` | 统计概览 |
详见 `backend/README.md``miniprogram/README.md`
---
## 数据来源与许可
- 数据集:[hasaneyldrm/exercises-dataset](https://github.com/hasaneyldrm/exercises-dataset),代码/数据基于 MIT 许可。
- 动作图片与动画 GIF 版权归 **© Gym visual (https://gymvisual.com/)**,需遵守其媒体使用条款;通过 `npm run sync:media` 同步到后端 `media/` 目录后由 `/media` 静态提供。
---
## 备注
- 后端默认在内存中加载数据集(启动快、查询快);如需持久化可替换为 TypeORM + 数据库,接口层不变。
- 数据集较大(约 17MB首次 `npm run build` 会自动从 GitHub 下载;也可手动放置 `exercises.json``backend/src/exercises/data/`

16
backend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# 后端服务端口
PORT=3000
# 前端(微信小程序)请求的基础地址,用于生成可被小程序访问的媒体 URL。
# 开发时填写你本机局域网 IP例如 http://192.168.1.10:3000
# 生产时填写你的域名(必须 HTTPS且已在小程序后台配置 request 合法域名)
API_BASE_URL=http://localhost:3000
# 媒体资源基础地址GIF / 图片)。默认直接指向源数据集在 jsDelivr 的 CDN
# 无需本地下载媒体文件即可在开发环境加载动作演示。
# 注意微信小程序要求图片域名在「request/downloadFile 合法域名」中白名单,
# 开发阶段可在开发者工具勾选「不校验合法域名」;正式发布需替换为自有 CDN 并配置白名单。
MEDIA_BASE_URL=https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main
# 是否允许跨域(小程序走 wx.request 通常不需要,但方便 Web 调试 / Postman
CORS_ENABLED=true

78
backend/README.md Normal file
View File

@@ -0,0 +1,78 @@
# FITCOACH 后端NestJS
健身动作数据代理与推荐服务。所有动作数据统一存储在后端,向前端小程序提供 REST 接口。
## 技术栈
- NestJS 10 + Express
- 内存数据集(构建时由 `exercises-dataset` 导入),查询/筛选/推荐均在内存完成,毫秒级响应
- 可选静态媒体服务(`/media`,指向 `backend/media/`
## 环境变量(`.env`,参考 `.env.example`
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `3000` | 服务端口 |
| `API_BASE_URL` | `http://localhost:3000` | 小程序访问基址(生成媒体 URL 用) |
| `MEDIA_BASE_URL` | `http://localhost:3000/media` | 媒体资源基址;可改为 CDN |
| `CORS_ENABLED` | `true` | 是否允许跨域Web 调试用) |
## 安装与运行
```bash
npm install
npm run build # 触发 prebuild: 自动下载 exercises.json 到 src/exercises/data/
npm run start:dev # 开发watch
npm run start:prod # 生产node dist/main.js
```
## 脚本
- `npm run sync:media` — 将数据集的图片/动画同步到 `backend/media/`(供 `/media` 提供)。可选环境变量 `MEDIA_CONCURRENCY`(并发)、`MEDIA_ONLY=gif|image`
- `node scripts/analyze.mjs` — 扫描数据集,输出各维度去重值与缺失的中文标签(用于补全 `labels.ts`)。
## 数据导入说明
数据集 `exercises.json`1,324 条)通过 `scripts/ensure-data.mjs` 在构建时自动从 GitHub 下载;
也可手动把 `exercises-dataset/data/exercises.json` 复制到 `src/exercises/data/exercises.json`
服务启动时由 `ExercisesService.onModuleInit()` 读取并 `normalize()` 为带中文字段的结构化对象。
## 接口示例
```bash
# 列表 + 筛选(按器械 dumbbell分页
curl "http://localhost:3000/api/exercises?equipment=dumbbell&pageSize=5"
# 动作详情
curl "http://localhost:3000/api/exercises/0001"
# 目标肌群智能推荐(肱二头肌,限定哑铃)
curl "http://localhost:3000/api/recommend?target=biceps&equipment=dumbbell&limit=10"
# 分类元数据
curl "http://localhost:3000/api/categories/targets"
curl "http://localhost:3000/api/categories/equipment"
# 训练组合
curl "http://localhost:3000/api/collections"
curl "http://localhost:3000/api/collections/legs/exercises?pageSize=10"
# 搜索
curl "http://localhost:3000/api/search?q=abs"
```
## 推荐算法
对每条动作计算相关性评分:
- 目标肌群完全匹配 `target`+100理由「主要训练 XX」
- 出现在协同肌群 `secondaryMuscles`+40理由「协同训练 XX」
- 指定器械匹配:+30若为自重可替代+10
仅返回评分 > 0 的动作,按评分降序、名称升序返回。
## 目录
```
src/exercises/
├── exercises.module.ts
├── exercises.service.ts # 数据加载、筛选、分类、推荐、组合
├── exercises.controller.ts # /api 路由
├── exercises.interface.ts # 类型定义
├── dto/ # QueryExercisesDto / RecommendQueryDtoclass-validator 校验)
├── utils/normalize.ts # 原始 → 结构化(类型推导、媒体 URL、中文标签
└── data/
├── exercises.json # 数据集(构建时生成)
├── labels.ts # 中英文标签映射
└── collections.ts # 智能训练组合定义
```

12
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [
{ "include": "exercises/data/exercises.json", "outDir": "dist" }
],
"watchAssets": true
}
}

4603
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "fitness-coach-backend",
"version": "1.0.0",
"description": "健身动作推荐小程序 - NestJS 后端代理与数据服务",
"author": "WorkBuddy",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "node scripts/ensure-data.mjs",
"build": "nest build && node scripts/copy-data.mjs",
"start": "nest start",
"dev": "nest start --watch",
"start:prod": "node dist/main.js",
"sync:media": "node scripts/sync-media.mjs",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
},
"dependencies": {
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"@nestjs/serve-static": "^4.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.4",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,74 @@
/**
* 数据分析脚本:扫描 exercises.json输出各维度的去重取值与数量
* 并报告哪些取值尚未被中文标签映射覆盖(便于补全 labels.ts
*
* 用法: node scripts/analyze.mjs [path-to-exercises.json]
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA = process.argv[2] || path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
// 复用 TS 标签映射(用 require 不行,这里用一份 JS 镜像做缺失检测)
const BODY_PART_LABELS = {
back: '背部', cardio: '有氧心肺', chest: '胸部', 'lower arms': '前臂',
'lower legs': '小腿', neck: '颈部', shoulders: '肩部', 'upper arms': '上臂',
'upper legs': '大腿', waist: '腰腹',
};
const EQUIPMENT_LABELS = {
'body weight': '自重', dumbbell: '哑铃', barbell: '杠铃', kettlebell: '壶铃', cable: '钢索',
machine: '固定器械', band: '弹力带', 'exercise ball': '健身球', 'medicine ball': '药球',
'ez bar': 'EZ曲杆', 'foam roll': '泡沫轴', treadmill: '跑步机', 'stationary bike': '固定单车',
rope: '战绳', tire: '轮胎', skier: '滑雪训练器', 'sled machine': '雪橇机', roller: '滚轮',
'elliptical machine': '椭圆机', 'olympic barbell': '奥杆', 'decline bench': '下斜凳',
'flat bench': '平板凳', 'pull-up bar': '单杠', 'stability ball': '瑞士球', trx: 'TRX悬挂带',
'assistance machine': '辅助器械', 'bosu ball': 'BOSU半球', 'wheel roller': '健腹轮',
none: '无器械', other: '其他',
};
const MUSCLE_LABELS = {
biceps: '肱二头肌', triceps: '肱三头肌', forearms: '前臂', abs: '腹肌', abdominals: '腹肌',
obliques: '腹斜肌', pectorals: '胸大肌', chest: '胸部', quadriceps: '股四头肌',
hamstrings: '腘绳肌', glutes: '臀大肌', calves: '小腿', traps: '斜方肌', 'upper traps': '上斜方肌',
lats: '背阔肌', 'lower back': '下背', delts: '三角肌', shoulders: '肩部', hips: '髋部',
adductors: '内收肌', neck: '颈部', 'serratus anterior': '前锯肌', 'spinal erectors': '竖脊肌',
'hip flexors': '髋屈肌', 'middle back': '中背',
};
function tally(arr, keyFn) {
const m = new Map();
for (const x of arr) {
const k = keyFn(x);
if (!k) continue;
if (Array.isArray(k)) k.forEach((v) => m.set(v, (m.get(v) || 0) + 1));
else m.set(k, (m.get(k) || 0) + 1);
}
return m;
}
function report(name, map, labels) {
const missing = [];
const rows = Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
console.log(`\n=== ${name} (${rows.length} 种) ===`);
for (const [v, c] of rows) {
const has = labels[v] ? '' : ' ⚠ 缺标签';
if (!labels[v]) missing.push(v);
console.log(` ${v.padEnd(22)} ${String(c).padStart(5)} ${labels[v] || ''}${has}`);
}
return missing;
}
const raw = JSON.parse(fs.readFileSync(DATA, 'utf-8'));
console.log(`总动作数: ${raw.length}`);
const eqMiss = report('EQUIPMENT', tally(raw, (e) => e.equipment), EQUIPMENT_LABELS);
const tMiss = report('TARGET', tally(raw, (e) => e.target), MUSCLE_LABELS);
const mgMiss = report('MUSCLE_GROUP', tally(raw, (e) => e.muscle_group), MUSCLE_LABELS);
const smMiss = report('SECONDARY_MUSCLES', tally(raw, (e) => e.secondary_muscles), MUSCLE_LABELS);
console.log('\n=== 缺失中文标签汇总(需补全 labels.ts===');
console.log('equipment :', eqMiss.join(', ') || '(无)');
console.log('target :', tMiss.join(', ') || '(无)');
console.log('muscle_group:', mgMiss.join(', ') || '(无)');
console.log('secondary :', smMiss.join(', ') || '(无)');

View File

@@ -0,0 +1,20 @@
// 将原始数据集 exercises.json 从 src 复制到编译产物 dist
// 因为 tsc 不会拷贝非 .ts 资源文件。
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const src = path.join(root, 'src', 'exercises', 'data', 'exercises.json');
const destDir = path.join(root, 'dist', 'exercises', 'data');
const dest = path.join(destDir, 'exercises.json');
if (!fs.existsSync(src)) {
console.warn('[copy-data] 源数据集不存在,跳过:', src);
process.exit(0);
}
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
console.log('[copy-data] 已复制数据集 ->', dest);

View File

@@ -0,0 +1,33 @@
/**
* prebuild 脚本:确保后端数据集存在。
* 若 backend/src/exercises/data/exercises.json 不存在,则从 GitHub 原始数据集下载。
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const SOURCE =
'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/data/exercises.json';
async function main() {
if (fs.existsSync(DATA_FILE)) {
console.log('[ensure-data] 数据集已存在,跳过下载。');
return;
}
console.log('[ensure-data] 未找到数据集,开始从 GitHub 下载 ...');
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
const res = await fetch(SOURCE);
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(DATA_FILE, buf);
console.log(`[ensure-data] 已保存数据集 (${(buf.length / 1024 / 1024).toFixed(1)} MB)`);
}
main().catch((e) => {
console.error('[ensure-data] 错误:', e.message);
console.error('请手动将 exercises-dataset 的 data/exercises.json 放入 backend/src/exercises/data/');
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
/**
* 媒体同步脚本:将 exercises-dataset 的 images/ 与 videos/ 下载到 backend/media/
* 使后端 /media 静态服务可直接提供缩略图与动画 GIF。
*
* 用法: node scripts/sync-media.mjs
* 可选环境变量:
* MEDIA_CONCURRENCY=8 并发下载数
* MEDIA_ONLY=gif|image 仅下载某类gif / image
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const RAW_BASE = 'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/';
const CONCURRENCY = parseInt(process.env.MEDIA_CONCURRENCY || '8', 10);
const ONLY = process.env.MEDIA_ONLY || 'all';
async function main() {
if (!fs.existsSync(DATA_FILE)) {
console.error('[sync-media] 未找到数据集,请先运行 npm run build 或手动放置 exercises.json');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
const paths = new Set();
for (const e of raw) {
if (e.image && (ONLY === 'all' || ONLY === 'image')) paths.add(e.image);
if (e.gif_url && (ONLY === 'all' || ONLY === 'gif')) paths.add(e.gif_url);
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
const list = Array.from(paths);
console.log(`[sync-media] 待下载媒体 ${list.length} 个,并发 ${CONCURRENCY}`);
let done = 0;
let skipped = 0;
let failed = 0;
const queue = [...list];
async function worker() {
while (queue.length) {
const p = queue.shift();
const dest = path.join(MEDIA_DIR, p);
if (fs.existsSync(dest) && fs.statSync(dest).size > 0) {
skipped++;
continue;
}
try {
const res = await fetch(RAW_BASE + p);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buf);
done++;
} catch (err) {
failed++;
console.error(` 失败 ${p}: ${err.message}`);
}
if ((done + skipped + failed) % 50 === 0) {
console.log(` 进度 ${done + skipped + failed}/${list.length}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.log(`[sync-media] 完成:新增 ${done},已存在 ${skipped},失败 ${failed}`);
}
main().catch((e) => {
console.error('[sync-media] 错误:', e.message);
process.exit(1);
});

27
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ExercisesModule } from './exercises/exercises.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { FavoritesModule } from './favorites/favorites.module';
import { PlansModule } from './plans/plans.module';
// 若 backend/media 目录存在(放有 images/ 与 videos/),则通过 /media 暴露静态资源
const mediaDir = path.join(__dirname, '..', 'media');
const staticImports = fs.existsSync(mediaDir)
? [ServeStaticModule.forRoot({ rootPath: mediaDir, serveRoot: '/media' })]
: [];
@Module({
imports: [
ExercisesModule,
AuthModule,
UsersModule,
FavoritesModule,
PlansModule,
...staticImports,
],
})
export class AppModule {}

View File

@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
@Controller('api/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto.code, dto.userInfo);
}
@Get('me')
@UseGuards(AuthGuard)
me(@CurrentUser() user: { openid: string }) {
return this.auth.me(user.openid);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,85 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { LoginDto, devOpenid } from './dto/login.dto';
import { signToken } from '../common/token';
import { UsersService } from '../users/users.service';
export interface LoginResult {
token: string;
user: {
openid: string;
nickname: string;
avatar: string;
createdAt: string;
};
}
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService) {}
async login(code: string, userInfo?: LoginDto['userInfo']): Promise<LoginResult> {
const openid = await this.resolveOpenid(code);
// 首次登录自动建号;若携带 userInfo 则同步昵称/头像
let user = this.users.findByOpenid(openid);
if (!user) {
user = this.users.create({
openid,
nickname: userInfo?.nickname,
avatar: userInfo?.avatar,
});
} else if (userInfo?.nickname || userInfo?.avatar) {
user = this.users.update(openid, {
...(userInfo.nickname ? { nickname: userInfo.nickname } : {}),
...(userInfo.avatar ? { avatar: userInfo.avatar } : {}),
});
}
return {
token: signToken(openid),
user: {
openid: user.openid,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
},
};
}
/** 解析 openid生产走 code2session开发模式降级为虚拟 openid */
private async resolveOpenid(code: string): Promise<string> {
const appid = process.env.WX_APPID;
const secret = process.env.WX_SECRET;
if (appid && secret && code && code !== 'tourist' && code !== 'mock') {
try {
const url =
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}` +
`&secret=${secret}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`;
const res = await fetch(url);
const json = (await res.json()) as {
openid?: string;
errcode?: number;
errmsg?: string;
};
if (json.openid) return json.openid;
// 失败则降级,避免阻塞开发
return devOpenid(code);
} catch {
return devOpenid(code);
}
}
return devOpenid(code);
}
me(openid: string) {
const user = this.users.findByOpenid(openid);
if (!user) throw new UnauthorizedException('用户不存在,请重新登录');
return {
openid: user.openid,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsOptional, IsObject } from 'class-validator';
export class LoginDto {
@IsString()
code: string;
@IsOptional()
@IsObject()
userInfo?: { nickname?: string; avatar?: string };
}
/** 开发模式(未配置 APPID/SECRET依据 code 生成稳定的虚拟 openid */
export function devOpenid(code: string): string {
// 延迟引入避免循环crypto 在 token.ts 已用,这里直接 require
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto');
return 'dev_' + crypto.createHash('sha256').update(code || 'tourist').digest('hex').slice(0, 16);
}
export const PLAN_LEVELS = ['beginner', 'intermediate', 'advanced', 'all'] as const;
export type PlanLevel = (typeof PLAN_LEVELS)[number];
export function isPlanLevel(v: unknown): v is PlanLevel {
return typeof v === 'string' && (PLAN_LEVELS as readonly string[]).includes(v);
}
export class UpdateProfileDto {
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsString()
avatar?: string;
}

View File

@@ -0,0 +1,20 @@
import {
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { verifyToken } from './token';
/** 从 Authorization: Bearer <token> 中解析并校验登录态,注入 req.user */
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const header = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(header);
if (!m) throw new UnauthorizedException('请先登录');
const payload = verifyToken(m[1]);
if (!payload) throw new UnauthorizedException('登录已过期,请重新登录');
req.user = { openid: payload.openid };
return true;
}
}

View File

@@ -0,0 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/** 取出 AuthGuard 注入的当前用户 openid: @CurrentUser() user: { openid: string } */
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);

View File

@@ -0,0 +1,77 @@
import * as fs from 'fs';
import * as path from 'path';
/**
* 轻量级文件持久化存储(单进程内单例)。
* 每个 name 对应 backend/data-store/<name>.json启动时从磁盘加载
* 每次写操作整体落盘。适合 demo / 轻量生产前置,避免引入数据库依赖。
*/
const ROOT = path.join(process.cwd(), 'data-store');
export class JsonStore<T> {
private file: string;
private data: T[] = [];
constructor(name: string) {
this.file = path.join(ROOT, `${name}.json`);
if (fs.existsSync(this.file)) {
try {
this.data = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
} catch {
this.data = [];
}
} else {
fs.mkdirSync(ROOT, { recursive: true });
this.data = [];
this.flush();
}
}
private flush() {
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
}
all(): T[] {
return this.data;
}
find(pred: (x: T) => boolean): T | undefined {
return this.data.find(pred);
}
filter(pred: (x: T) => boolean): T[] {
return this.data.filter(pred);
}
insert(item: T): T {
this.data.push(item);
this.flush();
return item;
}
update(pred: (x: T) => boolean, patch: Partial<T>): T | undefined {
const it = this.data.find(pred);
if (!it) return undefined;
Object.assign(it, patch);
this.flush();
return it;
}
remove(pred: (x: T) => boolean): boolean {
const i = this.data.findIndex(pred);
if (i >= 0) {
this.data.splice(i, 1);
this.flush();
return true;
}
return false;
}
}
const singletons: Record<string, JsonStore<any>> = {};
/** 进程内单例:同一 name 始终返回同一实例,避免多服务各自加载导致写覆盖 */
export function getStore<T>(name: string): JsonStore<T> {
if (!singletons[name]) singletons[name] = new JsonStore<T>(name);
return singletons[name] as JsonStore<T>;
}

View File

@@ -0,0 +1,42 @@
import * as crypto from 'crypto';
/**
* 极简 tokenbase64url(payload).HMAC-SHA256(payload)
* 不依赖数据库会话payload 内含 openid 与过期时间,校验时重算签名即可。
* 生产环境请通过环境变量 JWT_SECRET 设置强密钥。
*/
const SECRET = process.env.JWT_SECRET || 'fitcoach-dev-secret';
const TTL = 30 * 24 * 60 * 60 * 1000; // 30 天
export interface TokenPayload {
openid: string;
iat: number;
exp: number;
}
export function signToken(openid: string): string {
const payload: TokenPayload = {
openid,
iat: Date.now(),
exp: Date.now() + TTL,
};
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verifyToken(token: string): TokenPayload | null {
try {
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
// 防时序攻击的常量比较
if (sig.length !== expected.length) return null;
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const data = JSON.parse(Buffer.from(body, 'base64url').toString()) as TokenPayload;
if (!data.exp || data.exp < Date.now()) return null;
return data;
} catch {
return null;
}
}

View File

@@ -0,0 +1,208 @@
import { CollectionDef } from '../exercises.interface';
/**
* 智能训练组合(训练分化 / 场景化动作包)
* 每个组合通过 filter 条件在服务端实时解析动作,无需预存列表。
*/
export const COLLECTIONS: CollectionDef[] = [
{
slug: 'push',
name: '推日训练',
nameEn: 'Push Day',
emoji: '💪',
color: '#FF6B6B',
description: '胸、肩、肱三头肌为主,适合「推」类动作的集中刺激。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 2,
focus: '胸肩三头均衡发展',
filter: { bodyParts: ['chest', 'shoulders'] },
},
{
slug: 'pull',
name: '拉日训练',
nameEn: 'Pull Day',
emoji: '🪢',
color: '#4D96FF',
description: '背、肱二头、前臂为主,强化「拉」类动作与背部厚度。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 2,
focus: '背部厚度与手臂线条',
filter: { bodyParts: ['back', 'lower arms', 'upper arms'] },
},
{
slug: 'legs',
name: '腿部训练',
nameEn: 'Leg Day',
emoji: '🦵',
color: '#6BCB77',
description: '大腿与小腿全覆盖,打造下肢力量与线条。',
level: 'advanced',
durationWeeks: 10,
sessionsPerWeek: 2,
focus: '下肢力量与围度突破',
filter: { bodyParts: ['upper legs', 'lower legs'] },
},
{
slug: 'core',
name: '核心训练',
nameEn: 'Core Day',
emoji: '🔥',
color: '#FFD93D',
description: '腰腹核心集中训练,提升稳定性与体态。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 3,
focus: '腰腹稳定与体态改善',
filter: { bodyParts: ['waist'], limit: 16 },
},
{
slug: 'upper',
name: '上肢训练',
nameEn: 'Upper Body',
emoji: '🦾',
color: '#9B72FF',
description: '胸背肩臂全覆盖,一次练透上半身。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 3,
focus: '上半身整体塑形',
filter: { bodyParts: ['chest', 'back', 'shoulders', 'upper arms', 'lower arms', 'neck'] },
},
{
slug: 'full',
name: '全身训练',
nameEn: 'Full Body',
emoji: '🌟',
color: '#FF9F45',
description: '力量与柔韧动作组合,适合全身性训练日。',
level: 'beginner',
durationWeeks: 6,
sessionsPerWeek: 3,
focus: '零基础全身激活',
filter: { type: ['strength', 'flexibility'], limit: 18 },
},
{
slug: 'home',
name: '居家无器械',
nameEn: 'Home / No Equipment',
emoji: '🏠',
color: '#38C6C0',
description: '仅需自重即可完成,足不出户也能练。',
level: 'beginner',
durationWeeks: 6,
sessionsPerWeek: 4,
focus: '自重也能练出线条',
filter: { equipment: ['body weight', 'none'], excludeBodyParts: ['cardio'], limit: 18 },
},
{
slug: 'cardio',
name: '有氧心肺',
nameEn: 'Cardio',
emoji: '🏃',
color: '#FF7BAC',
description: '提升心肺耐力与燃脂效率的有氧动作。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 4,
focus: '心肺耐力与燃脂',
filter: { bodyParts: ['cardio'], limit: 14 },
},
// ===== 新增:按训练水平分级的官方推荐计划 =====
{
slug: 'beginner-7day',
name: '新手七天全身',
nameEn: '7-Day Starter',
emoji: '🌱',
color: '#7CD992',
description: '零基础友好,自重为主,用一周建立正确动作模式与训练习惯。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 3,
focus: '建立动作基础与训练习惯',
filter: { equipment: ['body weight', 'none'], type: ['strength'], excludeBodyParts: ['cardio'], limit: 12 },
},
{
slug: 'beginner-fatburn',
name: '新手燃脂起步',
nameEn: 'Fat Burn Kickstart',
emoji: '🔥',
color: '#FF9F68',
description: '低门槛有氧 + 自重循环,轻松开启燃脂第一步。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 4,
focus: '低门槛高效燃脂',
filter: { type: ['cardio', 'strength'], equipment: ['body weight', 'none'], limit: 14 },
},
{
slug: 'intermediate-hypertrophy',
name: '进阶增肌分化',
nameEn: 'Hypertrophy Split',
emoji: '📈',
color: '#C792EA',
description: '系统分化训练,以肌肥大为目标,覆盖全身主要肌群。',
level: 'intermediate',
durationWeeks: 12,
sessionsPerWeek: 5,
focus: '系统增肌 · 肌肥大优先',
filter: { type: ['strength'], limit: 24 },
},
{
slug: 'intermediate-strength',
name: '进阶力量推拉腿',
nameEn: 'Strength PPL',
emoji: '🏋️',
color: '#5AA9FF',
description: '推/拉/腿经典分化,专注复合动作与力量提升。',
level: 'intermediate',
durationWeeks: 12,
sessionsPerWeek: 4,
focus: '复合动作力量进阶',
filter: { bodyParts: ['chest', 'back', 'shoulders', 'upper legs', 'lower legs'], limit: 20 },
},
{
slug: 'advanced-athlete',
name: '高级竞技爆发',
nameEn: 'Athletic Power',
emoji: '⚡',
color: '#FF5C8A',
description: '高负荷器械训练,面向进阶者的力量与爆发力进阶。',
level: 'advanced',
durationWeeks: 12,
sessionsPerWeek: 5,
focus: '最大力量与爆发力',
filter: { equipment: ['barbell', 'dumbbell', 'kettlebell'], type: ['strength'], limit: 22 },
},
{
slug: 'mobility-recovery',
name: '舒展放松恢复',
nameEn: 'Mobility & Recovery',
emoji: '🧘',
color: '#5FD0C5',
description: '柔韧与放松动作,缓解久坐疲劳,改善活动度。',
level: 'all',
durationWeeks: 0,
sessionsPerWeek: 5,
focus: '柔韧改善与日常恢复',
filter: { type: ['flexibility'], limit: 14 },
},
{
slug: 'female-toning',
name: '女子塑形紧致',
nameEn: 'Tone & Sculpt',
emoji: '🌸',
color: '#FF8FB1',
description: '针对腰腹、臀腿与肩臂的塑形组合,紧致线条不粗壮。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 4,
focus: '线条紧致与体态雕琢',
filter: { bodyParts: ['waist', 'upper legs', 'lower legs', 'shoulders'], limit: 18 },
},
];
export function findCollection(slug: string): CollectionDef | undefined {
return COLLECTIONS.find((c) => c.slug === slug);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
/**
* 中英文标签映射
* 说明:本文件在数据分析阶段由 scripts/analyze.mjs 扫描真实数据集生成,
* 未知取值会回退为「首字母大写」的英文原文,保证不丢数据。
*/
export const BODY_PART_LABELS: Record<string, string> = {
back: '背部',
cardio: '有氧心肺',
chest: '胸部',
'lower arms': '前臂',
'lower legs': '小腿',
neck: '颈部',
shoulders: '肩部',
'upper arms': '上臂',
'upper legs': '大腿',
waist: '腰腹',
};
export const EQUIPMENT_LABELS: Record<string, string> = {
'body weight': '自重',
dumbbell: '哑铃',
barbell: '杠铃',
kettlebell: '壶铃',
cable: '钢索',
machine: '固定器械',
band: '弹力带',
'exercise ball': '健身球',
'medicine ball': '药球',
'ez bar': 'EZ曲杆',
'foam roll': '泡沫轴',
treadmill: '跑步机',
'stationary bike': '固定单车',
rope: '战绳',
tire: '轮胎',
skier: '滑雪训练器',
'sled machine': '雪橇机',
roller: '滚轮',
'elliptical machine': '椭圆机',
'olympic barbell': '奥杆',
'decline bench': '下斜凳',
'flat bench': '平板凳',
'pull-up bar': '单杠',
'stability ball': '瑞士球',
trx: 'TRX悬挂带',
'assistance machine': '辅助器械',
'bosu ball': 'BOSU半球',
'wheel roller': '健腹轮',
'leverage machine': '杠杆器械',
'smith machine': '史密斯机',
weighted: '负重',
'ez barbell': 'EZ杠铃',
assisted: '辅助器械',
'resistance band': '阻力带',
'upper body ergometer': '上肢测功仪',
'skierg machine': '滑雪机',
hammer: '锤',
'trap bar': '六角杠',
'stepmill machine': '楼梯机',
none: '无器械',
other: '其他',
};
export const MUSCLE_LABELS: Record<string, string> = {
biceps: '肱二头肌',
triceps: '肱三头肌',
'forearms': '前臂',
abs: '腹肌',
'abdominals': '腹肌',
obliques: '腹斜肌',
'pectorals': '胸大肌',
'chest': '胸部',
'quadriceps': '股四头肌',
'hamstrings': '腘绳肌',
glutes: '臀大肌',
calves: '小腿',
'calves ': '小腿',
traps: '斜方肌',
'upper traps': '上斜方肌',
lats: '背阔肌',
'lower back': '下背',
delts: '三角肌',
'shoulders': '肩部',
hips: '髋部',
adductors: '内收肌',
neck: '颈部',
'serratus anterior': '前锯肌',
'spinal erectors': '竖脊肌',
'hip flexors': '髋屈肌',
'middle back': '中背',
'upper back': '上背',
quads: '股四头肌',
'cardiovascular system': '心血管系统',
spine: '脊柱',
abductors: '外展肌',
'levator scapulae': '肩胛提肌',
trapezius: '斜方肌',
deltoids: '三角肌',
ankles: '踝部',
core: '核心',
'rotator cuff': '肩袖',
soleus: '比目鱼肌',
rhomboids: '菱形肌',
'wrist flexors': '腕屈肌',
'latissimus dorsi': '背阔肌',
'ankle stabilizers': '踝稳定肌',
'wrist extensors': '腕伸肌',
wrists: '腕部',
hands: '手部',
'rear deltoids': '后三角肌',
brachialis: '肱肌',
back: '背部',
feet: '足部',
'upper chest': '上胸',
'sternocleidomastoid': '胸锁乳突肌',
groin: '腹股沟',
};
/** 取标签;缺失时回退为首字母大写的英文原文 */
export function labelOf(map: Record<string, string>, key: string): string {
if (!key) return '';
return map[key] ?? key.replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,49 @@
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class QueryExercisesDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
pageSize = 20;
@IsOptional()
@IsString()
bodyPart?: string;
@IsOptional()
@IsString()
equipment?: string;
@IsOptional()
@IsString()
target?: string;
@IsOptional()
@IsString()
muscleGroup?: string;
@IsOptional()
@IsIn(['strength', 'cardio', 'flexibility'])
type?: 'strength' | 'cardio' | 'flexibility';
@IsOptional()
@IsString()
secondary?: string;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(['default', 'name'])
sort?: 'default' | 'name' = 'default';
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class RecommendQueryDto {
@IsString()
target: string;
@IsOptional()
@IsString()
equipment?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 20;
}

View File

@@ -0,0 +1,79 @@
import {
Controller,
Get,
Query,
Param,
ParseIntPipe,
} from '@nestjs/common';
import { ExercisesService } from './exercises.service';
import { QueryExercisesDto } from './dto/query-exercises.dto';
import { RecommendQueryDto } from './dto/recommend-query.dto';
@Controller('api')
export class ExercisesController {
constructor(private readonly service: ExercisesService) {}
@Get('exercises')
findAll(@Query() query: QueryExercisesDto) {
return this.service.findAll(query);
}
@Get('exercises/:id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Get('categories/body-parts')
bodyParts() {
return this.service.getBodyPartCategories();
}
@Get('categories/equipment')
equipment() {
return this.service.getEquipmentCategories();
}
@Get('categories/targets')
targets() {
return this.service.getTargetCategories();
}
@Get('categories/muscle-groups')
muscleGroups() {
return this.service.getMuscleGroupCategories();
}
@Get('categories/types')
types() {
return this.service.getTypeCategories();
}
@Get('recommend')
recommend(@Query() dto: RecommendQueryDto) {
return this.service.recommend(dto);
}
@Get('collections')
collections() {
return this.service.listCollections();
}
@Get('collections/:slug/exercises')
collectionExercises(
@Param('slug') slug: string,
@Query('page', new ParseIntPipe({ optional: true })) page = 1,
@Query('pageSize', new ParseIntPipe({ optional: true })) pageSize = 20,
) {
return this.service.getCollectionExercises(slug, page, pageSize);
}
@Get('search')
search(@Query('q') q: string) {
return this.service.findAll({ q, page: 1, pageSize: 30 });
}
@Get('stats')
stats() {
return this.service.stats();
}
}

View File

@@ -0,0 +1,128 @@
/**
* 健身动作数据接口定义
* 原始数据来自 exercises-datasethasaneyldrm/exercises-dataset
* 经后端 normalize 后对外提供结构化、带中文标签的字段。
*/
/** 数据集原始单条记录 */
export interface RawExercise {
id: string;
name: string;
category?: string;
body_part: string;
equipment: string;
instructions: Record<string, string>;
instruction_steps: Record<string, string[]>;
muscle_group: string;
secondary_muscles: string[];
target: string;
media_id: string;
image: string;
gif_url: string;
attribution: string;
created_at: string;
}
/** 锻炼类型 */
export type ExerciseType = 'strength' | 'cardio' | 'flexibility';
/** 对外暴露(详情)的结构化动作 */
export interface Exercise {
id: string;
name: string;
bodyPart: string;
bodyPartLabel: string;
equipment: string;
equipmentLabel: string;
target: string;
targetLabel: string;
muscleGroup: string;
muscleGroupLabel: string;
secondaryMuscles: string[];
secondaryMusclesLabels: string[];
type: ExerciseType;
typeLabel: string;
image: string;
gifUrl: string;
/** 仅保留中英双语说明以控制体积 */
instructions: { zh: string; en: string };
instructionSteps: { zh: string[]; en: string[] };
attribution: string;
mediaId: string;
}
/** 列表项(轻量投影,不含完整说明,保证接口性能) */
export interface ExerciseSummary {
id: string;
name: string;
bodyPart: string;
bodyPartLabel: string;
equipment: string;
equipmentLabel: string;
target: string;
targetLabel: string;
type: ExerciseType;
typeLabel: string;
image: string;
gifUrl: string;
}
/** 分类元数据项 */
export interface CategoryItem {
value: string;
label: string;
count: number;
/** 仅 target 分类会附带其所属部位,便于前端按部位分组 */
bodyPart?: string;
bodyPartLabel?: string;
}
/** 推荐结果项(带相关性评分与推荐理由) */
export interface RecommendItem extends ExerciseSummary {
score: number;
reason: string;
}
export interface RecommendResult {
target: string;
targetLabel: string;
total: number;
items: RecommendItem[];
}
/** 官方计划难度等级 */
export type PlanLevel = 'beginner' | 'intermediate' | 'advanced' | 'all';
/** 智能训练组合(官方计划)定义 */
export interface CollectionDef {
slug: string;
name: string;
nameEn: string;
emoji: string;
color: string;
description: string;
/** 难度等级,用于官方计划分级展示 */
level?: PlanLevel;
/** 周期(周) */
durationWeeks?: number;
/** 每周训练次数 */
sessionsPerWeek?: number;
/** 一句话训练目标 */
focus?: string;
/** 用于服务端解析动作的过滤条件 */
filter: {
bodyParts?: string[];
equipment?: string[];
type?: ExerciseType[];
excludeBodyParts?: string[];
/** 计划动作数量上限,便于组合成「一份计划」而非全部 */
limit?: number;
};
}
/** 官方计划按等级分组后的结构 */
export interface OfficialPlanGroup {
level: PlanLevel;
label: string;
plans: CollectionDef[];
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExercisesService } from './exercises.service';
import { ExercisesController } from './exercises.controller';
@Module({
controllers: [ExercisesController],
providers: [ExercisesService],
exports: [ExercisesService],
})
export class ExercisesModule {}

View File

@@ -0,0 +1,246 @@
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import {
Exercise,
ExerciseSummary,
CategoryItem,
RecommendResult,
RecommendItem,
CollectionDef,
RawExercise,
} from './exercises.interface';
import { QueryExercisesDto } from './dto/query-exercises.dto';
import { RecommendQueryDto } from './dto/recommend-query.dto';
import { normalize, toSummary } from './utils/normalize';
import { COLLECTIONS, findCollection } from './data/collections';
import { labelOf, MUSCLE_LABELS } from './data/labels';
@Injectable()
export class ExercisesService implements OnModuleInit {
private exercises: Exercise[] = [];
private summaries: ExerciseSummary[] = [];
onModuleInit() {
this.load();
}
private load() {
const file = path.join(__dirname, 'data', 'exercises.json');
if (!fs.existsSync(file)) {
// 数据缺失时给出明确提示,避免静默空返回
console.warn(
`[exercises] 未找到数据集: ${file}。请先执行仓库根目录的媒体/数据准备脚本,或将 exercises.json 放入 backend/src/exercises/data/。`,
);
this.exercises = [];
this.summaries = [];
return;
}
const raw: RawExercise[] = JSON.parse(fs.readFileSync(file, 'utf-8'));
this.exercises = raw.map(normalize);
this.summaries = this.exercises.map(toSummary);
console.log(`[exercises] 已加载 ${this.exercises.length} 条健身动作`);
}
/** 列表查询:分页 + 多维筛选 + 搜索 + 排序 */
findAll(query: QueryExercisesDto) {
let list = this.exercises;
if (query.bodyPart) list = list.filter((e) => e.bodyPart === query.bodyPart);
if (query.equipment) list = list.filter((e) => e.equipment === query.equipment);
if (query.type) list = list.filter((e) => e.type === query.type);
if (query.target) list = list.filter((e) => e.target === query.target);
if (query.muscleGroup)
list = list.filter((e) => e.muscleGroup === query.muscleGroup);
if (query.secondary)
list = list.filter((e) => e.secondaryMuscles.includes(query.secondary));
if (query.q) {
const q = query.q.trim().toLowerCase();
list = list.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
e.targetLabel.toLowerCase().includes(q) ||
e.equipmentLabel.toLowerCase().includes(q) ||
e.bodyPartLabel.toLowerCase().includes(q),
);
}
if (query.sort === 'name') {
list = [...list].sort((a, b) => a.name.localeCompare(b.name));
}
const total = list.length;
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const start = (page - 1) * pageSize;
const pageItems = list.slice(start, start + pageSize).map(toSummary);
return { total, page, pageSize, items: pageItems };
}
findOne(id: string): Exercise {
const ex = this.exercises.find((e) => e.id === id);
if (!ex) throw new NotFoundException(`动作 ${id} 不存在`);
return ex;
}
private countBy(getKey: (e: Exercise) => string | string[]): Map<string, number> {
const map = new Map<string, number>();
for (const e of this.exercises) {
const k = getKey(e);
if (Array.isArray(k)) {
k.forEach((v) => map.set(v, (map.get(v) || 0) + 1));
} else if (k) {
map.set(k, (map.get(k) || 0) + 1);
}
}
return map;
}
private toCategory(map: Map<string, number>, labelMap?: Record<string, string>): CategoryItem[] {
return Array.from(map.entries())
.map(([value, count]) => ({
value,
label: labelMap ? labelOf(labelMap, value) : value,
count,
}))
.sort((a, b) => b.count - a.count);
}
getBodyPartCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.bodyPart), require('./data/labels').BODY_PART_LABELS);
}
getEquipmentCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.equipment), require('./data/labels').EQUIPMENT_LABELS);
}
getTargetCategories(): CategoryItem[] {
// 统计每个 target 对应的部位分布,取出现最多的部位作为分组依据
const bpByTarget = new Map<string, Map<string, number>>();
for (const e of this.exercises) {
if (!bpByTarget.has(e.target)) bpByTarget.set(e.target, new Map());
const m = bpByTarget.get(e.target)!;
m.set(e.bodyPart, (m.get(e.bodyPart) || 0) + 1);
}
const items = this.toCategory(this.countBy((e) => e.target), MUSCLE_LABELS);
const bpLabels = require('./data/labels').BODY_PART_LABELS;
return items.map((it) => {
const dist = bpByTarget.get(it.value);
let topBp = '';
let topCount = -1;
dist?.forEach((c, bp) => {
if (c > topCount) {
topCount = c;
topBp = bp;
}
});
return {
...it,
bodyPart: topBp,
bodyPartLabel: topBp ? labelOf(bpLabels, topBp) : undefined,
};
});
}
getMuscleGroupCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.muscleGroup), MUSCLE_LABELS);
}
getTypeCategories(): CategoryItem[] {
const counts = this.countBy((e) => e.type);
const labels: Record<string, string> = { strength: '力量', cardio: '有氧', flexibility: '柔韧' };
return Array.from(counts.entries()).map(([value, count]) => ({
value,
label: labels[value] || value,
count,
}));
}
/** 核心推荐:按目标肌肉群智能排序 */
recommend(dto: RecommendQueryDto): RecommendResult {
const target = dto.target;
const targetLabel = labelOf(MUSCLE_LABELS, target);
const equipment = dto.equipment;
const scored: RecommendItem[] = [];
for (const ex of this.exercises) {
let score = 0;
const reasons: string[] = [];
// 只有真正训练目标肌群的动作才进入推荐(器械仅作为加分项,不能凭空纳入无关动作)
const trainsTarget =
ex.target === target || ex.secondaryMuscles.includes(target);
if (!trainsTarget) continue;
if (ex.target === target) {
score += 100;
reasons.push(`${targetLabel}为主要训练目标`);
}
if (ex.secondaryMuscles.includes(target)) {
score += 40;
reasons.push(`可协同强化${targetLabel}`);
}
if (equipment) {
if (ex.equipment === equipment) {
score += 30;
reasons.push('适配你选择的器械');
} else if (ex.equipment === 'body weight') {
score += 10;
reasons.push('自重即可替代');
}
}
scored.push({
...toSummary(ex),
score,
reason: reasons.join(' · '),
});
}
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
const items = scored.slice(0, dto.limit || 20);
return { target, targetLabel, total: scored.length, items };
}
listCollections(): CollectionDef[] {
return COLLECTIONS;
}
getCollectionExercises(slug: string, page = 1, pageSize = 20) {
const def = findCollection(slug);
if (!def) throw new NotFoundException(`训练组合 ${slug} 不存在`);
let list = this.exercises;
if (def.filter.bodyParts)
list = list.filter((e) => def.filter.bodyParts!.includes(e.bodyPart));
if (def.filter.excludeBodyParts)
list = list.filter((e) => !def.filter.excludeBodyParts!.includes(e.bodyPart));
if (def.filter.equipment)
list = list.filter((e) => def.filter.equipment!.includes(e.equipment));
if (def.filter.type)
list = list.filter((e) => def.filter.type!.includes(e.type));
// 计划可对动作总数做上限,便于组合成一份可执行的计划
if (def.filter.limit && def.filter.limit > 0 && list.length > def.filter.limit) {
list = list.slice(0, def.filter.limit);
}
const total = list.length;
const start = (page - 1) * pageSize;
const items = list.slice(start, start + pageSize).map(toSummary);
return { collection: def, total, page, pageSize, items };
}
stats() {
return {
total: this.exercises.length,
bodyParts: this.getBodyPartCategories(),
equipment: this.getEquipmentCategories(),
types: this.getTypeCategories(),
};
}
}

View File

@@ -0,0 +1,86 @@
import {
RawExercise,
Exercise,
ExerciseSummary,
ExerciseType,
} from '../exercises.interface';
import {
BODY_PART_LABELS,
EQUIPMENT_LABELS,
MUSCLE_LABELS,
labelOf,
} from '../data/labels';
const MEDIA_BASE_URL = (
process.env.MEDIA_BASE_URL || 'https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main'
).replace(/\/$/, '');
function toMediaUrl(path: string): string {
if (!path) return '';
if (/^https?:\/\//.test(path)) return path;
return `${MEDIA_BASE_URL}/${path}`;
}
function deriveType(raw: RawExercise): ExerciseType {
if (raw.body_part === 'cardio') return 'cardio';
const hay = `${raw.name} ${raw.equipment} ${raw.category || ''}`.toLowerCase();
const flexKeywords = ['stretch', 'yoga', 'mobility', 'pilates', 'flexibility', 'foam roll'];
if (flexKeywords.some((k) => hay.includes(k))) return 'flexibility';
return 'strength';
}
const TYPE_LABELS: Record<ExerciseType, string> = {
strength: '力量',
cardio: '有氧',
flexibility: '柔韧',
};
export function normalize(raw: RawExercise): Exercise {
const type = deriveType(raw);
const zh = raw.instructions?.zh || '';
const en = raw.instructions?.en || '';
const zhSteps = raw.instruction_steps?.zh || [];
const enSteps = raw.instruction_steps?.en || [];
return {
id: raw.id,
name: raw.name,
bodyPart: raw.body_part,
bodyPartLabel: labelOf(BODY_PART_LABELS, raw.body_part),
equipment: raw.equipment,
equipmentLabel: labelOf(EQUIPMENT_LABELS, raw.equipment),
target: raw.target,
targetLabel: labelOf(MUSCLE_LABELS, raw.target),
muscleGroup: raw.muscle_group,
muscleGroupLabel: labelOf(MUSCLE_LABELS, raw.muscle_group),
secondaryMuscles: raw.secondary_muscles || [],
secondaryMusclesLabels: (raw.secondary_muscles || []).map(
(m) => labelOf(MUSCLE_LABELS, m),
),
type,
typeLabel: TYPE_LABELS[type],
image: toMediaUrl(raw.image),
gifUrl: toMediaUrl(raw.gif_url),
instructions: { zh, en },
instructionSteps: { zh: zhSteps, en: enSteps },
attribution: raw.attribution,
mediaId: raw.media_id,
};
}
export function toSummary(ex: Exercise): ExerciseSummary {
return {
id: ex.id,
name: ex.name,
bodyPart: ex.bodyPart,
bodyPartLabel: ex.bodyPartLabel,
equipment: ex.equipment,
equipmentLabel: ex.equipmentLabel,
target: ex.target,
targetLabel: ex.targetLabel,
type: ex.type,
typeLabel: ex.typeLabel,
image: ex.image,
gifUrl: ex.gifUrl,
};
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { FavoritesService } from './favorites.service';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
import { IsString } from 'class-validator';
class FavoriteBody {
@IsString()
target: string; // exerciseId 或 plan slug
}
@Controller('api/favorites')
@UseGuards(AuthGuard)
export class FavoritesController {
constructor(private readonly fav: FavoritesService) {}
// ---- 动作收藏 ----
@Get('exercises')
listExercises(@CurrentUser() u: { openid: string }) {
return this.fav.listExercises(u.openid);
}
@Post('exercises')
addExercise(@CurrentUser() u: { openid: string }, @Body() body: FavoriteBody) {
return this.fav.addExercise(u.openid, body.target);
}
@Get('exercises/:exerciseId/status')
exerciseStatus(@CurrentUser() u: { openid: string }, @Param('exerciseId') exerciseId: string) {
return this.fav.exerciseStatus(u.openid, exerciseId);
}
@Delete('exercises/:exerciseId')
removeExercise(@CurrentUser() u: { openid: string }, @Param('exerciseId') exerciseId: string) {
return this.fav.removeExercise(u.openid, exerciseId);
}
// ---- 计划收藏 ----
@Get('plans')
listPlans(@CurrentUser() u: { openid: string }) {
return this.fav.listPlans(u.openid);
}
@Post('plans')
addPlan(@CurrentUser() u: { openid: string }, @Body() body: FavoriteBody) {
return this.fav.addPlan(u.openid, body.target);
}
@Get('plans/:slug/status')
planStatus(@CurrentUser() u: { openid: string }, @Param('slug') slug: string) {
return this.fav.planStatus(u.openid, slug);
}
@Delete('plans/:slug')
removePlan(@CurrentUser() u: { openid: string }, @Param('slug') slug: string) {
return this.fav.removePlan(u.openid, slug);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { FavoritesService } from './favorites.service';
import { FavoritesController } from './favorites.controller';
import { ExercisesModule } from '../exercises/exercises.module';
@Module({
imports: [ExercisesModule],
controllers: [FavoritesController],
providers: [FavoritesService],
exports: [FavoritesService],
})
export class FavoritesModule {}

View File

@@ -0,0 +1,110 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { getStore } from '../common/store';
import { ExercisesService } from '../exercises/exercises.service';
import { toSummary } from '../exercises/utils/normalize';
import { ExerciseSummary, CollectionDef } from '../exercises/exercises.interface';
import { findCollection } from '../exercises/data/collections';
interface ExerciseFavorite {
id: string;
openid: string;
exerciseId: string;
createdAt: string;
}
interface PlanFavorite {
id: string;
openid: string;
slug: string;
createdAt: string;
}
@Injectable()
export class FavoritesService {
private exStore = getStore<ExerciseFavorite>('favorites-exercises');
private planStore = getStore<PlanFavorite>('favorites-plans');
constructor(private readonly exercises: ExercisesService) {}
// ---------- 动作收藏 ----------
listExercises(openid: string): ExerciseSummary[] {
const ids = this.exStore
.filter((f) => f.openid === openid)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.map((f) => f.exerciseId);
return ids
.map((id) => {
try {
return toSummary(this.exercises.findOne(id));
} catch {
return null;
}
})
.filter((x): x is ExerciseSummary => x !== null);
}
addExercise(openid: string, exerciseId: string) {
// 校验动作存在(不存在会抛 404
this.exercises.findOne(exerciseId);
const exists = this.exStore.find(
(f) => f.openid === openid && f.exerciseId === exerciseId,
);
if (!exists) {
this.exStore.insert({
id: randomUUID(),
openid,
exerciseId,
createdAt: new Date().toISOString(),
});
}
return { favorited: true, total: this.exStore.filter((f) => f.openid === openid).length };
}
removeExercise(openid: string, exerciseId: string) {
this.exStore.remove((f) => f.openid === openid && f.exerciseId === exerciseId);
return { favorited: false, total: this.exStore.filter((f) => f.openid === openid).length };
}
exerciseStatus(openid: string, exerciseId: string) {
return {
favorited: !!this.exStore.find(
(f) => f.openid === openid && f.exerciseId === exerciseId,
),
};
}
// ---------- 计划收藏(官方计划) ----------
listPlans(openid: string): CollectionDef[] {
return this.planStore
.filter((f) => f.openid === openid)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.map((f) => findCollection(f.slug))
.filter((c): c is CollectionDef => !!c);
}
addPlan(openid: string, slug: string) {
const def = findCollection(slug);
if (!def) throw new NotFoundException(`官方计划 ${slug} 不存在`);
const exists = this.planStore.find((f) => f.openid === openid && f.slug === slug);
if (!exists) {
this.planStore.insert({
id: randomUUID(),
openid,
slug,
createdAt: new Date().toISOString(),
});
}
return { favorited: true, total: this.planStore.filter((f) => f.openid === openid).length };
}
removePlan(openid: string, slug: string) {
this.planStore.remove((f) => f.openid === openid && f.slug === slug);
return { favorited: false, total: this.planStore.filter((f) => f.openid === openid).length };
}
planStatus(openid: string, slug: string) {
return {
favorited: !!this.planStore.find((f) => f.openid === openid && f.slug === slug),
};
}
}

34
backend/src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env.CORS_ENABLED !== 'false') {
app.enableCors();
}
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
transformOptions: { enableImplicitConversion: true },
}),
);
const port = parseInt(process.env.PORT || '3000', 10);
await app.listen(port);
console.log(`🏋️ 健身动作推荐后端已启动: http://localhost:${port}`);
console.log(` · 动作列表 GET /api/exercises`);
console.log(` · 动作详情 GET /api/exercises/:id`);
console.log(` · 智能推荐 GET /api/recommend?target=biceps`);
console.log(` · 训练组合 GET /api/collections`);
console.log(` · 分类元数据 GET /api/categories/*`);
console.log(` · 微信登录 POST /api/auth/login`);
console.log(` · 我的资料 GET /api/auth/me | PATCH /api/users/me`);
console.log(` · 动作收藏 GET/POST/DELETE /api/favorites/exercises`);
console.log(` · 计划收藏 GET/POST/DELETE /api/favorites/plans`);
console.log(` · 我的计划 GET/POST /api/plans | 官方计划 GET /api/plans/official`);
}
bootstrap();

View File

@@ -0,0 +1,56 @@
import { IsString, IsOptional, IsArray } from 'class-validator';
import { PlanLevel } from '../../auth/dto/login.dto';
export class CreatePlanDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
emoji?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
exerciseIds?: string[];
}
export class UpdatePlanDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
emoji?: string;
@IsOptional()
@IsString()
color?: string;
}
export class AddExerciseDto {
@IsString()
exerciseId: string;
}
export class ImportPlanDto {
@IsString()
slug: string;
@IsOptional()
@IsString()
name?: string;
}

View File

@@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { PlansService } from './plans.service';
import { ExercisesService } from '../exercises/exercises.service';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
import { CreatePlanDto, UpdatePlanDto, AddExerciseDto, ImportPlanDto } from './dto/create-plan.dto';
import { CollectionDef, PlanLevel, OfficialPlanGroup } from '../exercises/exercises.interface';
const LEVEL_ORDER: PlanLevel[] = ['beginner', 'intermediate', 'advanced', 'all'];
const LEVEL_LABEL: Record<PlanLevel, string> = {
beginner: '新手入门',
intermediate: '进阶提升',
advanced: '高级挑战',
all: '全阶段适用',
};
@Controller('api/plans')
@UseGuards(AuthGuard)
export class PlansController {
constructor(
private readonly plans: PlansService,
private readonly exercises: ExercisesService,
) {}
@Get()
list(@CurrentUser() u: { openid: string }) {
return this.plans.list(u.openid);
}
@Post()
create(@CurrentUser() u: { openid: string }, @Body() dto: CreatePlanDto) {
return this.plans.create(u.openid, dto);
}
@Post('import')
importFromOfficial(@CurrentUser() u: { openid: string }, @Body() dto: ImportPlanDto) {
return this.plans.importFromOfficial(u.openid, dto);
}
@Get('official')
official() {
const all = this.exercises.listCollections();
const groups: OfficialPlanGroup[] = LEVEL_ORDER.map((level) => ({
level,
label: LEVEL_LABEL[level],
plans: all.filter((c) => (c.level || 'all') === level),
})).filter((g) => g.plans.length > 0);
return { groups };
}
@Get('official/:slug/exercises')
officialExercises(
@Param('slug') slug: string,
@Query('page') page = 1,
@Query('pageSize') pageSize = 20,
) {
return this.exercises.getCollectionExercises(slug, Number(page), Number(pageSize));
}
@Get(':id')
get(@CurrentUser() u: { openid: string }, @Param('id') id: string) {
return this.plans.get(u.openid, id);
}
@Patch(':id')
update(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Body() dto: UpdatePlanDto,
) {
return this.plans.update(u.openid, id, dto);
}
@Delete(':id')
remove(@CurrentUser() u: { openid: string }, @Param('id') id: string) {
return this.plans.remove(u.openid, id);
}
@Post(':id/exercises')
addExercise(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Body() dto: AddExerciseDto,
) {
return this.plans.addExercise(u.openid, id, dto.exerciseId);
}
@Delete(':id/exercises/:exerciseId')
removeExercise(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Param('exerciseId') exerciseId: string,
) {
return this.plans.removeExercise(u.openid, id, exerciseId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PlansService } from './plans.service';
import { PlansController } from './plans.controller';
import { ExercisesModule } from '../exercises/exercises.module';
@Module({
imports: [ExercisesModule],
controllers: [PlansController],
providers: [PlansService],
exports: [PlansService],
})
export class PlansModule {}

View File

@@ -0,0 +1,164 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { getStore } from '../common/store';
import { ExercisesService } from '../exercises/exercises.service';
import { toSummary } from '../exercises/utils/normalize';
import { ExerciseSummary } from '../exercises/exercises.interface';
import { CreatePlanDto, UpdatePlanDto, ImportPlanDto } from './dto/create-plan.dto';
export interface PlanRecord {
id: string;
openid: string;
name: string;
description: string;
emoji: string;
color: string;
exerciseIds: string[];
createdAt: string;
updatedAt: string;
}
export interface PlanSummary {
id: string;
name: string;
description: string;
emoji: string;
color: string;
exerciseCount: number;
coverExerciseId?: string;
createdAt: string;
updatedAt: string;
}
export interface PlanDetail extends PlanSummary {
exercises: ExerciseSummary[];
}
const DEFAULT_EMOJI = '📋';
const PALETTE = ['#D9B36C', '#58C9B9', '#9B72FF', '#FF6B6B', '#4D96FF', '#6BCB77'];
@Injectable()
export class PlansService {
private store = getStore<PlanRecord>('plans');
constructor(private readonly exercises: ExercisesService) {}
private toSummary(p: PlanRecord): PlanSummary {
return {
id: p.id,
name: p.name,
description: p.description,
emoji: p.emoji,
color: p.color,
exerciseCount: p.exerciseIds.length,
coverExerciseId: p.exerciseIds[0],
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
}
list(openid: string): PlanSummary[] {
return this.store
.filter((p) => p.openid === openid)
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
.map((p) => this.toSummary(p));
}
create(openid: string, dto: CreatePlanDto): PlanDetail {
const now = new Date().toISOString();
const ids = (dto.exerciseIds || []).filter((id) => this.safeExists(id));
const plan: PlanRecord = {
id: randomUUID(),
openid,
name: dto.name.trim() || '我的训练计划',
description: dto.description?.trim() || '',
emoji: dto.emoji || DEFAULT_EMOJI,
color: dto.color || PALETTE[this.store.all().length % PALETTE.length],
exerciseIds: ids,
createdAt: now,
updatedAt: now,
};
this.store.insert(plan);
return this.toDetail(plan);
}
private getOwned(openid: string, id: string): PlanRecord {
const p = this.store.find((x) => x.id === id);
if (!p) throw new NotFoundException(`计划 ${id} 不存在`);
if (p.openid !== openid) throw new ForbiddenException('无权访问该计划');
return p;
}
get(openid: string, id: string): PlanDetail {
return this.toDetail(this.getOwned(openid, id));
}
private toDetail(p: PlanRecord): PlanDetail {
const exercises = p.exerciseIds
.map((id) => {
try {
return toSummary(this.exercises.findOne(id));
} catch {
return null;
}
})
.filter((x): x is ExerciseSummary => x !== null);
return { ...this.toSummary(p), exercises };
}
update(openid: string, id: string, dto: UpdatePlanDto): PlanDetail {
const patch: Partial<PlanRecord> = { updatedAt: new Date().toISOString() };
if (dto.name !== undefined) patch.name = dto.name.trim() || '我的训练计划';
if (dto.description !== undefined) patch.description = dto.description.trim();
if (dto.emoji !== undefined) patch.emoji = dto.emoji;
if (dto.color !== undefined) patch.color = dto.color;
const updated = this.store.update((x) => x.id === id && x.openid === openid, patch);
if (!updated) throw new NotFoundException(`计划 ${id} 不存在`);
return this.toDetail(updated as PlanRecord);
}
remove(openid: string, id: string) {
const p = this.getOwned(openid, id);
this.store.remove((x) => x.id === p.id);
return { deleted: true };
}
addExercise(openid: string, id: string, exerciseId: string): PlanDetail {
const p = this.getOwned(openid, id);
this.exercises.findOne(exerciseId); // 校验存在
if (!p.exerciseIds.includes(exerciseId)) {
p.exerciseIds.push(exerciseId);
this.store.update((x) => x.id === id, { exerciseIds: p.exerciseIds, updatedAt: new Date().toISOString() });
}
return this.toDetail(p);
}
removeExercise(openid: string, id: string, exerciseId: string): PlanDetail {
const p = this.getOwned(openid, id);
p.exerciseIds = p.exerciseIds.filter((x) => x !== exerciseId);
this.store.update((x) => x.id === id, { exerciseIds: p.exerciseIds, updatedAt: new Date().toISOString() });
return this.toDetail(p);
}
/** 从官方计划一键导入为我的计划 */
importFromOfficial(openid: string, dto: ImportPlanDto): PlanDetail {
const res = this.exercises.getCollectionExercises(dto.slug, 1, 999);
const ids = res.items.map((e) => e.id);
return this.create(openid, {
name: dto.name?.trim() || `${res.collection.name} · 我的副本`,
description: res.collection.description,
emoji: res.collection.emoji,
color: res.collection.color,
exerciseIds: ids,
});
}
private safeExists(id: string): boolean {
try {
this.exercises.findOne(id);
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Patch, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateProfileDto } from '../auth/dto/login.dto';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
@Controller('api/users')
@UseGuards(AuthGuard)
export class UsersController {
constructor(private readonly users: UsersService) {}
@Patch('me')
update(
@CurrentUser() user: { openid: string },
@Body() dto: UpdateProfileDto,
) {
return this.users.update(user.openid, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { getStore } from '../common/store';
export interface UserRecord {
openid: string;
nickname: string;
avatar: string;
createdAt: string;
updatedAt: string;
}
@Injectable()
export class UsersService {
private store = getStore<UserRecord>('users');
findByOpenid(openid: string): UserRecord | undefined {
return this.store.find((u) => u.openid === openid);
}
create(input: { openid: string; nickname?: string; avatar?: string }): UserRecord {
const now = new Date().toISOString();
const user: UserRecord = {
openid: input.openid,
nickname: input.nickname?.trim() || '健身学员',
avatar: input.avatar || '🏃',
createdAt: now,
updatedAt: now,
};
return this.store.insert(user);
}
update(openid: string, patch: { nickname?: string; avatar?: string }): UserRecord {
const now = new Date().toISOString();
const updated = this.store.update(
(u) => u.openid === openid,
{
...(patch.nickname !== undefined ? { nickname: patch.nickname.trim() || '健身学员' } : {}),
...(patch.avatar !== undefined ? { avatar: patch.avatar } : {}),
updatedAt: now,
},
);
return updated as UserRecord;
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

23
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
},
"exclude": ["node_modules", "dist"]
}

47
miniprogram/README.md Normal file
View File

@@ -0,0 +1,47 @@
# FITCOACH 微信小程序(前端)
高端暗金风格dark luxe的健身动作推荐小程序。所有数据来自 NestJS 后端 API。
## 设计系统
设计令牌定义在 `app.wxss``page` 选择器CSS 变量):
- 背景 `#0E1110`、表面 `#171C19`、金 `#D9B36C`、次强调 `#58C9B9`
- 圆角 `--radius:28rpx`、发丝分隔线 `--line`、阴影 `--shadow`
- 图标统一使用 **emoji + CSS 渐变**(不依赖二进制图片资源)
## 配置
打开 `config.js`
```js
module.exports = { BASE_URL: 'http://localhost:3000' };
```
- 本地调试:填写电脑局域网 IP`http://192.168.1.10:3000`
- 真机/发布:**必须**改为 HTTPS 且已在小程序后台配置为 request 合法域名
## 运行
1. 微信开发者工具 → 导入项目 → 选择本目录AppID 可用测试号)
2. 本地调试可在「详情 → 本地设置」勾选「不校验合法域名、TLS 版本以及 HTTPS 证书」
3. 编译预览
## 页面
| 页面 | 文件 | 说明 |
|------|------|------|
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 训练组合 + 按部位/器械浏览 |
| 分类浏览 | `pages/category` | 按 dimension(bodyPart/equipment/target/type/muscleGroup) 筛选列表 |
| 智能推荐 | `pages/recommend` | 选目标肌群(按部位分组)+ 可选器械 → 评分排序结果 |
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 相关推荐 + 分享 |
| 训练组合 | `pages/collection` | 组合列表(推/拉/腿/核心/上肢/全身/居家/有氧) |
| 组合详情 | `pages/collection-detail` | 某组合下的动作 |
| 搜索 | `pages/search` | 名称/部位/器械/目标模糊搜索 |
## 组件
`exercise-card`(动作卡)、`section-header`(分区标题)、`chip`(筛选标签)、
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航)。
## 接口映射
`services/exercise.js` 直接映射后端 7 类接口(`listExercises` / `getExercise` /
`getCategories` / `recommend` / `listCollections` / `getCollectionExercises` /
`search` / `getStats`),字段与后端响应一一对应。
## 注意事项
- 小程序 `<image>` 支持 GIF动作演示直接使用 `gifUrl`
- 所有请求经 `utils/request.js` 单点封装,失败统一 toast。
- 包体保持精简:主包仅含页面与组件,无大体积本地资源。

26
miniprogram/app.js Normal file
View File

@@ -0,0 +1,26 @@
const { BASE_URL } = require('./config');
App({
globalData: {
// 状态栏高度px由 onLaunch 读取,供自定义导航栏计算顶部留白
statusBarHeight: 20,
// 后端基础地址
baseUrl: BASE_URL
},
onLaunch() {
// 读取窗口信息以获取状态栏高度(兼容新旧 API
try {
const info = (typeof wx.getWindowInfo === 'function')
? wx.getWindowInfo()
: wx.getSystemInfoSync();
this.globalData.statusBarHeight = info.statusBarHeight || 20;
} catch (e) {
this.globalData.statusBarHeight = 20;
}
// 可选:静默登录(占位,不请求真实后端)
// const { getOpenid } = require('./utils/auth');
// getOpenid();
}
});

19
miniprogram/app.json Normal file
View File

@@ -0,0 +1,19 @@
{
"pages": [
"pages/index/index",
"pages/category/category",
"pages/recommend/recommend",
"pages/detail/detail",
"pages/collection/collection",
"pages/collection-detail/collection-detail",
"pages/search/search"
],
"window": {
"navigationStyle": "custom",
"backgroundColor": "#0E1110",
"navigationBarTextStyle": "white",
"navigationBarTitleText": "FITCOACH"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

88
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,88 @@
/* ===== FITCOACH 设计系统:高端暗金奢华主题 ===== */
page {
/* 设计令牌 */
--bg: #0E1110;
--bg-grad: linear-gradient(160deg, #16201B 0%, #0E1110 55%);
--surface: #171C19;
--surface-2: #1F2622;
--gold: #D9B36C;
--gold-2: #F0D9A8;
--gold-soft: rgba(217,179,108,0.12);
--gold-line: rgba(217,179,108,0.28);
--teal: #58C9B9;
--text: #F3F5F2;
--text-2: #98A39B;
--text-3: #6B746D;
--line: rgba(255,255,255,0.07);
--radius: 28rpx;
--radius-sm: 18rpx;
--shadow: 0 12rpx 40rpx rgba(0,0,0,0.35);
background: var(--bg);
color: var(--text);
font-family: -apple-system, "PingFang SC", "Helvetica Neue", Helvetica, sans-serif;
font-size: 28rpx;
line-height: 1.5;
}
view, text, scroll-view, image, input {
box-sizing: border-box;
}
/* ===== 通用工具类 ===== */
.eyebrow {
font-size: 22rpx;
letter-spacing: 4rpx;
color: var(--gold);
text-transform: uppercase;
font-weight: 600;
}
.section {
padding: 40rpx 32rpx;
}
.section + .section {
border-top: 1rpx solid var(--line);
}
.card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1rpx solid var(--line);
}
.chip {
display: inline-flex;
align-items: center;
padding: 10rpx 24rpx;
border-radius: 999rpx;
background: var(--surface-2);
color: var(--text-2);
font-size: 24rpx;
border: 1rpx solid var(--line);
}
.gold-text { color: var(--gold); }
.muted { color: var(--text-2); }
.row { display: flex; flex-direction: row; align-items: center; }
.col { display: flex; flex-direction: column; }
.empty {
color: var(--text-3);
font-size: 26rpx;
text-align: center;
padding: 80rpx 0;
}
.tag {
font-size: 20rpx;
color: var(--text-2);
background: var(--surface-2);
padding: 4rpx 12rpx;
border-radius: 999rpx;
}
.tag--gold {
color: var(--gold-2);
background: var(--gold-soft);
}

View File

@@ -0,0 +1,20 @@
Component({
properties: {
active: { type: String, value: 'home' } // 'home' | 'category' | 'plan'
},
data: {
items: [
{ key: 'home', label: '首页', emoji: '🏠', page: '/pages/index/index' },
{ key: 'category', label: '分类', emoji: '📚', page: '/pages/category/category' },
{ key: 'plan', label: '计划', emoji: '📋', page: '/pages/collection/collection' }
]
},
methods: {
onTap(e) {
const page = e.currentTarget.dataset.page;
if (!page) return;
// 使用 reLaunch 重置页面栈,避免页面层级堆叠
wx.reLaunch({ url: page });
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,11 @@
<view class="bn">
<view
wx:for="{{items}}"
wx:key="key"
class="bn__item {{active === item.key ? 'bn__item--active' : ''}}"
data-page="{{item.page}}"
bindtap="onTap">
<text class="bn__emoji">{{item.emoji}}</text>
<text class="bn__label">{{item.label}}</text>
</view>
</view>

View File

@@ -0,0 +1,22 @@
.bn {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
background: rgba(20,26,22,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: env(safe-area-inset-bottom);
z-index: 60;
}
.bn__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 0 14rpx;
color: var(--text-3);
}
.bn__item--active { color: var(--gold); }
.bn__emoji { font-size: 40rpx; line-height: 1; }
.bn__label { font-size: 22rpx; margin-top: 6rpx; }
.bn__item--active .bn__label { color: var(--gold-2); }

View File

@@ -0,0 +1,16 @@
Component({
properties: {
label: { type: String, value: '' },
active: { type: Boolean, value: false },
color: { type: String, value: '' },
value: { type: String, value: '' }
},
methods: {
onTap() {
this.triggerEvent('tap', {
value: this.data.value,
label: this.data.label
});
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,6 @@
<view
class="chip {{active ? 'chip--active' : ''}}"
style="{{color ? 'border-color:' + color + '66; color:' + color : ''}}"
bindtap="onTap">
<text>{{label}}</text>
</view>

View File

@@ -0,0 +1,18 @@
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12rpx 26rpx;
border-radius: 999rpx;
background: var(--surface-2);
color: var(--text-2);
font-size: 24rpx;
border: 1rpx solid var(--line);
transition: all .15s ease;
white-space: nowrap;
}
.chip--active {
background: var(--gold-soft);
color: var(--gold-2);
border-color: var(--gold-line);
}

View File

@@ -0,0 +1,14 @@
Component({
properties: {
exercise: { type: Object, value: {} },
score: { type: Number, value: 0 },
reason: { type: String, value: '' },
compact: { type: Boolean, value: false }
},
methods: {
onTap() {
const ex = this.data.exercise || {};
this.triggerEvent('tap', { id: ex.id, exercise: ex });
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,21 @@
<view class="ex-card {{compact ? 'ex-card--compact' : ''}}" bindtap="onTap">
<view class="ex-card__media">
<image
wx:if="{{exercise.gifUrl || exercise.image}}"
class="ex-card__img"
src="{{exercise.gifUrl || exercise.image}}"
mode="aspectFill"
lazy-load="true" />
<view wx:else class="ex-card__ph">🏋️</view>
<view wx:if="{{exercise.typeLabel}}" class="ex-card__type">{{exercise.typeLabel}}</view>
<view wx:if="{{score > 0}}" class="ex-card__score">匹配 {{score}}</view>
</view>
<view class="ex-card__body">
<view class="ex-card__name">{{exercise.name}}</view>
<view class="ex-card__chips">
<text wx:if="{{exercise.targetLabel}}" class="tag tag--gold">{{exercise.targetLabel}}</text>
<text wx:if="{{exercise.equipmentLabel}}" class="tag">{{exercise.equipmentLabel}}</text>
</view>
<view wx:if="{{reason}}" class="ex-card__reason">{{reason}}</view>
</view>
</view>

View File

@@ -0,0 +1,40 @@
.ex-card {
background: var(--surface);
border-radius: var(--radius-sm);
overflow: hidden;
border: 1rpx solid var(--line);
width: 100%;
}
.ex-card__media {
position: relative;
width: 100%;
height: 200rpx;
background: var(--surface-2);
}
.ex-card--compact .ex-card__media { height: 160rpx; }
.ex-card__img { width: 100%; height: 100%; display: block; }
.ex-card__ph {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 64rpx;
}
.ex-card__type {
position: absolute; left: 12rpx; top: 12rpx;
font-size: 20rpx; color: var(--text-2);
background: rgba(0,0,0,0.45); padding: 4rpx 14rpx; border-radius: 999rpx;
}
.ex-card__score {
position: absolute; right: 12rpx; top: 12rpx;
font-size: 20rpx; color: #0E1110; font-weight: 700;
background: var(--gold); padding: 4rpx 14rpx; border-radius: 999rpx;
}
.ex-card__body { padding: 16rpx 18rpx 20rpx; }
.ex-card__name {
font-size: 28rpx; font-weight: 600; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ex-card__chips { display: flex; gap: 10rpx; margin-top: 12rpx; flex-wrap: wrap; }
.ex-card__reason {
margin-top: 12rpx; font-size: 22rpx; color: var(--text-3);
line-height: 1.45;
}

View File

@@ -0,0 +1,27 @@
const app = getApp();
Component({
properties: {
title: { type: String, value: '' },
showBack: { type: Boolean, value: false }
},
data: {
statusBarHeight: 20
},
lifetimes: {
attached() {
const h = (app.globalData && app.globalData.statusBarHeight) || 20;
this.setData({ statusBarHeight: h });
}
},
methods: {
onBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
wx.navigateBack();
} else {
wx.reLaunch({ url: '/pages/index/index' });
}
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,7 @@
<view class="navbar">
<view class="navbar__status" style="height: {{statusBarHeight}}px;"></view>
<view class="navbar__bar">
<view wx:if="{{showBack}}" class="navbar__back" bindtap="onBack"></view>
<view wx:if="{{title}}" class="navbar__title">{{title}}</view>
</view>
</view>

View File

@@ -0,0 +1,29 @@
.navbar {
background: linear-gradient(180deg, rgba(22,32,27,0.96) 0%, rgba(14,17,16,0.55) 100%);
position: sticky;
top: 0;
z-index: 50;
}
.navbar__status { width: 100%; }
.navbar__bar {
height: 88rpx;
display: flex;
align-items: center;
position: relative;
padding: 0 28rpx;
}
.navbar__back {
font-size: 56rpx;
color: var(--gold);
line-height: 1;
width: 60rpx;
}
.navbar__title {
position: absolute;
left: 0; right: 0;
text-align: center;
font-size: 34rpx;
font-weight: 700;
letter-spacing: 2rpx;
color: var(--text);
}

View File

@@ -0,0 +1,13 @@
Component({
properties: {
title: { type: String, value: '' },
subtitle: { type: String, value: '' },
eyebrow: { type: String, value: '' },
action: { type: String, value: '' }
},
methods: {
onAction() {
this.triggerEvent('action');
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,8 @@
<view class="sh">
<view class="sh__main">
<view wx:if="{{eyebrow}}" class="eyebrow">{{eyebrow}}</view>
<view class="sh__title">{{title}}</view>
<view wx:if="{{subtitle}}" class="sh__sub">{{subtitle}}</view>
</view>
<view wx:if="{{action}}" class="sh__action" bindtap="onAction">{{action}} </view>
</view>

View File

@@ -0,0 +1,23 @@
.sh {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 8rpx 0 20rpx;
}
.sh__title {
font-size: 40rpx;
font-weight: 700;
letter-spacing: 2rpx;
color: var(--text);
margin-top: 8rpx;
}
.sh__sub {
font-size: 24rpx;
color: var(--text-2);
margin-top: 8rpx;
}
.sh__action {
font-size: 26rpx;
color: var(--gold);
white-space: nowrap;
}

7
miniprogram/config.js Normal file
View File

@@ -0,0 +1,7 @@
// 后端基础地址配置。
// ⚠️ 生产环境必须替换为已在「微信公众平台 -> 开发 -> 开发设置 -> 服务器域名」
// 中配置的 HTTPS 合法域名request 合法域名),否则真机无法发起请求。
// localhost 仅用于开发者工具本地联调。
module.exports = {
BASE_URL: 'http://localhost:3000'
};

View File

@@ -0,0 +1,89 @@
const app = getApp();
const svc = require('../../services/exercise');
const DIM_MAP = {
bodyPart: 'body-parts',
equipment: 'equipment',
target: 'targets',
muscleGroup: 'muscle-groups',
type: 'types'
};
function labelForDim(d) {
return ({
bodyPart: '按部位',
equipment: '按器械',
target: '按目标',
muscleGroup: '按肌群',
type: '按类型'
})[d] || '分类';
}
Page({
data: {
statusBarHeight: 20,
dimension: 'bodyPart',
title: '分类',
value: '',
values: [],
activeValue: '',
exercises: [],
loading: true,
page: 1,
pageSize: 20,
total: 0
},
onLoad(query) {
const dimension = query.dimension || 'bodyPart';
const value = query.value || '';
const title = query.title || labelForDim(dimension);
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 20,
dimension,
value,
title
});
this.loadValues();
},
loadValues() {
this.setData({ loading: true });
const key = DIM_MAP[this.data.dimension] || 'body-parts';
svc.getCategories(key).then((items) => {
this.setData({
values: items || [],
activeValue: this.data.value,
loading: false
});
this.loadExercises();
}).catch(() => {
this.setData({ loading: false });
});
},
loadExercises() {
this.setData({ loading: true });
const params = { page: this.data.page, pageSize: this.data.pageSize };
params[this.data.dimension] = this.data.activeValue;
svc.listExercises(params).then((res) => {
this.setData({
exercises: (res && res.items) || [],
total: (res && res.total) || 0,
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
onValue(e) {
const value = e.detail.value;
if (value === this.data.activeValue) return;
this.setData({ activeValue: value, page: 1 }, () => this.loadExercises());
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
}
});

View File

@@ -0,0 +1,9 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"chip": "/components/chip/index",
"exercise-card": "/components/exercise-card/index",
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,29 @@
<view class="page">
<navbar title="{{title}}" show-back />
<view class="section" style="padding-top: 8rpx;">
<scroll-view wx:if="{{values.length}}" scroll-x class="filterbar">
<chip
wx:for="{{values}}"
wx:key="value"
label="{{item.label}}"
value="{{item.value}}"
active="{{activeValue === item.value}}"
bind:tap="onValue" />
<view class="filterbar__spacer"></view>
</scroll-view>
<view class="count muted" wx:if="{{!loading}}">共 {{total}} 个动作</view>
<view class="grid" wx:if="{{exercises.length}}">
<view class="grid__item" wx:for="{{exercises}}" wx:key="id">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
</view>
</view>
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="category" />
</view>

View File

@@ -0,0 +1,8 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.filterbar { white-space: nowrap; padding: 8rpx 0 4rpx; }
.filterbar chip { display: inline-block; margin-right: 16rpx; }
.filterbar__spacer { display: inline-block; width: 4rpx; }
.count { font-size: 24rpx; margin: 20rpx 0 8rpx; }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; margin-top: 12rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }

View File

@@ -0,0 +1,42 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
slug: '',
collection: null,
exercises: [],
loading: true,
page: 1,
pageSize: 30,
total: 0
},
onLoad(query) {
const slug = query.slug || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, slug });
this.load();
},
load() {
this.setData({ loading: true });
svc.getCollectionExercises(this.data.slug, {
page: this.data.page,
pageSize: this.data.pageSize
}).then((res) => {
this.setData({
collection: res.collection,
exercises: (res && res.items) || [],
total: (res && res.total) || 0,
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
}
}

View File

@@ -0,0 +1,24 @@
<view class="page" wx:if="{{collection}}">
<navbar title="{{collection.name}}" show-back />
<view class="header" style="background: linear-gradient(135deg, {{collection.color}}26, var(--surface));">
<view class="header__emoji" style="background: {{collection.color}}22; color: {{collection.color}};">{{collection.emoji}}</view>
<view class="header__name">{{collection.name}}</view>
<view class="header__desc">{{collection.description}}</view>
<view class="header__count" style="color: {{collection.color}};">共 {{total}} 个动作</view>
</view>
<view class="section" style="padding-top: 8rpx;">
<view class="grid" wx:if="{{exercises.length}}">
<view class="grid__item" wx:for="{{exercises}}" wx:key="id">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
</view>
</view>
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
<view wx:else class="empty">加载中…</view>
</view>
</view>
<view wx:else class="empty-page">
<view class="empty">{{ loading ? '加载中…' : '未找到该训练组合' }}</view>
</view>

View File

@@ -0,0 +1,14 @@
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 60rpx; }
.header { padding: 32rpx; border-bottom: 1rpx solid var(--line); }
.header__emoji {
width: 120rpx; height: 120rpx; border-radius: 28rpx;
display: flex; align-items: center; justify-content: center; font-size: 64rpx;
}
.header__name { font-size: 44rpx; font-weight: 700; margin-top: 20rpx; }
.header__desc { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; line-height: 1.5; }
.header__count { font-size: 24rpx; margin-top: 16rpx; }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-page { min-height: 100vh; background: var(--bg-grad); }

View File

@@ -0,0 +1,40 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
collections: [],
counts: {},
loading: true
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
this.load();
},
load() {
this.setData({ loading: true });
svc.listCollections().then((list) => {
const collections = list || [];
this.setData({ collections, loading: false });
// 异步获取每个训练组合的动作数量
collections.forEach(c => {
svc.getCollectionExercises(c.slug, { pageSize: 1 }).then((res) => {
const key = 'counts.' + c.slug;
this.setData({ [key]: (res && res.total) || 0 });
}).catch(() => {});
});
}).catch(() => {
this.setData({ loading: false });
});
},
onOpen(e) {
wx.navigateTo({
url: '/pages/collection-detail/collection-detail?slug=' +
encodeURIComponent(e.currentTarget.dataset.slug)
});
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,31 @@
<view class="page">
<navbar title="训练计划" />
<view class="section" style="padding-top: 8rpx;">
<view class="intro">
<view class="intro__eyebrow">PROGRAMS</view>
<view class="intro__title">挑选你的训练主题</view>
<view class="intro__sub">从推、拉、腿到居家无器械,一键开启</view>
</view>
<view class="list" wx:if="{{collections.length}}">
<view
wx:for="{{collections}}"
wx:key="slug"
class="col-card"
style="background: linear-gradient(135deg, {{item.color}}26, var(--surface)); border-color: {{item.color}}55;"
data-slug="{{item.slug}}"
bindtap="onOpen">
<view class="col-card__emoji" style="background: {{item.color}}22;">{{item.emoji}}</view>
<view class="col-card__main">
<view class="col-card__name">{{item.name}}</view>
<view class="col-card__desc">{{item.description}}</view>
</view>
<view class="col-card__count" style="color: {{item.color}};">{{counts[item.slug] || ''}} 个动作 </view>
</view>
</view>
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="plan" />
</view>

View File

@@ -0,0 +1,22 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.intro { padding: 16rpx 0 28rpx; }
.intro__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
.intro__title { font-size: 44rpx; font-weight: 700; margin-top: 12rpx; }
.intro__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
.list { display: flex; flex-direction: column; gap: 20rpx; }
.col-card {
display: flex; align-items: center; gap: 24rpx;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius); padding: 28rpx;
}
.col-card__emoji {
width: 96rpx; height: 96rpx; border-radius: 24rpx;
display: flex; align-items: center; justify-content: center;
font-size: 52rpx; flex: 0 0 auto;
}
.col-card__main { flex: 1; min-width: 0; }
.col-card__name { font-size: 34rpx; font-weight: 700; color: var(--text); }
.col-card__desc { font-size: 24rpx; color: var(--text-2); margin-top: 8rpx; line-height: 1.4; }
.col-card__count { font-size: 24rpx; flex: 0 0 auto; }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }

View File

@@ -0,0 +1,67 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
id: '',
exercise: null,
related: [],
loading: true
},
onLoad(query) {
const id = query.id || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
this.load();
},
load() {
this.setData({ loading: true });
svc.getExercise(this.data.id).then((ex) => {
this.setData({ exercise: ex, loading: false });
if (ex && ex.target) {
svc.listExercises({ target: ex.target, pageSize: 6 }).then((res) => {
const related = ((res && res.items) || [])
.filter(i => i.id !== ex.id)
.slice(0, 6);
this.setData({ related });
}).catch(() => {});
}
}).catch(() => {
this.setData({ loading: false });
});
},
onAdd() {
wx.showToast({ title: '已加入今日训练', icon: 'success' });
},
onBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
wx.navigateBack();
} else {
wx.reLaunch({ url: '/pages/index/index' });
}
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
},
onShareAppMessage() {
const ex = this.data.exercise || {};
return {
title: ex.name ? ('FITCOACH · ' + ex.name) : 'FITCOACH 智能健身教练',
path: '/pages/detail/detail?id=' + this.data.id
};
},
onShareTimeline() {
return {
title: 'FITCOACH · 精准训练推荐',
query: 'id=' + this.data.id
};
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
}
}

View File

@@ -0,0 +1,72 @@
<view class="page" wx:if="{{exercise}}">
<navbar title="{{exercise.name}}" show-back />
<view class="hero">
<image
wx:if="{{exercise.gifUrl || exercise.image}}"
class="hero__img"
src="{{exercise.gifUrl || exercise.image}}"
mode="aspectFill" />
<view wx:else class="hero__ph">🏋️</view>
</view>
<view class="body section">
<view class="name">{{exercise.name}}</view>
<view class="metas">
<view class="meta"><view class="meta__k">部位</view><view class="meta__v">{{exercise.bodyPartLabel}}</view></view>
<view class="meta"><view class="meta__k">器械</view><view class="meta__v">{{exercise.equipmentLabel}}</view></view>
<view class="meta"><view class="meta__k">目标</view><view class="meta__v gold-text">{{exercise.targetLabel}}</view></view>
<view class="meta"><view class="meta__k">类型</view><view class="meta__v">{{exercise.typeLabel}}</view></view>
</view>
<view class="block" wx:if="{{exercise.muscleGroupLabel}}">
<view class="block__h eyebrow">主要肌群</view>
<view class="chips">
<text class="tag tag--gold">{{exercise.muscleGroupLabel}}</text>
</view>
</view>
<view class="block" wx:if="{{exercise.secondaryMusclesLabels.length}}">
<view class="block__h eyebrow">协同肌群</view>
<view class="chips">
<text wx:for="{{exercise.secondaryMusclesLabels}}" wx:key="*this" class="tag">{{item}}</text>
</view>
</view>
<view class="block" wx:if="{{exercise.instructionSteps.zh.length}}">
<view class="block__h eyebrow">动作步骤</view>
<view class="steps">
<view class="step" wx:for="{{exercise.instructionSteps.zh}}" wx:key="index">
<view class="step__n">{{index + 1}}</view>
<view class="step__t">{{item}}</view>
</view>
</view>
</view>
<view class="block" wx:if="{{exercise.instructions.zh}}">
<view class="block__h eyebrow">要点提示</view>
<view class="tips">{{exercise.instructions.zh}}</view>
</view>
</view>
<view class="section" wx:if="{{related.length}}">
<view class="block__h eyebrow">相关推荐</view>
<scroll-view scroll-x class="hscroll">
<view class="hscroll__item" wx:for="{{related}}" wx:key="id">
<exercise-card exercise="{{item}}" compact bind:tap="onExercise" />
</view>
</scroll-view>
</view>
<view class="addbar">
<view class="cta" bindtap="onAdd">加入今日训练</view>
</view>
</view>
<view wx:else class="empty-page">
<view class="topbar" style="padding-top: {{statusBarHeight}}px;">
<view class="topbar__back" bindtap="onBack"></view>
</view>
<view class="empty">{{ loading ? '加载中…' : '未找到该动作' }}</view>
</view>

View File

@@ -0,0 +1,57 @@
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 180rpx; }
.hero { width: 100%; height: 520rpx; background: var(--surface-2); }
.hero__img { width: 100%; height: 100%; display: block; }
.hero__ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 120rpx; }
.name { font-size: 48rpx; font-weight: 700; color: var(--text); letter-spacing: 1rpx; }
.metas { display: flex; flex-wrap: wrap; gap: 14rpx; margin-top: 24rpx; }
.meta {
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius-sm); padding: 16rpx 22rpx;
min-width: calc((100% - 42rpx) / 4);
}
.meta__k { font-size: 20rpx; color: var(--text-3); }
.meta__v { font-size: 26rpx; color: var(--text); margin-top: 6rpx; font-weight: 600; }
.block { margin-top: 36rpx; }
.block__h { margin-bottom: 16rpx; }
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; }
.steps { display: flex; flex-direction: column; gap: 18rpx; }
.step { display: flex; gap: 20rpx; align-items: flex-start; }
.step__n {
flex: 0 0 auto; width: 48rpx; height: 48rpx; border-radius: 999rpx;
background: var(--gold-soft); color: var(--gold); border: 1rpx solid var(--gold-line);
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 26rpx;
}
.step__t { flex: 1; font-size: 28rpx; color: var(--text); line-height: 1.5; padding-top: 6rpx; }
.tips {
font-size: 28rpx; color: var(--text-2); line-height: 1.6;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius-sm); padding: 24rpx;
}
.hscroll { white-space: nowrap; margin-top: 12rpx; }
.hscroll__item { display: inline-block; width: 300rpx; margin-right: 20rpx; vertical-align: top; }
.addbar {
position: fixed; left: 0; right: 0; bottom: 0;
padding: 20rpx 32rpx;
background: rgba(14,17,16,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.cta {
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 26rpx;
}
.empty-page { min-height: 100vh; background: var(--bg-grad); }
.topbar { height: 88rpx; display: flex; align-items: center; padding: 0 24rpx; }
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
.empty { color: var(--text-3); font-size: 28rpx; text-align: center; padding: 120rpx 0; }

View File

@@ -0,0 +1,88 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
stats: null,
bodyParts: [],
equipment: [],
featured: [],
collections: [],
loading: true,
quickEntries: [
{ key: 'recommend', label: '智能推荐', emoji: '✨', url: '/pages/recommend/recommend' },
{ key: 'bodyPart', label: '按部位', emoji: '💪', url: '/pages/category/category?dimension=bodyPart' },
{ key: 'equipment', label: '按器械', emoji: '🏋️', url: '/pages/category/category?dimension=equipment' },
{ key: 'plan', label: '训练计划', emoji: '📋', url: '/pages/collection/collection' },
{ key: 'home', label: '居家无器械', emoji: '🏠', url: '/pages/collection-detail/collection-detail?slug=home' },
{ key: 'search', label: '搜索', emoji: '🔍', url: '/pages/search/search' }
]
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
this.loadData();
},
loadData() {
this.setData({ loading: true });
Promise.all([
svc.getStats().catch(() => null),
svc.listExercises({ pageSize: 8 }).catch(() => ({ items: [] })),
svc.listCollections().catch(() => [])
]).then(([stats, list, collections]) => {
this.setData({
stats,
bodyParts: (stats && stats.bodyParts) || [],
equipment: (stats && stats.equipment) || [],
featured: (list && list.items) || [],
collections: collections || [],
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
goRecommend() {
wx.navigateTo({ url: '/pages/recommend/recommend' });
},
onQuick(e) {
wx.navigateTo({ url: e.currentTarget.dataset.url });
},
onSearch() {
wx.navigateTo({ url: '/pages/search/search' });
},
onScanBodyPart(e) {
const { value, label } = e.currentTarget.dataset;
wx.navigateTo({
url: '/pages/category/category?dimension=bodyPart&value=' +
encodeURIComponent(value) + '&title=' + encodeURIComponent(label)
});
},
onScanEquipment(e) {
const { value, label } = e.currentTarget.dataset;
wx.navigateTo({
url: '/pages/category/category?dimension=equipment&value=' +
encodeURIComponent(value) + '&title=' + encodeURIComponent(label)
});
},
onCollection(e) {
wx.navigateTo({
url: '/pages/collection-detail/collection-detail?slug=' +
encodeURIComponent(e.currentTarget.dataset.slug)
});
},
onExercise(e) {
wx.navigateTo({
url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id)
});
}
});

View File

@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"usingComponents": {
"exercise-card": "/components/exercise-card/index",
"section-header": "/components/section-header/index",
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,99 @@
<view class="home" style="padding-top: {{statusBarHeight}}px;">
<!-- Hero -->
<view class="hero">
<view class="hero__eyebrow">FITCOACH · 智能健身教练</view>
<view class="hero__title">今天,练点什么?</view>
<view class="hero__sub">精准匹配你的目标肌群,让每一次训练都更高效</view>
<view class="search-pill" bindtap="onSearch">
<text class="search-pill__icon">🔍</text>
<text class="search-pill__ph">搜索动作 / 器械 / 部位</text>
</view>
<view class="cta" bindtap="goRecommend">
<text class="cta__icon">✨</text>
<text class="cta__txt">智能推荐</text>
<text class="cta__arrow"></text>
</view>
</view>
<!-- 快捷入口 2x3 -->
<view class="section quick">
<view class="quick__grid">
<view
wx:for="{{quickEntries}}"
wx:key="key"
class="quick__tile"
data-url="{{item.url}}"
bindtap="onQuick">
<text class="quick__emoji">{{item.emoji}}</text>
<text class="quick__label">{{item.label}}</text>
</view>
</view>
</view>
<!-- 精选动作 横向滑动 -->
<view class="section">
<section-header eyebrow="FEATURED" title="精选动作" subtitle="编辑精选 · 高效入门" action="全部" bind:action="goRecommend" />
<scroll-view wx:if="{{featured.length}}" scroll-x class="hscroll">
<view class="hscroll__item" wx:for="{{featured}}" wx:key="id">
<exercise-card exercise="{{item}}" compact bind:tap="onExercise" />
</view>
</scroll-view>
<view wx:else class="empty">暂无数据</view>
</view>
<!-- 训练组合 横向 -->
<view class="section" wx:if="{{collections.length}}">
<section-header eyebrow="PROGRAMS" title="训练组合" subtitle="一键开启主题训练" />
<scroll-view scroll-x class="chip-scroll">
<view
wx:for="{{collections}}"
wx:key="slug"
class="prog"
style="background: {{item.color}}22; border-color: {{item.color}}55;"
data-slug="{{item.slug}}"
bindtap="onCollection">
<text class="prog__emoji">{{item.emoji}}</text>
<text class="prog__name" style="color: {{item.color}};">{{item.name}}</text>
</view>
</scroll-view>
</view>
<!-- 按部位浏览 -->
<view class="section">
<section-header eyebrow="BY BODY PART" title="按部位浏览" subtitle="选择你的目标区域" />
<view class="bp-grid">
<view
wx:for="{{bodyParts}}"
wx:key="value"
class="bp"
data-value="{{item.value}}"
data-label="{{item.label}}"
bindtap="onScanBodyPart">
<text class="bp__label">{{item.label}}</text>
<text class="bp__count">{{item.count}} 个动作</text>
</view>
</view>
</view>
<!-- 热门器械 -->
<view class="section" wx:if="{{equipment.length}}">
<section-header eyebrow="EQUIPMENT" title="热门器械" subtitle="按器械筛选动作" />
<scroll-view scroll-x class="chip-scroll">
<view
wx:for="{{equipment}}"
wx:key="value"
class="eq"
data-value="{{item.value}}"
data-label="{{item.label}}"
bindtap="onScanEquipment">
<text class="eq__name">{{item.label}}</text>
<text class="eq__count">{{item.count}}</text>
</view>
</scroll-view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="home" />
</view>

View File

@@ -0,0 +1,78 @@
.home { min-height: 100vh; background: var(--bg-grad); }
.hero {
padding: 20rpx 40rpx 48rpx;
background: linear-gradient(150deg, #1C2A22 0%, #0E1110 70%);
border-bottom-left-radius: 48rpx;
border-bottom-right-radius: 48rpx;
}
.hero__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
.hero__title { font-size: 56rpx; font-weight: 700; letter-spacing: 2rpx; margin-top: 14rpx; color: var(--text); }
.hero__sub { font-size: 26rpx; color: var(--text-2); margin-top: 14rpx; }
.search-pill {
margin-top: 36rpx;
display: flex; align-items: center;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: 999rpx; padding: 22rpx 30rpx;
}
.search-pill__icon { font-size: 30rpx; margin-right: 16rpx; }
.search-pill__ph { color: var(--text-3); font-size: 28rpx; }
.cta {
margin-top: 24rpx;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 28rpx;
box-shadow: 0 12rpx 30rpx rgba(217,179,108,0.28);
}
.cta__icon { margin-right: 12rpx; }
.cta__arrow { margin-left: 12rpx; font-size: 36rpx; }
.quick__grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.quick__tile {
width: calc((100% - 36rpx) / 3);
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius); padding: 28rpx 18rpx;
display: flex; flex-direction: column; align-items: center;
position: relative; overflow: hidden;
}
.quick__tile::after {
content: ''; position: absolute; inset: 0;
background: radial-gradient(circle at 50% 0%, var(--gold-soft), transparent 70%);
}
.quick__emoji { font-size: 48rpx; position: relative; z-index: 1; }
.quick__label { font-size: 26rpx; margin-top: 12rpx; color: var(--text); position: relative; z-index: 1; }
.hscroll { white-space: nowrap; margin-top: 8rpx; }
.hscroll__item { display: inline-block; width: 300rpx; margin-right: 20rpx; vertical-align: top; }
.chip-scroll { white-space: nowrap; margin-top: 8rpx; }
.prog {
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
min-width: 160rpx; padding: 24rpx 28rpx; margin-right: 18rpx;
border-radius: var(--radius-sm); border: 1rpx solid;
}
.prog__emoji { font-size: 44rpx; }
.prog__name { font-size: 26rpx; font-weight: 700; margin-top: 10rpx; }
.bp-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
.bp {
width: calc((100% - 48rpx) / 4);
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius-sm); padding: 22rpx 12rpx;
display: flex; flex-direction: column; align-items: center;
}
.bp__label { font-size: 26rpx; color: var(--text); font-weight: 600; }
.bp__count { font-size: 20rpx; color: var(--text-3); margin-top: 6rpx; }
.eq {
display: inline-flex; align-items: center; gap: 12rpx;
background: var(--surface-2); border: 1rpx solid var(--line);
border-radius: 999rpx; padding: 16rpx 28rpx; margin-right: 16rpx;
}
.eq__name { font-size: 26rpx; color: var(--text); }
.eq__count { font-size: 22rpx; color: var(--gold); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 40rpx 0; }

View File

@@ -0,0 +1,91 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
step: 1, // 1: 选择目标肌群 / 2: 展示结果
bodyGroups: [],
selectedTarget: '',
selectedTargetLabel: '',
equipments: [],
selectedEquipment: '',
results: [],
loading: false
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
this.prepare();
},
prepare() {
this.setData({ loading: true });
Promise.all([
svc.getCategories('targets').catch(() => []),
svc.getCategories('equipment').catch(() => []),
svc.getCategories('body-parts').catch(() => [])
]).then(([targets, equipments, bodyParts]) => {
const tList = targets || [];
// 按 bodyPart 将目标肌群分组展示;后端未返回分组字段时退化为单组
const grouped = {};
(bodyParts || []).forEach(bp => { grouped[bp.value] = { label: bp.label, targets: [] }; });
tList.forEach(t => {
const key = t.bodyPart || 'other';
if (!grouped[key]) grouped[key] = { label: t.bodyPartLabel || '其他', targets: [] };
grouped[key].targets.push(t);
});
const bodyGroups = Object.keys(grouped)
.map(k => grouped[k])
.filter(g => g.targets.length);
this.setData({
bodyGroups: bodyGroups.length ? bodyGroups : [{ label: '全部目标肌群', targets: tList }],
equipments: equipments || [],
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
onSelectTarget(e) {
this.setData({
selectedTarget: e.currentTarget.dataset.value,
selectedTargetLabel: e.currentTarget.dataset.label
});
},
onSelectEquipment(e) {
const value = e.currentTarget.dataset.value;
this.setData({ selectedEquipment: this.data.selectedEquipment === value ? '' : value });
},
onConfirm() {
if (!this.data.selectedTarget) {
wx.showToast({ title: '请先选择目标肌群', icon: 'none' });
return;
}
this.setData({ loading: true, step: 2 });
svc.recommend({
target: this.data.selectedTarget,
equipment: this.data.selectedEquipment,
limit: 20
}).then((res) => {
this.setData({ results: (res && res.items) || [], loading: false });
}).catch(() => {
this.setData({ loading: false });
});
},
onBack() {
if (this.data.step === 2) {
this.setData({ step: 1 });
} else {
wx.navigateBack();
}
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
}
}

View File

@@ -0,0 +1,80 @@
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<view class="topbar">
<view class="topbar__back" bindtap="onBack"></view>
<view class="topbar__title">智能推荐</view>
</view>
<!-- 步骤 1选择目标肌群 -->
<block wx:if="{{step === 1}}">
<view class="hero2">
<view class="hero2__eyebrow">SMART MATCH</view>
<view class="hero2__title">选择你的目标肌群</view>
<view class="hero2__sub">为你智能匹配最合适动作,并给出训练理由</view>
</view>
<view class="section">
<view class="group" wx:for="{{bodyGroups}}" wx:key="label" wx:for-item="group">
<view class="group__label eyebrow">{{group.label}}</view>
<view class="group__chips">
<view
wx:for="{{group.targets}}"
wx:key="value"
wx:for-item="t"
class="tgt {{selectedTarget === t.value ? 'tgt--active' : ''}}"
data-value="{{t.value}}"
data-label="{{t.label}}"
bindtap="onSelectTarget">
<text>{{t.label}}</text>
<text class="tgt__count">{{t.count}}</text>
</view>
</view>
</view>
<view class="group" wx:if="{{equipments.length}}">
<view class="group__label eyebrow">器械偏好(可选)</view>
<view class="group__chips">
<view
wx:for="{{equipments}}"
wx:key="value"
wx:for-item="eq"
class="tgt {{selectedEquipment === eq.value ? 'tgt--active' : ''}}"
data-value="{{eq.value}}"
bindtap="onSelectEquipment">
<text>{{eq.label}}</text>
</view>
</view>
</view>
</view>
<view class="confirm">
<view class="confirm__bar">
<text wx:if="{{selectedTargetLabel}}" class="confirm__sel">已选:{{selectedTargetLabel}}</text>
<text wx:else class="confirm__hint">请选择目标肌群</text>
</view>
<view class="cta" bindtap="onConfirm">
<text>智能匹配动作</text>
<text class="cta__arrow"></text>
</view>
</view>
</block>
<!-- 步骤 2结果列表 -->
<block wx:else>
<view class="section" style="padding-top: 8rpx;">
<view class="result-head">
<text class="result-head__title">为你匹配 · {{selectedTargetLabel}}</text>
<text class="result-head__count">{{results.length}} 个动作</text>
</view>
<view class="rlist" wx:if="{{results.length}}">
<view class="rlist__item" wx:for="{{results}}" wx:key="id" wx:for-item="item">
<exercise-card
exercise="{{item}}"
score="{{item.score}}"
reason="{{item.reason}}"
bind:tap="onExercise" />
</view>
</view>
<view wx:else class="empty">{{ loading ? '匹配中…' : '暂无匹配结果' }}</view>
</view>
</block>
</view>

View File

@@ -0,0 +1,46 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.topbar { height: 88rpx; display: flex; align-items: center; position: relative; padding: 0 24rpx; }
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
.topbar__title { position: absolute; left: 0; right: 0; text-align: center; font-size: 34rpx; font-weight: 700; color: var(--text); }
.hero2 { padding: 12rpx 40rpx 28rpx; }
.hero2__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
.hero2__title { font-size: 48rpx; font-weight: 700; margin-top: 12rpx; }
.hero2__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
.group { margin-bottom: 36rpx; }
.group__label { margin-bottom: 18rpx; }
.group__chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
.tgt {
display: inline-flex; align-items: center; gap: 10rpx;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: 999rpx; padding: 16rpx 28rpx; font-size: 26rpx; color: var(--text);
}
.tgt--active { background: var(--gold-soft); border-color: var(--gold-line); color: var(--gold-2); }
.tgt__count { font-size: 22rpx; color: var(--text-3); }
.tgt--active .tgt__count { color: var(--gold); }
.confirm {
position: fixed; left: 0; right: 0; bottom: 0;
padding: 20rpx 32rpx;
background: rgba(14,17,16,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.confirm__bar { text-align: center; margin-bottom: 14rpx; }
.confirm__sel { color: var(--gold-2); font-size: 26rpx; }
.confirm__hint { color: var(--text-3); font-size: 26rpx; }
.cta {
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 26rpx;
}
.cta__arrow { margin-left: 12rpx; font-size: 36rpx; }
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20rpx; }
.result-head__title { font-size: 36rpx; font-weight: 700; color: var(--text); }
.result-head__count { font-size: 24rpx; color: var(--gold); }
.rlist { display: flex; flex-direction: column; gap: 20rpx; }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }

View File

@@ -0,0 +1,48 @@
const app = getApp();
const svc = require('../../services/exercise');
Page({
data: {
statusBarHeight: 20,
q: '',
results: [],
loading: false,
searched: false
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
},
onInput(e) {
const q = e.detail.value;
this.setData({ q });
this.debouncedSearch(q);
},
debouncedSearch(q) {
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(() => this.doSearch(q), 350);
},
doSearch(q) {
if (!q || !q.trim()) {
this.setData({ results: [], searched: false, loading: false });
return;
}
this.setData({ loading: true });
svc.search(q.trim()).then((res) => {
this.setData({
results: (res && res.items) || [],
searched: true,
loading: false
});
}).catch(() => {
this.setData({ searched: true, loading: false });
});
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
}
}

View File

@@ -0,0 +1,26 @@
<view class="page">
<navbar title="搜索" />
<view class="searchbar">
<view class="searchbar__box">
<text class="searchbar__icon">🔍</text>
<input
class="searchbar__input"
placeholder="搜索动作 / 器械 / 部位"
placeholder-class="ph"
value="{{q}}"
confirm-type="search"
bindinput="onInput" />
</view>
</view>
<view class="section" style="padding-top: 8rpx;">
<view class="grid" wx:if="{{results.length}}">
<view class="grid__item" wx:for="{{results}}" wx:key="id">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
</view>
</view>
<view wx:elif="{{loading}}" class="empty">搜索中…</view>
<view wx:elif="{{searched}}" class="empty">未找到相关动作</view>
<view wx:else class="empty">输入关键词开始搜索</view>
</view>
</view>

View File

@@ -0,0 +1,14 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.searchbar { padding: 12rpx 32rpx; }
.searchbar__box {
display: flex; align-items: center;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: 999rpx; padding: 20rpx 28rpx;
}
.searchbar__icon { font-size: 30rpx; margin-right: 16rpx; }
.searchbar__input { flex: 1; color: var(--text); font-size: 28rpx; }
.ph { color: var(--text-3); }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }

View File

@@ -0,0 +1,38 @@
{
"appid": "touristappid",
"projectname": "fitness-coach",
"compileType": "miniprogram",
"libVersion": "3.5.0",
"setting": {
"urlCheck": false,
"es6": true,
"minified": false,
"postcss": true,
"enhance": true,
"packNpmManually": false,
"packNpmRelationList": [],
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"condition": {},
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

@@ -0,0 +1,62 @@
// 业务服务层:直接映射后端 REST 接口字段,不做多余转换。
const { request } = require('../utils/request');
// 将参数对象序列化为 query string跳过空值
function buildQuery(params) {
const qs = Object.keys(params || {})
.filter(k => params[k] !== '' && params[k] !== undefined && params[k] !== null)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return qs ? '?' + qs : '';
}
// 1) 动作列表(支持多维筛选与排序)
function listExercises(params = {}) {
return request({ url: '/api/exercises' + buildQuery(params) });
}
// 2) 动作详情
function getExercise(id) {
return request({ url: '/api/exercises/' + encodeURIComponent(id) });
}
// 3) 分类维度枚举
function getCategories(type) {
return request({ url: '/api/categories/' + encodeURIComponent(type) });
}
// 4) 智能推荐
function recommend(params = {}) {
return request({ url: '/api/recommend' + buildQuery(params) });
}
// 5) 训练组合列表
function listCollections() {
return request({ url: '/api/collections' });
}
// 5) 训练组合下的动作
function getCollectionExercises(slug, params = {}) {
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) });
}
// 6) 搜索
function search(q) {
return request({ url: '/api/search?q=' + encodeURIComponent(q) });
}
// 7) 统计概览
function getStats() {
return request({ url: '/api/stats' });
}
module.exports = {
listExercises,
getExercise,
getCategories,
recommend,
listCollections,
getCollectionExercises,
search,
getStats
};

8
miniprogram/sitemap.json Normal file
View File

@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

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