音视频通话功能完成(P2P)

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:33:48 +08:00
parent be7bd1279a
commit 9db9b65ea3
15 changed files with 1544 additions and 704 deletions

View File

@@ -1,8 +1,9 @@
<template>
<router-view />
<Toast />
</template>
<script setup lang="ts">
// App root component
import Toast from '@/components/common/Toast.vue'
</script>

View File

@@ -48,20 +48,46 @@ class WebSocketManager {
this.ws.onmessage = (event) => {
try {
const payload: WebSocketMessage = JSON.parse(event.data)
// 处理可能的多行JSON或消息分割
const data = event.data.toString().trim()
// 接收clientId
if (payload.clientId) {
this.clientId = payload.clientId
console.log('Received clientId:', this.clientId)
}
// 尝试按行分割处理多个JSON对象
const lines = data.split('\n').filter(line => line.trim())
// 处理接收消息
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data)
for (const line of lines) {
try {
const payload: WebSocketMessage = JSON.parse(line)
// 接收clientId
if (payload.clientId) {
this.clientId = payload.clientId
console.log('Received clientId:', this.clientId)
}
// 处理接收消息
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data)
}
} catch (lineError) {
// 如果单行解析失败,尝试解析整个数据
if (lines.length === 1) {
const payload: WebSocketMessage = JSON.parse(data)
if (payload.clientId) {
this.clientId = payload.clientId
console.log('Received clientId:', this.clientId)
}
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data)
}
} else {
console.warn('Failed to parse WebSocket message line:', line, lineError)
}
}
}
} catch (error) {
console.error('WebSocket message parse error:', error)
console.error('WebSocket message parse error:', error, 'Data:', event.data)
}
}
@@ -140,13 +166,15 @@ class WebSocketManager {
* 处理接收到的消息
*/
private handleMessage(message: ChatMessage) {
// 信令消息message_type = 6
// 信令消息message_type = 6- 路由到signalHandlers不路由到messageHandlers
if (message.message_type === 6) {
console.log('[WebSocket] Routing signaling message:', message.call_status, message.call_id)
this.signalHandlers.forEach(handler => handler(message))
} else {
// 普通消息
this.messageHandlers.forEach(handler => handler(message))
return // 重要:信令消息不进入普通消息处理流程
}
// 普通消息 - 路由到messageHandlers
this.messageHandlers.forEach(handler => handler(message))
}
/**

View File

@@ -1,8 +1,8 @@
<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="[
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 font-sans"
:class="[
call.minimized
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
: isMobile
@@ -10,120 +10,134 @@
: '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 }"
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized && !isMobile && call.status === 'connected' }"
>
<div class="text-white text-sm font-bold drop-shadow-md px-2" v-if="!call.minimized">
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
{{ call.type === 'video' ? '视频通话' : '语音通话' }}
<span>{{ call.type === 'video' ? '视频通话' : '语音通话' }}</span>
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30">
{{ formatDuration(call.duration) }}
</span>
</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"
<!-- 最小化按钮 -->
<button
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition ml-auto"
@click="emit('toggle-minimize')"
title="最小化/还原"
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
<!-- 内容区域 -->
<!-- 内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- 纯语音 UI 连接中状态显示大头像 -->
<!-- 1. 语音/等待状态 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"
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 class="relative">
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
</div>
<!-- 波纹动画 -->
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
</div>
<div class="text-2xl font-bold text-gray-100 tracking-wide">
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
{{ 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>
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm">
<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 模式且连接成功显示) -->
<!-- 2. 远程视频流 (全屏) -->
<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',
}"
ref="remoteVideoRef"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-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>
<!-- 3. 本地视频 (画中画) -->
<!-- 优化移动端使用竖屏比例 (w-24 h-32 -> 3:4)PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
<div
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
:class="[
isMobile
? 'top-16 right-4 w-28 h-40 rounded-xl' /* 移动端:右上角,竖长方形 */
: 'bottom-28 right-8 w-56 h-40 rounded-lg' /* PC端右下角横长方形 */
]"
>
<video
ref="localVideoRef"
class="w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<!-- 摄像头关闭提示 -->
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
<i class="fas fa-video-slash"></i>
</div>
</div>
</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"
v-if="!call.minimized"
class="h-24 bg-gray-900/80 backdrop-blur-md border-t border-white/10 flex items-center justify-center gap-6 md:gap-10 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="挂断"
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 active:scale-95 shadow-lg shadow-red-500/30"
@click="emit('end-call')"
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="接听"
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 active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
@click="emit('accept-call')"
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="静音"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-mute')"
: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="关摄像头"
v-if="call.type === 'video'"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-camera')"
:class="{ 'bg-white !text-black': call.camOff }"
title="关摄像头"
>
<i class="fas" :class="call.camOff ? 'fa-video-slash' : 'fa-video'"></i>
</button>
@@ -133,7 +147,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, onMounted } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -141,16 +155,20 @@ interface Props {
active: boolean
minimized: boolean
type: 'audio' | 'video'
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
status: string
statusText: string
muted: boolean
camOff: boolean
duration: number
}
target: Contact | null
isMobile: boolean
// 接收 MediaStream 对象,而不是 DOM 元素
localStream: MediaStream | null
remoteStream: MediaStream | null
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
'end-call': []
@@ -160,28 +178,36 @@ const emit = defineEmits<{
'toggle-minimize': []
}>()
const remoteVideo = ref<HTMLVideoElement>()
const localVideo = ref<HTMLVideoElement>()
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
function endCall() {
emit('end-call')
// 格式化时间辅助函数
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
function acceptCall() {
emit('accept-call')
}
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) {
localVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleMute() {
emit('toggle-mute')
}
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) {
remoteVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleCamera() {
emit('toggle-camera')
}
defineExpose({
remoteVideo,
localVideo,
// 确保在组件挂载或 Video 元素出现时也能绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) {
localVideoRef.value.srcObject = props.localStream
}
if (remoteVideoRef.value && props.remoteStream) {
remoteVideoRef.value.srcObject = props.remoteStream
}
})
</script>

View File

