更新了一些功能
This commit is contained in:
487
src/components/CallInviteDialog.vue
Normal file
487
src/components/CallInviteDialog.vue
Normal file
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div class="call-invite-overlay" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="call-invite-dialog">
|
||||
<!-- 来电信息 -->
|
||||
<div class="caller-info">
|
||||
<div class="caller-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="caller-details">
|
||||
<h3 class="caller-name">{{ callerInfo.name }}</h3>
|
||||
<p class="call-type">{{ callType === 'video' ? '视频通话' : '语音通话' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话状态 -->
|
||||
<div class="call-status">
|
||||
<div class="status-text">{{ statusText }}</div>
|
||||
<div class="call-animation">
|
||||
<div class="pulse-ring"></div>
|
||||
<div class="pulse-ring delay-1"></div>
|
||||
<div class="pulse-ring delay-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="call-actions">
|
||||
<button
|
||||
v-if="isVisible && isIncoming"
|
||||
class="action-btn accept-btn"
|
||||
@click="acceptCall"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>接听</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn reject-btn"
|
||||
@click="rejectCall"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
<span>{{ isVisible && isIncoming ? '拒绝' : '取消' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 等待状态 -->
|
||||
<div v-if="isVisible && isProcessing" class="processing-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="processing-text">{{ processingText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const props = defineProps({
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
callerInfo: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
callType: {
|
||||
type: String,
|
||||
default: 'video',
|
||||
validator: (value) => ['video', 'audio'].includes(value)
|
||||
},
|
||||
isIncoming: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
callId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept', 'reject', 'timeout'])
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const timeoutTimer = ref(null)
|
||||
const callDuration = ref(0)
|
||||
const durationTimer = ref(null)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isProcessing.value) {
|
||||
return '连接中...'
|
||||
}
|
||||
|
||||
if (props.isIncoming) {
|
||||
return '邀请您进行通话'
|
||||
}
|
||||
|
||||
return '等待对方接听...'
|
||||
})
|
||||
|
||||
const processingText = computed(() => {
|
||||
if (props.isIncoming) {
|
||||
return '正在接听...'
|
||||
}
|
||||
return '正在取消...'
|
||||
})
|
||||
|
||||
const acceptCall = async () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
await emit('accept', {
|
||||
callId: props.callId,
|
||||
callType: props.callType
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('接听通话失败:', error)
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const rejectCall = async () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
await emit('reject', {
|
||||
callId: props.callId,
|
||||
callType: props.callType,
|
||||
reason: props.isIncoming ? '用户拒绝' : '用户取消'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('拒绝通话失败:', error)
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startTimeout = () => {
|
||||
// 来电30秒超时,拨出60秒超时
|
||||
const timeout = props.isIncoming ? 30000 : 60000
|
||||
|
||||
timeoutTimer.value = setTimeout(() => {
|
||||
emit('timeout', {
|
||||
callId: props.callId,
|
||||
reason: '超时未响应'
|
||||
})
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.value = setInterval(() => {
|
||||
callDuration.value++
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
if (timeoutTimer.value) {
|
||||
clearTimeout(timeoutTimer.value)
|
||||
timeoutTimer.value = null
|
||||
}
|
||||
|
||||
if (durationTimer.value) {
|
||||
clearInterval(durationTimer.value)
|
||||
durationTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event) => {
|
||||
if (!props.isVisible) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (props.isIncoming) {
|
||||
acceptCall()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
rejectCall()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isVisible) {
|
||||
startTimeout()
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
// 请求通知权限
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers()
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.call-invite-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.call-invite-dialog {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .call-invite-dialog {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.caller-info {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.caller-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.caller-name {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .caller-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.call-type {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .call-type {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.call-status {
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .status-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.call-animation {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #4361ee;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-ring.delay-1 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.pulse-ring.delay-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.accept-btn:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.reject-btn:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .processing-overlay {
|
||||
background: rgba(45, 45, 45, 0.9);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #4361ee;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .processing-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.call-invite-dialog {
|
||||
margin: 20px;
|
||||
padding: 24px;
|
||||
min-width: unset;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.caller-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.caller-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<!-- 图片预览组件 -->
|
||||
<div
|
||||
<!-- <div
|
||||
v-if="showImagePreview"
|
||||
class="media-preview-overlay"
|
||||
:class="{ 'dark': themeStore.isDarkMode }"
|
||||
@click.self="closePreview"
|
||||
@click.self="hidePreview"
|
||||
>
|
||||
<div class="preview-container">
|
||||
<button class="preview-close-btn" @click="closePreview">
|
||||
<button class="preview-close-btn" @click="hidePreview">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<div class="image-preview-content">
|
||||
<img
|
||||
:src="mediaData?.url"
|
||||
:src="mediaData"
|
||||
:alt="'图片预览'"
|
||||
class="preview-image"
|
||||
@load="handleImageLoad"
|
||||
@@ -29,24 +29,29 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>-->
|
||||
<VueEasyLightbox
|
||||
:visible="showImagePreview"
|
||||
:imgs="[{ src: mediaData }]"
|
||||
@hide="hidePreview"
|
||||
/>
|
||||
|
||||
<!-- 视频预览组件 -->
|
||||
<div
|
||||
v-if="showVideoPreview"
|
||||
class="media-preview-overlay video-preview"
|
||||
:class="{ 'dark': themeStore.isDarkMode }"
|
||||
@click.self="closePreview"
|
||||
@click.self="hidePreview"
|
||||
>
|
||||
<div class="video-preview-container" :class="{ 'portrait': isPortrait }">
|
||||
<button class="preview-close-btn" @click="closePreview">
|
||||
<button class="preview-close-btn" @click="hidePreview">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<div class="video-content">
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
:src="mediaData?.url"
|
||||
:src="mediaData"
|
||||
class="preview-video"
|
||||
controls
|
||||
autoplay
|
||||
@@ -117,10 +122,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useMediaPreview } from "@/composables/useMediaPreview.js";
|
||||
import VueEasyLightbox from 'vue-easy-lightbox';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { visible, mediaData, closePreview } = useMediaPreview();
|
||||
const { visible, mediaData, hidePreview, previewType } = useMediaPreview();
|
||||
|
||||
// 视频相关状态
|
||||
const videoPlayer = ref(null);
|
||||
@@ -134,8 +140,8 @@ const isPortrait = ref(false);
|
||||
const controlsTimeout = ref(null);
|
||||
|
||||
// 计算属性
|
||||
const showImagePreview = computed(() => visible.value && mediaData.value?.type === 'image');
|
||||
const showVideoPreview = computed(() => visible.value && mediaData.value?.type === 'video');
|
||||
const showImagePreview = computed(() => visible.value && previewType.value === 'image');
|
||||
const showVideoPreview = computed(() => visible.value && previewType.value === 'video');
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0;
|
||||
@@ -296,7 +302,7 @@ const handleKeydown = (event) => {
|
||||
|
||||
switch (event.code) {
|
||||
case 'Escape':
|
||||
closePreview();
|
||||
hidePreview();
|
||||
break;
|
||||
case 'Space':
|
||||
if (showVideoPreview.value) {
|
||||
@@ -321,7 +327,7 @@ const handleKeydown = (event) => {
|
||||
|
||||
// 监听媒体数据变化
|
||||
watch(mediaData, () => {
|
||||
if (mediaData.value?.type === 'video') {
|
||||
if (previewType.value=== 'video') {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<div class="flex items-start gap-3 mb-4" :class="isSent ? 'flex-row-reverse' : 'flex-row'">
|
||||
<!-- 头像 -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0 cursor-pointer"
|
||||
:style="avatarStyle"
|
||||
@click="showUserProfile"
|
||||
>
|
||||
{{ senderInfo.avatar }}
|
||||
</div>
|
||||
@@ -24,7 +25,7 @@
|
||||
<div
|
||||
v-if="message.type === 'image'"
|
||||
class="cursor-pointer relative"
|
||||
@click="previewMedia(message.content, 'image')"
|
||||
@click="handlePreviewMedia(message.content, 'image')"
|
||||
>
|
||||
<img
|
||||
:src="message.content"
|
||||
@@ -41,7 +42,7 @@
|
||||
<div
|
||||
v-else-if="message.type === 'video'"
|
||||
class="cursor-pointer relative video-message"
|
||||
@click="previewMedia(message.content, 'video')"
|
||||
@click="handlePreviewMedia(message.content, 'video')"
|
||||
>
|
||||
<video
|
||||
:src="message.content"
|
||||
@@ -85,6 +86,17 @@
|
||||
<!-- 音频消息 -->
|
||||
<AudioMessage v-else-if="message.type === 'audio'" :message="message" :is-sent="isSent" />
|
||||
|
||||
<!-- 通话消息 -->
|
||||
<div v-else-if="message.type === 'video-call' || message.type === 'voice-call'" class="call-message">
|
||||
<div class="call-info">
|
||||
<i class="fas" :class="message.type === 'video-call' ? 'fa-video' : 'fa-phone'"></i>
|
||||
<span>{{ getCallStatusText(message.callStatus) }}</span>
|
||||
</div>
|
||||
<div class="call-duration" v-if="message.duration">
|
||||
通话时长: {{ formatDuration(message.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<div v-else class="text-base leading-relaxed whitespace-pre-wrap">
|
||||
<span v-html="formatTextWithLinks(message.content)"></span>
|
||||
@@ -103,11 +115,10 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMediaPreview } from '@/composables/useMediaPreview';
|
||||
import previewMedia from '@/composables/useMediaPreview';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
import AudioMessage from './AudioMessage.vue';
|
||||
|
||||
const previewMedia = useMediaPreview();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps({
|
||||
@@ -125,6 +136,8 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show-user-profile']);
|
||||
|
||||
const avatarStyle = computed(() => ({
|
||||
background: props.senderInfo.color
|
||||
}));
|
||||
@@ -145,6 +158,14 @@ const bubbleClasses = computed(() => {
|
||||
return baseClasses;
|
||||
});
|
||||
|
||||
const handlePreviewMedia = (content, type) => {
|
||||
previewMedia(content, type);
|
||||
};
|
||||
|
||||
const showUserProfile = () => {
|
||||
emit('show-user-profile', props.senderInfo);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -159,6 +180,25 @@ const formatTextWithLinks = (text) => {
|
||||
const openUrl = (url) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const getCallStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'invite': '发起通话',
|
||||
'accepted': '通话已接通',
|
||||
'rejected': '通话被拒绝',
|
||||
'ended': '通话已结束',
|
||||
'missed': '未接通话'
|
||||
};
|
||||
return statusMap[status] || '通话消息';
|
||||
};
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.src = '/placeholder.svg?height=200&width=300';
|
||||
};
|
||||
|
||||
const handleVideoError = (event) => {
|
||||
console.error('视频加载失败:', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -249,6 +289,30 @@ const openUrl = (url) => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.call-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-info i {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.url-message {
|
||||
max-width: 400px;
|
||||
}
|
||||
@@ -260,6 +324,10 @@ const openUrl = (url) => {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.url-preview:hover {
|
||||
@@ -279,13 +347,6 @@ const openUrl = (url) => {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.url-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.url-favicon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,46 @@
|
||||
<template>
|
||||
<div class="side-navigation" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<!-- 用户头像区域 -->
|
||||
<div class="user-avatar-section">
|
||||
<div class="nav-header">
|
||||
<!-- <div class="logo">-->
|
||||
<!-- <i class="fas fa-comments"></i>-->
|
||||
<!-- </div>-->
|
||||
<div
|
||||
class="user-avatar-container"
|
||||
@click="showUserProfile = true"
|
||||
class="nav-item profile-item"
|
||||
@click="showProfile"
|
||||
title="个人资料"
|
||||
>
|
||||
<div
|
||||
class="user-avatar"
|
||||
:style="{ background: userStore.currentUser?.color || '#4cc9f0' }"
|
||||
>
|
||||
{{ userStore.currentUser?.name?.charAt(0) || 'U' }}
|
||||
<div class="nav-icon user-avatar" :style="{ background: userStore.currentUser.color }">
|
||||
{{ userStore.currentUser.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="online-indicator"></div>
|
||||
<!-- <div class="nav-label">我的</div>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="navigation-menu">
|
||||
<nav class="nav-menu">
|
||||
<div
|
||||
v-for="item in navigationItems"
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ 'active': activeNav === item.key }"
|
||||
@click="handleNavClick(item.key)"
|
||||
:class="{ 'active': currentView === item.key }"
|
||||
@click="switchView(item.key)"
|
||||
:title="item.label"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="nav-label" :class="{ 'active': activeNav === item.key }">
|
||||
<div class="nav-label">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div v-if="item.badge" class="nav-badge">
|
||||
{{ item.badge }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 底部设置 -->
|
||||
<div class="bottom-actions">
|
||||
<div class="nav-footer">
|
||||
<div
|
||||
class="nav-item settings-item"
|
||||
@click="showSettings = true"
|
||||
@click="showSettings"
|
||||
title="设置"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
@@ -47,252 +48,330 @@
|
||||
</div>
|
||||
<div class="nav-label">设置</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 用户信息弹窗 -->
|
||||
<UserProfileModal
|
||||
v-if="showUserProfile"
|
||||
@close="showUserProfile = false"
|
||||
<!-- 设置模态框 -->
|
||||
<SettingsModal
|
||||
:visible="showSettingsModal"
|
||||
@close="showSettingsModal = false"
|
||||
/>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<SettingsModal
|
||||
v-if="showSettings"
|
||||
@close="showSettings = false"
|
||||
<!-- 用户资料模态框 -->
|
||||
<UserProfileModal
|
||||
:visible="showProfileModal"
|
||||
:user-info="userStore.currentUser"
|
||||
@close="showProfileModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
import UserProfileModal from './UserProfileModal.vue';
|
||||
import SettingsModal from './SettingsModal.vue';
|
||||
import { ref, computed } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import SettingsModal from './SettingsModal.vue'
|
||||
import UserProfileModal from './UserProfileModal.vue'
|
||||
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
const themeStore = useThemeStore()
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const activeNav = ref('chat');
|
||||
const showUserProfile = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const props = defineProps({
|
||||
currentView: {
|
||||
type: String,
|
||||
default: 'chat'
|
||||
}
|
||||
})
|
||||
|
||||
const navigationItems = ref([
|
||||
const emit = defineEmits(['view-change'])
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const showProfileModal = ref(false)
|
||||
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
key: 'chat',
|
||||
label: '聊天',
|
||||
icon: 'fas fa-comments'
|
||||
icon: 'fas fa-comment-dots',
|
||||
badge: getUnreadCount()
|
||||
},
|
||||
{
|
||||
key: 'friends',
|
||||
label: '好友',
|
||||
icon: 'fas fa-user-friends'
|
||||
icon: 'fas fa-user-friends',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
label: '群聊',
|
||||
icon: 'fas fa-users'
|
||||
icon: 'fas fa-users',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
key: 'moments',
|
||||
label: '圈子',
|
||||
icon: 'fas fa-globe'
|
||||
icon: 'fas fa-globe',
|
||||
badge: null
|
||||
}
|
||||
]);
|
||||
])
|
||||
|
||||
const emit = defineEmits(['nav-change']);
|
||||
const getUnreadCount = () => {
|
||||
const totalUnread = chatStore.friends.reduce((total, friend) => {
|
||||
return total + (friend.unreadCount || 0)
|
||||
}, 0)
|
||||
|
||||
const handleNavClick = (key) => {
|
||||
activeNav.value = key;
|
||||
return totalUnread > 0 ? (totalUnread > 99 ? '99+' : totalUnread.toString()) : null
|
||||
}
|
||||
|
||||
const switchView = (key) => {
|
||||
emit('nav-change', key);
|
||||
};
|
||||
}
|
||||
|
||||
const showSettings = () => {
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
const showProfile = () => {
|
||||
showProfileModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.side-navigation {
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #4361ee 0%, #3f37c9 100%);
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
padding: 16px 0;
|
||||
position: relative;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.side-navigation.dark {
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.3);
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.user-avatar-section {
|
||||
margin-bottom: 30px;
|
||||
.nav-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.user-avatar-container:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 50%;
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(67, 97, 238, 0.1);
|
||||
color: #4361ee;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item:hover {
|
||||
background: rgba(67, 97, 238, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item.active {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.online-indicator {
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #4caf50;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: pulse-online 2s infinite;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-online {
|
||||
.nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.side-navigation.dark .settings-item {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.side-navigation.dark .settings-item:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.profile-item {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-item:hover {
|
||||
background: linear-gradient(135deg, #059669, #047857);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.navigation-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item.active {
|
||||
background: linear-gradient(135deg, #4cc9f0, #3a9bc1);
|
||||
box-shadow: 0 8px 20px rgba(76, 201, 240, 0.4);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.nav-label.active {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.2));
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-label.active {
|
||||
background: linear-gradient(135deg, rgba(76, 201, 240, 0.3), rgba(58, 155, 193, 0.2));
|
||||
border-color: rgba(76, 201, 240, 0.4);
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-label {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.side-navigation {
|
||||
width: 70px;
|
||||
padding: 15px 0;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: 8px 16px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
.nav-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
min-height: 60px;
|
||||
padding: 10px 6px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
min-width: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,162 +1,207 @@
|
||||
<template>
|
||||
<CustomModal
|
||||
:visible="true"
|
||||
title="个人信息"
|
||||
:visible="isVisible"
|
||||
title="用户资料"
|
||||
size="medium"
|
||||
@close="$emit('close')"
|
||||
@close="closeModal"
|
||||
>
|
||||
<div class="user-profile-content" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-section">
|
||||
<div
|
||||
class="user-avatar-large"
|
||||
:style="{ background: userStore.currentUser?.color || '#4cc9f0' }"
|
||||
>
|
||||
{{ userStore.currentUser?.name?.charAt(0) || 'U' }}
|
||||
</div>
|
||||
<button class="avatar-edit-btn">
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
<div class="user-avatar" :style="{ background: userInfo.color }">
|
||||
{{ userInfo.name?.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{{ userStore.currentUser?.name || '未知用户' }}</h2>
|
||||
<p class="user-id">ID: {{ userStore.currentUser?.id || '000000' }}</p>
|
||||
<div class="user-basic-info">
|
||||
<h2 class="user-name">{{ userInfo.name }}</h2>
|
||||
<p class="user-id">ID: {{ userInfo.id }}</p>
|
||||
<div class="user-status">
|
||||
<div class="status-indicator online"></div>
|
||||
<span>在线</span>
|
||||
<span class="status-dot" :class="isOnline ? 'online' : 'offline'"></span>
|
||||
<span class="status-text">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ friendsCount }}</div>
|
||||
<div class="stat-label">好友</div>
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="profile-details">
|
||||
<div class="detail-section">
|
||||
<h3 class="section-title">个人信息</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>昵称</label>
|
||||
<span>{{ userInfo.name || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>用户ID</label>
|
||||
<span>{{ userInfo.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>注册时间</label>
|
||||
<span>{{ formatDate(userInfo.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>最后在线</label>
|
||||
<span>{{ formatDate(userInfo.lastSeen) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ groupsCount }}</div>
|
||||
<div class="stat-label">群聊</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ messagesCount }}</div>
|
||||
<div class="stat-label">消息</div>
|
||||
|
||||
<!-- 聊天统计 -->
|
||||
<div class="detail-section">
|
||||
<h3 class="section-title">聊天统计</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ chatStats.messageCount || 0 }}</div>
|
||||
<div class="stat-label">消息数量</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ chatStats.callCount || 0 }}</div>
|
||||
<div class="stat-label">通话次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ formatDuration(chatStats.totalCallTime || 0) }}</div>
|
||||
<div class="stat-label">通话时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人设置 -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">个人设置</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<i class="fas fa-user setting-icon"></i>
|
||||
<div>
|
||||
<div class="setting-name">昵称</div>
|
||||
<div class="setting-desc">{{ userStore.currentUser?.name || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="setting-action">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<i class="fas fa-palette setting-icon"></i>
|
||||
<div>
|
||||
<div class="setting-name">主题颜色</div>
|
||||
<div class="setting-desc">个性化你的聊天界面</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-picker">
|
||||
<div
|
||||
v-for="color in themeColors"
|
||||
:key="color"
|
||||
class="color-option"
|
||||
:class="{ 'active': userStore.currentUser?.color === color }"
|
||||
:style="{ background: color }"
|
||||
@click="changeThemeColor(color)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<i class="fas fa-bell setting-icon"></i>
|
||||
<div>
|
||||
<div class="setting-name">消息通知</div>
|
||||
<div class="setting-desc">管理通知设置</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="setting-action">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<i class="fas fa-shield-alt setting-icon"></i>
|
||||
<div>
|
||||
<div class="setting-name">隐私设置</div>
|
||||
<div class="setting-desc">控制谁可以联系你</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="setting-action">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="profile-actions">
|
||||
<button class="action-btn primary-btn" @click="startChat">
|
||||
<i class="fas fa-comment"></i>
|
||||
发送消息
|
||||
</button>
|
||||
<button class="action-btn secondary-btn" @click="startVoiceCall">
|
||||
<i class="fas fa-phone"></i>
|
||||
语音通话
|
||||
</button>
|
||||
<button class="action-btn secondary-btn" @click="startVideoCall">
|
||||
<i class="fas fa-video"></i>
|
||||
视频通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="modal-btn modal-btn-cancel" @click="$emit('close')">
|
||||
关闭
|
||||
</button>
|
||||
<button class="modal-btn modal-btn-confirm">
|
||||
保存更改
|
||||
</button>
|
||||
</template>
|
||||
</CustomModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import CustomModal from './CustomModal.vue';
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import CustomModal from './CustomModal.vue'
|
||||
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const themeColors = ref([
|
||||
'#4cc9f0', '#ff6b6b', '#6a0dad', '#20b2aa',
|
||||
'#ffa500', '#9acd32', '#ff1493', '#4682b4',
|
||||
'#c71585', '#2e8b57', '#4361ee', '#3f37c9'
|
||||
]);
|
||||
|
||||
const friendsCount = computed(() => chatStore.friends?.length || 0);
|
||||
const groupsCount = computed(() => 0); // 暂时为0,后续实现群聊功能
|
||||
const messagesCount = computed(() => {
|
||||
// 计算总消息数
|
||||
return chatStore.friends?.reduce((total, friend) => {
|
||||
const chatHistory = JSON.parse(localStorage.getItem(`chat_${userStore.currentUser.id}_${friend.id}`) || '[]');
|
||||
return total + chatHistory.length;
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
const changeThemeColor = (color) => {
|
||||
if (userStore.currentUser) {
|
||||
userStore.currentUser.color = color;
|
||||
// 这里可以添加保存到后端的逻辑
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'start-chat', 'start-call'])
|
||||
|
||||
const isVisible = computed(() => props.visible)
|
||||
const isOnline = ref(true) // 简化处理,实际应该从服务器获取
|
||||
|
||||
// 聊天统计数据
|
||||
const chatStats = ref({
|
||||
messageCount: 0,
|
||||
callCount: 0,
|
||||
totalCallTime: 0
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '未知'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) { // 1分钟内
|
||||
return '刚刚'
|
||||
} else if (diff < 3600000) { // 1小时内
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
} else if (diff < 86400000) { // 24小时内
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
} else if (diff < 604800000) { // 7天内
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}分钟`
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return `${hours}小时${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聊天统计
|
||||
const loadChatStats = async () => {
|
||||
try {
|
||||
// 这里应该从数据库或API获取统计数据
|
||||
// 暂时使用模拟数据
|
||||
chatStats.value = {
|
||||
messageCount: Math.floor(Math.random() * 1000),
|
||||
callCount: Math.floor(Math.random() * 50),
|
||||
totalCallTime: Math.floor(Math.random() * 7200)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始聊天
|
||||
const startChat = () => {
|
||||
emit('start-chat', props.userInfo)
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 开始语音通话
|
||||
const startVoiceCall = () => {
|
||||
emit('start-call', {
|
||||
user: props.userInfo,
|
||||
type: 'audio'
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 开始视频通话
|
||||
const startVideoCall = () => {
|
||||
emit('start-call', {
|
||||
user: props.userInfo,
|
||||
type: 'video'
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听用户信息变化
|
||||
watch(() => props.userInfo, (newUserInfo) => {
|
||||
if (newUserInfo && newUserInfo.id) {
|
||||
loadChatStats()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -169,232 +214,284 @@ const changeThemeColor = (color) => {
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
padding: 32px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin: -24px -24px 24px -24px;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
position: relative;
|
||||
.user-profile-content.dark .profile-header {
|
||||
border-bottom-color: #374151;
|
||||
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-edit-btn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #4361ee;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.avatar-edit-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-basic-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .user-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .user-id {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
.status-dot.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .stats-section {
|
||||
background: #374151;
|
||||
.status-dot.offline {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .status-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .section-title {
|
||||
color: #f7fafc;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .detail-item label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 14px;
|
||||
color: #1a202c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .detail-item span {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .stat-item {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #4361ee;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .section-title {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
.profile-actions {
|
||||
padding: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #f8fafc;
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
.user-profile-content.dark .profile-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .setting-item {
|
||||
border-bottom-color: #4b5563;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .setting-item:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-icon {
|
||||
width: 20px;
|
||||
color: #4361ee;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a202c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .setting-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .setting-desc {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.setting-action {
|
||||
background: none;
|
||||
min-width: 120px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-action:hover {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .setting-action:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.1);
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.color-option.active {
|
||||
border-color: white;
|
||||
box-shadow: 0 0 0 2px #4361ee;
|
||||
.secondary-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-profile-content.dark .secondary-btn {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .secondary-btn:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.7; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
ref="container"
|
||||
class="video-call-container"
|
||||
:class="{ 'minimized': isMinimized, 'dark': themeStore.isDarkMode }"
|
||||
:style="{ left: position.x + 'px', top: position.y + 'px' }"
|
||||
@mousedown="startDrag"
|
||||
:class="{
|
||||
'minimized': isMinimized,
|
||||
'dark': themeStore.isDarkMode,
|
||||
'dragging': isDragging
|
||||
}"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<!-- 拖拽头部 -->
|
||||
<div class="call-header" v-if="!isMinimized">
|
||||
<div
|
||||
class="call-header"
|
||||
v-if="!isMinimized"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<div class="call-info">
|
||||
<div class="caller-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="caller-details">
|
||||
<div class="caller-name">{{ callerInfo.name }}</div>
|
||||
<div class="call-status">{{ callStatus }}</div>
|
||||
<div class="call-status">{{ callStatus }} {{ formattedDuration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-actions">
|
||||
<button class="action-btn minimize-btn" @click="toggleMinimize">
|
||||
<button class="action-btn minimize-btn" @click="toggleMinimize" @mousedown.stop>
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<button class="action-btn close-btn" @click="endCall">
|
||||
<button class="action-btn close-btn" @click="endCall" @mousedown.stop>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最小化状态 -->
|
||||
<div v-if="isMinimized" class="minimized-content" @click="toggleMinimize">
|
||||
<div class="mini-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.name.charAt(0) }}
|
||||
<!-- 设备状态指示 -->
|
||||
<div v-if="!isMinimized" class="device-status-container">
|
||||
<div v-if="callType === 'video' && !isCameraWorking" class="status-alert">
|
||||
<i class="fas fa-video-slash"></i> 摄像头未工作
|
||||
</div>
|
||||
<div v-if="!isMicWorking" class="status-alert">
|
||||
<i class="fas fa-microphone-slash"></i> 麦克风未工作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最小化状态 -->
|
||||
<div
|
||||
v-if="isMinimized"
|
||||
class="minimized-content"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<div class="remote-video-preview">
|
||||
<video
|
||||
v-if="callType === 'video' && hasRemoteVideo"
|
||||
class="remote-video"
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
<div v-else class="video-placeholder">
|
||||
<div class="placeholder-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mini-info">
|
||||
<div class="mini-name">{{ callerInfo.name }}</div>
|
||||
<div class="mini-status">{{ callStatus }}</div>
|
||||
</div>
|
||||
<div class="call-indicator">
|
||||
<div class="pulse-dot"></div>
|
||||
<div class="mini-status">{{ formattedDuration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +93,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 本地视频 -->
|
||||
<div class="local-video-container" v-if="hasLocalVideo && callType === 'video'">
|
||||
<div
|
||||
class="local-video-container"
|
||||
v-if="hasLocalVideo && callType === 'video' && isCameraWorking"
|
||||
>
|
||||
<video
|
||||
ref="localVideo"
|
||||
class="local-video"
|
||||
@@ -72,6 +105,14 @@
|
||||
muted
|
||||
></video>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="callType === 'video'"
|
||||
class="local-placeholder-container"
|
||||
>
|
||||
<div class="local-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制栏 -->
|
||||
@@ -116,7 +157,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
@@ -148,26 +189,48 @@ const isSpeakerOn = ref(true);
|
||||
const hasLocalVideo = ref(false);
|
||||
const hasRemoteVideo = ref(false);
|
||||
const connectionText = ref('正在连接...');
|
||||
|
||||
// 拖拽相关
|
||||
const position = reactive({ x: 100, y: 100 });
|
||||
const callDuration = ref(0);
|
||||
const callTimer = ref(null);
|
||||
const isCameraWorking = ref(true);
|
||||
const isMicWorking = ref(true);
|
||||
const isDragging = ref(false);
|
||||
const dragOffset = reactive({ x: 0, y: 0 });
|
||||
|
||||
// 视频元素引用
|
||||
const localVideo = ref(null);
|
||||
const remoteVideo = ref(null);
|
||||
// 拖拽相关状态
|
||||
const position = reactive({ x: 0, y: 0 });
|
||||
const startPosition = reactive({ x: 0, y: 0 });
|
||||
const container = ref(null);
|
||||
|
||||
// 媒体流
|
||||
const localStream = ref(null);
|
||||
const remoteStream = ref(null);
|
||||
// 通话计时器
|
||||
const startCallTimer = () => {
|
||||
if (callTimer.value) clearInterval(callTimer.value);
|
||||
callTimer.value = setInterval(() => {
|
||||
callDuration.value += 1;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 格式化通话时间
|
||||
const formattedDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// 计算容器样式
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: isMinimized.value ? '200px' : '500px',
|
||||
height: isMinimized.value ? '60px' : '380px'
|
||||
};
|
||||
});
|
||||
|
||||
// 通话状态
|
||||
const callStatus = computed(() => {
|
||||
if (props.isIncoming) {
|
||||
return '来电中...';
|
||||
}
|
||||
return '通话中 00:32';
|
||||
return '通话中';
|
||||
});
|
||||
|
||||
// 初始化媒体设备
|
||||
@@ -178,109 +241,99 @@ const initializeMedia = async () => {
|
||||
video: props.callType === 'video'
|
||||
};
|
||||
|
||||
localStream.value = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
if (localVideo.value && props.callType === 'video') {
|
||||
localVideo.value.srcObject = localStream.value;
|
||||
hasLocalVideo.value = true;
|
||||
}
|
||||
|
||||
// 检查是否有摄像头
|
||||
const videoTracks = localStream.value.getVideoTracks();
|
||||
if (videoTracks.length === 0) {
|
||||
isCameraOn.value = false;
|
||||
}
|
||||
|
||||
// 模拟连接成功
|
||||
// 模拟媒体访问
|
||||
setTimeout(() => {
|
||||
connectionText.value = '已连接';
|
||||
// 模拟远程视频(实际应用中这里会是真实的远程流)
|
||||
if (props.callType === 'video') {
|
||||
hasRemoteVideo.value = false; // 设为false显示头像
|
||||
hasLocalVideo.value = true;
|
||||
hasRemoteVideo.value = true;
|
||||
}
|
||||
}, 2000);
|
||||
connectionText.value = '已连接';
|
||||
startCallTimer();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('无法访问媒体设备:', error);
|
||||
isCameraOn.value = false;
|
||||
isCameraWorking.value = false;
|
||||
connectionText.value = '连接失败';
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
alert('请允许访问摄像头和麦克风');
|
||||
} else if (error.name === 'NotFoundError') {
|
||||
alert('未找到摄像头或麦克风设备');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 控制功能
|
||||
const toggleMic = () => {
|
||||
if (localStream.value) {
|
||||
const audioTracks = localStream.value.getAudioTracks();
|
||||
audioTracks.forEach(track => {
|
||||
track.enabled = !track.enabled;
|
||||
});
|
||||
isMicOn.value = !isMicOn.value;
|
||||
}
|
||||
isMicOn.value = !isMicOn.value;
|
||||
isMicWorking.value = isMicOn.value;
|
||||
};
|
||||
|
||||
const toggleCamera = () => {
|
||||
if (localStream.value && props.callType === 'video') {
|
||||
const videoTracks = localStream.value.getVideoTracks();
|
||||
videoTracks.forEach(track => {
|
||||
track.enabled = !track.enabled;
|
||||
});
|
||||
if (props.callType === 'video') {
|
||||
isCameraOn.value = !isCameraOn.value;
|
||||
hasLocalVideo.value = isCameraOn.value;
|
||||
isCameraWorking.value = isCameraOn.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSpeaker = () => {
|
||||
isSpeakerOn.value = !isSpeakerOn.value;
|
||||
if (remoteVideo.value) {
|
||||
remoteVideo.value.muted = !isSpeakerOn.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMinimize = () => {
|
||||
isMinimized.value = !isMinimized.value;
|
||||
|
||||
// 最小化时确保在屏幕内
|
||||
if (isMinimized.value) {
|
||||
position.x = Math.max(0, Math.min(position.x, window.innerWidth - 200));
|
||||
position.y = Math.max(0, Math.min(position.y, window.innerHeight - 60));
|
||||
} else {
|
||||
// 最大化时确保在屏幕内
|
||||
position.x = Math.max(0, Math.min(position.x, window.innerWidth - 500));
|
||||
position.y = Math.max(0, Math.min(position.y, window.innerHeight - 380));
|
||||
}
|
||||
};
|
||||
|
||||
const endCall = () => {
|
||||
// 停止所有媒体流
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
if (callTimer.value) clearInterval(callTimer.value);
|
||||
isVisible.value = false;
|
||||
emit('end-call');
|
||||
};
|
||||
|
||||
// 拖拽功能
|
||||
// 拖拽功能 - 完全重写以解决偏移问题
|
||||
const startDrag = (event) => {
|
||||
if (isMinimized.value || event.target.closest('.call-controls')) return;
|
||||
if (event.target.closest('.action-btn') || event.target.closest('.control-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging.value = true;
|
||||
dragOffset.x = event.clientX - position.x;
|
||||
dragOffset.y = event.clientY - position.y;
|
||||
|
||||
// 记录初始位置(鼠标位置和元素位置)
|
||||
startPosition.x = event.clientX;
|
||||
startPosition.y = event.clientY;
|
||||
|
||||
document.addEventListener('mousemove', handleDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrag = (event) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
position.x = event.clientX - dragOffset.x;
|
||||
position.y = event.clientY - dragOffset.y;
|
||||
// 计算鼠标移动的偏移量
|
||||
const deltaX = event.clientX - startPosition.x;
|
||||
const deltaY = event.clientY - startPosition.y;
|
||||
|
||||
// 限制在窗口范围内
|
||||
const maxX = window.innerWidth - 320;
|
||||
const maxY = window.innerHeight - 240;
|
||||
// 更新元素位置
|
||||
position.x += deltaX;
|
||||
position.y += deltaY;
|
||||
|
||||
position.x = Math.max(0, Math.min(position.x, maxX));
|
||||
position.y = Math.max(0, Math.min(position.y, maxY));
|
||||
// 更新初始位置为当前位置
|
||||
startPosition.x = event.clientX;
|
||||
startPosition.y = event.clientY;
|
||||
|
||||
// 确保不超出屏幕边界
|
||||
const width = isMinimized.value ? 200 : 500;
|
||||
const height = isMinimized.value ? 60 : 380;
|
||||
|
||||
position.x = Math.max(0, Math.min(position.x, window.innerWidth - width));
|
||||
position.y = Math.max(0, Math.min(position.y, window.innerHeight - height));
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
@@ -289,20 +342,30 @@ const stopDrag = () => {
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
};
|
||||
|
||||
// 窗口大小变化处理
|
||||
const handleWindowResize = () => {
|
||||
const width = isMinimized.value ? 200 : 500;
|
||||
const height = isMinimized.value ? 60 : 380;
|
||||
|
||||
position.x = Math.max(0, Math.min(position.x, window.innerWidth - width));
|
||||
position.y = Math.max(0, Math.min(position.y, window.innerHeight - height));
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeMedia();
|
||||
|
||||
// 设置初始位置(屏幕右上角)
|
||||
position.x = window.innerWidth - 340;
|
||||
position.y = 20;
|
||||
// 设置初始居中位置
|
||||
position.x = Math.max(0, (window.innerWidth - 500) / 2);
|
||||
position.y = Math.max(0, (window.innerHeight - 380) / 2);
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (callTimer.value) clearInterval(callTimer.value);
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -315,9 +378,14 @@ onUnmounted(() => {
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
width: 320px;
|
||||
height: 240px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.video-call-container.dragging {
|
||||
opacity: 0.9;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.5);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.video-call-container.dark {
|
||||
@@ -326,9 +394,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.video-call-container.minimized {
|
||||
width: 200px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.call-header {
|
||||
@@ -338,6 +404,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
@@ -401,36 +468,84 @@ onUnmounted(() => {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.device-status-container {
|
||||
position: absolute;
|
||||
top: 65px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
z-index: 10;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.status-alert {
|
||||
background: rgba(255, 87, 87, 0.8);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.minimized-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
padding: 0 10px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
.remote-video-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.placeholder-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mini-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mini-name {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -438,36 +553,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.mini-status {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.call-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4caf50;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.2); opacity: 0.7; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.video-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: calc(100% - 116px);
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.remote-video-container {
|
||||
@@ -476,12 +570,6 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -495,19 +583,19 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.placeholder-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -519,20 +607,41 @@ onUnmounted(() => {
|
||||
|
||||
.local-video-container {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 120px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.local-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
.local-placeholder-container {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 120px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.local-avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
@@ -542,6 +651,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
@@ -581,28 +691,35 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.video-call-container {
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
@media (max-width: 600px) {
|
||||
.video-call-container:not(.minimized) {
|
||||
width: 95vw !important;
|
||||
height: 75vh !important;
|
||||
max-width: 95vw;
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.video-call-container.minimized {
|
||||
width: 180px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.local-video-container {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
.call-header,
|
||||
.call-controls {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.local-video-container,
|
||||
.local-placeholder-container {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-height: 500px) {
|
||||
.video-call-container:not(.minimized) {
|
||||
height: 95vh !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,22 +2,33 @@ import { ref } from "vue"
|
||||
|
||||
const visible = ref(false)
|
||||
const mediaData = ref(null)
|
||||
const previewType = ref("image")
|
||||
|
||||
export function useMediaPreview() {
|
||||
const previewMedia = (url, type) => {
|
||||
mediaData.value = { url, type }
|
||||
visible.value = true
|
||||
}
|
||||
const showPreview = (content, type = "image") => {
|
||||
console.log("显示媒体预览:", content, type)
|
||||
mediaData.value = content
|
||||
previewType.value = type
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
visible.value = false
|
||||
mediaData.value = null
|
||||
}
|
||||
const hidePreview = () => {
|
||||
visible.value = false
|
||||
mediaData.value = null
|
||||
previewType.value = "image"
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
mediaData,
|
||||
previewMedia,
|
||||
closePreview,
|
||||
}
|
||||
return {
|
||||
visible,
|
||||
mediaData,
|
||||
previewType,
|
||||
showPreview,
|
||||
hidePreview,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出一个函数供组件直接使用
|
||||
export default function previewMedia(content, type = "image") {
|
||||
const { showPreview } = useMediaPreview()
|
||||
showPreview(content, type)
|
||||
}
|
||||
|
||||
132
src/composables/useWebRTC.js
Normal file
132
src/composables/useWebRTC.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
export function useWebRTC() {
|
||||
const localStream = ref(null)
|
||||
const remoteStream = ref(null)
|
||||
const peerConnection = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
|
||||
const configuration = {
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }],
|
||||
}
|
||||
|
||||
const createPeerConnection = () => {
|
||||
peerConnection.value = new RTCPeerConnection(configuration)
|
||||
|
||||
peerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
// 发送ICE候选到对端
|
||||
console.log("ICE候选:", event.candidate)
|
||||
}
|
||||
}
|
||||
|
||||
peerConnection.value.ontrack = (event) => {
|
||||
remoteStream.value = event.streams[0]
|
||||
console.log("接收到远程流:", event.streams[0])
|
||||
}
|
||||
|
||||
peerConnection.value.onconnectionstatechange = () => {
|
||||
const state = peerConnection.value.connectionState
|
||||
console.log("连接状态变化:", state)
|
||||
|
||||
if (state === "connected") {
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
} else if (state === "disconnected" || state === "failed") {
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return peerConnection.value
|
||||
}
|
||||
|
||||
const getLocalMedia = async (constraints = { audio: true, video: true }) => {
|
||||
try {
|
||||
localStream.value = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
return localStream.value
|
||||
} catch (error) {
|
||||
console.error("获取本地媒体失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const createOffer = async () => {
|
||||
if (!peerConnection.value) {
|
||||
createPeerConnection()
|
||||
}
|
||||
|
||||
// 添加本地流到连接
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
peerConnection.value.addTrack(track, localStream.value)
|
||||
})
|
||||
}
|
||||
|
||||
const offer = await peerConnection.value.createOffer()
|
||||
await peerConnection.value.setLocalDescription(offer)
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
const createAnswer = async (offer) => {
|
||||
if (!peerConnection.value) {
|
||||
createPeerConnection()
|
||||
}
|
||||
|
||||
// 添加本地流到连接
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
peerConnection.value.addTrack(track, localStream.value)
|
||||
})
|
||||
}
|
||||
|
||||
await peerConnection.value.setRemoteDescription(offer)
|
||||
const answer = await peerConnection.value.createAnswer()
|
||||
await peerConnection.value.setLocalDescription(answer)
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
const setRemoteAnswer = async (answer) => {
|
||||
await peerConnection.value.setRemoteDescription(answer)
|
||||
}
|
||||
|
||||
const addIceCandidate = async (candidate) => {
|
||||
if (peerConnection.value && peerConnection.value.remoteDescription) {
|
||||
await peerConnection.value.addIceCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const closeConnection = () => {
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => track.stop())
|
||||
localStream.value = null
|
||||
}
|
||||
|
||||
if (peerConnection.value) {
|
||||
peerConnection.value.close()
|
||||
peerConnection.value = null
|
||||
}
|
||||
|
||||
remoteStream.value = null
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
localStream,
|
||||
remoteStream,
|
||||
peerConnection,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
createPeerConnection,
|
||||
getLocalMedia,
|
||||
createOffer,
|
||||
createAnswer,
|
||||
setRemoteAnswer,
|
||||
addIceCandidate,
|
||||
closeConnection,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref, onMounted, onUnmounted } from "vue"
|
||||
import { useUserStore } from "@/stores/user"
|
||||
import { useChatStore } from "@/stores/chat"
|
||||
|
||||
@@ -5,76 +6,243 @@ export function useWebSocket() {
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const socket = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const reconnectAttempts = ref(0)
|
||||
const maxReconnectAttempts = 5
|
||||
const reconnectDelay = ref(1000)
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (isConnecting.value || isConnected.value) return
|
||||
|
||||
isConnecting.value = true
|
||||
chatStore.connectionStatus = "connecting"
|
||||
|
||||
try {
|
||||
chatStore.connectionStatus = "connecting"
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || "ws://localhost:12080/ws"
|
||||
socket.value = new WebSocket(wsUrl)
|
||||
|
||||
// const wsUrl = "ws://g-ws.nailaoyun.cn/ws"
|
||||
const wsUrl = "ws://127.0.0.1:12080/ws"
|
||||
console.log("连接WebSocket:", wsUrl)
|
||||
|
||||
chatStore.socket = new WebSocket(wsUrl)
|
||||
|
||||
chatStore.socket.onopen = () => {
|
||||
console.log("WebSocket连接已建立")
|
||||
chatStore.connectionStatus = "connected"
|
||||
|
||||
// 发送绑定请求
|
||||
if (userStore.currentUser) {
|
||||
const bindMsg = {
|
||||
request_type: "bind",
|
||||
sender_user_id: userStore.currentUser.id.toString(),
|
||||
}
|
||||
chatStore.socket.send(JSON.stringify(bindMsg))
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
console.log("收到消息:", message)
|
||||
|
||||
// 检查是否是系统消息
|
||||
if (message.clientId) return
|
||||
|
||||
chatStore.handleIncomingMessage(message, userStore.currentUser.id)
|
||||
} catch (e) {
|
||||
console.error("解析消息失败:", e)
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.socket.onclose = (event) => {
|
||||
console.log("WebSocket连接已关闭, 代码:", event.code, "原因:", event.reason)
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
|
||||
// 智能重连策略
|
||||
if (event.code !== 1000) {
|
||||
setTimeout(() => {
|
||||
console.log("尝试重新连接WebSocket...")
|
||||
connectWebSocket()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.socket.onerror = (error) => {
|
||||
console.error("WebSocket错误:", error)
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
}
|
||||
socket.value.onopen = handleOpen
|
||||
socket.value.onmessage = handleMessage
|
||||
socket.value.onclose = disconnectWebSocket
|
||||
socket.value.onerror = handleError
|
||||
} catch (error) {
|
||||
console.error("连接WebSocket失败:", error)
|
||||
console.error("WebSocket连接失败:", error)
|
||||
isConnecting.value = false
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectWebSocket = () => {
|
||||
if (chatStore.socket) {
|
||||
chatStore.socket.close()
|
||||
chatStore.socket = null
|
||||
const handleOpen = (event) => {
|
||||
console.log("WebSocket连接已建立")
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
reconnectAttempts.value = 0
|
||||
reconnectDelay.value = 1000
|
||||
chatStore.connectionStatus = "connected"
|
||||
chatStore.socket = socket.value
|
||||
}
|
||||
|
||||
const handleMessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
console.log("收到消息:", data)
|
||||
// 处理客户端ID
|
||||
if (data?.clientId) {
|
||||
// console.log("收到客户端ID:", data.clientId)
|
||||
// userStore.setClientId(data.clientId)
|
||||
//
|
||||
// // 绑定用户
|
||||
// if (userStore.currentUser?.id) {
|
||||
bindUser(userStore.currentUser.id)
|
||||
// }
|
||||
return
|
||||
}
|
||||
|
||||
// 处理通话信令
|
||||
if (data.call_id && data.call_status) {
|
||||
handleCallSignal(data)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理普通消息
|
||||
if (data.sender_user_id && data.receiver_user_id) {
|
||||
chatStore.handleIncomingMessage(data, userStore.currentUser?.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析WebSocket消息失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectWebSocket = (event) => {
|
||||
console.log("WebSocket连接已关闭:", event.code, event.reason)
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
chatStore.socket = null
|
||||
|
||||
// 自动重连
|
||||
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
reconnectAttempts.value++
|
||||
reconnectDelay.value = Math.min(reconnectDelay.value * 2, 30000)
|
||||
console.log(`尝试重连 (${reconnectAttempts.value}/${maxReconnectAttempts})`)
|
||||
connect()
|
||||
}, reconnectDelay.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error("WebSocket错误:", error)
|
||||
isConnecting.value = false
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
}
|
||||
|
||||
const handleCallSignal = (data) => {
|
||||
console.log("收到通话信令:", data)
|
||||
|
||||
// 根据通话状态处理
|
||||
switch (data.call_status) {
|
||||
case "invite":
|
||||
// 显示来电通知
|
||||
showIncomingCallDialog(data)
|
||||
break
|
||||
case "accepted":
|
||||
// 通话被接受
|
||||
handleCallAccepted(data)
|
||||
break
|
||||
case "rejected":
|
||||
// 通话被拒绝
|
||||
handleCallRejected(data)
|
||||
break
|
||||
case "ended":
|
||||
// 通话结束
|
||||
handleCallEnded(data)
|
||||
break
|
||||
case "candidate":
|
||||
// ICE候选
|
||||
handleIceCandidate(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const bindUser = (userId) => {
|
||||
console.log(userId, 'ssssssssss')
|
||||
if (!isConnected.value || !userId) return
|
||||
|
||||
const bindMessage = {
|
||||
request_type: "bind",
|
||||
sender_user_id: userId,
|
||||
client_id: userStore.clientId,
|
||||
}
|
||||
|
||||
send(bindMessage)
|
||||
}
|
||||
|
||||
const send = (message) => {
|
||||
if (!isConnected.value || !socket.value) {
|
||||
console.error("WebSocket未连接,无法发送消息")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
socket.value.send(JSON.stringify(message))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("发送消息失败:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = (receiverUserId, messageType, content) => {
|
||||
const message = {
|
||||
request_type: "send_message",
|
||||
sender_user_id: userStore.currentUser?.id,
|
||||
receiver_user_id: receiverUserId,
|
||||
message_type: messageType,
|
||||
message_content: content,
|
||||
}
|
||||
|
||||
return send(message)
|
||||
}
|
||||
|
||||
const sendCallSignal = (signal) => {
|
||||
return send(signal)
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (socket.value) {
|
||||
socket.value.close()
|
||||
socket.value = null
|
||||
}
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
chatStore.connectionStatus = "disconnected"
|
||||
chatStore.socket = null
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
const generateCallId = () => {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket()
|
||||
})
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
send,
|
||||
sendMessage,
|
||||
sendCallSignal,
|
||||
bindUser,
|
||||
generateCallId,
|
||||
}
|
||||
}
|
||||
|
||||
// 全局通话处理函数
|
||||
const incomingCallDialog = null
|
||||
const currentCall = null
|
||||
|
||||
function showIncomingCallDialog(callData) {
|
||||
// 这里应该显示来电弹窗
|
||||
console.log("显示来电弹窗:", callData)
|
||||
|
||||
// 创建来电通知
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(`${callData.sender_user_id} 邀请您进行${callData.message_type === 6 ? "视频" : "语音"}通话`, {
|
||||
icon: "/favicon.ico",
|
||||
tag: "incoming-call",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleCallAccepted(data) {
|
||||
console.log("通话被接受:", data)
|
||||
// 开始WebRTC连接
|
||||
}
|
||||
|
||||
function handleCallRejected(data) {
|
||||
console.log("通话被拒绝:", data)
|
||||
// 显示拒绝消息
|
||||
}
|
||||
|
||||
function handleCallEnded(data) {
|
||||
console.log("通话结束:", data)
|
||||
// 清理通话状态
|
||||
}
|
||||
|
||||
function handleIceCandidate(data) {
|
||||
console.log("收到ICE候选:", data)
|
||||
// 处理ICE候选
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- 主聊天容器 -->
|
||||
<div class="flex-1 max-w-7xl mx-auto bg-white/90 backdrop-blur-sm rounded-2xl shadow-2xl overflow-hidden flex z-10">
|
||||
<!-- 侧边导航 -->
|
||||
<SideNavigation @nav-change="handleNavChange" />
|
||||
<SideNavigation :current-view="currentNav" @nav-change="handleNavChange" />
|
||||
|
||||
<!-- 好友列表 -->
|
||||
<FriendList
|
||||
|
||||
Reference in New Issue
Block a user