Initial commit: MSH System\n\n- msh_single_uniapp: Vue 2 + UniApp 前端(微信小程序/H5/App/支付宝小程序)\n- msh_crmeb_22: Spring Boot 2.2 后端(C端API/管理端/业务逻辑)\n- models-integration: AI服务集成(Coze/KieAI/腾讯ASR)\n- docs: 产品文档与设计稿

This commit is contained in:
2026-02-28 05:40:21 +08:00
commit 14d29d51c0
2182 changed files with 482509 additions and 0 deletions

View File

@@ -0,0 +1,855 @@
<template>
<view class="community-page">
<!-- 顶部Tab导航 -->
<view class="tab-nav">
<view
class="tab-item"
:class="{ active: currentTab === 'recommend' }"
@click="switchTab('recommend')"
>
<!-- <text class="tab-icon">📊</text> -->
<text class="tab-text">推荐</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'latest' }"
@click="switchTab('latest')"
>
<!-- <text class="tab-icon">🕐</text> -->
<text class="tab-text">最新</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'follow' }"
@click="switchTab('follow')"
>
<!-- <text class="tab-icon">👥</text> -->
<text class="tab-text">关注</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'hot' }"
@click="switchTab('hot')"
>
<!-- <text class="tab-icon">🔥</text> -->
<text class="tab-text">热门</text>
</view>
</view>
<!-- 加载中状态 -->
<view class="loading-container" v-if="isLoading && postList.length === 0">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view class="empty-container" v-else-if="isEmpty">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无内容</text>
<text class="empty-hint" v-if="currentTab === 'follow'">关注更多用户查看他们的打卡动态</text>
<text class="empty-hint" v-else>快来发布第一条打卡动态吧</text>
</view>
<!-- 打卡卡片网格 -->
<view class="post-grid" v-else>
<view
class="post-card"
:class="{ 'no-image': !item.image }"
v-for="(item, index) in postList"
:key="item.id"
@click="goToPostDetail(item)"
>
<!-- 图片区域有图片时显示 -->
<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="video-badge" v-if="item.hasVideo || item.videoUrl">
<text class="badge-icon">🎬</text>
</view>
</view>
<!-- 内容区域 -->
<view class="post-content">
<!-- 无图片时显示类型标签 -->
<view class="type-tag" v-if="!item.image">{{ item.mealType }}</view>
<!-- 标题 -->
<view class="post-title">{{ item.title }}</view>
<!-- 内容摘要无图片时显示更多内容 -->
<view class="post-summary" v-if="item.content && !item.image">
{{ item.content }}
</view>
<!-- 话题标签 -->
<view class="tag-list" v-if="item.tags && item.tags.length > 0">
<view
class="tag-item"
v-for="(tag, tagIndex) in item.tags.slice(0, 2)"
:key="tagIndex"
>
{{ formatTag(tag) }}
</view>
</view>
<!-- 用户信息和互动数据 -->
<view class="post-footer">
<view class="user-info">
<image
v-if="item.userIcon && item.userIcon.startsWith('http')"
class="user-avatar"
:src="item.userIcon"
mode="aspectFill"
></image>
<text v-else class="user-icon">👤</text>
<text class="user-name">{{ item.userName }}</text>
</view>
<view class="interaction-info">
<view class="like-info" @click.stop="toggleLike(item, index)">
<text class="like-icon" :class="{ liked: item.isLiked }">{{ item.isLiked ? '❤️' : '🤍' }}</text>
<text class="like-count">{{ formatCount(item.likeCount) }}</text>
</view>
</view>
</view>
<!-- 发布时间 -->
<view class="post-time" v-if="item.createTimeText">
<text>{{ item.createTimeText }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="postList.length > 0">
<view v-if="isLoadingMore" class="loading-more">
<view class="loading-spinner small"></view>
<text>加载中...</text>
</view>
<text v-else-if="hasMore" @click="loadMore">上拉加载更多</text>
<text v-else class="no-more">- 没有更多了 -</text>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</view>
</template>
<script>
import { getCommunityList, toggleLike as apiToggleLike } from '@/api/tool.js'
import { checkLogin, toLogin } from '@/libs/login.js'
export default {
data() {
return {
currentTab: 'recommend',
postList: [],
page: 1,
limit: 10,
isLoading: false,
isLoadingMore: false,
hasMore: true,
isEmpty: false
}
},
onLoad() {
// 页面加载时获取数据
this.loadPostList()
},
onShow() {
// 页面显示时刷新数据(从详情页返回可能有变化)
if (this.postList.length > 0) {
this.refreshList()
}
},
onPullDownRefresh() {
// 下拉刷新
this.refreshList()
},
onReachBottom() {
// 触底加载更多
this.loadMore()
},
methods: {
// 切换Tab
switchTab(tab) {
if (this.currentTab === tab) return
// 关注Tab需要登录
if (tab === 'follow' && !checkLogin()) {
uni.showToast({
title: '请先登录查看关注内容',
icon: 'none'
})
setTimeout(() => {
toLogin()
}, 1000)
return
}
this.currentTab = tab
this.page = 1
this.hasMore = true
this.postList = []
this.loadPostList()
},
// 刷新列表
async refreshList() {
this.page = 1
this.hasMore = true
await this.loadPostList(true)
},
// 加载帖子列表
async loadPostList(isRefresh = false) {
if (this.isLoading) return
this.isLoading = true
if (!isRefresh) {
this.isEmpty = false
}
try {
const res = await getCommunityList({
tab: this.currentTab,
page: this.page,
limit: this.limit
})
const list = res.data?.list || res.data || []
const formattedList = this.formatPostList(list)
if (isRefresh || this.page === 1) {
this.postList = formattedList
} else {
this.postList = [...this.postList, ...formattedList]
}
// 判断是否还有更多
this.hasMore = list.length >= this.limit
this.isEmpty = this.postList.length === 0
} catch (error) {
console.error('加载社区列表失败:', error)
// 如果是首次加载失败,显示空状态
if (this.page === 1) {
this.isEmpty = true
}
uni.showToast({
title: error.message || '加载失败,请重试',
icon: 'none'
})
} finally {
this.isLoading = false
uni.stopPullDownRefresh()
}
},
// 格式化帖子数据
formatPostList(list) {
return list.map(item => {
// 解析图片JSON
let images = []
if (item.imagesJson) {
try {
images = typeof item.imagesJson === 'string'
? JSON.parse(item.imagesJson)
: item.imagesJson
} catch (e) {
images = []
}
}
// 解析标签JSON
let tags = []
if (item.tagsJson) {
try {
tags = typeof item.tagsJson === 'string'
? JSON.parse(item.tagsJson)
: item.tagsJson
} catch (e) {
tags = []
}
}
// 如果没有标签尝试从content中提取关键字
if (tags.length === 0 && item.content) {
const keywordMatch = item.content.match(/关键字[:]\s*([^<\n]+)/i)
if (keywordMatch && keywordMatch[1]) {
// 按逗号或顿号分割关键字
const keywords = keywordMatch[1].split(/[,,、]/).map(k => k.trim()).filter(k => k)
tags = keywords.slice(0, 3) // 最多取3个关键字
}
}
// 从HTML内容中提取纯文本摘要
let contentText = ''
if (item.content) {
contentText = this.stripHtml(item.content)
// 移除关键字和简介标记
contentText = contentText
.replace(/关键字[:][^简介]*/i, '')
.replace(/简介[:]/i, '')
.trim()
.substring(0, 120) // 截取前120字符
}
// 获取封面图片
const coverImage = item.coverImage || (images.length > 0 ? images[0] : '')
// 判断内容类型根据checkInRecordId判断是否为打卡记录
const isCheckin = !!item.checkInRecordId
const mealType = isCheckin ? '打卡' : '分享'
return {
id: item.id || item.postId,
postId: item.postId || item.id,
image: coverImage,
images: images,
mealType: item.mealType || mealType,
title: item.title || '',
content: contentText,
tags: tags,
userIcon: item.userAvatar || '',
userName: item.userName || '匿名用户',
userId: item.userId,
likeCount: item.likeCount || 0,
isLiked: item.isLiked || false,
commentCount: item.commentCount || 0,
collectCount: item.collectCount || 0,
shareCount: item.shareCount || 0,
viewCount: item.viewCount || 0,
createdAt: item.createdAt,
createTimeText: this.formatTime(item.createdAt),
isCheckin: isCheckin,
hasVideo: item.hasVideo || false,
videoUrl: item.videoUrl || '',
enableAIVideo: item.enableAIVideo || false
}
})
},
// 移除HTML标签并解析实体
stripHtml(html) {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ') // 换行转空格
.replace(/<hr\s*\/?>/gi, ' ') // 分隔线转空格
.replace(/<[^>]+>/g, '') // 移除HTML标签
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ') // 合并多个空格
.trim()
},
// 格式化时间显示
formatTime(timeStr) {
if (!timeStr) return ''
const date = new Date(timeStr.replace(/-/g, '/')) // 兼容iOS
const now = new Date()
const diff = now - date
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return '刚刚'
} else if (diff < hour) {
return Math.floor(diff / minute) + '分钟前'
} else if (diff < day) {
return Math.floor(diff / hour) + '小时前'
} else if (diff < 7 * day) {
return Math.floor(diff / day) + '天前'
} else {
const month = date.getMonth() + 1
const dayOfMonth = date.getDate()
return `${month}${dayOfMonth}`
}
},
// 加载更多
async loadMore() {
if (!this.hasMore || this.isLoadingMore || this.isLoading) return
this.isLoadingMore = true
this.page++
try {
const res = await getCommunityList({
tab: this.currentTab,
page: this.page,
limit: this.limit
})
const list = res.data?.list || res.data || []
const formattedList = this.formatPostList(list)
this.postList = [...this.postList, ...formattedList]
this.hasMore = list.length >= this.limit
} catch (error) {
console.error('加载更多失败:', error)
this.page-- // 失败时回退页码
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
this.isLoadingMore = false
}
},
// 跳转到帖子详情
goToPostDetail(item) {
uni.navigateTo({
url: `/pages/tool/post-detail?id=${item.id}`
})
},
// 格式化数量显示
formatCount(count) {
if (!count) return '0'
if (count >= 10000) {
return (count / 10000).toFixed(1) + 'w'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k'
}
return String(count)
},
// 格式化标签显示
formatTag(tag) {
if (!tag) return ''
// 如果标签已经以#开头,直接返回
if (typeof tag === 'string' && tag.startsWith('#')) {
return tag
}
// 如果是对象取name属性
if (typeof tag === 'object' && tag.name) {
return '#' + tag.name
}
return '#' + String(tag)
},
// 点赞/取消点赞
async toggleLike(item, index) {
// 检查登录状态
if (!checkLogin()) {
uni.showToast({
title: '请先登录后点赞',
icon: 'none'
})
setTimeout(() => {
toLogin()
}, 1000)
return
}
const newLikeState = !item.isLiked
const newLikeCount = newLikeState ? item.likeCount + 1 : item.likeCount - 1
// 乐观更新UI
this.$set(this.postList, index, {
...item,
isLiked: newLikeState,
likeCount: newLikeCount
})
try {
await apiToggleLike(item.id, newLikeState)
} catch (error) {
console.error('点赞失败:', error)
// 失败时回滚
this.$set(this.postList, index, {
...item,
isLiked: !newLikeState,
likeCount: item.likeCount
})
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
}
}
}
</script>
<style lang="scss" scoped>
.community-page {
min-height: 100vh;
background-color: #f4f5f7;
padding-bottom: 20rpx;
}
/* Tab导航 */
.tab-nav {
background: #ffffff;
border-bottom: 1rpx solid #d0dbea;
display: flex;
padding: 0 32rpx;
height: 88rpx;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 72rpx;
position: relative;
.tab-icon {
font-size: 32rpx;
margin-bottom: 8rpx;
}
.tab-text {
font-size: 28rpx;
color: #9fa5c0;
}
&.active {
.tab-text {
color: #ff6b35;
font-weight: 500;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100rpx;
height: 4rpx;
background: #ff6b35;
border-radius: 2rpx;
}
}
}
/* 打卡卡片网格 */
.post-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
padding: 32rpx;
}
.post-card {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
&.no-image {
.post-content {
padding-top: 32rpx;
}
.post-title {
-webkit-line-clamp: 3;
font-weight: 500;
}
}
}
.post-image {
width: 100%;
height: 436rpx;
position: relative;
overflow: hidden;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
.meal-tag {
position: absolute;
left: 16rpx;
top: 16rpx;
background: #ff6b35;
border-radius: 40rpx;
padding: 4rpx 16rpx;
font-size: 24rpx;
color: #ffffff;
}
.video-badge {
position: absolute;
right: 16rpx;
top: 16rpx;
background: rgba(0, 0, 0, 0.7);
border-radius: 8rpx;
padding: 8rpx 12rpx;
display: flex;
align-items: center;
backdrop-filter: blur(8rpx);
.badge-icon {
font-size: 28rpx;
}
}
.score-tag {
position: absolute;
right: 16rpx;
top: 16rpx;
background: #ff9800;
border-radius: 40rpx;
padding: 4rpx 16rpx;
display: flex;
align-items: center;
gap: 4rpx;
.score-icon {
font-size: 24rpx;
}
.score-text {
font-size: 24rpx;
color: #ffffff;
}
}
}
.post-content {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.type-tag {
display: inline-block;
background: #ff6b35;
border-radius: 40rpx;
padding: 4rpx 16rpx;
font-size: 22rpx;
color: #ffffff;
align-self: flex-start;
margin-bottom: 8rpx;
}
.post-title {
font-size: 28rpx;
color: #2e3e5c;
line-height: 1.4;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.post-summary {
font-size: 24rpx;
color: #9fa5c0;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.tag-item {
background: #fff5f0;
border: 1rpx solid rgba(255, 107, 53, 0.3);
border-radius: 40rpx;
padding: 4rpx 16rpx;
font-size: 24rpx;
color: #ff6b35;
}
.post-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
overflow: hidden;
.user-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
flex-shrink: 0;
}
.user-icon {
font-size: 36rpx;
flex-shrink: 0;
}
.user-name {
font-size: 24rpx;
color: #9fa5c0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.interaction-info {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.like-info {
display: flex;
align-items: center;
gap: 8rpx;
.like-icon {
font-size: 32rpx;
&.liked {
animation: likeAnimation 0.3s;
}
}
.like-count {
font-size: 24rpx;
color: #9fa5c0;
}
}
.post-time {
font-size: 22rpx;
color: #c0c5d0;
margin-top: 4rpx;
}
@keyframes likeAnimation {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
/* 加载中状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
.loading-text {
font-size: 28rpx;
color: #9fa5c0;
margin-top: 20rpx;
}
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #f0f0f0;
border-top-color: #ff6b35;
border-radius: 50%;
animation: spin 0.8s linear infinite;
&.small {
width: 32rpx;
height: 32rpx;
border-width: 3rpx;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 空状态 */
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 60rpx;
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 500;
margin-bottom: 12rpx;
}
.empty-hint {
font-size: 26rpx;
color: #9fa5c0;
text-align: center;
}
}
/* 加载更多 */
.load-more {
text-align: center;
padding: 32rpx;
color: #9fa5c0;
font-size: 28rpx;
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.no-more {
color: #d0dbea;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
</style>

View File

@@ -0,0 +1,724 @@
<template>
<view class="tool-page">
<!-- 用户健康卡片 -->
<view class="user-card">
<view class="user-card-content">
<view class="user-avatar" @tap="goToMyProfile">
<image class="avatar-img" :src='userInfo.avatar' v-if="userInfo.avatar && uid" mode="aspectFill"></image>
<image v-else class="avatar-img" :src="urlDomain+'crmebimage/perset/staticImg/f.png'" mode="aspectFill"></image>
</view>
<view class="user-info">
<view class="user-name" v-if="userInfo && uid">
{{userInfo.nickname}}
<!-- <view class="vip-tag" v-if="userInfo.vip">
<image :src="userInfo.vipIcon" mode="aspectFit" class="vip-icon"></image>
<text class="vip-text">{{userInfo.vipName || ''}}</text>
</view> -->
</view>
<view class="user-name" v-else @tap="openAuto">请点击登录</view>
<view class="user-desc" :class="{ 'completed': userHealthStatus.hasProfile }">
{{ userHealthStatus.profileStatus || '尚未完成健康档案' }}
</view>
</view>
<view class="checkin-btn" @tap="handleCheckin">
<text>打卡</text>
</view>
</view>
</view>
<!-- 四大功能入口 -->
<view class="function-grid">
<view class="function-item calculator" @tap="goToCalculator">
<view class="function-content">
<view class="function-text">
<view class="function-title">食谱计算器</view>
<view class="function-desc">个性化营养方案</view>
</view>
<view class="function-icon">
<text class="icon">📊</text>
</view>
</view>
</view>
<view class="function-item ai-nutritionist" @tap="goToAINutritionist">
<view class="function-content">
<view class="function-text">
<view class="function-title">AI营养师</view>
<view class="function-desc">智慧知肾健康</view>
</view>
<view class="function-icon">
<text class="icon">💬</text>
</view>
</view>
</view>
<view class="function-item food-encyclopedia" @tap="goToFoodEncyclopedia">
<view class="function-content">
<view class="function-text">
<view class="function-title">食物百科</view>
<view class="function-desc">营养成分查询</view>
</view>
<view class="function-icon">
<text class="icon">🔍</text>
</view>
</view>
</view>
<view class="function-item nutrition-knowledge" @tap="goToNutritionKnowledge">
<view class="function-content">
<view class="function-text">
<view class="function-title">营养知识</view>
<view class="function-desc">专业营养指导</view>
</view>
<view class="function-icon">
<text class="icon">💡</text>
</view>
</view>
</view>
</view>
<!-- 精选食谱 -->
<view class="section">
<view class="section-header">
<text class="section-title">精选食谱</text>
<view class="section-more" @tap="goToRecipeList">
<text></text>
</view>
</view>
<view class="recipe-list">
<view class="recipe-item" v-for="(item, index) in recipeList" :key="index" @tap="goToRecipeDetail(item)">
<view class="recipe-image">
<image :src="item.coverImage" mode="aspectFill"></image>
<view class="recipe-tag" :class="item.source === 'calculator' ? 'tag-mine' : 'tag-recommend'">
{{ item.source === 'calculator' ? '我的配餐' : '推荐' }}
</view>
</view>
<view class="recipe-info">
<view class="recipe-title">{{ item.name }}</view>
<view class="recipe-desc">{{ item.description || '' }}</view>
<view class="recipe-meta">
<view class="meta-item" v-if="item.totalProtein">
<text class="meta-icon">🥩</text>
<text class="meta-text">蛋白质 {{ item.totalProtein }}g</text>
</view>
<view class="meta-item" v-if="item.totalEnergy">
<text class="meta-icon">🔥</text>
<text class="meta-text">{{ item.totalEnergy }}kcal</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 营养方案领取卡片 -->
<view class="promotion-card" @tap="goToPromotion">
<view class="promotion-content">
<view class="promotion-text">
<view class="promotion-title">慢生活营养专家</view>
<view class="promotion-desc">专业个性化营养方案</view>
</view>
<view class="promotion-btn">
<text>立即领取福利</text>
</view>
</view>
</view>
<!-- 健康知识 -->
<view class="section">
<view class="section-header">
<text class="section-title">健康知识</text>
<view class="section-more" @tap="goToKnowledgeList">
<text></text>
</view>
</view>
<view class="knowledge-list">
<view class="knowledge-item" v-for="(item, index) in knowledgeList" :key="index" @tap="goToKnowledgeDetail(item)">
<view class="knowledge-icon">
<text>{{ item.icon }}</text>
</view>
<view class="knowledge-info">
<view class="knowledge-title">{{ item.title }}</view>
<view class="knowledge-desc">{{ item.desc }}</view>
<view class="knowledge-meta">
<text class="meta-text">{{ item.time }}</text>
<text class="meta-dot">·</text>
<text class="meta-text">{{ item.views }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</view>
</template>
<script>
import {
getRecommendedRecipes,
getRecommendedKnowledge,
getUserHealthStatus
} from '@/api/tool.js';
import { mapGetters } from 'vuex';
import { toLogin, checkLogin } from '@/libs/login.js';
import Cache from '@/utils/cache';
import { BACK_URL } from '@/config/cache';
export default {
name: 'ToolIndex',
computed: {
...mapGetters(['userInfo','uid','isLogin'])
},
data() {
return {
urlDomain: this.$Cache.get("imgHost"),
recipeList: [],
knowledgeList: [],
userHealthStatus: {
hasProfile: false,
profileStatus: '尚未完成健康档案'
},
loading: false
}
},
onLoad() {
this.loadData();
},
onPullDownRefresh() {
this.loadData().finally(() => {
uni.stopPullDownRefresh();
});
},
methods: {
// 打开授权
openAuto() {
Cache.set(BACK_URL, '')
toLogin();
},
// 加载页面数据
async loadData() {
this.loading = true;
try {
// 并行加载数据
const [recipesRes, knowledgeRes, healthRes] = await Promise.all([
getRecommendedRecipes({ limit: 2 }).catch(() => ({ data: [] })),
getRecommendedKnowledge({ limit: 2 }).catch(() => ({ data: [] })),
getUserHealthStatus().catch(() => ({ data: { hasProfile: false, profileStatus: '尚未完成健康档案' } }))
]);
this.recipeList = recipesRes.data?.list || recipesRes.data || [];
this.knowledgeList = knowledgeRes.data?.list || knowledgeRes.data || [];
this.userHealthStatus = healthRes.data || { hasProfile: false, profileStatus: '尚未完成健康档案' };
} catch (error) {
console.error('加载数据失败:', error);
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
});
} finally {
this.loading = false;
}
},
// 跳转到我的页面
goToMyProfile() {
uni.navigateTo({
url: '/pages/tool/my-profile'
})
},
// 跳转到打卡页面
handleCheckin() {
uni.navigateTo({
url: '/pages/tool/checkin'
})
},
// 跳转到食谱计算器
goToCalculator() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => toLogin(), 500);
return;
}
uni.navigateTo({
url: '/pages/tool/calculator'
})
},
// 跳转到AI营养师
goToAINutritionist() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => toLogin(), 500);
return;
}
uni.navigateTo({
url: '/pages/tool/ai-nutritionist'
})
},
// 跳转到食物百科
goToFoodEncyclopedia() {
uni.navigateTo({
url: '/pages/tool/food-encyclopedia'
})
},
// 跳转到营养知识
goToNutritionKnowledge() {
uni.navigateTo({
url: '/pages/tool/nutrition-knowledge'
})
},
// 跳转到食谱列表(功能开发中)
goToRecipeList() {
uni.showToast({
title: '食谱列表功能开发中',
icon: 'none'
})
},
// 跳转到食谱详情
goToRecipeDetail(item) {
if (!item) return
uni.navigateTo({
url: `/pages/tool/recipe-detail?id=${item.id || 1}`
})
},
// 跳转到会员福利
goToPromotion() {
uni.navigateTo({
url: '/pages/tool/welcome-gift'
})
},
// 跳转到营养知识列表
goToKnowledgeList() {
uni.navigateTo({
url: '/pages/tool/nutrition-knowledge'
})
},
// 跳转到营养知识详情
goToKnowledgeDetail(item) {
if (!item) return
uni.navigateTo({
url: `/pages/tool/nutrition-knowledge?id=${item.id || 1}`
})
}
}
}
</script>
<style lang="scss" scoped>
.tool-page {
min-height: 100vh;
background-color: #f4f5f7;
padding-bottom: 20rpx;
}
/* 用户健康卡片 */
.user-card {
margin: 32rpx 32rpx 0;
height: 192rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff7a4a 50%, #d64820 100%);
box-shadow: 0 16rpx 48rpx rgba(255, 107, 53, 0.25);
overflow: hidden;
position: relative;
.user-card-content {
display: flex;
align-items: center;
padding: 32rpx;
height: 100%;
position: relative;
z-index: 1;
}
.user-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #ffffff;
border: 5rpx solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
cursor: pointer;
overflow: hidden;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.user-info {
flex: 1;
.user-name {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
margin-bottom: 4rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.vip-tag {
display: flex;
align-items: center;
padding: 4rpx 16rpx;
background: rgba(0, 0, 0, 0.2);
border-radius: 20rpx;
.vip-icon {
width: 24rpx;
height: 24rpx;
margin-right: 6rpx;
}
.vip-text {
font-size: 20rpx;
color: #ffe157;
}
}
.user-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
}
.checkin-btn {
background: rgba(255, 255, 255, 0.95);
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 24rpx;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 40rpx rgba(255, 107, 53, 0.3);
text {
font-size: 24rpx;
color: #ff6b35;
}
}
}
/* 四大功能入口 */
.function-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
margin: 32rpx 32rpx 0;
}
.function-item {
height: 168rpx;
border-radius: 32rpx;
overflow: hidden;
position: relative;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.1);
.function-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 40rpx;
height: 100%;
position: relative;
z-index: 1;
}
.function-text {
flex: 1;
.function-title {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
margin-bottom: 8rpx;
}
.function-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
}
}
.function-icon {
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.3);
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 40rpx;
line-height: 1;
}
}
}
.calculator {
background: linear-gradient(135deg, #4ecdc4 0%, #44b8b0 100%);
box-shadow: 0 16rpx 48rpx rgba(78, 205, 196, 0.25);
}
.ai-nutritionist {
background: linear-gradient(135deg, #5b9bf3 0%, #4a8ae8 100%);
box-shadow: 0 16rpx 48rpx rgba(91, 155, 243, 0.25);
}
.food-encyclopedia {
background: linear-gradient(135deg, #ffb84d 0%, #ffa726 100%);
box-shadow: 0 16rpx 48rpx rgba(255, 184, 77, 0.25);
}
.nutrition-knowledge {
background: linear-gradient(135deg, #ff6b7a 0%, #ff5252 100%);
box-shadow: 0 16rpx 48rpx rgba(255, 107, 122, 0.25);
}
/* 通用区块样式 */
.section {
margin: 32rpx 32rpx 0;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
.section-title {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 500;
}
.section-more {
font-size: 32rpx;
color: #9fa5c0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
}
}
/* 精选食谱 */
.recipe-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.recipe-item {
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -4rpx rgba(0, 0, 0, 0.1);
display: flex;
gap: 24rpx;
}
.recipe-image {
width: 192rpx;
height: 192rpx;
border-radius: 32rpx;
overflow: hidden;
position: relative;
flex-shrink: 0;
box-shadow: 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 4rpx 8rpx -2rpx rgba(0, 0, 0, 0.1);
image {
width: 100%;
height: 100%;
}
.recipe-tag {
position: absolute;
left: 16rpx;
top: 16rpx;
border-radius: 16rpx;
padding: 8rpx 16rpx;
font-size: 24rpx;
color: #ffffff;
box-shadow: 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -4rpx rgba(0, 0, 0, 0.1);
&.tag-recommend {
background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 100%);
}
&.tag-mine {
background: linear-gradient(135deg, #4ecdc4 0%, #44b8b0 100%);
}
}
}
.recipe-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4rpx 0;
.recipe-title {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
margin-bottom: 12rpx;
line-height: 1.4;
}
.recipe-desc {
font-size: 24rpx;
color: #9fa5c0;
margin-bottom: 16rpx;
line-height: 1.4;
}
.recipe-meta {
display: flex;
gap: 32rpx;
align-items: center;
.meta-item {
display: flex;
align-items: center;
gap: 8rpx;
.meta-icon {
font-size: 24rpx;
width: 28rpx;
height: 28rpx;
}
.meta-text {
font-size: 24rpx;
color: #9fa5c0;
}
}
}
}
/* 营养方案领取卡片 */
.promotion-card {
margin: 32rpx 32rpx 0;
height: 192rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #ff6b35 0%, #ff7a4a 50%, #d64820 100%);
box-shadow: 0 40rpx 50rpx -10rpx rgba(0, 0, 0, 0.1), 0 16rpx 20rpx -6rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
.promotion-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
height: 100%;
position: relative;
z-index: 1;
}
.promotion-text {
.promotion-title {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
margin-bottom: 8rpx;
}
.promotion-desc {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
}
}
.promotion-btn {
background: #ffffff;
border: 2rpx solid rgba(255, 107, 53, 0.2);
border-radius: 24rpx;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 40rpx rgba(255, 107, 53, 0.3);
text {
font-size: 28rpx;
color: #ff6b35;
font-weight: 500;
}
}
}
/* 健康知识 */
.knowledge-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.knowledge-item {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -4rpx rgba(0, 0, 0, 0.1);
display: flex;
gap: 32rpx;
}
.knowledge-icon {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: #f4f5f7;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text {
font-size: 60rpx;
}
}
.knowledge-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.knowledge-title {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
margin-bottom: 12rpx;
}
.knowledge-desc {
font-size: 24rpx;
color: #9fa5c0;
line-height: 1.6;
margin-bottom: 16rpx;
}
.knowledge-meta {
display: flex;
align-items: center;
gap: 24rpx;
.meta-text {
font-size: 24rpx;
color: #9fa5c0;
}
.meta-dot {
font-size: 24rpx;
color: #d0dbea;
}
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
</style>