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

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