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>
This commit is contained in:
msh-agent
2026-04-11 15:20:10 +08:00
parent 2facd355ab
commit dce899f655
4 changed files with 1124 additions and 156 deletions

View File

@@ -324,6 +324,20 @@ function queryAsrStatus(taskId) {
// ==================== KieAI Gemini ChatBUG-005AI 营养师文本/多模态对话) ==================== // ==================== KieAI Gemini ChatBUG-005AI 营养师文本/多模态对话) ====================
/**
* 将 CommonResult.data 规范为可直接读 choices 的 OpenAI 形态(避免偶发多包一层 data
* 页面统一从返回值的 data.choices[0].message.content 取正文。
*/
function unwrapGeminiCompletionData(payload) {
if (payload == null || typeof payload !== 'object') return payload
if (Array.isArray(payload.choices) && payload.choices.length > 0) return payload
const nested = payload.data
if (nested && typeof nested === 'object' && Array.isArray(nested.choices) && nested.choices.length > 0) {
return nested
}
return payload
}
/** /**
* KieAI Gemini 对话 * KieAI Gemini 对话
* POST /api/front/kieai/gemini/chat * POST /api/front/kieai/gemini/chat
@@ -346,9 +360,159 @@ function kieaiGeminiChat(data) {
return request('/api/front/kieai/gemini/chat', { return request('/api/front/kieai/gemini/chat', {
method: 'POST', method: 'POST',
data: { messages, stream } data: { messages, stream }
}).then((res) => {
// HTTP 200 时仍可能是 CommonResult 业务失败,禁止把失败当成功、用空内容走本地固定话术
const c = res && res.code
if (c != null && Number(c) !== 200) {
const msg = (res.message || res.msg || 'Gemini 对话失败').toString()
return Promise.reject(new Error(msg))
}
let outData = res && res.data
// 少数环境下 data 为 JSON 字符串,解析后便于页面读取 data.choices[0].message.content
if (typeof outData === 'string') {
try {
outData = JSON.parse(outData)
} catch (e) {
return res
}
}
outData = unwrapGeminiCompletionData(outData)
return { ...res, data: outData }
}) })
} }
/**
* KieAI Gemini 流式对话 (SSE + enableChunked)
* POST /api/front/kieai/gemini/chat stream=true
* 返回与 cozeChatStream 相同的 controller 接口 { onMessage, onError, onComplete, abort }
* onMessage(text) 每次收到一段增量文本
* @param {object} data 请求体 { messages }
* @returns {object} 控制器
*/
function kieaiGeminiChatStream(data) {
const messages = data && data.messages
if (!messages || !Array.isArray(messages) || messages.length === 0) {
const ctrl = {
onMessage() { return ctrl },
onError(fn) { fn(new Error('messages 不能为空')); return ctrl },
onComplete() { return ctrl },
abort() {}
}
return ctrl
}
let _onMessage = () => {}
let _onError = () => {}
let _onComplete = () => {}
let _buffer = ''
let _task = null
let _gotChunks = false
const controller = {
onMessage(fn) { _onMessage = fn; return controller },
onError(fn) { _onError = fn; return controller },
onComplete(fn) { _onComplete = fn; return controller },
abort() { if (_task) _task.abort() },
getTask() { return _task }
}
/** 从 SSE data JSON 中提取增量文本 */
const extractDeltaText = (evt) => {
// OpenAI 兼容格式: choices[0].delta.content
if (evt && Array.isArray(evt.choices) && evt.choices[0]) {
const delta = evt.choices[0].delta
if (delta && typeof delta.content === 'string') return delta.content
// 非流式 fallback
const msg = evt.choices[0].message
if (msg && typeof msg.content === 'string') return msg.content
}
// Gemini 原生格式
if (evt && Array.isArray(evt.candidates) && evt.candidates[0]) {
const parts = evt.candidates[0].content && evt.candidates[0].content.parts
if (Array.isArray(parts) && parts[0] && typeof parts[0].text === 'string') return parts[0].text
}
return ''
}
const parseSseLines = (text) => {
_buffer += text
const lines = _buffer.split('\n')
_buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed === 'data: [DONE]') continue
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr) continue
try {
const evt = JSON.parse(jsonStr)
const delta = extractDeltaText(evt)
if (delta) _onMessage(delta)
} catch (e) {
console.warn('[kieaiGeminiChatStream] JSON parse failed:', jsonStr.slice(0, 200))
}
}
}
}
const token = store.state && store.state.app && store.state.app.token
_task = uni.request({
url: `${API_BASE_URL}/api/front/kieai/gemini/chat`,
method: 'POST',
data: { messages, stream: true },
header: {
'Content-Type': 'application/json',
...(token ? { [TOKENNAME]: token } : {})
},
enableChunked: true,
responseType: 'text',
success: (res) => {
if (res.statusCode !== 200) {
let errMsg = '请求失败: ' + res.statusCode
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (body && body.message) errMsg = body.message
else if (body && body.msg) errMsg = body.msg
} catch (e) { /* keep default */ }
_onError(new Error(errMsg))
return
}
// 处理剩余 buffer
if (_buffer.trim()) parseSseLines('\n')
// 如果 enableChunked 不支持(非微信小程序),从完整 response body 解析
if (!_gotChunks && res && res.data) {
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
parseSseLines(body + '\n')
}
_onComplete()
},
fail: (err) => {
console.error('[kieaiGeminiChatStream] request fail:', err)
_onError(err)
}
})
if (_task && _task.onChunkReceived) {
_task.onChunkReceived((res) => {
_gotChunks = true
try {
const bytes = new Uint8Array(res.data)
let text = ''
for (let i = 0; i < bytes.length; i++) {
text += String.fromCharCode(bytes[i])
}
text = decodeURIComponent(escape(text))
parseSseLines(text)
} catch (e) {
console.warn('[kieaiGeminiChatStream] chunk decode error:', e)
}
})
}
return controller
}
// ==================== 扣子Coze API ==================== // ==================== 扣子Coze API ====================
/** /**
@@ -658,6 +822,7 @@ export default {
createAsrTask, createAsrTask,
queryAsrStatus, queryAsrStatus,
kieaiGeminiChat, kieaiGeminiChat,
kieaiGeminiChatStream,
// Coze API // Coze API
cozeChat, cozeChat,
cozeChatStream, cozeChatStream,

View File

@@ -81,13 +81,27 @@
@click="previewImage(msg.imageUrl)" @click="previewImage(msg.imageUrl)"
></image> ></image>
</view> </view>
<!-- AI 回复播放按钮 --> <!-- AI 回复操作按钮 -->
<view <view
v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'" v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'"
class="tts-play-btn" class="msg-actions"
@click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)"
> >
<text class="tts-play-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text> <!-- 复制 -->
<view class="action-btn" @click="copyMessage(index)">
<text class="action-icon">📋</text>
</view>
<!-- 重新生成仅最后一条AI消息显示 -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="action-icon">🔄</text>
</view>
<!-- 语音朗读 -->
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="action-icon">🗑</text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -560,6 +574,56 @@ export default {
this.sendMessage(); this.sendMessage();
}); });
}, },
// ---------- 消息操作方法(复制/删除/重新生成) ----------
copyMessage(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
uni.setClipboardData({
data: msg.content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
},
deleteMessage(index) {
uni.showModal({
title: '提示',
content: '确定删除这条消息吗?',
success: (res) => {
if (res.confirm) {
this.messageList.splice(index, 1)
this.messageList = [...this.messageList]
}
}
})
},
regenerateMessage(index) {
// 找到该 AI 消息对应的上一条用户消息
let userMsgIndex = index - 1
while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') {
userMsgIndex--
}
if (userMsgIndex < 0) return
const userMsg = this.messageList[userMsgIndex]
// 移除当前 AI 回复
this.messageList.splice(index, 1)
this.messageList = [...this.messageList]
// 重新发送
this.sendToAI(userMsg.content, userMsg.type || 'text')
},
isLastAiMessage(index) {
for (let i = this.messageList.length - 1; i >= 0; i--) {
if (this.messageList[i].role === 'ai' && !this.messageList[i].loading) {
return i === index
}
}
return false
},
clearChat() { clearChat() {
uni.showModal({ uni.showModal({
title: '提示', title: '提示',
@@ -693,16 +757,74 @@ export default {
return [{ role: 'user', content: parts }]; return [{ role: 'user', content: parts }];
}, },
/** BUG-005严格从 CommonResult.data.choices[0].message.content 读取回复 */ /**
* BUG-005主路径为 response.data.choices[0].message.contentmodels-api 已尽量规范 data 形态)。
* 兼容上游 candidates、或根级即 completion 等变体;仅使用接口返回字段,
* 成功路径不使用 api/tool.js 的 getAIResponse 或本地关键词话术。
*/
getGeminiReplyFromResponse(response) { getGeminiReplyFromResponse(response) {
const content = response && if (!response || typeof response !== 'object') return '';
response.data && const looksLikeCompletion = (o) =>
Array.isArray(response.data.choices) && o && typeof o === 'object' &&
response.data.choices[0] && (Array.isArray(o.choices) || Array.isArray(o.candidates));
response.data.choices[0].message let data = response.data;
? response.data.choices[0].message.content if (typeof data === 'string') {
: ''; try {
return this.extractReplyContent(content); data = JSON.parse(data);
} catch (e) {
return '';
}
}
// 主路径OpenAI 形态 choices[0].message.content与需求 data.choices[0].message.content 一致)
if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) {
const choice0 = data.choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
if (!looksLikeCompletion(data) && looksLikeCompletion(response)) {
data = response;
}
if (!data || typeof data !== 'object') return '';
const payload = this.getGeminiPayload(data);
if (!payload || typeof payload !== 'object') return '';
const choices = payload.choices;
if (Array.isArray(choices) && choices.length > 0) {
const choice0 = choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
const cands = payload.candidates;
if (Array.isArray(cands) && cands.length > 0) {
const c0 = cands[0];
if (c0 && typeof c0 === 'object') {
const fromCand = this.extractReplyContent(c0.content);
if (fromCand.trim()) return fromCand;
if (typeof c0.text === 'string' && c0.text.trim()) return c0.text;
}
}
return '';
}, },
async sendToAI(content, type) { async sendToAI(content, type) {
@@ -711,16 +833,55 @@ export default {
this.messageList.push(aiMsg); this.messageList.push(aiMsg);
this.scrollToBottom(); this.scrollToBottom();
const messages = this.buildGeminiMessages(content, type);
// 优先尝试流式输出以改善响应速度感知
try {
await new Promise((resolve, reject) => {
const ctrl = api.kieaiGeminiChatStream({ messages })
this._streamCtrl = ctrl
// 收到第一个 chunk 后切换为 streaming 状态
ctrl.onMessage((deltaText) => {
if (aiMsg.loading) {
aiMsg.loading = false
aiMsg.streaming = true
}
aiMsg.content += deltaText
this.messageList = [...this.messageList]
this.scrollToBottom()
})
ctrl.onError((err) => {
console.warn('[sendToAI] 流式请求失败,降级为非流式:', err)
this._streamCtrl = null
reject(err)
})
ctrl.onComplete(() => {
this._streamCtrl = null
resolve()
})
})
// 流式完成,检查内容
if (!aiMsg.content.trim()) {
aiMsg.content = '模型未返回有效内容,请稍后重试。'
}
} catch (streamError) {
// 流式失败,降级为非流式请求
try { try {
const response = await api.kieaiGeminiChat({ const response = await api.kieaiGeminiChat({
messages: this.buildGeminiMessages(content, type), messages,
stream: false stream: false
}); });
const reply = this.getGeminiReplyFromResponse(response); const reply = this.getGeminiReplyFromResponse(response);
aiMsg.content = reply || '抱歉,未获取到模型回复。'; aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。';
} catch (error) { } catch (error) {
console.error('KieAI Gemini 对话失败:', error); console.error('KieAI Gemini 对话失败:', error);
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。'; const errText = error && error.message ? String(error.message) : '';
aiMsg.content = errText || '抱歉,处理您的请求时出现错误,请稍后再试。';
}
} finally { } finally {
aiMsg.loading = false; aiMsg.loading = false;
aiMsg.streaming = false; aiMsg.streaming = false;
@@ -973,6 +1134,34 @@ export default {
font-weight: 500; font-weight: 500;
} }
/* 消息操作按钮组 */
.msg-actions {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
align-items: center;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.action-btn:active {
transform: scale(0.9);
}
.action-icon {
font-size: 24rpx;
color: #4caf50;
}
.tts-play-btn { .tts-play-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -87,15 +87,15 @@
</view> </view>
<view class="nutrition-hint"> <view class="nutrition-hint">
<text class="hint-icon">💡</text> <text class="hint-icon">💡</text>
<text class="hint-text">相当于下方表中食物的推荐摄入量</text> <text class="hint-text">相当于下方表中食物的每日推荐克数</text>
</view> </view>
</view> </view>
<!-- 食物份数建议卡片 --> <!-- 每日食物建议卡片份数克数优化 -->
<view class="data-card"> <view class="data-card">
<view class="card-header"> <view class="card-header">
<text class="card-icon">🥗</text> <text class="card-icon">🥗</text>
<text class="card-title">食物份数建议</text> <text class="card-title">每日食物建议</text>
</view> </view>
<view class="food-list"> <view class="food-list">
<view <view
@@ -107,7 +107,7 @@
<view class="food-number">{{ item.number }}</view> <view class="food-number">{{ item.number }}</view>
<text class="food-name">{{ item.name }}</text> <text class="food-name">{{ item.name }}</text>
</view> </view>
<text class="food-portion">{{ item.portion }} </text> <text class="food-portion">{{ item.gram || item.portion }} </text>
</view> </view>
</view> </view>
</view> </view>
@@ -335,7 +335,11 @@ export default {
} }
} }
if (Array.isArray(data.foodList)) { if (Array.isArray(data.foodList)) {
this.foodList = 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) { if (data.mealPlan) {
this.mealPlan = { this.mealPlan = {
@@ -472,52 +476,57 @@ export default {
display: flex; display: flex;
padding: 0 32rpx; padding: 0 32rpx;
height: 96rpx; height: 96rpx;
align-items: center; /* stretchTab 占满栏高,激活态 border-bottom 才能贴底显示BUG-002 */
align-items: stretch;
position: sticky; position: sticky;
// top: 88rpx; // top: 88rpx;
z-index: 99; z-index: 99;
} }
/* Tab未激活灰字、透明底边占位无可见下划线激活加粗+主色+橙色底边BUG-002 */
.tab-item { .tab-item {
flex: 1; flex: 1;
height: 100%; min-height: 0;
min-height: 75rpx;
border-radius: 0; border-radius: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16rpx; gap: 16rpx;
transition: all 0.2s ease;
box-sizing: border-box; box-sizing: border-box;
/* 未激活明显变灰无下划线BUG-002 */
color: #9ca3af; color: #9ca3af;
font-weight: 400; font-weight: 400;
border-bottom: 3rpx solid transparent; text-decoration: none;
.tab-icon { 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; font-size: 28rpx;
color: inherit; }
font-weight: inherit;
} /* 未激活:灰字、常规字重(显式写到 text 节点,避免小程序不继承 view 颜色) */
.tab-text { .tab-item:not(.active) .tab-icon,
font-size: 28rpx; .tab-item:not(.active) .tab-text {
color: inherit; color: #9ca3af;
font-weight: inherit; font-weight: 400;
} text-decoration: none;
/* 激活加粗、主色、橙色底部下划线BUG-002 */ }
&.active {
/* 激活:粗体、主色、橙色底边(与未激活同宽底边,避免切换时跳动) */
.tab-item.active {
background: transparent; background: transparent;
color: #f97316; color: #f97316;
font-weight: 700; font-weight: 700;
text-decoration: none;
border-bottom: 3px solid #f97316; border-bottom: 3px solid #f97316;
.tab-text { }
.tab-item.active .tab-icon,
.tab-item.active .tab-text {
color: #f97316; color: #f97316;
font-weight: 700; font-weight: 700;
} text-decoration: none;
.tab-icon {
color: #f97316;
font-weight: 700;
}
}
} }
/* 内容滚动区域 */ /* 内容滚动区域 */

View File

@@ -99,16 +99,29 @@
<view <view
class="food-item" class="food-item"
v-for="(item, index) in filteredFoodList" v-for="(item, index) in filteredFoodList"
:key="index" :key="foodRowKey(item, index)"
@click="goToFoodDetail(item)" @click="goToFoodDetail(item)"
> >
<view class="food-image-wrapper"> <view class="food-image-wrapper">
<!-- 配图优先 item.imageUrl/img/imagenormalize 已对齐再经 displayFoodImage 拼域名与占位BUG-003 -->
<!-- #ifdef H5 -->
<img
class="food-image"
:class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
:src="displayFoodImage(item, index)"
alt=""
@error="onFoodImageError(item, index, $event)"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
<image <image
class="food-image" class="food-image"
:src="getFoodImage(item)" :class="{ 'food-image-placeholder': !hasFoodImage(item, index) }"
:src="displayFoodImage(item, index)"
mode="aspectFill" mode="aspectFill"
@error="onFoodImageError(item)" @error="onFoodImageError(item, index, $event)"
></image> ></image>
<!-- #endif -->
<view v-if="item.warning" class="warning-badge"></view> <view v-if="item.warning" class="warning-badge"></view>
</view> </view>
<view class="food-info"> <view class="food-info">
@@ -124,10 +137,11 @@
</view> --> </view> -->
</view> </view>
<view class="nutrition-list"> <view class="nutrition-list">
<!-- 营养简介nutrition / nutrients / nutritions / nutrientsJson + 扁平 energy displayNutritionListBUG-003 -->
<view <view
class="nutrition-item" class="nutrition-item"
v-for="(nut, idx) in getNutritionList(item)" v-for="(nut, idx) in displayNutritionList(item)"
:key="idx" :key="'n-' + foodRowKey(item, index) + '-' + idx"
> >
<text class="nutrition-label">{{ nut.label || '—' }}</text> <text class="nutrition-label">{{ nut.label || '—' }}</text>
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text> <text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value != null ? nut.value : '—' }}</text>
@@ -143,11 +157,12 @@
<script> <script>
import { HTTP_REQUEST_URL } from '@/config/app.js'; import { HTTP_REQUEST_URL } from '@/config/app.js';
import { normalizeFoodDetailIdString } from '@/api/tool.js';
export default { export default {
data() { data() {
// 无图时的占位图(灰色背景,与 .food-image-wrapper 背景一致 // 本地占位图(小程序/H5 均可用;勿用 data: URL部分端 image 组件不展示
const defaultPlaceholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkyIiBoZWlnaHQ9IjE5MiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTRlNWU3Ii8+PC9zdmc+'; const defaultPlaceholder = '/static/images/food-placeholder.svg';
return { return {
searchText: '', searchText: '',
currentCategory: 'all', currentCategory: 'all',
@@ -244,7 +259,7 @@ export default {
}, },
computed: { computed: {
filteredFoodList() { filteredFoodList() {
const list = (this.foodList || []).filter(item => item != null); const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name);
if (!this.searchText || !this.searchText.trim()) return list; if (!this.searchText || !this.searchText.trim()) return list;
const kw = this.searchText.trim().toLowerCase(); const kw = this.searchText.trim().toLowerCase();
return list.filter(item => item.name && item.name.toLowerCase().includes(kw)); return list.filter(item => item.name && item.name.toLowerCase().includes(kw));
@@ -257,6 +272,14 @@ export default {
this.loadFoodList(); this.loadFoodList();
}, },
methods: { methods: {
/** 列表 :key无效 id 曾为 '' 时多行共用同一 key导致点击与展示错乱 */
foodRowKey(item, index) {
const id = item && item.id
const idStr = id !== undefined && id !== null && id !== '' ? String(id).trim() : ''
if (idStr !== '' && /^-?\d+$/.test(idStr)) return 'id-' + idStr
const n = item && item.name != null ? String(item.name).trim() : ''
return 'idx-' + index + '-' + (n || 'row')
},
async loadFoodList() { async loadFoodList() {
try { try {
const { getFoodList } = await import('@/api/tool.js'); const { getFoodList } = await import('@/api/tool.js');
@@ -265,132 +288,682 @@ export default {
page: 1, page: 1,
limit: 100 limit: 100
}); });
const rawList = this.getRawFoodList(result); let rawList = this.getRawFoodList(result);
if (!Array.isArray(rawList)) rawList = [];
this.imageErrorIds = {}; this.imageErrorIds = {};
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item)).filter(i => i != null); this.foodList = (rawList || []).map((row) => {
let r = row;
if (typeof r === 'string' && r.trim()) {
try {
r = JSON.parse(r);
} catch (e) { /* keep string, normalize 会兜底 */ }
}
return this.normalizeFoodItem(r);
}).filter(i => i != null);
} catch (error) { } catch (error) {
console.error('加载食物列表失败:', error); console.error('加载食物列表失败:', error);
} }
}, },
// 兼容 result.data.list / result.list / result.data 为数组 等响应结构(后端 CommonPage 为 result.data.list // 兼容 CommonResult.data 为 CommonPage、网关再包一层 data、或直接数组等与 nutrition-knowledge extractKnowledgeListFromResponse 对齐
getRawFoodList(result) { getRawFoodList(result) {
if (!result) return []; if (result == null) return [];
// 若整个 result 就是列表数组(部分网关/封装可能直接返回数组) if (typeof result === 'string' && result.trim()) {
try {
return this.getRawFoodList(JSON.parse(result));
} catch (e) {
return [];
}
}
if (Array.isArray(result)) return result; if (Array.isArray(result)) return result;
if (typeof result === 'object' && typeof result.data === 'string' && result.data.trim()) {
try {
return this.getRawFoodList({ ...result, data: JSON.parse(result.data) });
} catch (e) {
/* ignore */
}
}
// 少数网关/旧版直接把列表放在 data数组
if (Array.isArray(result.data)) {
return result.data;
}
// CommonResult.data 为 CommonPage 时 list 在 data.list
if (result.data != null && typeof result.data === 'object' && result.data.list != null) {
const page = this.parsePageListIfString(result.data);
if (Array.isArray(page.list)) return page.list;
}
if (result.data != null && typeof result.data === 'object' && Array.isArray(result.data.records)) {
return result.data.records;
}
// 再包一层CommonResult.data.data.listlist 可能为 JSON 字符串)
if (result.data != null && typeof result.data === 'object' && result.data.data != null
&& typeof result.data.data === 'object' && result.data.data.list != null) {
const inner = this.parsePageListIfString(result.data.data);
if (Array.isArray(inner.list)) return inner.list;
}
const page = result.data !== undefined && result.data !== null ? result.data : result; const page = result.data !== undefined && result.data !== null ? result.data : result;
if (page && Array.isArray(page.list)) return page.list; const pick = (p) => {
if (page && Array.isArray(page)) return page; if (p == null || typeof p !== 'object' || Array.isArray(p)) return null;
// 部分接口直接返回 { list: [], total: 0 } if (p.data && Array.isArray(p.data)) return p.data;
if (result && Array.isArray(result.list)) return result.list; const pNorm = this.parsePageListIfString(p);
if (Array.isArray(pNorm.list)) return pNorm.list;
if (Array.isArray(p.records)) return p.records;
if (Array.isArray(p.rows)) return p.rows;
if (Array.isArray(p.results)) return p.results;
if (Array.isArray(p.content)) return p.content;
if (Array.isArray(p.items)) return p.items;
if (p.data != null && typeof p.data === 'object' && !Array.isArray(p.data) && p.data.list != null) {
const dNorm = this.parsePageListIfString(p.data);
if (Array.isArray(dNorm.list)) return dNorm.list;
}
if (p.data && Array.isArray(p.data.list)) return p.data.list;
if (p.data && Array.isArray(p.data.records)) return p.data.records;
return null;
};
let list = pick(page);
if (list) return list;
if (page != null && typeof page === 'object' && !Array.isArray(page) && page.data != null && typeof page.data === 'object' && !Array.isArray(page.data)) {
list = pick(page.data);
if (list) return list;
}
if (Array.isArray(page)) return page;
if (result && typeof result === 'object' && result.list != null) {
const rNorm = this.parsePageListIfString(result);
if (Array.isArray(rNorm.list)) return rNorm.list;
}
if (result && Array.isArray(result.records)) return result.records;
if (result.data && Array.isArray(result.data)) return result.data;
return []; return [];
}, },
getFoodImage(item) { /** 分页对象里 list 偶发为 JSON 字符串;解析失败则原样返回 */
if (!item) return this.defaultPlaceholder; parsePageListIfString(page) {
const id = item.id != null ? item.id : item.foodId; if (page == null || typeof page !== 'object' || Array.isArray(page)) return page;
if (id != null && this.imageErrorIds[String(id)]) return this.defaultPlaceholder; const L = page.list;
// 兼容后端 imageToolFoodServiceImpl 返回 image/ image_url / 前端 imageUrl、img、pic、coverImage、cover_image if (typeof L !== 'string' || !L.trim()) return page;
const raw = item.imageUrl != null ? item.imageUrl : (item.image != null ? item.image : (item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '')); try {
const parsed = JSON.parse(L);
return Array.isArray(parsed) ? { ...page, list: parsed } : page;
} catch (e) {
return page;
}
},
/** 部分网关/旧接口把单条包在 food、record、info、item、data 下 */
unwrapFoodListRow(raw) {
if (raw == null) return raw;
if (typeof raw === 'string' && raw.trim()) {
try {
return this.unwrapFoodListRow(JSON.parse(raw));
} catch (e) {
return raw;
}
}
if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
const hasFoodShape = (o) => {
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
const nameOk = o.name != null && String(o.name).trim() !== '';
const idOk = (o.id != null && o.id !== '') || (o.foodId != null && o.foodId !== '') || (o.food_id != null && o.food_id !== '');
const nutOk = ['image', 'imageUrl', 'image_url', 'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json', 'energy', 'protein', 'potassium', 'phosphorus', 'calcium', 'sodium'].some(
(k) => o[k] != null && o[k] !== ''
);
return nameOk || idOk || nutOk;
};
let base = raw;
if (!hasFoodShape(raw) && raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data) && hasFoodShape(raw.data)) {
base = { ...raw, ...raw.data };
}
const inner = base.food || base.foodVo || base.foodVO || base.v2Food || base.v2_food
|| base.record || base.info || base.detail || base.vo || base.item || base.row || base.entity;
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
// 以 inner 为主,避免外层空字段(如 image/nutrients 为空)覆盖内层真实数据
// 同时把外层标识字段补齐回结果(如 id、foodId 等)
const merged = { ...base, ...inner };
if (merged.id == null && base.id != null) merged.id = base.id;
if (merged.foodId == null && base.foodId != null) merged.foodId = base.foodId;
if (merged.food_id == null && base.food_id != null) merged.food_id = base.food_id;
return merged;
}
return base;
},
/**
* 列表行外层常有 id/name图片与营养在 data/vo 内;仅在目标字段为空时用 fill 补齐BUG-003
*/
shallowFillEmptyFields(target, fill) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
if (!fill || typeof fill !== 'object' || Array.isArray(fill)) return target;
const out = { ...target };
const keys = [
'image', 'imageUrl', 'image_url', 'img', 'picture', 'pictureUrl', 'picture_url',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutrition', 'nutrients', 'nutritions', 'nutrientList', 'nutritionList',
'nutrientVoList', 'nutrient_vo_list',
'energy', 'protein', 'potassium', 'phosphorus', 'sodium', 'calcium', 'fat', 'carbohydrate', 'carbs',
'suitabilityLevel', 'suitability_level'
];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!(k in fill)) continue;
const v = fill[k];
if (v == null || v === '') continue;
const cur = out[k];
const curEmpty = cur == null || cur === '' || (Array.isArray(cur) && cur.length === 0);
if (curEmpty) out[k] = v;
}
return out;
},
coalesceFoodImageField(item) {
if (!item || typeof item !== 'object') return '';
// 后端 ToolFoodServiceImpl 列表为 map.put("image", …);兼容 imageUrl、蛇形命名、OSS 字段等
const candidates = [
item.image, item.imageUrl, item.image_url,
item.picture, item.pictureUrl, item.picture_url,
item.foodPicture, item.food_picture,
item.imagePath, item.image_path, item.fileUrl, item.file_url,
item.img, item.imgUrl, item.img_url, item.pic, item.picUrl, item.pic_url,
item.coverImage, item.cover_image, item.coverUrl, item.cover_url, item.cover,
item.photo, item.photoUrl, item.photo_url, item.picture, item.pictureUrl,
item.thumbnail, item.thumb, item.icon, item.headImg, item.head_img,
item.foodImage, item.food_image, item.foodPic, item.food_pic, item.food_pic_url,
item.mainImage, item.main_image,
item.foodImg, item.food_img, item.logo, item.avatar, item.banner,
item.Image,
item.coverImageUrl,
item.ossUrl, item.oss_url, item.cdnUrl, item.cdn_url
];
for (let i = 0; i < candidates.length; i++) {
const v = candidates[i];
if (Array.isArray(v) && v.length > 0) {
const first = v[0];
if (first != null && typeof first === 'object' && !Array.isArray(first)) {
const nested = first.url || first.src || first.path || first.uri;
if (nested != null && String(nested).trim() !== '') return String(nested).trim();
} else if (first != null && String(first).trim() !== '' && String(first) !== 'null') {
return String(first).trim();
}
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const nested = v.url || v.src || v.path || v.uri;
if (nested != null && String(nested).trim() !== '') return String(nested).trim();
continue;
}
if (v != null && String(v).trim() !== '' && String(v) !== 'null' && String(v) !== 'undefined') {
return String(v).trim();
}
}
return '';
},
/** .food-image 的 :src有效图址优先否则本地占位保证 src 非空,便于各端与 E2E */
foodImageSrc(item, index) {
const resolved = this.getFoodImage(item, index);
const placeholder = this.defaultPlaceholder || '/static/images/food-placeholder.svg';
return (resolved && String(resolved).trim()) ? String(resolved).trim() : placeholder;
},
/**
* 模板 .food-image :src必须走 getFoodImage/foodImageSrc含 coalesce、base、imageErrorIds、占位图
* 禁止仅用 imageUrl/image 短路返回,否则会绕过加载失败后的占位与部分字段映射。
*/
displayFoodImage(item, index) {
try {
return this.foodImageSrc(item, index);
} catch (e) {
return this.defaultPlaceholder || '/static/images/food-placeholder.svg';
}
},
hasFoodImage(item, index) {
const url = this.getFoodImage(item, index);
return !!(url && String(url).trim());
},
/** 须始终非空键,否则 @error 无法写入 imageErrorIds失败图无法回退占位BUG-003 */
imageErrorKey(item, index) {
const i = typeof index === 'number' ? index : 0;
if (!item) {
return 'row:empty@' + i;
}
const id = item.id != null ? item.id : (item.foodId != null ? item.foodId : item.food_id);
if (id != null && String(id).trim() !== '') return 'id:' + String(id) + '@' + i;
const n = item.name != null ? String(item.name).trim() : '';
if (n) return 'name:' + n + '@' + i;
return 'idx:' + i + ':' + (n || 'row');
},
getFoodImage(item, index) {
if (!item) return '';
const errKey = this.imageErrorKey(item, index);
if (errKey && this.imageErrorIds[errKey]) return '';
const raw = this.coalesceFoodImageField(item);
const s = (raw != null && String(raw).trim()) ? String(raw).trim() : ''; const s = (raw != null && String(raw).trim()) ? String(raw).trim() : '';
if (!s || s === 'null' || s === 'undefined') return this.defaultPlaceholder; if (!s || s === 'null' || s === 'undefined') return '';
const url = (s.startsWith('//') || s.startsWith('http')) ? s : (s.startsWith('/') ? (HTTP_REQUEST_URL || '') + s : s); if (s.startsWith('data:')) return s;
return (url && String(url).trim()) ? url : this.defaultPlaceholder; const base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
const url = (s.startsWith('//') || s.startsWith('http')) ? s : (s.startsWith('/') ? base + s : (base ? base + '/' + s : s));
const finalUrl = (url && String(url).trim()) ? url : '';
if (!finalUrl) return '';
if (finalUrl.startsWith('data:') || finalUrl.startsWith('//') || finalUrl.startsWith('http') || finalUrl.startsWith('/')) return finalUrl;
// 无前导斜杠的相对路径:有 base 时已拼成绝对地址;无 base 时仍返回相对路径,由当前站点根路径解析(避免误当成无图)
if (base) {
return base + '/' + finalUrl.replace(/^\//, '');
}
return finalUrl;
},
nutritionListForItem(item) {
// normalizeFoodItem 已将 nutrientsJson + 扁平营养字段合并为 nutrition须优先使用避免 nutrientsJson
// 先被解析出少量无效行而提前 return导致忽略已合并好的 item.nutritionBUG-003 / TC-B03
if (item && Array.isArray(item.nutrition) && item.nutrition.length > 0) {
return item.nutrition;
}
if (item && Array.isArray(item.nutrients) && item.nutrients.length > 0) {
return item.nutrients;
}
if (item && Array.isArray(item.nutritions) && item.nutritions.length > 0) {
return item.nutritions;
}
// 后端 ToolFoodServiceImpl 列表字段为 nutrientsJson字符串或已解析对象用于未走 normalize 的兜底
const njEarly =
item && (item.nutrientsJson != null && item.nutrientsJson !== ''
? item.nutrientsJson
: (item.nutrients_json != null && item.nutrients_json !== '' ? item.nutrients_json : null));
if (njEarly != null) {
const fromNj = this.parseNutrientsFromItem({ ...item, nutrientsJson: njEarly });
if (fromNj && fromNj.length > 0) return fromNj;
}
// 优先使用常见字段nutrition / nutrients / nutritions列表接口可能为 JSON 字符串或对象)
// 注意:空数组 [] 在 JS 中为 truthy若 JSON 解析得到 {} 曾返回 [],会导致 if (n) 误判并吞掉扁平字段回退
const arrOrParse = (v) => {
if (Array.isArray(v) && v.length > 0) return v;
if (v && typeof v === 'object' && !Array.isArray(v)) {
const keys = Object.keys(v);
if (keys.length > 0) {
return keys.slice(0, 12).map((k) => ({
label: k,
value: v[k] != null ? String(v[k]) : '—',
colorClass: 'green'
}));
}
return null;
}
if (typeof v === 'string' && v.trim()) {
try {
const j = JSON.parse(v);
if (Array.isArray(j) && j.length > 0) return j;
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) {
return null;
}
}
return null;
};
const takeNonEmpty = (a) => (Array.isArray(a) && a.length > 0 ? a : null);
const n0 = item && takeNonEmpty(arrOrParse(item.nutrition));
if (n0) return n0;
const n1 = item && takeNonEmpty(arrOrParse(item.nutrients));
if (n1) return n1;
const n2 = item && takeNonEmpty(arrOrParse(item.nutritions));
if (n2) return n2;
const nBrief = item && takeNonEmpty(arrOrParse(item.nutritionBrief != null ? item.nutritionBrief : item.nutrition_brief));
if (nBrief) return nBrief;
const list = this.getNutritionList(item);
const out = Array.isArray(list) ? list : [];
if (out.length > 0) return out;
return [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
},
/**
* 模板 .nutrition-item v-for统一用 nutritionListForItem含 nutrition/nutrients/nutrientsJson/扁平字段),
* 再规范 label/valuename/title/nutrientName 等与列表接口对象形态)。
*/
displayNutritionList(item) {
try {
const list = this.nutritionListForItem(item);
const rows = Array.isArray(list) && list.length > 0 ? list : [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
return rows.map((n) => {
if (!n || typeof n !== 'object') return { label: '—', value: '—', colorClass: 'green' };
const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
const unit = n.unit != null ? String(n.unit) : '';
const valueStr = rawValue != null && rawValue !== '' ? String(rawValue) : '—';
return {
label: n.label || n.name || n.title || n.key || n.nutrientName || n.nutrient_name || n.labelName || n.text || '—',
value: valueStr !== '—' && unit && !valueStr.endsWith(unit) ? (valueStr + unit) : valueStr,
colorClass: n.colorClass || n.color || 'green'
};
});
} catch (e) {
return [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
}
}, },
getNutritionList(item) { getNutritionList(item) {
if (!item) return []; if (!item) return [];
// 优先使用已规范化的 nutrition兼容后端 nutrients / nutritions 数组 // 空数组在 JS 中为 truthy不能用 a || b否则会误用 [] 而跳过 nutrientsJson / 扁平字段
const arr = item.nutrition || item.nutrients || item.nutritions; const pickNutritionArray = () => {
if (Array.isArray(arr) && arr.length > 0) return arr; const keys = [
// 后端列表接口ToolFoodServiceImpl仅返回扁平字段 energy/protein/potassium/phosphorus 等,无数组时从此组装 'nutrition', 'nutrients', 'nutritions',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutritionList', 'nutrientList', 'nutrientItems',
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
'nutritionInfo', 'nutrition_info_json',
'nutritionBrief', 'nutrition_brief',
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
];
for (let i = 0; i < keys.length; i++) {
const v = item[keys[i]];
if (Array.isArray(v) && v.length > 0) return v;
}
return null;
};
let arr = pickNutritionArray();
if (arr == null) {
const keys = [
'nutrition', 'nutrients', 'nutritions',
'nutrientsJson', 'nutrients_json', 'nutritionJson', 'nutrition_json',
'nutritionList', 'nutrientList', 'nutrientItems',
'nutrientVoList', 'nutrient_vo_list', 'nutrition_list', 'nutrition_info',
'nutritionInfo', 'nutrition_info_json',
'nutritionBrief', 'nutrition_brief',
'keyNutrients', 'key_nutrients', 'nutritionFacts', 'nutrition_facts', 'per100gNutrition', 'per_100g_nutrition'
];
for (let i = 0; i < keys.length; i++) {
const v = item[keys[i]];
if (v == null || Array.isArray(v)) continue;
if (typeof v === 'string' && !v.trim()) continue;
arr = v;
break;
}
}
if (typeof arr === 'string' && arr.trim()) {
try {
const j = JSON.parse(arr);
if (Array.isArray(j)) arr = j;
else if (j && typeof j === 'object') arr = j;
else arr = null;
} catch (e) {
arr = null;
}
}
if (arr && typeof arr === 'object' && !Array.isArray(arr)) {
arr = Object.keys(arr).slice(0, 12).map((k) => ({ label: k, value: arr[k], colorClass: 'green' }));
}
if (Array.isArray(arr) && arr.length > 0) {
return arr.map((n) => {
if (!n || typeof n !== 'object') {
return { label: '—', value: '—', colorClass: 'green' };
}
const rawValue = n.value != null ? n.value : (n.amount != null ? n.amount : (n.content != null ? n.content : n.val));
const unit = n.unit != null ? String(n.unit) : '';
const numStr = (rawValue != null && rawValue !== '') ? String(rawValue) : '';
const value = numStr !== '' ? (unit && !numStr.endsWith(unit) ? numStr + unit : numStr) : '—';
return {
label: n.label || n.name || n.title || n.key || n.nutrientName || '—',
value,
colorClass: n.colorClass || n.color || 'green'
};
});
}
const parsed = this.parseNutrientsFromItem(item);
if (parsed && parsed.length > 0) return parsed;
const list = []; const list = [];
const push = (label, val, unit) => { const push = (label, val, unit) => {
const value = (val != null && val !== '') ? String(val) + (unit || '') : '—'; const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
list.push({ label, value, colorClass: 'green' }); list.push({ label, value, colorClass: 'green' });
}; };
push('能量', item.energy, 'kcal'); const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
push('蛋白质', item.protein, 'g'); const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
push('钾', item.potassium, 'mg'); const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
push('磷', item.phosphorus, 'mg'); const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
push('钠', item.sodium, 'mg'); const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
push('钙', item.calcium, 'mg'); const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
return list; push('能量', e, 'kcal');
push('蛋白质', p, 'g');
push('钾', k, 'mg');
push('磷', ph, 'mg');
push('钠', na, 'mg');
push('钙', ca, 'mg');
return list.length > 0 ? list : [{ label: '能量', value: '—', colorClass: 'green' }, { label: '蛋白质', value: '—', colorClass: 'green' }, { label: '钾', value: '—', colorClass: 'green' }];
}, },
onFoodImageError(item) { onFoodImageError(item, index, ev) {
const id = item.id != null ? item.id : item.foodId; try {
if (id != null && !this.imageErrorIds[String(id)]) { const src = ev && ev.target && (ev.target.src || ev.target.currentSrc);
this.imageErrorIds[String(id)] = true; if (src && String(src).indexOf('food-placeholder') !== -1) return;
this.imageErrorIds = { ...this.imageErrorIds }; } catch (e) { /* ignore */ }
const key = this.imageErrorKey(item, index);
if (key && !this.imageErrorIds[key]) {
this.imageErrorIds = { ...this.imageErrorIds, [key]: true };
} }
}, },
normalizeFoodItem(item) { /** 从 nutrition / nutrients / nutritions / nutrientsJson 解析为列表项 */
if (!item) return null; parseNutrientsFromItem(item) {
// 英文分类 key → 中文显示名称 if (!item || typeof item !== 'object') return null;
const mapNut = (n) => {
const u = n.unit != null && String(n.unit).trim() !== '' ? String(n.unit).trim() : '';
let val = '';
if (n.value != null && n.value !== '') {
val = String(n.value);
if (u && !val.endsWith(u)) val += u;
} else if (n.amount != null && n.amount !== '') {
val = String(n.amount) + u;
} else if (n.val != null && n.val !== '') {
val = String(n.val);
if (u && !val.endsWith(u)) val += u;
} else {
val = '—';
}
return {
label: n.label || n.name || n.labelName || n.nutrientName || n.nutrient_name || n.key || n.text || '—',
value: val,
colorClass: n.colorClass || 'green'
};
};
const tryArr = (arr) => {
if (!Array.isArray(arr) || arr.length === 0) return null;
return arr.map(mapNut);
};
const tryJsonString = (s) => {
if (typeof s !== 'string' || !s.trim()) return null;
try {
const j = JSON.parse(s);
if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) { /* ignore */ }
return null;
};
/** 后端 nutrientsJson 可能被反序列化为对象/数组,不能仅按字符串解析 */
const nutrientsJsonValue = (it) => {
if (!it || typeof it !== 'object') return null;
const v = it.nutrientsJson != null ? it.nutrientsJson
: (it.nutrients_json != null ? it.nutrients_json
: (it.nutritionJson != null ? it.nutritionJson : it.nutrition_json));
return v != null && v !== '' ? v : null;
};
const fromNutrientsJson = (nj) => {
if (nj == null || nj === '') return null;
if (Array.isArray(nj) && nj.length > 0) return nj.map(mapNut);
if (typeof nj === 'object' && !Array.isArray(nj)) {
const keys = Object.keys(nj);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: nj[k] != null ? String(nj[k]) : '—',
colorClass: 'green'
}));
}
if (typeof nj === 'string') {
try {
const j = JSON.parse(nj);
if (Array.isArray(j) && j.length > 0) return j.map(mapNut);
if (j && typeof j === 'object' && !Array.isArray(j)) {
const keys = Object.keys(j);
if (keys.length === 0) return null;
return keys.slice(0, 12).map((k) => ({
label: k,
value: j[k] != null ? String(j[k]) : '—',
colorClass: 'green'
}));
}
} catch (e) { /* ignore */ }
}
return null;
};
let out = fromNutrientsJson(nutrientsJsonValue(item));
// 空数组 [] 在 JS 中为 truthy不能用 if (out),否则会跳过 nutrition/nutrients 等后备字段
if (out && out.length > 0) return out;
out = tryArr(item.nutrition);
if (out && out.length > 0) return out;
out = tryArr(item.nutrients);
if (out && out.length > 0) return out;
out = tryArr(item.nutritions);
if (out && out.length > 0) return out;
out = tryJsonString(item.nutrition);
if (out && out.length > 0) return out;
out = tryJsonString(item.nutrients);
if (out && out.length > 0) return out;
return null;
},
normalizeFoodItem(raw) {
const categoryNameMap = { const categoryNameMap = {
grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类', grain: '谷薯类', vegetable: '蔬菜类', fruit: '水果类',
meat: '肉蛋类', seafood: '水产类', dairy: '奶类', meat: '肉蛋类', seafood: '水产类', dairy: '奶类',
bean: '豆类', nut: '坚果类', all: '全部' bean: '豆类', nut: '坚果类', all: '全部'
}; };
let item = this.unwrapFoodListRow(raw);
if (item && typeof item === 'object' && !Array.isArray(item)) {
const nests = [item.data, item.vo, item.record, item.detail].filter(
(x) => x && typeof x === 'object' && !Array.isArray(x)
);
for (let ni = 0; ni < nests.length; ni++) {
item = this.shallowFillEmptyFields(item, nests[ni]);
}
}
if (!item || typeof item !== 'object') {
return {
name: '',
image: '',
imageUrl: '',
nutrition: [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
],
category: '',
safety: '—',
safetyClass: 'safe'
};
}
const safetyMap = { const safetyMap = {
suitable: { safety: '放心吃', safetyClass: 'safe' }, suitable: { safety: '放心吃', safetyClass: 'safe' },
moderate: { safety: '限量吃', safetyClass: 'limited' }, moderate: { safety: '限量吃', safetyClass: 'limited' },
restricted: { safety: '谨慎吃', safetyClass: 'careful' }, restricted: { safety: '谨慎吃', safetyClass: 'careful' },
forbidden: { safety: '谨慎吃', safetyClass: 'careful' } forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
}; };
const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' }); const suitabilityLevel = item.suitabilityLevel != null && item.suitabilityLevel !== ''
? item.suitabilityLevel
: item.suitability_level;
const safety = item.safety != null
? { safety: item.safety, safetyClass: item.safetyClass || 'safe' }
: (safetyMap[suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
// 图片:后端列表返回 image兼容 image_url/imageUrl/img/pic/coverImage/cover_image相对路径补全空或无效则由 getFoodImage 用占位图 const rawStr = this.coalesceFoodImageField(item);
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 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 base = (HTTP_REQUEST_URL && String(HTTP_REQUEST_URL).trim()) ? String(HTTP_REQUEST_URL).replace(/\/$/, '') : '';
const image = imageUrl || ''; let image = '';
if (validRaw) {
// 营养简介:优先 item.nutrition其次 item.nutrients / item.nutritions兼容后端否则由扁平字段 energy/protein/potassium 等组装 if (rawStr.startsWith('data:')) image = rawStr;
let nutrition = item.nutrition; else if (rawStr.startsWith('//') || rawStr.startsWith('http')) image = rawStr;
const mapNut = (n) => ({ else if (rawStr.startsWith('/')) image = base ? base + rawStr : rawStr;
label: n.label || n.name || n.labelName || n.nutrientName || '—', else image = base ? base + '/' + rawStr : rawStr;
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(mapNut);
} else if (Array.isArray(item.nutrients) && item.nutrients.length > 0) {
nutrition = item.nutrients.map(mapNut);
} else if (Array.isArray(item.nutritions) && item.nutritions.length > 0) {
nutrition = item.nutritions.map(mapNut);
} else {
// 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —)
nutrition = [];
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');
push('磷', item.phosphorus, 'mg');
push('钠', item.sodium, 'mg');
push('钙', item.calcium, 'mg');
} }
// 后端详情接口仅接受 Long 类型 id若列表返回的 id 为非数字(如名称),不传 id避免详情页请求 400 let nutrition = this.parseNutrientsFromItem(item);
const rawId = item.id != null ? item.id : item.foodId; if (!nutrition || nutrition.length === 0) {
const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId))) nutrition = [];
? (typeof rawId === 'number' ? rawId : Number(rawId)) const push = (label, val, unit) => {
: undefined; const num = val != null && val !== '' && typeof val === 'object' && 'valueOf' in val ? val.valueOf() : val;
// 保证列表项必有 image/imageUrl空时由 getFoodImage 用 defaultPlaceholder和 nutrition 数组(.nutrition-item 数据来源) const value = (num != null && num !== '') ? String(num) + (unit || '') : '—';
// 将英文分类名映射为中文,若已是中文则保留 nutrition.push({ label, value, colorClass: 'green' });
};
const e = item.energy != null ? item.energy : (item.energyKcal != null ? item.energyKcal : item.energy_kcal);
const p = item.protein != null ? item.protein : (item.proteinG != null ? item.proteinG : item.protein_g);
const k = item.potassium != null ? item.potassium : (item.potassiumMg != null ? item.potassiumMg : item.potassium_mg);
const ph = item.phosphorus != null ? item.phosphorus : (item.phosphorusMg != null ? item.phosphorusMg : item.phosphorus_mg);
const na = item.sodium != null ? item.sodium : (item.sodiumMg != null ? item.sodiumMg : item.sodium_mg);
const ca = item.calcium != null ? item.calcium : (item.calciumMg != null ? item.calciumMg : item.calcium_mg);
push('能量', e, 'kcal');
push('蛋白质', p, 'g');
push('钾', k, 'mg');
push('磷', ph, 'mg');
push('钠', na, 'mg');
push('钙', ca, 'mg');
}
if (!nutrition.length) {
nutrition = [
{ label: '能量', value: '—', colorClass: 'green' },
{ label: '蛋白质', value: '—', colorClass: 'green' },
{ label: '钾', value: '—', colorClass: 'green' }
];
}
// 与 goToFoodDetail 一致:在 id / foodId / food_id 等字段中找第一个纯数字 ID避免 id 被误填为名称时整条链路传错
const pickFirstNumericFoodId = (it) => {
const cands = [it.id, it.foodId, it.food_id, it.v2FoodId, it.v2_food_id, it.foodID];
for (let i = 0; i < cands.length; i++) {
const n = normalizeFoodDetailIdString(cands[i]);
if (n !== '') return parseInt(n, 10);
}
return null;
};
const resolvedId = pickFirstNumericFoodId(item);
const rawCategory = item.category || item.categoryName || item.category_name || ''; const rawCategory = item.category || item.categoryName || item.category_name || '';
const category = categoryNameMap[rawCategory] || rawCategory; const category = categoryNameMap[rawCategory] || rawCategory;
const resolvedName = (item.name != null && String(item.name).trim() !== '')
? String(item.name).trim()
: String(item.foodName || item.title || item.food_name || '').trim();
// 仅当解析出合法数字 ID 时覆盖 id/foodId勿写 null避免抹掉后端其它字段且便于 name-only 跳转走 search 解析
const idFields =
resolvedId !== null && resolvedId !== undefined && !Number.isNaN(resolvedId)
? { id: resolvedId, foodId: resolvedId }
: {};
return { return {
...item, ...item,
id: numericId, ...idFields,
name: resolvedName || item.name || '—',
image: image || '', image: image || '',
imageUrl: image || '', imageUrl: image || '',
img: image || '',
category, category,
safety: safety.safety, safety: safety.safety,
safetyClass: safety.safetyClass, safetyClass: safety.safetyClass,
nutrition: Array.isArray(nutrition) ? nutrition : [] nutrition: Array.isArray(nutrition) ? nutrition : [],
nutrients: Array.isArray(nutrition) ? nutrition : [],
nutritions: Array.isArray(nutrition) ? nutrition : [],
energy: item.energy,
protein: item.protein,
potassium: item.potassium,
phosphorus: item.phosphorus,
sodium: item.sodium,
calcium: item.calcium
}; };
}, },
async selectCategory(category) { async selectCategory(category) {
@@ -416,9 +989,18 @@ export default {
page: 1, page: 1,
limit: 100 limit: 100
}); });
const rawList = this.getRawFoodList(result); let rawList = this.getRawFoodList(result);
if (!Array.isArray(rawList)) rawList = [];
this.imageErrorIds = {}; this.imageErrorIds = {};
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item)).filter(i => i != null); this.foodList = (rawList || []).map((row) => {
let r = row;
if (typeof r === 'string' && r.trim()) {
try {
r = JSON.parse(r);
} catch (e) { /* keep string */ }
}
return this.normalizeFoodItem(r);
}).filter(i => i != null);
} catch (error) { } catch (error) {
console.error('搜索失败:', error); console.error('搜索失败:', error);
} }
@@ -428,13 +1010,27 @@ export default {
}, 300); }, 300);
}, },
goToFoodDetail(item) { goToFoodDetail(item) {
// 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示 // 防御性校验:避免 item 为空时报错fix: TypeError Cannot read property 'id' of undefined
const rawId = item.id != null ? item.id : (item.foodId != null ? item.foodId : '') if (!item || typeof item !== 'object') {
const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : null console.warn('[food-encyclopedia] goToFoodDetail: item 为空,跳过跳转', item)
return
}
// 后端详情接口仅接受 Long 类型 id与 getFoodDetail 共用规范化(支持 123.0 等,禁止名称进路径)
const pickNumericIdStr = () => {
const cands = [item.id, item.foodId, item.food_id, item.v2FoodId, item.v2_food_id, item.foodID]
for (let i = 0; i < cands.length; i++) {
const n = normalizeFoodDetailIdString(cands[i])
if (n !== '') return n
}
return ''
}
const idStr = pickNumericIdStr()
const numericId = idStr !== '' ? parseInt(idStr, 10) : null
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : '' const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
const url = numericId !== null const url = (numericId !== null && typeof numericId === 'number' && !isNaN(numericId))
? `/pages/tool/food-detail?id=${numericId}${namePart}` ? `/pages/tool/food-detail?id=${numericId}${namePart}`
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}` : `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
console.log('[food-encyclopedia] goToFoodDetail 跳转参数:', { idStr, numericId, name: item.name, url })
uni.navigateTo({ url }) uni.navigateTo({ url })
} }
} }
@@ -565,6 +1161,14 @@ export default {
.food-image { .food-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
/* H5 使用原生 img 时与 image mode=aspectFill 一致 */
object-fit: cover;
}
/* 无图或加载失败时的占位(灰色背景,与 wrapper 一致) */
.food-image-placeholder {
background: #e4e5e7;
} }
.warning-badge { .warning-badge {
@@ -579,6 +1183,7 @@ export default {
.food-info { .food-info {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16rpx; gap: 16rpx;