feat: 集成 KieAI 服务,移除 models-integration 子项目

- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
This commit is contained in:
2026-03-03 15:33:50 +08:00
parent 1ddb051977
commit 4be53dcd1b
586 changed files with 21142 additions and 25130 deletions

View File

@@ -92,7 +92,8 @@
if (!/^[a-zA-Z]\w{5,17}$/i.test(that.password)) return that.$util.Tips({
title: '密码格式错误密码必须以字母开头长度在618之间只能包含字符、数字和下划线'
});
this.$refs.verify.show();
// this.$refs.verify.show();
this.code();
},
//滑块验证成功后
handlerOnVerSuccess(data) {

View File

@@ -613,7 +613,7 @@ export default {
async sendToAI(content, type) {
this.isLoading = true;
// 纯文字 多模态(图+文) 均走 KieAI Gemini,一次请求
// 纯文字 / 多模态均走 KieAI GeminiPOST /api/front/kieai/gemini/chat回复仅来自 data.choices[0].message.content
if (type === 'text' || type === 'multimodal') {
try {
const messages = [{ role: 'user', content: content }];
@@ -622,8 +622,11 @@ export default {
if (response && response.code === 200 && response.data) {
const data = response.data;
const choice = data.choices && data.choices[0];
const text = choice && choice.message && (choice.message.content || choice.message.text);
const reply = (typeof text === 'string' ? text : (text && String(text))) || '未能获取到有效回复。';
const msgObj = choice && choice.message;
// 仅使用接口返回的 content禁止固定话术
const reply = (msgObj && msgObj.content != null)
? (typeof msgObj.content === 'string' ? msgObj.content : String(msgObj.content))
: '未能获取到有效回复。';
this.messageList.push({ role: 'ai', content: reply });
} else {
const msg = (response && response.message) || '发起对话失败';
@@ -804,25 +807,6 @@ export default {
}
},
getAIResponse(question) {
// 这里可以根据问题返回不同的回复
// 实际应该调用AI API
const responses = {
'香蕉': '香蕉含钾量较高每100g约330mg对于需要控制钾摄入的透析患者来说需要谨慎食用。\n\n建议\n• 如果血钾控制良好,可以少量食用(如半根)\n• 透析后食用更安全\n• 建议咨询您的主治医生确认\n\n您最近的血钾指标如何呢',
'苹果': '苹果是相对安全的水果选择含钾量中等。建议适量食用每天1-2个即可。',
'蛋白质': '对于肾病患者蛋白质的摄入需要根据CKD分期和透析情况来调整。建议咨询专业营养师制定个性化方案。'
}
// 简单的关键词匹配
for (let key in responses) {
if (question.includes(key)) {
return responses[key]
}
}
// 默认回复
return `感谢您的提问。关于"${question}",我建议您:\n\n• 咨询您的主治医生获取专业建议\n• 根据您的CKD分期和透析情况调整饮食\n• 定期监测相关指标\n\n如需更详细的指导可以联系专业营养师。`
},
scrollToBottom() {
this.$nextTick(() => {
// 动态切换 scrollTop 值以触发滚动更新

View File

@@ -480,29 +480,40 @@ export default {
.tab-item {
flex: 1;
height: 75rpx;
border-radius: 50rpx;
height: 100%;
min-height: 75rpx;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
transition: all 0.3s;
border-bottom: 3px solid transparent;
box-sizing: border-box;
color: #9ca3af;
.tab-icon {
font-size: 28rpx;
color: #9ca3af;
}
.tab-text {
font-size: 28rpx;
color: #9fa5c0;
color: #9ca3af;
}
&.active {
background: #ff6b35;
background: transparent;
border-bottom: 3px solid #f97316;
color: #f97316;
font-weight: 700;
.tab-text {
color: #ffffff;
font-weight: 500;
color: #f97316;
font-weight: 700;
}
.tab-icon {
color: #f97316;
}
}
}

View File

@@ -36,9 +36,13 @@
</view>
</view>
<!-- 立即打卡按钮 -->
<view class="checkin-btn" @click="handleCheckin">
<text>立即打卡</text>
<!-- 立即打卡按钮今日已打卡则显示灰色不可点 -->
<view
class="checkin-btn"
:class="{ 'checkin-btn-disabled': todaySigned }"
@click="handleCheckin"
>
<text>{{ todaySigned ? '今日已打卡' : '立即打卡' }}</text>
</view>
</view>
@@ -108,6 +112,7 @@
export default {
data() {
return {
todaySigned: false,
currentPoints: 522,
streakDays: [
{ day: 1, completed: false, special: false },
@@ -170,6 +175,11 @@ export default {
}
},
onLoad() {
const { checkLogin, toLogin } = require('@/libs/login.js');
if (!checkLogin()) {
toLogin();
return;
}
this.loadCheckinData();
},
onShow() {
@@ -180,31 +190,32 @@ export default {
async loadCheckinData() {
try {
const { getUserPoints, getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js');
// 并行加载数据
const [pointsRes, streakRes, tasksRes] = await Promise.all([
const { getSignGet } = await import('@/api/user.js');
const [pointsRes, streakRes, tasksRes, signRes] = await Promise.all([
getUserPoints().catch(() => ({ data: { points: 0 } })),
getCheckinStreak().catch(() => ({ data: { streakDays: 7, currentStreak: 0 } })),
getCheckinTasks().catch(() => ({ data: { tasks: [] } }))
getCheckinTasks().catch(() => ({ data: { tasks: [] } })),
getSignGet().catch(() => ({ data: { today: false } }))
]);
// 更新积分
if (pointsRes.data) {
this.currentPoints = pointsRes.data.points || 0;
if (signRes && signRes.data && signRes.data.today) {
this.todaySigned = true;
}
// 更新连续打卡天数
if (pointsRes.data) {
this.currentPoints = pointsRes.data.totalPoints ?? pointsRes.data.points ?? 0;
}
if (streakRes.data) {
const streak = streakRes.data.currentStreak || 0;
// 生成连续打卡天数数组
this.streakDays = Array.from({ length: 7 }, (_, i) => ({
day: i + 1,
completed: i < streak,
special: i === 6 // 第7天特殊标记
special: i === 6
}));
}
// 更新任务列表
if (tasksRes.data && tasksRes.data.tasks && tasksRes.data.tasks.length > 0) {
this.taskList = tasksRes.data.tasks.map(task => ({
id: task.id || task.taskId,
@@ -224,16 +235,39 @@ export default {
url: '/pages/tool/points-rules'
})
},
handleCheckin() {
async handleCheckin() {
if (this.todaySigned) {
uni.showToast({ title: '今日已打卡', icon: 'none' });
return;
}
try {
const { setSignIntegral, getUserInfo } = await import('@/api/user.js');
const { getUserPoints } = await import('@/api/tool.js');
// 子问题 A不在 API 成功前修改 currentPoints避免积分提前跳变
await setSignIntegral();
this.todaySigned = true;
// 子问题 B打卡成功后用服务端最新积分刷新优先 GET /api/front/user/info禁止前端本地 +30
const userRes = await getUserInfo();
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;
}
}
} catch (e) {
const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败';
if (msg.includes('今日已签到') || msg.includes('不可重复')) {
this.todaySigned = true;
}
uni.showToast({ title: msg, icon: 'none' });
return;
}
uni.navigateTo({
url: '/pages/tool/checkin-publish'
})
// 更新打卡状态
const todayIndex = this.getTodayIndex()
if (todayIndex >= 0 && todayIndex < this.streakDays.length) {
// this.streakDays[todayIndex].completed = true
this.currentPoints += 30 // 每日打卡获得30积分
}
});
},
getTodayIndex() {
// 根据连续打卡数据计算当前是第几天
@@ -388,7 +422,7 @@ export default {
padding: 20rpx;
text-align: center;
box-shadow: 0 16rpx 48rpx rgba(255, 107, 53, 0.35);
text {
font-size: 32rpx;
color: #ffffff;
@@ -396,6 +430,12 @@ export default {
}
}
.checkin-btn-disabled {
background: linear-gradient(135deg, #c4c4c4 0%, #e0e0e0 100%);
box-shadow: none;
opacity: 0.9;
}
/* 通用区块样式 */
.section {
margin: 48rpx 32rpx 0;

View File

@@ -5,6 +5,11 @@
<image class="share-icon" :src="iconShare" mode="aspectFit"></image>
</view>
<!-- 数据来自缓存时的提示 -->
<view v-if="loadError" class="cache-notice">
<text>当前数据来自缓存可能不是最新</text>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 食物大图 -->
@@ -90,6 +95,10 @@ export default {
iconShare: 'https://www.figma.com/api/mcp/asset/f9f0d7b9-89c0-48d4-9e04-7140229e42f0',
iconSearch: 'https://www.figma.com/api/mcp/asset/aa6bb75b-0a9d-43cb-aaa4-6a71993fbd4d',
loading: false,
/** 加载失败时的具体错误信息,用于调试;有值时页面会展示「当前数据来自缓存」提示 */
loadError: '',
/** 入参 id/name用于 API 失败时用 name 填充展示 */
pageParams: { id: '', name: '' },
foodData: {
name: '',
category: '',
@@ -124,13 +133,20 @@ export default {
}
},
onLoad(options) {
if (options.id) {
this.loadFoodData(options.id)
this.pageParams = { id: options.id || '', name: options.name || '' }
// 打印入参便于排查:后端详情接口仅接受 Long 类型 id
console.log('[food-detail] onLoad params:', this.pageParams)
const rawId = options.id
const isNumericId = rawId !== undefined && rawId !== '' && !isNaN(Number(rawId))
if (isNumericId) {
this.loadFoodData(Number(rawId))
} else if (options.name) {
this.loadFoodData(options.name)
// 无有效 id 仅有 name 时,不请求接口(避免传 name 导致后端 NumberFormatException直接展示默认数据 + 当前名称
this.loadError = '暂无该食物详情数据,展示参考数据'
this.applyDefaultFoodData(false)
this.foodData.name = decodeURIComponent(String(options.name))
} else {
// 无参数时使用默认数据
this.foodData = { ...this.defaultFoodData }
this.applyDefaultFoodData()
}
},
methods: {
@@ -145,8 +161,29 @@ export default {
url: `/pages/tool/food-encyclopedia?category=${this.foodData.categoryType || 'all'}`
})
},
/** 用 defaultFoodData 填充页面,保证 keyNutrients/nutritionTable 为非空数组以便 .nutrient-card / .nutrition-row 正常渲染clearError 为 true 时顺带清空 loadError */
applyDefaultFoodData(clearError = true) {
if (clearError) this.loadError = ''
this.foodData = {
...this.defaultFoodData,
name: this.defaultFoodData.name || '—',
category: this.defaultFoodData.category || '—',
safetyTag: this.defaultFoodData.safetyTag || '—',
image: this.defaultFoodData.image || '',
keyNutrients: [...(this.defaultFoodData.keyNutrients || [])],
nutritionTable: [...(this.defaultFoodData.nutritionTable || [])]
}
},
/** 保证数组非空,空时返回 fallback 的副本,用于 API 解析结果避免空数组导致列表不渲染 */
ensureNonEmptyArray(arr, fallback) {
if (Array.isArray(arr) && arr.length > 0) return arr
return Array.isArray(fallback) ? [...fallback] : []
},
async loadFoodData(id) {
this.loading = true
this.loadError = ''
// 打印 API 请求参数便于确认(后端需要 Long 类型 id
console.log('[food-detail] getFoodDetail request param:', { id, type: typeof id })
try {
const res = await getFoodDetail(id)
const data = res.data || res
@@ -158,17 +195,26 @@ export default {
categoryType: data.categoryType || data.categoryCode || 'all',
safetyTag: data.safetyTag || data.safety || this.getSafetyTag(data),
image: data.image || data.imageUrl || data.coverImage || this.defaultFoodData.image,
keyNutrients: this.parseKeyNutrients(data),
nutritionTable: this.parseNutritionTable(data)
keyNutrients: this.ensureNonEmptyArray(this.parseKeyNutrients(data), this.defaultFoodData.keyNutrients),
nutritionTable: this.ensureNonEmptyArray(this.parseNutritionTable(data), this.defaultFoodData.nutritionTable)
}
} else {
// API 返回空数据,使用默认数据
this.foodData = { ...this.defaultFoodData }
this.applyDefaultFoodData()
}
} catch (error) {
console.error('加载食物数据失败:', error)
// 加载失败使用默认数据
this.foodData = { ...this.defaultFoodData }
const errMsg = (error && (error.message || error.msg || error)) ? String(error.message || error.msg || error) : '未知错误'
console.error('[food-detail] 加载食物数据失败:', error)
// a. 将 loadError 置为具体错误信息(用于调试)
this.loadError = errMsg
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示
this.applyDefaultFoodData(false)
// 若有入参 name用其覆盖展示名称避免显示默认「五谷香」
if (this.pageParams && this.pageParams.name) {
try {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
uni.showToast({
title: '数据加载失败',
icon: 'none'
@@ -237,6 +283,18 @@ export default {
flex-direction: column;
}
/* 数据来自缓存提示 */
.cache-notice {
padding: 16rpx 32rpx;
background: #fff8e1;
border-bottom: 1rpx solid #ffe082;
text-align: center;
text {
font-size: 24rpx;
color: #f57c00;
}
}
/* 分享按钮 */
.share-btn-top {
position: fixed;
@@ -457,6 +515,10 @@ export default {
&.medium {
background: #ffa500;
}
&.high {
background: #e53935;
}
}
.nutrition-label {

View File

@@ -99,33 +99,33 @@
<view
class="food-item"
v-for="(item, index) in filteredFoodList"
:key="index"
:key="item.id != null ? item.id : index"
@click="goToFoodDetail(item)"
>
<view class="food-image-wrapper">
<image class="food-image" :src="item.image" mode="aspectFill"></image>
<image class="food-image" :src="getFoodImage(item)" mode="aspectFill"></image>
<view v-if="item.warning" class="warning-badge"></view>
</view>
<view class="food-info">
<view class="food-header">
<view class="food-name-wrapper">
<text class="food-name">{{ item.name }}</text>
<view class="safety-tag" :class="item.safetyClass">
<text>{{ item.safety }}</text>
<view class="safety-tag" :class="item.safetyClass || 'safe'">
<text>{{ item.safety || '—' }}</text>
</view>
</view>
<view class="category-badge">
<view v-if="item.category" class="category-badge">
<text>{{ item.category }}</text>
</view>
</view>
<view class="nutrition-list">
<view
class="nutrition-item"
v-for="(nut, idx) in item.nutrition"
v-for="(nut, idx) in (item.nutrition || [])"
:key="idx"
>
<text class="nutrition-label">{{ nut.label }}</text>
<text class="nutrition-value" :class="nut.colorClass">{{ nut.value }}</text>
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value }}</text>
</view>
</view>
</view>
@@ -137,12 +137,17 @@
</template>
<script>
import { HTTP_REQUEST_URL } from '@/config/app.js';
export default {
data() {
// 无图时的占位图(灰色背景,与 .food-image-wrapper 背景一致)
const defaultPlaceholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkyIiBoZWlnaHQ9IjE5MiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTRlNWU3Ii8+PC9zdmc+';
return {
searchText: '',
currentCategory: 'all',
searchTimer: null,
defaultPlaceholder,
foodList: [
{
name: '香蕉',
@@ -251,13 +256,66 @@ export default {
page: 1,
limit: 100
});
if (result.data && result.data.list) {
this.foodList = result.data.list;
}
const rawList = result.data && (result.data.list || (Array.isArray(result.data) ? result.data : []));
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('加载食物列表失败:', error);
}
},
getFoodImage(item) {
const raw = item.imageUrl || item.image || item.img || '';
const url = raw && (raw.startsWith('//') || raw.startsWith('http')) ? raw : (raw && raw.startsWith('/') ? (HTTP_REQUEST_URL || '') + raw : raw);
return (url && String(url).trim()) ? url : this.defaultPlaceholder;
},
normalizeFoodItem(item) {
const safetyMap = {
suitable: { safety: '放心吃', safetyClass: 'safe' },
moderate: { safety: '限量吃', safetyClass: 'limited' },
restricted: { 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' });
// 图片:统一为 image / imageUrl相对路径补全为完整 URL
const rawImg = item.imageUrl || item.image || item.img || '';
const imageUrl = (rawImg && (rawImg.startsWith('//') || rawImg.startsWith('http'))) ? rawImg : (rawImg && rawImg.startsWith('/') ? (HTTP_REQUEST_URL || '') + rawImg : rawImg);
const image = imageUrl || '';
// 营养简介:优先 item.nutrition其次 item.nutrients兼容后端不同字段否则由扁平字段组装
let nutrition = item.nutrition;
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'
}));
} 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'
}));
} else {
nutrition = [];
const push = (label, val, unit) => { if (val != null && val !== '') nutrition.push({ label, value: String(val) + (unit || ''), 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 {
...item,
image,
imageUrl: image || undefined,
category: item.category || '',
safety: safety.safety,
safetyClass: safety.safetyClass,
nutrition
};
},
async selectCategory(category) {
this.currentCategory = category;
// 切换分类时清空搜索文本,避免搜索状态与分类状态冲突
@@ -281,9 +339,8 @@ export default {
page: 1,
limit: 100
});
if (result.data && result.data.list) {
this.foodList = result.data.list;
}
const rawList = result.data && (result.data.list || (Array.isArray(result.data) ? result.data : []));
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('搜索失败:', error);
}
@@ -293,9 +350,14 @@ export default {
}, 300);
},
goToFoodDetail(item) {
uni.navigateTo({
url: `/pages/tool/food-detail?id=${item.name}`
})
// 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示
const rawId = item.id != null ? item.id : ''
const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : null
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
const url = numericId !== null
? `/pages/tool/food-detail?id=${numericId}${namePart}`
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
uni.navigateTo({ url })
}
}
}

View File

@@ -0,0 +1,156 @@
<template>
<view class="knowledge-detail-page">
<scroll-view class="content-scroll" scroll-y v-if="detail.title">
<view class="detail-header">
<view class="detail-title">{{ detail.title }}</view>
<view class="detail-meta">
<text class="meta-item">{{ detail.time }}</text>
<text class="meta-dot">·</text>
<text class="meta-item">{{ detail.views }} 阅读</text>
</view>
<image
v-if="detail.coverImage || detail.cover_image"
class="detail-cover"
:src="detail.coverImage || detail.cover_image"
mode="widthFix"
/>
</view>
<view class="detail-body">
<jyf-parser :html="detail.content || ''" :tag-style="tagStyle" />
</view>
</scroll-view>
<view v-else-if="loading" class="loading-wrap">
<text>加载中...</text>
</view>
<view v-else-if="error" class="error-wrap">
<text>{{ error }}</text>
</view>
</view>
</template>
<script>
import { getKnowledgeDetail } from '@/api/tool.js';
import parser from '@/components/jyf-parser/jyf-parser';
export default {
components: {
'jyf-parser': parser
},
data() {
return {
id: '',
detail: {},
loading: true,
error: '',
tagStyle: {
img: 'width:100%;display:block;',
table: 'width:100%',
video: 'width:100%'
}
};
},
onLoad(options) {
if (options.id) {
this.id = options.id;
} else {
this.loading = false;
this.error = '缺少文章 ID';
}
},
onShow() {
if (this.id) {
this.loadDetail();
}
},
methods: {
formatTime(val) {
if (!val) return '';
const d = new Date(val);
if (isNaN(d.getTime())) return String(val);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
},
async loadDetail() {
this.loading = true;
this.error = '';
try {
const res = await getKnowledgeDetail(this.id);
if (res && res.data) {
const d = res.data;
this.detail = {
...d,
time: this.formatTime(d.publishedAt || d.updatedAt || d.createdAt),
views: d.viewCount != null ? d.viewCount : (d.views != null ? d.views : 0),
coverImage: d.coverImage || d.cover_image || ''
};
uni.setNavigationBarTitle({
title: (d.title || '知识详情').substring(0, 12) + (d.title && d.title.length > 12 ? '...' : '')
});
} else {
this.error = '加载失败';
}
} catch (e) {
console.error('知识详情加载失败:', e);
this.error = (e && (e.message || e.msg)) || '加载失败,请重试';
} finally {
this.loading = false;
}
}
}
};
</script>
<style lang="scss" scoped>
.knowledge-detail-page {
min-height: 100vh;
background: #f4f5f7;
}
.content-scroll {
height: 100vh;
}
.detail-header {
padding: 24rpx 30rpx;
background: #fff;
margin-bottom: 16rpx;
}
.detail-title {
font-size: 36rpx;
font-weight: bold;
color: #282828;
line-height: 1.4;
margin-bottom: 16rpx;
}
.detail-meta {
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
}
.meta-dot {
margin: 0 8rpx;
}
.detail-cover {
width: 100%;
border-radius: 16rpx;
display: block;
}
.detail-body {
padding: 24rpx 30rpx;
background: #fff;
min-height: 200rpx;
font-size: 30rpx;
line-height: 1.6;
color: #333;
}
.loading-wrap,
.error-wrap {
padding: 80rpx 30rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
.error-wrap {
color: #f56c6c;
}
</style>

View File

@@ -58,7 +58,7 @@
</view>
</view>
<!-- 饮食指南Tab内容 -->
<!-- 饮食指南Tab内容来自 v2_knowledgetype=guide -->
<view v-if="currentTab === 'guide'" class="tab-content">
<view class="knowledge-list">
<view
@@ -67,7 +67,10 @@
:key="index"
@click="goToDetail(item)"
>
<view class="knowledge-icon">
<view class="knowledge-cover" v-if="item.coverImage || item.cover_image">
<image :src="item.coverImage || item.cover_image" mode="aspectFill" class="cover-img" />
</view>
<view class="knowledge-icon" v-else>
<text>{{ item.icon }}</text>
</view>
<view class="knowledge-info">
@@ -86,7 +89,7 @@
</view>
</view>
<!-- 科普文章Tab内容 -->
<!-- 科普文章Tab内容来自 v2_knowledgetype=article -->
<view v-if="currentTab === 'articles'" class="tab-content">
<view class="knowledge-list">
<view
@@ -95,7 +98,10 @@
:key="index"
@click="goToDetail(item)"
>
<view class="knowledge-icon">
<view class="knowledge-cover" v-if="item.coverImage || item.cover_image">
<image :src="item.coverImage || item.cover_image" mode="aspectFill" class="cover-img" />
</view>
<view class="knowledge-icon" v-else>
<text>{{ item.icon }}</text>
</view>
<view class="knowledge-info">
@@ -177,37 +183,72 @@ export default {
}
},
onLoad(options) {
// 如果携带id参数自动切换到对应tab并加载
if (options && options.id) {
// 有id时切换到文章tab展示详情
// 有 id 时切换到科普文章 tab 并加载列表
this.switchTab('articles');
} else {
this.loadKnowledgeList();
// 无 id 时保持当前 tabnutrients切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
this.currentTab = 'nutrients';
}
},
methods: {
formatKnowledgeTime(val) {
if (!val) return '';
const d = new Date(val);
if (isNaN(d.getTime())) return String(val);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
},
async loadKnowledgeList() {
// 营养素列表使用本地静态数据不从API加载
if (this.currentTab === 'nutrients') {
return;
}
// type 与后端一致guide / article
const typeParam = this.currentTab === 'guide' ? 'guide' : 'article';
try {
const { getKnowledgeList } = await import('@/api/tool.js');
const result = await getKnowledgeList({
type: this.currentTab === 'guide' ? 'guide' : 'article',
type: typeParam,
page: 1,
limit: 50
});
if (result.data && result.data.list) {
if (this.currentTab === 'guide') {
this.guideList = result.data.list;
} else {
this.articleList = result.data.list;
// 兼容 result.data.list 或 result.data 为数组
let rawList = [];
if (result && result.data) {
if (Array.isArray(result.data.list)) {
rawList = result.data.list;
} else if (Array.isArray(result.data)) {
rawList = result.data;
}
}
const list = rawList.map(item => ({
...item,
desc: item.desc || item.summary || '',
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),
icon: item.icon || '📄',
coverImage: item.coverImage || item.cover_image || ''
}));
if (this.currentTab === 'guide') {
this.guideList = list;
} else if (this.currentTab === 'articles') {
this.articleList = list;
}
} catch (error) {
console.error('加载知识列表失败:', error);
uni.showToast({
title: (error && (error.message || error.msg)) || '加载列表失败',
icon: 'none'
});
// 确保列表始终为数组,不设为 undefined
if (this.currentTab === 'guide') {
this.guideList = this.guideList ?? [];
} else if (this.currentTab === 'articles') {
this.articleList = this.articleList ?? [];
}
}
},
async switchTab(tab) {
@@ -220,16 +261,19 @@ export default {
})
},
goToDetail(item) {
if (!item) return
// 饮食指南和科普文章使用知识详情页(富文本内容),而非营养素详情页
if (item.knowledgeId || item.id) {
const id = item.knowledgeId || item.id
// 如果有content富文本使用内嵌webview或者新闻详情页
// 跳转到文章详情(使用现有新闻详情页展示富文本内容)
uni.navigateTo({
url: `/pages/news/news_details/index?id=${id}`
})
if (!item) {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
const id = item.knowledgeId ?? item.id;
if (id === undefined || id === null || id === '') {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
// 饮食指南、科普文章使用知识详情页(调用 tool/knowledge/detail 接口)
uni.navigateTo({
url: `/pages/tool/knowledge-detail?id=${id}`
});
}
}
}
@@ -440,6 +484,18 @@ export default {
gap: 32rpx;
}
.knowledge-cover {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
overflow: hidden;
flex-shrink: 0;
}
.cover-img {
width: 100%;
height: 100%;
}
.knowledge-icon {
width: 128rpx;
height: 128rpx;

View File

@@ -110,8 +110,8 @@
</view>
</view>
<!-- 营养统计卡片 -->
<view class="nutrition-stats-card" v-if="postData.nutritionStats && postData.nutritionStats.length > 0">
<!-- 营养统计卡片:仅根据是否有数据展示 -->
<view class="nutrition-stats-card" v-if="nutritionStatsLength > 0">
<view class="stats-header">
<view class="stats-title">
<text class="title-icon">📊</text>
@@ -233,6 +233,7 @@
<script>
import {
getCommunityDetail,
getCheckinDetail,
toggleLike as apiToggleLike,
toggleCollect,
toggleFollow as apiToggleFollow,
@@ -284,6 +285,13 @@ export default {
commentText: ''
}
},
computed: {
// 营养统计条数,用于卡片显示条件(避免 postData.nutritionStats 未定义时报错)
nutritionStatsLength() {
const stats = this.postData && this.postData.nutritionStats
return Array.isArray(stats) ? stats.length : 0
}
},
onLoad(options) {
if (options.id) {
this.postId = options.id
@@ -301,6 +309,11 @@ export default {
// 格式化帖子数据
this.formatPostData(data)
// 若详情接口未返回营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载)
if (this.postData.nutritionStats.length === 0 && (data.checkInRecordId != null)) {
await this.fillNutritionStatsFromCheckin(data.checkInRecordId)
}
// 同步状态
this.isLiked = data.isLiked || false
@@ -324,6 +337,95 @@ export default {
}
},
/**
* 从详情接口返回的数据中构建 nutritionStats 数组
* 支持nutritionStats/nutrition_stats 数组、nutritionDataJson/nutrition_data_json、dietaryData/mealData 等
*/
buildNutritionStatsFromDetailData(data) {
if (!data) return []
// 1) 后端直接返回的 stat 数组(兼容不同命名)
const rawStats = data.nutritionStats || data.nutrition_stats
if (Array.isArray(rawStats) && rawStats.length > 0) {
return rawStats.map(s => ({
label: s.label || s.name || '',
value: s.value != null ? String(s.value) : '-'
})).filter(s => s.label)
}
// 2) nutritionDataJson / nutrition_data_json
const jsonRaw = data.nutritionDataJson || data.nutrition_data_json
if (jsonRaw) {
try {
const nutritionData = typeof jsonRaw === 'string' ? JSON.parse(jsonRaw) : jsonRaw
if (nutritionData && typeof nutritionData === 'object') {
return [
{ label: '热量(kcal)', value: nutritionData.calories != null ? String(nutritionData.calories) : '-' },
{ label: '蛋白质', value: nutritionData.protein != null ? nutritionData.protein + 'g' : '-' },
{ label: '钾', value: nutritionData.potassium != null ? nutritionData.potassium + 'mg' : '-' },
{ label: '磷', value: nutritionData.phosphorus != null ? nutritionData.phosphorus + 'mg' : '-' }
]
}
} catch (e) {
// ignore
}
}
// 3) dietaryData / mealData / dietary_data / meal_data饮食打卡对象
const dietary = data.dietaryData || data.dietary_data || data.mealData || data.meal_data
if (dietary) {
const obj = typeof dietary === 'string' ? (() => { try { return JSON.parse(dietary) } catch (_) { return null } })() : dietary
if (obj && typeof obj === 'object') {
const cal = obj.calories ?? obj.energy ?? obj.calorie
const pro = obj.protein ?? obj.proteins
const pot = obj.potassium ?? obj.k
const pho = obj.phosphorus ?? obj.p
return [
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
{ label: '蛋白质', value: pro != null ? (typeof pro === 'number' ? pro + 'g' : String(pro)) : '-' },
{ label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
{ label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
]
}
}
return []
},
/**
* 根据打卡详情接口返回的数据构建 nutritionStats蛋白质、热量、钾、磷
*/
buildNutritionStatsFromCheckinDetail(detail) {
if (!detail || typeof detail !== 'object') return []
const items = []
// 热量
const energy = detail.actualEnergy ?? detail.energy
items.push({ label: '热量(kcal)', value: energy != null ? String(energy) : '-' })
// 蛋白质
const protein = detail.actualProtein ?? detail.protein
items.push({ label: '蛋白质', value: protein != null ? (typeof protein === 'number' ? protein + 'g' : String(protein)) : '-' })
// 钾、磷:打卡详情可能没有,用 -
items.push({ label: '钾', value: '-' })
items.push({ label: '磷', value: '-' })
return items
},
/**
* 根据打卡记录 ID 拉取打卡详情,并填充 postData.nutritionStats不阻塞主流程
*/
async fillNutritionStatsFromCheckin(checkInRecordId) {
try {
const res = await getCheckinDetail(checkInRecordId)
const detail = res.data || res
const stats = this.buildNutritionStatsFromCheckinDetail(detail)
if (stats.length > 0) {
this.postData.nutritionStats = stats
}
} catch (e) {
console.warn('拉取打卡详情补充营养数据失败:', e)
}
},
// 格式化帖子数据
formatPostData(data) {
// 解析图片
@@ -368,23 +470,8 @@ export default {
}
}
// 解析营养数据
let nutritionStats = []
if (data.nutritionDataJson) {
try {
const nutritionData = typeof data.nutritionDataJson === 'string'
? JSON.parse(data.nutritionDataJson)
: data.nutritionDataJson
nutritionStats = [
{ value: nutritionData.calories || '-', label: '热量(kcal)' },
{ value: nutritionData.protein ? nutritionData.protein + 'g' : '-', label: '蛋白质' },
{ value: nutritionData.potassium ? nutritionData.potassium + 'mg' : '-', label: '钾' },
{ value: nutritionData.phosphorus ? nutritionData.phosphorus + 'mg' : '-', label: '磷' }
]
} catch (e) {
nutritionStats = []
}
}
// 解析营养数据:支持多种后端字段名,以及从打卡数据计算
let nutritionStats = this.buildNutritionStatsFromDetailData(data)
// 提取纯文本内容
let description = ''

View File

@@ -63,7 +63,7 @@
<view class="post-image" v-if="item.image">
<image :src="item.image" mode="aspectFill" lazy-load></image>
<!-- 类型标签 -->
<view class="meal-tag">{{ item.mealType }}</view>
<view class="meal-tag">{{ getMealTypeLabel(item.mealType) }}</view>
<!-- 视频标记 -->
<view class="video-badge" v-if="item.hasVideo || item.videoUrl">
<text class="badge-icon">🎬</text>
@@ -73,7 +73,7 @@
<!-- 内容区域 -->
<view class="post-content">
<!-- 无图片时显示类型标签 -->
<view class="type-tag" v-if="!item.image">{{ item.mealType }}</view>
<view class="type-tag" v-if="!item.image">{{ getMealTypeLabel(item.mealType) }}</view>
<!-- 标题 -->
<view class="post-title">{{ item.title }}</view>
@@ -426,6 +426,21 @@ export default {
return String(count)
},
// 帖子类型英文转中文显示(仅用于展示,保证 label 均为中文)
getMealTypeLabel(mealType) {
if (!mealType) return '分享'
const map = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
share: '分享',
checkin: '打卡'
}
const lower = String(mealType).toLowerCase()
return map[lower] != null ? map[lower] : '分享'
},
// 格式化标签显示
formatTag(tag) {
if (!tag) return ''

View File

@@ -26,8 +26,8 @@
</view>
</view>
<!-- 四大功能入口 -->
<view class="function-grid">
<!-- 四大功能入口根据系统配置 eb_system_config.field01=1 时显示 -->
<view class="function-grid" v-if="showFunctionEntries">
<view class="function-item calculator" @tap="goToCalculator">
<view class="function-content">
<view class="function-text">
@@ -64,7 +64,7 @@
<view class="function-item nutrition-knowledge" @tap="goToNutritionKnowledge">
<view class="function-content">
<view class="function-text">
<view class="function-title">营养知识</view>
<view class="function-title">健康知识</view>
<view class="function-desc">专业营养指导</view>
</view>
<view class="function-icon">
@@ -156,7 +156,8 @@
import {
getRecommendedRecipes,
getRecommendedKnowledge,
getUserHealthStatus
getUserHealthStatus,
getHomeDisplayConfig
} from '@/api/tool.js';
import { mapGetters } from 'vuex';
import { toLogin, checkLogin } from '@/libs/login.js';
@@ -177,6 +178,7 @@ import {
hasProfile: false,
profileStatus: '尚未完成健康档案'
},
showFunctionEntries: false,
loading: false
}
},
@@ -198,16 +200,18 @@ import {
async loadData() {
this.loading = true;
try {
// 并行加载数据
const [recipesRes, knowledgeRes, healthRes] = await Promise.all([
// 并行加载数据含首页展示配置field01=1 时显示四大功能入口)
const [recipesRes, knowledgeRes, healthRes, displayConfigRes] = await Promise.all([
getRecommendedRecipes({ limit: 2 }).catch(() => ({ data: [] })),
getRecommendedKnowledge({ limit: 2 }).catch(() => ({ data: [] })),
getUserHealthStatus().catch(() => ({ data: { hasProfile: false, profileStatus: '尚未完成健康档案' } }))
getUserHealthStatus().catch(() => ({ data: { hasProfile: false, profileStatus: '尚未完成健康档案' } })),
getHomeDisplayConfig().catch(() => ({ data: { showFunctionEntries: false } }))
]);
this.recipeList = recipesRes.data?.list || recipesRes.data || [];
this.knowledgeList = knowledgeRes.data?.list || knowledgeRes.data || [];
this.userHealthStatus = healthRes.data || { hasProfile: false, profileStatus: '尚未完成健康档案' };
this.showFunctionEntries = !!(displayConfigRes.data && displayConfigRes.data.showFunctionEntries);
} catch (error) {
console.error('加载数据失败:', error);
uni.showToast({
@@ -226,9 +230,14 @@ import {
},
// 跳转到打卡页面
handleCheckin() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => toLogin(), 500);
return;
}
uni.navigateTo({
url: '/pages/tool/checkin'
})
});
},
// 跳转到食谱计算器
goToCalculator() {

View File

@@ -435,8 +435,9 @@
if (!/^1(3|4|5|7|8|9|6)\d{9}$/i.test(that.account)) return that.$util.Tips({
title: '请输入正确的手机号码'
});
if (that.formItem == 2) that.type = "register";
that.$refs.verify.show();
if (that.formItem == 2) that.type = "register";
this.codeSend();
// that.$refs.verify.show();
},
navTap: function(index) {
this.current = index;