一部分功能

This commit is contained in:
2025-12-03 13:25:02 +08:00
parent 320673dd34
commit be7bd1279a
7 changed files with 698 additions and 21 deletions

View File

@@ -29,6 +29,11 @@ body {
background: transparent;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #475569 transparent;
}
/* 气泡尖角 */
.bubble-self {
border-top-right-radius: 2px;

View 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>

View File

@@ -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)"
/>

View File

@@ -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>

View 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>

View 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,
}
}

View File

@@ -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
}
})
}
// 加载联系人