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:
91
miniprogram/pages/recommend/recommend.js
Normal file
91
miniprogram/pages/recommend/recommend.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const app = getApp();
|
||||
const svc = require('../../services/exercise');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 20,
|
||||
step: 1, // 1: 选择目标肌群 / 2: 展示结果
|
||||
bodyGroups: [],
|
||||
selectedTarget: '',
|
||||
selectedTargetLabel: '',
|
||||
equipments: [],
|
||||
selectedEquipment: '',
|
||||
results: [],
|
||||
loading: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
|
||||
this.prepare();
|
||||
},
|
||||
|
||||
prepare() {
|
||||
this.setData({ loading: true });
|
||||
Promise.all([
|
||||
svc.getCategories('targets').catch(() => []),
|
||||
svc.getCategories('equipment').catch(() => []),
|
||||
svc.getCategories('body-parts').catch(() => [])
|
||||
]).then(([targets, equipments, bodyParts]) => {
|
||||
const tList = targets || [];
|
||||
// 按 bodyPart 将目标肌群分组展示;后端未返回分组字段时退化为单组
|
||||
const grouped = {};
|
||||
(bodyParts || []).forEach(bp => { grouped[bp.value] = { label: bp.label, targets: [] }; });
|
||||
tList.forEach(t => {
|
||||
const key = t.bodyPart || 'other';
|
||||
if (!grouped[key]) grouped[key] = { label: t.bodyPartLabel || '其他', targets: [] };
|
||||
grouped[key].targets.push(t);
|
||||
});
|
||||
const bodyGroups = Object.keys(grouped)
|
||||
.map(k => grouped[k])
|
||||
.filter(g => g.targets.length);
|
||||
this.setData({
|
||||
bodyGroups: bodyGroups.length ? bodyGroups : [{ label: '全部目标肌群', targets: tList }],
|
||||
equipments: equipments || [],
|
||||
loading: false
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onSelectTarget(e) {
|
||||
this.setData({
|
||||
selectedTarget: e.currentTarget.dataset.value,
|
||||
selectedTargetLabel: e.currentTarget.dataset.label
|
||||
});
|
||||
},
|
||||
|
||||
onSelectEquipment(e) {
|
||||
const value = e.currentTarget.dataset.value;
|
||||
this.setData({ selectedEquipment: this.data.selectedEquipment === value ? '' : value });
|
||||
},
|
||||
|
||||
onConfirm() {
|
||||
if (!this.data.selectedTarget) {
|
||||
wx.showToast({ title: '请先选择目标肌群', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.setData({ loading: true, step: 2 });
|
||||
svc.recommend({
|
||||
target: this.data.selectedTarget,
|
||||
equipment: this.data.selectedEquipment,
|
||||
limit: 20
|
||||
}).then((res) => {
|
||||
this.setData({ results: (res && res.items) || [], loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
onBack() {
|
||||
if (this.data.step === 2) {
|
||||
this.setData({ step: 1 });
|
||||
} else {
|
||||
wx.navigateBack();
|
||||
}
|
||||
},
|
||||
|
||||
onExercise(e) {
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
|
||||
}
|
||||
});
|
||||
7
miniprogram/pages/recommend/recommend.json
Normal file
7
miniprogram/pages/recommend/recommend.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"navbar": "/components/navbar/index",
|
||||
"exercise-card": "/components/exercise-card/index"
|
||||
}
|
||||
}
|
||||
80
miniprogram/pages/recommend/recommend.wxml
Normal file
80
miniprogram/pages/recommend/recommend.wxml
Normal file
@@ -0,0 +1,80 @@
|
||||
<view class="page" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="topbar">
|
||||
<view class="topbar__back" bindtap="onBack">‹</view>
|
||||
<view class="topbar__title">智能推荐</view>
|
||||
</view>
|
||||
|
||||
<!-- 步骤 1:选择目标肌群 -->
|
||||
<block wx:if="{{step === 1}}">
|
||||
<view class="hero2">
|
||||
<view class="hero2__eyebrow">SMART MATCH</view>
|
||||
<view class="hero2__title">选择你的目标肌群</view>
|
||||
<view class="hero2__sub">为你智能匹配最合适动作,并给出训练理由</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="group" wx:for="{{bodyGroups}}" wx:key="label" wx:for-item="group">
|
||||
<view class="group__label eyebrow">{{group.label}}</view>
|
||||
<view class="group__chips">
|
||||
<view
|
||||
wx:for="{{group.targets}}"
|
||||
wx:key="value"
|
||||
wx:for-item="t"
|
||||
class="tgt {{selectedTarget === t.value ? 'tgt--active' : ''}}"
|
||||
data-value="{{t.value}}"
|
||||
data-label="{{t.label}}"
|
||||
bindtap="onSelectTarget">
|
||||
<text>{{t.label}}</text>
|
||||
<text class="tgt__count">{{t.count}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="group" wx:if="{{equipments.length}}">
|
||||
<view class="group__label eyebrow">器械偏好(可选)</view>
|
||||
<view class="group__chips">
|
||||
<view
|
||||
wx:for="{{equipments}}"
|
||||
wx:key="value"
|
||||
wx:for-item="eq"
|
||||
class="tgt {{selectedEquipment === eq.value ? 'tgt--active' : ''}}"
|
||||
data-value="{{eq.value}}"
|
||||
bindtap="onSelectEquipment">
|
||||
<text>{{eq.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="confirm">
|
||||
<view class="confirm__bar">
|
||||
<text wx:if="{{selectedTargetLabel}}" class="confirm__sel">已选:{{selectedTargetLabel}}</text>
|
||||
<text wx:else class="confirm__hint">请选择目标肌群</text>
|
||||
</view>
|
||||
<view class="cta" bindtap="onConfirm">
|
||||
<text>智能匹配动作</text>
|
||||
<text class="cta__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 步骤 2:结果列表 -->
|
||||
<block wx:else>
|
||||
<view class="section" style="padding-top: 8rpx;">
|
||||
<view class="result-head">
|
||||
<text class="result-head__title">为你匹配 · {{selectedTargetLabel}}</text>
|
||||
<text class="result-head__count">{{results.length}} 个动作</text>
|
||||
</view>
|
||||
<view class="rlist" wx:if="{{results.length}}">
|
||||
<view class="rlist__item" wx:for="{{results}}" wx:key="id" wx:for-item="item">
|
||||
<exercise-card
|
||||
exercise="{{item}}"
|
||||
score="{{item.score}}"
|
||||
reason="{{item.reason}}"
|
||||
bind:tap="onExercise" />
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="empty">{{ loading ? '匹配中…' : '暂无匹配结果' }}</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
46
miniprogram/pages/recommend/recommend.wxss
Normal file
46
miniprogram/pages/recommend/recommend.wxss
Normal file
@@ -0,0 +1,46 @@
|
||||
.page { min-height: 100vh; background: var(--bg-grad); }
|
||||
.topbar { height: 88rpx; display: flex; align-items: center; position: relative; padding: 0 24rpx; }
|
||||
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
|
||||
.topbar__title { position: absolute; left: 0; right: 0; text-align: center; font-size: 34rpx; font-weight: 700; color: var(--text); }
|
||||
|
||||
.hero2 { padding: 12rpx 40rpx 28rpx; }
|
||||
.hero2__eyebrow { color: var(--gold); font-size: 22rpx; letter-spacing: 4rpx; }
|
||||
.hero2__title { font-size: 48rpx; font-weight: 700; margin-top: 12rpx; }
|
||||
.hero2__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
|
||||
|
||||
.group { margin-bottom: 36rpx; }
|
||||
.group__label { margin-bottom: 18rpx; }
|
||||
.group__chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||
.tgt {
|
||||
display: inline-flex; align-items: center; gap: 10rpx;
|
||||
background: var(--surface); border: 1rpx solid var(--line);
|
||||
border-radius: 999rpx; padding: 16rpx 28rpx; font-size: 26rpx; color: var(--text);
|
||||
}
|
||||
.tgt--active { background: var(--gold-soft); border-color: var(--gold-line); color: var(--gold-2); }
|
||||
.tgt__count { font-size: 22rpx; color: var(--text-3); }
|
||||
.tgt--active .tgt__count { color: var(--gold); }
|
||||
|
||||
.confirm {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
padding: 20rpx 32rpx;
|
||||
background: rgba(14,17,16,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1rpx solid var(--line);
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.confirm__bar { text-align: center; margin-bottom: 14rpx; }
|
||||
.confirm__sel { color: var(--gold-2); font-size: 26rpx; }
|
||||
.confirm__hint { color: var(--text-3); font-size: 26rpx; }
|
||||
.cta {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
|
||||
color: #0E1110; font-weight: 700; font-size: 32rpx;
|
||||
border-radius: 999rpx; padding: 26rpx;
|
||||
}
|
||||
.cta__arrow { margin-left: 12rpx; font-size: 36rpx; }
|
||||
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20rpx; }
|
||||
.result-head__title { font-size: 36rpx; font-weight: 700; color: var(--text); }
|
||||
.result-head__count { font-size: 24rpx; color: var(--gold); }
|
||||
.rlist { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }
|
||||
Reference in New Issue
Block a user