This commit is contained in:
刘正航
2026-04-21 22:45:19 +08:00
commit b5237f9038
159 changed files with 7769 additions and 0 deletions

View 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 })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "饮食推荐"
}

View 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>

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