@@ -21,7 +21,7 @@
<div
class="relative flex items-center group cursor-pointer select-none"
@mousedown="handleRecordStart"
@touchstart.prevent="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
@@ -69,6 +69,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
interface Props {
modelValue: string
@@ -144,7 +147,7 @@ async function handleRecordStart() {
stream.getTracks().forEach((t) => t.stop())
if (duration < 1000) {
alert('说话时间太短')
toastStore.warning('说话时间太短')
return
}
@@ -156,9 +159,9 @@ async function handleRecordStart() {
mediaRecorder.start()
recordStartTime = Date.now()
emit('record-start')
} catch (error) {
} catch (error: any) {
console.error('Failed to start recording:', error)
alert('无法访问麦克风')
toastStore.error(error.message || '无法访问麦克风')
}
}

View File

@@ -1,6 +1,6 @@
<template>
<img
:src="imageUrl"
:src="`http://127.0.0.1:12080${imageUrl}`"
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
@click="previewImage"
/>
@@ -22,7 +22,7 @@ const imageUrl = computed(() => {
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`<img src="${imageUrl.value}" style="max-width:100%">`)
w.document.write(`<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%">`)
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div
v-if="show"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="$emit('cancel')"
>
<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>{{ title }}</span>
<i class="fas fa-times cursor-pointer hover:text-white text-gray-500" @click="$emit('cancel')"></i>
</div>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center text-xl"
:class="{
'bg-danger/20 text-danger': type === 'danger',
'bg-warning/20 text-warning': type === 'warning',
'bg-primary/20 text-primary': type === 'info',
}"
>
<i
:class="{
'fas fa-exclamation-triangle': type === 'danger',
'fas fa-question-circle': type === 'warning',
'fas fa-info-circle': type === 'info',
}"
></i>
</div>
<div class="flex-1">
<p class="text-sm text-gray-300 leading-relaxed">{{ message }}</p>
<p v-if="description" class="text-xs text-gray-400 mt-2">{{ description }}</p>
</div>
</div>
</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('cancel')"
>
{{ cancelText }}
</button>
<button
class="flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700"
:class="{
'text-danger': type === 'danger',
'text-primary': type !== 'danger',
}"
@click="$emit('confirm')"
>
{{ confirmText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
show: boolean
title?: string
message: string
description?: string
type?: 'danger' | 'warning' | 'info'
confirmText?: string
cancelText?: string
}
withDefaults(defineProps<Props>(), {
title: '确认操作',
type: 'info',
confirmText: '确认',
cancelText: '取消',
})
defineEmits<{
confirm: []
cancel: []
}>()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<TransitionGroup
name="toast"
tag="div"
class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none"
>
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto min-w-[300px] max-w-[500px] bg-panel border border-gray-700 rounded-lg shadow-2xl p-4 flex items-start gap-3 animate-slide-in"
:class="{
'border-success': toast.type === 'success',
'border-danger': toast.type === 'error',
'border-yellow-500': toast.type === 'warning',
'border-primary': toast.type === 'info',
}"
>
<!-- 图标 -->
<div
class="flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="{
'bg-success/20 text-success': toast.type === 'success',
'bg-danger/20 text-danger': toast.type === 'error',
'bg-yellow-500/20 text-yellow-500': toast.type === 'warning',
'bg-primary/20 text-primary': toast.type === 'info',
}"
>
<i
:class="{
'fas fa-check-circle': toast.type === 'success',
'fas fa-exclamation-circle': toast.type === 'error',
'fas fa-exclamation-triangle': toast.type === 'warning',
'fas fa-info-circle': toast.type === 'info',
}"
></i>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-white">{{ toast.message }}</div>
<div v-if="toast.description" class="text-xs text-gray-400 mt-1">
{{ toast.description }}
</div>
</div>
<!-- 关闭按钮 -->
<button
class="flex-shrink-0 text-gray-400 hover:text-white transition"
@click="removeToast(toast.id)"
>
<i class="fas fa-times text-sm"></i>
</button>
</div>
</TransitionGroup>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
const toasts = computed(() => toastStore.toasts)
function removeToast(id: string) {
toastStore.removeToast(id)
}
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in {
animation: slide-in 0.3s ease;
}
</style>

View File

