朋友圈
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
591
src/components/common/VideoPlayer.vue
Normal file
591
src/components/common/VideoPlayer.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user