Files
msh-system/msh_single_uniapp/pages/tool/post-detail.vue

1734 lines
46 KiB
Vue
Raw Normal View History

<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_jsondietaryData/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>