朋友圈

This commit is contained in:
2025-12-08 13:43:54 +08:00
parent a7b5179976
commit b7a90e2fc2
7 changed files with 831 additions and 51 deletions

View File

@@ -124,7 +124,7 @@ class WebSocketManager {
onMessage(handler: MessageHandler) {
// 防止重复注册
if (!this.messageHandlers.includes(handler)) {
this.messageHandlers.push(handler)
this.messageHandlers.push(handler)
}
}
@@ -144,7 +144,7 @@ class WebSocketManager {
onSignal(handler: SignalHandler) {
// 防止重复注册
if (!this.signalHandlers.includes(handler)) {
this.signalHandlers.push(handler)
this.signalHandlers.push(handler)
}
}
@@ -164,7 +164,7 @@ class WebSocketManager {
onMomentNotification(handler: MomentNotifHandler) {
// 防止重复注册
if (!this.momentNotifHandlers.includes(handler)) {
this.momentNotifHandlers.push(handler)
this.momentNotifHandlers.push(handler)
}
}

View File

@@ -130,6 +130,19 @@
<div v-if="member.role === 2" class="absolute -top-1 -right-1 bg-yellow-500 text-black text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center shadow-sm">
<i class="fas fa-crown"></i>
</div>
<!-- 管理员标识 -->
<div v-else-if="member.role === 1" class="absolute -top-1 -right-1 bg-blue-500 text-white text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center shadow-sm">
<i class="fas fa-star"></i>
</div>
<!-- 移除按钮 -->
<button
v-if="canRemoveMember(member)"
class="absolute -top-1 -left-1 w-4 h-4 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10"
@click.stop="showRemoveConfirm(member)"
title="移除成员"
>
<i class="fas fa-minus text-[8px]"></i>
</button>
</div>
<span class="text-[10px] text-gray-400 mt-1 truncate w-14 text-center group-hover:text-white transition-colors">
{{ member.nickname || member.user?.name || '未知' }}
@@ -186,6 +199,31 @@
</div>
</div>
<!-- 弹窗 5: 移除成员确认 -->
<div v-if="removingMember" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4">
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="flex justify-center mb-4">
<Avatar
:name="removingMember.user?.name || removingMember.nickname"
:avatar="removingMember.user?.avatar"
size="lg"
rounded="full"
/>
</div>
<h3 class="text-white font-bold mb-2">移除成员</h3>
<p class="text-gray-400 text-sm mb-6">
确定要将 <span class="text-white font-medium">{{ removingMember.nickname || removingMember.user?.name || '该成员' }}</span> 移出群聊吗
</p>
<div class="flex gap-3">
<button class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm" @click="removingMember = null">取消</button>
<button class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm" :disabled="removing" @click="handleRemoveMember">
<i v-if="removing" class="fas fa-circle-notch fa-spin mr-1"></i>
确定移除
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -221,14 +259,29 @@ const showEditModal = ref(false)
const showQuitConfirm = ref(false)
const showDissolveConfirm = ref(false)
const showCallModal = ref(false)
const removingMember = ref<GroupMember | null>(null)
const removing = ref(false)
const editForm = ref({ name: '', avatar: '' })
// Computed
const isOwner = computed(() => groupInfo.value && authStore.user && members.value.find(m => m.user_id === authStore.user?.id)?.role === 2)
const isAdmin = computed(() => groupInfo.value && authStore.user && members.value.find(m => m.user_id === authStore.user?.id)?.role === 1)
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
const isOwner = computed(() => currentUserRole.value === 2)
const isAdmin = computed(() => currentUserRole.value === 1)
const canInvite = computed(() => isOwner.value || isAdmin.value)
const canEdit = computed(() => isOwner.value)
const canRemove = computed(() => isOwner.value || isAdmin.value)
// 判断是否可以移除某个成员
function canRemoveMember(member: GroupMember): boolean {
// 不能移除自己
if (member.user_id === authStore.user?.id) return false
// 群主可以移除任何人
if (isOwner.value) return true
// 管理员只能移除普通成员role=0
if (isAdmin.value && member.role === 0) return true
return false
}
// 转换群成员为 Contact 格式,供 GroupCallModal 使用
const callCandidates = computed(() => {
@@ -337,6 +390,27 @@ async function handleDissolve() {
function handleMemberClick(member: GroupMember) { /* 可以添加查看成员资料逻辑 */ }
// 移除成员相关
function showRemoveConfirm(member: GroupMember) {
removingMember.value = member
}
async function handleRemoveMember() {
if (!removingMember.value) return
removing.value = true
try {
await groupApi.removeGroupMember(props.roomId, removingMember.value.user_id)
toastStore.success('已移除该成员')
removingMember.value = null
loadGroupInfo()
emit('updated')
} catch (e) {
toastStore.error('移除失败')
} finally {
removing.value = false
}
}
watch(() => props.show, (val) => {
if (val) loadGroupInfo()
})

View File

@@ -0,0 +1,591 @@
<template>
<div
ref="containerRef"
class="video-player relative bg-black rounded-xl overflow-hidden group"
:class="{ 'fullscreen': isFullscreen }"
@mousemove="showControls"
@mouseleave="hideControlsDelayed"
>
<!-- 视频元素 -->
<video
ref="videoRef"
class="w-full h-full object-contain cursor-pointer"
:src="currentSrc"
:poster="poster"
:loop="isLoop"
preload="metadata"
@click="togglePlay"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@ended="onEnded"
@waiting="isBuffering = true"
@canplay="isBuffering = false"
@volumechange="onVolumeChange"
></video>
<!-- 加载中指示器 -->
<div
v-if="isBuffering"
class="absolute inset-0 flex items-center justify-center bg-black/30 z-10"
>
<div class="animate-spin rounded-full h-12 w-12 border-4 border-white/30 border-t-white"></div>
</div>
<!-- 大播放按钮暂停时显示 -->
<Transition name="fade">
<div
v-if="!isPlaying && !isBuffering"
class="absolute inset-0 flex items-center justify-center z-10 cursor-pointer"
@click="togglePlay"
>
<div class="w-20 h-20 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-all hover:scale-110">
<i class="fas fa-play text-3xl ml-1"></i>
</div>
</div>
</Transition>
<!-- 控制栏 -->
<Transition name="slide-up">
<div
v-show="controlsVisible || !isPlaying"
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent pt-12 pb-3 px-4 z-20"
>
<!-- 进度条 -->
<div
class="progress-bar relative h-1.5 bg-white/20 rounded-full cursor-pointer mb-3 group/progress"
@click="seekTo"
@mousedown="startDrag"
>
<!-- 缓冲进度 -->
<div
class="absolute inset-y-0 left-0 bg-white/30 rounded-full"
:style="{ width: bufferedPercent + '%' }"
></div>
<!-- 播放进度 -->
<div
class="absolute inset-y-0 left-0 bg-primary rounded-full transition-all"
:style="{ width: progressPercent + '%' }"
></div>
<!-- 拖动手柄 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg opacity-0 group-hover/progress:opacity-100 transition-opacity"
:style="{ left: `calc(${progressPercent}% - 8px)` }"
></div>
</div>
<!-- 控制按钮 -->
<div class="flex items-center justify-between text-white text-sm">
<!-- 左侧控制 -->
<div class="flex items-center gap-3">
<!-- 播放/暂停 -->
<button
class="w-10 h-10 flex items-center justify-center hover:bg-white/10 rounded-full transition"
@click="togglePlay"
>
<i :class="isPlaying ? 'fas fa-pause' : 'fas fa-play'"></i>
</button>
<!-- 快退 -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition text-xs"
@click="skip(-10)"
title="快退10秒"
>
<i class="fas fa-backward"></i>
</button>
<!-- 快进 -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition text-xs"
@click="skip(10)"
title="快进10秒"
>
<i class="fas fa-forward"></i>
</button>
<!-- 上一个/下一个多视频时 -->
<template v-if="videos.length > 1">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
:class="{ 'opacity-30 cursor-not-allowed': currentIndex === 0 }"
:disabled="currentIndex === 0"
@click="prev"
title="上一个"
>
<i class="fas fa-step-backward"></i>
</button>
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
:class="{ 'opacity-30 cursor-not-allowed': currentIndex === videos.length - 1 }"
:disabled="currentIndex === videos.length - 1"
@click="next"
title="下一个"
>
<i class="fas fa-step-forward"></i>
</button>
</template>
<!-- 音量控制 -->
<div class="relative flex items-center group/volume">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
@click="toggleMute"
>
<i :class="volumeIcon"></i>
</button>
<div class="hidden group-hover/volume:flex items-center ml-1 w-20">
<input
type="range"
min="0"
max="1"
step="0.1"
:value="volume"
class="volume-slider w-full h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
@input="setVolume"
/>
</div>
</div>
<!-- 时间显示 -->
<span class="text-xs text-white/70 tabular-nums ml-2">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<!-- 右侧控制 -->
<div class="flex items-center gap-2">
<!-- 视频序号多视频时 -->
<span v-if="videos.length > 1" class="text-xs text-white/60 mr-2">
{{ currentIndex + 1 }} / {{ videos.length }}
</span>
<!-- 倍速 -->
<div class="relative">
<button
class="px-2 h-8 flex items-center justify-center hover:bg-white/10 rounded-lg transition text-xs"
@click="showSpeedMenu = !showSpeedMenu"
>
{{ playbackRate }}x
</button>
<Transition name="fade">
<div
v-if="showSpeedMenu"
class="absolute bottom-full right-0 mb-2 bg-gray-900/95 backdrop-blur-sm rounded-lg py-1 shadow-xl border border-white/10"
>
<button
v-for="rate in playbackRates"
:key="rate"
class="block w-full px-4 py-1.5 text-left text-xs hover:bg-white/10 transition"
:class="{ 'text-primary': playbackRate === rate }"
@click="setPlaybackRate(rate)"
>
{{ rate }}x
</button>
</div>
</Transition>
</div>
<!-- 循环播放 -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
:class="{ 'text-primary': isLoop }"
@click="isLoop = !isLoop"
title="循环播放"
>
<i class="fas fa-redo-alt text-xs"></i>
</button>
<!-- 画中画 -->
<button
v-if="supportsPiP"
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
@click="togglePiP"
title="画中画"
>
<i class="fas fa-external-link-square-alt text-xs"></i>
</button>
<!-- 下载 -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
@click="download"
title="下载"
>
<i class="fas fa-download text-xs"></i>
</button>
<!-- 全屏 -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-white/10 rounded-full transition"
@click="toggleFullscreen"
title="全屏"
>
<i :class="isFullscreen ? 'fas fa-compress' : 'fas fa-expand'"></i>
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface Props {
src?: string
videos?: string[]
poster?: string
autoplay?: boolean
initialIndex?: number
}
const props = withDefaults(defineProps<Props>(), {
src: '',
videos: () => [],
poster: '',
autoplay: false,
initialIndex: 0
})
const emit = defineEmits<{
ended: []
timeupdate: [time: number]
}>()
// Refs
const containerRef = ref<HTMLElement | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
// 状态
const isPlaying = ref(false)
const isBuffering = ref(false)
const isFullscreen = ref(false)
const isLoop = ref(false)
const isMuted = ref(false)
const controlsVisible = ref(true)
const showSpeedMenu = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const buffered = ref(0)
const playbackRate = ref(1)
const currentIndex = ref(props.initialIndex)
const isDragging = ref(false)
// 定时器
let controlsTimer: ReturnType<typeof setTimeout> | null = null
// 可用的播放速率
const playbackRates = [0.5, 0.75, 1, 1.25, 1.5, 2]
// 计算属性
const videoList = computed(() => {
if (props.videos.length > 0) return props.videos
if (props.src) return [props.src]
return []
})
const currentSrc = computed(() => videoList.value[currentIndex.value] || '')
const progressPercent = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const bufferedPercent = computed(() => {
if (duration.value === 0) return 0
return (buffered.value / duration.value) * 100
})
const volumeIcon = computed(() => {
if (isMuted.value || volume.value === 0) return 'fas fa-volume-mute'
if (volume.value < 0.5) return 'fas fa-volume-down'
return 'fas fa-volume-up'
})
const supportsPiP = computed(() => {
return document.pictureInPictureEnabled
})
// 方法
function togglePlay() {
if (!videoRef.value) return
if (isPlaying.value) {
videoRef.value.pause()
} else {
videoRef.value.play()
}
isPlaying.value = !isPlaying.value
}
function skip(seconds: number) {
if (!videoRef.value) return
videoRef.value.currentTime = Math.max(0, Math.min(duration.value, videoRef.value.currentTime + seconds))
}
function prev() {
if (currentIndex.value > 0) {
currentIndex.value--
isPlaying.value = false
}
}
function next() {
if (currentIndex.value < videoList.value.length - 1) {
currentIndex.value++
isPlaying.value = false
}
}
function seekTo(e: MouseEvent) {
if (!videoRef.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
videoRef.value.currentTime = percent * duration.value
}
function startDrag() {
isDragging.value = true
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value || !videoRef.value || !containerRef.value) return
const progressBar = containerRef.value.querySelector('.progress-bar')
if (!progressBar) return
const rect = progressBar.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
videoRef.value.currentTime = percent * duration.value
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
function toggleMute() {
if (!videoRef.value) return
isMuted.value = !isMuted.value
videoRef.value.muted = isMuted.value
}
function setVolume(e: Event) {
if (!videoRef.value) return
const value = parseFloat((e.target as HTMLInputElement).value)
volume.value = value
videoRef.value.volume = value
isMuted.value = value === 0
}
function setPlaybackRate(rate: number) {
if (!videoRef.value) return
playbackRate.value = rate
videoRef.value.playbackRate = rate
showSpeedMenu.value = false
}
async function togglePiP() {
if (!videoRef.value) return
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
} else {
await videoRef.value.requestPictureInPicture()
}
} catch (e) {
console.error('PiP error:', e)
}
}
function toggleFullscreen() {
if (!containerRef.value) return
if (isFullscreen.value) {
document.exitFullscreen()
} else {
containerRef.value.requestFullscreen()
}
}
function download() {
if (!currentSrc.value) return
const a = document.createElement('a')
a.href = currentSrc.value
a.download = `video_${Date.now()}.mp4`
a.click()
}
function showControls() {
controlsVisible.value = true
if (controlsTimer) clearTimeout(controlsTimer)
}
function hideControlsDelayed() {
if (controlsTimer) clearTimeout(controlsTimer)
if (isPlaying.value) {
controlsTimer = setTimeout(() => {
controlsVisible.value = false
}, 3000)
}
}
function onTimeUpdate() {
if (!videoRef.value) return
currentTime.value = videoRef.value.currentTime
emit('timeupdate', currentTime.value)
// 更新缓冲进度
if (videoRef.value.buffered.length > 0) {
buffered.value = videoRef.value.buffered.end(videoRef.value.buffered.length - 1)
}
}
function onLoadedMetadata() {
if (!videoRef.value) return
duration.value = videoRef.value.duration
if (props.autoplay) {
videoRef.value.play()
isPlaying.value = true
}
}
function onEnded() {
isPlaying.value = false
emit('ended')
// 如果有下一个视频,自动播放
if (currentIndex.value < videoList.value.length - 1 && !isLoop.value) {
next()
setTimeout(() => {
if (videoRef.value) {
videoRef.value.play()
isPlaying.value = true
}
}, 100)
}
}
function onVolumeChange() {
if (!videoRef.value) return
volume.value = videoRef.value.volume
isMuted.value = videoRef.value.muted
}
function formatTime(seconds: number): string {
if (isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
if (!containerRef.value?.contains(document.activeElement) && document.activeElement !== document.body) return
switch (e.code) {
case 'Space':
e.preventDefault()
togglePlay()
break
case 'ArrowLeft':
e.preventDefault()
skip(-5)
break
case 'ArrowRight':
e.preventDefault()
skip(5)
break
case 'ArrowUp':
e.preventDefault()
if (videoRef.value) {
videoRef.value.volume = Math.min(1, volume.value + 0.1)
}
break
case 'ArrowDown':
e.preventDefault()
if (videoRef.value) {
videoRef.value.volume = Math.max(0, volume.value - 0.1)
}
break
case 'KeyM':
toggleMute()
break
case 'KeyF':
toggleFullscreen()
break
}
}
// 全屏变化监听
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
// 监听视频切换
watch(currentIndex, () => {
currentTime.value = 0
duration.value = 0
buffered.value = 0
})
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('fullscreenchange', onFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (controlsTimer) clearTimeout(controlsTimer)
})
</script>
<style scoped>
.video-player.fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
border-radius: 0;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>

View File

@@ -79,14 +79,8 @@
</div>
</div>
<!-- 视频 -->
<div v-else-if="moment.media_type === 2" class="relative aspect-video bg-black rounded overflow-hidden">
<video
:src="mediaUrls[0]"
class="w-full h-full object-contain"
controls
preload="metadata"
@click.stop
></video>
<div v-else-if="moment.media_type === 2" @click.stop>
<VideoPlayer :src="mediaUrls[0]" class="aspect-video rounded" />
</div>
</div>
@@ -183,6 +177,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import VideoPlayer from '@/components/common/VideoPlayer.vue'
import { useAuthStore } from '@/stores/auth'
import { parseMediaUrls, parseTopicTags } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'

View File

@@ -103,8 +103,8 @@
</div>
</div>
<!-- 视频 -->
<div v-else-if="moment.media_type === 2" class="relative aspect-video bg-black rounded-xl overflow-hidden max-w-lg">
<video :src="getMediaUrls(moment)[0]" class="w-full h-full object-contain" controls preload="metadata"></video>
<div v-else-if="moment.media_type === 2" class="max-w-lg">
<VideoPlayer :src="getMediaUrls(moment)[0]" class="aspect-video" />
</div>
</div>
@@ -155,47 +155,87 @@
</div>
</div>
<!-- 评论列表 -->
<div v-if="moment.comments && moment.comments.length > 0" class="mt-3 space-y-2">
<!-- 评论列表嵌套分层显示 -->
<div v-if="moment.comments && moment.comments.length > 0" class="mt-3 space-y-3">
<!-- 遍历一级评论 -->
<div
v-for="comment in moment.comments.slice(0, expandedMoments.has(moment.id) ? undefined : 3)"
v-for="comment in getDisplayComments(moment)"
:key="comment.id"
class="flex items-start gap-2 p-2 bg-white/5 rounded-lg text-sm group/comment"
class="space-y-2"
>
<!-- 一级评论 -->
<div class="flex items-start gap-2 p-2.5 bg-white/5 rounded-xl text-sm group/comment hover:bg-white/[0.07] transition">
<Avatar
:name="comment.user?.name || ''"
:avatar="comment.user?.avatar"
size="sm"
class="shrink-0 cursor-pointer hover:opacity-80 transition"
@click="showUserInfo(comment.user)"
class="shrink-0 cursor-pointer hover:opacity-80 transition"
@click="showUserInfo(comment.user)"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
class="font-medium text-gray-200 cursor-pointer hover:text-primary transition"
@click="showUserInfo(comment.user)"
>{{ comment.user?.name }}</span>
<span class="text-[10px] text-gray-600">{{ formatCommentTime(comment.created_at) }}</span>
</div>
<p class="text-gray-400 break-words mt-1 leading-relaxed">{{ comment.content }}</p>
<button
@click.stop="openCommentInput(moment, comment)"
class="mt-1.5 text-xs text-gray-500 hover:text-primary transition"
>
回复
</button>
</div>
</div>
<!-- 回复列表嵌套缩进 -->
<div v-if="comment.replies && comment.replies.length > 0" class="ml-8 pl-3 border-l-2 border-gray-700/50 space-y-2">
<div
v-for="reply in comment.replies"
:key="reply.id"
class="flex items-start gap-2 p-2 bg-white/[0.03] rounded-lg text-sm group/reply hover:bg-white/5 transition"
>
<Avatar
:name="reply.user?.name || ''"
:avatar="reply.user?.avatar"
size="xs"
class="shrink-0 cursor-pointer hover:opacity-80 transition"
@click="showUserInfo(reply.user)"
/>
<div class="flex-1 min-w-0">
<span
class="font-medium text-gray-300 cursor-pointer hover:text-primary transition"
@click="showUserInfo(comment.user)"
>{{ comment.user?.name }}</span>
<span v-if="comment.reply_to_user" class="text-gray-500">
回复 <span
class="text-gray-400 cursor-pointer hover:text-primary transition"
@click="showUserInfo(comment.reply_to_user)"
>{{ comment.reply_to_user.name }}</span>
<div class="flex items-center gap-1 flex-wrap text-xs">
<span
class="font-medium text-gray-300 cursor-pointer hover:text-primary transition"
@click="showUserInfo(reply.user)"
>{{ reply.user?.name }}</span>
<span v-if="reply.reply_to_user" class="text-gray-500">
<i class="fas fa-reply fa-flip-horizontal text-[10px] mx-1"></i>
<span
class="text-gray-400 cursor-pointer hover:text-primary transition"
@click="showUserInfo(reply.reply_to_user)"
>{{ reply.reply_to_user.name }}</span>
</span>
<span class="text-gray-500">: </span>
<span class="text-gray-400 break-words">{{ comment.content }}</span>
<!-- 回复按钮 -->
<button
@click.stop="openCommentInput(moment, comment)"
class="ml-2 text-xs text-gray-500 hover:text-primary transition opacity-0 group-hover/comment:opacity-100"
>
回复
</button>
<span class="text-gray-600 text-[10px] ml-1">{{ formatCommentTime(reply.created_at) }}</span>
</div>
<p class="text-gray-400 break-words mt-0.5 text-[13px] leading-relaxed">{{ reply.content }}</p>
<button
@click.stop="openCommentInput(moment, reply)"
class="mt-1 text-[11px] text-gray-500 hover:text-primary transition opacity-0 group-hover/reply:opacity-100"
>
回复
</button>
</div>
</div>
</div>
</div>
<!-- 展开更多评论 -->
<button
v-if="moment.comments.length > 3 && !expandedMoments.has(moment.id)"
v-if="getTopLevelCommentCount(moment.comments) > 3 && !expandedMoments.has(moment.id)"
@click="expandedMoments.add(moment.id)"
class="text-primary text-sm hover:underline"
class="text-primary text-sm hover:underline pl-2"
>
查看全部 {{ moment.comments.length }} 条评论
</button>
@@ -290,6 +330,7 @@ import { useChatStore } from '@/stores/chat'
import Avatar from '@/components/common/Avatar.vue'
import ImagePreview from '@/components/common/ImagePreview.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import VideoPlayer from '@/components/common/VideoPlayer.vue'
import MomentPublisherDark from './MomentPublisherDark.vue'
import MomentNotificationPanel from './MomentNotificationPanel.vue'
import { parseMediaUrls } from '@/types/moment'
@@ -410,6 +451,83 @@ function onPublished() {
showPublisher.value = false
}
// 评论分组接口
interface GroupedComment extends MomentComment {
replies?: MomentComment[]
}
// 将扁平评论列表转为树形结构(一级评论 + 回复)
function groupComments(comments: MomentComment[]): GroupedComment[] {
if (!comments || comments.length === 0) return []
// 找出所有一级评论(没有 reply_to_comment_id 的)
const topLevelComments: GroupedComment[] = comments
.filter(c => !c.reply_to_comment_id)
.map(c => ({ ...c, replies: [] }))
// 创建 ID 到评论的映射
const commentMap = new Map<number, GroupedComment>()
topLevelComments.forEach(c => commentMap.set(c.id, c))
// 将回复添加到对应的一级评论下
comments.forEach(comment => {
if (comment.reply_to_comment_id) {
// 找到被回复的评论(可能是一级评论,也可能是其他回复)
// 我们将所有回复都放到一级评论下面
const parentId = findRootCommentId(comment, comments)
const parent = commentMap.get(parentId)
if (parent) {
parent.replies = parent.replies || []
parent.replies.push(comment)
}
}
})
return topLevelComments
}
// 找到评论的根一级评论ID
function findRootCommentId(comment: MomentComment, allComments: MomentComment[]): number {
if (!comment.reply_to_comment_id) return comment.id
const parent = allComments.find(c => c.id === comment.reply_to_comment_id)
if (!parent || !parent.reply_to_comment_id) {
return comment.reply_to_comment_id
}
return findRootCommentId(parent, allComments)
}
// 获取要显示的评论(支持展开/收起)
function getDisplayComments(moment: Moment): GroupedComment[] {
const grouped = groupComments(moment.comments || [])
const isExpanded = expandedMoments.has(moment.id)
return isExpanded ? grouped : grouped.slice(0, 3)
}
// 获取一级评论数量
function getTopLevelCommentCount(comments: MomentComment[]): number {
if (!comments) return 0
return comments.filter(c => !c.reply_to_comment_id).length
}
// 格式化评论时间
function formatCommentTime(time: string): string {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return `${date.getMonth() + 1}${date.getDate()}`
}
// 显示用户信息卡片
function showUserInfo(user?: User | null) {
if (!user) return

View File

@@ -57,16 +57,14 @@
</div>
<!-- 视频预览 -->
<div v-if="mediaType === 2 && videoFile" class="mb-4">
<div class="relative aspect-video bg-black rounded-xl overflow-hidden">
<video :src="videoPreview" class="w-full h-full object-contain" controls></video>
<button
@click="removeVideo"
class="absolute top-3 right-3 w-8 h-8 bg-black/60 hover:bg-red-500 rounded-full flex items-center justify-center transition"
>
<i class="fas fa-times text-white"></i>
</button>
</div>
<div v-if="mediaType === 2 && videoFile" class="mb-4 relative">
<VideoPlayer :src="videoPreview" class="aspect-video rounded-xl" />
<button
@click="removeVideo"
class="absolute top-3 right-3 z-30 w-8 h-8 bg-black/60 hover:bg-red-500 rounded-full flex items-center justify-center transition"
>
<i class="fas fa-times text-white"></i>
</button>
</div>
<!-- 位置信息 -->
@@ -311,6 +309,7 @@ import { useChatStore } from '@/stores/chat'
import * as attachmentApi from '@/api/modules/attachment'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import VideoPlayer from '@/components/common/VideoPlayer.vue'
import type { CreateMomentRequest } from '@/types/moment'
import type { Contact } from '@/types/api'

View File

@@ -127,6 +127,9 @@