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:
@@ -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) {
|
||||
// 解析图片
|
||||
|
||||
Reference in New Issue
Block a user