Files
msh-system/msh_single_uniapp/pages/tool/post-detail.vue
msh-agent c1857ce852 fix: 修复关注按钮相关问题
- 食谱详情页: 修复 applyDefaultData 中未定义变量 id 的问题
- 帖子详情页: 优化 toggleFollow 方法,提前校验 author.id,兼容多种后端字段
- 为帖子详情页已关注状态添加灰色样式
2026-03-09 18:56:53 +08:00

1734 lines
46 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">{{ getMealTypeLabel(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">{{ getMealTypeLabel(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">{{ getMealTypeLabel(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
v-if="currentUserId !== postData.author.id"
class="follow-btn"
:class="{ followed: isFollowed }"
@click="toggleFollow"
>
<text>{{ isFollowed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
</view>
<!-- 营养统计卡片:仅当 nutritionStats.length > 0 时展示,不依赖后端字段存在性 -->
<view class="nutrition-stats-card" v-if="nutritionStats.length > 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 nutritionStats" :key="index">
<view class="stat-value">{{ stat.value }}</view>
<view class="stat-label">{{ stat.label }}</view>
</view>
</view>
</view>
<!-- T08/T09: 无营养数据时展示「分析营养成分」按钮 -->
<view class="nutrition-fill-row" v-else-if="!isLoading && (postData.description || postData.title)">
<button
class="ai-fill-btn"
:disabled="aiNutritionFilling"
@click="fillNutritionByAi"
>
<text v-if="!aiNutritionFilling">🤖 分析营养成分</text>
<text v-else>估算中...</text>
</button>
</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,
getAiNutritionFill,
fillPostNutrition,
toggleLike as apiToggleLike,
toggleCollect,
toggleFollow as apiToggleFollow,
getCommentList,
addComment,
getCommunityList
} from '@/api/tool.js'
import { checkLogin, toLogin } from '@/libs/login.js'
import Cache from '@/utils/cache'
import { USER_INFO } from '@/config/cache'
export default {
data() {
return {
postId: null,
currentUserId: 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: '',
aiNutritionFilling: false
}
},
computed: {
// 营养统计数组,用于卡片显示与列表渲染(单一数据源,避免未定义)
// 若全部为占位符 "-" 则视为无有效数据不展示卡片以便显示「AI 补充营养」等
nutritionStats() {
const stats = this.postData && this.postData.nutritionStats
const arr = Array.isArray(stats) ? stats : []
if (arr.length === 0) return []
const allDash = arr.every(s => s.value === '-' || s.value === '')
return allDash ? [] : arr
}
},
onLoad(options) {
const userInfo = Cache.get(USER_INFO, true)
if (userInfo) {
this.currentUserId = userInfo.uid || userInfo.id || null
}
if (options.id) {
// Ensure postId is number for API calls (URL params are strings)
this.postId = parseInt(options.id, 10) || options.id
this.loadPostData(this.postId)
}
},
methods: {
// 获取餐次类型中文标签
getMealTypeLabel(mealType) {
if (!mealType) return '分享'
const map = { breakfast:'早餐', lunch:'午餐', dinner:'晚餐', snack:'加餐', share:'分享', checkin:'打卡' }
return map[String(mealType).toLowerCase()] ?? '分享'
},
// 加载帖子详情
async loadPostData(id) {
this.isLoading = true
try {
const res = await getCommunityDetail(id)
// 兼容 CommonResultres = { code, data: 详情对象 } 或 { result: 详情对象 },取 data/result 作为详情
const data = (res && res.data !== undefined && res.data !== null)
? res.data
: (res && res.result !== undefined && res.result !== null)
? res.result
: res
// 格式化帖子数据
this.formatPostData(data)
// 判断是否有有效营养数据(有至少一项非占位符)
const hasValidNutritionStats = () => {
const arr = this.postData.nutritionStats
if (!Array.isArray(arr) || arr.length === 0) return false
return arr.some(s => s.value !== '-' && s.value !== '')
}
// 若详情接口未返回有效营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载)
const rawCheckInId = data.checkInRecordId ?? data.check_in_record_id
const checkInId = rawCheckInId != null ? (Number(rawCheckInId) || rawCheckInId) : null
if (!hasValidNutritionStats() && checkInId != null) {
await this.fillNutritionStatsFromCheckin(checkInId)
}
// T07: 营养仍无有效数据时调用服务端填充接口
if (!hasValidNutritionStats()) {
await this.fillNutritionFromServer()
}
// 同步状态
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) {
const mapped = rawStats.map(s => ({
label: s.label || s.name || '',
value: s.value != null ? String(s.value) : '-'
})).filter(s => s.label)
// 若全部为占位符 "-",视为无有效数据,返回 [] 以便走打卡详情/服务端填充
if (mapped.length > 0 && mapped.every(s => s.value === '-' || s.value === '')) {
return []
}
return mapped
}
// 1b) 后端返回 nutritionStats 为对象(如 { protein: 56, calories: 200 })时按营养对象解析
if (rawStats && typeof rawStats === 'object' && !Array.isArray(rawStats) && Object.keys(rawStats).length > 0) {
const statsFromObj = this.buildNutritionStatsFromNutritionObject(rawStats)
if (statsFromObj.length > 0 && !statsFromObj.every(s => s.value === '-')) return statsFromObj
}
// 2) nutritionDataJson / nutrition_data_json兼容后端驼峰与下划线含 fill-nutrition 的 energyKcal/proteinG、嵌套格式 calories: { value, unit }
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 []
// 2a) 若为数组格式 [{label, value}, ...],直接使用
if (Array.isArray(nutritionData) && nutritionData.length > 0) {
return nutritionData.map(s => ({
label: s.label || s.name || '',
value: s.value != null ? String(s.value) : '-'
})).filter(s => s.label)
}
// 2b) 对象格式:空对象 "{}" 视为无营养数据,返回 [] 以便走打卡详情/服务端填充
if (!Array.isArray(nutritionData) && Object.keys(nutritionData).length === 0) {
return []
}
// 2c) 有键的对象:兼容后端嵌套 { calories: { value, unit }, protein: { value, unit }, ... } 与扁平 energyKcal/proteinG
const statsFromJson = this.buildNutritionStatsFromNutritionObject(nutritionData)
// 若解析后全部为占位符 "-",视为无效数据,返回 [] 以便走打卡/服务端填充
if (statsFromJson.length > 0 && statsFromJson.every(s => s.value === '-')) {
return []
}
return statsFromJson
} catch (e) {
// ignore
}
}
// 2d) nutritionData / nutrition_data 为已解析对象(部分接口直接返回对象)
const nutritionDataObj = data.nutritionData || data.nutrition_data
if (nutritionDataObj && typeof nutritionDataObj === 'object' && !Array.isArray(nutritionDataObj) && Object.keys(nutritionDataObj).length > 0) {
const statsFromObj = this.buildNutritionStatsFromNutritionObject(nutritionDataObj)
if (statsFromObj.length > 0 && !statsFromObj.every(s => s.value === '-')) return statsFromObj
}
// 2e) 详情中嵌套的打卡记录(若后端返回 checkInRecord/check_in_record直接从中取营养
const checkInRecord = data.checkInRecord || data.check_in_record
if (checkInRecord && typeof checkInRecord === 'object') {
const statsFromCheckin = this.buildNutritionStatsFromCheckinDetail(checkInRecord)
if (statsFromCheckin.length > 0 && statsFromCheckin.some(s => s.value !== '-' && s.value !== '')) return statsFromCheckin
}
// 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' && !Array.isArray(obj)) {
const statsFromObj = this.buildNutritionStatsFromNutritionObject(obj)
if (statsFromObj.length > 0) return statsFromObj
}
}
// 4) data 本身为营养对象(如 fill-nutrition 接口直接返回的 { energyKcal, proteinG, potassiumMg, phosphorusMg }
if (data && typeof data === 'object' && !Array.isArray(data) && (data.energyKcal != null || data.proteinG != null || data.actualEnergy != null || data.actualProtein != null || data.calories != null || data.protein != null)) {
const statsFromData = this.buildNutritionStatsFromNutritionObject(data)
if (statsFromData.length > 0) return statsFromData
}
// 5) nutrition 对象(部分接口返回的 data.nutrition
const nutritionObj = data.nutrition
if (nutritionObj && typeof nutritionObj === 'object' && !Array.isArray(nutritionObj)) {
const statsFromNut = this.buildNutritionStatsFromNutritionObject(nutritionObj)
if (statsFromNut.length > 0) return statsFromNut
}
return []
},
/**
* 从对象中取数值,兼容后端嵌套格式 { calories: { value, unit } } 与扁平格式 energyKcal/proteinG
* 嵌套 value 支持 number 或 string后端 BigDecimal 等可能序列化为字符串)
*/
readNutritionNumber(obj, ...flatKeys) {
if (!obj || typeof obj !== 'object') return null
for (const key of flatKeys) {
const v = obj[key]
if (v == null) continue
if (typeof v === 'number' && !Number.isNaN(v)) return v
// 嵌套格式 { value, unit }value 可能为 number 或 string
if (typeof v === 'object' && v !== null && 'value' in v) {
const val = v.value
if (typeof val === 'number' && !Number.isNaN(val)) return val
const str = typeof val === 'string' ? val.trim() : (val != null ? String(val) : '')
if (str !== '' && str !== 'undefined' && str !== 'null') {
const n = parseFloat(str)
if (!Number.isNaN(n)) return n
}
}
const s = typeof v === 'string' ? v.trim() : String(v)
if (s !== '' && s !== 'undefined' && s !== 'null') {
const n = parseFloat(s)
if (!Number.isNaN(n)) return n
}
}
return null
},
/** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG、嵌套 calories: { value, unit }、下划线命名 */
buildNutritionStatsFromNutritionObject(obj) {
if (!obj || typeof obj !== 'object') return []
const cal = this.readNutritionNumber(obj, 'energyKcal', 'energy_kcal', 'calories', 'energy', 'calorie', 'actualEnergy')
const pro = this.readNutritionNumber(obj, 'proteinG', 'protein_g', 'protein', 'proteins', 'actualProtein')
const pot = this.readNutritionNumber(obj, 'potassiumMg', 'potassium_mg', 'potassium', 'k')
const pho = this.readNutritionNumber(obj, 'phosphorusMg', 'phosphorus_mg', 'phosphorus', 'p')
return [
{ label: '热量(kcal)', value: cal != null ? String(cal) : '-' },
{ label: '蛋白质', value: this.formatNutritionValue(pro, 'g') },
{ label: '钾', value: pot != null ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
{ label: '磷', value: pho != null ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
]
},
/** 格式化营养素显示值(兼容 number/string/BigDecimal 等) */
formatNutritionValue(val, unit) {
if (val == null || val === '') return '-'
const str = typeof val === 'number' ? String(val) : String(val)
return str === '' || str === 'undefined' || str === 'null' ? '-' : (unit ? str + unit : str)
},
/**
* 根据打卡详情接口返回的数据构建 nutritionStats热量、蛋白质、钾、磷
* 后端返回actualEnergy/actualProtein驼峰或 actual_energy/actual_protein下划线可能嵌套在 nutrition/dietaryData/aiResult 等
*/
buildNutritionStatsFromCheckinDetail(detail) {
if (!detail || typeof detail !== 'object') return []
// 优先从嵌套营养对象解析(兼容 aiResult、nutrition、dietaryData 等含完整营养字段)
const nested = detail.nutrition || detail.dietaryData || detail.dietary_data || detail.mealData || detail.meal_data
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const obj = typeof nested === 'string' ? (() => { try { return JSON.parse(nested) } catch (_) { return null } })() : nested
if (obj && typeof obj === 'object') {
const stats = this.buildNutritionStatsFromNutritionObject(obj)
if (stats.length > 0 && !stats.every(s => s.value === '-')) return stats
}
}
// aiResult 可能为 JSON 字符串,内含营养字段
const aiResult = detail.aiResult || detail.ai_result
if (aiResult) {
try {
const parsed = typeof aiResult === 'string' ? JSON.parse(aiResult) : aiResult
if (parsed && typeof parsed === 'object') {
const stats = this.buildNutritionStatsFromNutritionObject(parsed)
if (stats.length > 0 && !stats.every(s => s.value === '-')) return stats
}
} catch (_) {}
}
// 顶层字段:后端 getDetail 返回 actualEnergy、actualProtein驼峰
const energy = detail.actualEnergy ?? detail.actual_energy ?? detail.energy ?? detail.energy_kcal
const protein = detail.actualProtein ?? detail.actual_protein ?? detail.protein ?? detail.protein_g ?? detail.proteinG
const pot = detail.potassiumMg ?? detail.potassium_mg ?? detail.potassium ?? detail.k
const pho = detail.phosphorusMg ?? detail.phosphorus_mg ?? detail.phosphorus ?? detail.p
const stats = [
{ label: '热量(kcal)', value: energy != null && energy !== '' ? String(energy) : '-' },
{ label: '蛋白质', value: this.formatNutritionValue(protein, 'g') },
{ label: '钾', value: pot != null && pot !== '' ? (typeof pot === 'number' ? pot + 'mg' : String(pot)) : '-' },
{ label: '磷', value: pho != null && pho !== '' ? (typeof pho === 'number' ? pho + 'mg' : String(pho)) : '-' }
]
return stats
},
/**
* 根据打卡记录 ID 拉取打卡详情,并填充 postData.nutritionStats不阻塞主流程
* 接口路径GET tool/checkin/detail/{id},返回 CommonResult 即 { code, data: { actualEnergy, actualProtein, ... } }
*/
async fillNutritionStatsFromCheckin(checkInRecordId) {
try {
const res = await getCheckinDetail(checkInRecordId)
// 兼容res 为 { code, data } 或 { result } 时取 data/result部分封装直接返回 payload 则 res 即为 detail
const detail = (res && res.data !== undefined && res.data !== null)
? res.data
: (res && res.result !== undefined && res.result !== null)
? res.result
: res
if (!detail || typeof detail !== 'object') return
const stats = this.buildNutritionStatsFromCheckinDetail(detail)
// 仅当至少有一项有效值时才更新,避免展示全部为 "-" 的卡片
const hasAnyValue = stats.length > 0 && stats.some(s => s.value !== '-' && s.value !== '')
if (hasAnyValue) {
this.$set(this.postData, 'nutritionStats', stats)
}
} catch (e) {
console.warn('拉取打卡详情补充营养数据失败:', e)
}
},
/**
* T07: 调用服务端 fill-nutrition 接口,根据帖子内容补充营养并更新本地展示
*/
async fillNutritionFromServer() {
if (!this.postId) return
try {
const res = await fillPostNutrition(this.postId)
// fill-nutrition 返回 CommonResult.success(nutrition),营养对象在 res.data
const data = (res && res.data !== undefined && res.data !== null) ? res.data : res
if (!data) return
const stats = this.buildNutritionStatsFromDetailData(data)
if (stats.length > 0 && !stats.every(s => s.value === '-' || s.value === '')) {
this.$set(this.postData, 'nutritionStats', stats)
console.log('[post-detail] 营养数据已补齐:', stats)
}
} catch (e) {
console.warn('服务端填充帖子营养失败:', e)
}
},
/**
* T08/T09: 用 AI 根据帖子描述估算营养并填充 nutritionStats
*/
async fillNutritionByAi() {
const text = (this.postData.description || this.postData.title || '').trim()
if (!text) {
uni.showToast({ title: '暂无描述可估算', icon: 'none' })
return
}
this.aiNutritionFilling = true
try {
const res = await getAiNutritionFill(text)
const data = (res && res.data) ? res.data : res
if (!data || typeof data !== 'object') {
uni.showToast({ title: 'AI 估算暂无结果', icon: 'none' })
return
}
const energy = data.energyKcal != null ? String(data.energyKcal) : '-'
const protein = this.formatNutritionValue(data.proteinG, 'g')
const potassium = data.potassiumMg != null ? String(data.potassiumMg) + 'mg' : '-'
const phosphorus = data.phosphorusMg != null ? String(data.phosphorusMg) + 'mg' : '-'
this.$set(this.postData, 'nutritionStats', [
{ label: '热量(kcal)', value: energy },
{ label: '蛋白质', value: protein },
{ label: '钾', value: potassium },
{ label: '磷', value: phosphorus }
])
uni.showToast({ title: '已补充营养估算', icon: 'success' })
} catch (e) {
console.warn('AI 营养估算失败:', e)
uni.showToast({ title: '估算失败,请重试', icon: 'none' })
} finally {
this.aiNutritionFilling = false
}
},
// 格式化帖子数据
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)
// 若全部为占位符 "-",视为无有效数据,避免占位数组阻塞后续打卡详情/服务端填充
if (Array.isArray(nutritionStats) && nutritionStats.length > 0 && nutritionStats.every(s => s.value === '-' || s.value === '')) {
nutritionStats = []
}
// 提取纯文本内容
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 ?? data.authorId ?? data.user_id ?? data.author_id ?? null,
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 ?? data.check_in_record_id ?? null,
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
}
// 先检查 author.id 是否存在,再改状态、调 API避免无效请求导致「操作失败」
const authorId = this.postData && this.postData.author && (this.postData.author.id ?? this.postData.author.userId)
if (authorId == null || authorId === '') {
uni.showToast({ title: '无法获取作者信息', icon: 'none' })
return
}
const newFollowState = !this.isFollowed
this.isFollowed = newFollowState
try {
await apiToggleFollow(authorId, 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 postIdNum = typeof this.postId === 'number' ? this.postId : parseInt(this.postId, 10)
if (!postIdNum || isNaN(postIdNum)) {
console.error('[post-detail] toggleLike: invalid postId', this.postId)
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
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(postIdNum, newLikeState)
} catch (error) {
console.error('[post-detail] toggleLike failed:', 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 postIdNum = typeof this.postId === 'number' ? this.postId : parseInt(this.postId, 10)
if (!postIdNum || isNaN(postIdNum)) {
console.error('[post-detail] toggleFavorite: invalid postId', this.postId)
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
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(postIdNum, newCollectState)
uni.showToast({
title: newCollectState ? '已收藏' : '取消收藏',
icon: 'none'
})
} catch (error) {
console.error('[post-detail] toggleFavorite failed:', 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;
}
}
.follow-btn.followed {
background: linear-gradient(135deg, #9fa5c0 0%, #8a90a8 100%);
box-shadow: none;
}
/* 营养统计卡片 */
.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>