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