Files
msh-system/msh_single_uniapp/pages/tool_main/community.vue

907 lines
19 KiB
Vue
Raw Normal View History

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