feat: T10 回归测试 Bug 修复与功能完善
修复 BUG-001 至 BUG-009 及 T10-1 至 T10-6 相关问题: - 打卡积分显示与累加逻辑优化 - 食谱计算器 Tab 选中样式修复 - 食物百科列表图片与简介展示修复 - 食物详情页数据加载修复 - AI营养师差异化回复优化 - 健康知识/营养知识名称统一 - 饮食指南/科普文章详情页内容展示修复 - 帖子营养统计数据展示修复 - 社区帖子类型中文命名统一 - 帖子详情标签中文显示修复 - 食谱营养AI填充功能完善 - 食谱收藏/点赞功能修复 新增: - ToolNutritionFillService 营养填充服务 - T10 回归测试用例 (Playwright) - 知识文章数据 SQL 脚本 涉及模块: - crmeb-common: VO/Request/Response 优化 - crmeb-service: 业务逻辑完善 - crmeb-front: API 接口扩展 - msh_single_uniapp: 前端页面修复 - tests/e2e: 回归测试用例
This commit is contained in:
@@ -208,6 +208,8 @@ export function getFoodList(data) {
|
||||
*/
|
||||
export function getFoodDetail(id) {
|
||||
const numId = typeof id === 'number' && !isNaN(id) ? id : parseInt(String(id), 10);
|
||||
// 打印请求参数便于确认(后端仅接受 Long 类型 id,传 name 会 400)
|
||||
console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id });
|
||||
if (isNaN(numId)) {
|
||||
return Promise.reject(new Error('食物详情接口需要数字ID,当前传入: ' + id));
|
||||
}
|
||||
@@ -252,6 +254,15 @@ export function getNutrientDetail(name) {
|
||||
return request.get('tool/knowledge/nutrient/' + name);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 营养估算:根据饮食描述文本返回估算的热量、蛋白质、钾、磷(T06-T09)
|
||||
* @param {String} text - 饮食描述
|
||||
* @returns {Promise<{data: {energyKcal?, proteinG?, potassiumMg?, phosphorusMg?}}>}
|
||||
*/
|
||||
export function getAiNutritionFill(text) {
|
||||
return request.post('tool/nutrition/fill-ai', { text: text || '' });
|
||||
}
|
||||
|
||||
// ==================== 打卡社区相关 ====================
|
||||
|
||||
/**
|
||||
@@ -346,6 +357,16 @@ export function sharePost(postId) {
|
||||
return request.post('tool/community/share', { postId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充帖子营养数据(服务端根据帖子内容/打卡补充营养并回写)
|
||||
* @param {Number|String} postId - 帖子ID
|
||||
* @returns {Promise<{data?: object}>} 返回更新后的帖子或营养数据
|
||||
*/
|
||||
export function fillPostNutrition(postId) {
|
||||
const id = typeof postId === 'number' && !isNaN(postId) ? postId : parseInt(postId, 10);
|
||||
return request.post('tool/community/post/' + id + '/fill-nutrition');
|
||||
}
|
||||
|
||||
// ==================== 积分系统相关 ====================
|
||||
|
||||
/**
|
||||
@@ -458,6 +479,10 @@ export function getRecipeDetail(id) {
|
||||
export function toggleRecipeFavorite(recipeId, isFavorite) {
|
||||
return request.post('tool/recipe/favorite', { recipeId, isFavorite });
|
||||
}
|
||||
// T09: 食谱营养 AI 回填
|
||||
export function fillRecipeNutrition(recipeId) {
|
||||
return request.post("tool/recipe/" + recipeId + "/fill-nutrition")
|
||||
}
|
||||
|
||||
// ==================== 文件上传相关 ====================
|
||||
|
||||
|
||||
@@ -623,7 +623,8 @@ export default {
|
||||
async sendToAI(content, type) {
|
||||
this.isLoading = true;
|
||||
|
||||
// 文本对话必须走 KieAI Gemini:POST /api/front/kieai/gemini/chat,请求体 { messages: [{ role: 'user', content }], stream: false }
|
||||
// BUG-005:文本/多模态必须走 KieAI Gemini,回复仅从 data.choices[0].message.content 取,不得使用固定话术
|
||||
// 请求体: { messages: [{ role: 'user', content }], stream: false }
|
||||
if (type === 'text' || type === 'multimodal') {
|
||||
try {
|
||||
const messages = [{ role: 'user', content: content }];
|
||||
@@ -635,6 +636,7 @@ export default {
|
||||
const msgObj = choice && choice.message;
|
||||
const rawContent = msgObj && msgObj.content;
|
||||
const reply = rawContent != null ? this.extractReplyContent(rawContent) : '';
|
||||
// 成功时仅展示模型返回内容
|
||||
this.messageList.push({ role: 'ai', content: reply || '未能获取到有效回复。' });
|
||||
} else {
|
||||
const msg = (response && response.message) || '发起对话失败';
|
||||
|
||||
@@ -492,25 +492,23 @@ export default {
|
||||
box-sizing: border-box;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
|
||||
/* 未激活:明显变灰,无下划线 */
|
||||
.tab-icon {
|
||||
font-size: 28rpx;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 28rpx;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 激活:加粗、主色、橙色底部下划线 */
|
||||
&.active {
|
||||
background: transparent;
|
||||
border-bottom: 3px solid #f97316;
|
||||
color: #f97316;
|
||||
font-weight: 700;
|
||||
|
||||
.tab-text {
|
||||
color: #f97316;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -243,12 +243,14 @@ export default {
|
||||
try {
|
||||
const { setSignIntegral, getUserInfo } = await import('@/api/user.js');
|
||||
const { getUserPoints } = await import('@/api/tool.js');
|
||||
|
||||
// 子问题 A:不得在 API 返回成功前修改 currentPoints,避免打卡前积分提前跳变
|
||||
// 打卡接口:GET /api/front/user/sign/integral(setSignIntegral)
|
||||
// 子问题 B-1:打卡接口为 GET /api/front/user/sign/integral(setSignIntegral),与 /api/front/user/checkin 同属签到类接口
|
||||
await setSignIntegral();
|
||||
|
||||
this.todaySigned = true;
|
||||
// 子问题 B:仅用服务端返回的积分更新 currentPoints,禁止前端本地 +30
|
||||
// 先 GET /api/front/user(getUserInfo)刷新用户信息,取 integral 赋给 currentPoints
|
||||
|
||||
// 子问题 B-2/B-3:仅用服务端返回值更新积分,禁止前端本地 +30。调用 GET /api/front/user(getUserInfo)刷新用户信息,将返回的 integral 赋给 currentPoints
|
||||
const userRes = await getUserInfo();
|
||||
if (userRes && userRes.data && (userRes.data.integral != null || userRes.data.points != null)) {
|
||||
this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0;
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<!-- 食物大图 -->
|
||||
<view class="food-image-section">
|
||||
<image class="food-image" :src="foodData.image" mode="aspectFill"></image>
|
||||
<image class="food-image" :src="foodData.image || defaultFoodData.image" mode="aspectFill"></image>
|
||||
<view class="image-overlay"></view>
|
||||
<view class="food-info-overlay">
|
||||
<view class="food-name-overlay">{{ foodData.name }}</view>
|
||||
<view class="food-name-overlay">{{ foodData.name || '—' }}</view>
|
||||
<view class="food-tags">
|
||||
<view class="food-tag category">{{ foodData.category }}</view>
|
||||
<view class="food-tag safe">{{ foodData.safetyTag }}</view>
|
||||
<view class="food-tag category">{{ foodData.category || '—' }}</view>
|
||||
<view class="food-tag safe">{{ foodData.safetyTag || '—' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -193,6 +193,7 @@ export default {
|
||||
try {
|
||||
const res = await getFoodDetail(id)
|
||||
const data = res.data || res
|
||||
console.log('[food-detail] getFoodDetail 响应:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null)
|
||||
if (data && data.name) {
|
||||
// 解析API返回的食物数据
|
||||
this.foodData = {
|
||||
@@ -220,6 +221,7 @@ export default {
|
||||
} catch (error) {
|
||||
const errMsg = (error && (error.message || error.msg || error.errMsg || error)) ? String(error.message || error.msg || error.errMsg || error) : '未知错误'
|
||||
console.error('[food-detail] 加载食物数据失败:', error)
|
||||
console.error('[food-detail] loadError(用于调试):', errMsg)
|
||||
// a. 将 loadError 置为具体错误信息(用于调试)
|
||||
this.loadError = errMsg
|
||||
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示
|
||||
@@ -230,6 +232,7 @@ export default {
|
||||
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
|
||||
} catch (e) {}
|
||||
}
|
||||
// c. 页面已通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示
|
||||
uni.showToast({
|
||||
title: '数据加载失败',
|
||||
icon: 'none'
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<view class="food-image-wrapper">
|
||||
<image
|
||||
class="food-image"
|
||||
:src="getFoodImage(item)"
|
||||
:src="getFoodImage(item) || defaultPlaceholder"
|
||||
mode="aspectFill"
|
||||
@error="onFoodImageError(item)"
|
||||
></image>
|
||||
@@ -126,11 +126,11 @@
|
||||
<view class="nutrition-list">
|
||||
<view
|
||||
class="nutrition-item"
|
||||
v-for="(nut, idx) in (item.nutrition || [])"
|
||||
v-for="(nut, idx) in (item.nutrition || item.nutrients || [])"
|
||||
:key="idx"
|
||||
>
|
||||
<text class="nutrition-label">{{ nut.label }}</text>
|
||||
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value }}</text>
|
||||
<text class="nutrition-label">{{ nut.label || '—' }}</text>
|
||||
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -320,8 +320,12 @@ export default {
|
||||
colorClass: n.colorClass || 'green'
|
||||
}));
|
||||
} else {
|
||||
// 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —)
|
||||
nutrition = [];
|
||||
const push = (label, val, unit) => { if (val != null && val !== '') nutrition.push({ label, value: String(val) + (unit || ''), colorClass: 'green' }); };
|
||||
const push = (label, val, unit) => {
|
||||
const value = (val != null && val !== '') ? String(val) + (unit || '') : '—';
|
||||
nutrition.push({ label, value, colorClass: 'green' });
|
||||
};
|
||||
push('能量', item.energy, 'kcal');
|
||||
push('蛋白质', item.protein, 'g');
|
||||
push('钾', item.potassium, 'mg');
|
||||
@@ -330,9 +334,14 @@ export default {
|
||||
push('钙', item.calcium, 'mg');
|
||||
}
|
||||
|
||||
// 后端详情接口仅接受 Long 类型 id;若列表返回的 id 为非数字(如名称),不传 id,避免详情页请求 400
|
||||
const rawId = item.id != null ? item.id : item.foodId;
|
||||
const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId)))
|
||||
? (typeof rawId === 'number' ? rawId : Number(rawId))
|
||||
: undefined;
|
||||
return {
|
||||
...item,
|
||||
id: item.id != null ? item.id : item.foodId,
|
||||
id: numericId,
|
||||
image,
|
||||
imageUrl: image || undefined,
|
||||
category: item.category || '',
|
||||
|
||||
@@ -187,7 +187,7 @@ export default {
|
||||
// 有 id 时切换到科普文章 tab,switchTab 内会调用 loadKnowledgeList 加载列表
|
||||
this.switchTab('articles');
|
||||
} else {
|
||||
// 无 id 时默认当前 tab 为「营养素」;用户切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
|
||||
// 无 id 时默认当前 tab 为「营养素」,不请求接口;用户点击「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
|
||||
this.currentTab = 'nutrients';
|
||||
}
|
||||
},
|
||||
@@ -215,23 +215,30 @@ export default {
|
||||
page: 1,
|
||||
limit: 50
|
||||
});
|
||||
// 兼容 result.data.list 或 result.data 为数组
|
||||
// 兼容 CommonPage:result.data.list,或 result.data/result.data.records 为数组
|
||||
let rawList = [];
|
||||
if (result && result.data) {
|
||||
if (Array.isArray(result.data.list)) {
|
||||
rawList = result.data.list;
|
||||
} else if (Array.isArray(result.data.records)) {
|
||||
rawList = result.data.records;
|
||||
} else if (Array.isArray(result.data)) {
|
||||
rawList = result.data;
|
||||
}
|
||||
}
|
||||
const list = (rawList || []).map(item => ({
|
||||
...item,
|
||||
desc: item.desc || item.summary || '',
|
||||
time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''),
|
||||
views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0),
|
||||
icon: item.icon || '📄',
|
||||
coverImage: item.coverImage || item.cover_image || ''
|
||||
}));
|
||||
// Normalize id: backend may return knowledgeId, id, or knowledge_id (BeanUtil/JSON)
|
||||
const list = (rawList || []).map(item => {
|
||||
const id = item.knowledgeId ?? item.id ?? item.knowledge_id;
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
desc: item.desc || item.summary || '',
|
||||
time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''),
|
||||
views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0),
|
||||
icon: item.icon || '📄',
|
||||
coverImage: item.coverImage || item.cover_image || ''
|
||||
};
|
||||
});
|
||||
if (this.currentTab === 'guide') {
|
||||
this.guideList = list;
|
||||
} else if (this.currentTab === 'articles') {
|
||||
@@ -239,16 +246,16 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载知识列表失败:', error);
|
||||
const msg = (error && (error.message || error.msg)) || '加载列表失败';
|
||||
const msg = (error && (typeof error === 'string' ? error : (error.message || error.msg))) || '加载列表失败';
|
||||
uni.showToast({
|
||||
title: String(msg),
|
||||
icon: 'none'
|
||||
});
|
||||
// 确保列表始终为数组,不设为 undefined
|
||||
// 失败时清空当前 tab 列表并确保始终为数组,不设为 undefined
|
||||
if (this.currentTab === 'guide') {
|
||||
this.guideList = Array.isArray(this.guideList) ? this.guideList : [];
|
||||
this.guideList = [];
|
||||
} else if (this.currentTab === 'articles') {
|
||||
this.articleList = Array.isArray(this.articleList) ? this.articleList : [];
|
||||
this.articleList = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -266,13 +273,12 @@ export default {
|
||||
uni.showToast({ title: '暂无详情', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
// 兼容后端 knowledgeId / id / knowledge_id
|
||||
// 确保 knowledgeId 或 id 存在才跳转,否则提示暂无详情
|
||||
const id = item.knowledgeId ?? item.id ?? item.knowledge_id;
|
||||
if (id === undefined || id === null || id === '') {
|
||||
if (id === undefined || id === null || id === '' || (typeof id === 'number' && isNaN(id))) {
|
||||
uni.showToast({ title: '暂无详情', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
// 饮食指南、科普文章使用知识详情页(调用 tool/knowledge/detail 接口)
|
||||
uni.navigateTo({
|
||||
url: `/pages/tool/knowledge-detail?id=${id}`
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- 餐次标签 -->
|
||||
<view class="meal-tag" v-if="postData.mealType">
|
||||
<text class="meal-icon">{{ getMealIcon(postData.mealType) }}</text>
|
||||
<text class="meal-text">{{ postData.mealType }}</text>
|
||||
<text class="meal-text">{{ getMealTypeLabel(postData.mealType) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<!-- 餐次标签 -->
|
||||
<view class="meal-tag" v-if="postData.mealType">
|
||||
<text class="meal-icon">{{ getMealIcon(postData.mealType) }}</text>
|
||||
<text class="meal-text">{{ postData.mealType }}</text>
|
||||
<text class="meal-text">{{ getMealTypeLabel(postData.mealType) }}</text>
|
||||
</view>
|
||||
<!-- 图片页码 -->
|
||||
<view class="image-counter" v-if="postData.images.length > 1">
|
||||
@@ -56,7 +56,7 @@
|
||||
<view class="post-content-section">
|
||||
<!-- 无图片时显示类型标签 -->
|
||||
<view class="type-tag-row" v-if="!postData.images || postData.images.length === 0">
|
||||
<view class="type-tag">{{ postData.mealType || '分享' }}</view>
|
||||
<view class="type-tag">{{ getMealTypeLabel(postData.mealType) }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 标题和分数 -->
|
||||
@@ -129,6 +129,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- T08/T09: 无营养数据时展示「AI 补充营养」按钮 -->
|
||||
<view class="nutrition-fill-row" v-else-if="!isLoading && (postData.description || postData.title)">
|
||||
<button
|
||||
class="ai-fill-btn"
|
||||
:disabled="aiNutritionFilling"
|
||||
@click="fillNutritionByAi"
|
||||
>
|
||||
<text v-if="!aiNutritionFilling">🤖 AI 补充营养</text>
|
||||
<text v-else>估算中...</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- AI营养师点评 -->
|
||||
<view class="ai-comment-card" v-if="postData.aiComment">
|
||||
<view class="ai-header">
|
||||
@@ -234,6 +246,8 @@
|
||||
import {
|
||||
getCommunityDetail,
|
||||
getCheckinDetail,
|
||||
getAiNutritionFill,
|
||||
fillPostNutrition,
|
||||
toggleLike as apiToggleLike,
|
||||
toggleCollect,
|
||||
toggleFollow as apiToggleFollow,
|
||||
@@ -282,7 +296,8 @@ export default {
|
||||
hasMoreComments: true,
|
||||
isLoadingComments: false,
|
||||
showCommentInput: false,
|
||||
commentText: ''
|
||||
commentText: '',
|
||||
aiNutritionFilling: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -300,6 +315,13 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 获取餐次类型中文标签
|
||||
getMealTypeLabel(mealType) {
|
||||
if (!mealType) return '分享'
|
||||
const map = { breakfast:'早餐', lunch:'午餐', dinner:'晚餐', snack:'加餐', share:'分享', checkin:'打卡' }
|
||||
return map[String(mealType).toLowerCase()] ?? '分享'
|
||||
},
|
||||
|
||||
// 加载帖子详情
|
||||
async loadPostData(id) {
|
||||
this.isLoading = true
|
||||
@@ -312,8 +334,13 @@ export default {
|
||||
this.formatPostData(data)
|
||||
|
||||
// 若详情接口未返回营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载)
|
||||
if (this.postData.nutritionStats.length === 0 && (data.checkInRecordId != null)) {
|
||||
await this.fillNutritionStatsFromCheckin(data.checkInRecordId)
|
||||
const checkInId = data.checkInRecordId != null ? (Number(data.checkInRecordId) || data.checkInRecordId) : null
|
||||
if (this.postData.nutritionStats.length === 0 && checkInId != null) {
|
||||
await this.fillNutritionStatsFromCheckin(checkInId)
|
||||
}
|
||||
// T07: 营养仍为空时调用服务端填充接口
|
||||
if (this.postData.nutritionStats.length === 0) {
|
||||
await this.fillNutritionFromServer()
|
||||
}
|
||||
|
||||
// 同步状态
|
||||
@@ -344,14 +371,6 @@ export default {
|
||||
*/
|
||||
buildNutritionStatsFromDetailData(data) {
|
||||
if (!data) return []
|
||||
console.log('[post-detail] buildNutritionStatsFromDetailData API response data:', JSON.stringify({
|
||||
nutritionStats: data.nutritionStats,
|
||||
nutrition_stats: data.nutrition_stats,
|
||||
nutritionDataJson: data.nutritionDataJson,
|
||||
nutrition_data_json: data.nutrition_data_json,
|
||||
dietaryData: data.dietaryData,
|
||||
mealData: data.mealData
|
||||
}))
|
||||
|
||||
// 1) 后端直接返回的 stat 数组(兼容不同命名)
|
||||
const rawStats = data.nutritionStats || data.nutrition_stats
|
||||
@@ -362,23 +381,21 @@ export default {
|
||||
})).filter(s => s.label)
|
||||
}
|
||||
|
||||
// 2) nutritionDataJson / nutrition_data_json(兼容后端驼峰与下划线;含打卡字段 actualEnergy/actualProtein)
|
||||
// 2) nutritionDataJson / nutrition_data_json(兼容后端驼峰与下划线;含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein)
|
||||
const jsonRaw = data.nutritionDataJson || data.nutrition_data_json
|
||||
if (jsonRaw) {
|
||||
try {
|
||||
const nutritionData = typeof jsonRaw === 'string' ? JSON.parse(jsonRaw) : jsonRaw
|
||||
if (nutritionData && typeof nutritionData === 'object') {
|
||||
const cal = nutritionData.calories ?? nutritionData.energy ?? nutritionData.calorie ?? nutritionData.actualEnergy
|
||||
const pro = nutritionData.protein ?? nutritionData.proteins ?? nutritionData.actualProtein
|
||||
const pot = nutritionData.potassium ?? nutritionData.k
|
||||
const pho = nutritionData.phosphorus ?? nutritionData.p
|
||||
return [
|
||||
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
|
||||
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
|
||||
{ label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
|
||||
{ label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
|
||||
]
|
||||
if (!nutritionData || typeof nutritionData !== 'object') return []
|
||||
// 2a) 若为数组格式 [{label, value}, ...],直接使用
|
||||
if (Array.isArray(nutritionData) && nutritionData.length > 0) {
|
||||
return nutritionData.map(s => ({
|
||||
label: s.label || s.name || '',
|
||||
value: s.value != null ? String(s.value) : '-'
|
||||
})).filter(s => s.label)
|
||||
}
|
||||
// 2b) 对象格式:兼容后端 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg 及常见命名
|
||||
return this.buildNutritionStatsFromNutritionObject(nutritionData)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
@@ -388,23 +405,36 @@ export default {
|
||||
const dietary = data.dietaryData || data.dietary_data || data.mealData || data.meal_data
|
||||
if (dietary) {
|
||||
const obj = typeof dietary === 'string' ? (() => { try { return JSON.parse(dietary) } catch (_) { return null } })() : dietary
|
||||
if (obj && typeof obj === 'object') {
|
||||
const cal = obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy
|
||||
const pro = obj.protein ?? obj.proteins ?? obj.actualProtein
|
||||
const pot = obj.potassium ?? obj.k
|
||||
const pho = obj.phosphorus ?? obj.p
|
||||
return [
|
||||
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
|
||||
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
|
||||
{ label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
|
||||
{ label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
|
||||
]
|
||||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
const statsFromObj = this.buildNutritionStatsFromNutritionObject(obj)
|
||||
if (statsFromObj.length > 0) return statsFromObj
|
||||
}
|
||||
}
|
||||
|
||||
// 4) data 本身为营养对象(如 fill-nutrition 接口直接返回的 { energyKcal, proteinG, potassiumMg, phosphorusMg })
|
||||
if (data && typeof data === 'object' && !Array.isArray(data) && (data.energyKcal != null || data.proteinG != null || data.actualEnergy != null || data.actualProtein != null || data.calories != null || data.protein != null)) {
|
||||
const statsFromData = this.buildNutritionStatsFromNutritionObject(data)
|
||||
if (statsFromData.length > 0) return statsFromData
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
|
||||
/** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG/potassiumMg/phosphorusMg 及 calories/protein 等命名 */
|
||||
buildNutritionStatsFromNutritionObject(obj) {
|
||||
if (!obj || typeof obj !== 'object') return []
|
||||
const cal = obj.energyKcal ?? obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy
|
||||
const pro = obj.proteinG ?? obj.protein ?? obj.proteins ?? obj.actualProtein
|
||||
const pot = obj.potassiumMg ?? obj.potassium ?? obj.k
|
||||
const pho = obj.phosphorusMg ?? obj.phosphorus ?? obj.p
|
||||
return [
|
||||
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
|
||||
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
|
||||
{ label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
|
||||
{ label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
|
||||
]
|
||||
},
|
||||
|
||||
/** 格式化营养素显示值(兼容 number/string/BigDecimal 等) */
|
||||
formatNutritionValue(val, unit) {
|
||||
if (val == null || val === '') return '-'
|
||||
@@ -443,6 +473,60 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* T07: 调用服务端 fill-nutrition 接口,根据帖子内容补充营养并更新本地展示
|
||||
*/
|
||||
async fillNutritionFromServer() {
|
||||
if (!this.postId) return
|
||||
try {
|
||||
const res = await fillPostNutrition(this.postId)
|
||||
const data = res.data || res
|
||||
if (!data) return
|
||||
const stats = this.buildNutritionStatsFromDetailData(data)
|
||||
if (stats.length > 0) {
|
||||
this.postData.nutritionStats = stats
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('服务端填充帖子营养失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* T08/T09: 用 AI 根据帖子描述估算营养并填充 nutritionStats
|
||||
*/
|
||||
async fillNutritionByAi() {
|
||||
const text = (this.postData.description || this.postData.title || '').trim()
|
||||
if (!text) {
|
||||
uni.showToast({ title: '暂无描述可估算', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.aiNutritionFilling = true
|
||||
try {
|
||||
const res = await getAiNutritionFill(text)
|
||||
const data = (res && res.data) ? res.data : res
|
||||
if (!data || typeof data !== 'object') {
|
||||
uni.showToast({ title: 'AI 估算暂无结果', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const energy = data.energyKcal != null ? String(data.energyKcal) : '-'
|
||||
const protein = this.formatNutritionValue(data.proteinG, 'g')
|
||||
const potassium = data.potassiumMg != null ? String(data.potassiumMg) + 'mg' : '-'
|
||||
const phosphorus = data.phosphorusMg != null ? String(data.phosphorusMg) + 'mg' : '-'
|
||||
this.postData.nutritionStats = [
|
||||
{ label: '热量(kcal)', value: energy },
|
||||
{ label: '蛋白质', value: protein },
|
||||
{ label: '钾', value: potassium },
|
||||
{ label: '磷', value: phosphorus }
|
||||
]
|
||||
uni.showToast({ title: '已补充营养估算', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.warn('AI 营养估算失败:', e)
|
||||
uni.showToast({ title: '估算失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
this.aiNutritionFilling = false
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化帖子数据
|
||||
formatPostData(data) {
|
||||
// 解析图片
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="action-btn-small" @click="toggleLike">
|
||||
<text class="action-icon-small" :class="{ active: isLiked }">❤️</text>
|
||||
<text class="action-icon-small" :class="{ active: isLiked }">{{ isLiked ? '❤️' : '🤍' }}</text>
|
||||
</view>
|
||||
<view class="action-btn-small" @click="toggleFavorite">
|
||||
<text class="action-icon-small" :class="{ active: isFavorite }">⭐</text>
|
||||
@@ -227,7 +227,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRecipeDetail, toggleRecipeFavorite } from '@/api/tool.js';
|
||||
import { getRecipeDetail, toggleRecipeFavorite, fillRecipeNutrition } from '@/api/tool.js';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -321,9 +321,41 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// T09: AI 回填食谱营养
|
||||
async fillNutritionFromAI(recipeId) {
|
||||
try {
|
||||
uni.showLoading({ title: 'AI分析中...' })
|
||||
const res = await fillRecipeNutrition(recipeId)
|
||||
const data = res.data || res
|
||||
if (data) {
|
||||
// 更新营养数据
|
||||
this.nutritionData = {
|
||||
main: [
|
||||
{ value: String(data.energyKcal || '--'), label: '热量(kcal)' },
|
||||
{ value: (data.proteinG || '--') + 'g', label: '蛋白质' },
|
||||
{ value: '--', label: '脂肪' },
|
||||
{ value: '--', label: '碳水' }
|
||||
],
|
||||
minor: [
|
||||
{ value: data.sodiumMg ? data.sodiumMg + 'mg' : '--', label: '钠' },
|
||||
{ value: data.potassiumMg ? data.potassiumMg + 'mg' : '--', label: '钾' },
|
||||
{ value: data.phosphorusMg ? data.phosphorusMg + 'mg' : '--', label: '磷' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('AI营养回填失败', e)
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
applyDefaultData() {
|
||||
this.recipeData = { ...this.defaultRecipeData }
|
||||
this.nutritionData = JSON.parse(JSON.stringify(this.defaultNutritionData))
|
||||
|
||||
// T09: 若无营养数据,调用 AI 回填
|
||||
this.fillNutritionFromAI(id)
|
||||
this.mealPlan = JSON.parse(JSON.stringify(this.defaultMealPlan))
|
||||
this.warningList = [...this.defaultWarningList]
|
||||
},
|
||||
@@ -382,6 +414,9 @@ export default {
|
||||
}
|
||||
} else {
|
||||
this.nutritionData = JSON.parse(JSON.stringify(this.defaultNutritionData))
|
||||
|
||||
// T09: 若无营养数据,调用 AI 回填
|
||||
this.fillNutritionFromAI(id)
|
||||
}
|
||||
|
||||
// 解析三餐配餐:
|
||||
|
||||
@@ -437,8 +437,12 @@ export default {
|
||||
share: '分享',
|
||||
checkin: '打卡'
|
||||
}
|
||||
const lower = String(mealType).toLowerCase()
|
||||
return map[lower] != null ? map[lower] : '分享'
|
||||
const str = String(mealType).trim()
|
||||
const lower = str.toLowerCase()
|
||||
if (map[lower] != null) return map[lower]
|
||||
// 后端可能直接返回中文,避免误显示为「分享」
|
||||
if (/[\u4e00-\u9fa5]/.test(str)) return str
|
||||
return '分享'
|
||||
},
|
||||
|
||||
// 格式化标签显示
|
||||
|
||||
Reference in New Issue
Block a user