@@ -1,7 +1,8 @@
import { ref, reactive } from 'vue'
import { reactive, shallowRef } from 'vue'
import * as systemApi from '@/api/modules/system'
import * as messageApi from '@/api/modules/message'
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import type { ChatMessage } from '@/types/api'
import type { CallStatus } from '@/types/message'
@@ -14,9 +15,13 @@ export interface CallState {
id: string | null
muted: boolean
camOff: boolean
duration: number
startTime: number | null
}
export function useWebRTC(userId: string) {
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
const toastStore = useToastStore()
const call = reactive<CallState>({
active: false,
minimized: false,
@@ -26,91 +31,134 @@ export function useWebRTC(userId: string) {
id: null,
muted: false,
camOff: false,
duration: 0,
startTime: null,
})
const localVideo = ref<HTMLVideoElement | null>(null)
const remoteVideo = ref<HTMLVideoElement | null>(null)
// 使用 shallowRef 存储 MediaStream避免 Vue 进行深层代理导致的性能问题
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
let pc: RTCPeerConnection | null = null
let localStream: MediaStream | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
/**
* 初始化媒体流
*/
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
// 停止之前的流
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
}
const constraints: MediaStreamConstraints = {
video: videoEnabled ? {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user', // 优先使用前置摄像头
} : false,
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
localStream.value = stream // 赋值给响应式对象,视图会自动更新
console.log('[WebRTC] Local media stream obtained')
} catch (error) {
console.error('Failed to get media:', error)
throw new Error('无法获取设备权限或设备不支持')
throw new Error('无法获取摄像头或麦克风权限')
}
}
/**
* 获取ICE服务器配置
* 获取并增强 ICE Servers 配置
*/
async function getIceServers() {
const servers: RTCIceServer[] = []
try {
const servers = await systemApi.getIceServers(userId)
if (servers && servers.length > 0) {
return servers.map((s) => ({
const apiServers = await systemApi.getIceServers(userId)
if (apiServers && apiServers.length > 0) {
servers.push(...apiServers.map((s: any) => ({
urls: s.urls,
username: s.username,
credential: s.credential,
}))
})))
}
} catch (error) {
console.error('Failed to fetch ICE servers:', error)
console.warn('Failed to fetch ICE servers from API')
}
// Fallback: 使用默认STUN
return [{ urls: 'stun:stun.l.google.com:19302' }]
// 添加公共 STUN 服务器作为兜底
servers.push({ urls: 'stun:stun.l.google.com:19302' })
servers.push({ urls: 'stun:global.stun.twilio.com:3478' })
return servers
}
/**
* 创建PeerConnection
* 创建 PeerConnection
*/
async function createPC(): Promise<void> {
const iceServers = await getIceServers()
iceServers.push({ urls: 'stun:stun.l.google.com:19302' })
pc = new RTCPeerConnection({ iceServers })
// 关闭旧连接
if (pc) pc.close()
if (localStream) {
localStream.getTracks().forEach((track) => {
pc!.addTrack(track, localStream!)
pc = new RTCPeerConnection({
iceServers,
iceCandidatePoolSize: 10
})
// 添加本地轨道
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
pc!.addTrack(track, localStream.value!)
})
} else {
console.warn('[WebRTC] No local stream to add to PC')
}
// 监听远程轨道
pc.ontrack = (e) => {
if (remoteVideo.value) {
remoteVideo.value.srcObject = e.streams[0]
console.log('[WebRTC] Received remote track', e.streams)
if (e.streams && e.streams[0]) {
remoteStream.value = e.streams[0] // 赋值给响应式对象
}
}
// 监听 ICE 候选
pc.onicecandidate = (e) => {
if (e.candidate && call.id) {
sendSignal('candidate', e.candidate)
}
}
// 监听连接状态
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc?.connectionState)
if (pc?.connectionState === 'disconnected' || pc?.connectionState === 'failed') {
toastStore.error('通话连接中断')
}
}
}
let currentReceiverUserId = ''
/**
* 发送信令
* 发送信令 (统一封装)
*/
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
if (!call.id) return
const targetUserId = receiverUserId || currentReceiverUserId
if (!targetUserId) return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: receiverUserId || currentReceiverUserId,
receiver_user_id: targetUserId,
room_id: '',
message_type: 6,
message_type: 6, // Signaling Message
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
@@ -120,9 +168,33 @@ export function useWebRTC(userId: string) {
messageApi.sendMessage(payload).catch(console.error)
}
/**
* 开始通话
*/
// --- 计时器逻辑 ---
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
call.duration = 0
durationTimer = window.setInterval(() => {
if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000)
}, 1000)
}
function stopCallTimer() {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
call.startTime = null
call.duration = 0
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
// --- 通话控制 ---
async function startCall(type: 'audio' | 'video', receiverUserId: string) {
currentReceiverUserId = receiverUserId
call.type = type
@@ -130,160 +202,160 @@ export function useWebRTC(userId: string) {
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '等待对方接听...'
call.statusText = '正在呼叫...'
call.duration = 0
remoteStream.value = null // 重置远程流
try {
await initMedia(type === 'video')
await createPC()
sendSignal('invite', undefined, receiverUserId)
} catch (error: any) {
alert(error.message || '无法启动通话')
toastStore.error(error.message || '无法启动通话')
endCall()
}
}
/**
* 接听通话
*/
async function acceptCall(senderUserId?: string) {
if (senderUserId) {
currentReceiverUserId = senderUserId
}
if (senderUserId) currentReceiverUserId = senderUserId
call.status = 'connected'
call.statusText = '连接...'
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)
// 发送 accepted等待发起方创建 Offer
sendSignal('accepted', undefined, currentReceiverUserId)
} 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
stopCallTimer()
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
// 停止本地流
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
remoteStream.value = 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
const content = message.content ? JSON.parse(message.content) : {}
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
let callType = 'video'
if (extra && extra.type) {
callType = extra.type
}
// 更新通话类型
if (extra.type) call.type = 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.id = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `对方邀请进行${callType === 'video' ? '视频' : '语音'}通话...`
call.statusText = `邀请进行${call.type === 'video' ? '视频' : '语音'}通话`
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
// 对方已接受,作为发起方,创建 Offer
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer, message.sender_user_id)
sendSignal('offer', offer)
} else if (signal === 'offer') {
const desc = JSON.parse(message.content)
// 接收 Offer创建 Answer
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(desc)
await pc!.setRemoteDescription(content)
// 处理缓冲的 Candidates
processPendingCandidates()
const answer = await pc!.createAnswer()
await pc!.setLocalDescription(answer)
sendSignal('answer', answer, message.sender_user_id)
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
} else if (signal === 'answer') {
await pc!.setRemoteDescription(JSON.parse(message.content))
// 接收 Answer
await pc!.setRemoteDescription(content)
processPendingCandidates()
} else if (signal === 'candidate') {
await pc!.addIceCandidate(JSON.parse(message.content))
if (pc && pc.remoteDescription) {
await pc.addIceCandidate(content)
} else {
pendingCandidates.push(content)
}
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
closeCall()
if (signal === 'answered_elsewhere') {
alert('已在其他设备接听')
if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听')
}
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const candidate = pendingCandidates.shift()
if (candidate && pc) {
await pc.addIceCandidate(candidate)
}
}
}
/**
* 切换静音
*/
// --- 媒体控制 ---
function toggleMute() {
call.muted = !call.muted
if (localStream) {
localStream.getAudioTracks()[0].enabled = !call.muted
if (localStream.value) {
localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
}
/**
* 切换摄像头
*/
function toggleCamera() {
call.camOff = !call.camOff
if (localStream) {
const videoTrack = localStream.getVideoTracks()[0]
if (videoTrack) {
videoTrack.enabled = !call.camOff
}
if (localStream.value) {
localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
}
}
return {
call,
localVideo,
remoteVideo,
localStream, // 暴露流
remoteStream, // 暴露流
startCall,
acceptCall,
endCall,
handleSignaling,
toggleMute,
toggleCamera,
formatDuration,
}
}

View File

@@ -23,6 +23,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/contact/ContactView.vue'),
meta: { requiresAuth: true },
},
{
path: '/contact/circle',
name: 'ContactCircle',
component: () => import('@/views/contact/ContactCircle.vue'),
meta: { requiresAuth: true },
},
{
path: '/contact/:id',
name: 'ContactDetail',
component: () => import('@/views/contact/ContactDetail.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({

93
src/stores/toast.ts Normal file
View File

@@ -0,0 +1,93 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: string
message: string
description?: string
type: ToastType
duration?: number
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
/**
* 显示Toast
*/
function showToast(
message: string,
type: ToastType = 'info',
duration: number = 3000,
description?: string
) {
const id = `toast-${Date.now()}-${Math.random()}`
const toast: Toast = {
id,
message,
description,
type,
duration,
}
toasts.value.push(toast)
// 自动移除
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
return id
}
/**
* 移除Toast
*/
function removeToast(id: string) {
const index = toasts.value.findIndex((t) => t.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
/**
* 清空所有Toast
*/
function clearAll() {
toasts.value = []
}
// 便捷方法
function success(message: string, description?: string, duration?: number) {
return showToast(message, 'success', duration, description)
}
function error(message: string, description?: string, duration?: number) {
return showToast(message, 'error', duration || 5000, description)
}
function warning(message: string, description?: string, duration?: number) {
return showToast(message, 'warning', duration, description)
}
function info(message: string, description?: string, duration?: number) {
return showToast(message, 'info', duration, description)
}
return {
toasts,
showToast,
removeToast,
clearAll,
success,
error,
warning,
info,
}
})

View File

@@ -1,92 +1,43 @@
<template>
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 -->
<!-- 左侧功能导航 (保持不变) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0">
<Avatar
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-white transition shadow-lg"
@click="showProfileModal = true"
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-white transition shadow-lg"
@click="showProfileModal = true"
/>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition relative"
:class="{ 'text-primary': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<div class="text-gray-400 hover:text-primary cursor-pointer transition relative" :class="{ 'text-primary': currentTab === 'chat' }" @click="currentTab = 'chat'">
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"></div>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition"
:class="{ 'text-primary': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<div class="text-gray-400 hover:text-primary cursor-pointer transition" :class="{ 'text-primary': currentTab === 'contact' }" @click="currentTab = 'contact'">
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition"
@click="handleLogout"
title="退出登录"
>
<div class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition" @click="handleLogout" title="退出登录">
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
<!-- 中间侧边栏 (联系人列表) -->
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<!-- 中间侧边栏 (保持不变) -->
<div v-show="currentTab === 'chat' && (!isMobile || !chatVisible)" class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0">
<div class="p-4 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索联系人..."
/>
<input v-model="searchQuery" type="text" class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500" placeholder="搜索联系人..." />
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent"
:class="{
'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id,
'bg-black/20': contact.is_top,
}"
@click="selectChat(contact)"
@contextmenu.stop="showContactMenu($event, contact)"
>
<div v-for="contact in filteredContacts" :key="contact.id" class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent" :class="{ 'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id, 'bg-black/20': contact.is_top }" @click="selectChat(contact)" @contextmenu.stop="showContactMenu($event, contact)">
<div class="relative shrink-0">
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
:color="contact.color"
size="contact"
rounded="xl"
class="transition transform group-hover:scale-105"
/>
<div
v-if="(contact.unread || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm"
>
{{ contact.unread }}
</div>
<Avatar :name="contact.user?.name || contact.remark_name" :avatar="contact.user?.avatar || contact.remark_name?.charAt(0)" :color="contact.color" size="contact" rounded="xl" class="transition transform group-hover:scale-105" />
<div v-if="(contact.unread || 0) > 0" class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm">{{ contact.unread }}</div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm">
{{ contact.remark_name || contact.user?.name || '未知' }}
</span>
<span class="font-semibold truncate text-gray-200 text-sm">{{ contact.remark_name || contact.user?.name || '未知' }}</span>
<span class="text-xs text-gray-500">{{ formatTime(contact.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4">
@@ -98,135 +49,54 @@
</div>
</div>
<!-- 右侧主聊天区 -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<!-- 右侧主聊天区 (保持不变省略部分代码以聚焦核心更改) -->
<div v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))" class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden">
<!-- 头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-4 bg-panel/80 backdrop-blur shrink-0 z-20 shadow-sm">
<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 || chatStore.currentTarget.remark_name"
:avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)"
:color="chatStore.currentTarget.color"
size="sm"
rounded="full"
/>
<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">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
</h3>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }} <span class="w-2 h-2 bg-success rounded-full animate-pulse"></span></h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="语音通话"
@click="startCall('audio')"
>
<i class="fas fa-phone"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="视频通话"
@click="startCall('video')"
>
<i class="fas fa-video"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
@click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"
>
<i class="fas fa-ellipsis-v"></i>
</button>
<!-- 绑定通话事件 -->
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click="startCall('video')"><i class="fas fa-video"></i></button>
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
</div>
</div>
<!-- 消息列表 -->
<MessageList
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
/>
<!-- 输入区 -->
<MessageInput
v-model="inputText"
:is-recording="isRecording"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
/>
<!-- 消息列表与输入框 (保持原样) -->
<MessageList :messages="currentMessages" :current-user="authStore.user!" :target="chatStore.currentTarget" />
<MessageInput v-model="inputText" :is-recording="isRecording" @send="handleSendText" @file="handleFileSelect" @record-start="handleRecordStart" @record-stop="handleRecordStop" @record-cancel="handleRecordCancel" />
</div>
<!-- 空状态 -->
<div
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-24 h-24 bg-panel rounded-full flex items-center justify-center mb-6 shadow-xl border border-gray-800">
<i class="fas fa-comments text-4xl text-primary opacity-80"></i>
</div>
<p class="text-xl font-medium text-gray-300">NL-IM</p>
<p class="text-sm mt-2 opacity-60">安全 · 极速 · 沉浸式体验</p>
<div v-else-if="currentTab === 'chat'" class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex">
<!-- ... -->
</div>
<!-- 联系人页面 -->
<ContactView v-if="currentTab === 'contact'" />
<!-- 右键菜单 -->
<ContextMenu />
<!-- 通话窗口 -->
<!-- 关键修复 Stream 传递给 CallWindow -->
<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"
ref="callWindowRef"
:call="webrtc.call"
:target="chatStore.currentTarget"
:is-mobile="isMobile"
:local-stream="webrtc.localStream.value"
:remote-stream="webrtc.remoteStream.value"
@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"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showProfileModal = false"
>
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<Avatar
: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>
<p class="text-sm text-gray-400 mt-1">{{ authStore.user.desc || '暂无签名' }}</p>
</div>
</div>
</div>
<!-- 其他模态框 (保持不变) -->
<ContactView v-if="currentTab === 'contact'" />
<ContextMenu />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
</div>
</template>
@@ -235,11 +105,12 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useContextMenu } from '@/composables/useContextMenu'
import { useContextMenuStore } from '@/stores/contextMenu'
import { wsManager } from '@/api/websocket'
import * as contactApi from '@/api/modules/contact'
import * as messageApi from '@/api/modules/message'
import * as attachmentApi from '@/api/modules/attachment'
import { formatTime } from '@/utils/format'
import type { Contact, ChatMessage } from '@/types/api'
import { useWebRTC } from '@/composables/useWebRTC'
@@ -254,10 +125,21 @@ import ContactView from '@/views/contact/ContactView.vue'
const router = useRouter()
const authStore = useAuthStore()
const chatStore = useChatStore()
const contextMenuStore = useContextMenuStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
const webrtc = useWebRTC(authStore.user?.id || '')
// 处理来电回调
function handleIncomingCall(senderUserId: string) {
const contact = chatStore.contacts.find(c => c.user_id === senderUserId || c.id === senderUserId)
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
selectChat(contact)
}
}
// 初始化 WebRTC
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
// 状态定义
const currentTab = ref<'chat' | 'contact'>('chat')
const searchQuery = ref('')
const inputText = ref('')
@@ -265,42 +147,36 @@ 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 callWindowRef = ref(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()
return chatStore.contacts.filter(contact => {
const name = (contact.remark_name || contact.user?.name || '').toLowerCase()
return name.includes(query)
})
return chatStore.contacts.filter(contact => (contact.remark_name || contact.user?.name || '').toLowerCase().includes(query))
})
const currentMessages = computed(() => {
if (!chatStore.currentTarget) return []
const roomId = getRoomId(chatStore.currentTarget)
return chatStore.getRoomMessages(roomId)
return chatStore.getRoomMessages(getRoomId(chatStore.currentTarget))
})
// 辅助函数
function getRoomId(contact: Contact): string {
const userIds = [authStore.user!.id, contact.user_id || contact.id].sort()
return userIds.join('_')
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }
return types[msg.message_type] || msg.content
}
// 核心业务逻辑
async function selectChat(contact: Contact) {
chatStore.setCurrentTarget(contact)
chatVisible.value = true
const roomId = getRoomId(contact)
// 加载历史消息
if (chatStore.getRoomMessages(roomId).length === 0) {
try {
const response = await messageApi.getMessages(roomId, 1, 50)
@@ -310,312 +186,85 @@ async function selectChat(contact: Contact) {
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (error) {
console.error('Failed to load messages:', error)
}
} catch (e) { console.error(e) }
}
// 滚动到底部
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
setTimeout(() => { const el = document.getElementById('msgListContainer'); if (el) el.scrollTop = el.scrollHeight }, 100)
}
function backToList() {
if (isMobile.value) {
chatVisible.value = false
}
}
function handleLogout() {
authStore.logout()
wsManager.disconnect()
router.push('/login')
}
function handleSendText() {
if (!inputText.value.trim() || !chatStore.currentTarget) return
sendMessage(0, inputText.value)
inputText.value = ''
}
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
// 消息发送相关
function handleSendText() { if (inputText.value.trim()) { sendMessage(0, inputText.value); inputText.value = '' } }
function handleFileSelect(type: number, file: 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)
}
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const r = new FileReader(); r.onload = e => fileModal.value.preview = e.target?.result as string; r.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
if (!fileModal.value.file) return
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
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, fileModal.value.type === 1 ? 'image' : 'file')
await sendMessage(fileModal.value.type, attachment.file_url, { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url }, 0)
fileModal.value.show = false
} catch (e) { toastStore.error('发送失败') }
}
// 录音相关
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) {
isRecording.value = false
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() {
isRecording.value = false
const file = new File([blob], `audio.webm`, { type: 'audio/webm' })
const attachment = await attachmentApi.uploadAttachment(file, 'file')
await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration)
} catch (e) { toastStore.error('发送失败') }
}
function handleRecordCancel() { isRecording.value = false }
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
const roomId = getRoomId(chatStore.currentTarget)
const clientId = wsManager.getClientId()
const payload = {
sender_client_id: clientId || '',
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
room_id: roomId,
message_type: type,
content,
duration,
extra: JSON.stringify(extra),
}
try {
await messageApi.sendMessage(payload)
// 本地添加消息
const message: ChatMessage = {
id: Date.now(),
room_id: roomId,
sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
message_type: type,
content,
duration,
extra,
created_at: new Date().toISOString(),
isSelf: true,
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(
chatStore.currentTarget.id,
getMsgSummary(message),
Date.now()
)
} catch (error) {
console.error('Failed to send message:', error)
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
}
await messageApi.sendMessage(payload)
const msg = { ...payload, id: Date.now(), created_at: new Date().toISOString(), isSelf: true, extra }
chatStore.addMessage(roomId, msg as any)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(msg as any), Date.now())
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = {
1: '[图片]',
2: '[语音]',
3: '[视频]',
8: '[文件]',
}
return types[msg.message_type] || msg.content
}
// 通话相关
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
await webrtc.startCall(type, receiverUserId)
await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.id)
}
function showContactMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{
label: '置顶会话',
icon: 'fas fa-thumbtack',
action: () => {
// TODO: 实现置顶
},
},
{
label: '标记已读',
icon: 'fas fa-check-double',
action: () => {
contact.unread = 0
},
},
{
label: '删除会话',
icon: 'fas fa-trash',
danger: true,
action: () => {
// TODO: 实现删除会话
},
},
])
}
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{
label: '查看资料',
icon: 'fas fa-user',
action: () => {
// TODO: 显示用户资料
},
},
{
label: '清空记录',
icon: 'fas fa-eraser',
danger: true,
action: () => {
const roomId = getRoomId(contact)
chatStore.clearRoomMessages(roomId)
},
},
])
}
// 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
chatStore.addMessage(roomId, message)
// 更新联系人最后消息
const contact = chatStore.contacts.find(c =>
c.user_id === message.sender_user_id || c.id === message.sender_user_id
)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
if (!message.isSelf) {
chatStore.incrementUnread(contact.id)
}
}
// 如果当前正在查看该聊天,滚动到底部
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) {
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
}
}
// 加载联系人列表
async function loadContacts() {
try {
const contacts = await contactApi.getContacts()
chatStore.setContacts(contacts)
} catch (error) {
console.error('Failed to load contacts:', error)
}
}
// 菜单与上下文
function showContactMenu(e: MouseEvent, c: Contact) { showContextMenu(e, [{ label: '删除', danger: true, action: () => {} }]) }
function showChatOptionsMenu(e: MouseEvent, c: Contact) { showContextMenu(e, [{ label: '清空', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(c)) }]) }
// 生命周期
onMounted(async () => {
// 检查认证
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) {
router.push('/login')
return
}
}
// 连接WebSocket
if (!authStore.isAuthenticated && !(await authStore.checkAuth())) { router.push('/login'); return }
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
}
wsManager.onMessage(msg => {
if (msg.message_type === 6) return // 信令单独处理
const roomId = msg.room_id
msg.isSelf = msg.sender_user_id === authStore.user!.id
msg.extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
chatStore.addMessage(roomId, msg)
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) setTimeout(() => { const el = document.getElementById('msgListContainer'); if(el) el.scrollTop = el.scrollHeight }, 100)
})
// 绑定信令
wsManager.onSignal(webrtc.handleSignaling)
}
// 加载联系人
await loadContacts()
// 响应式处理
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
})
await contactApi.getContacts().then(chatStore.setContacts)
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
})
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
wsManager.disconnect()
})
</script>

