feat: 小程序连接服务器IP + 同步未提交开发
- miniprogram/config.js: BASE_URL 指向 http://192.227.237.8:3001 - 动作标题中文化(nameZh),搜索匹配 name/nameZh/nameEn - 新增计划/收藏/我的 等页面与组件 - 后端 Docker 化(Dockerfile/.dockerignore)与翻译脚本 - .gitignore 补充 .DS_Store
This commit is contained in:
38
.gitignore
vendored
38
.gitignore
vendored
@@ -1,36 +1,22 @@
|
||||
# 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
|
||||
# 本地媒体同步目录(可选)
|
||||
backend/media/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.local
|
||||
|
||||
# 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/
|
||||
# macOS 垃圾文件
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
29
OVERVIEW.md
29
OVERVIEW.md
@@ -7,18 +7,18 @@
|
||||
```
|
||||
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 个智能训练组合定义
|
||||
│ ├── src/exercises/ 查询 / 多维筛选 / 智能推荐 / 官方训练组合(含 level 分级)
|
||||
│ ├── src/common/ store(文件持久化) / token / auth.guard / current-user
|
||||
│ ├── src/auth users favorites plans 用户体系四模块
|
||||
│ ├── data/store/ 运行时用户数据(users / favorites / plans *.json)
|
||||
│ └── 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 化请求层
|
||||
├── pages/ index / recommend / category / detail / collection / collection-detail
|
||||
│ / search / profile(我的) / favorites(收藏动作) / my-plans(我的计划)
|
||||
│ / plan-edit / plan-detail / plan-favorites(收藏计划)
|
||||
├── components/ exercise-card / chip / navbar / bottom-nav / section-header / level-tag / avatar / plan-card
|
||||
├── services/ exercise / user / favorite / plan
|
||||
└── utils/ request(Bearer+401自动重登) / auth(登录态) / format
|
||||
```
|
||||
|
||||
## 核心功能实现
|
||||
@@ -29,14 +29,17 @@ fitness-coach/
|
||||
| 按器械筛选 | `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 快捷入口 + 精选动作轮播 + 训练组合 + 按部位浏览 + 热门器械 |
|
||||
| 发散功能 | 8 个智能训练组合 + 7 个按水平分级的官方推荐计划(新手七天/新手燃脂/进阶增肌/进阶力量/高级竞技/舒展放松/女子塑形)、搜索、统计、按类型筛选 |
|
||||
| 优雅首页 | 渐变 hero + 问候 + 智能推荐 CTA + 2×3 快捷入口 + 精选动作轮播 + 官方计划分级入口 + 按部位浏览 + 热门器械 |
|
||||
| 用户体系 | 微信登录(开发降级模式)+ 个人中心 + 收藏动作 + 我的计划(组建/编辑/导入官方计划)+ 收藏官方计划,数据文件持久化 |
|
||||
| 数据来源 | 数据集导入后端内存,统一经 NestJS 对外提供(含 GIF 演示,CDN 直链) |
|
||||
|
||||
## 已验证(curl 全通过)
|
||||
- 列表分页与多维筛选、分类元数据(target 含 `bodyPart` 分组)、详情(含中文步骤)
|
||||
- 推荐逻辑修复后 **total 一致**(装备仅作加分排序,不再虚增无关动作)
|
||||
- 错误码:坏 id → 404;recommend 缺 target → 400
|
||||
- **用户体系**:登录(开发降级返回虚拟 openid)→ 个人资料读写 → 动作收藏增删查与状态 → 我的计划 CRUD / 增删动作 / 从官方计划一键导入 → 官方计划按 4 级分组下发(共 15 个计划)→ 收藏官方计划;401 未登录拦截、用户数据隔离均正确
|
||||
- 错误码:坏 id → 404;recommend 缺 target → 400;未登录访问受保护接口 → 401
|
||||
- 前端 30 个 JS 文件 `node --check` 语法全部通过;所有页面 `usingComponents` 与实际使用的组件一致
|
||||
|
||||
## 运行方式
|
||||
```bash
|
||||
|
||||
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
data-store
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
@@ -14,3 +14,12 @@ MEDIA_BASE_URL=https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main
|
||||
|
||||
# 是否允许跨域(小程序走 wx.request 通常不需要,但方便 Web 调试 / Postman)
|
||||
CORS_ENABLED=true
|
||||
|
||||
# ===== 微信登录(用户体系)=====
|
||||
# 不填 APPID/SECRET 时,登录走「开发降级模式」:任意 code 都会生成稳定的虚拟 openid,
|
||||
# 便于本地联调,无需真实小程序账号。生产环境务必填入并在微信公众平台获取。
|
||||
WX_APPID=
|
||||
WX_SECRET=
|
||||
|
||||
# token 签名密钥(HMAC-SHA256)。生产环境请改为强随机值,例如 openssl rand -hex 32
|
||||
JWT_SECRET=fitcoach-dev-secret
|
||||
|
||||
27
backend/Dockerfile
Normal file
27
backend/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# ---- build stage ----
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# npm run build => ensure-data (skip, dataset present) + nest build + copy-data
|
||||
RUN npm run build
|
||||
|
||||
# ---- runtime stage ----
|
||||
FROM node:20-slim AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
CORS_ENABLED=true \
|
||||
MEDIA_BASE_URL=https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main \
|
||||
JWT_SECRET=fitcoach-dev-secret
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
# data-store 是运行时生成的 JSON 库,挂卷持久化
|
||||
VOLUME ["/app/data-store"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:'+process.env.PORT+'/api/exercises').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -12,8 +12,11 @@
|
||||
|------|--------|------|
|
||||
| `PORT` | `3000` | 服务端口 |
|
||||
| `API_BASE_URL` | `http://localhost:3000` | 小程序访问基址(生成媒体 URL 用) |
|
||||
| `MEDIA_BASE_URL` | `http://localhost:3000/media` | 媒体资源基址;可改为 CDN |
|
||||
| `MEDIA_BASE_URL` | `https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main` | 媒体资源基址(CDN 直链,免本地下载) |
|
||||
| `CORS_ENABLED` | `true` | 是否允许跨域(Web 调试用) |
|
||||
| `WX_APPID` | 空 | 微信小程序 AppID;**不填则登录走开发降级模式**(任意 code 生成稳定虚拟 openid) |
|
||||
| `WX_SECRET` | 空 | 微信小程序 AppSecret,配合 `WX_APPID` 调用 code2session 换取真实 openid |
|
||||
| `JWT_SECRET` | `fitcoach-dev-secret` | token 签名密钥,生产务必修改为强随机值 |
|
||||
|
||||
## 安装与运行
|
||||
```bash
|
||||
@@ -53,6 +56,30 @@ curl "http://localhost:3000/api/collections/legs/exercises?pageSize=10"
|
||||
|
||||
# 搜索
|
||||
curl "http://localhost:3000/api/search?q=abs"
|
||||
|
||||
# ===== 用户体系(登录 / 收藏 / 计划)=====
|
||||
# 登录(开发模式:任意 code 均可,后端自动建号)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login -H 'content-type: application/json' \
|
||||
-d '{"code":"dev_123","userInfo":{"nickname":"阿强","avatar":"🦊"}}' | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).token))")
|
||||
|
||||
# 个人资料
|
||||
curl http://localhost:3000/api/auth/me -H "Authorization: Bearer $TOKEN"
|
||||
curl -X PATCH http://localhost:3000/api/users/me -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"nickname":"阿强Pro"}'
|
||||
|
||||
# 收藏动作
|
||||
curl -X POST http://localhost:3000/api/favorites/exercises -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"target":"0001"}'
|
||||
curl http://localhost:3000/api/favorites/exercises -H "Authorization: Bearer $TOKEN"
|
||||
curl http://localhost:3000/api/favorites/exercises/0001/status -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 我的计划
|
||||
curl -X POST http://localhost:3000/api/plans -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"胸肌日","exerciseIds":["0001","0002"]}'
|
||||
curl http://localhost:3000/api/plans -H "Authorization: Bearer $TOKEN"
|
||||
# 从官方计划一键导入为我的计划
|
||||
curl -X POST http://localhost:3000/api/plans/import -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"slug":"home"}'
|
||||
|
||||
# 官方计划(按水平分级)
|
||||
curl http://localhost:3000/api/plans/official -H "Authorization: Bearer $TOKEN"
|
||||
curl "http://localhost:3000/api/plans/official/home/exercises?pageSize=10" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## 推荐算法
|
||||
@@ -62,6 +89,23 @@ curl "http://localhost:3000/api/search?q=abs"
|
||||
- 指定器械匹配:+30;若为自重可替代:+10
|
||||
仅返回评分 > 0 的动作,按评分降序、名称升序返回。
|
||||
|
||||
## 用户体系 / 收藏 / 计划(v2 新增)
|
||||
|
||||
独立的 `auth` / `users` / `favorites` / `plans` 四个模块,构成完整用户闭环:
|
||||
|
||||
- **登录**:`POST /api/auth/login` 接收 `wx.login` 的 `code`,生产环境调用微信 `code2session` 换取真实 `openid`;**未配置 `WX_APPID`/`WX_SECRET` 时自动降级**,用任意 code 生成稳定虚拟 `openid`,方便本地联调。返回自签 `token`(HMAC-SHA256,30 天有效)与用户资料。
|
||||
- **鉴权**:受保护接口需 `Authorization: Bearer <token>`;统一 `AuthGuard` 校验,非法/过期返回 401。
|
||||
- **资料**:`GET /api/auth/me`、`PATCH /api/users/me`(昵称/头像 emoji)。
|
||||
- **收藏**:动作收藏(`/api/favorites/exercises`)+ 官方计划收藏(`/api/favorites/plans`),均带状态查询接口。
|
||||
- **我的计划**:完整 CRUD(`/api/plans`)+ 向计划增删动作;`POST /api/plans/import` 可把官方计划一键复制为个人计划。
|
||||
- **官方计划**:在原有 8 个训练组合基础上新增「按水平分级」的官方推荐计划(新手七天 / 新手燃脂 / 进阶增肌 / 进阶力量 / 高级竞技 / 舒展放松 / 女子塑形等),通过 `GET /api/plans/official` 按 `新手入门 / 进阶提升 / 高级挑战 / 全阶段适用` 分组下发。
|
||||
|
||||
### 数据持久化
|
||||
|
||||
用户、收藏、计划数据落地在 **`backend/data-store/*.json`**(`users.json` / `favorites-exercises.json` / `favorites-plans.json` / `plans.json`),由 `src/common/store.ts` 的单例 `JsonStore` 在进程内加载、每次写入整体落盘。无需数据库即可持久化;首次启动自动建目录与空文件。
|
||||
|
||||
> 该目录为运行时数据,**建议加入 `.gitignore`**(已在仓库根 `.gitignore` 忽略 `data-store/`)。
|
||||
|
||||
## 目录
|
||||
```
|
||||
src/exercises/
|
||||
@@ -74,5 +118,11 @@ src/exercises/
|
||||
└── data/
|
||||
├── exercises.json # 数据集(构建时生成)
|
||||
├── labels.ts # 中英文标签映射
|
||||
└── collections.ts # 智能训练组合定义
|
||||
└── collections.ts # 智能训练组合 / 官方计划定义(含 level 分级)
|
||||
src/common/
|
||||
├── store.ts # 文件持久化单例 JsonStore(data-store/)
|
||||
├── token.ts # token 签名 / 校验
|
||||
├── auth.guard.ts # Bearer 鉴权守卫
|
||||
└── current-user.decorator.ts # 注入当前 openid
|
||||
src/auth/ src/users/ src/favorites/ src/plans/ # 用户体系四模块
|
||||
```
|
||||
|
||||
338
backend/scripts/translate-exercises.mjs
Normal file
338
backend/scripts/translate-exercises.mjs
Normal file
@@ -0,0 +1,338 @@
|
||||
// 动作标题中文化引擎 v2
|
||||
// 策略:短语词典(习语优先)→ 词元词典(大幅扩充)→ 词干归并 → 未知英文做音译兜底
|
||||
// 保证 100% 中文化且无生硬英文残留;只保留数字与少数专有器械缩写(EZ/SZ/TRX/BOSU)。
|
||||
import fs from 'fs';
|
||||
|
||||
const DATA = '/Users/wm/WorkBuddy/健身/fitness-coach/backend/src/exercises/data/exercises.json';
|
||||
|
||||
const PHRASES = [
|
||||
['push up', '俯卧撑'], ['pull up', '引体向上'], ['chin up', '下巴引体'],
|
||||
['sit-up', '仰卧起坐'], ['pull-up', '引体向上'], ['push-up', '俯卧撑'], ['chin-up', '下巴引体'],
|
||||
['bench press', '卧推'], ['incline bench', '上斜凳'], ['decline bench', '下斜凳'],
|
||||
['flat bench', '平板凳'], ['chest press', '胸推'], ['shoulder press', '肩上推举'],
|
||||
['overhead press', '过顶推举'], ['military press', '军用推举'], ['arnold press', '阿诺德推举'],
|
||||
['landmine press', '地雷推举'], ['leg press', '腿举'], ['leg curl', '腿弯举'],
|
||||
['leg extension', '腿屈伸'], ['calf raise', '提踵'], ['sit up', '仰卧起坐'],
|
||||
['side bend', '侧屈'], ['v up', 'V字挺身'], ['good morning', '早安式'],
|
||||
['romanian deadlift', '罗马尼亚硬拉'], ['stiff leg deadlift', '直腿硬拉'],
|
||||
['sumo deadlift', '相扑硬拉'], ['hip thrust', '髋推'], ['glute bridge', '臀桥'],
|
||||
['hip bridge', '臀桥'], ['wall sit', '靠墙静蹲'], ['wall ball', '墙球'],
|
||||
['battle rope', '战绳'], ['mountain climber', '登山跑'], ['russian twist', '俄罗斯转体'],
|
||||
['bicycle crunch', '蹬车卷腹'], ['reverse crunch', '反向卷腹'], ['cable crossover', '绳索夹胸'],
|
||||
['pec deck', '夹胸器'], ['lat pulldown', '高位下拉'], ['pulldown', '下拉'],
|
||||
['preacher curl', '传教士弯举'], ['hammer curl', '锤式弯举'], ['bicep curl', '二头弯举'],
|
||||
['tricep extension', '三头下压'], ['triceps pushdown', '三头下压'], ['skull crusher', '仰卧臂屈伸'],
|
||||
['lateral raise', '侧平举'], ['front raise', '前平举'], ['rear delt raise', '后束平举'],
|
||||
['rear delt fly', '后束飞鸟'], ['face pull', '面拉'], ['kettlebell swing', '壶铃摆荡'],
|
||||
['dumbbell swing', '哑铃摆荡'], ['jump squat', '深蹲跳'], ['jump lunge', '箭步蹲跳'],
|
||||
['split squat', '分腿蹲'], ['bulgarian split squat', '保加利亚分腿蹲'], ['goblet squat', '高脚杯深蹲'],
|
||||
['front squat', '前蹲'], ['back squat', '背蹲'], ['overhead squat', '过顶深蹲'],
|
||||
['box jump', '跳箱'], ['burpee', '波比跳'], ['jumping jack', '开合跳'],
|
||||
['bear crawl', '熊爬'], ['crab walk', '蟹行'], ['bird dog', '鸟狗式'],
|
||||
['dead bug', '死虫式'], ['plank', '平板支撑'], ['side plank', '侧平板'],
|
||||
['hollow hold', ' hollow支撑'], ['superman', '超人式'], ['resistance band', '阻力带'],
|
||||
['mini band', '迷你弹力带'], ['loop band', '环形弹力带'], ['exercise ball', '健身球'],
|
||||
['medicine ball', '药球'], ['stability ball', '瑞士球'], ['swiss ball', '瑞士球'],
|
||||
['bosu ball', '博苏球'], ['foam roller', '泡沫轴'], ['foam roll', '泡沫轴'],
|
||||
['ab wheel', '健腹轮'], ['wheel roller', '健腹轮'], ['smith machine', '史密斯机'],
|
||||
['cable machine', '绳索器械'], ['ez bar', '曲杠'], ['ez barbell', '曲杠铃'], ['ez-bar', '曲杠'], ['ez-barbell', '曲杠铃'],
|
||||
['pull up bar', '单杠'], ['pullup bar', '单杠'], ['parallel bars', '双杠'],
|
||||
['body weight', '自重'], ['bodyweight', '自重'], ['overhand grip', '正握'],
|
||||
['underhand grip', '反握'], ['neutral grip', '对握'], ['close grip', '窄握'],
|
||||
['wide grip', '宽握'], ['reverse grip', '反握'], ['hammer grip', '锤式握'],
|
||||
['mixed grip', '混握'], ['single leg', '单腿'], ['single arm', '单臂'],
|
||||
['double leg', '双腿'], ['both legs', '双腿'], ['both arms', '双臂'],
|
||||
['one leg', '单腿'], ['one arm', '单臂'], ['t bar', 'T杆'], ['t-bar', 'T杆'],
|
||||
['dumbbell row', '哑铃划船'], ['barbell row', '杠铃划船'], ['incline row', '上斜划船'],
|
||||
['seated row', '坐姿划船'], ['cable row', '绳索划船'], ['upright row', '直立划船'],
|
||||
['inverted row', '反向划船'], ['bent over', '俯身'], ['bent-over', '俯身'], ['lying', '仰卧'],
|
||||
['pullover', '过顶臂屈伸'], ['clean and press', '抓举推举'], ['clean', '抓举'],
|
||||
['snatch', '挺举'], ['jerk', '上挺'], ['thruster', '推举蹲'], ['zercher squat', '泽彻深蹲'], ['pin presses', '销钉推'],
|
||||
['hack squat', '哈克深蹲'], ['jefferson squat', '杰弗逊深蹲'], ['pendlay row', '彭德雷划船'],
|
||||
['guillotine bench press', '断头台卧推'], ['jm bench press', '杰姆卧推'], ['rack pull', '架上拉'],
|
||||
['skullcrusher', '碎颅者'], ['skull crusher', '碎颅者'], ['pin presses', '销钉推'],
|
||||
['cuban press', '古巴推举'], ['scott press', '斯科特推举'], ['tate press', '泰特推举'],
|
||||
['w-press', 'W推举'], ['waiter biceps curl', '侍者弯举'], ['zottman preacher curl', '佐特曼传教士弯举'],
|
||||
['figure 8', '8字'], ['turkish get up', '土耳其起立'], ['pike push up', '派克俯卧撑'],
|
||||
['diamond push-up', '钻石俯卧撑'], ['clap push up', '击掌俯卧撑'], ['clock push-up', '时钟俯卧撑'],
|
||||
['spider curl', '蜘蛛弯举'], ['french press', '法式推举'], ['sumo pull through', '相扑穿插'],
|
||||
['sissy squat', '少女深蹲'], ['cossack squats', '哥萨克深蹲'], ['pistol squat', '手枪深蹲'],
|
||||
['potty squat', '幼儿深蹲'], ['frog crunch', '蛙式卷腹'], ['windmill', '风车'],
|
||||
['renegade row', '叛徒划船'], ['see-saw press', '跷跷板推举'], ['gironda sternum chin', '吉龙达下胸引体'],
|
||||
['world greatest stretch', '世界最佳拉伸'], ['butterfly yoga pose', '蝴蝶式瑜伽'], ['cobra', '眼镜蛇式'],
|
||||
['sphinx', '狮身人面式'], ['upward facing dog', '上犬式'], ['child pose', '婴儿式'],
|
||||
['pelvic tilt', '骨盆倾斜'], ['pelvic tilt into bridge', '骨盆倾斜成桥'],
|
||||
['three bench dip', '三凳臂屈伸'], ['turkish get up', '土耳其起立'], ['skin the cat', '剥猫式'],
|
||||
['v-bar', 'V杠'], ['sz-bar', '直杠'], ['arnold press', '阿诺德推举'], ['cuban press', '古巴推举'],
|
||||
['incline t-raise', '上斜T上举'], ['cross body hammer curl', '交叉锤式弯举'],
|
||||
['v sit', 'V坐'], ['rear lunge', '后箭步蹲'], ['side split squat', '侧分腿蹲'],
|
||||
['side bent', '侧俯身'], ['arnold', '阿诺德'], ['cuban', '古巴'], ['decline shrug', '下斜耸肩'],
|
||||
['cross body', '交叉'], ['biceps curl', '二头弯举'], ['incline curl', '上斜弯举'], ['concentration curl', '集中弯举'],
|
||||
['v-up', 'V字挺身'], ['v-sit', 'V坐'], ['l-sit', 'L支撑'], ['l-pull-up', 'L引体'],
|
||||
['band v-up', '弹力带V字挺身'], ['band alternating v-up', '弹力带交替V字挺身'],
|
||||
['close-grip', '窄握'], ['flutter kicks', '打水踢腿'], ['otis up', '奥蒂斯上举'],
|
||||
['svend press', '斯文德推举'], ['peacher hammer curl', '传教士锤式弯举'],
|
||||
['on floor', '地面'], ['all fours', '四点'],
|
||||
];
|
||||
|
||||
// 残留/边界词补充
|
||||
const EXTRA = {
|
||||
v: 'V', up: '上', breeding: '抬举', arnold: '阿诺德', cuban: '古巴', all: '全', apart: '分开', and: '与', concentration: '集中',
|
||||
revers: '反向', split: '分腿', sit: '坐', t: 'T', blaster: '助力带',
|
||||
pallof: '帕洛夫', zercher: '泽彻', gironda: '吉龙达', svend: '斯文德', jm: '杰姆',
|
||||
pendlay: '彭德雷', jefferson: '杰弗逊', guillotine: '断头台', bradford: '布拉德福德',
|
||||
rocky: '摇摆', otis: '奥蒂斯', thibaudeau: '蒂博多', kayak: '皮划艇', scatter: '散射',
|
||||
spider: '蜘蛛', zottman: '佐特曼', peacher: '传教士', tate: '泰特', waiter: '侍者',
|
||||
scott: '斯科特', world: '世界', stork: '鹳', bowling: '保龄', contralateral: '对侧',
|
||||
can: '罐', french: '法式', pronate: '旋前', pronated: '旋前', supinated: '旋后',
|
||||
pronation: '旋前', supination: '旋后', support: '支撑', supported: '支撑', row_shoulder: '划肩',
|
||||
around: '环绕', across: '横过', face: '面', femoral: '股', iron: '铁', plyo: '增强式',
|
||||
finger: '手指', fingers: '手指', raised: '抬起', spider: '蜘蛛', dumbbells: '哑铃',
|
||||
dips: '臂屈伸', elevator: '电梯', off: '离', ground: '地面', flexor: '屈肌', hug: '抱',
|
||||
pyramid: '金字塔', tennis: '网球', between: '之间', diagonal: '对角', pike: '派克',
|
||||
anti: '抗', gravity: '重力', farmers: '农夫', flag: '旗', flexion: '屈曲', flutter: '打水',
|
||||
frankenstein: '弗兰肯斯坦', frog: '蛙', planche: '水平支撑', reps: '次', maltese: '马耳他',
|
||||
gorilla: '大猩猩', groin: '腹股沟', bends: '屈', clasped: '交叉', handstand: '倒立',
|
||||
keens: '膝', hyght: '高位', hyperextension: '超伸', impossible: '不可能', inchworm: '尺蠖',
|
||||
depth: '深度', scapula: '肩胛', scapular: '肩胛', intermediate: '中级', straps: '带',
|
||||
wipers: '雨刷', jackknife: '折刀', janda: '扬达', advanced: '高级', windmill: '风车',
|
||||
hang: '悬垂', renegade: '叛徒', position: '位', jerk: '上挺', figure: '图形', pass: '穿越',
|
||||
pirate: '海盗', supper: '晚餐', pistol: '手枪', seesaw: '跷跷板', turkish: '土耳其',
|
||||
get: '起', style: '式', out: '出', kipping: '借力', muscle: '双力臂', korean: '韩式',
|
||||
lean: '前倾', left: '左', hook: '勾拳', boxing: '拳击', gripless: '无握', gripper: '握力',
|
||||
overhand: '正握', rotary: '旋转', pad: '垫', unilateral: '单侧', london: '伦敦', catch: '接',
|
||||
point: '点', multiple: '多次', response: '反应', release: '释放', modified: '改良',
|
||||
hindu: '印度', monster: '怪兽', negative: '退让', olympic: '奥林匹克', outside: '外',
|
||||
pelvic: '骨盆', into: '成', peroneals: '腓骨肌', cobra: '眼镜蛇', posterior: '后侧',
|
||||
tibialis: '胫骨肌', potty: '幼儿', power: '力量', prisoner: '囚徒', inside: '内', plus: '加',
|
||||
quarter: '四分之一', quick: '快速', feet: '脚', reclining: '仰卧', big: '大', thrusts: '推',
|
||||
hyper: '超', ring: '吊环', saw: '锯', depresor: '下压', retractor: '后缩', equipment: '器械',
|
||||
runners: '跑者', self: '自', semi: '半', stride: '跨步', outstretched: '伸展', slide: '滑动',
|
||||
sissy: '少女', skater: '滑冰', ski: '滑雪', ergometer: '测功仪', skin: '皮', cat: '猫',
|
||||
degrees: '度', closer: '更近', sledge: '雪橇', raises: '上举', sprint: '冲刺', spell: '法术',
|
||||
caster: '施法', sphinx: '狮身', stalder: '斯塔尔德', staircase: '楼梯', star: '星形',
|
||||
stationary: '固定', straddle: '分腿', outer: '外', suspended: '悬吊', fallout: '塌陷',
|
||||
swimmer: '游泳', three: '三', twin: '双', handle: '握把', upward: '向上', facing: '面向',
|
||||
dog: '犬', elliptical: '椭圆', walking: '行走', treadmill: '跑步机', stepmill: '楼梯机',
|
||||
cossack: '哥萨克', round: '圆', wind: '风', greatest: '最佳', rollerer: '滚轮', archer: '射手',
|
||||
slingers: '摆臂', touchers: '触碰', airbike: '空气单车', fours: '四点', squad: '小队',
|
||||
circular: '环绕', piriformis: '梨状肌', gluteus: '臀肌', motion: '动作', parallel: '平行',
|
||||
rectus: '直肌', femoris: '股直肌', major: '大', towel: '毛巾', astride: '分腿', forth: '往复',
|
||||
backward: '向后', board: '板', basic: '基础', battling: '战斗', ups: '上', drop: '落下',
|
||||
bottoms: '底', cross: '交叉', butt: '臀', butterfly: '蝴蝶', yoga: '瑜伽', pose: '体式',
|
||||
inverse: '反向', variation: '变式', forward: '前', judo: '柔道', flip: '翻', kickback: '后踢',
|
||||
range: '全程', pro: '专业', stirrups: '马镫', drive: '驱动', inner: '内', external: '外',
|
||||
crossovers: '交叉', kayak: '皮划艇', against: '靠', captains: '队长', extended: '伸展',
|
||||
cage: '架', butt: '臀', clasped: '交叉', handstand: '倒立', keens: '膝', hyght: '高位',
|
||||
cocoons: '茧', crab: '蟹', curtsey: '屈膝礼', cycle: '单车', trainer: '训练器', diamond: '钻石',
|
||||
donkey: '驴式', tap: '拍', clap: '击掌', clock: '时钟', flag: '旗', flexion: '屈曲',
|
||||
monster: '怪兽', gripless: '无握', gripper: '握力', depresor: '下压', retractor: '后缩',
|
||||
peroneals: '腓骨肌', tibialis: '胫骨肌', potty: '幼儿', prisoner: '囚徒', reclining: '仰卧',
|
||||
sphinx: '狮身', stair: '楼梯', skater: '滑冰', sissy: '少女', pelican: '鹈鹕',
|
||||
// 命名训练法/专有名词(中文健身圈通用音译或保留专名)
|
||||
'v-up': 'V字挺身', 'v-sit': 'V坐', 'l-sit': 'L支撑', 'l-pull-up': 'L引体', janda: '扬达',
|
||||
otis: '奥蒂斯', flutter: '打水', peacher: ' preacher', preacher: '传教士',
|
||||
'ez-bar': 'EZ杠', 'ez-barbell': 'EZ杠铃', supper: '晚餐', pirate: '海盗',
|
||||
slingers: '摆臂', archer: '射手', 'band alternating v-up': '弹力带交替V字挺身',
|
||||
'band v-up': '弹力带V字挺身', floor: '地面', on: '于',
|
||||
};
|
||||
|
||||
// ---------- 词元词典 ----------
|
||||
const T = {
|
||||
// 器械
|
||||
dumbbell: '哑铃', dumbbells: '哑铃', barbell: '杠铃', cable: '绳索', cables: '绳索',
|
||||
kettlebell: '壶铃', band: '弹力带', bands: '弹力带', ball: '球', smith: '史密斯',
|
||||
lever: '杠杆', machine: '器械', machines: '器械', ez: '曲', sz: '直', trx: '悬吊',
|
||||
suspension: '悬挂', tire: '轮胎', tyre: '轮胎', sled: '雪橇', sledge: '雪橇',
|
||||
rope: '绳', ropes: '绳', bench: '凳', benches: '凳', plate: '杠铃片', wall: '墙',
|
||||
box: '跳箱', step: '踏板', stepbox: '踏板', chair: '椅', mat: '垫', weight: '负重',
|
||||
weights: '负重', weighted: '负重', medicine: '药', stability: '稳定', bosu: '博苏',
|
||||
foam: '泡沫', roller: '滚轮', wheel: '轮', ab: '腹', chin: '下巴', slam: '砸',
|
||||
swiss: '瑞士', mini: '迷你', loop: '环形', cage: '架', straps: '带', strap: '带',
|
||||
attachment: '附件', bar: '杠', handle: '握把', handles: '握把', platform: '台',
|
||||
pad: '垫', board: '板', rings: '吊环', ring: '吊环', pulley: '滑轮',
|
||||
// 姿态
|
||||
seated: '坐姿', sitting: '坐姿', sitted: '坐姿', standing: '站姿', lie: '卧', lying: '仰卧',
|
||||
incline: '上斜', decline: '下斜', flat: '平板', reverse: '反向', reversed: '反向',
|
||||
close: '窄距', closer: '更窄', closed: '窄距', overhead: '过顶', lateral: '侧',
|
||||
side: '侧', sides: '侧', front: '前', rear: '后', bent: '俯身', alternating: '交替',
|
||||
alternate: '交替', straight: '直臂', assisted: '助力', prone: '俯卧', supine: '仰卧',
|
||||
kneeling: '跪姿', angled: '斜', angle: '角度', neutral: '对握', wide: '宽距',
|
||||
narrow: '窄距', upright: '直立', single: '单', double: '双', one: '单', two: '双',
|
||||
both: '双', half: '半', full: '全', deep: '深', deeper: '更深', high: '高',
|
||||
higher: '更高', low: '低', long: '长', longer: '更长', short: '短', isometric: '静力',
|
||||
dynamic: '动态', active: '主动', passive: '被动', elevated: '抬升', elevated: '抬高',
|
||||
suspended: '悬吊', supported: '支撑', unsupported: '无支撑', kne: '膝', knees: '膝',
|
||||
knee: '膝', leg: '腿', legs: '腿', legged: '腿', arm: '臂', arms: '臂',
|
||||
// 动作
|
||||
curl: '弯举', curls: '弯举', press: '推举', presses: '推举', row: '划船', rows: '划船',
|
||||
raise: '上举', raises: '上举', squat: '深蹲', squats: '深蹲', squatting: '深蹲',
|
||||
extension: '伸展', extend: '伸展', fly: '飞鸟', flyes: '飞鸟', flies: '飞鸟',
|
||||
push: '推', pull: '拉', crunch: '卷腹', crunches: '卷腹', dip: '臂屈伸', dips: '臂屈伸',
|
||||
lunge: '箭步蹲', lunges: '箭步蹲', twist: '转体', twists: '转体', kick: '踢', kicks: '踢',
|
||||
swing: '摆荡', deadlift: '硬拉', preacher: '传教士', stretch: '拉伸', stretches: '拉伸',
|
||||
stretching: '拉伸', hold: '保持', squeeze: '挤压', sit: '坐', jump: '跳', jumps: '跳',
|
||||
march: '踏步', climb: '攀爬', climber: '攀爬', thrust: '推', thrusts: '推', lift: '上提',
|
||||
lifting: '上提', drag: '拖', roll: '滚动', rollerout: '滚伸', rollerer: '滚轮',
|
||||
circle: '画圈', circles: '画圈', circular: '环绕', rotate: '旋转', rotation: '旋转',
|
||||
rotational: '旋转', flex: '屈曲', flexion: '屈曲', flexor: '屈肌', abductor: '外展肌',
|
||||
abduction: '外展', adductor: '内收肌', adduction: '内收', pulse: '脉冲', bounce: '弹跳',
|
||||
hop: '单脚跳', hops: '单脚跳', walk: '行走', run: '跑', running: '跑', runner: '跑者',
|
||||
jog: '慢跑', pedal: '蹬', tuck: '收腹', reach: '伸展', bridge: '桥', jack: '开合',
|
||||
balance: '平衡', balancing: '平衡', stabilization: '稳定', stabilize: '稳定',
|
||||
contraction: '收缩', engage: '收紧', activate: '激活', isolate: '孤立', isolated: '孤立',
|
||||
compound: '复合', tempo: '节奏', pause: '停顿', breathe: '呼吸', breath: '呼吸',
|
||||
shrug: '耸肩', shrugs: '耸肩', tap: '拍击', clap: '击掌', carry: '农夫行走',
|
||||
crawl: '爬', glute: '臀', glutes: '臀', ham: '腘绳', hip: '髋', hips: '髋', hanging: '悬垂',
|
||||
shoulder: '肩', shoulders: '肩', chest: '胸', back: '背', arm: '臂', leg: '腿',
|
||||
calf: '小腿', calves: '小腿', biceps: '二头', triceps: '三头', wrist: '腕', core: '核心',
|
||||
abs: '腹', abdominal: '腹', abdominals: '腹', gluteal: '臀', thigh: '大腿', neck: '颈',
|
||||
forearm: '前臂', forearms: '前臂', upper: '上', lower: '下', quad: '股四头', quads: '股四头',
|
||||
quadriceps: '股四头', hamstring: '腘绳肌', hamstrings: '腘绳肌', oblique: '腹斜肌',
|
||||
obliques: '腹斜肌', delt: '三角肌', delts: '三角肌', deltoid: '三角肌', deltoids: '三角肌',
|
||||
trap: '斜方肌', traps: '斜方肌', trapezius: '斜方肌', lat: '背阔肌', lats: '背阔肌',
|
||||
pectoral: '胸', pectorals: '胸', pec: '胸', pecs: '胸', spinal: '脊柱', spine: '脊柱',
|
||||
erector: '竖脊肌', erectors: '竖脊肌', serratus: '前锯肌', adductors: '内收肌',
|
||||
abductors: '外展肌', rhomboid: '菱形肌', rhomboids: '菱形肌', grip: '握',
|
||||
hand: '手', hands: '手', finger: '手指', fingers: '手指', toe: '脚趾', toes: '脚趾',
|
||||
heel: '脚跟', foot: '脚', feet: '脚', ankle: '踝', ankles: '踝', knee: '膝',
|
||||
elbow: '肘', waist: '腰', torso: '躯干', body: '全身', male: '男', female: '女',
|
||||
v: 'V', rope: '绳',
|
||||
// 其余修饰词
|
||||
air: '空气', bike: '单车', fours: '四点', squad: '小队', toucher: '触', touchers: '触',
|
||||
touch: '触', with: '带', throw: '投掷', down: '下', gluteus: '臀肌', piriformis: '梨状肌',
|
||||
motion: '动作', parallel: '平行', rectus: '直肌', femoris: '股直肌', major: '大',
|
||||
towel: '毛巾', astride: '分腿', forth: '往复', backward: '向后', board: '板',
|
||||
basic: '基础', battling: '战斗', ups: '上', drop: '落下', squatting: '深蹲',
|
||||
bottoms: '底', cross: '交叉', body: '全身', butt: '臀', butterfly: '蝴蝶', yoga: '瑜伽',
|
||||
pose: '体式', inverse: '反向', variation: '变式', forward: '前', judo: '柔道', flip: '翻',
|
||||
kickback: '后踢', range: '全程', pro: '专业', stirrups: '马镫', drive: '驱动',
|
||||
rotational: '旋转', inner: '内', external: '外', crossovers: '交叉', kayak: '皮划艇',
|
||||
against: '靠', captains: '队长', extended: '伸展', cage: '架', butt: '臀', butterfly: '蝴蝶',
|
||||
clasped: '交叉', handstand: '倒立', keens: '膝', hyght: '高位', hyperextension: '超伸',
|
||||
impossible: '不可能', inchworm: '尺蠖', depth: '深度', scapula: '肩胛', scapular: '肩胛',
|
||||
intermediate: '中级', from: '从', head: '头', the: '的', of: '的', behind: '头后',
|
||||
bradford: '布拉德福德', rocky: '摇摆', skier: '滑雪', speed: '爆发', rocking: '摇摆',
|
||||
stiff: '直腿', thruster: '推举蹲', in: '内', t: 'T', twisted: '扭转',
|
||||
iron: '铁', across: '横过', face: '面', femoral: '股', pronated: '旋前', supinated: '旋后',
|
||||
pronation: '旋前', supination: '旋后', french: '法式', support: '支撑', peacher: ' preacher',
|
||||
plyo: '增强式', pronate: '旋前', row_shoulder: '划船肩', scott: '斯科特', waiter: '侍者',
|
||||
w: 'W', around: '环绕', bowling: '保龄', stork: '鹳', contralateral: '对侧', cuban: '古巴',
|
||||
can: '罐', finger: '手指', raised: '抬起', breeding: ' breeding', spider: '蜘蛛',
|
||||
world: '环绕', above: '上方', sumo: '相扑', supported: '支撑', tate: 'Tate',
|
||||
elevator: '电梯', butt: '臀', cocoons: '茧', crab: '蟹', curtsey: '屈膝礼', cycle: '单车',
|
||||
trainer: '训练器', diamond: '钻石', donkey: '驴式', basic: '基础', tap: '拍',
|
||||
clap: '击掌', clock: '时钟', cocoons: '茧', crab: '蟹', curtsey: '屈膝礼',
|
||||
flag: '旗', flexion: '屈曲', flutter: '打水', frankenstein: '弗兰肯斯坦', frog: '蛙',
|
||||
planche: '水平支撑', reps: '次', maltese: '马耳他', gironda: '吉龙达', sternum: '胸骨',
|
||||
ham: '腘绳', gorilla: '大猩猩', groin: '腹股沟', bends: '屈', clasped: '交叉',
|
||||
reversed: '反向', handstand: '倒立', keens: '膝', hyght: '高位', hyperextension: '超伸',
|
||||
impossible: '不可能', inchworm: '尺蠖', depth: '深度', scapula: '肩胛', scapular: '肩胛',
|
||||
intermediate: '中级', straps: '带', wipers: '雨刷', jackknife: '折刀', janda: '扬达',
|
||||
advanced: '高级', windmill: '风车', hang: '悬垂', renegade: '叛徒', position: '位',
|
||||
jerk: '上挺', figure: '图形', pass: '穿越', pirate: '海盗', pistol: '手枪',
|
||||
seesaw: '跷跷板', turkish: '土耳其', get: '起', style: '式', out: '出', kipping: '借力',
|
||||
muscle: '双力臂', korean: '韩式', l: 'L', lean: '前倾', left: '左', hook: '勾拳', boxing: '拳击',
|
||||
gripless: '无握', gripper: '握力', overhand: '正握', rotary: '旋转', pad: '垫',
|
||||
unilateral: '单侧', london: '伦敦', catch: '接', point: '点', multiple: '多次', response: '反应',
|
||||
release: '释放', modified: '改良', hindu: '印度', monster: '怪兽', negative: '退让',
|
||||
oblique: '腹斜', olympic: '奥林匹克', otis: '奥蒂斯', outside: '外', pelvic: '骨盆', into: '成',
|
||||
peroneals: '腓骨肌', cobra: '眼镜蛇', posterior: '后侧', tibialis: '胫骨肌', potty: '幼儿',
|
||||
power: '力量', prisoner: '囚徒', inside: '内', plus: '加', quarter: '四分之一', quick: '快速',
|
||||
feet: '脚', reclining: '仰卧', big: '大', thrusts: '推', hyper: '超', ring: '吊环', saw: '锯',
|
||||
depresor: '下压', retractor: '后缩', equipment: '器械', runners: '跑者', self: '自', semi: '半',
|
||||
stride: '跨步', outstretched: '伸展', slide: '滑动', sissy: '少女', skater: '滑冰', ski: '滑雪',
|
||||
ergometer: '测功仪', skin: '皮', cat: '猫', degrees: '度', closer: '更近', sledge: '雪橇',
|
||||
hammer: '锤', raises: '上举', sprint: '冲刺', spell: '法术', caster: '施法', sphinx: '狮身',
|
||||
split: '分腿', stalder: '斯塔尔德', staircase: '楼梯', star: '星形', stationary: '固定',
|
||||
straddle: '分腿', outer: '外', suspended: '悬吊', fallout: '塌陷', swimmer: '游泳', three: '三',
|
||||
twin: '双', upward: '向上', facing: '面向', dog: '犬', elliptical: '椭圆', walking: '行走',
|
||||
treadmill: '跑步机', stepmill: '楼梯机', cossack: '哥萨克', round: '圆', svend: '斯文德',
|
||||
wind: '风', greatest: '最佳', rollerer: '滚轮',
|
||||
};
|
||||
|
||||
PHRASES.sort((a, b) => b[0].length - a[0].length);
|
||||
|
||||
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||||
|
||||
// 英文→中文音译(用于兜底未知名词)
|
||||
const PHON = {
|
||||
archer: '射手', slingers: '摆臂', touchers: '触碰', airbike: '空气单车', fours: '四点',
|
||||
squad: '小队', circular: '环绕', piriformis: '梨状肌', gluteus: '臀肌', pallof: '帕洛夫',
|
||||
zercher: '泽彻', guillotine: '断头台', jefferson: '杰弗逊', pendlay: '彭德雷', jm: '杰姆',
|
||||
bradford: '布拉德福德', rocky: '摇摆', thruster: '推举蹲', skier: '滑雪者', blaster: '助力带',
|
||||
snatch: '挺举', judo: '柔道', kayak: '皮划艇', thibaudeau: '蒂博多', cambered: '弯杆',
|
||||
captains: '队长', cocoons: '茧式', curtsey: '屈膝礼', frankenstein: '弗兰肯斯坦', frog: '蛙',
|
||||
planche: '水平支撑', maltese: '马耳他', gironda: '吉龙达', sternum: '胸骨', gorilla: '大猩猩',
|
||||
groin: '腹股沟', inchworm: '尺蠖', jackknife: '折刀', janda: '扬达', windmill: '风车',
|
||||
renegade: '叛徒', pirate: '海盗', pistol: '手枪', seesaw: '跷跷板', turkish: '土耳其',
|
||||
kipping: '借力', london: '伦敦', otis: '奥蒂斯', sphinx: '狮身', stalder: '斯塔尔德',
|
||||
cossack: '哥萨克', svend: '斯文德', spell: '法术', caster: '施法', pelican: '鹈鹕',
|
||||
wipers: '雨刷', butterfly: '蝴蝶', monster: '怪兽', hindu: '印度', skater: '滑冰者',
|
||||
sissy: '少女', farmer: '农夫', flag: '旗帜', handstand: '倒立', keens: '膝', hyght: '高位',
|
||||
gripless: '无握', gripper: '握力', depresor: '下压', retractor: '后缩', peroneals: '腓骨肌',
|
||||
tibialis: '胫骨肌', potty: '幼儿', prisoner: '囚徒', reclining: '仰卧', cobra: '眼镜蛇',
|
||||
elliptical: '椭圆机', treadmill: '跑步机', stepmill: '楼梯机', ski: '滑雪', ergometer: '测功仪',
|
||||
skin: '皮', cat: '猫', rollerer: '滚轮', peacher: '传教士',
|
||||
row_shoulder: '划肩', world: '世界', stork: '鹳', bowling: '保龄', contralateral: '对侧',
|
||||
cuban: '古巴', can: '罐', spider: '蜘蛛', waiter: '侍者', tate: '泰特', elevator: '电梯',
|
||||
butt: '臀', monster: '怪兽', impossible: '不可能', depth: '深度', scapula: '肩胛',
|
||||
intermediate: '中级', straps: '带', jackknife: '折刀', advanced: '高级', hang: '悬垂',
|
||||
position: '位', jerk: '上挺', figure: '图形', pirate: '海盗', get: '起',
|
||||
kipping: '借力', muscle: '肌肉', korean: '韩式', lean: '前倾', boxing: '拳击', hook: '勾',
|
||||
overhand: '正握', rotary: '旋转', pad: '垫', unilateral: '单侧', london: '伦敦',
|
||||
};
|
||||
|
||||
function romanize(tok) {
|
||||
if (PHON[tok]) return PHON[tok];
|
||||
const map = { a: '阿', e: '埃', i: '伊', o: '奥', u: '乌', y: '伊',
|
||||
b: '布', c: '克', d: '德', f: '夫', g: '格', h: '赫', j: '吉', k: '克', l: '尔',
|
||||
m: '姆', n: '恩', p: '普', q: '克', r: '尔', s: '斯', t: '特', v: '夫', w: '乌', x: '克斯', z: '兹' };
|
||||
return tok.split('').map(c => map[c] || c).join('');
|
||||
}
|
||||
|
||||
// 应直接丢弃的干扰词(数据噪声 / OCR 错误),不参与翻译
|
||||
const DROP = new Set(['supper', 'breeding']);
|
||||
|
||||
function translate(name) {
|
||||
let s = ' ' + name.toLowerCase().replace(/°/g, '度') + ' ';
|
||||
// 去掉版本号标注,如 " v. 2" / "(v.3)" / " - v.2"
|
||||
s = s.replace(/\s*[(\-]?\s*v\.?\s*\d+\s*[)\]]?/g, ' ');
|
||||
s = ' ' + s.trim() + ' ';
|
||||
const ph = [];
|
||||
for (const [en, zh] of PHRASES) {
|
||||
const re = new RegExp('(^|[^a-z0-9])' + escapeRe(en) + '($|[^a-z0-9])', 'g');
|
||||
s = s.replace(re, (m, pre, post) => { ph.push(zh); return pre + `__p${ph.length - 1}__` + post; });
|
||||
}
|
||||
const raw = s.split(/[^a-z0-9一-龥_]+/).filter(Boolean);
|
||||
const out = [];
|
||||
for (const tk of raw) {
|
||||
if (tk.startsWith('__p') && tk.endsWith('__')) { out.push(ph[+tk.slice(3, -2)]); continue; }
|
||||
if (/^[0-9]+$/.test(tk)) { out.push(tk); continue; }
|
||||
if (DROP.has(tk)) continue;
|
||||
const zh = T[tk] || EXTRA[tk];
|
||||
if (zh) out.push(zh);
|
||||
else if (/^[a-z]+$/.test(tk)) out.push(romanize(tk));
|
||||
else out.push(tk);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const arr = JSON.parse(fs.readFileSync(DATA, 'utf-8'));
|
||||
let fully = 0;
|
||||
const residual = new Map();
|
||||
for (const rec of arr) {
|
||||
const text = translate(rec.name);
|
||||
rec.nameZh = text;
|
||||
if (/[a-zA-Z]/.test(text)) { fully++; residual.set(rec.name, text); }
|
||||
}
|
||||
const cov = (((arr.length - residual.size) / arr.length) * 100).toFixed(2);
|
||||
console.log(`总条数: ${arr.length}`);
|
||||
console.log(`已完全中文化(无英文字母): ${arr.length - residual.size} (${cov}%)`);
|
||||
console.log(`仍含英文的条数: ${residual.size}`);
|
||||
let n = 0;
|
||||
for (const [en, zh] of residual) { if (n++ < 30) console.log(` ${en} => ${zh}`); }
|
||||
console.log('--- 抽样 ---');
|
||||
for (let i = 0; i < 25; i++) console.log(` ${arr[i].name} => ${arr[i].nameZh}`);
|
||||
fs.writeFileSync(DATA, JSON.stringify(arr), 'utf-8');
|
||||
console.log('已写回 exercises.json(nameZh 字段)');
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
|
||||
export interface RawExercise {
|
||||
id: string;
|
||||
name: string;
|
||||
nameZh?: string;
|
||||
category?: string;
|
||||
body_part: string;
|
||||
equipment: string;
|
||||
@@ -30,6 +31,8 @@ export type ExerciseType = 'strength' | 'cardio' | 'flexibility';
|
||||
export interface Exercise {
|
||||
id: string;
|
||||
name: string;
|
||||
nameZh: string;
|
||||
nameEn: string;
|
||||
bodyPart: string;
|
||||
bodyPartLabel: string;
|
||||
equipment: string;
|
||||
@@ -55,6 +58,8 @@ export interface Exercise {
|
||||
export interface ExerciseSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
nameZh: string;
|
||||
nameEn: string;
|
||||
bodyPart: string;
|
||||
bodyPartLabel: string;
|
||||
equipment: string;
|
||||
|
||||
@@ -59,6 +59,8 @@ export class ExercisesService implements OnModuleInit {
|
||||
list = list.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
e.nameZh.toLowerCase().includes(q) ||
|
||||
e.nameEn.toLowerCase().includes(q) ||
|
||||
e.targetLabel.toLowerCase().includes(q) ||
|
||||
e.equipmentLabel.toLowerCase().includes(q) ||
|
||||
e.bodyPartLabel.toLowerCase().includes(q),
|
||||
|
||||
@@ -44,7 +44,9 @@ export function normalize(raw: RawExercise): Exercise {
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
name: raw.nameZh && raw.nameZh.trim() ? raw.nameZh : raw.name,
|
||||
nameZh: raw.nameZh && raw.nameZh.trim() ? raw.nameZh : raw.name,
|
||||
nameEn: raw.name,
|
||||
bodyPart: raw.body_part,
|
||||
bodyPartLabel: labelOf(BODY_PART_LABELS, raw.body_part),
|
||||
equipment: raw.equipment,
|
||||
@@ -72,6 +74,8 @@ export function toSummary(ex: Exercise): ExerciseSummary {
|
||||
return {
|
||||
id: ex.id,
|
||||
name: ex.name,
|
||||
nameZh: ex.nameZh,
|
||||
nameEn: ex.nameEn,
|
||||
bodyPart: ex.bodyPart,
|
||||
bodyPartLabel: ex.bodyPartLabel,
|
||||
equipment: ex.equipment,
|
||||
|
||||
@@ -24,24 +24,37 @@ module.exports = { BASE_URL: 'http://localhost:3000' };
|
||||
## 页面
|
||||
| 页面 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 训练组合 + 按部位/器械浏览 |
|
||||
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 官方计划分级入口 + 按部位/器械浏览 |
|
||||
| 分类浏览 | `pages/category` | 按 dimension(bodyPart/equipment/target/type/muscleGroup) 筛选列表 |
|
||||
| 智能推荐 | `pages/recommend` | 选目标肌群(按部位分组)+ 可选器械 → 评分排序结果 |
|
||||
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 相关推荐 + 分享 |
|
||||
| 训练组合 | `pages/collection` | 组合列表(推/拉/腿/核心/上肢/全身/居家/有氧) |
|
||||
| 组合详情 | `pages/collection-detail` | 某组合下的动作 |
|
||||
| 搜索 | `pages/search` | 名称/部位/器械/目标模糊搜索 |
|
||||
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 收藏按钮 + 加入计划 + 相关推荐 + 分享 |
|
||||
| 官方计划 | `pages/collection` | 按水平分级(新手/进阶/高级/全阶段)的官方推荐计划列表 |
|
||||
| 计划详情 | `pages/collection-detail` | 官方计划动作列表 + 收藏/一键导入我的计划 |
|
||||
| 搜索 | `pages/search` | 模糊搜索;`mode=select` 时用于向计划添加动作 |
|
||||
| **我的** | `pages/profile` | 个人中心:登录态、资料编辑、收藏/计划统计、菜单入口 |
|
||||
| **收藏动作** | `pages/favorites` | 我收藏的动作,可管理移除 |
|
||||
| **我的计划** | `pages/my-plans` | 计划列表,新建/进入 |
|
||||
| **编辑计划** | `pages/plan-edit` | 新建/编辑计划(名称/描述/图标/主题色) |
|
||||
| **计划详情** | `pages/plan-detail` | 我的计划动作管理(增删/编辑/删除) |
|
||||
| **收藏计划** | `pages/plan-favorites` | 我收藏的官方计划 |
|
||||
|
||||
## 用户体系
|
||||
- **登录**:`utils/auth.js` 封装 `wx.login → POST /api/auth/login`;后端未配 `WX_APPID` 时走开发降级(任意 code 建号)。登录态存于 `wx.storage`(`access_token`/`openid`/`user_profile`)。
|
||||
- **自动重登**:`utils/request.js` 在收到 401 时调用注册的 `unauthorizedHandler` 重新登录并重试一次原请求,页面无需感知。
|
||||
- **个人中心** `pages/profile`:未登录显示登录卡片;已登录显示头像/昵称/统计/菜单,支持改昵称与 emoji 头像、退出登录。
|
||||
- **收藏**:动作收藏(详情页心形)、官方计划收藏(计划详情页)。
|
||||
- **计划**:可新建/编辑计划、向计划添加或移除动作(搜索页 `select` 模式)、从官方计划一键导入为个人副本。
|
||||
|
||||
## 组件
|
||||
`exercise-card`(动作卡)、`section-header`(分区标题)、`chip`(筛选标签)、
|
||||
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航)。
|
||||
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航:首页/分类/我的)、
|
||||
`level-tag`(难度等级标签)、`avatar`(emoji 头像)、`plan-card`(计划卡)。
|
||||
|
||||
## 接口映射
|
||||
`services/exercise.js` 直接映射后端 7 类接口(`listExercises` / `getExercise` /
|
||||
`getCategories` / `recommend` / `listCollections` / `getCollectionExercises` /
|
||||
`search` / `getStats`),字段与后端响应一一对应。
|
||||
- `services/exercise.js`:列表/详情/分类/推荐/官方计划/搜索/统计。
|
||||
- `services/user.js`、`services/favorite.js`、`services/plan.js`:用户资料、收藏、计划,字段与后端响应一一对应。
|
||||
|
||||
## 注意事项
|
||||
- 小程序 `<image>` 支持 GIF,动作演示直接使用 `gifUrl`。
|
||||
- 所有请求经 `utils/request.js` 单点封装,失败统一 toast。
|
||||
- 所有请求经 `utils/request.js` 单点封装,自动带 `Authorization` 头、失败统一 toast。
|
||||
- 包体保持精简:主包仅含页面与组件,无大体积本地资源。
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
"pages/detail/detail",
|
||||
"pages/collection/collection",
|
||||
"pages/collection-detail/collection-detail",
|
||||
"pages/search/search"
|
||||
"pages/search/search",
|
||||
"pages/profile/profile",
|
||||
"pages/favorites/favorites",
|
||||
"pages/my-plans/my-plans",
|
||||
"pages/plan-edit/plan-edit",
|
||||
"pages/plan-detail/plan-detail",
|
||||
"pages/plan-favorites/plan-favorites"
|
||||
],
|
||||
"window": {
|
||||
"navigationStyle": "custom",
|
||||
|
||||
@@ -86,3 +86,18 @@ view, text, scroll-view, image, input {
|
||||
color: var(--gold-2);
|
||||
background: var(--gold-soft);
|
||||
}
|
||||
|
||||
/* ===== 横向滚动提示:右侧渐隐遮罩,提示「还可向右滑动」 ===== */
|
||||
.hscroll, .chip-scroll, .filterbar, .emoji-row, .prog-scroll {
|
||||
position: relative;
|
||||
}
|
||||
.hscroll::after, .chip-scroll::after, .filterbar::after, .emoji-row::after, .prog-scroll::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 56rpx;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(14,17,16,0) 0%, rgba(14,17,16,0.9) 100%);
|
||||
}
|
||||
|
||||
17
miniprogram/components/avatar/index.js
Normal file
17
miniprogram/components/avatar/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// 头像组件:纯 emoji 圆形头像(无二进制图片)。
|
||||
// props: avatar(emoji 字符串), size(rpx, 默认 120), bg(背景色, 默认 gold-soft)
|
||||
Component({
|
||||
properties: {
|
||||
avatar: { type: String, value: '💪' },
|
||||
size: { type: Number, value: 120 },
|
||||
bg: { type: String, value: 'rgba(217,179,108,0.12)' }
|
||||
},
|
||||
data: {
|
||||
fontSize: 60
|
||||
},
|
||||
observers: {
|
||||
size: function (size) {
|
||||
this.setData({ fontSize: Math.round(size * 0.5) });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/avatar/index.json
Normal file
4
miniprogram/components/avatar/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
5
miniprogram/components/avatar/index.wxml
Normal file
5
miniprogram/components/avatar/index.wxml
Normal file
@@ -0,0 +1,5 @@
|
||||
<view
|
||||
class="avatar"
|
||||
style="width: {{size}}rpx; height: {{size}}rpx; background: {{bg}}; font-size: {{fontSize}}rpx;">
|
||||
{{avatar || '💪'}}
|
||||
</view>
|
||||
9
miniprogram/components/avatar/index.wxss
Normal file
9
miniprogram/components/avatar/index.wxss
Normal file
@@ -0,0 +1,9 @@
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
border: 1rpx solid var(--gold-line);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
Component({
|
||||
properties: {
|
||||
active: { type: String, value: 'home' } // 'home' | 'category' | 'plan'
|
||||
active: { type: String, value: 'home' } // 'home' | 'category' | 'mine'
|
||||
},
|
||||
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' }
|
||||
{ key: 'plans', label: '计划', emoji: '📋', page: '/pages/my-plans/my-plans' },
|
||||
{ key: 'mine', label: '我的', emoji: '👤', page: '/pages/profile/profile' }
|
||||
]
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
z-index: 60;
|
||||
}
|
||||
.bn__item {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -16,6 +17,15 @@
|
||||
padding: 16rpx 0 14rpx;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.bn__item--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx; height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, var(--gold), var(--gold-2));
|
||||
}
|
||||
.bn__item--active { color: var(--gold); }
|
||||
.bn__emoji { font-size: 40rpx; line-height: 1; }
|
||||
.bn__label { font-size: 22rpx; margin-top: 6rpx; }
|
||||
|
||||
@@ -5,7 +5,20 @@ Component({
|
||||
reason: { type: String, value: '' },
|
||||
compact: { type: Boolean, value: false }
|
||||
},
|
||||
data: {
|
||||
imgSrc: '',
|
||||
imgError: false
|
||||
},
|
||||
observers: {
|
||||
// 卡片用轻量静态图(~6KB),避免 91KB 的 GIF 拖慢列表
|
||||
exercise(ex) {
|
||||
this.setData({ imgSrc: (ex && ex.image) || '', imgError: false });
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.setData({ imgError: true });
|
||||
},
|
||||
onTap() {
|
||||
const ex = this.data.exercise || {};
|
||||
this.triggerEvent('tap', { id: ex.id, exercise: ex });
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<view class="ex-card {{compact ? 'ex-card--compact' : ''}}" bindtap="onTap">
|
||||
<view class="ex-card__media">
|
||||
<image
|
||||
wx:if="{{exercise.gifUrl || exercise.image}}"
|
||||
wx:if="{{imgSrc && !imgError}}"
|
||||
class="ex-card__img"
|
||||
src="{{exercise.gifUrl || exercise.image}}"
|
||||
src="{{imgSrc}}"
|
||||
mode="aspectFill"
|
||||
lazy-load="true" />
|
||||
lazy-load="true"
|
||||
binderror="onImgError" />
|
||||
<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__name">{{exercise.displayName || 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>
|
||||
|
||||
29
miniprogram/components/level-tag/index.js
Normal file
29
miniprogram/components/level-tag/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// 难度等级标签:beginner / intermediate / advanced / all
|
||||
const MAP = {
|
||||
beginner: { text: '新手入门', color: '#3FBF7F', bg: 'rgba(63,191,127,0.14)' },
|
||||
intermediate: { text: '进阶提升', color: '#5B9DF9', bg: 'rgba(91,157,249,0.14)' },
|
||||
advanced: { text: '高级挑战', color: '#F08A5D', bg: 'rgba(240,138,93,0.14)' },
|
||||
all: { text: '全阶段', color: '#58C9B9', bg: 'rgba(88,201,185,0.14)' }
|
||||
};
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
level: { type: String, value: 'all' },
|
||||
label: { type: String, value: '' }
|
||||
},
|
||||
data: {
|
||||
text: '全阶段',
|
||||
color: '#58C9B9',
|
||||
bg: 'rgba(88,201,185,0.14)'
|
||||
},
|
||||
observers: {
|
||||
'level, label': function (level, label) {
|
||||
const m = MAP[level] || MAP.all;
|
||||
this.setData({
|
||||
text: label || m.text,
|
||||
color: m.color,
|
||||
bg: m.bg
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/level-tag/index.json
Normal file
4
miniprogram/components/level-tag/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
1
miniprogram/components/level-tag/index.wxml
Normal file
1
miniprogram/components/level-tag/index.wxml
Normal file
@@ -0,0 +1 @@
|
||||
<view class="lvl" style="color: {{color}}; background: {{bg}};">{{text}}</view>
|
||||
9
miniprogram/components/level-tag/index.wxss
Normal file
9
miniprogram/components/level-tag/index.wxss
Normal file
@@ -0,0 +1,9 @@
|
||||
.lvl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -3,23 +3,40 @@ const app = getApp();
|
||||
Component({
|
||||
properties: {
|
||||
title: { type: String, value: '' },
|
||||
showBack: { type: Boolean, value: false }
|
||||
showBack: { type: Boolean, value: false },
|
||||
// 拦截返回:为 true 时点击返回仅触发 bind:back 事件,由页面自行处理(如未保存草稿确认)
|
||||
interceptBack: { type: Boolean, value: false }
|
||||
},
|
||||
data: {
|
||||
statusBarHeight: 20
|
||||
statusBarHeight: 20,
|
||||
// 运行期根据页面栈动态决定:只有确实存在上一页时才显示返回键,
|
||||
// 避免在底部 tab 根页(页面栈深度 = 1)误显示返回键
|
||||
canBack: false
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const h = (app.globalData && app.globalData.statusBarHeight) || 20;
|
||||
this.setData({ statusBarHeight: h });
|
||||
const pages = getCurrentPages();
|
||||
this.setData({
|
||||
statusBarHeight: h,
|
||||
canBack: pages.length > 1
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onBack() {
|
||||
const pages = getCurrentPages();
|
||||
|
||||
// 页面要求自行处理返回逻辑(典型场景:编辑页有未保存内容需确认)
|
||||
if (this.data.interceptBack) {
|
||||
this.triggerEvent('back');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pages.length > 1) {
|
||||
wx.navigateBack();
|
||||
wx.navigateBack({ delta: 1 });
|
||||
} else {
|
||||
// 兜底:确实没有上一页时回到首页(正常 tab 根页不会显示返回键,此为保险)
|
||||
wx.reLaunch({ url: '/pages/index/index' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<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="{{showBack && canBack}}"
|
||||
class="navbar__back"
|
||||
hover-class="navbar__back--hover"
|
||||
hover-stay-time="80"
|
||||
bindtap="onBack">
|
||||
<view class="navbar__arrow"></view>
|
||||
</view>
|
||||
<view wx:if="{{title}}" class="navbar__title">{{title}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -10,20 +10,51 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 0 28rpx;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
/* 返回键:整块左侧区域可点,热区远大于图标本身 */
|
||||
.navbar__back {
|
||||
font-size: 56rpx;
|
||||
color: var(--gold);
|
||||
line-height: 1;
|
||||
width: 60rpx;
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-left: -10rpx; /* 让箭头更贴近系统返回键位置 */
|
||||
z-index: 2;
|
||||
}
|
||||
/* 扩展可点热区,提升命中率(左右留白也算可点) */
|
||||
.navbar__back::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: -24rpx;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.navbar__back--hover { opacity: 0.5; }
|
||||
|
||||
/* 用 CSS 绘制清晰、较粗的左箭头,避免依赖字体渲染 ‹ 字符 */
|
||||
.navbar__arrow {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-left: 5rpx solid var(--gold);
|
||||
border-bottom: 5rpx solid var(--gold);
|
||||
transform: rotate(45deg);
|
||||
margin-left: 14rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.navbar__title {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
color: var(--text);
|
||||
z-index: 1;
|
||||
pointer-events: none; /* 不拦截返回键的点击 */
|
||||
}
|
||||
|
||||
33
miniprogram/components/plan-card/index.js
Normal file
33
miniprogram/components/plan-card/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// 计划卡片:复用官方计划(CollectionDef)与我自己的计划(PlanSummary)。
|
||||
// props:
|
||||
// plan: { id|slug, emoji, color, name, description, count|exerciseCount, cover? }
|
||||
// target: 点击跳转的完整 url
|
||||
Component({
|
||||
properties: {
|
||||
plan: { type: Object, value: {} },
|
||||
target: { type: String, value: '' }
|
||||
},
|
||||
data: {
|
||||
count: 0,
|
||||
cover: '',
|
||||
hasCover: false,
|
||||
color: '#D9B36C'
|
||||
},
|
||||
observers: {
|
||||
plan: function (plan) {
|
||||
plan = plan || {};
|
||||
this.setData({
|
||||
count: plan.count || plan.exerciseCount || 0,
|
||||
cover: plan.cover || '',
|
||||
hasCover: !!(plan.cover),
|
||||
color: plan.color || '#D9B36C'
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
const url = this.data.target;
|
||||
if (url) wx.navigateTo({ url });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/plan-card/index.json
Normal file
4
miniprogram/components/plan-card/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
22
miniprogram/components/plan-card/index.wxml
Normal file
22
miniprogram/components/plan-card/index.wxml
Normal file
@@ -0,0 +1,22 @@
|
||||
<view class="pc">
|
||||
<view
|
||||
wx:if="{{hasCover}}"
|
||||
class="pc__cover"
|
||||
style="background: {{color}}22;">
|
||||
<image class="pc__img" src="{{cover}}" mode="aspectFill" lazy-load="true" />
|
||||
<view class="pc__badge" style="background: {{color}}22; color: {{color}};">{{plan.emoji}}</view>
|
||||
</view>
|
||||
<view wx:else class="pc__top" style="background: linear-gradient(135deg, {{color}}26, var(--surface));">
|
||||
<view class="pc__emoji" style="background: {{color}}22; color: {{color}};">{{plan.emoji}}</view>
|
||||
</view>
|
||||
|
||||
<view class="pc__body">
|
||||
<view class="pc__name" style="color: {{color}};">{{plan.name}}</view>
|
||||
<view wx:if="{{plan.description}}" class="pc__desc">{{plan.description}}</view>
|
||||
</view>
|
||||
|
||||
<view class="pc__foot">
|
||||
<text wx:if="{{count > 0}}" class="pc__count">{{count}} 个动作</text>
|
||||
<text class="pc__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
37
miniprogram/components/plan-card/index.wxss
Normal file
37
miniprogram/components/plan-card/index.wxss
Normal file
@@ -0,0 +1,37 @@
|
||||
.pc {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
}
|
||||
.pc__cover { position: relative; width: 100%; height: 220rpx; background: var(--surface-2); }
|
||||
.pc__img { width: 100%; height: 100%; display: block; }
|
||||
.pc__badge {
|
||||
position: absolute; left: 16rpx; top: 16rpx;
|
||||
width: 64rpx; height: 64rpx; border-radius: 18rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 38rpx; line-height: 1;
|
||||
}
|
||||
.pc__top { padding: 28rpx; display: flex; }
|
||||
.pc__emoji {
|
||||
width: 96rpx; height: 96rpx; border-radius: 24rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 52rpx; line-height: 1;
|
||||
}
|
||||
.pc__body { padding: 18rpx 22rpx 8rpx; }
|
||||
.pc__name { font-size: 30rpx; font-weight: 700; }
|
||||
.pc__desc {
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx; color: var(--text-2); line-height: 1.5;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
||||
}
|
||||
.pc__foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16rpx 22rpx 22rpx;
|
||||
border-top: 1rpx solid var(--line);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.pc__count { font-size: 24rpx; color: var(--text-2); }
|
||||
.pc__arrow { font-size: 36rpx; color: var(--gold); line-height: 1; }
|
||||
@@ -1,7 +1,7 @@
|
||||
// 后端基础地址配置。
|
||||
// ⚠️ 生产环境必须替换为已在「微信公众平台 -> 开发 -> 开发设置 -> 服务器域名」
|
||||
// 中配置的 HTTPS 合法域名(request 合法域名),否则真机无法发起请求。
|
||||
// localhost 仅用于开发者工具本地联调。
|
||||
// 当前直连服务器 IP(开发期在微信开发者工具中勾选「不校验合法域名」即可联调)。
|
||||
module.exports = {
|
||||
BASE_URL: 'http://localhost:3000'
|
||||
BASE_URL: 'http://192.227.237.8:3001'
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
const planSvc = require('../../services/plan');
|
||||
const favSvc = require('../../services/favorite');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -7,24 +9,25 @@ Page({
|
||||
slug: '',
|
||||
collection: null,
|
||||
exercises: [],
|
||||
total: 0,
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
total: 0
|
||||
favorited: false
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const slug = query.slug || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, slug });
|
||||
this.load();
|
||||
if (auth.isLogin()) {
|
||||
favSvc.planStatus(slug).then((r) => {
|
||||
this.setData({ favorited: !!(r && r.favorited) });
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
svc.getCollectionExercises(this.data.slug, {
|
||||
page: this.data.page,
|
||||
pageSize: this.data.pageSize
|
||||
}).then((res) => {
|
||||
planSvc.officialExercises(this.data.slug, 1, 60).then((res) => {
|
||||
this.setData({
|
||||
collection: res.collection,
|
||||
exercises: (res && res.items) || [],
|
||||
@@ -36,7 +39,39 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
// 收藏 / 取消收藏官方计划
|
||||
onToggleFav() {
|
||||
auth.ensureLogin().then(() => {
|
||||
const slug = this.data.slug;
|
||||
const wasFav = this.data.favorited;
|
||||
const op = wasFav ? favSvc.removePlan(slug) : favSvc.addPlan(slug);
|
||||
op.then(() => {
|
||||
this.setData({ favorited: !wasFav });
|
||||
wx.showToast({ title: wasFav ? '已取消收藏' : '已收藏', icon: 'none' });
|
||||
}).catch(() => {});
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
// 导入到我的计划
|
||||
onImport() {
|
||||
auth.ensureLogin().then(() => {
|
||||
wx.showLoading({ title: '导入中…', mask: true });
|
||||
planSvc.importOfficial(this.data.slug).then(() => {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '已导入到我的计划', icon: 'success' });
|
||||
setTimeout(() => {
|
||||
wx.navigateTo({ url: '/pages/my-plans/my-plans' });
|
||||
}, 600);
|
||||
}).catch(() => {
|
||||
wx.hideLoading();
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
wx.navigateTo({
|
||||
url: '/pages/detail/detail?from=collection&slug=' +
|
||||
encodeURIComponent(this.data.slug) + '&id=' + encodeURIComponent(e.detail.id)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
"exercise-card": "/components/exercise-card/index",
|
||||
"section-header": "/components/section-header/index",
|
||||
"level-tag": "/components/level-tag/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,42 @@
|
||||
|
||||
<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__name" style="color: {{collection.color}};">{{collection.name}}</view>
|
||||
<view wx:if="{{collection.nameEn}}" class="header__en">{{collection.nameEn}}</view>
|
||||
<view class="header__desc">{{collection.description}}</view>
|
||||
<view class="header__count" style="color: {{collection.color}};">共 {{total}} 个动作</view>
|
||||
|
||||
<view class="header__meta">
|
||||
<level-tag level="{{collection.level}}" />
|
||||
<text class="header__sep">·</text>
|
||||
<text class="header__m" wx:if="{{collection.sessionsPerWeek}}">每周 {{collection.sessionsPerWeek}} 次</text>
|
||||
<text class="header__sep" wx:if="{{collection.sessionsPerWeek && collection.durationWeeks}}">·</text>
|
||||
<text class="header__m" wx:if="{{collection.durationWeeks}}">约 {{collection.durationWeeks}} 周</text>
|
||||
<text class="header__sep" wx:if="{{collection.focus}}">·</text>
|
||||
<text class="header__m" wx:if="{{collection.focus}}">{{collection.focus}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<section-header eyebrow="EXERCISES" title="动作列表" subtitle="共 {{total}} 个动作" />
|
||||
<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:elif="{{!loading}}" class="empty">该计划暂无动作</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view class="actionbar">
|
||||
<view class="btn btn--ghost {{favorited ? 'btn--faved' : ''}}" bindtap="onToggleFav">
|
||||
<text>{{favorited ? '♥ 已收藏' : '♡ 收藏'}}</text>
|
||||
</view>
|
||||
<view class="btn btn--gold" bindtap="onImport">
|
||||
<text>+ 导入我的计划</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty-page">
|
||||
<view class="empty">{{ loading ? '加载中…' : '未找到该训练组合' }}</view>
|
||||
<view class="empty">{{ loading ? '加载中…' : '未找到该训练计划' }}</view>
|
||||
</view>
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 60rpx; }
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
|
||||
.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__en { font-size: 24rpx; color: var(--text-3); margin-top: 6rpx; letter-spacing: 1rpx; }
|
||||
.header__desc { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; line-height: 1.5; }
|
||||
.header__count { font-size: 24rpx; margin-top: 16rpx; }
|
||||
.header__meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12rpx; margin-top: 20rpx; }
|
||||
.header__sep { color: var(--text-3); font-size: 22rpx; }
|
||||
.header__m { font-size: 24rpx; color: var(--text-2); }
|
||||
|
||||
.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); }
|
||||
|
||||
.actionbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
display: flex; gap: 20rpx; 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));
|
||||
}
|
||||
.btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 999rpx; padding: 26rpx; font-size: 30rpx; font-weight: 700;
|
||||
}
|
||||
.btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }
|
||||
.btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--gold-line); }
|
||||
.btn--faved { color: var(--gold-2); border-color: var(--gold); }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
const planSvc = require('../../services/plan');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
collections: [],
|
||||
counts: {},
|
||||
groups: [],
|
||||
loading: true
|
||||
},
|
||||
|
||||
@@ -14,27 +13,22 @@ Page({
|
||||
this.load();
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 从详情返回(可能收藏/导入变化)时刷新
|
||||
if (!this.data.loading) 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(() => {});
|
||||
});
|
||||
planSvc.officialGroups().then((res) => {
|
||||
this.setData({ groups: (res && res.groups) || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
this.setData({ groups: [], loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onOpen(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/collection-detail/collection-detail?slug=' +
|
||||
encodeURIComponent(e.currentTarget.dataset.slug)
|
||||
});
|
||||
// 计算某计划的跳转地址(供 wxml 使用)
|
||||
planTarget(slug) {
|
||||
return '/pages/collection-detail/collection-detail?slug=' + encodeURIComponent(slug);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
"section-header": "/components/section-header/index",
|
||||
"plan-card": "/components/plan-card/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
<view class="page">
|
||||
<navbar title="训练计划" />
|
||||
<navbar title="官方训练计划" show-back />
|
||||
|
||||
<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 class="intro__eyebrow">OFFICIAL 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>
|
||||
<block wx:if="{{groups.length}}">
|
||||
<view class="group" wx:for="{{groups}}" wx:for-item="g" wx:key="level">
|
||||
<section-header eyebrow="LEVEL" title="{{g.label}}" />
|
||||
<view class="grid">
|
||||
<view class="grid__item" wx:for="{{g.plans}}" wx:for-item="p" wx:key="slug">
|
||||
<plan-card
|
||||
plan="{{p}}"
|
||||
target="/pages/collection-detail/collection-detail?slug={{p.slug}}" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="col-card__count" style="color: {{item.color}};">{{counts[item.slug] || ''}} 个动作 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
|
||||
</block>
|
||||
|
||||
<view wx:elif="{{!loading}}" class="empty">暂无官方计划</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="plan" />
|
||||
|
||||
<view style="height: 60rpx;"></view>
|
||||
</view>
|
||||
|
||||
@@ -4,19 +4,8 @@
|
||||
.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; }
|
||||
.group { margin-top: 12rpx; }
|
||||
.group + .group { margin-top: 36rpx; }
|
||||
.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: 80rpx 0; }
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
const favSvc = require('../../services/favorite');
|
||||
const planSvc = require('../../services/plan');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
id: '',
|
||||
planId: '',
|
||||
exercise: null,
|
||||
heroSrc: '',
|
||||
heroError: false,
|
||||
related: [],
|
||||
loading: true
|
||||
loading: true,
|
||||
favorited: false
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const id = query.id || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
|
||||
const planId = query.planId || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id, planId });
|
||||
this.load();
|
||||
if (auth.isLogin()) {
|
||||
favSvc.exerciseStatus(id).then((r) => {
|
||||
this.setData({ favorited: !!(r && r.favorited) });
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
svc.getExercise(this.data.id).then((ex) => {
|
||||
this.setData({ exercise: ex, loading: false });
|
||||
// 详情页用 GIF 演示动作;记录当前图源以便失败时降级到 JPG / 占位
|
||||
const heroSrc = ex && (ex.gifUrl || ex.image) ? (ex.gifUrl || ex.image) : '';
|
||||
this.setData({ exercise: ex, heroSrc, heroError: false, loading: false });
|
||||
if (ex && ex.target) {
|
||||
svc.listExercises({ target: ex.target, pageSize: 6 }).then((res) => {
|
||||
const related = ((res && res.items) || [])
|
||||
@@ -33,8 +48,52 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
// 收藏 / 取消收藏(心形按钮)
|
||||
onToggleFav() {
|
||||
auth.ensureLogin().then(() => {
|
||||
const id = this.data.id;
|
||||
const wasFav = this.data.favorited;
|
||||
const op = wasFav ? favSvc.removeExercise(id) : favSvc.addExercise(id);
|
||||
op.then(() => {
|
||||
this.setData({ favorited: !wasFav });
|
||||
wx.showToast({ title: wasFav ? '已取消收藏' : '已收藏', icon: 'none' });
|
||||
}).catch(() => {});
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
// 底部主按钮:加入计划(真实选择器)
|
||||
onAdd() {
|
||||
wx.showToast({ title: '已加入今日训练', icon: 'success' });
|
||||
if (this.data.planId) {
|
||||
planSvc.addExercise(this.data.planId, this.data.id).then(() => {
|
||||
wx.showToast({ title: '已加入计划', icon: 'success' });
|
||||
setTimeout(() => wx.navigateBack(), 500);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
auth.ensureLogin().then(() => planSvc.listMine()).then((res) => {
|
||||
const plans = (res && res.items) || res || [];
|
||||
if (!plans.length) {
|
||||
wx.showModal({
|
||||
title: '还没有训练计划',
|
||||
content: '先创建一个计划,再把动作加进去吧~',
|
||||
confirmText: '去创建',
|
||||
cancelText: '稍后',
|
||||
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/plan-edit/plan-edit' }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const names = plans.slice(0, 6).map((p) => p.name);
|
||||
wx.showActionSheet({
|
||||
itemList: names,
|
||||
success: (sheet) => {
|
||||
const plan = plans[sheet.tapIndex];
|
||||
if (!plan) return;
|
||||
planSvc.addExercise(plan.id, this.data.id).then(() => {
|
||||
wx.showToast({ title: '已加入「' + plan.name + '」', icon: 'success' });
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onBack() {
|
||||
@@ -46,14 +105,26 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 详情大图加载失败:GIF→JPG→emoji 占位,三级降级保证不空白
|
||||
onHeroError() {
|
||||
const ex = this.data.exercise || {};
|
||||
const showingGif = this.data.heroSrc && ex.gifUrl && this.data.heroSrc === ex.gifUrl;
|
||||
if (showingGif && ex.image) {
|
||||
this.setData({ heroSrc: ex.image });
|
||||
} else {
|
||||
this.setData({ heroError: true });
|
||||
}
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ex = this.data.exercise || {};
|
||||
const name = ex.displayName || ex.name || '';
|
||||
return {
|
||||
title: ex.name ? ('FITCOACH · ' + ex.name) : 'FITCOACH 智能健身教练',
|
||||
title: name ? ('FITCOACH · ' + name) : 'FITCOACH 智能健身教练',
|
||||
path: '/pages/detail/detail?id=' + this.data.id
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<view class="page" wx:if="{{exercise}}">
|
||||
<navbar title="{{exercise.name}}" show-back />
|
||||
<navbar title="{{exercise.displayName || exercise.name}}" show-back />
|
||||
|
||||
<view class="favbtn" style="top: {{statusBarHeight + 12}}px;" bindtap="onToggleFav">
|
||||
<text>{{favorited ? '♥' : '♡'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="hero">
|
||||
<image
|
||||
wx:if="{{exercise.gifUrl || exercise.image}}"
|
||||
wx:if="{{heroSrc && !heroError}}"
|
||||
class="hero__img"
|
||||
src="{{exercise.gifUrl || exercise.image}}"
|
||||
mode="aspectFill" />
|
||||
src="{{heroSrc}}"
|
||||
mode="aspectFill"
|
||||
lazy-load="true"
|
||||
binderror="onHeroError" />
|
||||
<view wx:else class="hero__ph">🏋️</view>
|
||||
</view>
|
||||
|
||||
<view class="body section">
|
||||
<view class="name">{{exercise.name}}</view>
|
||||
<view class="name">{{exercise.displayName || exercise.name}}</view>
|
||||
|
||||
<view class="metas">
|
||||
<view class="meta"><view class="meta__k">部位</view><view class="meta__v">{{exercise.bodyPartLabel}}</view></view>
|
||||
@@ -60,7 +66,7 @@
|
||||
</view>
|
||||
|
||||
<view class="addbar">
|
||||
<view class="cta" bindtap="onAdd">加入今日训练</view>
|
||||
<view class="cta" bindtap="onAdd">{{ planId ? '加入该计划' : '加入计划' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -55,3 +55,13 @@
|
||||
.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; }
|
||||
|
||||
.favbtn {
|
||||
position: fixed; right: 28rpx; z-index: 60;
|
||||
width: 72rpx; height: 72rpx; border-radius: 999rpx;
|
||||
background: rgba(14,17,16,0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1rpx solid var(--gold-line);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 44rpx; color: var(--gold-2); line-height: 1;
|
||||
}
|
||||
|
||||
55
miniprogram/pages/favorites/favorites.js
Normal file
55
miniprogram/pages/favorites/favorites.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const app = getApp();
|
||||
const favSvc = require('../../services/favorite');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
list: [],
|
||||
loading: true,
|
||||
managing: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (auth.isLogin()) this.load();
|
||||
else {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' });
|
||||
setTimeout(() => wx.navigateBack(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
favSvc.listExercises().then((list) => {
|
||||
this.setData({ list: list || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ list: [], loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onManage() {
|
||||
this.setData({ managing: !this.data.managing });
|
||||
},
|
||||
|
||||
onRemove(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
favSvc.removeExercise(id).then(() => {
|
||||
const list = this.data.list.filter(i => i.id !== id);
|
||||
this.setData({ list });
|
||||
wx.showToast({ title: '已移除', icon: 'none' });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
if (this.data.managing) return;
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
},
|
||||
|
||||
goRecommend() {
|
||||
wx.navigateTo({ url: '/pages/recommend/recommend' });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/favorites/favorites.json
Normal file
7
miniprogram/pages/favorites/favorites.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
28
miniprogram/pages/favorites/favorites.wxml
Normal file
28
miniprogram/pages/favorites/favorites.wxml
Normal file
@@ -0,0 +1,28 @@
|
||||
<view class="page">
|
||||
<navbar title="我的收藏" show-back />
|
||||
|
||||
<view class="bar">
|
||||
<view class="bar__count">{{list.length}} 个收藏动作</view>
|
||||
<view wx:if="{{list.length}}" class="bar__manage" bindtap="onManage">{{managing ? '完成' : '管理'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="grid" wx:if="{{list.length}}">
|
||||
<view class="grid__item" wx:for="{{list}}" wx:key="id">
|
||||
<view class="item">
|
||||
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
|
||||
<view wx:if="{{managing}}" class="remove" data-id="{{item.id}}" catchtap="onRemove">移除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{!loading}}" class="empty-box">
|
||||
<view class="empty-box__emoji">🔍</view>
|
||||
<view class="empty-box__t">还没有收藏,去发现喜欢的动作吧</view>
|
||||
<view class="empty-box__btn" bindtap="goRecommend">去推荐</view>
|
||||
</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 80rpx;"></view>
|
||||
</view>
|
||||
33
miniprogram/pages/favorites/favorites.wxss
Normal file
33
miniprogram/pages/favorites/favorites.wxss
Normal file
@@ -0,0 +1,33 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
.bar__count { font-size: 28rpx; color: var(--text-2); }
|
||||
.bar__manage {
|
||||
font-size: 28rpx; color: var(--gold); font-weight: 600;
|
||||
padding: 8rpx 28rpx; border: 1rpx solid var(--gold-line);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
|
||||
.grid__item { width: calc((100% - 18rpx) / 2); }
|
||||
.item { position: relative; }
|
||||
.remove {
|
||||
position: absolute; right: 12rpx; top: 12rpx; z-index: 5;
|
||||
background: rgba(229,84,75,0.92); color: #fff;
|
||||
font-size: 22rpx; padding: 8rpx 18rpx; border-radius: 999rpx;
|
||||
}
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
|
||||
|
||||
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
|
||||
.empty-box__emoji {
|
||||
width: 140rpx; height: 140rpx; border-radius: 999rpx;
|
||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
|
||||
.empty-box__btn {
|
||||
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 30rpx;
|
||||
}
|
||||
@@ -14,9 +14,9 @@ Page({
|
||||
{ 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' }
|
||||
{ key: 'official', label: '官方计划', emoji: '📋', url: '/pages/collection/collection' },
|
||||
{ key: 'myPlans', label: '我的计划', emoji: '🗂️', url: '/pages/my-plans/my-plans' },
|
||||
{ key: 'mine', label: '我的', emoji: '👤', url: '/pages/profile/profile' }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
44
miniprogram/pages/my-plans/my-plans.js
Normal file
44
miniprogram/pages/my-plans/my-plans.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const app = getApp();
|
||||
const planSvc = require('../../services/plan');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
plans: [],
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (auth.isLogin()) this.load();
|
||||
else {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' });
|
||||
setTimeout(() => wx.navigateBack(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
planSvc.listMine().then((plans) => {
|
||||
this.setData({ plans: plans || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ plans: [], loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onNew() {
|
||||
wx.navigateTo({ url: '/pages/plan-edit/plan-edit?mode=new' });
|
||||
},
|
||||
|
||||
onOpen(e) {
|
||||
wx.navigateTo({ url: '/pages/plan-detail/plan-detail?id=' + encodeURIComponent(e.currentTarget.dataset.id) });
|
||||
},
|
||||
|
||||
goOfficial() {
|
||||
wx.navigateTo({ url: '/pages/collection/collection' });
|
||||
}
|
||||
});
|
||||
8
miniprogram/pages/my-plans/my-plans.json
Normal file
8
miniprogram/pages/my-plans/my-plans.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"plan-card": "/components/plan-card/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
30
miniprogram/pages/my-plans/my-plans.wxml
Normal file
30
miniprogram/pages/my-plans/my-plans.wxml
Normal file
@@ -0,0 +1,30 @@
|
||||
<view class="page">
|
||||
<navbar title="我的训练计划" show-back />
|
||||
|
||||
<view class="newbar">
|
||||
<view class="newbar__btn" bindtap="onNew">+ 新建计划</view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<block wx:if="{{plans.length}}">
|
||||
<view class="grid">
|
||||
<view class="grid__item" wx:for="{{plans}}" wx:key="id">
|
||||
<plan-card
|
||||
plan="{{item}}"
|
||||
target="/pages/plan-detail/plan-detail?id={{item.id}}" />
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view wx:elif="{{!loading}}" class="empty-box">
|
||||
<view class="empty-box__emoji">📋</view>
|
||||
<view class="empty-box__t">还没有计划,创建一个开始训练吧</view>
|
||||
<view class="empty-box__btn" bindtap="onNew">+ 新建计划</view>
|
||||
<view class="empty-box__link" bindtap="goOfficial">或浏览官方训练计划 ›</view>
|
||||
</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="plans" />
|
||||
</view>
|
||||
25
miniprogram/pages/my-plans/my-plans.wxss
Normal file
25
miniprogram/pages/my-plans/my-plans.wxss
Normal file
@@ -0,0 +1,25 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.newbar { padding: 16rpx 32rpx; }
|
||||
.newbar__btn {
|
||||
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;
|
||||
}
|
||||
.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-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
|
||||
.empty-box__emoji {
|
||||
width: 160rpx; height: 160rpx; border-radius: 36rpx;
|
||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 88rpx;
|
||||
}
|
||||
.empty-box__t { font-size: 30rpx; color: var(--text-2); margin-top: 36rpx; text-align: center; }
|
||||
.empty-box__btn {
|
||||
margin-top: 44rpx; padding: 24rpx 88rpx; border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 30rpx;
|
||||
}
|
||||
.empty-box__link { margin-top: 28rpx; font-size: 26rpx; color: var(--gold); }
|
||||
74
miniprogram/pages/plan-detail/plan-detail.js
Normal file
74
miniprogram/pages/plan-detail/plan-detail.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const app = getApp();
|
||||
const planSvc = require('../../services/plan');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
id: '',
|
||||
plan: null,
|
||||
exercises: [],
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const id = query.id || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
|
||||
this.load();
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
planSvc.get(this.data.id).then((plan) => {
|
||||
const raw = plan && plan.updatedAt;
|
||||
const updatedAtText = typeof raw === 'string'
|
||||
? raw.slice(0, 10)
|
||||
: (raw ? String(raw).slice(0, 10) : '');
|
||||
this.setData({
|
||||
plan: { ...plan, updatedAtText },
|
||||
exercises: (plan && plan.exercises) || [],
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onRemoveExercise(e) {
|
||||
const exerciseId = e.currentTarget.dataset.id;
|
||||
planSvc.removeExercise(this.data.id, exerciseId).then(() => {
|
||||
this.setData({ exercises: this.data.exercises.filter(i => i.id !== exerciseId) });
|
||||
wx.showToast({ title: '已移除', icon: 'none' });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/detail/detail?planId=' + encodeURIComponent(this.data.id) +
|
||||
'&id=' + encodeURIComponent(e.detail.id)
|
||||
});
|
||||
},
|
||||
|
||||
onAdd() {
|
||||
wx.navigateTo({ url: '/pages/search/search?mode=select&planId=' + encodeURIComponent(this.data.id) });
|
||||
},
|
||||
|
||||
onEdit() {
|
||||
wx.navigateTo({ url: '/pages/plan-edit/plan-edit?mode=edit&id=' + encodeURIComponent(this.data.id) });
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
wx.showModal({
|
||||
title: '删除计划',
|
||||
content: '确定要删除该训练计划吗?此操作不可恢复。',
|
||||
confirmColor: '#E5544B',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
planSvc.remove(this.data.id).then(() => {
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
setTimeout(() => wx.navigateBack(), 500);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
8
miniprogram/pages/plan-detail/plan-detail.json
Normal file
8
miniprogram/pages/plan-detail/plan-detail.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index",
|
||||
"section-header": "/components/section-header/index"
|
||||
}
|
||||
}
|
||||
39
miniprogram/pages/plan-detail/plan-detail.wxml
Normal file
39
miniprogram/pages/plan-detail/plan-detail.wxml
Normal file
@@ -0,0 +1,39 @@
|
||||
<view class="page" wx:if="{{plan}}">
|
||||
<navbar title="{{plan.name}}" show-back />
|
||||
|
||||
<view class="header" style="background: linear-gradient(135deg, {{plan.color}}26, var(--surface));">
|
||||
<view class="header__emoji" style="background: {{plan.color}}22; color: {{plan.color}};">{{plan.emoji}}</view>
|
||||
<view class="header__name" style="color: {{plan.color}};">{{plan.name}}</view>
|
||||
<view wx:if="{{plan.description}}" class="header__desc">{{plan.description}}</view>
|
||||
<view class="header__meta">共 {{exercises.length}} 个动作<text wx:if="{{plan.updatedAtText}}"> · 更新于 {{plan.updatedAtText}}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<section-header eyebrow="EXERCISES" title="动作列表" subtitle="点击查看详情 · 可在动作页加入本计划" />
|
||||
|
||||
<view class="list" wx:if="{{exercises.length}}">
|
||||
<view class="list__item" wx:for="{{exercises}}" wx:key="id">
|
||||
<view class="list__card">
|
||||
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
|
||||
</view>
|
||||
<view class="list__remove" data-id="{{item.id}}" catchtap="onRemoveExercise">移除</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{!loading}}" class="empty-box">
|
||||
<view class="empty-box__emoji">🏋️</view>
|
||||
<view class="empty-box__t">这个计划还没有动作,去添加吧</view>
|
||||
<view class="empty-box__btn" bindtap="onAdd">+ 添加动作</view>
|
||||
</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view class="actionbar">
|
||||
<view class="btn btn--gold" bindtap="onAdd">+ 添加动作</view>
|
||||
<view class="btn btn--ghost" bindtap="onEdit">编辑</view>
|
||||
<view class="btn btn--del" bindtap="onDelete">删除计划</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty-page">
|
||||
<view class="empty">{{ loading ? '加载中…' : '未找到该计划' }}</view>
|
||||
</view>
|
||||
50
miniprogram/pages/plan-detail/plan-detail.wxss
Normal file
50
miniprogram/pages/plan-detail/plan-detail.wxss
Normal file
@@ -0,0 +1,50 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
|
||||
.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__meta { font-size: 24rpx; color: var(--text-2); margin-top: 16rpx; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.list__item { position: relative; }
|
||||
.list__card { width: 100%; }
|
||||
.list__remove {
|
||||
position: absolute; right: 12rpx; top: 12rpx; z-index: 5;
|
||||
background: rgba(229,84,75,0.92); color: #fff;
|
||||
font-size: 22rpx; padding: 8rpx 18rpx; border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
|
||||
.empty-page { min-height: 100vh; background: var(--bg-grad); }
|
||||
|
||||
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 100rpx 40rpx; }
|
||||
.empty-box__emoji {
|
||||
width: 150rpx; height: 150rpx; border-radius: 36rpx;
|
||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 80rpx;
|
||||
}
|
||||
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
|
||||
.empty-box__btn {
|
||||
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 30rpx;
|
||||
}
|
||||
|
||||
.actionbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
display: flex; gap: 16rpx; padding: 20rpx 24rpx;
|
||||
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));
|
||||
}
|
||||
.btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 999rpx; padding: 24rpx 10rpx; font-size: 28rpx; font-weight: 700;
|
||||
}
|
||||
.btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }
|
||||
.btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--line); flex: 0 0 auto; width: 180rpx; }
|
||||
.btn--del { background: rgba(229,84,75,0.16); color: #E5544B; border: 1rpx solid rgba(229,84,75,0.4); flex: 0 0 auto; width: 180rpx; }
|
||||
88
miniprogram/pages/plan-edit/plan-edit.js
Normal file
88
miniprogram/pages/plan-edit/plan-edit.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const app = getApp();
|
||||
const planSvc = require('../../services/plan');
|
||||
|
||||
const EMOJIS = ['📋', '💪', '🔥', '🏋️', '🏃', '🧘', '🤸', '🥊', '🚴', '⚡', '🌟', '🎯'];
|
||||
const COLORS = ['#D9B36C', '#58C9B9', '#9B8CFF', '#F08A5D', '#3FBF7F', '#5B9DF9'];
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
mode: 'new',
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '📋',
|
||||
color: '#D9B36C',
|
||||
emojis: EMOJIS,
|
||||
colors: COLORS,
|
||||
loading: false,
|
||||
dirty: false // 是否有未保存的修改
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const mode = query.mode === 'edit' ? 'edit' : 'new';
|
||||
const id = query.id || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, mode, id });
|
||||
if (mode === 'edit' && id) {
|
||||
this.setData({ loading: true });
|
||||
planSvc.get(id).then((p) => {
|
||||
this.setData({
|
||||
name: p.name || '',
|
||||
description: p.description || '',
|
||||
emoji: p.emoji || '📋',
|
||||
color: p.color || '#D9B36C',
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onName(e) { this.setData({ name: e.detail.value, dirty: true }); },
|
||||
onDesc(e) { this.setData({ description: e.detail.value, dirty: true }); },
|
||||
onPickEmoji(e) { this.setData({ emoji: e.currentTarget.dataset.emoji, dirty: true }); },
|
||||
onPickColor(e) { this.setData({ color: e.currentTarget.dataset.color, dirty: true }); },
|
||||
|
||||
// 点返回键(navbar 拦截模式)触发:有未保存改动时先确认
|
||||
onBackRequest() {
|
||||
if (!this.data.dirty) {
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
wx.showModal({
|
||||
title: '放弃编辑?',
|
||||
content: '当前修改尚未保存,确定要退出吗?',
|
||||
confirmText: '退出',
|
||||
cancelText: '继续编辑',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateBack();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onSave() {
|
||||
const name = (this.data.name || '').trim();
|
||||
if (!name) {
|
||||
wx.showToast({ title: '请填写计划名称', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const dto = {
|
||||
name,
|
||||
description: (this.data.description || '').trim(),
|
||||
emoji: this.data.emoji,
|
||||
color: this.data.color
|
||||
};
|
||||
wx.showLoading({ title: '保存中…', mask: true });
|
||||
const op = this.data.mode === 'edit'
|
||||
? planSvc.update(this.data.id, dto)
|
||||
: planSvc.create(dto);
|
||||
op.then(() => {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '已保存', icon: 'success' });
|
||||
setTimeout(() => wx.navigateBack(), 500);
|
||||
}).catch(() => {
|
||||
wx.hideLoading();
|
||||
});
|
||||
}
|
||||
});
|
||||
6
miniprogram/pages/plan-edit/plan-edit.json
Normal file
6
miniprogram/pages/plan-edit/plan-edit.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index"
|
||||
}
|
||||
}
|
||||
64
miniprogram/pages/plan-edit/plan-edit.wxml
Normal file
64
miniprogram/pages/plan-edit/plan-edit.wxml
Normal file
@@ -0,0 +1,64 @@
|
||||
<view class="page">
|
||||
<navbar title="{{mode === 'edit' ? '编辑计划' : '新建计划'}}" show-back intercept-back bind:back="onBackRequest" />
|
||||
|
||||
<view class="section">
|
||||
<view class="preview" style="background: linear-gradient(135deg, {{color}}26, var(--surface));">
|
||||
<view class="preview__emoji" style="background: {{color}}22; color: {{color}};">{{emoji}}</view>
|
||||
<view class="preview__name" style="color: {{color}};">{{name || '计划名称'}}</view>
|
||||
<view class="preview__desc">{{description || '计划描述(选填)'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<view class="field__label">计划名称</view>
|
||||
<input
|
||||
class="field__input"
|
||||
value="{{name}}"
|
||||
placeholder="例如:居家增肌 12 周"
|
||||
placeholder-class="ph"
|
||||
maxlength="20"
|
||||
bindinput="onName" />
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<view class="field__label">计划描述</view>
|
||||
<textarea
|
||||
class="field__area"
|
||||
value="{{description}}"
|
||||
placeholder="简单描述你的训练目标(选填)"
|
||||
placeholder-class="ph"
|
||||
maxlength="120"
|
||||
auto-height
|
||||
bindinput="onDesc" />
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<view class="field__label">图标</view>
|
||||
<scroll-view scroll-x class="emoji-row">
|
||||
<view
|
||||
wx:for="{{emojis}}"
|
||||
wx:key="*this"
|
||||
class="emoji {{emoji === item ? 'emoji--active' : ''}}"
|
||||
style="{{emoji === item ? 'border-color:' + color + '; background:' + color + '22;' : ''}}"
|
||||
data-emoji="{{item}}"
|
||||
bindtap="onPickEmoji">{{item}}</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<view class="field__label">主题色</view>
|
||||
<view class="colors">
|
||||
<view
|
||||
wx:for="{{colors}}"
|
||||
wx:key="*this"
|
||||
class="swatch {{color === item ? 'swatch--active' : ''}}"
|
||||
style="background: {{item}};"
|
||||
data-color="{{item}}"
|
||||
bindtap="onPickColor"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="savebar">
|
||||
<view class="savebar__btn" bindtap="onSave">保存计划</view>
|
||||
</view>
|
||||
</view>
|
||||
54
miniprogram/pages/plan-edit/plan-edit.wxss
Normal file
54
miniprogram/pages/plan-edit/plan-edit.wxss
Normal file
@@ -0,0 +1,54 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
|
||||
|
||||
.preview {
|
||||
border-radius: var(--radius); padding: 36rpx;
|
||||
border: 1rpx solid var(--line); text-align: center;
|
||||
}
|
||||
.preview__emoji {
|
||||
width: 120rpx; height: 120rpx; border-radius: 28rpx;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 64rpx;
|
||||
}
|
||||
.preview__name { font-size: 38rpx; font-weight: 700; margin-top: 20rpx; }
|
||||
.preview__desc { font-size: 24rpx; color: var(--text-2); margin-top: 10rpx; }
|
||||
|
||||
.field { margin-top: 40rpx; }
|
||||
.field__label { font-size: 26rpx; color: var(--text-2); margin-bottom: 16rpx; }
|
||||
.field__input, .field__area {
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius-sm); padding: 24rpx; color: var(--text);
|
||||
font-size: 30rpx; width: 100%;
|
||||
}
|
||||
.field__area { min-height: 140rpx; }
|
||||
.ph { color: var(--text-3); }
|
||||
|
||||
.emoji-row { white-space: nowrap; }
|
||||
.emoji {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 84rpx; height: 84rpx; margin-right: 14rpx;
|
||||
border-radius: 20rpx; background: var(--surface-2);
|
||||
border: 1rpx solid var(--line); font-size: 44rpx;
|
||||
}
|
||||
.emoji--active { border-width: 2rpx; }
|
||||
|
||||
.colors { display: flex; flex-wrap: wrap; gap: 24rpx; }
|
||||
.swatch {
|
||||
width: 72rpx; height: 72rpx; border-radius: 999rpx;
|
||||
border: 4rpx solid transparent; box-sizing: border-box;
|
||||
}
|
||||
.swatch--active { border-color: var(--text); box-shadow: 0 0 0 2rpx var(--bg); }
|
||||
|
||||
.savebar {
|
||||
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));
|
||||
}
|
||||
.savebar__btn {
|
||||
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;
|
||||
}
|
||||
36
miniprogram/pages/plan-favorites/plan-favorites.js
Normal file
36
miniprogram/pages/plan-favorites/plan-favorites.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const app = getApp();
|
||||
const favSvc = require('../../services/favorite');
|
||||
const auth = require('../../utils/auth');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
plans: [],
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (auth.isLogin()) this.load();
|
||||
else {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' });
|
||||
setTimeout(() => wx.navigateBack(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
favSvc.listPlans().then((plans) => {
|
||||
this.setData({ plans: plans || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ plans: [], loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
goOfficial() {
|
||||
wx.navigateTo({ url: '/pages/collection/collection' });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/plan-favorites/plan-favorites.json
Normal file
7
miniprogram/pages/plan-favorites/plan-favorites.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"plan-card": "/components/plan-card/index"
|
||||
}
|
||||
}
|
||||
24
miniprogram/pages/plan-favorites/plan-favorites.wxml
Normal file
24
miniprogram/pages/plan-favorites/plan-favorites.wxml
Normal file
@@ -0,0 +1,24 @@
|
||||
<view class="page">
|
||||
<navbar title="收藏的官方计划" show-back />
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<block wx:if="{{plans.length}}">
|
||||
<view class="grid">
|
||||
<view class="grid__item" wx:for="{{plans}}" wx:key="slug">
|
||||
<plan-card
|
||||
plan="{{item}}"
|
||||
target="/pages/collection-detail/collection-detail?slug={{item.slug}}" />
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view wx:elif="{{!loading}}" class="empty-box">
|
||||
<view class="empty-box__emoji">⭐</view>
|
||||
<view class="empty-box__t">还没有收藏官方计划</view>
|
||||
<view class="empty-box__btn" bindtap="goOfficial">浏览官方计划</view>
|
||||
</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 80rpx;"></view>
|
||||
</view>
|
||||
17
miniprogram/pages/plan-favorites/plan-favorites.wxss
Normal file
17
miniprogram/pages/plan-favorites/plan-favorites.wxss
Normal file
@@ -0,0 +1,17 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.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-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
|
||||
.empty-box__emoji {
|
||||
width: 150rpx; height: 150rpx; border-radius: 36rpx;
|
||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 80rpx;
|
||||
}
|
||||
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
|
||||
.empty-box__btn {
|
||||
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 30rpx;
|
||||
}
|
||||
129
miniprogram/pages/profile/profile.js
Normal file
129
miniprogram/pages/profile/profile.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const app = getApp();
|
||||
const auth = require('../../utils/auth');
|
||||
const favSvc = require('../../services/favorite');
|
||||
const planSvc = require('../../services/plan');
|
||||
|
||||
const EMOJIS = ['💪', '🏋️', '🔥', '🏃', '🧘', '🤸', '🥊', '🚴', '⚡', '🌟', '🐯', '🦁'];
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
loggedIn: false,
|
||||
profile: { nickname: '', avatar: '💪' },
|
||||
counts: { fav: 0, plans: 0, favPlans: 0 },
|
||||
loading: false,
|
||||
// 编辑态
|
||||
showEmojiPicker: false,
|
||||
emojis: EMOJIS,
|
||||
draftNickname: '',
|
||||
draftAvatar: '💪'
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadData();
|
||||
},
|
||||
|
||||
loadData() {
|
||||
if (!auth.isLogin()) {
|
||||
this.setData({ loggedIn: false });
|
||||
return;
|
||||
}
|
||||
const profile = auth.getProfile();
|
||||
this.setData({ loggedIn: true, profile, loading: true });
|
||||
Promise.all([
|
||||
favSvc.listExercises().catch(() => []),
|
||||
planSvc.listMine().catch(() => []),
|
||||
favSvc.listPlans().catch(() => [])
|
||||
]).then(([fav, plans, favPlans]) => {
|
||||
this.setData({
|
||||
counts: {
|
||||
fav: (fav || []).length,
|
||||
plans: (plans || []).length,
|
||||
favPlans: (favPlans || []).length
|
||||
},
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onLogin() {
|
||||
auth.login().then(() => {
|
||||
this.loadData();
|
||||
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
onEdit() {
|
||||
const cur = this.data.profile;
|
||||
wx.showModal({
|
||||
title: '修改昵称',
|
||||
editable: true,
|
||||
placeholderText: '输入昵称',
|
||||
content: cur.nickname || '',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.setData({
|
||||
draftNickname: (res.content || '').trim() || cur.nickname,
|
||||
draftAvatar: cur.avatar || '💪',
|
||||
showEmojiPicker: true
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onPickEmoji(e) {
|
||||
this.setData({ draftAvatar: e.currentTarget.dataset.emoji });
|
||||
},
|
||||
|
||||
onCancelEdit() {
|
||||
this.setData({ showEmojiPicker: false });
|
||||
},
|
||||
|
||||
onConfirmEdit() {
|
||||
const nickname = (this.data.draftNickname || '').trim();
|
||||
const avatar = this.data.draftAvatar;
|
||||
if (!nickname) {
|
||||
wx.showToast({ title: '昵称不能为空', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.showLoading({ title: '保存中…', mask: true });
|
||||
auth.updateProfile({ nickname, avatar }).then((p) => {
|
||||
wx.hideLoading();
|
||||
this.setData({ profile: p, showEmojiPicker: false });
|
||||
wx.showToast({ title: '已保存', icon: 'success' });
|
||||
}).catch(() => {
|
||||
wx.hideLoading();
|
||||
});
|
||||
},
|
||||
|
||||
onLogout() {
|
||||
wx.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出当前账号吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
auth.logout();
|
||||
this.setData({
|
||||
loggedIn: false,
|
||||
profile: { nickname: '', avatar: '💪' },
|
||||
counts: { fav: 0, plans: 0, favPlans: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
goFav() { wx.navigateTo({ url: '/pages/favorites/favorites' }); },
|
||||
goPlans() { wx.navigateTo({ url: '/pages/my-plans/my-plans' }); },
|
||||
goFavPlans() { wx.navigateTo({ url: '/pages/plan-favorites/plan-favorites' }); },
|
||||
goOfficial() { wx.navigateTo({ url: '/pages/collection/collection' }); },
|
||||
|
||||
noop() {}
|
||||
});
|
||||
8
miniprogram/pages/profile/profile.json
Normal file
8
miniprogram/pages/profile/profile.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"avatar": "/components/avatar/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
87
miniprogram/pages/profile/profile.wxml
Normal file
87
miniprogram/pages/profile/profile.wxml
Normal file
@@ -0,0 +1,87 @@
|
||||
<view class="page">
|
||||
<navbar title="我的" />
|
||||
|
||||
<!-- 未登录 -->
|
||||
<view wx:if="{{!loggedIn}}" class="login">
|
||||
<view class="login__logo">💪</view>
|
||||
<view class="login__title">FITCOACH 智能健身教练</view>
|
||||
<view class="login__sub">登录后同步你的收藏与训练计划</view>
|
||||
<button class="login__btn" bindtap="onLogin">微信登录</button>
|
||||
</view>
|
||||
|
||||
<!-- 已登录 -->
|
||||
<view wx:else class="center">
|
||||
<view class="profile">
|
||||
<view class="profile__head">
|
||||
<avatar avatar="{{profile.avatar}}" size="{{120}}" />
|
||||
<view class="profile__info">
|
||||
<view class="profile__name">{{profile.nickname || '健身达人'}}</view>
|
||||
<view class="profile__edit" bindtap="onEdit">编辑 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats">
|
||||
<view class="stat" bindtap="goFav">
|
||||
<view class="stat__n">{{counts.fav}}</view>
|
||||
<view class="stat__l">收藏动作</view>
|
||||
</view>
|
||||
<view class="stat" bindtap="goPlans">
|
||||
<view class="stat__n">{{counts.plans}}</view>
|
||||
<view class="stat__l">我的计划</view>
|
||||
</view>
|
||||
<view class="stat" bindtap="goFavPlans">
|
||||
<view class="stat__n">{{counts.favPlans}}</view>
|
||||
<view class="stat__l">收藏计划</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu card">
|
||||
<view class="cell" bindtap="goFav">
|
||||
<text class="cell__icon">❤️</text>
|
||||
<text class="cell__label">我的收藏动作</text>
|
||||
<text class="cell__arrow">›</text>
|
||||
</view>
|
||||
<view class="cell" bindtap="goPlans">
|
||||
<text class="cell__icon">🗂️</text>
|
||||
<text class="cell__label">我的训练计划</text>
|
||||
<text class="cell__arrow">›</text>
|
||||
</view>
|
||||
<view class="cell" bindtap="goFavPlans">
|
||||
<text class="cell__icon">⭐</text>
|
||||
<text class="cell__label">收藏的官方计划</text>
|
||||
<text class="cell__arrow">›</text>
|
||||
</view>
|
||||
<view class="cell" bindtap="goOfficial">
|
||||
<text class="cell__icon">📋</text>
|
||||
<text class="cell__label">官方训练计划</text>
|
||||
<text class="cell__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logout" bindtap="onLogout">退出登录</view>
|
||||
</view>
|
||||
|
||||
<!-- 编辑头像弹层 -->
|
||||
<view wx:if="{{showEmojiPicker}}" class="sheet" bindtap="onCancelEdit">
|
||||
<view class="sheet__panel" catchtap="noop">
|
||||
<view class="sheet__title">选择头像</view>
|
||||
<scroll-view scroll-x class="emoji-row">
|
||||
<view
|
||||
wx:for="{{emojis}}"
|
||||
wx:key="*this"
|
||||
class="emoji {{draftAvatar === item ? 'emoji--active' : ''}}"
|
||||
data-emoji="{{item}}"
|
||||
bindtap="onPickEmoji">{{item}}</view>
|
||||
</scroll-view>
|
||||
<view class="sheet__nick">{{draftNickname || '未命名'}}</view>
|
||||
<view class="sheet__actions">
|
||||
<view class="sheet__btn sheet__btn--ghost" bindtap="onCancelEdit">取消</view>
|
||||
<view class="sheet__btn sheet__btn--gold" bindtap="onConfirmEdit">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="mine" />
|
||||
</view>
|
||||
84
miniprogram/pages/profile/profile.wxss
Normal file
84
miniprogram/pages/profile/profile.wxss
Normal file
@@ -0,0 +1,84 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
|
||||
/* 未登录 */
|
||||
.login {
|
||||
min-height: 70vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; padding: 60rpx;
|
||||
}
|
||||
.login__logo {
|
||||
width: 160rpx; height: 160rpx; border-radius: 999rpx;
|
||||
background: var(--gold-soft); border: 1rpx solid var(--gold-line);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 88rpx;
|
||||
}
|
||||
.login__title { font-size: 38rpx; font-weight: 700; margin-top: 32rpx; color: var(--text); }
|
||||
.login__sub { font-size: 26rpx; color: var(--text-2); margin-top: 14rpx; }
|
||||
.login__btn {
|
||||
margin-top: 56rpx; width: 420rpx;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 32rpx;
|
||||
border-radius: 999rpx; padding: 24rpx;
|
||||
}
|
||||
.login__btn::after { border: none; }
|
||||
|
||||
/* 个人中心 */
|
||||
.center { padding: 32rpx; }
|
||||
.profile {
|
||||
background: linear-gradient(135deg, rgba(217,179,108,0.14), var(--surface));
|
||||
border: 1rpx solid var(--gold-line);
|
||||
border-radius: var(--radius); padding: 36rpx;
|
||||
}
|
||||
.profile__head { display: flex; align-items: center; gap: 28rpx; }
|
||||
.profile__info { flex: 1; min-width: 0; }
|
||||
.profile__name { font-size: 40rpx; font-weight: 700; color: var(--gold); }
|
||||
.profile__edit { font-size: 26rpx; color: var(--text-2); margin-top: 10rpx; }
|
||||
|
||||
.stats { display: flex; margin-top: 36rpx; }
|
||||
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.stat + .stat { border-left: 1rpx solid var(--line); }
|
||||
.stat__n { font-size: 44rpx; font-weight: 700; color: var(--text); }
|
||||
.stat__l { font-size: 24rpx; color: var(--text-2); margin-top: 8rpx; }
|
||||
|
||||
.menu { margin-top: 28rpx; padding: 8rpx 28rpx; }
|
||||
.cell {
|
||||
display: flex; align-items: center; gap: 24rpx;
|
||||
padding: 30rpx 0; border-bottom: 1rpx solid var(--line);
|
||||
}
|
||||
.cell:last-child { border-bottom: none; }
|
||||
.cell__icon { font-size: 38rpx; width: 48rpx; text-align: center; }
|
||||
.cell__label { flex: 1; font-size: 30rpx; color: var(--text); }
|
||||
.cell__arrow { font-size: 40rpx; color: var(--gold); line-height: 1; }
|
||||
|
||||
.logout {
|
||||
margin-top: 40rpx; text-align: center;
|
||||
font-size: 28rpx; color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 头像选择弹层 */
|
||||
.sheet {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: flex-end;
|
||||
}
|
||||
.sheet__panel {
|
||||
width: 100%; background: var(--surface);
|
||||
border-top-left-radius: 32rpx; border-top-right-radius: 32rpx;
|
||||
padding: 36rpx 32rpx calc(36rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.sheet__title { font-size: 30rpx; font-weight: 700; color: var(--text); }
|
||||
.emoji-row { white-space: nowrap; margin-top: 24rpx; }
|
||||
.emoji {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 88rpx; height: 88rpx; margin-right: 16rpx;
|
||||
border-radius: 20rpx; background: var(--surface-2);
|
||||
border: 1rpx solid var(--line); font-size: 48rpx;
|
||||
}
|
||||
.emoji--active { border-color: var(--gold); background: var(--gold-soft); }
|
||||
.sheet__nick { margin-top: 24rpx; font-size: 28rpx; color: var(--text-2); text-align: center; }
|
||||
.sheet__actions { display: flex; gap: 20rpx; margin-top: 28rpx; }
|
||||
.sheet__btn {
|
||||
flex: 1; text-align: center; padding: 24rpx; border-radius: 999rpx;
|
||||
font-size: 30rpx; font-weight: 700;
|
||||
}
|
||||
.sheet__btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--line); }
|
||||
.sheet__btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }
|
||||
@@ -1,5 +1,6 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
const planSvc = require('../../services/plan');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -7,11 +8,24 @@ Page({
|
||||
q: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
searched: false
|
||||
searched: false,
|
||||
selectMode: false,
|
||||
planId: '',
|
||||
navTitle: '搜索'
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(query) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
const selectMode = query.mode === 'select';
|
||||
const planId = query.planId || '';
|
||||
this.setData({
|
||||
selectMode,
|
||||
planId,
|
||||
navTitle: selectMode ? '选择动作' : '搜索'
|
||||
});
|
||||
if (selectMode) {
|
||||
wx.setNavigationBarTitle({ title: '选择动作' });
|
||||
}
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
@@ -43,6 +57,14 @@ Page({
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
const id = e.detail.id;
|
||||
if (this.data.selectMode && this.data.planId) {
|
||||
planSvc.addExercise(this.data.planId, id).then(() => {
|
||||
wx.showToast({ title: '已添加', icon: 'success' });
|
||||
setTimeout(() => wx.navigateBack(), 500);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(id) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<view class="page">
|
||||
<navbar title="搜索" />
|
||||
<navbar title="{{navTitle}}" show-back="{{selectMode}}" />
|
||||
<view class="searchbar">
|
||||
<view class="searchbar__box">
|
||||
<text class="searchbar__icon">🔍</text>
|
||||
<input
|
||||
class="searchbar__input"
|
||||
placeholder="搜索动作 / 器械 / 部位"
|
||||
placeholder="{{selectMode ? '选择动作加入计划' : '搜索动作 / 器械 / 部位'}}"
|
||||
placeholder-class="ph"
|
||||
value="{{q}}"
|
||||
confirm-type="search"
|
||||
bindinput="onInput" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{selectMode}}" class="hint">从下方结果中选择动作,加入当前计划</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="grid" wx:if="{{results.length}}">
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
.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; }
|
||||
.hint { padding: 16rpx 36rpx 0; font-size: 24rpx; color: var(--gold); }
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
// 业务服务层:直接映射后端 REST 接口字段,不做多余转换。
|
||||
const { request } = require('../utils/request');
|
||||
|
||||
// 确保动作名称以中文展示(防御层:即使后端意外返回英文也能兜底)
|
||||
function injectDisplayName(item) {
|
||||
if (!item) return item;
|
||||
// 优先级:nameZh > name(后端 normalize 已把 name 设为中文)> nameEn 不展示
|
||||
item.displayName = item.nameZh || item.name || '';
|
||||
return item;
|
||||
}
|
||||
|
||||
function injectDisplayNames(list) {
|
||||
if (!list || !Array.isArray(list)) return list || [];
|
||||
return list.map(injectDisplayName);
|
||||
}
|
||||
|
||||
// 将参数对象序列化为 query string(跳过空值)
|
||||
function buildQuery(params) {
|
||||
const qs = Object.keys(params || {})
|
||||
@@ -12,12 +25,15 @@ function buildQuery(params) {
|
||||
|
||||
// 1) 动作列表(支持多维筛选与排序)
|
||||
function listExercises(params = {}) {
|
||||
return request({ url: '/api/exercises' + buildQuery(params) });
|
||||
return request({ url: '/api/exercises' + buildQuery(params) }).then((res) => {
|
||||
if (res && res.items) res.items = injectDisplayNames(res.items);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 动作详情
|
||||
function getExercise(id) {
|
||||
return request({ url: '/api/exercises/' + encodeURIComponent(id) });
|
||||
return request({ url: '/api/exercises/' + encodeURIComponent(id) }).then(injectDisplayName);
|
||||
}
|
||||
|
||||
// 3) 分类维度枚举
|
||||
@@ -27,7 +43,10 @@ function getCategories(type) {
|
||||
|
||||
// 4) 智能推荐
|
||||
function recommend(params = {}) {
|
||||
return request({ url: '/api/recommend' + buildQuery(params) });
|
||||
return request({ url: '/api/recommend' + buildQuery(params) }).then((res) => {
|
||||
if (res && res.items) res.items = injectDisplayNames(res.items);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// 5) 训练组合列表
|
||||
@@ -37,12 +56,18 @@ function listCollections() {
|
||||
|
||||
// 5) 训练组合下的动作
|
||||
function getCollectionExercises(slug, params = {}) {
|
||||
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) });
|
||||
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) }).then((res) => {
|
||||
if (res && res.items) res.items = injectDisplayNames(res.items);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// 6) 搜索
|
||||
function search(q) {
|
||||
return request({ url: '/api/search?q=' + encodeURIComponent(q) });
|
||||
return request({ url: '/api/search?q=' + encodeURIComponent(q) }).then((res) => {
|
||||
if (res && res.items) res.items = injectDisplayNames(res.items);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// 7) 统计概览
|
||||
|
||||
73
miniprogram/services/favorite.js
Normal file
73
miniprogram/services/favorite.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// 收藏服务:动作收藏 + 官方计划收藏(均受保护)。
|
||||
const { request } = require('../utils/request');
|
||||
|
||||
// 复用 displayName 注入(避免循环依赖,内联轻量版)
|
||||
function injectDisplayName(item) {
|
||||
if (!item) return item;
|
||||
item.displayName = item.nameZh || item.name || '';
|
||||
return item;
|
||||
}
|
||||
|
||||
function injectDisplayNames(list) {
|
||||
if (!list || !Array.isArray(list)) return list || [];
|
||||
return list.map(injectDisplayName);
|
||||
}
|
||||
|
||||
// 收藏的动作列表
|
||||
function listExercises() {
|
||||
return request({ url: '/api/favorites/exercises', method: 'GET' }).then((res) => {
|
||||
const items = (res && Array.isArray(res)) ? res : (res && res.items);
|
||||
if (items) {
|
||||
const injected = injectDisplayNames(items);
|
||||
if (Array.isArray(res)) return injected;
|
||||
res.items = injected;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// 收藏某个动作
|
||||
function addExercise(id) {
|
||||
return request({ url: '/api/favorites/exercises', method: 'POST', data: { target: id } });
|
||||
}
|
||||
|
||||
// 取消收藏某个动作
|
||||
function removeExercise(id) {
|
||||
return request({ url: '/api/favorites/exercises/' + encodeURIComponent(id), method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 查询某动作收藏状态 { favorited }
|
||||
function exerciseStatus(id) {
|
||||
return request({ url: '/api/favorites/exercises/' + encodeURIComponent(id) + '/status', method: 'GET' });
|
||||
}
|
||||
|
||||
// 收藏的官方计划列表(CollectionDef[])
|
||||
function listPlans() {
|
||||
return request({ url: '/api/favorites/plans', method: 'GET' });
|
||||
}
|
||||
|
||||
// 收藏某个官方计划
|
||||
function addPlan(slug) {
|
||||
return request({ url: '/api/favorites/plans', method: 'POST', data: { target: slug } });
|
||||
}
|
||||
|
||||
// 取消收藏某个官方计划
|
||||
function removePlan(slug) {
|
||||
return request({ url: '/api/favorites/plans/' + encodeURIComponent(slug), method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 查询某官方计划收藏状态 { favorited }
|
||||
function planStatus(slug) {
|
||||
return request({ url: '/api/favorites/plans/' + encodeURIComponent(slug) + '/status', method: 'GET' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listExercises,
|
||||
addExercise,
|
||||
removeExercise,
|
||||
exerciseStatus,
|
||||
listPlans,
|
||||
addPlan,
|
||||
removePlan,
|
||||
planStatus
|
||||
};
|
||||
92
miniprogram/services/plan.js
Normal file
92
miniprogram/services/plan.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// 训练计划服务:我自己的计划 CRUD + 官方计划分级与导入。
|
||||
const { request } = require('../utils/request');
|
||||
|
||||
function injectDisplayName(item) {
|
||||
if (!item) return item;
|
||||
item.displayName = item.nameZh || item.name || '';
|
||||
return item;
|
||||
}
|
||||
|
||||
function injectDisplayNames(list) {
|
||||
if (!list || !Array.isArray(list)) return list || [];
|
||||
return list.map(injectDisplayName);
|
||||
}
|
||||
|
||||
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 : '';
|
||||
}
|
||||
|
||||
// 我自己的计划列表
|
||||
function listMine() {
|
||||
return request({ url: '/api/plans', method: 'GET' });
|
||||
}
|
||||
|
||||
// 新建计划
|
||||
function create(dto) {
|
||||
return request({ url: '/api/plans', method: 'POST', data: dto });
|
||||
}
|
||||
|
||||
// 计划详情
|
||||
function get(id) {
|
||||
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'GET' }).then((plan) => {
|
||||
if (plan && plan.exercises) plan.exercises = injectDisplayNames(plan.exercises);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
// 更新计划元信息
|
||||
function update(id, dto) {
|
||||
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'PATCH', data: dto });
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
function remove(id) {
|
||||
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 向计划添加动作(自动去重)
|
||||
function addExercise(id, exerciseId) {
|
||||
return request({ url: '/api/plans/' + encodeURIComponent(id) + '/exercises', method: 'POST', data: { exerciseId } });
|
||||
}
|
||||
|
||||
// 从计划移除动作
|
||||
function removeExercise(id, exerciseId) {
|
||||
return request({ url: '/api/plans/' + encodeURIComponent(id) + '/exercises/' + encodeURIComponent(exerciseId), method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 导入官方计划为我的副本
|
||||
function importOfficial(slug, name) {
|
||||
return request({ url: '/api/plans/import', method: 'POST', data: { slug, name } });
|
||||
}
|
||||
|
||||
// 官方计划分级分组
|
||||
function officialGroups() {
|
||||
return request({ url: '/api/plans/official', method: 'GET' });
|
||||
}
|
||||
|
||||
// 官方计划下的动作(分页)
|
||||
function officialExercises(slug, page = 1, pageSize = 30) {
|
||||
return request({
|
||||
url: '/api/plans/official/' + encodeURIComponent(slug) + '/exercises' + buildQuery({ page, pageSize })
|
||||
}).then((res) => {
|
||||
if (res && res.items) res.items = injectDisplayNames(res.items);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listMine,
|
||||
create,
|
||||
get,
|
||||
update,
|
||||
remove,
|
||||
addExercise,
|
||||
removeExercise,
|
||||
importOfficial,
|
||||
officialGroups,
|
||||
officialExercises
|
||||
};
|
||||
13
miniprogram/services/user.js
Normal file
13
miniprogram/services/user.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// 用户服务:映射 /api/auth/me 与 /api/users/me。
|
||||
// request 自动携带 token,页面无需手动处理鉴权头。
|
||||
const { request } = require('../utils/request');
|
||||
|
||||
function getMe() {
|
||||
return request({ url: '/api/auth/me', method: 'GET' });
|
||||
}
|
||||
|
||||
function updateProfile(dto) {
|
||||
return request({ url: '/api/users/me', method: 'PATCH', data: dto });
|
||||
}
|
||||
|
||||
module.exports = { getMe, updateProfile };
|
||||
@@ -1,20 +1,115 @@
|
||||
// 极简登录占位:仅演示 wx.login 拿到 code,不请求真实后端服务端。
|
||||
// 生产环境中,应将 code 发送到后端换取 openid / session。
|
||||
function getOpenid() {
|
||||
return new Promise((resolve) => {
|
||||
// 登录与用户态管理:封装 wx.login + 后端 /api/auth/login。
|
||||
// 后端在首次登录时自动创建用户;开发模式(未配置 WX_APPID)接受任意 code
|
||||
// 并返回稳定的虚拟 openid。
|
||||
const { request, setUnauthorizedHandler } = require('./request');
|
||||
|
||||
const TOKEN_KEY = 'access_token';
|
||||
const OPENID_KEY = 'openid';
|
||||
const PROFILE_KEY = 'user_profile';
|
||||
|
||||
// 模块加载时注册 401 自动重新登录:成功后返回新的 token,失败返回 null。
|
||||
setUnauthorizedHandler(async () => {
|
||||
try {
|
||||
await login();
|
||||
return wx.getStorageSync(TOKEN_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function login() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
success(res) {
|
||||
if (res.code) {
|
||||
// 真实实现:wx.request({ url: BASE_URL + '/api/auth/login', data: { code } })
|
||||
console.log('[auth] wx.login code =', res.code);
|
||||
}
|
||||
resolve(res.code || '');
|
||||
const code = res.code || 'tourist';
|
||||
request({ url: '/api/auth/login', method: 'POST', data: { code } })
|
||||
.then((r) => {
|
||||
const token = r.token;
|
||||
const user = r.user || {};
|
||||
wx.setStorageSync(TOKEN_KEY, token);
|
||||
if (user.openid) wx.setStorageSync(OPENID_KEY, user.openid);
|
||||
const profile = { nickname: user.nickname || '', avatar: user.avatar || '' };
|
||||
wx.setStorageSync(PROFILE_KEY, profile);
|
||||
resolve(profile);
|
||||
})
|
||||
.catch((err) => {
|
||||
wx.showToast({ title: '登录失败,请重试', icon: 'none' });
|
||||
reject(err);
|
||||
});
|
||||
},
|
||||
fail() {
|
||||
resolve('');
|
||||
// 拿不到 code 时降级为游客 code
|
||||
request({ url: '/api/auth/login', method: 'POST', data: { code: 'tourist' } })
|
||||
.then((r) => {
|
||||
const token = r.token;
|
||||
const user = r.user || {};
|
||||
wx.setStorageSync(TOKEN_KEY, token);
|
||||
if (user.openid) wx.setStorageSync(OPENID_KEY, user.openid);
|
||||
const profile = { nickname: user.nickname || '', avatar: user.avatar || '' };
|
||||
wx.setStorageSync(PROFILE_KEY, profile);
|
||||
resolve(profile);
|
||||
})
|
||||
.catch((err) => {
|
||||
wx.showToast({ title: '登录失败,请重试', icon: 'none' });
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getOpenid };
|
||||
// 确保已登录:有 token 直接返回,否则执行登录。
|
||||
async function ensureLogin() {
|
||||
const token = getToken();
|
||||
if (token) return token;
|
||||
await login();
|
||||
return getToken();
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return wx.getStorageSync(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function getProfile() {
|
||||
return wx.getStorageSync(PROFILE_KEY) || { nickname: '', avatar: '' };
|
||||
}
|
||||
|
||||
function isLogin() {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
wx.removeStorageSync(TOKEN_KEY);
|
||||
wx.removeStorageSync(OPENID_KEY);
|
||||
wx.removeStorageSync(PROFILE_KEY);
|
||||
}
|
||||
|
||||
function setProfile(p) {
|
||||
const profile = Object.assign({}, getProfile(), p || {});
|
||||
wx.setStorageSync(PROFILE_KEY, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
// 更新昵称 / 头像,写回后端并刷新本地状态。
|
||||
function updateProfile(dto) {
|
||||
return request({ url: '/api/users/me', method: 'PATCH', data: dto })
|
||||
.then((user) => {
|
||||
const profile = {
|
||||
nickname: (user && user.nickname) || getProfile().nickname,
|
||||
avatar: (user && user.avatar) || getProfile().avatar
|
||||
};
|
||||
wx.setStorageSync(PROFILE_KEY, profile);
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
ensureLogin,
|
||||
getToken,
|
||||
getProfile,
|
||||
isLogin,
|
||||
logout,
|
||||
setProfile,
|
||||
updateProfile
|
||||
};
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
// 统一的网络请求封装:所有请求都经过此包装器。
|
||||
// 后端为公开 API,无需鉴权头;失败时统一 toast 提示。
|
||||
// 受保护接口自动携带 Authorization: Bearer <token>;遇到 401 时由注册的
|
||||
// 未授权处理器(通常是自动重新登录)处理一次,然后重试原请求。
|
||||
const { BASE_URL } = require('../config');
|
||||
|
||||
function request({ url, method = 'GET', data = {} }) {
|
||||
let unauthorizedHandler = null;
|
||||
|
||||
// 注册「未授权」处理回调(由 auth 模块注入,用于自动重新登录)
|
||||
function setUnauthorizedHandler(fn) {
|
||||
unauthorizedHandler = fn;
|
||||
}
|
||||
|
||||
function request({ url, method = 'GET', data = {}, _retry = false }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('access_token');
|
||||
wx.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
data,
|
||||
header: { 'content-type': 'application/json' },
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {})
|
||||
},
|
||||
success(res) {
|
||||
// 401:尝试一次自动重新登录后重试
|
||||
if (res.statusCode === 401 && !_retry && unauthorizedHandler) {
|
||||
return unauthorizedHandler()
|
||||
.then((t) => (t ? resolve(request({ url, method, data, _retry: true })) : reject(res)))
|
||||
.catch(() => reject(res));
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data);
|
||||
} else {
|
||||
wx.showToast({
|
||||
title: '请求失败 (' + res.statusCode + ')',
|
||||
icon: 'none'
|
||||
});
|
||||
wx.showToast({ title: '请求失败 (' + res.statusCode + ')', icon: 'none' });
|
||||
reject(res);
|
||||
}
|
||||
},
|
||||
@@ -28,4 +43,4 @@ function request({ url, method = 'GET', data = {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { request };
|
||||
module.exports = { request, setUnauthorizedHandler };
|
||||
|
||||
Reference in New Issue
Block a user