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:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
57
OVERVIEW.md
Normal 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 原始数据集(17MB,1324 条)
|
||||
│ │ ├── 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 → 404;recommend 缺 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 已设为 touristappid,urlCheck: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
121
README.md
Normal 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
16
backend/.env.example
Normal 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
78
backend/README.md
Normal 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 / RecommendQueryDto(class-validator 校验)
|
||||
├── utils/normalize.ts # 原始 → 结构化(类型推导、媒体 URL、中文标签)
|
||||
└── data/
|
||||
├── exercises.json # 数据集(构建时生成)
|
||||
├── labels.ts # 中英文标签映射
|
||||
└── collections.ts # 智能训练组合定义
|
||||
```
|
||||
12
backend/nest-cli.json
Normal file
12
backend/nest-cli.json
Normal 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
4603
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
backend/package.json
Normal file
35
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
74
backend/scripts/analyze.mjs
Normal file
74
backend/scripts/analyze.mjs
Normal 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(', ') || '(无)');
|
||||
20
backend/scripts/copy-data.mjs
Normal file
20
backend/scripts/copy-data.mjs
Normal 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);
|
||||
33
backend/scripts/ensure-data.mjs
Normal file
33
backend/scripts/ensure-data.mjs
Normal 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);
|
||||
});
|
||||
74
backend/scripts/sync-media.mjs
Normal file
74
backend/scripts/sync-media.mjs
Normal 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
27
backend/src/app.module.ts
Normal 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 {}
|
||||
21
backend/src/auth/auth.controller.ts
Normal file
21
backend/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/auth/auth.module.ts
Normal file
12
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||
85
backend/src/auth/auth.service.ts
Normal file
85
backend/src/auth/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
backend/src/auth/dto/login.dto.ts
Normal file
35
backend/src/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
20
backend/src/common/auth.guard.ts
Normal file
20
backend/src/common/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
backend/src/common/current-user.decorator.ts
Normal file
6
backend/src/common/current-user.decorator.ts
Normal 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,
|
||||
);
|
||||
77
backend/src/common/store.ts
Normal file
77
backend/src/common/store.ts
Normal 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>;
|
||||
}
|
||||
42
backend/src/common/token.ts
Normal file
42
backend/src/common/token.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 极简 token:base64url(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;
|
||||
}
|
||||
}
|
||||
208
backend/src/exercises/data/collections.ts
Normal file
208
backend/src/exercises/data/collections.ts
Normal 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);
|
||||
}
|
||||
145883
backend/src/exercises/data/exercises.json
Normal file
145883
backend/src/exercises/data/exercises.json
Normal file
File diff suppressed because it is too large
Load Diff
123
backend/src/exercises/data/labels.ts
Normal file
123
backend/src/exercises/data/labels.ts
Normal 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());
|
||||
}
|
||||
49
backend/src/exercises/dto/query-exercises.dto.ts
Normal file
49
backend/src/exercises/dto/query-exercises.dto.ts
Normal 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';
|
||||
}
|
||||
18
backend/src/exercises/dto/recommend-query.dto.ts
Normal file
18
backend/src/exercises/dto/recommend-query.dto.ts
Normal 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;
|
||||
}
|
||||
79
backend/src/exercises/exercises.controller.ts
Normal file
79
backend/src/exercises/exercises.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
128
backend/src/exercises/exercises.interface.ts
Normal file
128
backend/src/exercises/exercises.interface.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 健身动作数据接口定义
|
||||
* 原始数据来自 exercises-dataset(hasaneyldrm/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[];
|
||||
}
|
||||
10
backend/src/exercises/exercises.module.ts
Normal file
10
backend/src/exercises/exercises.module.ts
Normal 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 {}
|
||||
246
backend/src/exercises/exercises.service.ts
Normal file
246
backend/src/exercises/exercises.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
86
backend/src/exercises/utils/normalize.ts
Normal file
86
backend/src/exercises/utils/normalize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
backend/src/favorites/favorites.controller.ts
Normal file
66
backend/src/favorites/favorites.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/favorites/favorites.module.ts
Normal file
12
backend/src/favorites/favorites.module.ts
Normal 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 {}
|
||||
110
backend/src/favorites/favorites.service.ts
Normal file
110
backend/src/favorites/favorites.service.ts
Normal 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
34
backend/src/main.ts
Normal 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();
|
||||
56
backend/src/plans/dto/create-plan.dto.ts
Normal file
56
backend/src/plans/dto/create-plan.dto.ts
Normal 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;
|
||||
}
|
||||
106
backend/src/plans/plans.controller.ts
Normal file
106
backend/src/plans/plans.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/plans/plans.module.ts
Normal file
12
backend/src/plans/plans.module.ts
Normal 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 {}
|
||||
164
backend/src/plans/plans.service.ts
Normal file
164
backend/src/plans/plans.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/src/users/users.controller.ts
Normal file
19
backend/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
backend/src/users/users.module.ts
Normal file
10
backend/src/users/users.module.ts
Normal 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 {}
|
||||
44
backend/src/users/users.service.ts
Normal file
44
backend/src/users/users.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
23
backend/tsconfig.json
Normal file
23
backend/tsconfig.json
Normal 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
47
miniprogram/README.md
Normal 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
26
miniprogram/app.js
Normal 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
19
miniprogram/app.json
Normal 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
88
miniprogram/app.wxss
Normal 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);
|
||||
}
|
||||
20
miniprogram/components/bottom-nav/index.js
Normal file
20
miniprogram/components/bottom-nav/index.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/bottom-nav/index.json
Normal file
4
miniprogram/components/bottom-nav/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
11
miniprogram/components/bottom-nav/index.wxml
Normal file
11
miniprogram/components/bottom-nav/index.wxml
Normal 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>
|
||||
22
miniprogram/components/bottom-nav/index.wxss
Normal file
22
miniprogram/components/bottom-nav/index.wxss
Normal 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); }
|
||||
16
miniprogram/components/chip/index.js
Normal file
16
miniprogram/components/chip/index.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/chip/index.json
Normal file
4
miniprogram/components/chip/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
6
miniprogram/components/chip/index.wxml
Normal file
6
miniprogram/components/chip/index.wxml
Normal 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>
|
||||
18
miniprogram/components/chip/index.wxss
Normal file
18
miniprogram/components/chip/index.wxss
Normal 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);
|
||||
}
|
||||
14
miniprogram/components/exercise-card/index.js
Normal file
14
miniprogram/components/exercise-card/index.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/exercise-card/index.json
Normal file
4
miniprogram/components/exercise-card/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
21
miniprogram/components/exercise-card/index.wxml
Normal file
21
miniprogram/components/exercise-card/index.wxml
Normal 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>
|
||||
40
miniprogram/components/exercise-card/index.wxss
Normal file
40
miniprogram/components/exercise-card/index.wxss
Normal 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;
|
||||
}
|
||||
27
miniprogram/components/navbar/index.js
Normal file
27
miniprogram/components/navbar/index.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/navbar/index.json
Normal file
4
miniprogram/components/navbar/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
7
miniprogram/components/navbar/index.wxml
Normal file
7
miniprogram/components/navbar/index.wxml
Normal 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>
|
||||
29
miniprogram/components/navbar/index.wxss
Normal file
29
miniprogram/components/navbar/index.wxss
Normal 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);
|
||||
}
|
||||
13
miniprogram/components/section-header/index.js
Normal file
13
miniprogram/components/section-header/index.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/section-header/index.json
Normal file
4
miniprogram/components/section-header/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
8
miniprogram/components/section-header/index.wxml
Normal file
8
miniprogram/components/section-header/index.wxml
Normal 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>
|
||||
23
miniprogram/components/section-header/index.wxss
Normal file
23
miniprogram/components/section-header/index.wxss
Normal 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
7
miniprogram/config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// 后端基础地址配置。
|
||||
// ⚠️ 生产环境必须替换为已在「微信公众平台 -> 开发 -> 开发设置 -> 服务器域名」
|
||||
// 中配置的 HTTPS 合法域名(request 合法域名),否则真机无法发起请求。
|
||||
// localhost 仅用于开发者工具本地联调。
|
||||
module.exports = {
|
||||
BASE_URL: 'http://localhost:3000'
|
||||
};
|
||||
89
miniprogram/pages/category/category.js
Normal file
89
miniprogram/pages/category/category.js
Normal 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) });
|
||||
}
|
||||
});
|
||||
9
miniprogram/pages/category/category.json
Normal file
9
miniprogram/pages/category/category.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
miniprogram/pages/category/category.wxml
Normal file
29
miniprogram/pages/category/category.wxml
Normal 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>
|
||||
8
miniprogram/pages/category/category.wxss
Normal file
8
miniprogram/pages/category/category.wxss
Normal 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; }
|
||||
42
miniprogram/pages/collection-detail/collection-detail.js
Normal file
42
miniprogram/pages/collection-detail/collection-detail.js
Normal 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) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
24
miniprogram/pages/collection-detail/collection-detail.wxml
Normal file
24
miniprogram/pages/collection-detail/collection-detail.wxml
Normal 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>
|
||||
14
miniprogram/pages/collection-detail/collection-detail.wxss
Normal file
14
miniprogram/pages/collection-detail/collection-detail.wxss
Normal 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); }
|
||||
40
miniprogram/pages/collection/collection.js
Normal file
40
miniprogram/pages/collection/collection.js
Normal 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)
|
||||
});
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/collection/collection.json
Normal file
7
miniprogram/pages/collection/collection.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
31
miniprogram/pages/collection/collection.wxml
Normal file
31
miniprogram/pages/collection/collection.wxml
Normal 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>
|
||||
22
miniprogram/pages/collection/collection.wxss
Normal file
22
miniprogram/pages/collection/collection.wxss
Normal 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; }
|
||||
67
miniprogram/pages/detail/detail.js
Normal file
67
miniprogram/pages/detail/detail.js
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/detail/detail.json
Normal file
7
miniprogram/pages/detail/detail.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
72
miniprogram/pages/detail/detail.wxml
Normal file
72
miniprogram/pages/detail/detail.wxml
Normal 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>
|
||||
57
miniprogram/pages/detail/detail.wxss
Normal file
57
miniprogram/pages/detail/detail.wxss
Normal 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; }
|
||||
88
miniprogram/pages/index/index.js
Normal file
88
miniprogram/pages/index/index.js
Normal 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)
|
||||
});
|
||||
}
|
||||
});
|
||||
8
miniprogram/pages/index/index.json
Normal file
8
miniprogram/pages/index/index.json
Normal 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"
|
||||
}
|
||||
}
|
||||
99
miniprogram/pages/index/index.wxml
Normal file
99
miniprogram/pages/index/index.wxml
Normal 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>
|
||||
78
miniprogram/pages/index/index.wxss
Normal file
78
miniprogram/pages/index/index.wxss
Normal 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; }
|
||||
91
miniprogram/pages/recommend/recommend.js
Normal file
91
miniprogram/pages/recommend/recommend.js
Normal 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) });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/recommend/recommend.json
Normal file
7
miniprogram/pages/recommend/recommend.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
80
miniprogram/pages/recommend/recommend.wxml
Normal file
80
miniprogram/pages/recommend/recommend.wxml
Normal 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>
|
||||
46
miniprogram/pages/recommend/recommend.wxss
Normal file
46
miniprogram/pages/recommend/recommend.wxss
Normal 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; }
|
||||
48
miniprogram/pages/search/search.js
Normal file
48
miniprogram/pages/search/search.js
Normal 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) });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/search/search.json
Normal file
7
miniprogram/pages/search/search.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
26
miniprogram/pages/search/search.wxml
Normal file
26
miniprogram/pages/search/search.wxml
Normal 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>
|
||||
14
miniprogram/pages/search/search.wxss
Normal file
14
miniprogram/pages/search/search.wxss
Normal 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; }
|
||||
38
miniprogram/project.config.json
Normal file
38
miniprogram/project.config.json
Normal 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": {}
|
||||
}
|
||||
62
miniprogram/services/exercise.js
Normal file
62
miniprogram/services/exercise.js
Normal 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
8
miniprogram/sitemap.json
Normal 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
Reference in New Issue
Block a user