View File

@@ -0,0 +1,381 @@
<template>
<div class="flex-1 flex flex-col bg-panel h-full">
<!-- 顶部导航栏 -->
<div class="p-4 border-b border-gray-800 flex justify-between items-center">
<h2 class="text-xl font-bold text-white">好友圈</h2>
<button
class="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm"
@click="showAddGroupModal = true"
>
<i class="fas fa-plus mr-2"></i>新建分组
</button>
</div>
<!-- 分组标签栏 -->
<div class="px-4 py-3 border-b border-gray-800 overflow-x-auto">
<div class="flex gap-2 min-w-max">
<button
v-for="group in groups"
:key="group.id"
class="px-4 py-2 rounded-lg transition whitespace-nowrap"
:class="
currentGroupId === group.id
? 'bg-primary text-white'
: 'bg-input text-gray-300 hover:bg-white/5'
"
@click="currentGroupId = group.id"
>
{{ group.group_name }}
</button>
<button
class="px-4 py-2 rounded-lg transition whitespace-nowrap"
:class="
currentGroupId === null
? 'bg-primary text-white'
: 'bg-input text-gray-300 hover:bg-white/5'
"
@click="currentGroupId = null"
>
全部
</button>
</div>
</div>
<!-- 好友列表 -->
<div class="flex-1 overflow-y-auto p-4">
<div v-if="filteredContacts.length === 0" class="text-center py-12 text-gray-400">
<i class="fas fa-users text-4xl mb-4 opacity-50"></i>
<p>暂无好友</p>
</div>
<div v-else class="space-y-2">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition cursor-pointer group"
@click="handleContactClick(contact)"
@contextmenu.stop="showContactMenu($event, contact)"
>
<div class="relative shrink-0">
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar"
:color="contact.color"
size="md"
rounded="xl"
/>
<div
v-if="contact.user?.is_online"
class="absolute bottom-0 right-0 w-3 h-3 bg-success rounded-full border-2 border-panel"
></div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex items-center justify-between mb-1">
<span class="font-semibold text-white text-sm truncate">
{{ contact.remark_name || contact.user?.name || '未知' }}
</span>
<span v-if="contact.unread && contact.unread > 0" class="text-xs text-red-500 font-bold">
{{ contact.unread }}
</span>
</div>
<div class="text-xs text-gray-400 truncate">
{{ contact.user?.desc || contact.last_msg || '暂无签名' }}
</div>
<div v-if="contact.last_time" class="text-xs text-gray-500 mt-1">
{{ formatTime(contact.last_time) }}
</div>
</div>
<div class="flex items-center gap-2 ml-2 opacity-0 group-hover:opacity-100 transition">
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 text-sm"></i>
<i v-if="contact.is_muted" class="fas fa-bell-slash text-gray-500 text-sm"></i>
</div>
</div>
</div>
</div>
<!-- 新建分组模态框 -->
<div
v-if="showAddGroupModal"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showAddGroupModal = false"
>
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
<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="showAddGroupModal = false"
></i>
</div>
<div class="p-6">
<input
v-model="newGroupName"
type="text"
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入分组名称"
@keyup.enter="handleCreateGroup"
/>
</div>
<div class="flex border-t border-gray-700">
<button
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
@click="showAddGroupModal = false"
>
取消
</button>
<button
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
@click="handleCreateGroup"
>
确认
</button>
</div>
</div>
</div>
<!-- 编辑分组模态框 -->
<div
v-if="showEditGroupModal && editingGroup"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showEditGroupModal = false"
>
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
<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="showEditGroupModal = false"
></i>
</div>
<div class="p-6">
<input
v-model="editingGroupName"
type="text"
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入分组名称"
@keyup.enter="handleUpdateGroup"
/>
</div>
<div class="flex border-t border-gray-700">
<button
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
@click="showEditGroupModal = false"
>
取消
</button>
<button
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
@click="handleUpdateGroup"
>
确认
</button>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<ConfirmModal
:show="showDeleteConfirm"
title="确认删除"
message="确定要删除这个分组吗?分组内的好友不会被删除。"
type="danger"
confirm-text="删除"
@confirm="confirmDeleteGroup"
@cancel="showDeleteConfirm = false"
/>
<!-- 右键菜单 -->
<ContextMenu />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useContactStore } from '@/stores/contact'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useContextMenu } from '@/composables/useContextMenu'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import { formatTime } from '@/utils/format'
import type { Contact, ContactGroup } from '@/types/api'
const router = useRouter()
const contactStore = useContactStore()
const chatStore = useChatStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
const currentGroupId = ref<number | null>(null)
const groups = ref<ContactGroup[]>([])
const showAddGroupModal = ref(false)
const showEditGroupModal = ref(false)
const editingGroup = ref<ContactGroup | null>(null)
const editingGroupName = ref('')
const newGroupName = ref('')
const showDeleteConfirm = ref(false)
const deletingGroupId = ref<number | null>(null)
const filteredContacts = computed(() => {
const contacts = chatStore.contacts
if (currentGroupId.value === null) {
return contacts
}
return contacts.filter((c) => c.group_id === currentGroupId.value)
})
async function loadGroups() {
try {
const groupsList = await contactApi.getGroups()
groups.value = groupsList
} catch (error: any) {
console.error('Failed to load groups:', error)
toastStore.error(error.message || '加载分组失败')
}
}
async function handleCreateGroup() {
if (!newGroupName.value.trim()) {
toastStore.warning('请输入分组名称')
return
}
try {
const group = await contactApi.createGroup({ group_name: newGroupName.value })
groups.value.push(group)
newGroupName.value = ''
showAddGroupModal.value = false
toastStore.success('分组创建成功')
} catch (error: any) {
toastStore.error(error.message || '创建失败')
}
}
function handleEditGroup(group: ContactGroup) {
editingGroup.value = group
editingGroupName.value = group.group_name
showEditGroupModal.value = true
}
async function handleUpdateGroup() {
if (!editingGroup.value || !editingGroupName.value.trim()) {
toastStore.warning('请输入分组名称')
return
}
try {
await contactApi.updateGroup(editingGroup.value.id, {
group_name: editingGroupName.value,
})
const index = groups.value.findIndex((g) => g.id === editingGroup.value!.id)
if (index > -1) {
groups.value[index].group_name = editingGroupName.value
}
showEditGroupModal.value = false
editingGroup.value = null
toastStore.success('分组更新成功')
} catch (error: any) {
toastStore.error(error.message || '更新失败')
}
}
function handleDeleteGroup(group: ContactGroup) {
deletingGroupId.value = group.id
showDeleteConfirm.value = true
}
async function confirmDeleteGroup() {
if (!deletingGroupId.value) return
try {
await contactApi.deleteGroup(deletingGroupId.value)
groups.value = groups.value.filter((g) => g.id !== deletingGroupId.value)
if (currentGroupId.value === deletingGroupId.value) {
currentGroupId.value = null
}
showDeleteConfirm.value = false
deletingGroupId.value = null
toastStore.success('分组删除成功')
} catch (error: any) {
toastStore.error(error.message || '删除失败')
}
}
function handleContactClick(contact: Contact) {
chatStore.setCurrentTarget(contact)
router.push('/chat')
}
function showContactMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{
label: '查看资料',
icon: 'fas fa-user',
action: () => {
router.push(`/contact/${contact.id}`)
},
},
{
label: '修改备注',
icon: 'fas fa-edit',
action: () => {
// TODO: 实现修改备注
toastStore.info('修改备注功能开发中')
},
},
{
label: contact.is_top ? '取消置顶' : '置顶',
icon: 'fas fa-thumbtack',
action: async () => {
try {
await contactApi.updateContact(contact.id, { is_top: !contact.is_top })
contact.is_top = !contact.is_top
toastStore.success(contact.is_top ? '已置顶' : '已取消置顶')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
},
},
{
label: contact.is_muted ? '取消免打扰' : '免打扰',
icon: 'fas fa-bell-slash',
action: async () => {
try {
await contactApi.updateContact(contact.id, { is_muted: !contact.is_muted })
contact.is_muted = !contact.is_muted
toastStore.success(contact.is_muted ? '已开启免打扰' : '已关闭免打扰')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
},
},
{
label: '删除好友',
icon: 'fas fa-trash',
danger: true,
action: async () => {
try {
await contactApi.deleteContact(contact.id)
chatStore.contacts = chatStore.contacts.filter((c) => c.id !== contact.id)
if (chatStore.currentTarget?.id === contact.id) {
chatStore.setCurrentTarget(null)
}
toastStore.success('好友已删除')
} catch (error: any) {
toastStore.error(error.message || '删除失败')
}
},
},
])
}
onMounted(async () => {
await loadGroups()
})
</script>

