feat: init fitness-coach (backend + miniprogram)
- backend: NestJS service with exercise/plan data - miniprogram: WeChat mini program client - exclude node_modules, dist, runtime data-store, local env
This commit is contained in:
47
miniprogram/README.md
Normal file
47
miniprogram/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# FITCOACH 微信小程序(前端)
|
||||
|
||||
高端暗金风格(dark luxe)的健身动作推荐小程序。所有数据来自 NestJS 后端 API。
|
||||
|
||||
## 设计系统
|
||||
设计令牌定义在 `app.wxss` 的 `page` 选择器(CSS 变量):
|
||||
- 背景 `#0E1110`、表面 `#171C19`、金 `#D9B36C`、次强调 `#58C9B9`
|
||||
- 圆角 `--radius:28rpx`、发丝分隔线 `--line`、阴影 `--shadow`
|
||||
- 图标统一使用 **emoji + CSS 渐变**(不依赖二进制图片资源)
|
||||
|
||||
## 配置
|
||||
打开 `config.js`:
|
||||
```js
|
||||
module.exports = { BASE_URL: 'http://localhost:3000' };
|
||||
```
|
||||
- 本地调试:填写电脑局域网 IP,如 `http://192.168.1.10:3000`
|
||||
- 真机/发布:**必须**改为 HTTPS 且已在小程序后台配置为 request 合法域名
|
||||
|
||||
## 运行
|
||||
1. 微信开发者工具 → 导入项目 → 选择本目录(AppID 可用测试号)
|
||||
2. 本地调试可在「详情 → 本地设置」勾选「不校验合法域名、TLS 版本以及 HTTPS 证书」
|
||||
3. 编译预览
|
||||
|
||||
## 页面
|
||||
| 页面 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 训练组合 + 按部位/器械浏览 |
|
||||
| 分类浏览 | `pages/category` | 按 dimension(bodyPart/equipment/target/type/muscleGroup) 筛选列表 |
|
||||
| 智能推荐 | `pages/recommend` | 选目标肌群(按部位分组)+ 可选器械 → 评分排序结果 |
|
||||
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 相关推荐 + 分享 |
|
||||
| 训练组合 | `pages/collection` | 组合列表(推/拉/腿/核心/上肢/全身/居家/有氧) |
|
||||
| 组合详情 | `pages/collection-detail` | 某组合下的动作 |
|
||||
| 搜索 | `pages/search` | 名称/部位/器械/目标模糊搜索 |
|
||||
|
||||
## 组件
|
||||
`exercise-card`(动作卡)、`section-header`(分区标题)、`chip`(筛选标签)、
|
||||
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航)。
|
||||
|
||||
## 接口映射
|
||||
`services/exercise.js` 直接映射后端 7 类接口(`listExercises` / `getExercise` /
|
||||
`getCategories` / `recommend` / `listCollections` / `getCollectionExercises` /
|
||||
`search` / `getStats`),字段与后端响应一一对应。
|
||||
|
||||
## 注意事项
|
||||
- 小程序 `<image>` 支持 GIF,动作演示直接使用 `gifUrl`。
|
||||
- 所有请求经 `utils/request.js` 单点封装,失败统一 toast。
|
||||
- 包体保持精简:主包仅含页面与组件,无大体积本地资源。
|
||||
26
miniprogram/app.js
Normal file
26
miniprogram/app.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { BASE_URL } = require('./config');
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
// 状态栏高度(px),由 onLaunch 读取,供自定义导航栏计算顶部留白
|
||||
statusBarHeight: 20,
|
||||
// 后端基础地址
|
||||
baseUrl: BASE_URL
|
||||
},
|
||||
|
||||
onLaunch() {
|
||||
// 读取窗口信息以获取状态栏高度(兼容新旧 API)
|
||||
try {
|
||||
const info = (typeof wx.getWindowInfo === 'function')
|
||||
? wx.getWindowInfo()
|
||||
: wx.getSystemInfoSync();
|
||||
this.globalData.statusBarHeight = info.statusBarHeight || 20;
|
||||
} catch (e) {
|
||||
this.globalData.statusBarHeight = 20;
|
||||
}
|
||||
|
||||
// 可选:静默登录(占位,不请求真实后端)
|
||||
// const { getOpenid } = require('./utils/auth');
|
||||
// getOpenid();
|
||||
}
|
||||
});
|
||||
19
miniprogram/app.json
Normal file
19
miniprogram/app.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/index/index",
|
||||
"pages/category/category",
|
||||
"pages/recommend/recommend",
|
||||
"pages/detail/detail",
|
||||
"pages/collection/collection",
|
||||
"pages/collection-detail/collection-detail",
|
||||
"pages/search/search"
|
||||
],
|
||||
"window": {
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#0E1110",
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "FITCOACH"
|
||||
},
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
88
miniprogram/app.wxss
Normal file
88
miniprogram/app.wxss
Normal file
@@ -0,0 +1,88 @@
|
||||
/* ===== FITCOACH 设计系统:高端暗金奢华主题 ===== */
|
||||
page {
|
||||
/* 设计令牌 */
|
||||
--bg: #0E1110;
|
||||
--bg-grad: linear-gradient(160deg, #16201B 0%, #0E1110 55%);
|
||||
--surface: #171C19;
|
||||
--surface-2: #1F2622;
|
||||
--gold: #D9B36C;
|
||||
--gold-2: #F0D9A8;
|
||||
--gold-soft: rgba(217,179,108,0.12);
|
||||
--gold-line: rgba(217,179,108,0.28);
|
||||
--teal: #58C9B9;
|
||||
--text: #F3F5F2;
|
||||
--text-2: #98A39B;
|
||||
--text-3: #6B746D;
|
||||
--line: rgba(255,255,255,0.07);
|
||||
--radius: 28rpx;
|
||||
--radius-sm: 18rpx;
|
||||
--shadow: 0 12rpx 40rpx rgba(0,0,0,0.35);
|
||||
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, "PingFang SC", "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view, text, scroll-view, image, input {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== 通用工具类 ===== */
|
||||
.eyebrow {
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: var(--gold);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 40rpx 32rpx;
|
||||
}
|
||||
.section + .section {
|
||||
border-top: 1rpx solid var(--line);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1rpx solid var(--line);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 24rpx;
|
||||
border: 1rpx solid var(--line);
|
||||
}
|
||||
|
||||
.gold-text { color: var(--gold); }
|
||||
.muted { color: var(--text-2); }
|
||||
.row { display: flex; flex-direction: row; align-items: center; }
|
||||
.col { display: flex; flex-direction: column; }
|
||||
|
||||
.empty {
|
||||
color: var(--text-3);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-2);
|
||||
background: var(--surface-2);
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.tag--gold {
|
||||
color: var(--gold-2);
|
||||
background: var(--gold-soft);
|
||||
}
|
||||
20
miniprogram/components/bottom-nav/index.js
Normal file
20
miniprogram/components/bottom-nav/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
Component({
|
||||
properties: {
|
||||
active: { type: String, value: 'home' } // 'home' | 'category' | 'plan'
|
||||
},
|
||||
data: {
|
||||
items: [
|
||||
{ key: 'home', label: '首页', emoji: '🏠', page: '/pages/index/index' },
|
||||
{ key: 'category', label: '分类', emoji: '📚', page: '/pages/category/category' },
|
||||
{ key: 'plan', label: '计划', emoji: '📋', page: '/pages/collection/collection' }
|
||||
]
|
||||
},
|
||||
methods: {
|
||||
onTap(e) {
|
||||
const page = e.currentTarget.dataset.page;
|
||||
if (!page) return;
|
||||
// 使用 reLaunch 重置页面栈,避免页面层级堆叠
|
||||
wx.reLaunch({ url: page });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/bottom-nav/index.json
Normal file
4
miniprogram/components/bottom-nav/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
11
miniprogram/components/bottom-nav/index.wxml
Normal file
11
miniprogram/components/bottom-nav/index.wxml
Normal file
@@ -0,0 +1,11 @@
|
||||
<view class="bn">
|
||||
<view
|
||||
wx:for="{{items}}"
|
||||
wx:key="key"
|
||||
class="bn__item {{active === item.key ? 'bn__item--active' : ''}}"
|
||||
data-page="{{item.page}}"
|
||||
bindtap="onTap">
|
||||
<text class="bn__emoji">{{item.emoji}}</text>
|
||||
<text class="bn__label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
22
miniprogram/components/bottom-nav/index.wxss
Normal file
22
miniprogram/components/bottom-nav/index.wxss
Normal file
@@ -0,0 +1,22 @@
|
||||
.bn {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
background: rgba(20,26,22,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1rpx solid var(--line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 60;
|
||||
}
|
||||
.bn__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16rpx 0 14rpx;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.bn__item--active { color: var(--gold); }
|
||||
.bn__emoji { font-size: 40rpx; line-height: 1; }
|
||||
.bn__label { font-size: 22rpx; margin-top: 6rpx; }
|
||||
.bn__item--active .bn__label { color: var(--gold-2); }
|
||||
16
miniprogram/components/chip/index.js
Normal file
16
miniprogram/components/chip/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
Component({
|
||||
properties: {
|
||||
label: { type: String, value: '' },
|
||||
active: { type: Boolean, value: false },
|
||||
color: { type: String, value: '' },
|
||||
value: { type: String, value: '' }
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('tap', {
|
||||
value: this.data.value,
|
||||
label: this.data.label
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/chip/index.json
Normal file
4
miniprogram/components/chip/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
6
miniprogram/components/chip/index.wxml
Normal file
6
miniprogram/components/chip/index.wxml
Normal file
@@ -0,0 +1,6 @@
|
||||
<view
|
||||
class="chip {{active ? 'chip--active' : ''}}"
|
||||
style="{{color ? 'border-color:' + color + '66; color:' + color : ''}}"
|
||||
bindtap="onTap">
|
||||
<text>{{label}}</text>
|
||||
</view>
|
||||
18
miniprogram/components/chip/index.wxss
Normal file
18
miniprogram/components/chip/index.wxss
Normal file
@@ -0,0 +1,18 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12rpx 26rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 24rpx;
|
||||
border: 1rpx solid var(--line);
|
||||
transition: all .15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip--active {
|
||||
background: var(--gold-soft);
|
||||
color: var(--gold-2);
|
||||
border-color: var(--gold-line);
|
||||
}
|
||||
14
miniprogram/components/exercise-card/index.js
Normal file
14
miniprogram/components/exercise-card/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
Component({
|
||||
properties: {
|
||||
exercise: { type: Object, value: {} },
|
||||
score: { type: Number, value: 0 },
|
||||
reason: { type: String, value: '' },
|
||||
compact: { type: Boolean, value: false }
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
const ex = this.data.exercise || {};
|
||||
this.triggerEvent('tap', { id: ex.id, exercise: ex });
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/exercise-card/index.json
Normal file
4
miniprogram/components/exercise-card/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
21
miniprogram/components/exercise-card/index.wxml
Normal file
21
miniprogram/components/exercise-card/index.wxml
Normal file
@@ -0,0 +1,21 @@
|
||||
<view class="ex-card {{compact ? 'ex-card--compact' : ''}}" bindtap="onTap">
|
||||
<view class="ex-card__media">
|
||||
<image
|
||||
wx:if="{{exercise.gifUrl || exercise.image}}"
|
||||
class="ex-card__img"
|
||||
src="{{exercise.gifUrl || exercise.image}}"
|
||||
mode="aspectFill"
|
||||
lazy-load="true" />
|
||||
<view wx:else class="ex-card__ph">🏋️</view>
|
||||
<view wx:if="{{exercise.typeLabel}}" class="ex-card__type">{{exercise.typeLabel}}</view>
|
||||
<view wx:if="{{score > 0}}" class="ex-card__score">匹配 {{score}}</view>
|
||||
</view>
|
||||
<view class="ex-card__body">
|
||||
<view class="ex-card__name">{{exercise.name}}</view>
|
||||
<view class="ex-card__chips">
|
||||
<text wx:if="{{exercise.targetLabel}}" class="tag tag--gold">{{exercise.targetLabel}}</text>
|
||||
<text wx:if="{{exercise.equipmentLabel}}" class="tag">{{exercise.equipmentLabel}}</text>
|
||||
</view>
|
||||
<view wx:if="{{reason}}" class="ex-card__reason">{{reason}}</view>
|
||||
</view>
|
||||
</view>
|
||||
40
miniprogram/components/exercise-card/index.wxss
Normal file
40
miniprogram/components/exercise-card/index.wxss
Normal file
@@ -0,0 +1,40 @@
|
||||
.ex-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--line);
|
||||
width: 100%;
|
||||
}
|
||||
.ex-card__media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ex-card--compact .ex-card__media { height: 160rpx; }
|
||||
.ex-card__img { width: 100%; height: 100%; display: block; }
|
||||
.ex-card__ph {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 64rpx;
|
||||
}
|
||||
.ex-card__type {
|
||||
position: absolute; left: 12rpx; top: 12rpx;
|
||||
font-size: 20rpx; color: var(--text-2);
|
||||
background: rgba(0,0,0,0.45); padding: 4rpx 14rpx; border-radius: 999rpx;
|
||||
}
|
||||
.ex-card__score {
|
||||
position: absolute; right: 12rpx; top: 12rpx;
|
||||
font-size: 20rpx; color: #0E1110; font-weight: 700;
|
||||
background: var(--gold); padding: 4rpx 14rpx; border-radius: 999rpx;
|
||||
}
|
||||
.ex-card__body { padding: 16rpx 18rpx 20rpx; }
|
||||
.ex-card__name {
|
||||
font-size: 28rpx; font-weight: 600; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ex-card__chips { display: flex; gap: 10rpx; margin-top: 12rpx; flex-wrap: wrap; }
|
||||
.ex-card__reason {
|
||||
margin-top: 12rpx; font-size: 22rpx; color: var(--text-3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
27
miniprogram/components/navbar/index.js
Normal file
27
miniprogram/components/navbar/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const app = getApp();
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
title: { type: String, value: '' },
|
||||
showBack: { type: Boolean, value: false }
|
||||
},
|
||||
data: {
|
||||
statusBarHeight: 20
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const h = (app.globalData && app.globalData.statusBarHeight) || 20;
|
||||
this.setData({ statusBarHeight: h });
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
wx.navigateBack();
|
||||
} else {
|
||||
wx.reLaunch({ url: '/pages/index/index' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/navbar/index.json
Normal file
4
miniprogram/components/navbar/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
7
miniprogram/components/navbar/index.wxml
Normal file
7
miniprogram/components/navbar/index.wxml
Normal file
@@ -0,0 +1,7 @@
|
||||
<view class="navbar">
|
||||
<view class="navbar__status" style="height: {{statusBarHeight}}px;"></view>
|
||||
<view class="navbar__bar">
|
||||
<view wx:if="{{showBack}}" class="navbar__back" bindtap="onBack">‹</view>
|
||||
<view wx:if="{{title}}" class="navbar__title">{{title}}</view>
|
||||
</view>
|
||||
</view>
|
||||
29
miniprogram/components/navbar/index.wxss
Normal file
29
miniprogram/components/navbar/index.wxss
Normal file
@@ -0,0 +1,29 @@
|
||||
.navbar {
|
||||
background: linear-gradient(180deg, rgba(22,32,27,0.96) 0%, rgba(14,17,16,0.55) 100%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.navbar__status { width: 100%; }
|
||||
.navbar__bar {
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
.navbar__back {
|
||||
font-size: 56rpx;
|
||||
color: var(--gold);
|
||||
line-height: 1;
|
||||
width: 60rpx;
|
||||
}
|
||||
.navbar__title {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
color: var(--text);
|
||||
}
|
||||
13
miniprogram/components/section-header/index.js
Normal file
13
miniprogram/components/section-header/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
Component({
|
||||
properties: {
|
||||
title: { type: String, value: '' },
|
||||
subtitle: { type: String, value: '' },
|
||||
eyebrow: { type: String, value: '' },
|
||||
action: { type: String, value: '' }
|
||||
},
|
||||
methods: {
|
||||
onAction() {
|
||||
this.triggerEvent('action');
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/section-header/index.json
Normal file
4
miniprogram/components/section-header/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
8
miniprogram/components/section-header/index.wxml
Normal file
8
miniprogram/components/section-header/index.wxml
Normal file
@@ -0,0 +1,8 @@
|
||||
<view class="sh">
|
||||
<view class="sh__main">
|
||||
<view wx:if="{{eyebrow}}" class="eyebrow">{{eyebrow}}</view>
|
||||
<view class="sh__title">{{title}}</view>
|
||||
<view wx:if="{{subtitle}}" class="sh__sub">{{subtitle}}</view>
|
||||
</view>
|
||||
<view wx:if="{{action}}" class="sh__action" bindtap="onAction">{{action}} ›</view>
|
||||
</view>
|
||||
23
miniprogram/components/section-header/index.wxss
Normal file
23
miniprogram/components/section-header/index.wxss
Normal file
@@ -0,0 +1,23 @@
|
||||
.sh {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 8rpx 0 20rpx;
|
||||
}
|
||||
.sh__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
color: var(--text);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.sh__sub {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-2);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.sh__action {
|
||||
font-size: 26rpx;
|
||||
color: var(--gold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
7
miniprogram/config.js
Normal file
7
miniprogram/config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// 后端基础地址配置。
|
||||
// ⚠️ 生产环境必须替换为已在「微信公众平台 -> 开发 -> 开发设置 -> 服务器域名」
|
||||
// 中配置的 HTTPS 合法域名(request 合法域名),否则真机无法发起请求。
|
||||
// localhost 仅用于开发者工具本地联调。
|
||||
module.exports = {
|
||||
BASE_URL: 'http://localhost:3000'
|
||||
};
|
||||
89
miniprogram/pages/category/category.js
Normal file
89
miniprogram/pages/category/category.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
const DIM_MAP = {
|
||||
bodyPart: 'body-parts',
|
||||
equipment: 'equipment',
|
||||
target: 'targets',
|
||||
muscleGroup: 'muscle-groups',
|
||||
type: 'types'
|
||||
};
|
||||
|
||||
function labelForDim(d) {
|
||||
return ({
|
||||
bodyPart: '按部位',
|
||||
equipment: '按器械',
|
||||
target: '按目标',
|
||||
muscleGroup: '按肌群',
|
||||
type: '按类型'
|
||||
})[d] || '分类';
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
dimension: 'bodyPart',
|
||||
title: '分类',
|
||||
value: '',
|
||||
values: [],
|
||||
activeValue: '',
|
||||
exercises: [],
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const dimension = query.dimension || 'bodyPart';
|
||||
const value = query.value || '';
|
||||
const title = query.title || labelForDim(dimension);
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 20,
|
||||
dimension,
|
||||
value,
|
||||
title
|
||||
});
|
||||
this.loadValues();
|
||||
},
|
||||
|
||||
loadValues() {
|
||||
this.setData({ loading: true });
|
||||
const key = DIM_MAP[this.data.dimension] || 'body-parts';
|
||||
svc.getCategories(key).then((items) => {
|
||||
this.setData({
|
||||
values: items || [],
|
||||
activeValue: this.data.value,
|
||||
loading: false
|
||||
});
|
||||
this.loadExercises();
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
loadExercises() {
|
||||
this.setData({ loading: true });
|
||||
const params = { page: this.data.page, pageSize: this.data.pageSize };
|
||||
params[this.data.dimension] = this.data.activeValue;
|
||||
svc.listExercises(params).then((res) => {
|
||||
this.setData({
|
||||
exercises: (res && res.items) || [],
|
||||
total: (res && res.total) || 0,
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onValue(e) {
|
||||
const value = e.detail.value;
|
||||
if (value === this.data.activeValue) return;
|
||||
this.setData({ activeValue: value, page: 1 }, () => this.loadExercises());
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
}
|
||||
});
|
||||
9
miniprogram/pages/category/category.json
Normal file
9
miniprogram/pages/category/category.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"chip": "/components/chip/index",
|
||||
"exercise-card": "/components/exercise-card/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
29
miniprogram/pages/category/category.wxml
Normal file
29
miniprogram/pages/category/category.wxml
Normal file
@@ -0,0 +1,29 @@
|
||||
<view class="page">
|
||||
<navbar title="{{title}}" show-back />
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<scroll-view wx:if="{{values.length}}" scroll-x class="filterbar">
|
||||
<chip
|
||||
wx:for="{{values}}"
|
||||
wx:key="value"
|
||||
label="{{item.label}}"
|
||||
value="{{item.value}}"
|
||||
active="{{activeValue === item.value}}"
|
||||
bind:tap="onValue" />
|
||||
<view class="filterbar__spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="count muted" wx:if="{{!loading}}">共 {{total}} 个动作</view>
|
||||
|
||||
<view class="grid" wx:if="{{exercises.length}}">
|
||||
<view class="grid__item" wx:for="{{exercises}}" wx:key="id">
|
||||
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="category" />
|
||||
</view>
|
||||
8
miniprogram/pages/category/category.wxss
Normal file
8
miniprogram/pages/category/category.wxss
Normal file
@@ -0,0 +1,8 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.filterbar { white-space: nowrap; padding: 8rpx 0 4rpx; }
|
||||
.filterbar chip { display: inline-block; margin-right: 16rpx; }
|
||||
.filterbar__spacer { display: inline-block; width: 4rpx; }
|
||||
.count { font-size: 24rpx; margin: 20rpx 0 8rpx; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; margin-top: 12rpx; }
|
||||
.grid__item { width: calc((100% - 18rpx) / 2); }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }
|
||||
42
miniprogram/pages/collection-detail/collection-detail.js
Normal file
42
miniprogram/pages/collection-detail/collection-detail.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
slug: '',
|
||||
collection: null,
|
||||
exercises: [],
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
total: 0
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const slug = query.slug || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, slug });
|
||||
this.load();
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
svc.getCollectionExercises(this.data.slug, {
|
||||
page: this.data.page,
|
||||
pageSize: this.data.pageSize
|
||||
}).then((res) => {
|
||||
this.setData({
|
||||
collection: res.collection,
|
||||
exercises: (res && res.items) || [],
|
||||
total: (res && res.total) || 0,
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
24
miniprogram/pages/collection-detail/collection-detail.wxml
Normal file
24
miniprogram/pages/collection-detail/collection-detail.wxml
Normal file
@@ -0,0 +1,24 @@
|
||||
<view class="page" wx:if="{{collection}}">
|
||||
<navbar title="{{collection.name}}" show-back />
|
||||
|
||||
<view class="header" style="background: linear-gradient(135deg, {{collection.color}}26, var(--surface));">
|
||||
<view class="header__emoji" style="background: {{collection.color}}22; color: {{collection.color}};">{{collection.emoji}}</view>
|
||||
<view class="header__name">{{collection.name}}</view>
|
||||
<view class="header__desc">{{collection.description}}</view>
|
||||
<view class="header__count" style="color: {{collection.color}};">共 {{total}} 个动作</view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="grid" wx:if="{{exercises.length}}">
|
||||
<view class="grid__item" wx:for="{{exercises}}" wx:key="id">
|
||||
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty-page">
|
||||
<view class="empty">{{ loading ? '加载中…' : '未找到该训练组合' }}</view>
|
||||
</view>
|
||||
14
miniprogram/pages/collection-detail/collection-detail.wxss
Normal file
14
miniprogram/pages/collection-detail/collection-detail.wxss
Normal file
@@ -0,0 +1,14 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 60rpx; }
|
||||
.header { padding: 32rpx; border-bottom: 1rpx solid var(--line); }
|
||||
.header__emoji {
|
||||
width: 120rpx; height: 120rpx; border-radius: 28rpx;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 64rpx;
|
||||
}
|
||||
.header__name { font-size: 44rpx; font-weight: 700; margin-top: 20rpx; }
|
||||
.header__desc { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; line-height: 1.5; }
|
||||
.header__count { font-size: 24rpx; margin-top: 16rpx; }
|
||||
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
|
||||
.grid__item { width: calc((100% - 18rpx) / 2); }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
|
||||
.empty-page { min-height: 100vh; background: var(--bg-grad); }
|
||||
40
miniprogram/pages/collection/collection.js
Normal file
40
miniprogram/pages/collection/collection.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
collections: [],
|
||||
counts: {},
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
this.load();
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
svc.listCollections().then((list) => {
|
||||
const collections = list || [];
|
||||
this.setData({ collections, loading: false });
|
||||
// 异步获取每个训练组合的动作数量
|
||||
collections.forEach(c => {
|
||||
svc.getCollectionExercises(c.slug, { pageSize: 1 }).then((res) => {
|
||||
const key = 'counts.' + c.slug;
|
||||
this.setData({ [key]: (res && res.total) || 0 });
|
||||
}).catch(() => {});
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onOpen(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/collection-detail/collection-detail?slug=' +
|
||||
encodeURIComponent(e.currentTarget.dataset.slug)
|
||||
});
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/collection/collection.json
Normal file
7
miniprogram/pages/collection/collection.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
31
miniprogram/pages/collection/collection.wxml
Normal file
31
miniprogram/pages/collection/collection.wxml
Normal file
@@ -0,0 +1,31 @@
|
||||
<view class="page">
|
||||
<navbar title="训练计划" />
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="intro">
|
||||
<view class="intro__eyebrow">PROGRAMS</view>
|
||||
<view class="intro__title">挑选你的训练主题</view>
|
||||
<view class="intro__sub">从推、拉、腿到居家无器械,一键开启</view>
|
||||
</view>
|
||||
|
||||
<view class="list" wx:if="{{collections.length}}">
|
||||
<view
|
||||
wx:for="{{collections}}"
|
||||
wx:key="slug"
|
||||
class="col-card"
|
||||
style="background: linear-gradient(135deg, {{item.color}}26, var(--surface)); border-color: {{item.color}}55;"
|
||||
data-slug="{{item.slug}}"
|
||||
bindtap="onOpen">
|
||||
<view class="col-card__emoji" style="background: {{item.color}}22;">{{item.emoji}}</view>
|
||||
<view class="col-card__main">
|
||||
<view class="col-card__name">{{item.name}}</view>
|
||||
<view class="col-card__desc">{{item.description}}</view>
|
||||
</view>
|
||||
<view class="col-card__count" style="color: {{item.color}};">{{counts[item.slug] || ''}} 个动作 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
|
||||
<view wx:else class="empty">加载中…</view>
|
||||
</view>
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="plan" />
|
||||
</view>
|
||||
22
miniprogram/pages/collection/collection.wxss
Normal file
22
miniprogram/pages/collection/collection.wxss
Normal file
@@ -0,0 +1,22 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.intro { padding: 16rpx 0 28rpx; }
|
||||
.intro__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
|
||||
.intro__title { font-size: 44rpx; font-weight: 700; margin-top: 12rpx; }
|
||||
.intro__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.col-card {
|
||||
display: flex; align-items: center; gap: 24rpx;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius); padding: 28rpx;
|
||||
}
|
||||
.col-card__emoji {
|
||||
width: 96rpx; height: 96rpx; border-radius: 24rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 52rpx; flex: 0 0 auto;
|
||||
}
|
||||
.col-card__main { flex: 1; min-width: 0; }
|
||||
.col-card__name { font-size: 34rpx; font-weight: 700; color: var(--text); }
|
||||
.col-card__desc { font-size: 24rpx; color: var(--text-2); margin-top: 8rpx; line-height: 1.4; }
|
||||
.col-card__count { font-size: 24rpx; flex: 0 0 auto; }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }
|
||||
67
miniprogram/pages/detail/detail.js
Normal file
67
miniprogram/pages/detail/detail.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
id: '',
|
||||
exercise: null,
|
||||
related: [],
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const id = query.id || '';
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
|
||||
this.load();
|
||||
},
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true });
|
||||
svc.getExercise(this.data.id).then((ex) => {
|
||||
this.setData({ exercise: ex, loading: false });
|
||||
if (ex && ex.target) {
|
||||
svc.listExercises({ target: ex.target, pageSize: 6 }).then((res) => {
|
||||
const related = ((res && res.items) || [])
|
||||
.filter(i => i.id !== ex.id)
|
||||
.slice(0, 6);
|
||||
this.setData({ related });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onAdd() {
|
||||
wx.showToast({ title: '已加入今日训练', icon: 'success' });
|
||||
},
|
||||
|
||||
onBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
wx.navigateBack();
|
||||
} else {
|
||||
wx.reLaunch({ url: '/pages/index/index' });
|
||||
}
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ex = this.data.exercise || {};
|
||||
return {
|
||||
title: ex.name ? ('FITCOACH · ' + ex.name) : 'FITCOACH 智能健身教练',
|
||||
path: '/pages/detail/detail?id=' + this.data.id
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: 'FITCOACH · 精准训练推荐',
|
||||
query: 'id=' + this.data.id
|
||||
};
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/detail/detail.json
Normal file
7
miniprogram/pages/detail/detail.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
72
miniprogram/pages/detail/detail.wxml
Normal file
72
miniprogram/pages/detail/detail.wxml
Normal file
@@ -0,0 +1,72 @@
|
||||
<view class="page" wx:if="{{exercise}}">
|
||||
<navbar title="{{exercise.name}}" show-back />
|
||||
|
||||
<view class="hero">
|
||||
<image
|
||||
wx:if="{{exercise.gifUrl || exercise.image}}"
|
||||
class="hero__img"
|
||||
src="{{exercise.gifUrl || exercise.image}}"
|
||||
mode="aspectFill" />
|
||||
<view wx:else class="hero__ph">🏋️</view>
|
||||
</view>
|
||||
|
||||
<view class="body section">
|
||||
<view class="name">{{exercise.name}}</view>
|
||||
|
||||
<view class="metas">
|
||||
<view class="meta"><view class="meta__k">部位</view><view class="meta__v">{{exercise.bodyPartLabel}}</view></view>
|
||||
<view class="meta"><view class="meta__k">器械</view><view class="meta__v">{{exercise.equipmentLabel}}</view></view>
|
||||
<view class="meta"><view class="meta__k">目标</view><view class="meta__v gold-text">{{exercise.targetLabel}}</view></view>
|
||||
<view class="meta"><view class="meta__k">类型</view><view class="meta__v">{{exercise.typeLabel}}</view></view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{exercise.muscleGroupLabel}}">
|
||||
<view class="block__h eyebrow">主要肌群</view>
|
||||
<view class="chips">
|
||||
<text class="tag tag--gold">{{exercise.muscleGroupLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{exercise.secondaryMusclesLabels.length}}">
|
||||
<view class="block__h eyebrow">协同肌群</view>
|
||||
<view class="chips">
|
||||
<text wx:for="{{exercise.secondaryMusclesLabels}}" wx:key="*this" class="tag">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{exercise.instructionSteps.zh.length}}">
|
||||
<view class="block__h eyebrow">动作步骤</view>
|
||||
<view class="steps">
|
||||
<view class="step" wx:for="{{exercise.instructionSteps.zh}}" wx:key="index">
|
||||
<view class="step__n">{{index + 1}}</view>
|
||||
<view class="step__t">{{item}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{exercise.instructions.zh}}">
|
||||
<view class="block__h eyebrow">要点提示</view>
|
||||
<view class="tips">{{exercise.instructions.zh}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" wx:if="{{related.length}}">
|
||||
<view class="block__h eyebrow">相关推荐</view>
|
||||
<scroll-view scroll-x class="hscroll">
|
||||
<view class="hscroll__item" wx:for="{{related}}" wx:key="id">
|
||||
<exercise-card exercise="{{item}}" compact bind:tap="onExercise" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="addbar">
|
||||
<view class="cta" bindtap="onAdd">加入今日训练</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty-page">
|
||||
<view class="topbar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="topbar__back" bindtap="onBack">‹</view>
|
||||
</view>
|
||||
<view class="empty">{{ loading ? '加载中…' : '未找到该动作' }}</view>
|
||||
</view>
|
||||
57
miniprogram/pages/detail/detail.wxss
Normal file
57
miniprogram/pages/detail/detail.wxss
Normal file
@@ -0,0 +1,57 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 180rpx; }
|
||||
.hero { width: 100%; height: 520rpx; background: var(--surface-2); }
|
||||
.hero__img { width: 100%; height: 100%; display: block; }
|
||||
.hero__ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 120rpx; }
|
||||
|
||||
.name { font-size: 48rpx; font-weight: 700; color: var(--text); letter-spacing: 1rpx; }
|
||||
|
||||
.metas { display: flex; flex-wrap: wrap; gap: 14rpx; margin-top: 24rpx; }
|
||||
.meta {
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius-sm); padding: 16rpx 22rpx;
|
||||
min-width: calc((100% - 42rpx) / 4);
|
||||
}
|
||||
.meta__k { font-size: 20rpx; color: var(--text-3); }
|
||||
.meta__v { font-size: 26rpx; color: var(--text); margin-top: 6rpx; font-weight: 600; }
|
||||
|
||||
.block { margin-top: 36rpx; }
|
||||
.block__h { margin-bottom: 16rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
|
||||
.steps { display: flex; flex-direction: column; gap: 18rpx; }
|
||||
.step { display: flex; gap: 20rpx; align-items: flex-start; }
|
||||
.step__n {
|
||||
flex: 0 0 auto; width: 48rpx; height: 48rpx; border-radius: 999rpx;
|
||||
background: var(--gold-soft); color: var(--gold); border: 1rpx solid var(--gold-line);
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 26rpx;
|
||||
}
|
||||
.step__t { flex: 1; font-size: 28rpx; color: var(--text); line-height: 1.5; padding-top: 6rpx; }
|
||||
|
||||
.tips {
|
||||
font-size: 28rpx; color: var(--text-2); line-height: 1.6;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius-sm); padding: 24rpx;
|
||||
}
|
||||
|
||||
.hscroll { white-space: nowrap; margin-top: 12rpx; }
|
||||
.hscroll__item { display: inline-block; width: 300rpx; margin-right: 20rpx; vertical-align: top; }
|
||||
|
||||
.addbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
padding: 20rpx 32rpx;
|
||||
background: rgba(14,17,16,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1rpx solid var(--line);
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.cta {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 32rpx;
|
||||
border-radius: 999rpx; padding: 26rpx;
|
||||
}
|
||||
|
||||
.empty-page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.topbar { height: 88rpx; display: flex; align-items: center; padding: 0 24rpx; }
|
||||
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
|
||||
.empty { color: var(--text-3); font-size: 28rpx; text-align: center; padding: 120rpx 0; }
|
||||
88
miniprogram/pages/index/index.js
Normal file
88
miniprogram/pages/index/index.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
stats: null,
|
||||
bodyParts: [],
|
||||
equipment: [],
|
||||
featured: [],
|
||||
collections: [],
|
||||
loading: true,
|
||||
quickEntries: [
|
||||
{ key: 'recommend', label: '智能推荐', emoji: '✨', url: '/pages/recommend/recommend' },
|
||||
{ key: 'bodyPart', label: '按部位', emoji: '💪', url: '/pages/category/category?dimension=bodyPart' },
|
||||
{ key: 'equipment', label: '按器械', emoji: '🏋️', url: '/pages/category/category?dimension=equipment' },
|
||||
{ key: 'plan', label: '训练计划', emoji: '📋', url: '/pages/collection/collection' },
|
||||
{ key: 'home', label: '居家无器械', emoji: '🏠', url: '/pages/collection-detail/collection-detail?slug=home' },
|
||||
{ key: 'search', label: '搜索', emoji: '🔍', url: '/pages/search/search' }
|
||||
]
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
this.loadData();
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ loading: true });
|
||||
Promise.all([
|
||||
svc.getStats().catch(() => null),
|
||||
svc.listExercises({ pageSize: 8 }).catch(() => ({ items: [] })),
|
||||
svc.listCollections().catch(() => [])
|
||||
]).then(([stats, list, collections]) => {
|
||||
this.setData({
|
||||
stats,
|
||||
bodyParts: (stats && stats.bodyParts) || [],
|
||||
equipment: (stats && stats.equipment) || [],
|
||||
featured: (list && list.items) || [],
|
||||
collections: collections || [],
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
goRecommend() {
|
||||
wx.navigateTo({ url: '/pages/recommend/recommend' });
|
||||
},
|
||||
|
||||
onQuick(e) {
|
||||
wx.navigateTo({ url: e.currentTarget.dataset.url });
|
||||
},
|
||||
|
||||
onSearch() {
|
||||
wx.navigateTo({ url: '/pages/search/search' });
|
||||
},
|
||||
|
||||
onScanBodyPart(e) {
|
||||
const { value, label } = e.currentTarget.dataset;
|
||||
wx.navigateTo({
|
||||
url: '/pages/category/category?dimension=bodyPart&value=' +
|
||||
encodeURIComponent(value) + '&title=' + encodeURIComponent(label)
|
||||
});
|
||||
},
|
||||
|
||||
onScanEquipment(e) {
|
||||
const { value, label } = e.currentTarget.dataset;
|
||||
wx.navigateTo({
|
||||
url: '/pages/category/category?dimension=equipment&value=' +
|
||||
encodeURIComponent(value) + '&title=' + encodeURIComponent(label)
|
||||
});
|
||||
},
|
||||
|
||||
onCollection(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/collection-detail/collection-detail?slug=' +
|
||||
encodeURIComponent(e.currentTarget.dataset.slug)
|
||||
});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id)
|
||||
});
|
||||
}
|
||||
});
|
||||
8
miniprogram/pages/index/index.json
Normal file
8
miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"exercise-card": "/components/exercise-card/index",
|
||||
"section-header": "/components/section-header/index",
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
99
miniprogram/pages/index/index.wxml
Normal file
99
miniprogram/pages/index/index.wxml
Normal file
@@ -0,0 +1,99 @@
|
||||
<view class="home" style="padding-top: {{statusBarHeight}}px;">
|
||||
<!-- Hero -->
|
||||
<view class="hero">
|
||||
<view class="hero__eyebrow">FITCOACH · 智能健身教练</view>
|
||||
<view class="hero__title">今天,练点什么?</view>
|
||||
<view class="hero__sub">精准匹配你的目标肌群,让每一次训练都更高效</view>
|
||||
|
||||
<view class="search-pill" bindtap="onSearch">
|
||||
<text class="search-pill__icon">🔍</text>
|
||||
<text class="search-pill__ph">搜索动作 / 器械 / 部位</text>
|
||||
</view>
|
||||
|
||||
<view class="cta" bindtap="goRecommend">
|
||||
<text class="cta__icon">✨</text>
|
||||
<text class="cta__txt">智能推荐</text>
|
||||
<text class="cta__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 2x3 -->
|
||||
<view class="section quick">
|
||||
<view class="quick__grid">
|
||||
<view
|
||||
wx:for="{{quickEntries}}"
|
||||
wx:key="key"
|
||||
class="quick__tile"
|
||||
data-url="{{item.url}}"
|
||||
bindtap="onQuick">
|
||||
<text class="quick__emoji">{{item.emoji}}</text>
|
||||
<text class="quick__label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 精选动作 横向滑动 -->
|
||||
<view class="section">
|
||||
<section-header eyebrow="FEATURED" title="精选动作" subtitle="编辑精选 · 高效入门" action="全部" bind:action="goRecommend" />
|
||||
<scroll-view wx:if="{{featured.length}}" scroll-x class="hscroll">
|
||||
<view class="hscroll__item" wx:for="{{featured}}" wx:key="id">
|
||||
<exercise-card exercise="{{item}}" compact bind:tap="onExercise" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:else class="empty">暂无数据</view>
|
||||
</view>
|
||||
|
||||
<!-- 训练组合 横向 -->
|
||||
<view class="section" wx:if="{{collections.length}}">
|
||||
<section-header eyebrow="PROGRAMS" title="训练组合" subtitle="一键开启主题训练" />
|
||||
<scroll-view scroll-x class="chip-scroll">
|
||||
<view
|
||||
wx:for="{{collections}}"
|
||||
wx:key="slug"
|
||||
class="prog"
|
||||
style="background: {{item.color}}22; border-color: {{item.color}}55;"
|
||||
data-slug="{{item.slug}}"
|
||||
bindtap="onCollection">
|
||||
<text class="prog__emoji">{{item.emoji}}</text>
|
||||
<text class="prog__name" style="color: {{item.color}};">{{item.name}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 按部位浏览 -->
|
||||
<view class="section">
|
||||
<section-header eyebrow="BY BODY PART" title="按部位浏览" subtitle="选择你的目标区域" />
|
||||
<view class="bp-grid">
|
||||
<view
|
||||
wx:for="{{bodyParts}}"
|
||||
wx:key="value"
|
||||
class="bp"
|
||||
data-value="{{item.value}}"
|
||||
data-label="{{item.label}}"
|
||||
bindtap="onScanBodyPart">
|
||||
<text class="bp__label">{{item.label}}</text>
|
||||
<text class="bp__count">{{item.count}} 个动作</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 热门器械 -->
|
||||
<view class="section" wx:if="{{equipment.length}}">
|
||||
<section-header eyebrow="EQUIPMENT" title="热门器械" subtitle="按器械筛选动作" />
|
||||
<scroll-view scroll-x class="chip-scroll">
|
||||
<view
|
||||
wx:for="{{equipment}}"
|
||||
wx:key="value"
|
||||
class="eq"
|
||||
data-value="{{item.value}}"
|
||||
data-label="{{item.label}}"
|
||||
bindtap="onScanEquipment">
|
||||
<text class="eq__name">{{item.label}}</text>
|
||||
<text class="eq__count">{{item.count}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view style="height: 160rpx;"></view>
|
||||
<bottom-nav active="home" />
|
||||
</view>
|
||||
78
miniprogram/pages/index/index.wxss
Normal file
78
miniprogram/pages/index/index.wxss
Normal file
@@ -0,0 +1,78 @@
|
||||
.home { min-height: 100vh; background: var(--bg-grad); }
|
||||
|
||||
.hero {
|
||||
padding: 20rpx 40rpx 48rpx;
|
||||
background: linear-gradient(150deg, #1C2A22 0%, #0E1110 70%);
|
||||
border-bottom-left-radius: 48rpx;
|
||||
border-bottom-right-radius: 48rpx;
|
||||
}
|
||||
.hero__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
|
||||
.hero__title { font-size: 56rpx; font-weight: 700; letter-spacing: 2rpx; margin-top: 14rpx; color: var(--text); }
|
||||
.hero__sub { font-size: 26rpx; color: var(--text-2); margin-top: 14rpx; }
|
||||
|
||||
.search-pill {
|
||||
margin-top: 36rpx;
|
||||
display: flex; align-items: center;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: 999rpx; padding: 22rpx 30rpx;
|
||||
}
|
||||
.search-pill__icon { font-size: 30rpx; margin-right: 16rpx; }
|
||||
.search-pill__ph { color: var(--text-3); font-size: 28rpx; }
|
||||
|
||||
.cta {
|
||||
margin-top: 24rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 32rpx;
|
||||
border-radius: 999rpx; padding: 28rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(217,179,108,0.28);
|
||||
}
|
||||
.cta__icon { margin-right: 12rpx; }
|
||||
.cta__arrow { margin-left: 12rpx; font-size: 36rpx; }
|
||||
|
||||
.quick__grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
|
||||
.quick__tile {
|
||||
width: calc((100% - 36rpx) / 3);
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius); padding: 28rpx 18rpx;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.quick__tile::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: radial-gradient(circle at 50% 0%, var(--gold-soft), transparent 70%);
|
||||
}
|
||||
.quick__emoji { font-size: 48rpx; position: relative; z-index: 1; }
|
||||
.quick__label { font-size: 26rpx; margin-top: 12rpx; color: var(--text); position: relative; z-index: 1; }
|
||||
|
||||
.hscroll { white-space: nowrap; margin-top: 8rpx; }
|
||||
.hscroll__item { display: inline-block; width: 300rpx; margin-right: 20rpx; vertical-align: top; }
|
||||
|
||||
.chip-scroll { white-space: nowrap; margin-top: 8rpx; }
|
||||
.prog {
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-width: 160rpx; padding: 24rpx 28rpx; margin-right: 18rpx;
|
||||
border-radius: var(--radius-sm); border: 1rpx solid;
|
||||
}
|
||||
.prog__emoji { font-size: 44rpx; }
|
||||
.prog__name { font-size: 26rpx; font-weight: 700; margin-top: 10rpx; }
|
||||
|
||||
.bp-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||
.bp {
|
||||
width: calc((100% - 48rpx) / 4);
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: var(--radius-sm); padding: 22rpx 12rpx;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.bp__label { font-size: 26rpx; color: var(--text); font-weight: 600; }
|
||||
.bp__count { font-size: 20rpx; color: var(--text-3); margin-top: 6rpx; }
|
||||
|
||||
.eq {
|
||||
display: inline-flex; align-items: center; gap: 12rpx;
|
||||
background: var(--surface-2); border: 1rpx solid var(--line);
|
||||
border-radius: 999rpx; padding: 16rpx 28rpx; margin-right: 16rpx;
|
||||
}
|
||||
.eq__name { font-size: 26rpx; color: var(--text); }
|
||||
.eq__count { font-size: 22rpx; color: var(--gold); }
|
||||
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 40rpx 0; }
|
||||
91
miniprogram/pages/recommend/recommend.js
Normal file
91
miniprogram/pages/recommend/recommend.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
step: 1, // 1: 选择目标肌群 / 2: 展示结果
|
||||
bodyGroups: [],
|
||||
selectedTarget: '',
|
||||
selectedTargetLabel: '',
|
||||
equipments: [],
|
||||
selectedEquipment: '',
|
||||
results: [],
|
||||
loading: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
this.prepare();
|
||||
},
|
||||
|
||||
prepare() {
|
||||
this.setData({ loading: true });
|
||||
Promise.all([
|
||||
svc.getCategories('targets').catch(() => []),
|
||||
svc.getCategories('equipment').catch(() => []),
|
||||
svc.getCategories('body-parts').catch(() => [])
|
||||
]).then(([targets, equipments, bodyParts]) => {
|
||||
const tList = targets || [];
|
||||
// 按 bodyPart 将目标肌群分组展示;后端未返回分组字段时退化为单组
|
||||
const grouped = {};
|
||||
(bodyParts || []).forEach(bp => { grouped[bp.value] = { label: bp.label, targets: [] }; });
|
||||
tList.forEach(t => {
|
||||
const key = t.bodyPart || 'other';
|
||||
if (!grouped[key]) grouped[key] = { label: t.bodyPartLabel || '其他', targets: [] };
|
||||
grouped[key].targets.push(t);
|
||||
});
|
||||
const bodyGroups = Object.keys(grouped)
|
||||
.map(k => grouped[k])
|
||||
.filter(g => g.targets.length);
|
||||
this.setData({
|
||||
bodyGroups: bodyGroups.length ? bodyGroups : [{ label: '全部目标肌群', targets: tList }],
|
||||
equipments: equipments || [],
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onSelectTarget(e) {
|
||||
this.setData({
|
||||
selectedTarget: e.currentTarget.dataset.value,
|
||||
selectedTargetLabel: e.currentTarget.dataset.label
|
||||
});
|
||||
},
|
||||
|
||||
onSelectEquipment(e) {
|
||||
const value = e.currentTarget.dataset.value;
|
||||
this.setData({ selectedEquipment: this.data.selectedEquipment === value ? '' : value });
|
||||
},
|
||||
|
||||
onConfirm() {
|
||||
if (!this.data.selectedTarget) {
|
||||
wx.showToast({ title: '请先选择目标肌群', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.setData({ loading: true, step: 2 });
|
||||
svc.recommend({
|
||||
target: this.data.selectedTarget,
|
||||
equipment: this.data.selectedEquipment,
|
||||
limit: 20
|
||||
}).then((res) => {
|
||||
this.setData({ results: (res && res.items) || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onBack() {
|
||||
if (this.data.step === 2) {
|
||||
this.setData({ step: 1 });
|
||||
} else {
|
||||
wx.navigateBack();
|
||||
}
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/recommend/recommend.json
Normal file
7
miniprogram/pages/recommend/recommend.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
80
miniprogram/pages/recommend/recommend.wxml
Normal file
80
miniprogram/pages/recommend/recommend.wxml
Normal file
@@ -0,0 +1,80 @@
|
||||
<view class="page" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="topbar">
|
||||
<view class="topbar__back" bindtap="onBack">‹</view>
|
||||
<view class="topbar__title">智能推荐</view>
|
||||
</view>
|
||||
|
||||
<!-- 步骤 1:选择目标肌群 -->
|
||||
<block wx:if="{{step === 1}}">
|
||||
<view class="hero2">
|
||||
<view class="hero2__eyebrow">SMART MATCH</view>
|
||||
<view class="hero2__title">选择你的目标肌群</view>
|
||||
<view class="hero2__sub">为你智能匹配最合适动作,并给出训练理由</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="group" wx:for="{{bodyGroups}}" wx:key="label" wx:for-item="group">
|
||||
<view class="group__label eyebrow">{{group.label}}</view>
|
||||
<view class="group__chips">
|
||||
<view
|
||||
wx:for="{{group.targets}}"
|
||||
wx:key="value"
|
||||
wx:for-item="t"
|
||||
class="tgt {{selectedTarget === t.value ? 'tgt--active' : ''}}"
|
||||
data-value="{{t.value}}"
|
||||
data-label="{{t.label}}"
|
||||
bindtap="onSelectTarget">
|
||||
<text>{{t.label}}</text>
|
||||
<text class="tgt__count">{{t.count}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group" wx:if="{{equipments.length}}">
|
||||
<view class="group__label eyebrow">器械偏好(可选)</view>
|
||||
<view class="group__chips">
|
||||
<view
|
||||
wx:for="{{equipments}}"
|
||||
wx:key="value"
|
||||
wx:for-item="eq"
|
||||
class="tgt {{selectedEquipment === eq.value ? 'tgt--active' : ''}}"
|
||||
data-value="{{eq.value}}"
|
||||
bindtap="onSelectEquipment">
|
||||
<text>{{eq.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="confirm">
|
||||
<view class="confirm__bar">
|
||||
<text wx:if="{{selectedTargetLabel}}" class="confirm__sel">已选:{{selectedTargetLabel}}</text>
|
||||
<text wx:else class="confirm__hint">请选择目标肌群</text>
|
||||
</view>
|
||||
<view class="cta" bindtap="onConfirm">
|
||||
<text>智能匹配动作</text>
|
||||
<text class="cta__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 步骤 2:结果列表 -->
|
||||
<block wx:else>
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="result-head">
|
||||
<text class="result-head__title">为你匹配 · {{selectedTargetLabel}}</text>
|
||||
<text class="result-head__count">{{results.length}} 个动作</text>
|
||||
</view>
|
||||
<view class="rlist" wx:if="{{results.length}}">
|
||||
<view class="rlist__item" wx:for="{{results}}" wx:key="id" wx:for-item="item">
|
||||
<exercise-card
|
||||
exercise="{{item}}"
|
||||
score="{{item.score}}"
|
||||
reason="{{item.reason}}"
|
||||
bind:tap="onExercise" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="empty">{{ loading ? '匹配中…' : '暂无匹配结果' }}</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
46
miniprogram/pages/recommend/recommend.wxss
Normal file
46
miniprogram/pages/recommend/recommend.wxss
Normal file
@@ -0,0 +1,46 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.topbar { height: 88rpx; display: flex; align-items: center; position: relative; padding: 0 24rpx; }
|
||||
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
|
||||
.topbar__title { position: absolute; left: 0; right: 0; text-align: center; font-size: 34rpx; font-weight: 700; color: var(--text); }
|
||||
|
||||
.hero2 { padding: 12rpx 40rpx 28rpx; }
|
||||
.hero2__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
|
||||
.hero2__title { font-size: 48rpx; font-weight: 700; margin-top: 12rpx; }
|
||||
.hero2__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
|
||||
|
||||
.group { margin-bottom: 36rpx; }
|
||||
.group__label { margin-bottom: 18rpx; }
|
||||
.group__chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||
.tgt {
|
||||
display: inline-flex; align-items: center; gap: 10rpx;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: 999rpx; padding: 16rpx 28rpx; font-size: 26rpx; color: var(--text);
|
||||
}
|
||||
.tgt--active { background: var(--gold-soft); border-color: var(--gold-line); color: var(--gold-2); }
|
||||
.tgt__count { font-size: 22rpx; color: var(--text-3); }
|
||||
.tgt--active .tgt__count { color: var(--gold); }
|
||||
|
||||
.confirm {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
padding: 20rpx 32rpx;
|
||||
background: rgba(14,17,16,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1rpx solid var(--line);
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.confirm__bar { text-align: center; margin-bottom: 14rpx; }
|
||||
.confirm__sel { color: var(--gold-2); font-size: 26rpx; }
|
||||
.confirm__hint { color: var(--text-3); font-size: 26rpx; }
|
||||
.cta {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 32rpx;
|
||||
border-radius: 999rpx; padding: 26rpx;
|
||||
}
|
||||
.cta__arrow { margin-left: 12rpx; font-size: 36rpx; }
|
||||
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20rpx; }
|
||||
.result-head__title { font-size: 36rpx; font-weight: 700; color: var(--text); }
|
||||
.result-head__count { font-size: 24rpx; color: var(--gold); }
|
||||
.rlist { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }
|
||||
48
miniprogram/pages/search/search.js
Normal file
48
miniprogram/pages/search/search.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
q: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
searched: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
const q = e.detail.value;
|
||||
this.setData({ q });
|
||||
this.debouncedSearch(q);
|
||||
},
|
||||
|
||||
debouncedSearch(q) {
|
||||
if (this._timer) clearTimeout(this._timer);
|
||||
this._timer = setTimeout(() => this.doSearch(q), 350);
|
||||
},
|
||||
|
||||
doSearch(q) {
|
||||
if (!q || !q.trim()) {
|
||||
this.setData({ results: [], searched: false, loading: false });
|
||||
return;
|
||||
}
|
||||
this.setData({ loading: true });
|
||||
svc.search(q.trim()).then((res) => {
|
||||
this.setData({
|
||||
results: (res && res.items) || [],
|
||||
searched: true,
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ searched: true, loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/search/search.json
Normal file
7
miniprogram/pages/search/search.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
26
miniprogram/pages/search/search.wxml
Normal file
26
miniprogram/pages/search/search.wxml
Normal file
@@ -0,0 +1,26 @@
|
||||
<view class="page">
|
||||
<navbar title="搜索" />
|
||||
<view class="searchbar">
|
||||
<view class="searchbar__box">
|
||||
<text class="searchbar__icon">🔍</text>
|
||||
<input
|
||||
class="searchbar__input"
|
||||
placeholder="搜索动作 / 器械 / 部位"
|
||||
placeholder-class="ph"
|
||||
value="{{q}}"
|
||||
confirm-type="search"
|
||||
bindinput="onInput" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="grid" wx:if="{{results.length}}">
|
||||
<view class="grid__item" wx:for="{{results}}" wx:key="id">
|
||||
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:elif="{{loading}}" class="empty">搜索中…</view>
|
||||
<view wx:elif="{{searched}}" class="empty">未找到相关动作</view>
|
||||
<view wx:else class="empty">输入关键词开始搜索</view>
|
||||
</view>
|
||||
</view>
|
||||
14
miniprogram/pages/search/search.wxss
Normal file
14
miniprogram/pages/search/search.wxss
Normal file
@@ -0,0 +1,14 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.searchbar { padding: 12rpx 32rpx; }
|
||||
.searchbar__box {
|
||||
display: flex; align-items: center;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: 999rpx; padding: 20rpx 28rpx;
|
||||
}
|
||||
.searchbar__icon { font-size: 30rpx; margin-right: 16rpx; }
|
||||
.searchbar__input { flex: 1; color: var(--text); font-size: 28rpx; }
|
||||
.ph { color: var(--text-3); }
|
||||
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
|
||||
.grid__item { width: calc((100% - 18rpx) / 2); }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
|
||||
38
miniprogram/project.config.json
Normal file
38
miniprogram/project.config.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"appid": "touristappid",
|
||||
"projectname": "fitness-coach",
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.5.0",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": false,
|
||||
"postcss": true,
|
||||
"enhance": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"condition": {},
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
62
miniprogram/services/exercise.js
Normal file
62
miniprogram/services/exercise.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// 业务服务层:直接映射后端 REST 接口字段,不做多余转换。
|
||||
const { request } = require('../utils/request');
|
||||
|
||||
// 将参数对象序列化为 query string(跳过空值)
|
||||
function buildQuery(params) {
|
||||
const qs = Object.keys(params || {})
|
||||
.filter(k => params[k] !== '' && params[k] !== undefined && params[k] !== null)
|
||||
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
|
||||
.join('&');
|
||||
return qs ? '?' + qs : '';
|
||||
}
|
||||
|
||||
// 1) 动作列表(支持多维筛选与排序)
|
||||
function listExercises(params = {}) {
|
||||
return request({ url: '/api/exercises' + buildQuery(params) });
|
||||
}
|
||||
|
||||
// 2) 动作详情
|
||||
function getExercise(id) {
|
||||
return request({ url: '/api/exercises/' + encodeURIComponent(id) });
|
||||
}
|
||||
|
||||
// 3) 分类维度枚举
|
||||
function getCategories(type) {
|
||||
return request({ url: '/api/categories/' + encodeURIComponent(type) });
|
||||
}
|
||||
|
||||
// 4) 智能推荐
|
||||
function recommend(params = {}) {
|
||||
return request({ url: '/api/recommend' + buildQuery(params) });
|
||||
}
|
||||
|
||||
// 5) 训练组合列表
|
||||
function listCollections() {
|
||||
return request({ url: '/api/collections' });
|
||||
}
|
||||
|
||||
// 5) 训练组合下的动作
|
||||
function getCollectionExercises(slug, params = {}) {
|
||||
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) });
|
||||
}
|
||||
|
||||
// 6) 搜索
|
||||
function search(q) {
|
||||
return request({ url: '/api/search?q=' + encodeURIComponent(q) });
|
||||
}
|
||||
|
||||
// 7) 统计概览
|
||||
function getStats() {
|
||||
return request({ url: '/api/stats' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listExercises,
|
||||
getExercise,
|
||||
getCategories,
|
||||
recommend,
|
||||
listCollections,
|
||||
getCollectionExercises,
|
||||
search,
|
||||
getStats
|
||||
};
|
||||
8
miniprogram/sitemap.json
Normal file
8
miniprogram/sitemap.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
miniprogram/utils/auth.js
Normal file
20
miniprogram/utils/auth.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// 极简登录占位:仅演示 wx.login 拿到 code,不请求真实后端服务端。
|
||||
// 生产环境中,应将 code 发送到后端换取 openid / session。
|
||||
function getOpenid() {
|
||||
return new Promise((resolve) => {
|
||||
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 || '');
|
||||
},
|
||||
fail() {
|
||||
resolve('');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getOpenid };
|
||||
27
miniprogram/utils/format.js
Normal file
27
miniprogram/utils/format.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// 通用格式化辅助函数
|
||||
function truncate(str, n) {
|
||||
if (!str) return '';
|
||||
return str.length > n ? str.slice(0, n) + '…' : str;
|
||||
}
|
||||
|
||||
// 训练类型 -> 主题色(用于标签 / 角标)
|
||||
function typeColor(type) {
|
||||
const map = {
|
||||
strength: '#D9B36C', // 力量 -> 金
|
||||
cardio: '#58C9B9', // 有氧 -> 青
|
||||
flexibility: '#9B8CFF' // 拉伸 -> 紫
|
||||
};
|
||||
return map[type] || '#D9B36C';
|
||||
}
|
||||
|
||||
// 训练类型 -> 中文名
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
strength: '力量',
|
||||
cardio: '有氧',
|
||||
flexibility: '拉伸'
|
||||
};
|
||||
return map[type] || '动作';
|
||||
}
|
||||
|
||||
module.exports = { truncate, typeColor, typeLabel };
|
||||
31
miniprogram/utils/request.js
Normal file
31
miniprogram/utils/request.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// 统一的网络请求封装:所有请求都经过此包装器。
|
||||
// 后端为公开 API,无需鉴权头;失败时统一 toast 提示。
|
||||
const { BASE_URL } = require('../config');
|
||||
|
||||
function request({ url, method = 'GET', data = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
data,
|
||||
header: { 'content-type': 'application/json' },
|
||||
success(res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data);
|
||||
} else {
|
||||
wx.showToast({
|
||||
title: '请求失败 (' + res.statusCode + ')',
|
||||
icon: 'none'
|
||||
});
|
||||
reject(res);
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
wx.showToast({ title: '网络错误,请稍后重试', icon: 'none' });
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { request };
|
||||
Reference in New Issue
Block a user