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

871 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="community-page">
<!-- 顶部Tab导航 -->
<view class="tab-nav">
<view
class="tab-item"
:class="{ active: currentTab === 'recommend' }"
@click="switchTab('recommend')"
>
<!-- <text class="tab-icon">📊</text> -->
<text class="tab-text">推荐</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'latest' }"
@click="switchTab('latest')"
>
<!-- <text class="tab-icon">🕐</text> -->
<text class="tab-text">最新</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'follow' }"
@click="switchTab('follow')"
>
<!-- <text class="tab-icon">👥</text> -->
<text class="tab-text">关注</text>
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'hot' }"
@click="switchTab('hot')"
>
<!-- <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 === 'follow'">关注更多用户查看他们的打卡动态</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: 'recommend',
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
switchTab(tab) {
if (this.currentTab === tab) return
// 关注Tab需要登录
if (tab === 'follow' && !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.currentTab,
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.currentTab,
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) + 'w'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k'
}
return String(count)
},
// 帖子类型英文转中文显示(仅用于展示,保证 label 均为中文)
getMealTypeLabel(mealType) {
if (!mealType) return '分享'
const map = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
share: '分享',
checkin: '打卡'
}
const lower = String(mealType).toLowerCase()
return map[lower] != null ? map[lower] : '分享'
},
// 格式化标签显示
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>