View File

@@ -0,0 +1,383 @@
<template>
<div class="flex-1 flex flex-col bg-panel h-full">
<!-- 顶部导航栏 -->
<div class="p-4 border-b border-gray-800 flex items-center gap-3">
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition flex items-center justify-center"
@click="router.back()"
>
<i class="fas fa-arrow-left"></i>
</button>
<h2 class="text-xl font-bold text-white">好友资料</h2>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="text-gray-400">加载中...</div>
</div>
<div v-else-if="contact" class="flex-1 overflow-y-auto">
<!-- 用户信息卡片 -->
<div class="p-6 text-center border-b border-gray-800">
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar"
:color="contact.color"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl mb-4"
/>
<h3 class="text-xl font-bold text-white mb-1">
{{ contact.remark_name || contact.user?.name || '未知' }}
</h3>
<p class="text-sm text-gray-400 mb-2">{{ contact.user?.desc || '暂无签名' }}</p>
<div class="flex items-center justify-center gap-2 text-xs text-gray-500">
<span v-if="contact.user?.is_online" class="flex items-center gap-1">
<span class="w-2 h-2 bg-success rounded-full"></span>
在线
</span>
<span v-else class="flex items-center gap-1">
<span class="w-2 h-2 bg-gray-500 rounded-full"></span>
离线
</span>
</div>
</div>
<!-- 详细信息 -->
<div class="p-4 space-y-3">
<div class="flex items-center justify-between p-3 bg-input rounded-lg">
<span class="text-sm text-gray-400">备注名称</span>
<span class="text-sm text-white">{{ contact.remark_name || '未设置' }}</span>
</div>
<div v-if="contact.user?.phone" class="flex items-center justify-between p-3 bg-input rounded-lg">
<span class="text-sm text-gray-400">电话号码</span>
<span class="text-sm text-white">{{ contact.user.phone }}</span>
</div>
<div v-if="contact.user?.email" class="flex items-center justify-between p-3 bg-input rounded-lg">
<span class="text-sm text-gray-400">邮箱</span>
<span class="text-sm text-white">{{ contact.user.email }}</span>
</div>
<div v-if="contact.user?.region" class="flex items-center justify-between p-3 bg-input rounded-lg">
<span class="text-sm text-gray-400">地区</span>
<span class="text-sm text-white">{{ contact.user.region }}</span>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="p-4 space-y-3 border-t border-gray-800 mt-auto">
<button
class="w-full py-3 bg-primary hover:bg-indigo-500 text-white rounded-lg transition font-medium"
@click="handleSendMessage"
>
<i class="fas fa-comment mr-2"></i>发送消息
</button>
<div class="flex gap-3">
<button
class="flex-1 py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
@click="handleAudioCall"
>
<i class="fas fa-phone mr-2"></i>语音通话
</button>
<button
class="flex-1 py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
@click="handleVideoCall"
>
<i class="fas fa-video mr-2"></i>视频通话
</button>
</div>
<button
class="w-full py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
@click="showMoreOptions = !showMoreOptions"
>
<i class="fas fa-ellipsis-h mr-2"></i>更多操作
</button>
<!-- 更多操作菜单 -->
<div v-if="showMoreOptions" class="space-y-2 mt-2">
<button
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
@click="showEditRemarkModal = true"
>
<i class="fas fa-edit mr-2"></i>修改备注
</button>
<button
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
@click="showGroupSelectModal = true"
>
<i class="fas fa-folder mr-2"></i>设置分组
</button>
<button
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
@click="handleToggleTop"
>
<i class="fas fa-thumbtack mr-2"></i>{{ contact.is_top ? '取消置顶' : '置顶聊天' }}
</button>
<button
class="w-full py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm text-left"
@click="handleToggleMuted"
>
<i class="fas fa-bell-slash mr-2"></i>{{ contact.is_muted ? '取消免打扰' : '免打扰' }}
</button>
<button
class="w-full py-2 px-4 bg-input hover:bg-red-500/20 text-red-400 rounded-lg transition text-sm text-left"
@click="showDeleteConfirm = true"
>
<i class="fas fa-trash mr-2"></i>删除好友
</button>
</div>
</div>
</div>
<!-- 修改备注模态框 -->
<div
v-if="showEditRemarkModal"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showEditRemarkModal = false"
>
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700">
<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="showEditRemarkModal = false"
></i>
</div>
<div class="p-6">
<input
v-model="remarkName"
type="text"
class="w-full bg-input rounded-lg py-2 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入备注名称"
@keyup.enter="handleUpdateRemark"
/>
</div>
<div class="flex border-t border-gray-700">
<button
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
@click="showEditRemarkModal = false"
>
取消
</button>
<button
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
@click="handleUpdateRemark"
>
确认
</button>
</div>
</div>
</div>
<!-- 分组选择模态框 -->
<div
v-if="showGroupSelectModal"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showGroupSelectModal = false"
>
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700 max-h-[80vh] flex flex-col">
<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="showGroupSelectModal = false"
></i>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div
v-for="group in groups"
:key="group.id"
class="p-3 bg-input rounded-lg hover:bg-white/5 transition cursor-pointer mb-2"
:class="{ 'bg-primary/20': contact?.group_id === group.id }"
@click="handleSelectGroup(group.id)"
>
{{ group.group_name }}
</div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<ConfirmModal
:show="showDeleteConfirm"
title="确认删除"
message="确定要删除这个好友吗?删除后将无法接收对方的消息。"
type="danger"
confirm-text="删除"
@confirm="handleDeleteContact"
@cancel="showDeleteConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useWebRTC } from '@/composables/useWebRTC'
import { useAuthStore } from '@/stores/auth'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import type { Contact, ContactGroup } from '@/types/api'
const route = useRoute()
const router = useRouter()
const chatStore = useChatStore()
const toastStore = useToastStore()
const authStore = useAuthStore()
const webrtc = useWebRTC(authStore.user?.id || '')
const contact = ref<Contact | null>(null)
const groups = ref<ContactGroup[]>([])
const loading = ref(true)
const showMoreOptions = ref(false)
const showEditRemarkModal = ref(false)
const showGroupSelectModal = ref(false)
const showDeleteConfirm = ref(false)
const remarkName = ref('')
async function loadContactDetail() {
const contactId = route.params.id as string
if (!contactId) {
router.back()
return
}
try {
const detail = await contactApi.getContactDetail(contactId)
contact.value = detail
remarkName.value = detail.remark_name || ''
} catch (error: any) {
console.error('Failed to load contact detail:', error)
toastStore.error(error.message || '加载失败')
router.back()
} finally {
loading.value = false
}
}
async function loadGroups() {
try {
const groupsList = await contactApi.getGroups()
groups.value = groupsList
} catch (error: any) {
console.error('Failed to load groups:', error)
}
}
function handleSendMessage() {
if (contact.value) {
chatStore.setCurrentTarget(contact.value)
router.push('/chat')
}
}
function handleAudioCall() {
if (contact.value) {
const receiverUserId = contact.value.user_id || contact.value.id
webrtc.startCall('audio', receiverUserId)
}
}
function handleVideoCall() {
if (contact.value) {
const receiverUserId = contact.value.user_id || contact.value.id
webrtc.startCall('video', receiverUserId)
}
}
async function handleUpdateRemark() {
if (!contact.value || !remarkName.value.trim()) {
toastStore.warning('请输入备注名称')
return
}
try {
await contactApi.updateContact(contact.value.id, { remark_name: remarkName.value })
contact.value.remark_name = remarkName.value
// 更新联系人列表中的备注
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
if (listContact) {
listContact.remark_name = remarkName.value
}
showEditRemarkModal.value = false
toastStore.success('备注更新成功')
} catch (error: any) {
toastStore.error(error.message || '更新失败')
}
}
async function handleSelectGroup(groupId: number) {
if (!contact.value) return
try {
await contactApi.updateContact(contact.value.id, { group_id: groupId })
contact.value.group_id = groupId
// 更新联系人列表中的分组
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
if (listContact) {
listContact.group_id = groupId
}
showGroupSelectModal.value = false
toastStore.success('分组设置成功')
} catch (error: any) {
toastStore.error(error.message || '设置失败')
}
}
async function handleToggleTop() {
if (!contact.value) return
try {
await contactApi.updateContact(contact.value.id, { is_top: !contact.value.is_top })
contact.value.is_top = !contact.value.is_top
// 更新联系人列表
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
if (listContact) {
listContact.is_top = contact.value.is_top
}
toastStore.success(contact.value.is_top ? '已置顶' : '已取消置顶')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
}
async function handleToggleMuted() {
if (!contact.value) return
try {
await contactApi.updateContact(contact.value.id, { is_muted: !contact.value.is_muted })
contact.value.is_muted = !contact.value.is_muted
// 更新联系人列表
const listContact = chatStore.contacts.find((c) => c.id === contact.value!.id)
if (listContact) {
listContact.is_muted = contact.value.is_muted
}
toastStore.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰')
} catch (error: any) {
toastStore.error(error.message || '操作失败')
}
}
async function handleDeleteContact() {
if (!contact.value) return
try {
await contactApi.deleteContact(contact.value.id)
chatStore.contacts = chatStore.contacts.filter((c) => c.id !== contact.value!.id)
if (chatStore.currentTarget?.id === contact.value.id) {
chatStore.setCurrentTarget(null)
}
toastStore.success('好友已删除')
router.back()
} catch (error: any) {
toastStore.error(error.message || '删除失败')
}
}
onMounted(async () => {
await Promise.all([loadContactDetail(), loadGroups()])
})
</script>

View File

@@ -95,11 +95,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useContactStore } from '@/stores/contact'
import { useToastStore } from '@/stores/toast'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
import type { User } from '@/types/api'
const contactStore = useContactStore()
const toastStore = useToastStore()
const searchKeyword = ref('')
const searchResults = ref<User[]>([])
@@ -111,9 +113,12 @@ async function handleSearch() {
try {
const results = await contactApi.searchUsers(searchKeyword.value)
searchResults.value = results
} catch (error) {
if (results.length === 0) {
toastStore.info('未找到相关用户')
}
} catch (error: any) {
console.error('Search failed:', error)
alert('搜索失败')
toastStore.error(error.message || '搜索失败')
}
}
@@ -123,9 +128,9 @@ async function handleAddFriend(user: User) {
to_user_id: user.id,
message: '你好,我想加你为好友',
})
alert('好友申请已发送')
toastStore.success('好友申请已发送')
} catch (error: any) {
alert(error.message || '添加失败')
toastStore.error(error.message || '添加失败')
}
}
@@ -134,9 +139,9 @@ async function handleAcceptRequest(requestId: number) {
await contactApi.acceptFriendRequest(requestId)
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
alert('已接受好友申请')
toastStore.success('已接受好友申请')
} catch (error: any) {
alert(error.message || '操作失败')
toastStore.error(error.message || '操作失败')
}
}
@@ -145,9 +150,9 @@ async function handleRejectRequest(requestId: number) {
await contactApi.rejectFriendRequest(requestId)
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
alert('已拒绝好友申请')
toastStore.success('已拒绝好友申请')
} catch (error: any) {
alert(error.message || '操作失败')
toastStore.error(error.message || '操作失败')
}
}

