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