Files

1977 lines
45 KiB
Plaintext
Raw Permalink 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>
<scroll-view class="page" :show-scrollbar="false">
<!-- 顶部导航 -->
<view class="page-header">
<view class="header-left">
<uni-icons type="left" size="32"></uni-icons>
</view>
<view class="header-center">
<text class="header-title">动态详情</text>
</view>
<view class="header-right">
<uni-icons class="header-icon" :type="dynamicData.isFavorited ? 'star':'star-filled'"
:color="dynamicData.isFavorited ? '#ff5d9b': '#c2c2c2'" size="32"
@click="toggleFavorite"></uni-icons>
<uni-icons class="header-icon" type="more-filled" size="32" @click="showMoreActions"></uni-icons>
</view>
</view>
<!-- 动态内容 -->
<view class="dynamic-content" ref="dynamicContent">
<!-- 作者信息 -->
<view class="author-info">
<image :src="dynamicData.avatar" class="author-avatar" />
<view class="author-details">
<view class="author-name">{{ dynamicData.username }}</view>
<view class="post-info">
<text class="post-time">{{ formatTime(dynamicData.createTime) }}</text>
<text v-if="dynamicData.location" class="post-location">
<uni-icons type="location"></uni-icons> {{ dynamicData.location }}
</text>
</view>
</view>
<button class="follow-btn" v-if="!isSelf && !dynamicData.isFollowed" @click="toggleFollow">
<i class="fas fa-plus"></i> 关注
</button>
<button class="following-btn" v-if="!isSelf && dynamicData.isFollowed" @click="toggleFollow">
已关注
</button>
</view>
<!-- 动态文本内容 -->
<view class="content-text">
{{ dynamicData.content }}
</view>
<!-- 图片/视频展示 -->
<view v-if="dynamicData.media && dynamicData.media.length > 0" class="content-media">
<view v-if="dynamicData.media.length === 1" class="single-media">
<image v-if="dynamicData.media[0].type === 'image'" :src="dynamicData.media[0].url" alt="动态图片"
class="media-image" @click="previewMedia(0)" />
<view v-if="dynamicData.media[0].type === 'video'" class="media-video">
<video :src="dynamicData.media[0].url" controls class="video-player" @click="playVideo"></video>
<view v-if="!isPlaying" class="video-overlay" @click="playVideo">
<i class="fas fa-play"></i>
</view>
</view>
</view>
<view v-if="dynamicData.media.length > 1" class="multi-media">
<view v-for="(media, index) in dynamicData.media" :key="index" class="media-grid-item"
:class="gridClass(dynamicData.media.length)" @click="previewMedia(index)">
<image v-if="media.type === 'image'" :src="media.url" :alt="'图片' + (index + 1)" />
<view v-if="media.type === 'video'" class="video-thumbnail">
<image :src="media.thumbnail != null || media.url != null" :alt="'视频' + (index + 1)" />
<view class="video-play-icon">
<uni-icons type="videocam-filled"></uni-icons>
</view>
</view>
<view v-if="dynamicData.media.length > 9 && index === 8" class="more-indicator">
+{{ dynamicData.media.length - 9 }}
</view>
</view>
</view>
</view>
<!-- 位置信息 -->
<view v-if="dynamicData.location && dynamicData.coordinates" class="location-section">
<view class="location-header">
<i class="fas fa-map-marker-alt"></i>
<text class="location-name">{{ dynamicData.location }}</text>
</view>
<view class="map-preview" @click="openMap">
<!-- 这里可以集成地图组件 -->
<view class="map-placeholder">
<i class="fas fa-map"></i>
<text>查看地图位置</text>
</view>
</view>
</view>
<!-- 话题标签 -->
<view v-if="dynamicData.hashtags && dynamicData.hashtags.length > 0" class="hashtag-section">
<text v-for="(tag, index) in dynamicData.hashtags" :key="index" class="hashtag"
@click="searchHashtag(tag.name)">
#{{ tag.name }}
</text>
</view>
<!-- 动态统计信息 -->
<view class="stats-section">
<view class="stats-item">
<i class="far fa-heart"></i>
<text>{{ dynamicData.like }} 人点赞</text>
</view>
<view class="stats-item">
<i class="far fa-comment"></i>
<text>{{ dynamicData.comment }} 条评论</text>
</view>
<view class="stats-item">
<i class="fas fa-share"></i>
<text>{{ dynamicData.share }} 次分享</text>
</view>
<view class="stats-item">
<i class="far fa-eye"></i>
<text>{{ dynamicData.view || 0 }} 次浏览</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<button class="action-button like-btn" :class="{ active: dynamicData.likede }" @click="toggleLike">
<i class="fas" :class="dynamicData.likede ? 'fa-heart' : 'fa-heart-o'"></i>
<text>{{ dynamicData.likede ? '已赞' : '点赞' }}</text>
</button>
<button class="action-button comment-btn" @click="scrollToComment">
<i class="far fa-comment"></i>
<text>评论</text>
</button>
<button class="action-button share-btn" @click="showShareOptions">
<i class="fas fa-share-alt"></i>
<text>分享</text>
</button>
<button class="action-button collect-btn" @click="toggleCollect">
<i class="far" :class="dynamicData.collected ? 'fa-bookmark' : 'fa-bookmark-o'"></i>
<text>{{ dynamicData.collected ? '已收藏' : '收藏' }}</text>
</button>
</view>
</view>
<!-- 评论区域 -->
<view class="comment-section">
<text class="section-title"> 评论 ({{ commentCount }}) </text>
<!-- 评论组件 -->
<view class="comment-container">
<view class="comment-input-area">
<view class="input-header">
<image :src="currentUser.avatar" class="user-avatar" alt="用户头像" />
<view class="input-wrapper">
<view class="reply-hint" v-if="replyToUser"> 回复 @{{ replyToUser.username }}
<button class="cancel-reply" @click="cancelReply">×</button>
</view>
<view class="input-box">
<textarea v-model="commentInput" placeholder="说点什么..." class="comment-textarea"
maxlength="1000" @input="onCommentInput" ref="commentTextareaRef"></textarea>
<view class="input-actions">
<view class="emoji-picker">
<button class="action-btn" @click="toggleEmoji">
<text class="far fa-smile">🙂</text>
</button>
<view class="emoji-list" v-if="showEmoji">
<text v-for="emoji in emojiList.slice(0, 24)" :key="emoji"
@click="insertEmoji(emoji)">{{ emoji }}</text>
</view>
</view>
<view class="word-count">{{ commentInput.length }}/1000</view>
<button class="submit-btn" @click="submitComment" :disabled="!canSubmitComment">
{{ publishingComment ? '发送中...' : '发送' }}
</button>
</view>
</view>
</view>
</view>
</view>
<!-- 评论列表 -->
<view class="comment-list" v-if="comments.length > 0">
<view v-for="comment in comments" :key="comment.id" class="comment-item">
<view class="comment-main">
<view class="comment-avatar">
<image :src="comment.avatar" alt="用户头像" />
</view>
<view class="comment-content">
<view class="comment-header">
<text class="username">{{ comment.username }}</text>
<text class="comment-time">{{ formatTime(comment.createTime) }}</text>
</view>
<view class="comment-text">
<text v-if="comment.replyTo" class="reply-to">@{{ comment.replyTo.username }}</text>
{{ comment.content }}
</view>
<view class="comment-actions">
<button class="action-btn like-btn" :class="{ active: comment.liked }"
@click="toggleCommentLike(comment)">
<i class="far fa-thumbs-up"></i>
<text>{{ comment.like }}</text>
</button>
<button class="action-btn reply-btn" @click="startReply(comment)">
回复
</button>
<view class="more-actions">
<button class="action-btn more-btn" @click="toggleCommentActions(comment.id)">
<i class="fas fa-ellipsis-h"></i>
</button>
<view class="more-menu" v-if="showCommentActions === comment.id">
<button v-if="comment.userID === currentUser.id"
class="menu-item delete-btn" @click="deleteComment(comment)">
<i class="far fa-trash-alt"></i> 删除
</button>
<button class="menu-item report-btn" @click="reportComment(comment)">
<i class="far fa-flag"></i> 举报
</button>
</view>
</view>
</view>
<!-- 回复列表 -->
<view class="reply-list" v-if="comment.replies && comment.replies.length > 0">
<view v-for="reply in comment.replies" :key="reply.id" class="reply-item">
<view class="reply-avatar">
<image :src="reply.avatar" alt="用户头像" />
</view>
<view class="reply-content">
<view class="reply-header">
<text class="username">{{ reply.username }}</text>
<text class="reply-time">{{ formatTime(reply.createTime) }}</text>
</view>
<view class="reply-text">
<text v-if="reply.replyTo"
class="reply-to">@{{ reply.replyTo.username }}</text>
{{ reply.content }}
</view>
<view class="reply-actions">
<button class="action-btn like-btn" :class="{ active: reply.liked }"
@click="toggleReplyLike(comment, reply)">
<i class="far fa-thumbs-up"></i>
<text>{{ reply.like }}</text>
</button>
<button class="action-btn reply-btn"
@click="startReply(comment, reply)">
回复
</button>
<view class="more-actions">
<button class="action-btn more-btn"
@click="toggleCommentActions(reply.id)">
<i class="fas fa-ellipsis-h"></i>
</button>
<view class="more-menu" v-if="showCommentActions === reply.id">
<button v-if="reply.userID === currentUser.id"
class="menu-item delete-btn"
@click="deleteReply(comment, reply)">
<i class="far fa-trash-alt"></i> 删除
</button>
<button class="menu-item report-btn"
@click="reportComment(reply)">
<i class="far fa-flag"></i> 举报
</button>
</view>
</view>
</view>
</view>
</view>
<!-- 查看更多回复 -->
<view v-if="comment.replyCount > comment.replies.length" class="view-more-replies"
@click="loadMoreReplies(comment)">
查看{{ comment.replyCount - comment.replies.length }}条回复
<i class="fas fa-chevron-down"></i>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多评论 -->
<view v-if="hasMoreComments" class="load-more-comments" @click="loadMoreComments">
<i class="fas fa-spinner fa-spin" v-if="loadingComments"></i>
<text v-else>加载更多评论</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-comments">
<i class="far fa-comment-dots"></i>
<p>还没有评论,快来抢沙发吧~</p>
</view>
</view>
</view>
<!-- 媒体预览弹窗 -->
<view v-if="showMediaPreview" class="media-preview-modal" @click="closeMediaPreview">
<view class="modal-content" @click.stop>
<view class="modal-header">
<button class="close-btn" @click="closeMediaPreview">×</button>
</view>
<view class="modal-body">
<image v-if="previewMediaIndex !== null" :src="dynamicData.media[previewMediaIndex].url"
class="preview-image" />
</view>
<view class="modal-footer">
<view class="media-info">
{{ previewMediaIndex + 1 }} / {{ dynamicData.media.length }}
</view>
</view>
</view>
</view>
<!-- 分享弹窗 -->
<view v-if="showShareModal" class="share-modal" @click="closeShareModal">
<view class="modal-content share-content" @click.stop>
<view class="share-header">
<text>分享到</text>
<button class="close-btn" @click="closeShareModal">×</button>
</view>
<view class="share-options">
<view class="share-option" @click="shareTo('wechat')">
<i class="fab fa-weixin"></i>
<text>微信</text>
</view>
<view class="share-option" @click="shareTo('qq')">
<i class="fab fa-qq"></i>
<text>QQ</text>
</view>
<view class="share-option" @click="shareTo('weibo')">
<i class="fab fa-weibo"></i>
<text>微博</text>
</view>
<view class="share-option" @click="shareTo('copy')">
<i class="fas fa-link"></i>
<text>复制链接</text>
</view>
</view>
</view>
</view>
<!-- 更多操作弹窗 -->
<view v-if="showActionsModal" class="actions-modal" @click="closeActionsModal">
<view class="modal-content actions-content" @click.stop>
<view class="action-list">
<button v-if="!isSelf" class="action-item" @click="toggleFollowFromModal">
<i class="fas" :class="dynamicData.isFollowed ? 'fa-user-minus' : 'fa-user-plus'"></i>
<text>{{ dynamicData.isFollowed ? '取消关注' : '关注' }}</text>
</button>
<button class="action-item" @click="toggleFavorite">
<i class="far" :class="dynamicData.isFavorited ? 'fa-heart' : 'fa-heart-o'"></i>
<text>{{ dynamicData.isFavorited ? '取消喜欢' : '喜欢' }}</text>
</button>
<button class="action-item" @click="toggleCollect">
<i class="far" :class="dynamicData.collected ? 'fa-bookmark' : 'fa-bookmark-o'"></i>
<text>{{ dynamicData.collected ? '取消收藏' : '收藏' }}</text>
</button>
<button v-if="isSelf" class="action-item" @click="editDynamic">
<i class="far fa-edit"></i>
<text>编辑动态</text>
</button>
<button v-if="isSelf" class="action-item delete-action" @click="deleteDynamic">
<i class="far fa-trash-alt"></i>
<text>删除动态</text>
</button>
<button class="action-item" @click="reportDynamic">
<i class="far fa-flag"></i>
<text>举报</text>
</button>
</view>
<view class="modal-footer">
<button class="cancel-btn" @click="closeActionsModal">取消</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
// 当前用户信息
const me = getUserInfo()
const currentUser = reactive({ id: me?.uid ?? 0, username: me?.nickname ?? '我', avatar: me?.avatar ?? '' })
// 动态ID(从路由获取)
const momentId = ref(0)
// 动态数据
const dynamicData = reactive<any>({
id: momentId.value,
uid: 101,
nickname: '科技探索者',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
content: '今天去了新开的科技馆,体验了VR太空漫步,仿佛真的置身宇宙之中,太震撼了!推荐大家一定要去试试。',
createTime: Date.now() - 2 * 60 * 60 * 1000, // 2小时前
location: '北京·科技馆',
coordinates: { lat: 39.9042, lng: 116.4074 },
media: [
{ type: 'image', url: 'https://picsum.photos/400/300?random=1' },
{ type: 'image', url: 'https://picsum.photos/400/300?random=2' },
{ type: 'image', url: 'https://picsum.photos/400/300?random=3' },
{ type: 'video', url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', thumbnail: 'https://picsum.photos/400/300?random=4' }
],
hashtags: ['科技', 'VR体验', '太空漫步'],
like: 156,
comment: 23,
share: 12,
view: 456,
likede: true,
collected: false,
isFavorited: false,
isFollowed: false
})
const momentData = ref<IMoment>(null)
// 是否为当前用户
const isSelf = computed(() => dynamicData.uid == currentUser.id && currentUser.id > 0)
// 评论相关
const comments = ref<any[]>([])
const commentCount = ref(23)
const commentInput = ref('')
const replyToUser = ref(null)
const replyToComment = ref(null)
const showEmoji = ref(false)
const publishingComment = ref(false)
const hasMoreComments = ref(true)
const loadingComments = ref(false)
const showCommentActions = ref(null)
const commentTextareaRef = ref(null)
// 表情列表
const emojiList = ref(['😀', '😁', '😂', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤔', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮', '🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '😬', '😰', '😱', '😳', '😵', '😡', '😠', '🤧', '😇', '🤠', '🤡', '🤥', '🤢', '🤕', '🤒'])
// 弹窗控制
const showMediaPreview = ref(false)
const previewMediaIndex = ref(null)
const showShareModal = ref(false)
const showActionsModal = ref(false)
const isPlaying = ref(false)
// 计算属性
const canSubmitComment = computed(() => {
return commentInput.value.trim().length > 0 && !publishingComment.value
})
// 初始化
onMounted(() => {
loadMoment()
})
// 返回上一页
const goBack = () => {
uni.navigateBack()
}
// 格式化时间
const formatTime = (timestamp) => {
const now = Date.now()
const diff = now - timestamp
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 {
const date = new Date(timestamp)
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
}
// 网格布局类
const gridClass = (count) => {
if (count === 2) return 'grid-2'
if (count === 3) return 'grid-3'
if (count === 4) return 'grid-4'
if (count >= 5 && count <= 9) return 'grid-3'
return 'grid-3'
}
// 点赞动态
const toggleLike = () => {
const will = ! dynamicData.likede
dynamicData.likede = will
dynamicData.like += will ? 1 : -1
Api.moment.like({ dynamic_id: momentId.value })
.then((r : UTSJSONObject) => {
const d = r.get('data') as UTSJSONObject
if (d != null) {
dynamicData.likede = !!d.get('liked')
dynamicData.like = Number(d.get('like_count') ?? dynamicData.like)
}
})
.catch(() => { dynamicData.likede = !will; dynamicData.like += will ? -1 : 1 })
}
// 收藏动态
const toggleCollect = () => {
const will = ! dynamicData.collected
dynamicData.collected = will
dynamicData.isFavorited = will
Api.moment.collect({ dynamic_id: momentId.value })
.catch(() => { dynamicData.collected = !will; dynamicData.isFavorited = !will })
}
// 喜欢动态(等同收藏)
const toggleFavorite = () => {
toggleCollect()
}
// 关注用户
const toggleFollow = () => {
const will = ! dynamicData.isFollowed
dynamicData.isFollowed = will
Api.moment.follow({ following_id: dynamicData.uid })
.catch(() => { dynamicData.isFollowed = !will })
}
// 滚动到评论区域
const scrollToComment = () => {
// 评论区在当前页面内,无需特殊处理
}
// 显示分享选项
const showShareOptions = () => {
showShareModal.value = true
}
// 关闭分享弹窗
const closeShareModal = () => {
showShareModal.value = false
}
// 显示更多操作
const showMoreActions = () => {
showActionsModal.value = true
}
// 关闭操作弹窗
const closeActionsModal = () => {
showActionsModal.value = false
}
// 从弹窗关注用户
const toggleFollowFromModal = () => {
toggleFollow()
closeActionsModal()
}
// 分享到
const shareTo = (platform : string) => {
if (platform == 'copy') {
uni.setClipboardData({ data: `动态详情 ${momentId.value}` })
} else {
uni.showToast({ title: '分享成功', icon: 'success' })
}
closeShareModal()
}
// 预览媒体
const previewMedia = (index) => {
previewMediaIndex.value = index
showMediaPreview.value = true
}
// 关闭媒体预览
const closeMediaPreview = () => {
showMediaPreview.value = false
previewMediaIndex.value = null
}
// 播放视频
const playVideo = () => {
isPlaying.value = !isPlaying.value
}
// 搜索话题
const searchHashtag = (tag) => {
console.log('搜索话题:', tag)
// 实际项目中跳转到话题搜索页
// router.push(`/search/hashtag/${tag}`)
}
// 打开地图
const openMap = () => {
uni.showToast({ title: '地图功能开发中', icon: 'none' })
}
// 编辑动态
const editDynamic = () => {
console.log('编辑动态')
closeActionsModal()
// 实际项目中跳转到编辑页
// router.push(`/dynamic/${dynamicId.value}/edit`)
}
// 删除动态
const deleteDynamic = () => {
uni.showModal({
title: '提示',
content: '确定删除该动态?',
success: (r) => { if (r.confirm) uni.navigateBack() }
})
closeActionsModal()
}
// 举报动态
const reportDynamic = () => {
uni.showToast({ title: '已举报' })
closeActionsModal()
}
// 加载更多评论
const loadMoreComments = () => {
uni.showToast({ title: '已加载全部评论', icon: 'none' })
}
// 提交评论
const submitComment = () => {
if (!canSubmitComment.value) return
publishingComment.value = true
const content = commentInput.value.trim()
const parentId = replyToComment.value != null ? replyToComment.value.id : 0
const replyToUid = replyToUser.value != null ? replyToUser.value.id : 0
Api.moment.comment({ dynamic_id: momentId.value, content: content, parent_id: parentId, reply_to_user_id: replyToUid })
.then((r : UTSJSONObject) => {
const c = r.get('data') as UTSJSONObject
const mapped = c ? mapComment(c) : {
id: Date.now(), username: currentUser.username, avatar: currentUser.avatar, uid: currentUser.id,
content: content, createTime: Date.now(), like: 0, liked: false, replyCount: 0,
replyTo: replyToUser.value, replies: []
}
if (parentId > 0 && replyToComment.value != null) {
const pc = comments.value.find(x => x.id == replyToComment.value!.id)
if (pc != null) pc.replies.push(mapped)
} else {
comments.value.unshift(mapped)
}
commentCount.value++
commentInput.value = ''
replyToUser.value = null
replyToComment.value = null
})
.catch(() => { uni.showToast({ title: '评论失败', icon: 'none' }) })
.finally(() => { publishingComment.value = false })
}
// 开始回复
const startReply = (comment : any, reply : any = null) => {
if (reply) {
replyToUser.value = { id: reply.uid, username: reply.username }
replyToComment.value = comment
} else {
replyToUser.value = { id: comment.uid, username: comment.username }
replyToComment.value = comment
}
scrollToComment()
}
// 取消回复
const cancelReply = () => {
replyToUser.value = null
replyToComment.value = null
}
// 切换评论点赞
const toggleCommentLike = (comment) => {
comment.liked = !comment.liked
comment.like += comment.liked ? 1 : -1
// 模拟API请求
// api.toggleLikeComment(comment.id, comment.liked)
}
// 切换回复点赞
const toggleReplyLike = (parentComment, reply) => {
reply.liked = !reply.liked
reply.like += reply.liked ? 1 : -1
// 模拟API请求
// api.toggleLikeReply(reply.id, reply.liked)
}
// 删除评论
const deleteComment = (comment : any) => {
uni.showModal({
title: '提示', content: '确定删除这条评论吗?',
success: (r) => {
if (r.confirm) {
const index = comments.value.findIndex(c => c.id === comment.id)
if (index !== -1) {
comments.value.splice(index, 1)
commentCount.value--
dynamicData.comment--
}
}
}
})
showCommentActions.value = null
}
// 删除回复
const deleteReply = (parentComment : any, reply : any) => {
uni.showModal({
title: '提示', content: '确定删除这条回复吗?',
success: (r) => {
if (r.confirm) {
const index = parentComment.replies.findIndex((x : any) => x.id === reply.id)
if (index !== -1) {
parentComment.replies.splice(index, 1)
parentComment.replyCount--
}
}
}
})
showCommentActions.value = null
}
// 举报评论
const reportComment = (comment : any) => {
uni.showToast({ title: '已举报' })
showCommentActions.value = null
}
// 加载更多回复
const loadMoreReplies = (comment : any) => {
uni.showToast({ title: '已加载全部回复', icon: 'none' })
}
// 切换评论操作菜单
const toggleCommentActions = (commentId) => {
showCommentActions.value = showCommentActions.value === commentId ? null : commentId
}
// 切换表情选择器
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
}
// 插入表情
const insertEmoji = (emoji) => {
commentInput.value += emoji
showEmoji.value = false
// 重新聚焦输入框
nextTick(() => {
if (commentTextareaRef.value) {
commentTextareaRef.value.focus()
}
})
}
// 评论输入处理
const onCommentInput = () => {
// 可以添加输入限制、敏感词检测等
}
// 加载评论(已由 loadMoment 通过后端返回,无需独立 mock 加载)
// 加载动态数据
const loadMoment = () => {
Api.moment.detail(momentId.value)
.then((res : UTSJSONObject) => {
const m = res.get('moment') as UTSJSONObject
if (m != null) {
dynamicData.id = Number(m.get('id'))
dynamicData.uid = Number(m.get('uid') ?? m.get('userId') ?? 0)
dynamicData.avatar = (m.get('userAvatar') ?? '') as string
dynamicData.username = (m.get('userName') ?? '') as string
dynamicData.createTime = Number(m.get('time') ?? 0)
dynamicData.location = (m.get('location') ?? '') as string
dynamicData.coordinates = null
dynamicData.content = (m.get('content') ?? '') as string
dynamicData.media = (m.get('media') ?? []) as any[]
dynamicData.hashtags = (m.get('hashtags') ?? []) as any[]
dynamicData.like = Number(m.get('likes') ?? 0)
dynamicData.comment = Number(m.get('comments') ?? 0)
dynamicData.share = Number(m.get('shares') ?? 0)
dynamicData.view = Number(m.get('view') ?? 0)
dynamicData.likede = !!(m.get('isLiked'))
const inter = (m.get('interaction') as UTSJSONObject) ?? null
dynamicData.collected = inter ? !!inter.get('collected') : false
dynamicData.isFollowed = inter ? !!inter.get('followed') : false
}
const cs = (res.get('comments') as Array<UTSJSONObject>) ?? []
comments.value = cs.map((c : UTSJSONObject) => mapComment(c))
commentCount.value = Number(res.get('comment_total') ?? comments.value.length)
})
.catch(() => {})
}
const mapComment = (c : UTSJSONObject) : any => {
const user = (c.get('user') as UTSJSONObject) ?? ({} as UTSJSONObject)
const replyTo = (c.get('reply_to') as UTSJSONObject) ?? null
const replies = (c.get('replies') as Array<UTSJSONObject>) ?? []
return {
id: Number(c.get('id')),
username: (user.get('nickname') ?? '') as string,
avatar: (user.get('avatar') ?? '') as string,
uid: Number(user.get('id') ?? 0),
content: (c.get('content') ?? '') as string,
createTime: (Number(c.get('create_time') ?? 0)) * 1000,
like: Number(c.get('like_count') ?? 0),
liked: false,
replyCount: Number(c.get('reply_count') ?? 0),
replyTo: replyTo ? { id: Number(replyTo.get('id')), username: (replyTo.get('nickname') ?? '') as string } : null,
replies: replies.map((r : UTSJSONObject) => {
const ru = (r.get('user') as UTSJSONObject) ?? ({} as UTSJSONObject)
const rrt = (r.get('reply_to') as UTSJSONObject) ?? null
return {
id: Number(r.get('id')),
username: (ru.get('nickname') ?? '') as string,
avatar: (ru.get('avatar') ?? '') as string,
uid: Number(ru.get('id') ?? 0),
content: (r.get('content') ?? '') as string,
createTime: (Number(r.get('create_time') ?? 0)) * 1000,
like: Number(r.get('like_count') ?? 0),
liked: false,
replyCount: 0,
replyTo: rrt ? { id: Number(rrt.get('id')), username: (rrt.get('nickname') ?? '') as string } : null,
replies: []
}
})
}
}
onLoad((options : any) => {
momentId.value = Number(options?.id ?? 0)
if (momentId.value > 0) loadMoment()
})
// 浏览量由后端 red 接口自动累加,无需前端处理
</script>
<style scoped>
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-icon {
margin: 3rpx 5rpx 3rpx 5rpx;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
transition: background 0.2s;
}
/* 动态内容 */
.dynamic-content {
background: white;
padding: 100rpx 16px 20px 16px;
}
/* 作者信息 */
.author-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 16px;
}
.author-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
margin-right: 12px;
}
.author-details {
flex: 1;
}
.author-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.post-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 12px;
font-size: 13px;
color: #999;
}
.post-location {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px;
}
.post-location i {
font-size: 12px;
}
.follow-btn,
.following-btn {
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.follow-btn {
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
color: white;
}
.follow-btn:hover {
opacity: 0.9;
}
.following-btn {
background: #f0f0f0;
color: #666;
}
.following-btn:hover {
background: #e0e0e0;
}
.follow-btn i {
margin-right: 4px;
font-size: 11px;
}
/* 动态文本内容 */
.content-text {
font-size: 16px;
line-height: 1.6;
color: #333;
margin-bottom: 16px;
white-space: pre-wrap;
word-break: break-word;
}
/* 媒体内容 */
.content-media {
flex: 1;
display: flex;
justify-content: space-between;
flex: 0 0 236rpx;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
}
.single-media {
width: 100%;
max-height: 500px;
border-radius: 12px;
overflow: hidden;
}
.media-image {
width: 100%;
height: auto;
max-height: 500px;
object-fit: contain;
cursor: pointer;
transition: transform 0.3s;
}
.media-image:hover {
transform: scale(1.02);
}
.media-video {
position: relative;
width: 100%;
height: 300px;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
color: white;
font-size: 48px;
cursor: pointer;
transition: background 0.3s;
}
.video-overlay:hover {
background: rgba(0, 0, 0, 0.5);
}
/* 多图网格 */
.multi-media {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
border-radius: 12px;
overflow: hidden;
}
.media-grid-item {
position: relative;
aspect-ratio: 1/1;
background-color: #f5f5f5;
cursor: pointer;
overflow: hidden;
}
.media-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.media-grid-item:hover img {
transform: scale(1.05);
}
.video-thumbnail {
position: relative;
width: 100%;
height: 100%;
}
.video-play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.more-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 20px;
font-weight: 600;
}
/* 网格类 */
.grid-2 {
grid-column: text 1;
}
.grid-3 {
grid-column: text 1;
}
.grid-4 {
grid-column: text 1;
}
/* 位置信息 */
.location-section {
margin: 16px 0;
padding: 12px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e9ecef;
}
.location-header {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
color: #333;
}
.location-header i {
color: #f74c31;
}
.location-name {
font-weight: 500;
}
.map-preview {
width: 100%;
height: 100px;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
display: flex;
flex-flow: row nowrap;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
transition: opacity 0.3s;
}
.map-preview:hover {
opacity: 0.9;
}
.map-placeholder {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 8px;
}
.map-placeholder i {
font-size: 20px;
}
/* 话题标签 */
.hashtag-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.hashtag {
display: inline-block;
padding: 4px 10px;
background: #e6f3ff;
color: #2575fc;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.hashtag:hover {
background: #d1e7ff;
}
/* 统计信息 */
.stats-section {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-around;
padding: 16px 0;
margin: 16px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.stats-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex-direction: column;
gap: 6px;
color: #666;
font-size: 13px;
}
.stats-item i {
font-size: 18px;
color: #999;
}
/* 操作按钮 */
.action-section {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-around;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
}
.action-button {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex-direction: column;
gap: 4px;
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
transition: color 0.2s;
padding: 8px 12px;
border-radius: 8px;
}
.action-button:hover {
background: #f5f5f5;
}
.action-button i {
font-size: 20px;
}
.action-button text {
font-size: 12px;
}
.like-btn.active {
color: #f74c31;
}
.comment-btn:hover {
color: #2575fc;
}
.share-btn:hover {
color: #6a11cb;
}
.collect-btn.active {
color: #ffc107;
}
/* 评论区域 */
.comment-section {
background: white;
border-radius: 12px 12px 0 0;
padding: 20px 16px;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
/* 评论容器 */
.comment-container {
background: white;
border-radius: 12px;
padding: 0;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.comment-input-area {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.input-header {
display: flex;
flex-flow: row nowrap;
gap: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
}
.input-wrapper {
flex: 1;
}
.reply-hint {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
background: #f5f5f5;
padding: 6px 12px;
border-radius: 6px;
margin-bottom: 8px;
font-size: 13px;
color: #666;
}
.cancel-reply {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
}
.cancel-reply:hover {
color: #333;
}
.input-box {
position: relative;
}
.comment-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
resize: vertical;
transition: border-color 0.2s;
}
.comment-textarea:focus {
outline: none;
border-color: #2575fc;
box-shadow: 0 0 0 2px rgba(37, 117, 252, 0.1);
}
.input-actions {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.emoji-picker {
position: relative;
}
.action-btn {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: #f5f5f5;
border: none;
border-radius: 4px;
font-size: 13px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: #e0e0e0;
}
.emoji-list {
position: absolute;
bottom: 100%;
left: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px;
max-width: 240px;
max-height: 200px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.emoji-list text {
display: inline-flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 18px;
border-radius: 4px;
}
.emoji-list text:hover {
background: #f0f0f0;
}
.word-count {
font-size: 12px;
color: #999;
}
.submit-btn {
padding: 6px 20px;
background: #2575fc;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-btn:not(:disabled):hover {
background: #1c68e8;
}
.comment-list {
margin-top: 20px;
}
.comment-item {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #f5f5f5;
}
.comment-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.comment-main {
display: flex;
flex-flow: row nowrap;
gap: 12px;
}
.comment-avatar {
flex-shrink: 0;
}
.comment-avatar img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e0e0e0;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.username {
font-weight: 500;
font-size: 14px;
color: #333;
}
.comment-time {
font-size: 12px;
color: #999;
}
.comment-text {
font-size: 14px;
line-height: 1.5;
color: #333;
margin-bottom: 8px;
}
.reply-to {
color: #2575fc;
margin-right: 4px;
cursor: pointer;
}
.reply-to:hover {
text-decoration: underline;
}
.comment-actions {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.like-btn {
padding: 4px 8px;
background: transparent;
border: none;
color: #666;
font-size: 12px;
}
.like-btn.active {
color: #f74c31;
}
.like-btn i {
margin-right: 4px;
}
.reply-btn {
padding: 4px 8px;
background: transparent;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
}
.reply-btn:hover {
color: #2575fc;
}
.more-actions {
position: relative;
}
.more-btn {
padding: 4px 8px;
background: transparent;
border: none;
color: #999;
cursor: pointer;
}
.more-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 4px 0;
min-width: 100px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.menu-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
font-size: 12px;
color: #666;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item i {
width: 16px;
text-align: center;
}
.delete-btn {
color: #f74c31;
}
.report-btn {
color: #666;
}
.reply-list {
margin-top: 16px;
padding-left: 12px;
border-left: 2px solid #f0f0f0;
}
.reply-item {
display: flex;
flex-flow: row nowrap;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 8px;
}
.reply-item:last-child {
margin-bottom: 0;
}
.reply-avatar img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e0e0e0;
}
.reply-content {
flex: 1;
}
.reply-header {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.reply-time {
font-size: 11px;
color: #999;
}
.reply-text {
font-size: 13px;
line-height: 1.4;
color: #333;
margin-bottom: 6px;
}
.reply-actions {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.reply-actions .like-btn,
.reply-actions .reply-btn {
font-size: 11px;
padding: 2px 6px;
}
.reply-actions .more-btn {
padding: 2px 6px;
font-size: 12px;
}
.view-more-replies {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px;
padding: 8px 12px;
color: #2575fc;
font-size: 12px;
cursor: pointer;
background: transparent;
border: none;
width: 100%;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
}
.view-more-replies:hover {
background: #f0f8ff;
}
.view-more-replies i {
font-size: 10px;
transition: transform 0.2s;
}
.view-more-replies:hover i {
transform: translateY(2px);
}
.load-more-comments {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
padding: 12px;
color: #666;
font-size: 13px;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
margin-top: 20px;
border: 1px dashed #e0e0e0;
}
.load-more-comments:hover {
background: #f5f5f5;
color: #2575fc;
}
.empty-comments {
text-align: center;
padding: 40px 20px;
color: #999;
}
.empty-comments i {
font-size: 48px;
margin-bottom: 12px;
color: #e0e0e0;
}
.empty-comments p {
font-size: 14px;
margin: 0;
}
/* 弹窗样式 */
.media-preview-modal,
.share-modal,
.actions-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
.media-preview-modal .modal-content,
.share-modal .modal-content,
.actions-modal .modal-content {
background: white;
border-radius: 12px;
overflow: hidden;
max-width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
}
/* 媒体预览弹窗 */
.media-preview-modal .modal-content {
width: 90%;
height: 90%;
}
.modal-header {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.5);
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.modal-body {
flex: 1;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
padding: 20px;
background: black;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.modal-footer {
padding: 12px 16px;
background: rgba(0, 0, 0, 0.5);
color: white;
text-align: center;
font-size: 14px;
}
/* 分享弹窗 */
.share-content {
width: 300px;
background: white;
border-radius: 16px;
overflow: hidden;
}
.share-header {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 20px 20px 0 20px;
margin-bottom: 20px;
}
.share-header text {
margin: 0;
font-size: 18px;
color: #333;
font-weight: 600;
}
.share-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
padding: 0 20px 20px 20px;
}
.share-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 12px 0;
border-radius: 8px;
transition: background 0.2s;
}
.share-option:hover {
background: #f5f5f5;
}
.share-option i {
font-size: 28px;
margin-bottom: 4px;
}
.share-option:nth-child(1) i {
color: #07c160;
}
/* 微信 */
.share-option:nth-child(2) i {
color: #12b7f5;
}
/* QQ */
.share-option:nth-child(3) i {
color: #e6162d;
}
/* 微博 */
.share-option:nth-child(4) i {
color: #666;
}
/* 复制链接 */
.share-option text {
font-size: 12px;
color: #666;
}
/* 操作弹窗 */
.actions-content {
width: 280px;
background: white;
border-radius: 16px;
overflow: hidden;
}
.action-list {
padding: 8px 0;
}
.action-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
width: 100%;
padding: 16px 20px;
background: none;
border: none;
text-align: left;
font-size: 16px;
color: #333;
cursor: pointer;
transition: background 0.2s;
}
.action-item:hover {
background: #f5f5f5;
}
.action-item i {
width: 24px;
margin-right: 12px;
font-size: 18px;
text-align: center;
}
.delete-action {
color: #f74c31;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
text-align: center;
}
.cancel-btn {
width: 100%;
padding: 12px 0;
background: #f5f5f5;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #e0e0e0;
}
</style>