Files
msh-system/msh_single_uniapp/pages/tool/post-detail.vue
scottpan 4be53dcd1b feat: 集成 KieAI 服务,移除 models-integration 子项目
- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
2026-03-03 15:33:50 +08:00

1474 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="post-detail-page">
<!-- 加载中状态 -->
<view class="loading-container" v-if="isLoading">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y v-else>
<!-- 视频播放器区域优先显示 -->
<view class="video-player-section" v-if="postData.videoUrl">
<video
class="video-player"
:src="postData.videoUrl"
:poster="postData.images && postData.images.length > 0 ? postData.images[0] : ''"
controls
objectFit="contain"
show-center-play-btn
enable-play-gesture
></video>
<!-- 餐次标签 -->
<view class="meal-tag" v-if="postData.mealType">
<text class="meal-icon">{{ getMealIcon(postData.mealType) }}</text>
<text class="meal-text">{{ postData.mealType }}</text>
</view>
</view>
<!-- 图片轮播区域无视频时显示 -->
<view class="image-carousel" v-else-if="postData.images && postData.images.length > 0">
<swiper
class="swiper"
:indicator-dots="postData.images.length > 1"
:autoplay="false"
:current="currentImageIndex"
@change="onImageChange"
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#ffffff"
>
<swiper-item v-for="(image, index) in postData.images" :key="index">
<image class="carousel-image" :src="image" mode="aspectFill" @click="previewImage(index)"></image>
</swiper-item>
</swiper>
<!-- 餐次标签 -->
<view class="meal-tag" v-if="postData.mealType">
<text class="meal-icon">{{ getMealIcon(postData.mealType) }}</text>
<text class="meal-text">{{ postData.mealType }}</text>
</view>
<!-- 图片页码 -->
<view class="image-counter" v-if="postData.images.length > 1">
{{ currentImageIndex + 1 }} / {{ postData.images.length }}
</view>
</view>
<!-- 帖子内容区域 -->
<view class="post-content-section">
<!-- 无图片时显示类型标签 -->
<view class="type-tag-row" v-if="!postData.images || postData.images.length === 0">
<view class="type-tag">{{ postData.mealType || '分享' }}</view>
</view>
<!-- 标题和分数 -->
<view class="title-row">
<view class="post-title">{{ postData.title }}</view>
<view class="score-badge" v-if="postData.score > 0">
<text>{{ postData.score }}分</text>
</view>
</view>
<!-- 帖子描述 -->
<view class="post-description" v-if="postData.description">
<text>{{ postData.description }}</text>
</view>
<!-- 标签列表 -->
<view class="tag-list" v-if="postData.tags && postData.tags.length > 0">
<view
class="tag-item"
v-for="(tag, index) in postData.tags"
:key="index"
>
{{ tag }}
</view>
</view>
<!-- 作者信息 -->
<view class="author-section">
<view class="author-info">
<view class="author-avatar">
<image
v-if="postData.author.avatar && postData.author.avatar.startsWith('http')"
class="avatar-img"
:src="postData.author.avatar"
mode="aspectFill"
></image>
<text v-else class="avatar-icon">👤</text>
</view>
<view class="author-details">
<view class="author-name">{{ postData.author.name }}</view>
<view class="post-time">{{ postData.author.time }}</view>
</view>
</view>
<view
class="follow-btn"
:class="{ followed: isFollowed }"
@click="toggleFollow"
>
<text>{{ isFollowed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
</view>
<!-- 营养统计卡片:仅根据是否有数据展示 -->
<view class="nutrition-stats-card" v-if="nutritionStatsLength > 0">
<view class="stats-header">
<view class="stats-title">
<text class="title-icon">📊</text>
<text class="title-text">营养统计</text>
</view>
<view class="stats-unit">
<text>基于100g</text>
</view>
</view>
<view class="stats-grid">
<view class="stat-item" v-for="(stat, index) in postData.nutritionStats" :key="index">
<view class="stat-value">{{ stat.value }}</view>
<view class="stat-label">{{ stat.label }}</view>
</view>
</view>
</view>
<!-- AI营养师点评 -->
<view class="ai-comment-card" v-if="postData.aiComment">
<view class="ai-header">
<view class="ai-avatar">AI</view>
<view class="ai-info">
<view class="ai-name-row">
<text class="ai-name">营养师点评</text>
<text class="ai-verified">已认证</text>
</view>
<view class="ai-comment-text">
{{ postData.aiComment }}
</view>
</view>
</view>
</view>
<!-- 一键借鉴打卡按钮 -->
<view class="copy-checkin-btn" @click="handleCopyCheckin">
<text class="btn-icon">🎬</text>
<text class="btn-text">一键借鉴打卡</text>
</view>
<!-- 分隔线 -->
<view class="divider"></view>
<!-- 评论区域 -->
<view class="comments-section">
<view class="comments-header">
<text class="comments-icon">💬</text>
<text class="comments-title">全部评论 {{ postData.comments.length }}</text>
</view>
<view class="comments-list">
<view
class="comment-item"
v-for="(comment, index) in postData.comments"
:key="index"
>
<view class="comment-avatar">
<text>{{ comment.avatar }}</text>
</view>
<view class="comment-content">
<view class="comment-header">
<text class="comment-name">{{ comment.name }}</text>
<text class="comment-time">{{ comment.time }}</text>
</view>
<view class="comment-text">{{ comment.text }}</view>
<view class="comment-actions">
<view class="like-btn" @click="toggleCommentLike(comment, index)">
<text class="like-icon">❤️</text>
<text class="like-count">{{ comment.likeCount }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 相关推荐 -->
<view class="related-section">
<view class="related-header">
<text class="related-icon">✨</text>
<text class="related-title">相关推荐</text>
</view>
<view class="related-list">
<view
class="related-item"
v-for="(item, index) in relatedPosts"
:key="index"
@click="goToRelatedPost(item)"
>
<image class="related-image" :src="item.image" mode="aspectFill"></image>
</view>
</view>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-bar">
<view class="input-box" @click="focusInput">
<text class="input-placeholder">说点什么...</text>
</view>
<view class="action-buttons">
<view class="action-btn-item" @click="toggleLike">
<text class="action-icon">❤️</text>
<text class="action-count">{{ postData.likeCount }}</text>
</view>
<view class="action-btn-item" @click="toggleFavorite">
<text class="action-icon">⭐</text>
<text class="action-count">{{ postData.favoriteCount }}</text>
</view>
<!-- <view class="action-btn-item" @click="handleShare">
<text class="action-icon">📤</text>
</view> -->
</view>
</view>
</view>
</template>
<script>
import {
getCommunityDetail,
getCheckinDetail,
toggleLike as apiToggleLike,
toggleCollect,
toggleFollow as apiToggleFollow,
getCommentList,
addComment,
getCommunityList
} from '@/api/tool.js'
import { checkLogin, toLogin } from '@/libs/login.js'
export default {
data() {
return {
postId: null,
currentImageIndex: 0,
isLoading: true,
isFollowed: false,
isLiked: false,
isCollected: false,
postData: {
id: null,
images: [],
mealType: '',
title: '',
score: 0,
description: '',
tags: [],
author: {
id: null,
avatar: '👤',
name: '',
time: ''
},
nutritionStats: [],
aiComment: '',
comments: [],
likeCount: 0,
favoriteCount: 0,
commentCount: 0,
videoUrl: null,
videoStatus: null,
hasVideo: false
},
relatedPosts: [],
commentPage: 1,
commentLimit: 10,
hasMoreComments: true,
isLoadingComments: false,
showCommentInput: false,
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
this.loadPostData(options.id)
}
},
methods: {
// 加载帖子详情
async loadPostData(id) {
this.isLoading = true
try {
const res = await getCommunityDetail(id)
const data = res.data || res
// 格式化帖子数据
this.formatPostData(data)
// 若详情接口未返回营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载)
if (this.postData.nutritionStats.length === 0 && (data.checkInRecordId != null)) {
await this.fillNutritionStatsFromCheckin(data.checkInRecordId)
}
// 同步状态
this.isLiked = data.isLiked || false
this.isCollected = data.isCollected || false
this.isFollowed = data.isFollowed || false
// 加载评论
this.loadComments()
// 加载相关推荐
this.loadRelatedPosts()
} catch (error) {
console.error('加载帖子详情失败:', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
this.isLoading = false
}
},
/**
* 从详情接口返回的数据中构建 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) {
// 解析图片
let images = []
if (data.imagesJson) {
try {
images = typeof data.imagesJson === 'string'
? JSON.parse(data.imagesJson)
: data.imagesJson
} catch (e) {
images = []
}
}
if (data.coverImage && !images.includes(data.coverImage)) {
images.unshift(data.coverImage)
}
// 解析标签
let tags = []
if (data.tagsJson) {
try {
const parsedTags = typeof data.tagsJson === 'string'
? JSON.parse(data.tagsJson)
: data.tagsJson
tags = parsedTags.map(tag => {
if (typeof tag === 'string') {
return tag.startsWith('#') ? tag : '#' + tag
}
return tag.name ? '#' + tag.name : ''
}).filter(t => t)
} catch (e) {
tags = []
}
}
// 如果没有标签,从内容中提取关键字
if (tags.length === 0 && data.content) {
const keywordMatch = data.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, 4).map(k => '#' + k)
}
}
// 解析营养数据:支持多种后端字段名,以及从打卡数据计算
let nutritionStats = this.buildNutritionStatsFromDetailData(data)
// 提取纯文本内容
let description = ''
if (data.content) {
description = this.stripHtml(data.content)
// 移除关键字和简介标记
description = description
.replace(/关键字[:][^简介]*/i, '')
.replace(/简介[:]/i, '')
.trim()
}
this.postData = {
id: data.id || data.postId,
images: images.length > 0 ? images : [],
mealType: data.mealType || '分享',
title: data.title || '',
score: data.score || 0,
description: description,
tags: tags,
author: {
id: data.userId,
avatar: data.userAvatar || '👤',
name: data.userName || '匿名用户',
time: this.formatTime(data.createdAt)
},
nutritionStats: nutritionStats,
aiComment: data.aiComment || '',
comments: [],
likeCount: data.likeCount || 0,
favoriteCount: data.collectCount || 0,
commentCount: data.commentCount || 0,
checkInRecordId: data.checkInRecordId,
videoUrl: data.videoUrl || null,
videoStatus: data.videoStatus,
hasVideo: data.hasVideo || false
}
},
// 移除HTML标签
stripHtml(html) {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<hr\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.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, '/'))
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 loadComments() {
if (this.isLoadingComments || !this.hasMoreComments) return
this.isLoadingComments = true
try {
const res = await getCommentList(this.postId, {
page: this.commentPage,
limit: this.commentLimit
})
const list = res.data?.list || res.data || []
const formattedComments = list.map(item => ({
id: item.id,
avatar: item.userAvatar || '👤',
name: item.userName || '匿名用户',
time: this.formatTime(item.createdAt),
text: item.content,
likeCount: item.likeCount || 0,
isLiked: item.isLiked || false,
userId: item.userId
}))
if (this.commentPage === 1) {
this.postData.comments = formattedComments
} else {
this.postData.comments = [...this.postData.comments, ...formattedComments]
}
this.hasMoreComments = list.length >= this.commentLimit
} catch (error) {
console.error('加载评论失败:', error)
} finally {
this.isLoadingComments = false
}
},
// 加载相关推荐
async loadRelatedPosts() {
try {
const res = await getCommunityList({
tab: 'recommend',
page: 1,
limit: 4
})
const list = res.data?.list || res.data || []
this.relatedPosts = list
.filter(item => item.id !== this.postData.id)
.slice(0, 2)
.map(item => {
let coverImage = item.coverImage || ''
if (!coverImage && item.imagesJson) {
try {
const images = typeof item.imagesJson === 'string'
? JSON.parse(item.imagesJson)
: item.imagesJson
coverImage = images[0] || ''
} catch (e) {}
}
return {
id: item.id || item.postId,
image: coverImage,
title: item.title
}
})
} catch (error) {
console.error('加载相关推荐失败:', error)
}
},
onImageChange(e) {
this.currentImageIndex = e.detail.current
},
// 关注/取消关注
async toggleFollow() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
const newFollowState = !this.isFollowed
this.isFollowed = newFollowState
try {
await apiToggleFollow(this.postData.author.id, newFollowState)
uni.showToast({
title: newFollowState ? '已关注' : '取消关注',
icon: 'none'
})
} catch (error) {
this.isFollowed = !newFollowState
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
},
// 一键借鉴打卡
handleCopyCheckin() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
const recordId = this.postData.checkInRecordId || this.postData.id
uni.navigateTo({
url: `/pages/tool/checkin-publish?sourcePostId=${this.postData.id}&sourceType=learn`
})
},
// 评论点赞
async toggleCommentLike(comment, index) {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
const newLikeState = !comment.isLiked
const newLikeCount = newLikeState ? comment.likeCount + 1 : comment.likeCount - 1
// 乐观更新UI
this.$set(this.postData.comments, index, {
...comment,
isLiked: newLikeState,
likeCount: newLikeCount
})
// 调用评论点赞API复用帖子点赞接口传入评论ID
try {
await apiToggleLike(comment.id, newLikeState)
} catch (error) {
// 回滚
this.$set(this.postData.comments, index, {
...comment,
isLiked: !newLikeState,
likeCount: comment.likeCount
})
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
},
// 帖子点赞
async toggleLike() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
const newLikeState = !this.isLiked
const newLikeCount = newLikeState
? this.postData.likeCount + 1
: this.postData.likeCount - 1
// 乐观更新
this.isLiked = newLikeState
this.postData.likeCount = newLikeCount
try {
await apiToggleLike(this.postId, newLikeState)
} catch (error) {
// 回滚
this.isLiked = !newLikeState
this.postData.likeCount = newLikeState
? newLikeCount - 1
: newLikeCount + 1
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
},
// 收藏
async toggleFavorite() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
const newCollectState = !this.isCollected
const newCollectCount = newCollectState
? this.postData.favoriteCount + 1
: this.postData.favoriteCount - 1
// 乐观更新
this.isCollected = newCollectState
this.postData.favoriteCount = newCollectCount
try {
await toggleCollect(this.postId, newCollectState)
uni.showToast({
title: newCollectState ? '已收藏' : '取消收藏',
icon: 'none'
})
} catch (error) {
// 回滚
this.isCollected = !newCollectState
this.postData.favoriteCount = newCollectState
? newCollectCount - 1
: newCollectCount + 1
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
},
handleShare() {
uni.showShareMenu({
withShareTicket: true
})
},
// 点击评论输入框
focusInput() {
if (!checkLogin()) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => toLogin(), 1000)
return
}
// 显示评论输入弹窗
uni.showModal({
title: '发表评论',
editable: true,
placeholderText: '说点什么...',
success: async (res) => {
if (res.confirm && res.content) {
await this.submitComment(res.content)
}
}
})
},
// 提交评论
async submitComment(content) {
if (!content.trim()) {
uni.showToast({ title: '请输入评论内容', icon: 'none' })
return
}
uni.showLoading({ title: '发送中...' })
try {
await addComment({
postId: this.postId,
content: content.trim()
})
uni.hideLoading()
uni.showToast({ title: '评论成功', icon: 'success' })
// 刷新评论列表
this.commentPage = 1
this.hasMoreComments = true
this.loadComments()
// 更新评论数
this.postData.commentCount++
} catch (error) {
uni.hideLoading()
uni.showToast({
title: error.message || '评论失败,请重试',
icon: 'none'
})
}
},
previewImage(index) {
if (this.postData.images && this.postData.images.length > 0) {
uni.previewImage({
current: index,
urls: this.postData.images
})
}
},
getMealIcon(type) {
const map = {
'早餐': '🌅',
'午餐': '☀️',
'晚餐': '🌙',
'加餐': '🍪',
'breakfast': '🌅',
'lunch': '☀️',
'dinner': '🌙',
'snack': '🍪'
}
return map[type] || '🍽️'
},
goToRelatedPost(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)
}
}
}
</script>
<style lang="scss" scoped>
.post-detail-page {
min-height: 100vh;
background: linear-gradient(180deg, #fafafa 0%, #ffffff 100%);
display: flex;
flex-direction: column;
}
/* 内容滚动区域 */
.content-scroll {
flex: 1;
margin-bottom: 120rpx;
}
/* 视频播放器区域 */
.video-player-section {
width: 100%;
height: 750rpx;
position: relative;
background: #000000;
.video-player {
width: 100%;
height: 100%;
}
.meal-tag {
position: absolute;
left: 32rpx;
top: 32rpx;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50rpx;
padding: 8rpx 16rpx;
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
z-index: 10;
.meal-icon {
font-size: 32rpx;
}
.meal-text {
font-size: 28rpx;
color: #3e5481;
}
}
}
/* 图片轮播区域 */
.image-carousel {
width: 100%;
height: 750rpx;
position: relative;
background: #f4f5f7;
.swiper {
width: 100%;
height: 100%;
}
.carousel-image {
width: 100%;
height: 100%;
}
.meal-tag {
position: absolute;
left: 32rpx;
top: 32rpx;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50rpx;
padding: 8rpx 16rpx;
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
.meal-icon {
font-size: 32rpx;
}
.meal-text {
font-size: 28rpx;
color: #3e5481;
}
}
.image-counter {
position: absolute;
right: 32rpx;
bottom: 32rpx;
background: rgba(0, 0, 0, 0.7);
border-radius: 50rpx;
padding: 8rpx 16rpx;
font-size: 24rpx;
color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
}
/* 帖子内容区域 */
.post-content-section {
padding: 32rpx;
background: #ffffff;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 24rpx;
.post-title {
flex: 1;
font-size: 36rpx;
color: #2e3e5c;
line-height: 1.4;
font-weight: 400;
}
.score-badge {
background: linear-gradient(135deg, #ff8c5a 0%, #ff6b35 50%, #e85a2a 100%);
border-radius: 50rpx;
padding: 8rpx 20rpx;
font-size: 28rpx;
color: #ffffff;
white-space: nowrap;
}
}
.post-description {
font-size: 28rpx;
color: #3e5481;
line-height: 1.6;
margin-bottom: 32rpx;
white-space: pre-wrap;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 32rpx;
}
.tag-item {
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 100%);
border: 1rpx solid rgba(255, 136, 68, 0.3);
border-radius: 50rpx;
padding: 8rpx 24rpx;
font-size: 24rpx;
color: #ff7722;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
}
.author-section {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 32rpx;
border-top: 1rpx solid #d0dbea;
}
.author-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.author-avatar {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 100%);
border: 2rpx solid #ff7722;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar-icon {
font-size: 36rpx;
}
}
.author-details {
display: flex;
flex-direction: column;
gap: 8rpx;
.author-name {
font-size: 28rpx;
color: #2e3e5c;
}
.post-time {
font-size: 24rpx;
color: #9fa5c0;
}
}
.follow-btn {
background: linear-gradient(135deg, #ff8c5a 0%, #ff6b35 100%);
border-radius: 50rpx;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
}
/* 营养统计卡片 */
.nutrition-stats-card {
margin: 10rpx 32rpx;
padding: 32rpx;
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 50%, #ffffff 100%);
border: 2rpx solid rgba(255, 136, 68, 0.3);
border-radius: 32rpx;
box-shadow: 0 16rpx 60rpx rgba(255, 136, 68, 0.15);
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.stats-title {
display: flex;
align-items: center;
gap: 16rpx;
.title-icon {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, #ff9966 0%, #ff8844 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
box-shadow: 0 8rpx 12rpx rgba(0, 0, 0, 0.1);
}
.title-text {
font-size: 28rpx;
color: #2e3e5c;
}
}
.stats-unit {
background: rgba(255, 255, 255, 0.9);
border: 1rpx solid rgba(255, 136, 68, 0.3);
border-radius: 50rpx;
padding: 8rpx 20rpx;
font-size: 24rpx;
color: #ff7722;
}
.stats-grid {
display: flex;
gap: 24rpx;
}
.stat-item {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border: 1rpx solid rgba(255, 136, 68, 0.2);
border-radius: 24rpx;
padding: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.stat-value {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 500;
}
.stat-label {
font-size: 24rpx;
color: #9fa5c0;
text-align: center;
}
}
/* AI营养师点评 */
.ai-comment-card {
margin: 32rpx;
padding: 32rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
}
.ai-header {
display: flex;
gap: 24rpx;
}
.ai-avatar {
width: 72rpx;
height: 72rpx;
background: #ff6b35;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #ffffff;
flex-shrink: 0;
}
.ai-info {
flex: 1;
}
.ai-name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
.ai-name {
font-size: 28rpx;
color: #2e3e5c;
}
.ai-verified {
font-size: 24rpx;
color: #9fa5c0;
}
}
.ai-comment-text {
font-size: 28rpx;
color: #3e5481;
line-height: 1.6;
}
/* 一键借鉴打卡按钮 */
.copy-checkin-btn {
margin: 22rpx;
height: 94rpx;
background: linear-gradient(135deg, #ff8844 0%, #ff6611 50%, #ff7722 100%);
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
box-shadow: 0 16rpx 60rpx rgba(255, 107, 53, 0.3);
.btn-icon {
font-size: 36rpx;
}
.btn-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
}
/* 分隔线 */
.divider {
height: 16rpx;
background: #f4f5f7;
}
/* 评论区域 */
.comments-section {
padding: 32rpx;
background: #ffffff;
}
.comments-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 32rpx;
.comments-icon {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, #ff9966 0%, #ff8844 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
box-shadow: 0 8rpx 12rpx rgba(0, 0, 0, 0.1);
}
.comments-title {
font-size: 28rpx;
color: #2e3e5c;
}
}
.comments-list {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.comment-item {
display: flex;
gap: 24rpx;
}
.comment-avatar {
width: 72rpx;
height: 72rpx;
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 100%);
border: 1rpx solid rgba(255, 136, 68, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
flex-shrink: 0;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
.comment-name {
font-size: 28rpx;
color: #2e3e5c;
}
.comment-time {
font-size: 24rpx;
color: #9fa5c0;
}
}
.comment-text {
font-size: 28rpx;
color: #3e5481;
line-height: 1.6;
margin-bottom: 16rpx;
}
.comment-actions {
display: flex;
align-items: center;
gap: 8rpx;
}
.like-btn {
display: flex;
align-items: center;
gap: 8rpx;
.like-icon {
font-size: 28rpx;
}
.like-count {
font-size: 24rpx;
color: #9fa5c0;
}
}
/* 相关推荐 */
.related-section {
padding: 32rpx;
background: linear-gradient(180deg, #fafafa 0%, #f4f5f7 100%);
}
.related-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 32rpx;
.related-icon {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, #ff8c5a 0%, #ff6b35 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
box-shadow: 0 8rpx 12rpx rgba(0, 0, 0, 0.1);
}
.related-title {
font-size: 28rpx;
color: #2e3e5c;
}
}
.related-list {
display: flex;
gap: 24rpx;
}
.related-item {
flex: 1;
height: 440rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 32rpx;
overflow: hidden;
.related-image {
width: 100%;
height: 100%;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: rgba(255, 255, 255, 0.95);
border-top: 1rpx solid rgba(0, 0, 0, 0.1);
padding: 0 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
z-index: 1000;
}
.input-box {
flex: 1;
height: 72rpx;
background: #f4f5f7;
border-radius: 50rpx;
padding: 0 32rpx;
display: flex;
align-items: center;
.input-placeholder {
font-size: 28rpx;
color: #9fa5c0;
}
}
.action-buttons {
display: flex;
gap: 24rpx;
align-items: center;
}
.action-btn-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
.action-icon {
font-size: 40rpx;
}
.action-count {
font-size: 24rpx;
color: #9fa5c0;
}
}
</style>