更新了一些功能

This commit is contained in:
2025-07-04 12:56:18 +08:00
parent 90d6f01b50
commit 62563c365e
13 changed files with 2501 additions and 981 deletions

View File

@@ -13,6 +13,7 @@
"@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"@tailwindcss/vite": "^4.1.4",
"@vueuse/core": "^13.5.0",
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",

34
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.4
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
'@vueuse/core':
specifier: ^13.5.0
version: 13.5.0(vue@3.5.13)
'@vueuse/head':
specifier: ^2.0.0
version: 2.0.0(vue@3.5.13)
@@ -551,6 +554,9 @@ packages:
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@unhead/dom@1.11.20':
resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==}
@@ -607,11 +613,24 @@ packages:
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vueuse/core@13.5.0':
resolution: {integrity: sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==}
peerDependencies:
vue: ^3.5.0
'@vueuse/head@2.0.0':
resolution: {integrity: sha512-ykdOxTGs95xjD4WXE4na/umxZea2Itl0GWBILas+O4oqS7eXIods38INvk3XkJKjqMdWPcpCyLX/DioLQxU1KA==}
peerDependencies:
vue: '>=2.7 || >=3'
'@vueuse/metadata@13.5.0':
resolution: {integrity: sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==}
'@vueuse/shared@13.5.0':
resolution: {integrity: sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==}
peerDependencies:
vue: ^3.5.0
ant-design-vue@4.2.6:
resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==}
engines: {node: '>=12.22.0'}
@@ -1402,6 +1421,8 @@ snapshots:
'@types/estree@1.0.7': {}
'@types/web-bluetooth@0.0.21': {}
'@unhead/dom@1.11.20':
dependencies:
'@unhead/schema': 1.11.20
@@ -1491,6 +1512,13 @@ snapshots:
'@vue/shared@3.5.13': {}
'@vueuse/core@13.5.0(vue@3.5.13)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.5.0
'@vueuse/shared': 13.5.0(vue@3.5.13)
vue: 3.5.13
'@vueuse/head@2.0.0(vue@3.5.13)':
dependencies:
'@unhead/dom': 1.11.20
@@ -1499,6 +1527,12 @@ snapshots:
'@unhead/vue': 1.11.20(vue@3.5.13)
vue: 3.5.13
'@vueuse/metadata@13.5.0': {}
'@vueuse/shared@13.5.0(vue@3.5.13)':
dependencies:
vue: 3.5.13
ant-design-vue@4.2.6(vue@3.5.13):
dependencies:
'@ant-design/colors': 6.0.0

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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候选
}

View File

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