feat: 更新前端多个页面和后端服务

- 前端: 更新AI营养师、计算器、打卡、食物详情等页面
- 前端: 更新食物百科、知识详情、营养知识页面
- 前端: 更新社区首页
- 后端: 更新ToolKieAIServiceImpl服务
- API: 更新models-api.js和user.js
This commit is contained in:
2026-03-07 22:26:37 +08:00
parent 1632801880
commit f692c75f7b
11 changed files with 221 additions and 85 deletions

View File

@@ -390,6 +390,7 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
return emitter; return emitter;
} }
/** BUG-005: 仅使用 request.getMessages() 透传用户/助手消息,不注入硬编码 prompt */
private Map<String, Object> buildGeminiRequestBody(KieAIGeminiChatRequest request, boolean stream) { private Map<String, Object> buildGeminiRequestBody(KieAIGeminiChatRequest request, boolean stream) {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
List<Map<String, Object>> messagesOut = new ArrayList<>(); List<Map<String, Object>> messagesOut = new ArrayList<>();

View File

@@ -332,11 +332,12 @@ function queryAsrStatus(taskId) {
* @returns {Promise} 对话响应 * @returns {Promise} 对话响应
*/ */
/** /**
* KieAI Gemini 2.5 Flash 对话(非流式 * KieAI Gemini 对话POST /api/front/kieai/gemini/chat
* 文本对话请求体: { messages: [{ role: 'user', content: 用户输入 }], stream: false }
* @param {object} data 请求体 * @param {object} data 请求体
* @param {Array} data.messages 消息列表 [{ role: 'user'|'assistant'|'system', content: string }] * @param {Array} data.messages 消息列表 [{ role: 'user'|'assistant'|'system', content: string|Array }]
* @param {boolean} data.stream 是否流式,默认 false * @param {boolean} data.stream 是否流式,默认 false
* @returns {Promise} 响应 data 为 Gemini API 格式 { choices: [{ message: { content } }] } * @returns {Promise} 响应 data 为 Gemini 格式 { choices: [{ message: { content } }] },回复取 data.choices[0].message.content
*/ */
function kieaiGeminiChat(data) { function kieaiGeminiChat(data) {
return request('/api/front/kieai/gemini/chat', { return request('/api/front/kieai/gemini/chat', {

View File

@@ -19,6 +19,13 @@ export function getUserInfo(){
return request.get('user'); return request.get('user');
} }
/**
* 获取用户信息GET /api/front/user/info用于打卡后刷新积分等
*/
export function getFrontUserInfo(){
return request.get('user/info');
}
/** /**
* 设置用户分享 * 设置用户分享
* *

View File

@@ -610,21 +610,24 @@ export default {
this.scrollToBottom(); this.scrollToBottom();
}, },
/** 从 Gemini 响应 message.content 提取展示文本(支持 string parts 数组) */ /** 从 Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } 对象 */
extractReplyContent(content) { extractReplyContent(content) {
if (content == null) return ''; if (content == null) return '';
if (typeof content === 'string') return content; if (typeof content === 'string') return content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join(''); 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('');
}
return String(content); return String(content);
}, },
async sendToAI(content, type) { async sendToAI(content, type) {
this.isLoading = true; this.isLoading = true;
// BUG-005文本/多模态必须走 KieAI Gemini,回复仅从 data.choices[0].message.content 取,不得使用固定话术 // BUG-005文本/多模态必须走 KieAI Gemini;请求体 { messages: [{ role: 'user', content: 用户输入 }], stream: false }
// 请求体: { messages: [{ role: 'user', content }], stream: false } // 回复仅从 data.choices[0].message.content 取,禁止使用 getAIResponse 等固定话术
if (type === 'text' || type === 'multimodal') { if (type === 'text' || type === 'multimodal') {
try { try {
const messages = [{ role: 'user', content: content }]; const messages = [{ role: 'user', content: content }];
@@ -634,10 +637,10 @@ export default {
const data = response.data; const data = response.data;
const choice = data.choices && data.choices[0]; const choice = data.choices && data.choices[0];
const msgObj = choice && choice.message; const msgObj = choice && choice.message;
const rawContent = msgObj && msgObj.content; const rawContent = msgObj != null ? msgObj.content : undefined;
const reply = rawContent != null ? this.extractReplyContent(rawContent) : ''; const reply = rawContent != null && rawContent !== undefined ? this.extractReplyContent(rawContent) : '';
// 成功时仅展示模型返回内容 // BUG-005: 仅展示接口返回的 data.choices[0].message.content不使用固定话术
this.messageList.push({ role: 'ai', content: reply || '未能获取到有效回复。' }); this.messageList.push({ role: 'ai', content: reply.trim() || '未能获取到有效回复。' });
} else { } else {
const msg = (response && response.message) || '发起对话失败'; const msg = (response && response.message) || '发起对话失败';
this.messageList.push({ role: 'ai', content: '请求失败:' + msg }); this.messageList.push({ role: 'ai', content: '请求失败:' + msg });

View File

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

View File

@@ -241,17 +241,20 @@ export default {
return; return;
} }
try { try {
const { setSignIntegral, getUserInfo } = await import('@/api/user.js'); const { setSignIntegral, getFrontUserInfo, getUserInfo } = await import('@/api/user.js');
const { getUserPoints } = await import('@/api/tool.js'); const { getUserPoints } = await import('@/api/tool.js');
// 子问题 A不得在 API 返回成功前修改 currentPoints避免打卡前积分提前跳变 // 子问题 A不得在 API 返回成功前修改 currentPoints避免打卡前积分提前跳变
// 子问题 B-1打卡接口GET /api/front/user/sign/integralsetSignIntegral与 /api/front/user/checkin 同属签到类接口 // 打卡接口GET /api/front/user/sign/integralsetSignIntegral等同于 checkin 签到
await setSignIntegral(); await setSignIntegral();
this.todaySigned = true; // 子问题 B仅在打卡成功后用服务端数据更新积分。先 GET /api/front/user/info 刷新用户积分,禁止硬编码 +30
let userRes = null;
// 子问题 B-2/B-3仅用服务端返回值更新积分禁止前端本地 +30。调用 GET /api/front/usergetUserInfo刷新用户信息将返回的 integral 赋给 currentPoints try {
const userRes = await getUserInfo(); 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)) { if (userRes && userRes.data && (userRes.data.integral != null || userRes.data.points != null)) {
this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0; this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0;
} else { } else {
@@ -261,6 +264,9 @@ export default {
this.currentPoints = serverPoints; this.currentPoints = serverPoints;
} }
} }
// 积分已从服务端更新后再更新打卡状态并跳转,避免“已打卡”与积分不同步
this.todaySigned = true;
} catch (e) { } catch (e) {
const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败'; const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败';
if (msg.includes('今日已签到') || msg.includes('不可重复')) { if (msg.includes('今日已签到') || msg.includes('不可重复')) {

View File

@@ -14,13 +14,13 @@
<scroll-view class="content-scroll" scroll-y> <scroll-view class="content-scroll" scroll-y>
<!-- 食物大图 --> <!-- 食物大图 -->
<view class="food-image-section"> <view class="food-image-section">
<image class="food-image" :src="foodData.image || defaultFoodData.image" mode="aspectFill"></image> <image class="food-image" :src="displayImage" mode="aspectFill"></image>
<view class="image-overlay"></view> <view class="image-overlay"></view>
<view class="food-info-overlay"> <view class="food-info-overlay">
<view class="food-name-overlay">{{ foodData.name || '—' }}</view> <view class="food-name-overlay">{{ displayName }}</view>
<view class="food-tags"> <view class="food-tags">
<view class="food-tag category">{{ foodData.category || '—' }}</view> <view class="food-tag category">{{ displayCategory }}</view>
<view class="food-tag safe">{{ foodData.safetyTag || '—' }}</view> <view class="food-tag safe">{{ displaySafetyTag }}</view>
</view> </view>
</view> </view>
</view> </view>
@@ -34,7 +34,7 @@
<view class="key-nutrients-grid"> <view class="key-nutrients-grid">
<view <view
class="nutrient-card" class="nutrient-card"
v-for="(nutrient, index) in foodData.keyNutrients" v-for="(nutrient, index) in displayKeyNutrients"
:key="index" :key="index"
> >
<text class="nutrient-name">{{ nutrient.name }}</text> <text class="nutrient-name">{{ nutrient.name }}</text>
@@ -56,7 +56,7 @@
<view class="nutrition-table"> <view class="nutrition-table">
<view <view
class="nutrition-row" class="nutrition-row"
v-for="(item, index) in foodData.nutritionTable" v-for="(item, index) in displayNutritionTable"
:key="index" :key="index"
> >
<view class="nutrition-left"> <view class="nutrition-left">
@@ -132,21 +132,58 @@ export default {
} }
} }
}, },
computed: {
// 保证 .food-name-overlay / .nutrient-card / .nutrition-row 在 defaultFoodData 状态下也有非空数据可渲染
displayName() {
return (this.foodData && this.foodData.name) ? this.foodData.name : (this.defaultFoodData.name || '—')
},
displayCategory() {
return (this.foodData && this.foodData.category) ? this.foodData.category : (this.defaultFoodData.category || '—')
},
displaySafetyTag() {
return (this.foodData && this.foodData.safetyTag) ? this.foodData.safetyTag : (this.defaultFoodData.safetyTag || '—')
},
displayImage() {
return (this.foodData && this.foodData.image) ? this.foodData.image : (this.defaultFoodData.image || '')
},
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: '—' }]
},
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: '—' }]
}
},
onLoad(options) { onLoad(options) {
this.pageParams = { id: options.id || '', name: options.name || '' } options = options || {}
// 若列表误将 name 传成 id如 id=羊肉(熟)),用 id 作为展示用 name避免请求接口 400
const rawId = options.id
const rawName = options.name
const hasNonNumericId = rawId !== undefined && rawId !== '' && isNaN(Number(rawId))
const displayName = rawName || (hasNonNumericId ? rawId : '')
this.pageParams = {
id: (rawId !== undefined && rawId !== '') ? String(rawId) : '',
name: (displayName !== undefined && displayName !== '') ? String(displayName) : ''
}
// 打印入参便于排查:后端详情接口仅接受 Long 类型 id传 name 会导致 400 // 打印入参便于排查:后端详情接口仅接受 Long 类型 id传 name 会导致 400
console.log('[food-detail] onLoad options:', options) console.log('[food-detail] onLoad options:', options)
console.log('[food-detail] onLoad pageParams (id/name):', this.pageParams) console.log('[food-detail] onLoad pageParams (id/name):', this.pageParams)
const rawId = options.id
const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId)) const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId))
if (isNumericId) { if (isNumericId) {
this.loadFoodData(Number(rawId)) const numId = Number(rawId)
} else if (options.name) { console.log('[food-detail] 使用数字 id 请求详情,不传 name 字段:', numId)
// 无有效 id 仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称 this.loadFoodData(numId)
} else if (this.pageParams.name) {
// 无有效 id、仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称
this.loadError = '暂无该食物详情数据,展示参考数据' this.loadError = '暂无该食物详情数据,展示参考数据'
this.applyDefaultFoodData(false) this.applyDefaultFoodData(false)
try { try {
this.foodData.name = decodeURIComponent(String(options.name)) this.foodData.name = decodeURIComponent(this.pageParams.name)
} catch (e) {} } catch (e) {}
} else { } else {
this.applyDefaultFoodData() this.applyDefaultFoodData()
@@ -192,8 +229,10 @@ export default {
console.log('[food-detail] getFoodDetail request param:', { id, type: typeof id }) console.log('[food-detail] getFoodDetail request param:', { id, type: typeof id })
try { try {
const res = await getFoodDetail(id) const res = await getFoodDetail(id)
const data = res.data || res // 打印响应结构便于确认request 成功时 resolve 的是 res.data即 { code: 200, data: {...} }
console.log('[food-detail] getFoodDetail 响应:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null) console.log('[food-detail] getFoodDetail 响应结构:', res ? { hasData: !!res.data, code: res.code, keys: Object.keys(res || {}) } : null)
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) { if (data && data.name) {
// 解析API返回的食物数据 // 解析API返回的食物数据
this.foodData = { this.foodData = {
@@ -219,12 +258,14 @@ export default {
uni.showToast({ title: '数据加载失败', icon: 'none' }) uni.showToast({ title: '数据加载失败', icon: 'none' })
} }
} catch (error) { } catch (error) {
const errMsg = (error && (error.message || error.msg || error.errMsg || error)) ? String(error.message || error.msg || error.errMsg || error) : '未知错误' // a. 将 loadError 置为具体错误信息(用于调试):兼容 Error、{ message/msg }、字符串
const errMsg = (error && (error.message || error.msg || error.errMsg))
? String(error.message || error.msg || error.errMsg)
: (typeof error === 'string' ? error : (error ? String(error) : '未知错误'))
console.error('[food-detail] 加载食物数据失败:', error) console.error('[food-detail] 加载食物数据失败:', error)
console.error('[food-detail] loadError(用于调试):', errMsg) console.error('[food-detail] loadError(用于调试):', errMsg)
// a. 将 loadError 置为具体错误信息(用于调试)
this.loadError = errMsg this.loadError = errMsg
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示 // b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面而不是空白
this.applyDefaultFoodData(false) this.applyDefaultFoodData(false)
// 若有入参 name用其覆盖展示名称避免显示默认「五谷香」 // 若有入参 name用其覆盖展示名称避免显示默认「五谷香」
if (this.pageParams && this.pageParams.name) { if (this.pageParams && this.pageParams.name) {
@@ -232,7 +273,7 @@ export default {
this.foodData.name = decodeURIComponent(String(this.pageParams.name)) this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {} } catch (e) {}
} }
// c. 页面通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示 // c. 页面通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示
uni.showToast({ uni.showToast({
title: '数据加载失败', title: '数据加载失败',
icon: 'none' icon: 'none'

View File

@@ -105,7 +105,7 @@
<view class="food-image-wrapper"> <view class="food-image-wrapper">
<image <image
class="food-image" class="food-image"
:src="getFoodImage(item) || defaultPlaceholder" :src="getFoodImage(item)"
mode="aspectFill" mode="aspectFill"
@error="onFoodImageError(item)" @error="onFoodImageError(item)"
></image> ></image>
@@ -126,7 +126,7 @@
<view class="nutrition-list"> <view class="nutrition-list">
<view <view
class="nutrition-item" class="nutrition-item"
v-for="(nut, idx) in (item.nutrition || item.nutrients || [])" v-for="(nut, idx) in getNutritionList(item)"
:key="idx" :key="idx"
> >
<text class="nutrition-label">{{ nut.label || '—' }}</text> <text class="nutrition-label">{{ nut.label || '—' }}</text>
@@ -278,12 +278,34 @@ export default {
return []; return [];
}, },
getFoodImage(item) { getFoodImage(item) {
if (!item) return this.defaultPlaceholder;
const id = item.id != null ? item.id : item.foodId; const id = item.id != null ? item.id : item.foodId;
if (id != null && this.imageErrorIds[String(id)]) return this.defaultPlaceholder; if (id != null && this.imageErrorIds[String(id)]) return this.defaultPlaceholder;
const raw = item.imageUrl || item.image || item.img || item.pic || item.coverImage || ''; // 兼容后端 image / image_url / 前端 imageUrl、img、pic、coverImage、cover_image
const url = raw && (raw.startsWith('//') || raw.startsWith('http')) ? raw : (raw && raw.startsWith('/') ? (HTTP_REQUEST_URL || '') + raw : raw); const raw = item.imageUrl || item.image || item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '';
const s = (raw && 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; return (url && String(url).trim()) ? url : this.defaultPlaceholder;
}, },
getNutritionList(item) {
if (!item) return [];
const arr = item.nutrition || item.nutrients || item.nutritions;
if (Array.isArray(arr) && arr.length > 0) return arr;
// 无数组时从扁平字段组装,确保列表始终有营养简介
const list = [];
const push = (label, val, unit) => {
const value = (val != null && val !== '') ? String(val) + (unit || '') : '—';
list.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');
return list;
},
onFoodImageError(item) { onFoodImageError(item) {
const id = item.id != null ? item.id : item.foodId; const id = item.id != null ? item.id : item.foodId;
if (id != null && !this.imageErrorIds[String(id)]) { if (id != null && !this.imageErrorIds[String(id)]) {
@@ -300,9 +322,11 @@ export default {
}; };
const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' }); const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
// 图片:兼容 image/imageUrl/img/pic/coverImage,相对路径补全为完整 URL空则留空由 getFoodImage 用占位图 // 图片:兼容 image/image_url/imageUrl/img/pic/coverImage/cover_image相对路径补全空或无效则由 getFoodImage 用占位图
const rawImg = item.imageUrl || item.image || item.img || item.pic || item.coverImage || ''; const rawImg = item.imageUrl || item.image || item.image_url || item.img || item.pic || item.coverImage || item.cover_image || '';
const imageUrl = (rawImg && (rawImg.startsWith('//') || rawImg.startsWith('http'))) ? rawImg : (rawImg && rawImg.startsWith('/') ? (HTTP_REQUEST_URL || '') + rawImg : rawImg); const rawStr = (rawImg && 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 || ''; const image = imageUrl || '';
// 营养简介:优先 item.nutrition其次 item.nutrients兼容后端否则由扁平字段 energy/protein/potassium 等组装 // 营养简介:优先 item.nutrition其次 item.nutrients兼容后端否则由扁平字段 energy/protein/potassium 等组装
@@ -386,7 +410,7 @@ export default {
}, },
goToFoodDetail(item) { goToFoodDetail(item) {
// 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示 // 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示
const rawId = item.id != null ? item.id : '' const rawId = item.id != null ? item.id : (item.foodId != null ? item.foodId : '')
const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : null const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : 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

View File

@@ -25,6 +25,9 @@
<view v-else-if="error" class="error-wrap"> <view v-else-if="error" class="error-wrap">
<text>{{ error }}</text> <text>{{ error }}</text>
</view> </view>
<view v-else class="empty-wrap">
<text>暂无内容</text>
</view>
</view> </view>
</template> </template>
@@ -50,8 +53,9 @@ export default {
}; };
}, },
onLoad(options) { onLoad(options) {
if (options.id) { const rawId = options.id;
this.id = options.id; if (rawId != null && rawId !== '' && String(rawId).trim() !== '' && String(rawId) !== 'undefined') {
this.id = String(rawId).trim();
} else { } else {
this.loading = false; this.loading = false;
this.error = '缺少文章 ID'; this.error = '缺少文章 ID';
@@ -144,7 +148,8 @@ export default {
color: #333; color: #333;
} }
.loading-wrap, .loading-wrap,
.error-wrap { .error-wrap,
.empty-wrap {
padding: 80rpx 30rpx; padding: 80rpx 30rpx;
text-align: center; text-align: center;
color: #999; color: #999;
@@ -153,4 +158,7 @@ export default {
.error-wrap { .error-wrap {
color: #f56c6c; color: #f56c6c;
} }
.empty-wrap {
color: #9fa5c0;
}
</style> </style>

View File

@@ -128,7 +128,7 @@ export default {
data() { data() {
return { return {
currentTab: 'nutrients', currentTab: 'nutrients',
scrollViewHeight: 'calc(100vh - 120rpx)', // 为 tab 预留高度,微信小程序 scroll-view 明确高度 scrollViewHeight: '100vh', // 默认值onReady 中按机型计算为 px保证微信小程序 scroll-view 明确高度
guideList: [], guideList: [],
articleList: [], articleList: [],
nutrientList: [ nutrientList: [
@@ -188,8 +188,25 @@ export default {
// 有 id 时切换到科普文章 tabswitchTab 内会调用 loadKnowledgeList 加载列表 // 有 id 时切换到科普文章 tabswitchTab 内会调用 loadKnowledgeList 加载列表
this.switchTab('articles'); this.switchTab('articles');
} else { } else {
// 无 id 时默认当前 tab 为「营养素」,不请求接口;用户点击「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList // 无 id 时默认当前 tab 为「营养素」;切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
this.currentTab = 'nutrients'; this.currentTab = 'nutrients';
// 确保列表初始为数组,避免未加载时为 undefined切换 Tab 后再加载对应列表
this.guideList = Array.isArray(this.guideList) ? this.guideList : [];
this.articleList = Array.isArray(this.articleList) ? this.articleList : [];
}
},
onReady() {
// 微信小程序 scroll-view 必须使用明确高度,且 calc(vh - rpx) 兼容性差,改为用 px 计算
try {
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = systemInfo.statusBarHeight || 0;
const navHeight = 44;
const tabBarRpx = 120;
const windowWidth = systemInfo.windowWidth || 375;
const tabBarPx = Math.ceil((windowWidth / 750) * tabBarRpx);
this.scrollViewHeight = `calc(100vh - ${statusBarHeight + navHeight + tabBarPx}px)`;
} catch (e) {
this.scrollViewHeight = 'calc(100vh - 140px)';
} }
}, },
methods: { methods: {
@@ -216,16 +233,21 @@ export default {
page: 1, page: 1,
limit: 50 limit: 50
}); });
// 兼容 CommonPageresult.data.list,或 result.data/result.data.records 为数组 // 兼容多种返回结构result.data.list / result.data.records / result.data 为数组 / result.list
let rawList = []; let rawList = [];
if (result && result.data) { if (result && result.data) {
if (Array.isArray(result.data.list)) { const d = result.data;
rawList = result.data.list; if (Array.isArray(d.list)) {
} else if (Array.isArray(result.data.records)) { rawList = d.list;
rawList = result.data.records; } else if (Array.isArray(d.records)) {
} else if (Array.isArray(result.data)) { rawList = d.records;
rawList = result.data; } else if (Array.isArray(d)) {
rawList = d;
} else if (d && Array.isArray(d.data)) {
rawList = d.data;
} }
} else if (result && Array.isArray(result.list)) {
rawList = result.list;
} }
// Normalize id: backend may return knowledgeId, id, or knowledge_id (BeanUtil/JSON) // Normalize id: backend may return knowledgeId, id, or knowledge_id (BeanUtil/JSON)
const list = (rawList || []).map(item => { const list = (rawList || []).map(item => {
@@ -233,6 +255,7 @@ export default {
return { return {
...item, ...item,
id, id,
knowledgeId: item.knowledgeId ?? id,
desc: item.desc || item.summary || '', desc: item.desc || item.summary || '',
time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''), time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''),
views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0), views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0),
@@ -240,19 +263,20 @@ export default {
coverImage: item.coverImage || item.cover_image || '' coverImage: item.coverImage || item.cover_image || ''
}; };
}); });
// 始终赋值为数组,绝不设为 undefined
if (this.currentTab === 'guide') { if (this.currentTab === 'guide') {
this.guideList = list; this.guideList = Array.isArray(list) ? list : [];
} else if (this.currentTab === 'articles') { } else if (this.currentTab === 'articles') {
this.articleList = list; this.articleList = Array.isArray(list) ? list : [];
} }
} catch (error) { } catch (error) {
console.error('加载知识列表失败:', error); console.error('加载知识列表失败:', error);
const msg = (error && (typeof error === 'string' ? error : (error.message || error.msg))) || '加载列表失败'; const msg = (error && (typeof error === 'string' ? error : (error.message || error.msg))) || '加载列表失败';
uni.showToast({ uni.showToast({
title: String(msg), title: String(msg).substring(0, 20),
icon: 'none' icon: 'none'
}); });
// 失败时清空当前 tab 列表确保始终为数组,不设为 undefined // 失败时清空当前 tab 列表确保始终为数组,不设为 undefined
if (this.currentTab === 'guide') { if (this.currentTab === 'guide') {
this.guideList = []; this.guideList = [];
} else if (this.currentTab === 'articles') { } else if (this.currentTab === 'articles') {
@@ -275,13 +299,19 @@ export default {
goToDetail(event) { goToDetail(event) {
const id = event.currentTarget.dataset.itemId; const id = event.currentTarget.dataset.itemId;
const knowledgeId = event.currentTarget.dataset.itemKid; const knowledgeId = event.currentTarget.dataset.itemKid;
const finalId = knowledgeId || id; const finalId = knowledgeId ?? id;
if (!finalId) { // 仅当 knowledgeId 或 id 存在且有效时才跳转,否则提示暂无详情
uni.showToast({ title: "暂无详情", icon: "none" }); 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; return;
} }
uni.navigateTo({ uni.navigateTo({
url: `/pages/tool/knowledge-detail?id=${finalId}` url: `/pages/tool/knowledge-detail?id=${encodeURIComponent(idStr)}`
}); });
} }
} }

View File

@@ -4,32 +4,32 @@
<view class="tab-nav"> <view class="tab-nav">
<view <view
class="tab-item" class="tab-item"
:class="{ active: currentTab === 'recommend' }" :class="{ active: currentTab === '推荐' }"
@click="switchTab('recommend')" @click="switchTab('推荐')"
> >
<!-- <text class="tab-icon">📊</text> --> <!-- <text class="tab-icon">📊</text> -->
<text class="tab-text">推荐</text> <text class="tab-text">推荐</text>
</view> </view>
<view <view
class="tab-item" class="tab-item"
:class="{ active: currentTab === 'latest' }" :class="{ active: currentTab === '最新' }"
@click="switchTab('latest')" @click="switchTab('最新')"
> >
<!-- <text class="tab-icon">🕐</text> --> <!-- <text class="tab-icon">🕐</text> -->
<text class="tab-text">最新</text> <text class="tab-text">最新</text>
</view> </view>
<view <view
class="tab-item" class="tab-item"
:class="{ active: currentTab === 'follow' }" :class="{ active: currentTab === '关注' }"
@click="switchTab('follow')" @click="switchTab('关注')"
> >
<!-- <text class="tab-icon">👥</text> --> <!-- <text class="tab-icon">👥</text> -->
<text class="tab-text">关注</text> <text class="tab-text">关注</text>
</view> </view>
<view <view
class="tab-item" class="tab-item"
:class="{ active: currentTab === 'hot' }" :class="{ active: currentTab === '热门' }"
@click="switchTab('hot')" @click="switchTab('热门')"
> >
<!-- <text class="tab-icon">🔥</text> --> <!-- <text class="tab-icon">🔥</text> -->
<text class="tab-text">热门</text> <text class="tab-text">热门</text>
@@ -46,7 +46,7 @@
<view class="empty-container" v-else-if="isEmpty"> <view class="empty-container" v-else-if="isEmpty">
<text class="empty-icon">📭</text> <text class="empty-icon">📭</text>
<text class="empty-text">暂无内容</text> <text class="empty-text">暂无内容</text>
<text class="empty-hint" v-if="currentTab === 'follow'">关注更多用户查看他们的打卡动态</text> <text class="empty-hint" v-if="currentTab === '关注'">关注更多用户查看他们的打卡动态</text>
<text class="empty-hint" v-else>快来发布第一条打卡动态吧</text> <text class="empty-hint" v-else>快来发布第一条打卡动态吧</text>
</view> </view>
@@ -144,7 +144,7 @@ import { checkLogin, toLogin } from '@/libs/login.js'
export default { export default {
data() { data() {
return { return {
currentTab: 'recommend', currentTab: '推荐',
postList: [], postList: [],
page: 1, page: 1,
limit: 10, limit: 10,
@@ -173,12 +173,18 @@ export default {
this.loadMore() this.loadMore()
}, },
methods: { methods: {
// Tab 中文转接口参数(不改动接口逻辑,仅在此处映射)
getTabApiValue() {
const map = { '推荐': 'recommend', '最新': 'latest', '关注': 'follow', '热门': 'hot' }
return map[this.currentTab] || 'recommend'
},
// 切换Tab // 切换Tab
switchTab(tab) { switchTab(tab) {
if (this.currentTab === tab) return if (this.currentTab === tab) return
// 关注Tab需要登录 // 关注Tab需要登录
if (tab === 'follow' && !checkLogin()) { if (tab === '关注' && !checkLogin()) {
uni.showToast({ uni.showToast({
title: '请先登录查看关注内容', title: '请先登录查看关注内容',
icon: 'none' icon: 'none'
@@ -214,7 +220,7 @@ export default {
try { try {
const res = await getCommunityList({ const res = await getCommunityList({
tab: this.currentTab, tab: this.getTabApiValue(),
page: this.page, page: this.page,
limit: this.limit limit: this.limit
}) })
@@ -385,7 +391,7 @@ export default {
try { try {
const res = await getCommunityList({ const res = await getCommunityList({
tab: this.currentTab, tab: this.getTabApiValue(),
page: this.page, page: this.page,
limit: this.limit limit: this.limit
}) })
@@ -415,18 +421,18 @@ export default {
}) })
}, },
// 格式化数量显示 // 格式化数量显示(中文单位)
formatCount(count) { formatCount(count) {
if (!count) return '0' if (!count) return '0'
if (count >= 10000) { if (count >= 10000) {
return (count / 10000).toFixed(1) + 'w' return (count / 10000).toFixed(1) + ''
} else if (count >= 1000) { } else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k' return (count / 1000).toFixed(1) + ''
} }
return String(count) return String(count)
}, },
// 帖子类型英文转中文显示(仅用于展示,保证 label 均为中文) // 帖子类型英文/拼音转中文显示(仅用于展示,保证 label 均为中文)
getMealTypeLabel(mealType) { getMealTypeLabel(mealType) {
if (!mealType) return '分享' if (!mealType) return '分享'
const map = { const map = {
@@ -435,7 +441,16 @@ export default {
dinner: '晚餐', dinner: '晚餐',
snack: '加餐', snack: '加餐',
share: '分享', share: '分享',
checkin: '打卡' checkin: '打卡',
zaocan: '早餐',
wucan: '午餐',
wancan: '晚餐',
jiacan: '加餐',
fenxiang: '分享',
daka: '打卡',
morning: '早餐',
noon: '午餐',
night: '晚餐'
} }
const str = String(mealType).trim() const str = String(mealType).trim()
const lower = str.toLowerCase() const lower = str.toLowerCase()