fix: 修复关注按钮相关问题

- 食谱详情页: 修复 applyDefaultData 中未定义变量 id 的问题
- 帖子详情页: 优化 toggleFollow 方法,提前校验 author.id,兼容多种后端字段
- 为帖子详情页已关注状态添加灰色样式
This commit is contained in:
msh-agent
2026-03-09 18:56:53 +08:00
parent b516089c4f
commit c1857ce852
14 changed files with 3590 additions and 102 deletions

BIN
.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -217,11 +217,12 @@ 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 });
const apiPath = 'tool/food/detail/' + numId;
console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id, apiPath });
if (isNaN(numId)) {
return Promise.reject(new Error('食物详情接口需要数字ID当前传入: ' + id));
}
return request.get('tool/food/detail/' + numId);
return request.get(apiPath);
}
/**

View File

@@ -610,15 +610,18 @@ export default {
this.scrollToBottom();
},
/** 从 Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } 对象 */
/** 从 Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text } */
extractReplyContent(content) {
if (content == null) return '';
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content === 'object' && Array.isArray(content.parts)) {
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
if (typeof content === 'object') {
if (Array.isArray(content.parts)) {
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content.text === 'string') return content.text;
}
return String(content);
},

View File

@@ -489,19 +489,19 @@ export default {
gap: 16rpx;
transition: all 0.2s ease;
box-sizing: border-box;
/* 未激活:明显变灰,无下划线 */
/* 未激活:明显变灰,无下划线BUG-002 */
color: #9ca3af;
font-weight: 400;
border-bottom: none;
border-bottom: 3rpx solid transparent;
.tab-icon {
font-size: 28rpx;
color: #9ca3af;
font-weight: 400;
color: inherit;
font-weight: inherit;
}
.tab-text {
font-size: 28rpx;
color: #9ca3af;
font-weight: 400;
color: inherit;
font-weight: inherit;
}
/* 激活加粗、主色、橙色底部下划线BUG-002 */
&.active {

View File

@@ -244,28 +244,16 @@ export default {
const { setSignIntegral, getFrontUserInfo, getUserInfo } = await import('@/api/user.js');
const { getUserPoints } = await import('@/api/tool.js');
// 子问题 A不得在 API 返回成功前修改 currentPoints避免打卡前积分提前跳变
// 打卡接口GET /api/front/user/sign/integralsetSignIntegral等同于 checkin 签到
// 子问题 A在 API 返回成功前不得修改 currentPoints避免打卡前积分提前跳变(禁止前端本地 +30 等)
// 打卡接口GET /api/front/user/sign/integralsetSignIntegral即本项目的签到/打卡接口
await setSignIntegral();
// 子问题 B仅在打卡成功后用服务端数据更新积分。 GET /api/front/user/info 刷新用户积分,禁止硬编码 +30
let userRes = null;
try {
userRes = await getFrontUserInfo(); // GET /api/front/user/info
} catch (_) {
userRes = await getUserInfo().catch(() => null);
}
if (userRes && userRes.data && (userRes.data.integral != null || userRes.data.points != null)) {
this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0;
} else {
const pointsRes = await getUserPoints();
if (pointsRes && pointsRes.data) {
const serverPoints = pointsRes.data.totalPoints ?? pointsRes.data.points ?? pointsRes.data.availablePoints ?? 0;
this.currentPoints = serverPoints;
}
// 子问题 B打卡成功后必须用服务端数据更新积分。 GET /api/front/user/info 刷新用户积分,禁止硬编码 +30
const serverPoints = await this._fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints);
if (serverPoints != null) {
this.currentPoints = Number(serverPoints);
}
// 积分已从服务端更新后再更新打卡状态并跳转,避免“已打卡”与积分不同步
this.todaySigned = true;
} catch (e) {
const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败';
@@ -279,6 +267,37 @@ export default {
url: '/pages/tool/checkin-publish'
});
},
/**
* 从服务端拉取积分(用于打卡成功后刷新,积分值必须来自服务端,不可前端累加)
* 优先 GET /api/front/user/info失败时回退到 user 或 tool/points/info
*/
async _fetchServerPoints(getFrontUserInfo, getUserInfo, getUserPoints) {
try {
const userRes = await getFrontUserInfo();
if (userRes && userRes.data) {
const d = userRes.data;
const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints));
if (p != null) return p;
}
} catch (_) {}
try {
const userRes = await getUserInfo();
if (userRes && userRes.data) {
const d = userRes.data;
const p = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints));
if (p != null) return p;
}
} catch (_) {}
try {
const pointsRes = await getUserPoints();
if (pointsRes && pointsRes.data) {
const d = pointsRes.data;
const p = d.totalPoints ?? d.points ?? d.availablePoints;
if (p != null) return p;
}
} catch (_) {}
return null;
},
getTodayIndex() {
// 根据连续打卡数据计算当前是第几天
// streakDays 中已有 completed 状态,找第一个未完成的位置即为今天

View File

@@ -133,9 +133,10 @@ export default {
}
},
computed: {
// 保证 .food-name-overlay / .nutrient-card / .nutrition-row 在 defaultFoodData 状态下也有非空数据可渲染
// 保证 .food-name-overlay / .nutrient-card / .nutrition-row 在 defaultFoodData 状态下也有非空数据可渲染(不为空字符串/空数组)
displayName() {
return (this.foodData && this.foodData.name) ? this.foodData.name : (this.defaultFoodData.name || '—')
const name = (this.foodData && this.foodData.name) ? this.foodData.name : (this.defaultFoodData && this.defaultFoodData.name) ? this.defaultFoodData.name : '—'
return (name != null && String(name).trim() !== '') ? String(name).trim() : '—'
},
displayCategory() {
return (this.foodData && this.foodData.category) ? this.foodData.category : (this.defaultFoodData.category || '—')
@@ -149,14 +150,16 @@ export default {
displayKeyNutrients() {
const arr = this.foodData && this.foodData.keyNutrients
if (Array.isArray(arr) && arr.length > 0) return arr
const def = this.defaultFoodData.keyNutrients
return Array.isArray(def) && def.length > 0 ? def : [{ name: '—', value: '—', unit: '', status: '—' }]
const def = this.defaultFoodData && this.defaultFoodData.keyNutrients
if (Array.isArray(def) && def.length > 0) return def
return [{ name: '—', value: '—', unit: '', status: '—' }]
},
displayNutritionTable() {
const arr = this.foodData && this.foodData.nutritionTable
if (Array.isArray(arr) && arr.length > 0) return arr
const def = this.defaultFoodData.nutritionTable
return Array.isArray(def) && def.length > 0 ? def : [{ name: '—', value: '—', unit: '', level: 'low', levelText: '—' }]
const def = this.defaultFoodData && this.defaultFoodData.nutritionTable
if (Array.isArray(def) && def.length > 0) return def
return [{ name: '—', value: '—', unit: '', level: 'low', levelText: '—' }]
}
},
onLoad(options) {
@@ -171,21 +174,23 @@ export default {
name: (displayName !== undefined && displayName !== '') ? String(displayName) : ''
}
// 打印入参便于排查:后端详情接口仅接受 Long 类型 id传 name 会导致 400
console.log('[food-detail] onLoad options:', options)
console.log('[food-detail] onLoad pageParams (id/name):', this.pageParams)
console.log('[food-detail] onLoad options:', JSON.stringify(options))
console.log('[food-detail] onLoad pageParams (id/name):', JSON.stringify(this.pageParams))
const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId))
if (isNumericId) {
const numId = Number(rawId)
console.log('[food-detail] 使用数字 id 请求详情,不传 name 字段:', numId)
console.log('[food-detail] 使用数字 id 请求详情:', { id: numId, idType: typeof numId, name: this.pageParams.name })
this.loadFoodData(numId)
} else if (this.pageParams.name) {
// 无有效 id、仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称
console.log('[food-detail] 无数字 id仅用 name 展示默认数据,不请求详情接口')
this.loadError = '暂无该食物详情数据,展示参考数据'
this.applyDefaultFoodData(false)
try {
this.foodData.name = decodeURIComponent(this.pageParams.name)
} catch (e) {}
} else {
console.log('[food-detail] 无 id 与 name展示默认数据')
this.applyDefaultFoodData()
}
},
@@ -225,12 +230,26 @@ export default {
async loadFoodData(id) {
this.loading = true
this.loadError = ''
// 打印 API 请求参数便于确认(后端需要 Long 类型 id
console.log('[food-detail] getFoodDetail request param:', { id, type: typeof id })
// 打印 API 请求参数便于确认(后端需要 Long 类型 id,传 name 会 400
console.log('[food-detail] getFoodDetail API 请求参数:', {
id,
idType: typeof id,
pageParamsId: this.pageParams.id,
pageParamsName: this.pageParams.name
})
try {
const res = await getFoodDetail(id)
// 打印响应结构便于确认request 成功时 resolve 的是 res.data即 { code: 200, data: {...} }
console.log('[food-detail] getFoodDetail 响应结构:', res ? { hasData: !!res.data, code: res.code, keys: Object.keys(res || {}) } : null)
console.log('[food-detail] getFoodDetail 响应结构:', res ? { hasData: !!(res && res.data), code: res && res.code, keys: Object.keys(res || {}) } : null)
if (!res) {
this.loadError = '接口未返回数据'
this.applyDefaultFoodData(false)
if (this.pageParams && this.pageParams.name) {
try { this.foodData.name = decodeURIComponent(String(this.pageParams.name)) } catch (e) {}
}
uni.showToast({ title: '数据加载失败', icon: 'none' })
return
}
const data = res.data != null ? res.data : res
console.log('[food-detail] getFoodDetail 解析后 data:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null)
if (data && data.name) {
@@ -258,9 +277,11 @@ export default {
uni.showToast({ title: '数据加载失败', icon: 'none' })
}
} catch (error) {
// a. 将 loadError 置为具体错误信息(用于调试):兼容 Error、{ message/msg }、字符串
// a. 将 loadError 置为具体错误信息(用于调试):兼容 Error、{ message/msg }、字符串、后端 res.data 对象
const errMsg = (error && (error.message || error.msg || error.errMsg))
? String(error.message || error.msg || error.errMsg)
: (error && error.data && (error.data.message || error.data.msg))
? String(error.data.message || error.data.msg)
: (typeof error === 'string' ? error : (error ? String(error) : '未知错误'))
console.error('[food-detail] 加载食物数据失败:', error)
console.error('[food-detail] loadError(用于调试):', errMsg)
@@ -273,7 +294,8 @@ export default {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
// c. 页面通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示
console.log('[food-detail] 已用 defaultFoodData 填充页面loadError=', this.loadError)
// c. 页面 v-if="loadError" 会显示「当前数据来自缓存,可能不是最新」;同时弹出轻提示
uni.showToast({
title: '数据加载失败',
icon: 'none'

View File

@@ -269,30 +269,35 @@ export default {
console.error('加载食物列表失败:', error);
}
},
// 兼容 result.data.list / result.list / result.data 为数组 等响应结构
// 兼容 result.data.list / result.list / result.data 为数组 等响应结构(后端 CommonPage 为 result.data.list
getRawFoodList(result) {
if (!result) return [];
// 若整个 result 就是列表数组(部分网关/封装可能直接返回数组)
if (Array.isArray(result)) return result;
const page = result.data !== undefined && result.data !== null ? result.data : result;
if (page && Array.isArray(page.list)) return page.list;
if (Array.isArray(page)) return page;
if (page && Array.isArray(page)) return page;
// 部分接口直接返回 { list: [], total: 0 }
if (result && Array.isArray(result.list)) return result.list;
return [];
},
getFoodImage(item) {
if (!item) return this.defaultPlaceholder;
const id = item.id != null ? item.id : item.foodId;
if (id != null && this.imageErrorIds[String(id)]) return this.defaultPlaceholder;
// 兼容后端 image / image_url / 前端 imageUrl、img、pic、coverImage、cover_image
const raw = item.imageUrl || item.image || item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '';
const s = (raw && String(raw).trim()) || '';
// 兼容后端 imageToolFoodServiceImpl 返回 image/ image_url / 前端 imageUrl、img、pic、coverImage、cover_image
const raw = item.imageUrl != null ? item.imageUrl : (item.image != null ? item.image : (item.image_url || item.img || item.pic || item.coverImage || item.cover_image || ''));
const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
if (!s || s === 'null' || s === 'undefined') return this.defaultPlaceholder;
const url = (s.startsWith('//') || s.startsWith('http')) ? s : (s.startsWith('/') ? (HTTP_REQUEST_URL || '') + s : s);
return (url && String(url).trim()) ? url : this.defaultPlaceholder;
},
getNutritionList(item) {
if (!item) return [];
// 优先使用已规范化的 nutrition兼容后端 nutrients / nutritions 数组
const arr = item.nutrition || item.nutrients || item.nutritions;
if (Array.isArray(arr) && arr.length > 0) return arr;
// 无数组时从扁平字段组装,确保列表始终有营养简介
// 后端列表接口ToolFoodServiceImpl仅返回扁平字段 energy/protein/potassium/phosphorus 等,无数组时从此组装
const list = [];
const push = (label, val, unit) => {
const value = (val != null && val !== '') ? String(val) + (unit || '') : '—';
@@ -322,27 +327,26 @@ export default {
};
const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
// 图片:兼容 image/image_url/imageUrl/img/pic/coverImage/cover_image相对路径补全空或无效则由 getFoodImage 用占位图
const rawImg = item.imageUrl || item.image || item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '';
const rawStr = (rawImg && String(rawImg).trim()) || '';
// 图片:后端列表返回 image,兼容 image_url/imageUrl/img/pic/coverImage/cover_image相对路径补全空或无效则由 getFoodImage 用占位图
const rawImg = item.image || item.imageUrl || item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '';
const rawStr = (rawImg != null && String(rawImg).trim()) ? String(rawImg).trim() : '';
const validRaw = rawStr && rawStr !== 'null' && rawStr !== 'undefined';
const imageUrl = validRaw && (rawStr.startsWith('//') || rawStr.startsWith('http')) ? rawStr : (validRaw && rawStr.startsWith('/') ? (HTTP_REQUEST_URL || '') + rawStr : (validRaw ? rawStr : ''));
const image = imageUrl || '';
// 营养简介:优先 item.nutrition其次 item.nutrients兼容后端否则由扁平字段 energy/protein/potassium 等组装
// 营养简介:优先 item.nutrition其次 item.nutrients / item.nutritions(兼容后端),否则由扁平字段 energy/protein/potassium 等组装
let nutrition = item.nutrition;
const mapNut = (n) => ({
label: n.label || n.name || n.labelName || n.nutrientName || '—',
value: n.value != null ? String(n.value) : (n.amount != null ? String(n.amount) + (n.unit || '') : '—'),
colorClass: n.colorClass || 'green'
});
if (Array.isArray(nutrition) && nutrition.length > 0) {
nutrition = nutrition.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
nutrition = nutrition.map(mapNut);
} else if (Array.isArray(item.nutrients) && item.nutrients.length > 0) {
nutrition = item.nutrients.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
nutrition = item.nutrients.map(mapNut);
} else if (Array.isArray(item.nutritions) && item.nutritions.length > 0) {
nutrition = item.nutritions.map(mapNut);
} else {
// 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —)
nutrition = [];
@@ -363,11 +367,12 @@ export default {
const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId)))
? (typeof rawId === 'number' ? rawId : Number(rawId))
: undefined;
// 保证列表项必有 image/imageUrl空时由 getFoodImage 用 defaultPlaceholder和 nutrition 数组(.nutrition-item 数据来源)
return {
...item,
id: numericId,
image,
imageUrl: image || undefined,
image: image || '',
imageUrl: image || '',
category: item.category || '',
safety: safety.safety,
safetyClass: safety.safetyClass,

View File

@@ -65,7 +65,7 @@
class="knowledge-item"
v-for="(item, index) in (guideList || [])"
:key="item.knowledgeId || item.id || index"
@click="goToDetail" :data-item-id="item.id" :data-item-kid="item.knowledgeId"
@click="goToDetail($event, item, index, 'guide')"
>
<view class="knowledge-cover" v-if="item.coverImage || item.cover_image">
<image :src="item.coverImage || item.cover_image" mode="aspectFill" class="cover-img" />
@@ -96,7 +96,7 @@
class="knowledge-item"
v-for="(item, index) in (articleList || [])"
:key="item.knowledgeId || item.id || index"
@click="goToDetail" :data-item-id="item.id" :data-item-kid="item.knowledgeId"
@click="goToDetail($event, item, index, 'articles')"
>
<view class="knowledge-cover" v-if="item.coverImage || item.cover_image">
<image :src="item.coverImage || item.cover_image" mode="aspectFill" class="cover-img" />
@@ -125,6 +125,7 @@
<script>
export default {
navigationBarTitleText: '健康知识',
data() {
return {
currentTab: 'nutrients',
@@ -184,15 +185,15 @@ export default {
}
},
onLoad(options) {
// 确保列表初始为数组,避免未加载时为 undefined
this.guideList = Array.isArray(this.guideList) ? this.guideList : [];
this.articleList = Array.isArray(this.articleList) ? this.articleList : [];
if (options && options.id) {
// 有 id 时切换到科普文章 tabswitchTab 内会调用 loadKnowledgeList 加载列表
this.switchTab('articles');
} else {
// 无 id 时默认当前 tab 为「营养素」;切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
// 无 id 时默认显示「营养素」tab本地静态数据用户切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList 加载对应列表
this.currentTab = 'nutrients';
// 确保列表初始为数组,避免未加载时为 undefined切换 Tab 后再加载对应列表
this.guideList = Array.isArray(this.guideList) ? this.guideList : [];
this.articleList = Array.isArray(this.articleList) ? this.articleList : [];
}
},
onReady() {
@@ -224,7 +225,7 @@ export default {
if (this.currentTab === 'nutrients') {
return;
}
// type 与后端一致guide / articlev2_knowledge 表 type 字段)
// type 与后端 v2_knowledge 表一致guide=饮食指南article=科普文章
const typeParam = this.currentTab === 'guide' ? 'guide' : 'article';
try {
const { getKnowledgeList } = await import('@/api/tool.js');
@@ -264,10 +265,11 @@ export default {
};
});
// 始终赋值为数组,绝不设为 undefined
const safeList = Array.isArray(list) ? list : [];
if (this.currentTab === 'guide') {
this.guideList = Array.isArray(list) ? list : [];
this.guideList = safeList;
} else if (this.currentTab === 'articles') {
this.articleList = Array.isArray(list) ? list : [];
this.articleList = safeList;
}
} catch (error) {
console.error('加载知识列表失败:', error);
@@ -296,20 +298,24 @@ export default {
url: `/pages/tool/nutrient-detail?name=${encodeURIComponent(item.name)}`
});
},
goToDetail(event) {
const id = event.currentTarget.dataset.itemId;
const knowledgeId = event.currentTarget.dataset.itemKid;
const finalId = knowledgeId ?? id;
goToDetail(event, item, index, tab) {
// 优先从传入的 item 取 knowledgeId 或 id避免 dataset 序列化丢失
const fromItem = item != null ? (item.knowledgeId ?? item.id) : undefined;
const fromDataset = event && event.currentTarget && event.currentTarget.dataset;
const id = fromDataset ? (fromDataset.itemId ?? fromDataset.item_id) : undefined;
const knowledgeId = fromDataset ? (fromDataset.itemKid ?? fromDataset.item_kid) : undefined;
let finalId = fromItem ?? knowledgeId ?? id;
if (finalId == null && tab != null && index != null) {
const list = tab === 'guide' ? this.guideList : this.articleList;
const listItem = Array.isArray(list) ? list[index] : null;
finalId = listItem != null ? (listItem.knowledgeId ?? listItem.id) : undefined;
}
// 仅当 knowledgeId 或 id 存在且有效时才跳转,否则提示暂无详情
if (finalId == null || finalId === '' || String(finalId).trim() === '' || String(finalId) === 'undefined') {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
const idStr = String(finalId).trim();
if (idStr === 'undefined') {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
uni.navigateTo({
url: `/pages/tool/knowledge-detail?id=${encodeURIComponent(idStr)}`
});

View File

@@ -340,9 +340,13 @@ export default {
try {
const res = await getCommunityDetail(id)
// 兼容 CommonResultres = { code, data: 详情对象 },取 res.data 作为详情
const data = (res && res.data !== undefined && res.data !== null) ? res.data : res
// 兼容 CommonResultres = { code, data: 详情对象 } 或 { result: 详情对象 },取 data/result 作为详情
const data = (res && res.data !== undefined && res.data !== null)
? res.data
: (res && res.result !== undefined && res.result !== null)
? res.result
: res
// 格式化帖子数据
this.formatPostData(data)
@@ -412,7 +416,7 @@ export default {
if (statsFromObj.length > 0 && !statsFromObj.every(s => s.value === '-')) return statsFromObj
}
// 2) nutritionDataJson / nutrition_data_json兼容后端驼峰与下划线含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein
// 2) nutritionDataJson / nutrition_data_json兼容后端驼峰与下划线含 fill-nutrition 的 energyKcal/proteinG、嵌套格式 calories: { value, unit }
const jsonRaw = data.nutritionDataJson || data.nutrition_data_json
if (jsonRaw) {
try {
@@ -429,7 +433,7 @@ export default {
if (!Array.isArray(nutritionData) && Object.keys(nutritionData).length === 0) {
return []
}
// 2c) 有键的对象:兼容后端 fill-nutrition 的 energyKcal/proteinG
// 2c) 有键的对象:兼容后端嵌套 { calories: { value, unit }, protein: { value, unit }, ... } 与扁平 energyKcal/proteinG
const statsFromJson = this.buildNutritionStatsFromNutritionObject(nutritionData)
// 若解析后全部为占位符 "-",视为无效数据,返回 [] 以便走打卡/服务端填充
if (statsFromJson.length > 0 && statsFromJson.every(s => s.value === '-')) {
@@ -441,6 +445,20 @@ export default {
}
}
// 2d) nutritionData / nutrition_data 为已解析对象(部分接口直接返回对象)
const nutritionDataObj = data.nutritionData || data.nutrition_data
if (nutritionDataObj && typeof nutritionDataObj === 'object' && !Array.isArray(nutritionDataObj) && Object.keys(nutritionDataObj).length > 0) {
const statsFromObj = this.buildNutritionStatsFromNutritionObject(nutritionDataObj)
if (statsFromObj.length > 0 && !statsFromObj.every(s => s.value === '-')) return statsFromObj
}
// 2e) 详情中嵌套的打卡记录(若后端返回 checkInRecord/check_in_record直接从中取营养
const checkInRecord = data.checkInRecord || data.check_in_record
if (checkInRecord && typeof checkInRecord === 'object') {
const statsFromCheckin = this.buildNutritionStatsFromCheckinDetail(checkInRecord)
if (statsFromCheckin.length > 0 && statsFromCheckin.some(s => s.value !== '-' && s.value !== '')) return statsFromCheckin
}
// 3) dietaryData / mealData / dietary_data / meal_data饮食打卡对象
const dietary = data.dietaryData || data.dietary_data || data.mealData || data.meal_data
if (dietary) {
@@ -565,8 +583,12 @@ export default {
async fillNutritionStatsFromCheckin(checkInRecordId) {
try {
const res = await getCheckinDetail(checkInRecordId)
// 兼容res 为 { code, data } 时取 res.data;部分封装直接返回 payload 则 res 即为 detail
const detail = (res && res.data !== undefined && res.data !== null) ? res.data : res
// 兼容res 为 { code, data } 或 { result } 时取 data/result;部分封装直接返回 payload 则 res 即为 detail
const detail = (res && res.data !== undefined && res.data !== null)
? res.data
: (res && res.result !== undefined && res.result !== null)
? res.result
: res
if (!detail || typeof detail !== 'object') return
const stats = this.buildNutritionStatsFromCheckinDetail(detail)
// 仅当至少有一项有效值时才更新,避免展示全部为 "-" 的卡片
@@ -706,7 +728,7 @@ export default {
description: description,
tags: tags,
author: {
id: data.userId,
id: data.userId ?? data.authorId ?? data.user_id ?? data.author_id ?? null,
avatar: data.userAvatar || '👤',
name: data.userName || '匿名用户',
time: this.formatTime(data.createdAt)
@@ -717,7 +739,7 @@ export default {
likeCount: data.likeCount || 0,
favoriteCount: data.collectCount || 0,
commentCount: data.commentCount || 0,
checkInRecordId: data.checkInRecordId,
checkInRecordId: data.checkInRecordId ?? data.check_in_record_id ?? null,
videoUrl: data.videoUrl || null,
videoStatus: data.videoStatus,
hasVideo: data.hasVideo || false
@@ -852,12 +874,16 @@ export default {
setTimeout(() => toLogin(), 1000)
return
}
// 先检查 author.id 是否存在,再改状态、调 API避免无效请求导致「操作失败」
const authorId = this.postData && this.postData.author && (this.postData.author.id ?? this.postData.author.userId)
if (authorId == null || authorId === '') {
uni.showToast({ title: '无法获取作者信息', icon: 'none' })
return
}
const newFollowState = !this.isFollowed
this.isFollowed = newFollowState
try {
await apiToggleFollow(this.postData.author.id, newFollowState)
await apiToggleFollow(authorId, newFollowState)
uni.showToast({
title: newFollowState ? '已关注' : '取消关注',
icon: 'none'
@@ -1327,6 +1353,11 @@ export default {
}
}
.follow-btn.followed {
background: linear-gradient(135deg, #9fa5c0 0%, #8a90a8 100%);
box-shadow: none;
}
/* 营养统计卡片 */
.nutrition-stats-card {
margin: 10rpx 32rpx;

View File

@@ -50,8 +50,8 @@
<text class="team-role">专业营养师</text>
</view>
</view>
<view class="follow-btn" @click="toggleFollow">
<text>+ 关注</text>
<view class="follow-btn" :class="{ followed: isFollowing }" @click="toggleFollow">
<text>{{ isFollowing ? '已关注' : '+ 关注' }}</text>
</view>
</view>
@@ -353,9 +353,9 @@ export default {
applyDefaultData() {
this.recipeData = { ...this.defaultRecipeData }
this.nutritionData = JSON.parse(JSON.stringify(this.defaultNutritionData))
// T09: 若无营养数据,调用 AI 回填
this.fillNutritionFromAI(id)
if (this.recipeId) {
this.fillNutritionFromAI(this.recipeId)
}
this.mealPlan = JSON.parse(JSON.stringify(this.defaultMealPlan))
this.warningList = [...this.defaultWarningList]
},
@@ -715,6 +715,10 @@ export default {
}
}
.follow-btn.followed {
background: linear-gradient(180deg, #9fa5c0 0%, #8a90a8 100%);
}
/* 介绍卡片 */
.intro-card {
background: #ffffff;

View File

@@ -436,21 +436,38 @@ export default {
getMealTypeLabel(mealType) {
if (!mealType) return '分享'
const map = {
// 英文
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
share: '分享',
checkin: '打卡',
brunch: '早午餐',
tea: '茶点',
supper: '晚餐',
other: '其他',
morning: '早餐',
noon: '午餐',
night: '晚餐',
afternoon_tea: '下午茶',
afternoon: '下午茶',
midnight: '夜宵',
midnight_snack: '夜宵',
morning_snack: '早加餐',
night_snack: '夜宵',
// 拼音
zaocan: '早餐',
wucan: '午餐',
wancan: '晚餐',
jiacan: '加餐',
fenxiang: '分享',
daka: '打卡',
morning: '早餐',
noon: '午餐',
night: '晚餐'
zaowucan: '早餐',
chadian: '茶点',
qita: '其他',
xiawucha: '下午茶',
yexiao: '夜宵'
}
const str = String(mealType).trim()
const lower = str.toLowerCase()

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Run T10 full regression tests and optionally update report.
# Usage: ./scripts/run-t10-regression.sh [--update-report]
set -e
cd "$(dirname "$0")/.."
echo "Running T10 regression tests..."
npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10" --reporter=list
echo "Done. See docs/Testing/T10-full-regression-report.md to fill pass/fail from the output above."