feat: init fitness-coach (backend + miniprogram)

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

47
miniprogram/README.md Normal file
View File

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

26
miniprogram/app.js Normal file
View File

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

19
miniprogram/app.json Normal file
View File

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

88
miniprogram/app.wxss Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
miniprogram/config.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

8
miniprogram/sitemap.json Normal file
View File

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

20
miniprogram/utils/auth.js Normal file
View 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 };

View 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 };

View 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 };