1
This commit is contained in:
187
miniprogram/pages/recommend/index.js
Normal file
187
miniprogram/pages/recommend/index.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const { request } = require('../../utils/request')
|
||||
|
||||
const REC_TYPES = [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前状态推荐',
|
||||
desc: '基于你最近一次饮食状态,用随机森林给出当前最匹配的食谱。',
|
||||
scene: '日常实时决策',
|
||||
requirement: '建议先填写今天的体重、体脂、摄入和消耗'
|
||||
},
|
||||
{
|
||||
key: 'health',
|
||||
label: '健康状态推荐',
|
||||
desc: '按体脂、摄入和运动平衡进行规则评分,优先推荐营养结构更均衡的方案。',
|
||||
scene: '想吃得更健康',
|
||||
requirement: '推荐先有基础饮食状态数据'
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: '历史计划推荐',
|
||||
desc: '根据近一段时间的体重和摄入趋势,给出偏减脂或偏增肌的策略型推荐。',
|
||||
scene: '需要阶段性调整',
|
||||
requirement: '至少有 3 天历史记录'
|
||||
},
|
||||
{
|
||||
key: 'goal',
|
||||
label: '未来目标推荐',
|
||||
desc: '围绕你的目标(减脂/增肌/生酮/维持)进行宏量营养匹配。',
|
||||
scene: '目标导向饮食',
|
||||
requirement: '可跟随个人资料,也可手动指定目标'
|
||||
},
|
||||
{
|
||||
key: 'occupation',
|
||||
label: '职业行业推荐',
|
||||
desc: '根据职业活动强度和作息特点,推荐更贴近日常能量需求的食谱。',
|
||||
scene: '工作日场景优化',
|
||||
requirement: '可跟随个人资料,也可手动指定职业'
|
||||
}
|
||||
]
|
||||
|
||||
const TOP_K_OPTIONS = [
|
||||
{ value: 3, label: '3 条' },
|
||||
{ value: 5, label: '5 条' },
|
||||
{ value: 10, label: '10 条' }
|
||||
]
|
||||
|
||||
const GOAL_OPTIONS = [
|
||||
{ value: '', label: '跟随个人资料(推荐)' },
|
||||
{ value: 'lose_fat', label: '减脂(lose_fat)' },
|
||||
{ value: 'gain_muscle', label: '增肌(gain_muscle)' },
|
||||
{ value: 'keto', label: '生酮(keto)' },
|
||||
{ value: 'maintain', label: '维持(maintain)' }
|
||||
]
|
||||
|
||||
const OCCUPATION_OPTIONS = [
|
||||
{ value: '', label: '跟随个人资料(推荐)' },
|
||||
{ value: 'office', label: '办公室(office)' },
|
||||
{ value: 'developer', label: '程序员(developer)' },
|
||||
{ value: 'fitness', label: '健身行业(fitness)' },
|
||||
{ value: 'teacher', label: '教师(teacher)' },
|
||||
{ value: 'student', label: '学生(student)' },
|
||||
{ value: 'manual', label: '体力劳动(manual)' }
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
recType: 'current',
|
||||
recTypes: REC_TYPES,
|
||||
activeType: REC_TYPES[0],
|
||||
selectedLabel: REC_TYPES[0].label,
|
||||
topK: 5,
|
||||
topKLabel: '5 条',
|
||||
topKOptions: TOP_K_OPTIONS,
|
||||
goalValue: '',
|
||||
goalLabel: GOAL_OPTIONS[0].label,
|
||||
goalOptions: GOAL_OPTIONS,
|
||||
occupationValue: '',
|
||||
occupationLabel: OCCUPATION_OPTIONS[0].label,
|
||||
occupationOptions: OCCUPATION_OPTIONS,
|
||||
loading: false,
|
||||
payload: null,
|
||||
summary: null,
|
||||
errorText: '',
|
||||
lastUpdatedAt: ''
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.fetchRecommend()
|
||||
},
|
||||
|
||||
onTypeChange(e) {
|
||||
const idx = Number(e.detail.value)
|
||||
const item = this.data.recTypes[idx]
|
||||
if (!item) return
|
||||
this.setData({
|
||||
recType: item.key,
|
||||
selectedLabel: item.label,
|
||||
activeType: item
|
||||
})
|
||||
this.fetchRecommend()
|
||||
},
|
||||
|
||||
onTopKChange(e) {
|
||||
const idx = Number(e.detail.value)
|
||||
const item = this.data.topKOptions[idx]
|
||||
if (!item) return
|
||||
this.setData({ topK: item.value, topKLabel: item.label })
|
||||
this.fetchRecommend()
|
||||
},
|
||||
|
||||
onGoalChange(e) {
|
||||
const idx = Number(e.detail.value)
|
||||
const item = this.data.goalOptions[idx]
|
||||
if (!item) return
|
||||
this.setData({ goalValue: item.value, goalLabel: item.label })
|
||||
this.fetchRecommend()
|
||||
},
|
||||
|
||||
onOccupationChange(e) {
|
||||
const idx = Number(e.detail.value)
|
||||
const item = this.data.occupationOptions[idx]
|
||||
if (!item) return
|
||||
this.setData({ occupationValue: item.value, occupationLabel: item.label })
|
||||
this.fetchRecommend()
|
||||
},
|
||||
|
||||
buildUrl() {
|
||||
const topK = this.data.topK || 5
|
||||
const type = this.data.recType
|
||||
if (type === 'current') return `/recommend/current?top_k=${topK}`
|
||||
if (type === 'health') return `/recommend/health?top_k=${topK}`
|
||||
if (type === 'history') return `/recommend/plan/history?top_k=${topK}`
|
||||
if (type === 'goal') {
|
||||
const extra = this.data.goalValue ? `&goal=${encodeURIComponent(this.data.goalValue)}` : ''
|
||||
return `/recommend/plan/goal?top_k=${topK}${extra}`
|
||||
}
|
||||
const occ = this.data.occupationValue ? `&occupation=${encodeURIComponent(this.data.occupationValue)}` : ''
|
||||
return `/recommend/plan/occupation?top_k=${topK}${occ}`
|
||||
},
|
||||
|
||||
buildSummary(items = []) {
|
||||
if (!items.length) return null
|
||||
const totalCalories = items.reduce((sum, row) => sum + Number(row.calories || 0), 0)
|
||||
const totalProtein = items.reduce((sum, row) => sum + Number(row.protein || 0), 0)
|
||||
return {
|
||||
count: items.length,
|
||||
avgCalories: Math.round((totalCalories / items.length) * 10) / 10,
|
||||
avgProtein: Math.round((totalProtein / items.length) * 10) / 10
|
||||
}
|
||||
},
|
||||
|
||||
formatNow() {
|
||||
const d = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
},
|
||||
|
||||
async fetchRecommend() {
|
||||
this.setData({ loading: true, errorText: '' })
|
||||
try {
|
||||
const payload = await request({ url: this.buildUrl() })
|
||||
const items = payload && Array.isArray(payload.items) ? payload.items : []
|
||||
const normalizedItems = items.map((row, idx) => {
|
||||
const score = Number(row.rf_score ?? row.score ?? 0)
|
||||
return {
|
||||
...row,
|
||||
rank: idx + 1,
|
||||
displayScore: score.toFixed(1)
|
||||
}
|
||||
})
|
||||
this.setData({
|
||||
payload: { ...payload, items: normalizedItems },
|
||||
summary: this.buildSummary(normalizedItems),
|
||||
lastUpdatedAt: this.formatNow(),
|
||||
errorText: ''
|
||||
})
|
||||
} catch (err) {
|
||||
this.setData({
|
||||
payload: null,
|
||||
summary: null,
|
||||
errorText: (err && err.message) || '推荐请求失败,请稍后重试'
|
||||
})
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/recommend/index.json
Normal file
3
miniprogram/pages/recommend/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "饮食推荐"
|
||||
}
|
||||
104
miniprogram/pages/recommend/index.wxml
Normal file
104
miniprogram/pages/recommend/index.wxml
Normal file
@@ -0,0 +1,104 @@
|
||||
<view class="container">
|
||||
<view class="hero">
|
||||
<view class="hero-title">饮食推荐管理</view>
|
||||
<view class="hero-sub">更清晰的推荐类型说明和结果解读,帮助你快速做决策</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">推荐参数</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">推荐类型</text>
|
||||
<picker mode="selector" range="{{recTypes}}" range-key="label" bindchange="onTypeChange">
|
||||
<text class="value">{{selectedLabel}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="type-intro" wx:if="{{activeType}}">
|
||||
<view class="type-intro-title">{{activeType.label}}</view>
|
||||
<view class="type-intro-text">{{activeType.desc}}</view>
|
||||
<view class="type-intro-sub">适用场景:{{activeType.scene}}</view>
|
||||
<view class="type-intro-sub">数据要求:{{activeType.requirement}}</view>
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">推荐数量</text>
|
||||
<picker mode="selector" range="{{topKOptions}}" range-key="label" bindchange="onTopKChange">
|
||||
<text class="value">{{topKLabel}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:if="{{recType === 'goal'}}">
|
||||
<text class="label">目标设定</text>
|
||||
<picker mode="selector" range="{{goalOptions}}" range-key="label" bindchange="onGoalChange">
|
||||
<text class="value">{{goalLabel}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:if="{{recType === 'occupation'}}">
|
||||
<text class="label">职业设定</text>
|
||||
<picker mode="selector" range="{{occupationOptions}}" range-key="label" bindchange="onOccupationChange">
|
||||
<text class="value">{{occupationLabel}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="selection-tags">
|
||||
<text class="selection-tag">类型:{{selectedLabel}}</text>
|
||||
<text class="selection-tag">数量:{{topKLabel}}</text>
|
||||
<text class="selection-tag" wx:if="{{recType === 'goal'}}">目标:{{goalLabel}}</text>
|
||||
<text class="selection-tag" wx:if="{{recType === 'occupation'}}">职业:{{occupationLabel}}</text>
|
||||
</view>
|
||||
|
||||
<button class="btn btn-primary" loading="{{loading}}" bindtap="fetchRecommend">刷新推荐</button>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{errorText}}">
|
||||
<view class="card-title">提示</view>
|
||||
<view class="warn">{{errorText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{payload}}">
|
||||
<view class="card-title">推荐结果</view>
|
||||
<view class="muted" wx:if="{{payload.strategy}}">策略:{{payload.strategy}}</view>
|
||||
<view class="muted" wx:if="{{lastUpdatedAt}}">更新时间:{{lastUpdatedAt}}</view>
|
||||
|
||||
<view class="summary-grid" wx:if="{{summary}}">
|
||||
<view class="summary-item">
|
||||
<view class="summary-label">命中结果</view>
|
||||
<view class="summary-value">{{summary.count}} 条</view>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<view class="summary-label">平均热量</view>
|
||||
<view class="summary-value">{{summary.avgCalories}} kcal</view>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<view class="summary-label">平均蛋白</view>
|
||||
<view class="summary-value">{{summary.avgProtein}} g</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="metrics-tags" wx:if="{{payload.metrics}}">
|
||||
<text class="metric-tag">平均体重 {{payload.metrics.avg_weight}} kg</text>
|
||||
<text class="metric-tag">体重趋势 {{payload.metrics.weight_trend}} kg</text>
|
||||
<text class="metric-tag">平均摄入 {{payload.metrics.avg_intake}} kcal</text>
|
||||
<text class="metric-tag">平均运动 {{payload.metrics.avg_exercise}} kcal</text>
|
||||
</view>
|
||||
|
||||
<view class="score-tip">评分越高,表示与当前策略和用户画像匹配度越高。</view>
|
||||
|
||||
<view wx:if="{{!payload.items || !payload.items.length}}" class="empty">暂无推荐结果</view>
|
||||
|
||||
<view wx:for="{{payload.items}}" wx:key="id" wx:for-item="recipe" class="item">
|
||||
<view class="item-head">
|
||||
<view class="rank-badge">#{{recipe.rank}}</view>
|
||||
<view class="item-title">{{recipe.name}}</view>
|
||||
<view class="value">{{recipe.displayScore}}</view>
|
||||
</view>
|
||||
<view class="item-sub">{{recipe.category}} · {{recipe.calories}} kcal · 蛋白{{recipe.protein}}g</view>
|
||||
<view class="item-sub">{{recipe.reason}}</view>
|
||||
<view class="item-sub" wx:if="{{recipe.tags && recipe.tags.length}}">
|
||||
<text wx:for="{{recipe.tags}}" wx:key="index" wx:for-item="tag" class="pill">{{tag}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
115
miniprogram/pages/recommend/index.wxss
Normal file
115
miniprogram/pages/recommend/index.wxss
Normal file
@@ -0,0 +1,115 @@
|
||||
.type-intro {
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f3f8ff;
|
||||
border: 1rpx solid rgba(13, 59, 102, 0.16);
|
||||
}
|
||||
|
||||
.type-intro-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #134773;
|
||||
}
|
||||
|
||||
.type-intro-text {
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
color: #2b4b69;
|
||||
}
|
||||
|
||||
.type-intro-sub {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #5e7489;
|
||||
}
|
||||
|
||||
.selection-tags {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.selection-tag {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #edf3fa;
|
||||
color: #34516e;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
margin-top: 14rpx;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #f8fbff;
|
||||
border: 1rpx solid rgba(13, 59, 102, 0.1);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 22rpx;
|
||||
color: #60768b;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
margin-top: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: #143d62;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metrics-tags {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.metric-tag {
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(31, 157, 114, 0.1);
|
||||
color: #1a7e5e;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.score-tip {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 14rpx;
|
||||
border-left: 6rpx solid #f4a261;
|
||||
background: #fff7ee;
|
||||
color: #7a4f22;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
min-width: 54rpx;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f4a261, #e76f51);
|
||||
}
|
||||
|
||||
.item-head .item-title {
|
||||
flex: 1;
|
||||
}
|
||||
Reference in New Issue
Block a user