- {{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
+
+
+
+ {{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
+
+
+
-
+
+
{{ target?.remark_name || target?.user?.name || '未知用户' }}
-
-
+
+
{{ call.statusText }}
-
-
+
-
-
+
+
+
-
+
-
+
-
+
@@ -133,7 +147,7 @@
-
diff --git a/src/components/chat/MessageInput.vue b/src/components/chat/MessageInput.vue
index 88afcf3..eed2901 100644
--- a/src/components/chat/MessageInput.vue
+++ b/src/components/chat/MessageInput.vue
@@ -21,7 +21,7 @@
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 || '无法访问麦克风')
}
}
diff --git a/src/components/chat/bubble/Image.vue b/src/components/chat/bubble/Image.vue
index 56b1ca2..342e50c 100644
--- a/src/components/chat/bubble/Image.vue
+++ b/src/components/chat/bubble/Image.vue
@@ -1,6 +1,6 @@
@@ -22,7 +22,7 @@ const imageUrl = computed(() => {
function previewImage() {
const w = window.open('')
if (w) {
- w.document.write(`
`)
+ w.document.write(`
`)
}
}
diff --git a/src/components/common/ConfirmModal.vue b/src/components/common/ConfirmModal.vue
new file mode 100644
index 0000000..ee1f8a4
--- /dev/null
+++ b/src/components/common/ConfirmModal.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
diff --git a/src/components/common/Toast.vue b/src/components/common/Toast.vue
new file mode 100644
index 0000000..88dfe50
--- /dev/null
+++ b/src/components/common/Toast.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
{{ toast.message }}
+
+ {{ toast.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useWebRTC.ts b/src/composables/useWebRTC.ts
index 726d5a4..d3ed74c 100644
--- a/src/composables/useWebRTC.ts
+++ b/src/composables/useWebRTC.ts
@@ -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({
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(null)
- const remoteVideo = ref(null)
+ // 使用 shallowRef 存储 MediaStream,避免 Vue 进行深层代理导致的性能问题
+ const localStream = shallowRef(null)
+ const remoteStream = shallowRef(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 {
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 {
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,
}
}
-
diff --git a/src/router/index.ts b/src/router/index.ts
index 02da5cc..0313e43 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -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({
diff --git a/src/stores/toast.ts b/src/stores/toast.ts
new file mode 100644
index 0000000..41bf256
--- /dev/null
+++ b/src/stores/toast.ts
@@ -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
+ */
+ 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,
+ }
+})
+
+
diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue
index 58c0c6e..6644c6f 100644
--- a/src/views/chat/ChatView.vue
+++ b/src/views/chat/ChatView.vue
@@ -1,92 +1,43 @@
-
+
-
-
+
-
-
+
-
-
-
-
+
+
-
-
+
-
-
- {{ contact.unread }}
-
+
+
{{ contact.unread }}
-
-
- {{ contact.remark_name || contact.user?.name || '未知' }}
-
+ {{ contact.remark_name || contact.user?.name || '未知' }}
{{ formatTime(contact.last_time || Date.now()) }}
@@ -98,135 +49,54 @@
-
-
-
+
+
+
-
+
-
- {{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
-
-
+ {{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
-
NL-IM
-
安全 · 极速 · 沉浸式体验
+
+
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
{{ authStore.user.name }}
-
{{ authStore.user.desc || '暂无签名' }}
-
-
-
+
+
+
+
@@ -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
| 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 = { 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 = {
- 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()
})
-
diff --git a/src/views/contact/ContactCircle.vue b/src/views/contact/ContactCircle.vue
new file mode 100644
index 0000000..ccb9239
--- /dev/null
+++ b/src/views/contact/ContactCircle.vue
@@ -0,0 +1,381 @@
+
+
+
+
+
好友圈
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新建分组
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 编辑分组
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/contact/ContactDetail.vue b/src/views/contact/ContactDetail.vue
new file mode 100644
index 0000000..7b35144
--- /dev/null
+++ b/src/views/contact/ContactDetail.vue
@@ -0,0 +1,383 @@
+
+
+
+
+
+
好友资料
+
+
+
+
+
+
+
+
+
+ {{ contact.remark_name || contact.user?.name || '未知' }}
+
+
{{ contact.user?.desc || '暂无签名' }}
+
+
+
+ 在线
+
+
+
+ 离线
+
+
+
+
+
+
+
+ 备注名称
+ {{ contact.remark_name || '未设置' }}
+
+
+ 电话号码
+ {{ contact.user.phone }}
+
+
+ 邮箱
+ {{ contact.user.email }}
+
+
+ 地区
+ {{ contact.user.region }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 修改备注
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择分组
+
+
+
+
+ {{ group.group_name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/contact/ContactView.vue b/src/views/contact/ContactView.vue
index 696af56..f8a590a 100644
--- a/src/views/contact/ContactView.vue
+++ b/src/views/contact/ContactView.vue
@@ -95,11 +95,13 @@