修复 BUG-001 至 BUG-009 及 T10-1 至 T10-6 相关问题: - 打卡积分显示与累加逻辑优化 - 食谱计算器 Tab 选中样式修复 - 食物百科列表图片与简介展示修复 - 食物详情页数据加载修复 - AI营养师差异化回复优化 - 健康知识/营养知识名称统一 - 饮食指南/科普文章详情页内容展示修复 - 帖子营养统计数据展示修复 - 社区帖子类型中文命名统一 - 帖子详情标签中文显示修复 - 食谱营养AI填充功能完善 - 食谱收藏/点赞功能修复 新增: - ToolNutritionFillService 营养填充服务 - T10 回归测试用例 (Playwright) - 知识文章数据 SQL 脚本 涉及模块: - crmeb-common: VO/Request/Response 优化 - crmeb-service: 业务逻辑完善 - crmeb-front: API 接口扩展 - msh_single_uniapp: 前端页面修复 - tests/e2e: 回归测试用例
1591 lines
38 KiB
Vue
1591 lines
38 KiB
Vue
<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
|
||
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: 无营养数据时展示「AI 补充营养」按钮 -->
|
||
<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">🤖 AI 补充营养</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'
|
||
|
||
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: '',
|
||
aiNutritionFilling: false
|
||
}
|
||
},
|
||
computed: {
|
||
// 营养统计数组,用于卡片显示与列表渲染(单一数据源,避免未定义)
|
||
nutritionStats() {
|
||
const stats = this.postData && this.postData.nutritionStats
|
||
return Array.isArray(stats) ? stats : []
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
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)
|
||
const data = res.data || res
|
||
|
||
// 格式化帖子数据
|
||
this.formatPostData(data)
|
||
|
||
// 若详情接口未返回营养数据且有关联打卡记录,则根据打卡详情补充营养统计(等待完成后再结束加载)
|
||
const checkInId = data.checkInRecordId != null ? (Number(data.checkInRecordId) || data.checkInRecordId) : null
|
||
if (this.postData.nutritionStats.length === 0 && checkInId != null) {
|
||
await this.fillNutritionStatsFromCheckin(checkInId)
|
||
}
|
||
// T07: 营养仍为空时调用服务端填充接口
|
||
if (this.postData.nutritionStats.length === 0) {
|
||
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) {
|
||
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(兼容后端驼峰与下划线;含 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg、打卡字段 actualEnergy/actualProtein)
|
||
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) 对象格式:兼容后端 fill-nutrition 的 energyKcal/proteinG/potassiumMg/phosphorusMg 及常见命名
|
||
return this.buildNutritionStatsFromNutritionObject(nutritionData)
|
||
} 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' && !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
|
||
}
|
||
|
||
return []
|
||
},
|
||
|
||
/** 从单一营养对象构建 [{label, value}, ...],统一兼容 energyKcal/proteinG/potassiumMg/phosphorusMg 及 calories/protein 等命名 */
|
||
buildNutritionStatsFromNutritionObject(obj) {
|
||
if (!obj || typeof obj !== 'object') return []
|
||
const cal = obj.energyKcal ?? obj.calories ?? obj.energy ?? obj.calorie ?? obj.actualEnergy
|
||
const pro = obj.proteinG ?? obj.protein ?? obj.proteins ?? obj.actualProtein
|
||
const pot = obj.potassiumMg ?? obj.potassium ?? obj.k
|
||
const pho = obj.phosphorusMg ?? obj.phosphorus ?? obj.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(蛋白质、热量、钾、磷)
|
||
*/
|
||
buildNutritionStatsFromCheckinDetail(detail) {
|
||
if (!detail || typeof detail !== 'object') return []
|
||
const energy = detail.actualEnergy ?? detail.energy
|
||
const protein = detail.actualProtein ?? detail.protein
|
||
return [
|
||
{ label: '热量(kcal)', value: energy != null ? String(energy) : '-' },
|
||
{ label: '蛋白质', value: this.formatNutritionValue(protein, 'g') },
|
||
{ label: '钾', value: '-' },
|
||
{ label: '磷', value: '-' }
|
||
]
|
||
},
|
||
|
||
/**
|
||
* 根据打卡记录 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)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* T07: 调用服务端 fill-nutrition 接口,根据帖子内容补充营养并更新本地展示
|
||
*/
|
||
async fillNutritionFromServer() {
|
||
if (!this.postId) return
|
||
try {
|
||
const res = await fillPostNutrition(this.postId)
|
||
const data = res.data || res
|
||
if (!data) return
|
||
const stats = this.buildNutritionStatsFromDetailData(data)
|
||
if (stats.length > 0) {
|
||
this.postData.nutritionStats = 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.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)
|
||
|
||
// 提取纯文本内容
|
||
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(/ /g, ' ')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/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 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;
|
||
}
|
||
}
|
||
|
||
/* 营养统计卡片 */
|
||
.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>
|
||
|