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

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()