View File

@@ -137,10 +137,12 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import * as authApi from '@/api/modules/auth'
const router = useRouter()
const authStore = useAuthStore()
const toastStore = useToastStore()
const showRegister = ref(false)
const loading = ref(false)
@@ -163,16 +165,17 @@ const registerForm = ref({
async function handleLogin() {
if (!loginForm.value.account || !loginForm.value.password) {
alert('请填写账号和密码')
toastStore.warning('请填写账号和密码')
return
}
loading.value = true
try {
await authStore.login(loginForm.value)
toastStore.success('登录成功')
router.push('/chat')
} catch (error: any) {
alert(error.message || '登录失败')
toastStore.error(error.message || '登录失败')
} finally {
loading.value = false
}
@@ -180,28 +183,28 @@ async function handleLogin() {
async function handleRegister() {
if (!registerForm.value.email || !registerForm.value.phone || !registerForm.value.password) {
alert('请填写完整信息')
toastStore.warning('请填写完整信息')
return
}
if (registerForm.value.password !== registerForm.value.confirmPassword) {
alert('两次密码不一致')
toastStore.warning('两次密码不一致')
return
}
if (!registerForm.value.agreeTerms) {
alert('请同意服务条款')
toastStore.warning('请同意服务条款')
return
}
loading.value = true
try {
await authStore.register(registerForm.value)
alert('注册成功,请登录')
toastStore.success('注册成功,请登录')
showRegister.value = false
loginForm.value.account = registerForm.value.email
} catch (error: any) {
alert(error.message || '注册失败')
toastStore.error(error.message || '注册失败')
} finally {
loading.value = false
}
@@ -210,7 +213,7 @@ async function handleRegister() {
async function handleSendCode() {
const target = registerForm.value.email || registerForm.value.phone
if (!target) {
alert('请先填写邮箱或手机号')
toastStore.warning('请先填写邮箱或手机号')
return
}
@@ -218,9 +221,9 @@ async function handleSendCode() {
try {
const type = registerForm.value.email ? 'email' : 'sms'
await authApi.sendEmailCode({ target, type })
alert('验证码已发送')
toastStore.success('验证码已发送')
} catch (error: any) {
alert(error.message || '发送失败')
toastStore.error(error.message || '发送失败')
} finally {
codeLoading.value = false
}