一部分功能
This commit is contained in:
@@ -29,6 +29,11 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
|
||||
/* 气泡尖角 */
|
||||
.bubble-self {
|
||||
border-top-right-radius: 2px;
|
||||
|
||||
187
src/components/call/CallWindow.vue
Normal file
187
src/components/call/CallWindow.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="call.active"
|
||||
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700"
|
||||
:class="[
|
||||
call.minimized
|
||||
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
|
||||
: isMobile
|
||||
? 'inset-0 w-full h-full rounded-none'
|
||||
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
|
||||
]"
|
||||
>
|
||||
<!-- 顶部功能栏 -->
|
||||
<div
|
||||
class="absolute top-0 w-full p-4 z-20 flex justify-between items-start bg-gradient-to-b from-black/60 to-transparent transition-opacity hover:opacity-100"
|
||||
:class="{ 'opacity-0': !call.minimized }"
|
||||
>
|
||||
<div class="text-white text-sm font-bold drop-shadow-md px-2" v-if="!call.minimized">
|
||||
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
|
||||
{{ call.type === 'video' ? '视频通话' : '语音通话' }}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-black/40 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition"
|
||||
@click="call.minimized = !call.minimized"
|
||||
title="最小化/还原"
|
||||
>
|
||||
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
|
||||
<!-- 纯语音 UI 或 连接中状态:显示大头像 -->
|
||||
<div
|
||||
v-if="call.type === 'audio' || call.status !== 'connected'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
|
||||
>
|
||||
<!-- 呼叫中/语音通话时的波纹动画 -->
|
||||
<div
|
||||
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-xl transition-all duration-1000 relative"
|
||||
:class="{
|
||||
'animate-[ripple_2s_infinite]':
|
||||
call.status === 'connected' || call.status === 'outgoing',
|
||||
}"
|
||||
:style="{ background: target?.color || '#6366f1' }"
|
||||
>
|
||||
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-100 tracking-wide">
|
||||
{{ target?.remark_name || target?.user?.name || '未知用户' }}
|
||||
</div>
|
||||
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2">
|
||||
<span
|
||||
v-if="call.status === 'connected'"
|
||||
class="w-2 h-2 bg-green-500 rounded-full animate-pulse"
|
||||
></span>
|
||||
{{ call.statusText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频通话 UI -->
|
||||
<!-- 远程视频流 (仅 Video 模式且连接成功显示) -->
|
||||
<video
|
||||
ref="remoteVideo"
|
||||
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0"
|
||||
autoplay
|
||||
playsinline
|
||||
:class="{
|
||||
'opacity-0': call.type === 'audio' || call.status !== 'connected',
|
||||
}"
|
||||
></video>
|
||||
|
||||
<!-- 本地视频 (小窗) - 仅 Video 模式显示 -->
|
||||
<video
|
||||
v-show="call.type === 'video' && !call.minimized"
|
||||
ref="localVideo"
|
||||
class="absolute top-5 right-5 w-48 h-36 bg-gray-800 rounded-lg border border-gray-600 object-cover z-20 shadow-2xl transition hover:scale-105"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<div
|
||||
v-if="!call.minimized"
|
||||
class="h-24 bg-gray-900/90 backdrop-blur border-t border-gray-800 flex items-center justify-center gap-8 shrink-0 pb-4 z-30"
|
||||
>
|
||||
<!-- 挂断 -->
|
||||
<button
|
||||
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-red-500/30"
|
||||
@click="endCall"
|
||||
title="挂断"
|
||||
>
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
</button>
|
||||
|
||||
<!-- 接听 -->
|
||||
<button
|
||||
v-if="call.status === 'incoming'"
|
||||
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-green-500/30"
|
||||
@click="acceptCall"
|
||||
title="接听"
|
||||
>
|
||||
<i class="fas fa-phone"></i>
|
||||
</button>
|
||||
|
||||
<!-- 通话中功能 -->
|
||||
<template v-if="call.status === 'connected'">
|
||||
<button
|
||||
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
|
||||
@click="toggleMute"
|
||||
:class="{ 'bg-white text-black': call.muted }"
|
||||
title="静音"
|
||||
>
|
||||
<i class="fas" :class="call.muted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
|
||||
</button>
|
||||
<!-- 仅视频模式显示关闭摄像头 -->
|
||||
<button
|
||||
v-if="call.type === 'video'"
|
||||
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
|
||||
@click="toggleCamera"
|
||||
:class="{ 'bg-white text-black': call.camOff }"
|
||||
title="关闭摄像头"
|
||||
>
|
||||
<i class="fas" :class="call.camOff ? 'fa-video-slash' : 'fa-video'"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
call: {
|
||||
active: boolean
|
||||
minimized: boolean
|
||||
type: 'audio' | 'video'
|
||||
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
|
||||
statusText: string
|
||||
muted: boolean
|
||||
camOff: boolean
|
||||
}
|
||||
target: Contact | null
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'end-call': []
|
||||
'accept-call': []
|
||||
'toggle-mute': []
|
||||
'toggle-camera': []
|
||||
'toggle-minimize': []
|
||||
}>()
|
||||
|
||||
const remoteVideo = ref<HTMLVideoElement>()
|
||||
const localVideo = ref<HTMLVideoElement>()
|
||||
|
||||
function endCall() {
|
||||
emit('end-call')
|
||||
}
|
||||
|
||||
function acceptCall() {
|
||||
emit('accept-call')
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
emit('toggle-mute')
|
||||
}
|
||||
|
||||
function toggleCamera() {
|
||||
emit('toggle-camera')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
remoteVideo,
|
||||
localVideo,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<Avatar
|
||||
:name="msg.isSelf ? currentUser.name : target.user?.name"
|
||||
:avatar="msg.isSelf ? currentUser.avatar : target.user?.avatar"
|
||||
:name="msg.isSelf ? currentUser.name : (target.user?.name || target.remark_name)"
|
||||
:avatar="msg.isSelf ? currentUser.avatar : (target.user?.avatar || target.remark_name?.charAt(0))"
|
||||
:color="msg.isSelf ? generateColor(currentUser.id) : target.color"
|
||||
size="sm"
|
||||
rounded="lg"
|
||||
class="cursor-pointer hover:opacity-80 transition self-start mt-1"
|
||||
@click="$emit('avatar-click', msg.isSelf ? currentUser : target)"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center text-white font-bold shadow-md transition transform hover:opacity-80 cursor-pointer"
|
||||
:class="sizeClass"
|
||||
:style="{ background: color || generateColor(name || '') }"
|
||||
class="flex items-center justify-center text-white font-bold shadow-md transition"
|
||||
:class="[sizeClass, roundedClass, { 'cursor-pointer hover:opacity-80': $attrs.onClick }]"
|
||||
:style="{ background: color || generateColor(name || avatar || '') }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ avatar || name?.charAt(0).toUpperCase() || '?' }}
|
||||
@@ -17,25 +17,36 @@ interface Props {
|
||||
avatar?: string
|
||||
name?: string
|
||||
color?: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'contact'
|
||||
rounded?: 'full' | 'xl' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
rounded: 'full',
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
sm: 'w-9 h-9 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-16 h-16 text-xl',
|
||||
contact: 'w-11 h-11 text-lg',
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
const roundedClass = computed(() => {
|
||||
const rounded = {
|
||||
full: 'rounded-full',
|
||||
xl: 'rounded-xl',
|
||||
lg: 'rounded-lg',
|
||||
}
|
||||
return rounded[props.rounded]
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
65
src/components/common/FileConfirmModal.vue
Normal file
65
src/components/common/FileConfirmModal.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700 transform transition-all scale-100">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>发送确认</span>
|
||||
<i class="fas fa-times cursor-pointer hover:text-white text-gray-500" @click="$emit('close')"></i>
|
||||
</div>
|
||||
<div class="p-6 text-center">
|
||||
<img
|
||||
v-if="type === 1"
|
||||
:src="preview"
|
||||
class="max-h-60 mx-auto rounded-lg mb-4 shadow-md bg-black object-contain border border-gray-700"
|
||||
/>
|
||||
<div v-else class="text-6xl text-blue-400 mb-4 animate-bounce">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 font-medium truncate px-4">{{ name || '未命名文件' }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ fileSize }}</p>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
确认发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
type: number
|
||||
preview?: string
|
||||
name?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const fileSize = computed(() => {
|
||||
return formatSize(props.size || 0)
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
289
src/composables/useWebRTC.ts
Normal file
289
src/composables/useWebRTC.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import * as systemApi from '@/api/modules/system'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
import type { CallStatus } from '@/types/message'
|
||||
|
||||
export interface CallState {
|
||||
active: boolean
|
||||
minimized: boolean
|
||||
type: 'audio' | 'video'
|
||||
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
|
||||
statusText: string
|
||||
id: string | null
|
||||
muted: boolean
|
||||
camOff: boolean
|
||||
}
|
||||
|
||||
export function useWebRTC(userId: string) {
|
||||
const call = reactive<CallState>({
|
||||
active: false,
|
||||
minimized: false,
|
||||
type: 'video',
|
||||
status: 'idle',
|
||||
statusText: '',
|
||||
id: null,
|
||||
muted: false,
|
||||
camOff: false,
|
||||
})
|
||||
|
||||
const localVideo = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideo = ref<HTMLVideoElement | null>(null)
|
||||
let pc: RTCPeerConnection | null = null
|
||||
let localStream: MediaStream | null = null
|
||||
|
||||
/**
|
||||
* 初始化媒体流
|
||||
*/
|
||||
async function initMedia(videoEnabled: boolean): Promise<void> {
|
||||
try {
|
||||
const constraints = { video: videoEnabled, audio: true }
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
localStream = stream
|
||||
if (videoEnabled && localVideo.value) {
|
||||
localVideo.value.srcObject = stream
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get media:', error)
|
||||
throw new Error('无法获取设备权限或设备不支持')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ICE服务器配置
|
||||
*/
|
||||
async function getIceServers() {
|
||||
try {
|
||||
const servers = await systemApi.getIceServers(userId)
|
||||
if (servers && servers.length > 0) {
|
||||
return servers.map((s) => ({
|
||||
urls: s.urls,
|
||||
username: s.username,
|
||||
credential: s.credential,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ICE servers:', error)
|
||||
}
|
||||
// Fallback: 使用默认STUN
|
||||
return [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PeerConnection
|
||||
*/
|
||||
async function createPC(): Promise<void> {
|
||||
const iceServers = await getIceServers()
|
||||
iceServers.push({ urls: 'stun:stun.l.google.com:19302' })
|
||||
|
||||
pc = new RTCPeerConnection({ iceServers })
|
||||
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach((track) => {
|
||||
pc!.addTrack(track, localStream!)
|
||||
})
|
||||
}
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
if (remoteVideo.value) {
|
||||
remoteVideo.value.srcObject = e.streams[0]
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && call.id) {
|
||||
sendSignal('candidate', e.candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let currentReceiverUserId = ''
|
||||
|
||||
/**
|
||||
* 发送信令
|
||||
*/
|
||||
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
|
||||
if (!call.id) return
|
||||
|
||||
const payload = {
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: receiverUserId || currentReceiverUserId,
|
||||
room_id: '',
|
||||
message_type: 6,
|
||||
content: JSON.stringify(data || {}),
|
||||
call_id: call.id,
|
||||
call_status: status,
|
||||
extra: JSON.stringify({ type: call.type }),
|
||||
}
|
||||
|
||||
messageApi.sendMessage(payload).catch(console.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始通话
|
||||
*/
|
||||
async function startCall(type: 'audio' | 'video', receiverUserId: string) {
|
||||
currentReceiverUserId = receiverUserId
|
||||
call.type = type
|
||||
call.id = Date.now().toString()
|
||||
call.active = true
|
||||
call.minimized = false
|
||||
call.status = 'outgoing'
|
||||
call.statusText = '等待对方接听...'
|
||||
|
||||
try {
|
||||
await initMedia(type === 'video')
|
||||
await createPC()
|
||||
sendSignal('invite', undefined, receiverUserId)
|
||||
} catch (error: any) {
|
||||
alert(error.message || '无法启动通话')
|
||||
endCall()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接听通话
|
||||
*/
|
||||
async function acceptCall(senderUserId?: string) {
|
||||
if (senderUserId) {
|
||||
currentReceiverUserId = senderUserId
|
||||
}
|
||||
call.status = 'connected'
|
||||
call.statusText = '连接中...'
|
||||
|
||||
try {
|
||||
await initMedia(call.type === 'video')
|
||||
await createPC()
|
||||
|
||||
const offer = await pc!.createOffer()
|
||||
await pc!.setLocalDescription(offer)
|
||||
sendSignal('accepted', undefined, senderUserId)
|
||||
sendSignal('offer', offer, senderUserId)
|
||||
} catch (error) {
|
||||
console.error('Failed to accept call:', error)
|
||||
endCall()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束通话
|
||||
*/
|
||||
function endCall() {
|
||||
sendSignal('hangup')
|
||||
closeCall()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭通话
|
||||
*/
|
||||
function closeCall() {
|
||||
call.active = false
|
||||
call.status = 'idle'
|
||||
call.statusText = ''
|
||||
call.id = null
|
||||
|
||||
if (pc) {
|
||||
pc.close()
|
||||
pc = null
|
||||
}
|
||||
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach((track) => track.stop())
|
||||
localStream = null
|
||||
}
|
||||
|
||||
if (localVideo.value) {
|
||||
localVideo.value.srcObject = null
|
||||
}
|
||||
if (remoteVideo.value) {
|
||||
remoteVideo.value.srcObject = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信令消息
|
||||
*/
|
||||
async function handleSignaling(message: ChatMessage) {
|
||||
const signal = message.call_status as CallStatus
|
||||
const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
|
||||
|
||||
let callType = 'video'
|
||||
if (extra && extra.type) {
|
||||
callType = extra.type
|
||||
}
|
||||
|
||||
if (signal === 'invite') {
|
||||
currentReceiverUserId = message.sender_user_id
|
||||
call.id = message.call_id || Date.now().toString()
|
||||
call.type = callType as 'audio' | 'video'
|
||||
call.active = true
|
||||
call.minimized = false
|
||||
call.status = 'incoming'
|
||||
call.statusText = `对方邀请您进行${callType === 'video' ? '视频' : '语音'}通话...`
|
||||
} else if (signal === 'accepted') {
|
||||
call.status = 'connected'
|
||||
call.statusText = '通话中'
|
||||
const offer = await pc!.createOffer()
|
||||
await pc!.setLocalDescription(offer)
|
||||
sendSignal('offer', offer, message.sender_user_id)
|
||||
} else if (signal === 'offer') {
|
||||
const desc = JSON.parse(message.content)
|
||||
if (!pc) {
|
||||
await initMedia(call.type === 'video')
|
||||
await createPC()
|
||||
}
|
||||
await pc!.setRemoteDescription(desc)
|
||||
const answer = await pc!.createAnswer()
|
||||
await pc!.setLocalDescription(answer)
|
||||
sendSignal('answer', answer, message.sender_user_id)
|
||||
call.status = 'connected'
|
||||
call.statusText = '通话中'
|
||||
} else if (signal === 'answer') {
|
||||
await pc!.setRemoteDescription(JSON.parse(message.content))
|
||||
} else if (signal === 'candidate') {
|
||||
await pc!.addIceCandidate(JSON.parse(message.content))
|
||||
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
|
||||
closeCall()
|
||||
if (signal === 'answered_elsewhere') {
|
||||
alert('已在其他设备接听')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换静音
|
||||
*/
|
||||
function toggleMute() {
|
||||
call.muted = !call.muted
|
||||
if (localStream) {
|
||||
localStream.getAudioTracks()[0].enabled = !call.muted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换摄像头
|
||||
*/
|
||||
function toggleCamera() {
|
||||
call.camOff = !call.camOff
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0]
|
||||
if (videoTrack) {
|
||||
videoTrack.enabled = !call.camOff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
localVideo,
|
||||
remoteVideo,
|
||||
startCall,
|
||||
acceptCall,
|
||||
endCall,
|
||||
handleSignaling,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,10 @@
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:name="contact.user?.name || contact.remark_name"
|
||||
:avatar="contact.user?.avatar"
|
||||
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
|
||||
:color="contact.color"
|
||||
size="md"
|
||||
size="contact"
|
||||
rounded="xl"
|
||||
class="transition transform group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
@@ -107,10 +108,11 @@
|
||||
<div class="flex items-center gap-3 cursor-pointer" @click="backToList">
|
||||
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
|
||||
<Avatar
|
||||
:name="chatStore.currentTarget.user?.name"
|
||||
:avatar="chatStore.currentTarget.user?.avatar"
|
||||
:name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name"
|
||||
:avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)"
|
||||
:color="chatStore.currentTarget.color"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
|
||||
@@ -181,6 +183,29 @@
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu />
|
||||
|
||||
<!-- 通话窗口 -->
|
||||
<CallWindow
|
||||
:call="webrtc.call"
|
||||
:target="chatStore.currentTarget"
|
||||
:is-mobile="isMobile"
|
||||
@end-call="webrtc.endCall"
|
||||
@accept-call="webrtc.acceptCall"
|
||||
@toggle-mute="webrtc.toggleMute"
|
||||
@toggle-camera="webrtc.toggleCamera"
|
||||
@toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized"
|
||||
/>
|
||||
|
||||
<!-- 文件确认模态框 -->
|
||||
<FileConfirmModal
|
||||
:show="fileModal.show"
|
||||
:type="fileModal.type"
|
||||
:preview="fileModal.preview"
|
||||
:name="fileModal.name"
|
||||
:size="fileModal.size"
|
||||
@close="fileModal.show = false"
|
||||
@confirm="confirmSendFile"
|
||||
/>
|
||||
|
||||
<!-- 用户资料模态框 -->
|
||||
<div
|
||||
v-if="showProfileModal && authStore.user"
|
||||
@@ -194,6 +219,7 @@
|
||||
:name="authStore.user.name"
|
||||
:avatar="authStore.user.avatar"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="mx-auto border-4 border-panel shadow-xl"
|
||||
/>
|
||||
<h2 class="text-xl font-bold mt-3 text-white">{{ authStore.user.name }}</h2>
|
||||
@@ -205,7 +231,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
@@ -216,8 +242,11 @@ import * as contactApi from '@/api/modules/contact'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { Contact, ChatMessage } from '@/types/api'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
|
||||
import CallWindow from '@/components/call/CallWindow.vue'
|
||||
import MessageList from '@/components/chat/MessageList.vue'
|
||||
import MessageInput from '@/components/chat/MessageInput.vue'
|
||||
import ContactView from '@/views/contact/ContactView.vue'
|
||||
@@ -227,6 +256,7 @@ const authStore = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const contextMenuStore = useContextMenuStore()
|
||||
const { showContextMenu } = useContextMenu()
|
||||
const webrtc = useWebRTC(authStore.user?.id || '')
|
||||
|
||||
const currentTab = ref<'chat' | 'contact'>('chat')
|
||||
const searchQuery = ref('')
|
||||
@@ -235,6 +265,15 @@ const chatVisible = ref(false)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const showProfileModal = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
|
||||
const fileModal = ref({
|
||||
show: false,
|
||||
type: 0,
|
||||
preview: '',
|
||||
name: '',
|
||||
size: 0,
|
||||
file: null as File | null,
|
||||
})
|
||||
|
||||
const filteredContacts = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -305,18 +344,82 @@ function handleSendText() {
|
||||
}
|
||||
|
||||
function handleFileSelect(type: number, file: File) {
|
||||
// TODO: 实现文件上传和发送
|
||||
console.log('File selected:', type, file)
|
||||
fileModal.value = {
|
||||
show: true,
|
||||
type,
|
||||
preview: '',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
file,
|
||||
}
|
||||
|
||||
// 如果是图片,生成预览
|
||||
if (type === 1) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
fileModal.value.preview = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSendFile() {
|
||||
if (!fileModal.value.file || !chatStore.currentTarget) {
|
||||
fileModal.value.show = false
|
||||
return
|
||||
}
|
||||
|
||||
const file = fileModal.value.file
|
||||
const type = fileModal.value.type
|
||||
|
||||
try {
|
||||
// 读取文件为base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
const base64 = e.target?.result as string
|
||||
const extra = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url: base64,
|
||||
}
|
||||
|
||||
await sendMessage(type, base64, extra, 0)
|
||||
fileModal.value.show = false
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} catch (error) {
|
||||
console.error('Failed to send file:', error)
|
||||
alert('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecordStart() {
|
||||
isRecording.value = true
|
||||
}
|
||||
|
||||
function handleRecordStop(blob: Blob, duration: number) {
|
||||
async function handleRecordStop(blob: Blob, duration: number) {
|
||||
isRecording.value = false
|
||||
// TODO: 实现语音发送
|
||||
console.log('Record stopped:', blob, duration)
|
||||
|
||||
if (!chatStore.currentTarget) return
|
||||
|
||||
try {
|
||||
// 读取音频为base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
const base64 = e.target?.result as string
|
||||
const durationStr = `00:${duration < 10 ? '0' + duration : duration}`
|
||||
const extra = {
|
||||
duration: durationStr,
|
||||
url: base64,
|
||||
}
|
||||
|
||||
await sendMessage(2, base64, extra, duration)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
} catch (error) {
|
||||
console.error('Failed to send audio:', error)
|
||||
alert('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecordCancel() {
|
||||
@@ -377,9 +480,10 @@ function getMsgSummary(msg: ChatMessage): string {
|
||||
return types[msg.message_type] || msg.content
|
||||
}
|
||||
|
||||
function startCall(type: 'audio' | 'video') {
|
||||
// TODO: 实现WebRTC通话
|
||||
console.log('Start call:', type)
|
||||
async function startCall(type: 'audio' | 'video') {
|
||||
if (!chatStore.currentTarget) return
|
||||
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
|
||||
await webrtc.startCall(type, receiverUserId)
|
||||
}
|
||||
|
||||
function showContactMenu(event: MouseEvent, contact: Contact) {
|
||||
@@ -432,6 +536,12 @@ function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
|
||||
|
||||
// WebSocket消息处理
|
||||
function handleWebSocketMessage(message: ChatMessage) {
|
||||
// 处理信令消息
|
||||
if (message.message_type === 6) {
|
||||
webrtc.handleSignaling(message)
|
||||
return
|
||||
}
|
||||
|
||||
const roomId = message.room_id
|
||||
message.isSelf = message.sender_user_id === authStore.user!.id
|
||||
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
|
||||
@@ -484,6 +594,15 @@ onMounted(async () => {
|
||||
if (authStore.user) {
|
||||
await wsManager.connect(authStore.user.id)
|
||||
wsManager.onMessage(handleWebSocketMessage)
|
||||
|
||||
// 连接WebRTC视频元素
|
||||
nextTick(() => {
|
||||
const callWindow = callWindowRef.value
|
||||
if (callWindow) {
|
||||
webrtc.localVideo = callWindow.localVideo
|
||||
webrtc.remoteVideo = callWindow.remoteVideo
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载联系人
|
||||
|
||||
Reference in New Issue
Block a user