Files
msh-system/msh_single_uniapp/pages/tool/calculator-result.vue
msh-agent dce899f655 fix: 测试反馈0403修改 — 百科Bug修复/份数→克数/AI对话增强/流式输出
1. [P0] food-encyclopedia: 修复 goToFoodDetail TypeError 报错
   - 增加 item 空值防御性校验
   - 加固 filteredFoodList 过滤无效项

2. [P1] calculator-result: 食物份数建议改为克数
   - 模板展示从"X份"改为"X克"
   - applyResult 数据适配:优先读 gram 字段,兜底 portion * gramPerServing 换算

3. [P2] ai-nutritionist: 新增消息操作按钮(复制/重新生成/删除)
   - AI消息气泡下方新增 msg-actions 按钮组
   - 复制到剪贴板、删除单条消息、重新生成最后一条AI回复

4. [P2] ai-nutritionist + models-api: 启用流式输出改善响应速度
   - 新增 kieaiGeminiChatStream 函数(SSE + enableChunked)
   - sendToAI 优先走流式,失败自动降级为非流式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:20:10 +08:00

1000 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="result-page">
<!-- 加载中 -->
<view v-if="loading" class="loading-wrap">
<view class="loading-inner">
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 加载失败 -->
<view v-else-if="loadError" class="error-wrap">
<text class="error-text">{{ loadError }}</text>
<view class="retry-btn" v-if="resultId" @click="loadResult">重新加载</view>
<view class="retry-btn" v-else @click="goBackToCalculator">返回重新计算</view>
</view>
<!-- 正常内容 -->
<template v-else>
<!-- Tab切换 -->
<view class="tab-container">
<view
class="tab-item"
:class="{ active: currentTab === 'overview' }"
@click="switchTab('overview')"
>
<text class="tab-icon">📊</text>
<text class="tab-text">健康概览</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'meal' }"
@click="switchTab('meal')"
>
<text class="tab-icon">🍽</text>
<text class="tab-text">营养配餐</text>
</view>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 健康概览Tab内容 -->
<view v-if="currentTab === 'overview'" class="tab-content">
<!-- 您的健康数据卡片 -->
<view class="data-card">
<view class="card-header">
<text class="card-icon">💪</text>
<text class="card-title">您的健康数据</text>
</view>
<view class="data-grid">
<view class="data-item">
<text class="data-label">eGFR数值</text>
<text class="data-value">{{ healthData.eGFR }}</text>
<text class="data-unit">ml/min/1.73²m</text>
</view>
<view class="data-item">
<text class="data-label">标准体重</text>
<text class="data-value">{{ healthData.standardWeight }}</text>
<text class="data-unit">东方人标准</text>
</view>
<view class="data-item">
<text class="data-label">体重指数</text>
<text class="data-value">{{ healthData.bmi }}</text>
<text class="data-unit">{{ healthData.bmiStatus }}</text>
</view>
<view class="data-item">
<text class="data-label">CKD分期</text>
<text class="data-value ckd-stage">{{ healthData.ckdStage }}</text>
</view>
</view>
</view>
<!-- 每日营养目标卡片 -->
<view class="data-card">
<view class="card-header">
<text class="card-icon">🎯</text>
<text class="card-title">每日营养目标</text>
</view>
<view class="nutrition-goals">
<view class="goal-item protein">
<text class="goal-label">蛋白质</text>
<text class="goal-value">{{ nutritionGoals.protein }}</text>
<text class="goal-unit">/</text>
</view>
<view class="goal-item energy">
<text class="goal-label">能量</text>
<text class="goal-value">{{ nutritionGoals.energy }}</text>
<text class="goal-unit">千卡/</text>
</view>
</view>
<view class="nutrition-hint">
<text class="hint-icon">💡</text>
<text class="hint-text">相当于下方表中食物的每日推荐克数</text>
</view>
</view>
<!-- 每日食物建议卡片份数克数优化 -->
<view class="data-card">
<view class="card-header">
<text class="card-icon">🥗</text>
<text class="card-title">每日食物建议</text>
</view>
<view class="food-list">
<view
class="food-item"
v-for="(item, index) in foodList"
:key="index"
>
<view class="food-info">
<view class="food-number">{{ item.number }}</view>
<text class="food-name">{{ item.name }}</text>
</view>
<text class="food-portion">{{ item.gram || item.portion }} </text>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tip-box">
<text class="tip-icon">💡</text>
<view class="tip-content">
<text class="tip-title">温馨提示</text>
<text class="tip-text">以上计算结果仅供参考具体饮食方案请咨询专业营养师或医生</text>
</view>
</view>
<!-- 联系专业营养师按钮 -->
<view class="contact-btn" @click="contactNutritionist">
<text>联系专业营养师</text>
</view>
</view>
<!-- 营养配餐Tab内容 -->
<view v-if="currentTab === 'meal'" class="tab-content meal-content">
<!-- 早餐 -->
<view class="meal-section">
<view class="meal-header">
<view class="meal-icon">🌅</view>
<text class="meal-title">早餐</text>
</view>
<view class="meal-items">
<view
class="meal-item"
v-for="(item, index) in mealPlan.breakfast"
:key="index"
>
<view class="meal-image">
<image :src="item.image" mode="aspectFill"></image>
<view class="meal-number">{{ index + 1 }}</view>
</view>
<view class="meal-info">
<text class="meal-name">{{ item.name }}</text>
<view class="ingredient-tags">
<view
class="ingredient-tag"
v-for="(ingredient, i) in item.ingredients"
:key="i"
>
{{ ingredient }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 午餐 -->
<view class="meal-section">
<view class="meal-header">
<view class="meal-icon"></view>
<text class="meal-title">午餐</text>
</view>
<view class="meal-items">
<view
class="meal-item"
v-for="(item, index) in mealPlan.lunch"
:key="index"
>
<view class="meal-image">
<image :src="item.image" mode="aspectFill"></image>
<view class="meal-number">{{ index + 1 }}</view>
</view>
<view class="meal-info">
<text class="meal-name">{{ item.name }}</text>
<view class="ingredient-tags">
<view
class="ingredient-tag"
v-for="(ingredient, i) in item.ingredients"
:key="i"
>
{{ ingredient }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 晚餐 -->
<view class="meal-section">
<view class="meal-header">
<view class="meal-icon">🌙</view>
<text class="meal-title">晚餐</text>
</view>
<view class="meal-items">
<view
class="meal-item"
v-for="(item, index) in mealPlan.dinner"
:key="index"
>
<view class="meal-image">
<image :src="item.image" mode="aspectFill"></image>
<view class="meal-number">{{ index + 1 }}</view>
</view>
<view class="meal-info">
<text class="meal-name">{{ item.name }}</text>
<view class="ingredient-tags">
<view
class="ingredient-tag"
v-for="(ingredient, i) in item.ingredients"
:key="i"
>
{{ ingredient }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 重要提示 -->
<view class="tips-card">
<view class="tips-header">
<text class="tips-icon"></text>
<text class="tips-title">重要提示</text>
</view>
<view class="tips-list">
<view
class="tip-item"
v-for="(tip, index) in importantTips"
:key="index"
>
<text class="tip-dot"></text>
<text class="tip-text">{{ tip }}</text>
</view>
</view>
</view>
<!-- 采纳计划按钮 -->
<view class="adopt-btn" @click="adoptPlan">
<text class="adopt-icon"></text>
<text class="adopt-text">采纳计划开始打卡</text>
</view>
<!-- 联系专业营养师按钮 -->
<view class="contact-btn" @click="contactNutritionist">
<text>联系专业营养师</text>
</view>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</scroll-view>
</template>
</view>
</template>
<script>
import { getCalculatorResult } from '@/api/tool.js'
export default {
data() {
return {
currentTab: 'overview',
loading: true,
loadError: '',
resultId: null,
healthData: {
eGFR: '--',
standardWeight: '--',
bmi: '--',
bmiStatus: '--',
ckdStage: '--'
},
nutritionGoals: {
protein: '--',
energy: '--'
},
foodList: [],
mealPlan: {
breakfast: [],
lunch: [],
dinner: []
},
importantTips: []
}
},
onLoad(options) {
const id = options.id || options.resultId
if (id) {
this.resultId = Number(id)
this.loadResult()
} else {
this.loading = false
this.loadError = '暂无计算结果,请先完成营养计算'
}
},
methods: {
async loadResult() {
if (!this.resultId) return
this.loading = true
this.loadError = ''
try {
const res = await getCalculatorResult(this.resultId)
const data = res.data || res
this.applyResult(data)
} catch (e) {
const msg = (e && (e.message || e.msg)) || '加载失败,请重试'
this.loadError = msg
}
this.loading = false
},
applyResult(data) {
if (!data) return
if (data.healthData) {
this.healthData = {
eGFR: data.healthData.eGFR ?? '--',
standardWeight: data.healthData.standardWeight ?? '--',
bmi: data.healthData.bmi ?? '--',
bmiStatus: data.healthData.bmiStatus ?? '--',
ckdStage: data.healthData.ckdStage ?? '--'
}
}
if (data.nutritionGoals) {
this.nutritionGoals = {
protein: data.nutritionGoals.protein ?? '--',
energy: data.nutritionGoals.energy ?? '--'
}
}
if (Array.isArray(data.foodList)) {
// 份数→克数适配:优先使用 gram 字段,兜底通过 portion * gramPerServing 换算
this.foodList = data.foodList.map(item => ({
...item,
gram: item.gram || (item.portion && item.gramPerServing ? Math.round(item.portion * item.gramPerServing) : null)
}))
}
if (data.mealPlan) {
this.mealPlan = {
breakfast: Array.isArray(data.mealPlan.breakfast) ? data.mealPlan.breakfast : [],
lunch: Array.isArray(data.mealPlan.lunch) ? data.mealPlan.lunch : [],
dinner: Array.isArray(data.mealPlan.dinner) ? data.mealPlan.dinner : []
}
}
if (Array.isArray(data.importantTips)) {
this.importantTips = data.importantTips
}
},
goBackToCalculator() {
uni.navigateBack({
delta: 1,
fail: () => {
uni.redirectTo({
url: '/pages/tool/calculator'
})
}
})
},
switchTab(tab) {
this.currentTab = tab
},
contactNutritionist() {
// #ifdef MP-WEIXIN
const openConversation =
typeof uni.openCustomerServiceConversation === 'function'
? uni.openCustomerServiceConversation
: (typeof wx !== 'undefined' && typeof wx.openCustomerServiceConversation === 'function'
? wx.openCustomerServiceConversation
: null);
if (!openConversation) {
uni.showToast({
title: '当前微信版本暂不支持客服会话,请到「我的-联系客服」',
icon: 'none'
});
return;
}
openConversation({
showMessageCard: true,
success: () => {},
fail: () => {
uni.showToast({
title: '打开客服失败,请到「我的-联系客服」',
icon: 'none'
});
}
});
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '请在微信小程序中联系客服',
icon: 'none'
});
// #endif
},
async adoptPlan() {
if (!this.resultId) {
uni.showToast({ title: '暂无计算结果', icon: 'none' })
return
}
// 防止重复点击
if (this._adoptingPlan) return
this._adoptingPlan = true
try {
const { adoptNutritionPlan } = await import('@/api/tool.js')
await adoptNutritionPlan(this.resultId)
uni.showToast({ title: '采纳成功', icon: 'success' })
uni.navigateTo({
url: `/pages/tool/checkin-publish?planId=${this.resultId}`
})
} catch (e) {
const msg = (e && (e.message || e.msg)) || '采纳失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this._adoptingPlan = false
}
}
}
}
</script>
<style lang="scss" scoped>
.result-page {
min-height: 100vh;
background-color: #f4f5f7;
}
/* 加载中 / 错误态 */
.loading-wrap,
.error-wrap {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
}
.loading-inner {
text-align: center;
}
.loading-text {
font-size: 28rpx;
color: #9fa5c0;
}
.error-wrap {
flex-direction: column;
gap: 24rpx;
}
.error-text {
font-size: 28rpx;
color: #666;
text-align: center;
}
.retry-btn {
padding: 20rpx 48rpx;
background: #ff6b35;
color: #fff;
font-size: 28rpx;
border-radius: 48rpx;
}
/* Tab切换 */
.tab-container {
background: #ffffff;
border-bottom: 1rpx solid #d0dbea;
display: flex;
padding: 0 32rpx;
height: 96rpx;
/* stretchTab 占满栏高,激活态 border-bottom 才能贴底显示BUG-002 */
align-items: stretch;
position: sticky;
// top: 88rpx;
z-index: 99;
}
/* Tab未激活灰字、透明底边占位无可见下划线激活加粗+主色+橙色底边BUG-002 */
.tab-item {
flex: 1;
min-height: 0;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
box-sizing: border-box;
color: #9ca3af;
font-weight: 400;
text-decoration: none;
border-bottom: 3px solid transparent;
transition: color 0.2s ease, border-color 0.2s ease;
}
.tab-item .tab-icon,
.tab-item .tab-text {
font-size: 28rpx;
}
/* 未激活:灰字、常规字重(显式写到 text 节点,避免小程序不继承 view 颜色) */
.tab-item:not(.active) .tab-icon,
.tab-item:not(.active) .tab-text {
color: #9ca3af;
font-weight: 400;
text-decoration: none;
}
/* 激活:粗体、主色、橙色底边(与未激活同宽底边,避免切换时跳动) */
.tab-item.active {
background: transparent;
color: #f97316;
font-weight: 700;
text-decoration: none;
border-bottom: 3px solid #f97316;
}
.tab-item.active .tab-icon,
.tab-item.active .tab-text {
color: #f97316;
font-weight: 700;
text-decoration: none;
}
/* 内容滚动区域 */
.content-scroll {
height: calc(100vh - 184rpx);
}
.tab-content {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
/* 数据卡片 */
.data-card {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
padding: 40rpx;
}
.card-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 32rpx;
.card-icon {
font-size: 48rpx;
}
.card-title {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 500;
}
}
/* 健康数据网格 */
.data-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.data-item {
background: #f4f5f7;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
padding: 30rpx 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
.data-label {
font-size: 24rpx;
color: #9fa5c0;
}
.data-value {
font-size: 40rpx;
color: #3e5481;
font-weight: 500;
&.ckd-stage {
font-size: 32rpx;
}
}
.data-unit {
font-size: 24rpx;
color: #9fa5c0;
}
}
/* 营养目标 */
.nutrition-goals {
display: flex;
gap: 24rpx;
margin-bottom: 24rpx;
}
.goal-item {
flex: 1;
border-radius: 24rpx;
padding: 32rpx 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
&.protein {
background: #fff5f0;
border: 1rpx solid rgba(255, 107, 53, 0.3);
.goal-label {
color: #ff6b35;
}
.goal-value {
color: #ff6b35;
}
.goal-unit {
color: rgba(255, 107, 53, 0.7);
}
}
&.energy {
background: #fff4e6;
border: 1rpx solid rgba(255, 165, 0, 0.3);
.goal-label {
color: #ff9800;
}
.goal-value {
color: #ff9800;
}
.goal-unit {
color: rgba(255, 152, 0, 0.7);
}
}
.goal-label {
font-size: 24rpx;
}
.goal-value {
font-size: 48rpx;
font-weight: 500;
}
.goal-unit {
font-size: 24rpx;
}
}
.nutrition-hint {
background: #f4f5f7;
border: 1rpx solid #d0dbea;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
.hint-icon {
font-size: 32rpx;
}
.hint-text {
font-size: 24rpx;
color: #9fa5c0;
flex: 1;
}
}
/* 食物列表 */
.food-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.food-item {
background: #f4f5f7;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.food-info {
display: flex;
align-items: center;
gap: 24rpx;
.food-number {
width: 56rpx;
height: 56rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #3e5481;
flex-shrink: 0;
}
.food-name {
font-size: 28rpx;
color: #3e5481;
}
}
.food-portion {
font-size: 28rpx;
color: #ff6b35;
font-weight: 500;
}
/* 温馨提示 */
.tip-box {
background: #e3f2fd;
border: 1rpx solid #64b5f6;
border-radius: 24rpx;
padding: 32rpx;
display: flex;
gap: 20rpx;
align-items: flex-start;
.tip-icon {
font-size: 40rpx;
flex-shrink: 0;
}
.tip-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.tip-title {
font-size: 28rpx;
color: #1976d2;
font-weight: 500;
}
.tip-text {
font-size: 24rpx;
color: #1565c0;
line-height: 1.6;
}
}
}
/* 联系营养师按钮 */
.contact-btn {
width: calc(100% - 64rpx);
height: 96rpx;
background: #ffffff;
border: 2rpx solid #ff6b35;
border-radius: 50rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 0 32rpx 24rpx;
text {
font-size: 28rpx;
color: #ff6b35;
font-weight: 500;
}
}
/* 空状态 */
.empty-placeholder {
padding: 200rpx 0;
text-align: center;
color: #9fa5c0;
font-size: 28rpx;
}
/* 营养配餐内容 */
.meal-content {
padding: 0;
gap: 0;
}
/* 餐次区块 */
.meal-section {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
margin: 0 32rpx 32rpx;
overflow: hidden;
}
.meal-header {
background: linear-gradient(180deg, #f8f9fa 0%, #f4f5f7 100%);
border-bottom: 1rpx solid #d0dbea;
padding: 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
}
.meal-icon {
width: 80rpx;
height: 80rpx;
background: #ffffff;
border: 2rpx solid rgba(255, 136, 68, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
}
.meal-title {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
}
/* 菜品列表 */
.meal-items {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.meal-item {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
padding: 16rpx;
display: flex;
gap: 24rpx;
}
.meal-image {
width: 160rpx;
height: 160rpx;
border-radius: 24rpx;
overflow: hidden;
position: relative;
background: #f4f5f7;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
}
.meal-number {
position: absolute;
left: 12rpx;
top: 12rpx;
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, #ff8844 0%, #ff7722 100%);
border: 2rpx solid #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #ffffff;
font-weight: 500;
}
}
.meal-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 8rpx 0;
}
.meal-name {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
}
.ingredient-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.ingredient-tag {
background: linear-gradient(135deg, #fff8f0 0%, #fff5eb 100%);
border: 1rpx solid rgba(255, 136, 68, 0.3);
border-radius: 12rpx;
padding: 8rpx 16rpx;
font-size: 24rpx;
color: #ff7722;
}
/* 重要提示卡片 */
.tips-card {
background: #fff3e0;
border: 1rpx solid #ffb74d;
border-radius: 24rpx;
margin: 0 32rpx 32rpx;
padding: 32rpx;
}
.tips-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
.tips-icon {
font-size: 40rpx;
}
.tips-title {
font-size: 28rpx;
color: #e65100;
font-weight: 500;
}
}
.tips-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.tip-item {
display: flex;
gap: 16rpx;
align-items: flex-start;
.tip-dot {
font-size: 28rpx;
color: #e65100;
line-height: 1;
margin-top: 4rpx;
}
.tip-text {
flex: 1;
font-size: 28rpx;
color: #f57c00;
line-height: 1.6;
}
}
/* 采纳计划按钮 */
.adopt-btn {
width: calc(100% - 64rpx);
height: 96rpx;
background: #ff6b35;
border-radius: 50rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin: 0 32rpx 24rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
.adopt-icon {
font-size: 28rpx;
}
.adopt-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
</style>