fix: 聊天功能(部分)
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
2025-07-19 16:47:52 +08:00
parent 77d608404d
commit b17e6c53ad
44 changed files with 13104 additions and 181 deletions

View File

@@ -1,40 +1,39 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { useUserStore as useVbenUserStore } from '@vben/stores';
import { App, ConfigProvider, theme } from 'ant-design-vue';
import { antdLocale } from '#/locales';
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { useUserStore } from '#/views/business/chat/stores/user';
defineOptions({ name: 'App' });
// 定义颜色代码
const redBoldYellowBg = '\u001B[1;31;43m'; // 1=加粗 31=红色文字 43=黄色背景
const reset = '\u001B[0m';
console.log(
`${redBoldYellowBg}......................阿弥陀佛...................... \n` +
` _oo0oo_ \n` +
` o8888888o \n` +
` 88" . "88 \n` +
` (| -_- |) \n` +
` 0\\ = /0 \n` +
` ___/---\\___ \n` +
` .' \\| |/ '. \n` +
` / \\\\||| : |||// \\ \n` +
` / _||||| -卍-|||||_ \\ \n` +
` | | \\\\\\ - /// | | \n` +
` | \\_| ''\\---/'' |_/ | \n` +
` \\ .-\\__ '-' ___/-. / \n` +
` ___'. .' /--.--\\ '. .'___ \n` +
` ."" < .___\\_<|>_/___.> "". \n` +
` | | : - \\.;\\ _ /;./ - : | | \n` +
` \\ \\ _. \\_ __\\ /__ _/ .- / / \n` +
` =====-.____.___ \\_____/___.-___.-===== \n` +
` =---= \n` +
` \n` +
`....................佛祖保佑 ,永无BUG...................${reset}`,
const { connectWebSocket } = useWebSocket();
const userStore = useUserStore();
const chatStore = useChatStore();
const vbenUserStore = useVbenUserStore();
// 用户信息加载状态跟踪
const userInfoLoaded = ref(false);
// 监听userInfo加载完成
watch(
() => vbenUserStore.userInfo,
(newUserInfo) => {
if (newUserInfo) {
console.log('用户信息已加载:', newUserInfo)
userStore.currentUser = newUserInfo;
userStore.login(newUserInfo);
userInfoLoaded.value = true;
}
},
{ immediate: true },
);
const { isDark } = usePreferences();
@@ -45,7 +44,6 @@ const tokenTheme = computed(() => {
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
// antd 紧凑模式算法
if (preferences.app.compact) {
algorithm.push(theme.compactAlgorithm);
}
@@ -55,6 +53,27 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
const initWebsocket = () => {
// 确保用户信息已加载
if (!userStore.currentUser) return;
// 初始化数据库
chatStore.initDB();
// 加载好友列表使用用户ID
chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.id);
// 创建WebSocket连接
connectWebSocket();
};
// 当userInfo加载完成后再执行初始化
watch(userInfoLoaded, (loaded) => {
if (loaded) {
initWebsocket();
}
});
</script>
<template>

View File

@@ -3,6 +3,9 @@ import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
// eslint-disable-next-line n/no-extraneous-import
import '@fortawesome/fontawesome-free/css/all.min.css';
/**
* 应用初始化完成之后再进行页面加载渲染
*/

View File

@@ -0,0 +1,10 @@
import { requestClient } from '#/api/request';
const prefix = 'chat-friends/';
/**
* 获取当前登录的诊所信息
* @param data
*/
export async function getChatFriendsListApi(data: any = {}) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}

View File

@@ -0,0 +1,253 @@
<template>
<div
class="rounded-xl p-4 flex items-center gap-4 min-w-72 max-w-md cursor-pointer transition-all duration-200 hover:opacity-90"
:class="audioContainerClasses"
@click="togglePlay"
>
<!-- 播放状态指示器 -->
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full"
:class="playIconBgClasses">
<component
:is="isPlaying ? PauseOutlined : CaretRightOutlined"
class="text-white"
style="font-size: 16px; transform: translateX(1px)"
/>
</div>
<div class="flex-1 overflow-hidden">
<!-- 高级音波动画 -->
<div class="flex items-end gap-1 h-8 mb-2">
<div
v-for="i in 20"
:key="i"
class="rounded-full transition-all duration-300 ease-out"
:class="waveBarClasses"
:style="{
width: '3px',
height: isPlaying ? `${getWaveHeight(i)}px` : '4px',
animationDelay: `${i * 40}ms`
}"
></div>
</div>
<!-- 进度条和时间显示 -->
<div class="flex items-center gap-2 w-full">
<!-- 当前时间 -->
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
{{ formatDuration(currentTime) }}
</div>
<!-- 进度条 -->
<div class="flex-1 rounded-full h-1.5 overflow-hidden" :class="progressTrackClasses">
<div
class="h-full rounded-full transition-all duration-300 ease-out"
:class="progressBarClasses"
:style="{ width: `${progress}%` }"
></div>
</div>
<!-- 剩余时间 -->
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
-{{ formatDuration(remainingTime) }}
</div>
</div>
</div>
<!-- 总时长显示 -->
<div class="text-sm font-medium flex-shrink-0 min-w-12 text-right" :class="durationTextClasses">
{{ formatDuration(audioDuration) }}
</div>
</div>
</template>
<script setup>
import {ref, onUnmounted, computed, watch} from 'vue';
import {CaretRightOutlined, PauseOutlined} from '@ant-design/icons-vue';
const props = defineProps({
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
}
});
const isPlaying = ref(false);
const progress = ref(0);
const audio = ref(null);
const audioDuration = ref(0);
const currentTime = ref(0);
const remainingTime = computed(() => Math.max(0, audioDuration.value - currentTime.value));
// 直接获取音频时长
const getAudioDuration = () => {
// 如果消息对象中已经包含时长,直接使用
if (props.message.duration && props.message.duration > 0) {
audioDuration.value = props.message.duration;
return;
}
// 否则创建临时音频获取元数据
const tempAudio = new Audio(props.message.content);
tempAudio.addEventListener('loadedmetadata', () => {
if (tempAudio.duration && tempAudio.duration > 0) {
audioDuration.value = Math.round(tempAudio.duration);
}
});
// 设置超时以防无法获取元数据
setTimeout(() => {
if (audioDuration.value === 0 && tempAudio.duration > 0) {
audioDuration.value = Math.round(tempAudio.duration);
}
}, 500);
};
// 初始化时获取音频时长
getAudioDuration();
// 样式计算属性
const audioContainerClasses = computed(() => {
if (props.isSent) {
return 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg';
} else {
return 'bg-gray-50 dark:bg-gray-700 border border-gray-100 dark:border-gray-600 shadow';
}
});
const playIconBgClasses = computed(() => {
if (props.isSent) {
return 'bg-white/20';
} else {
return 'bg-blue-500';
}
});
const waveBarClasses = computed(() => {
if (props.isSent) {
return ['bg-white/80', {'animate-wave': isPlaying.value}];
} else {
return ['bg-blue-500', {'animate-wave': isPlaying.value}];
}
});
const progressTrackClasses = computed(() => {
if (props.isSent) {
return 'bg-white/30';
} else {
return 'bg-gray-200 dark:bg-gray-600';
}
});
const progressBarClasses = computed(() => {
if (props.isSent) {
return 'bg-white';
} else {
return 'bg-blue-500';
}
});
const durationTextClasses = computed(() => {
if (props.isSent) {
return 'text-white/90';
} else {
return 'text-gray-700 dark:text-gray-300';
}
});
const timeTextClasses = computed(() => {
if (props.isSent) {
return 'text-white/80';
} else {
return 'text-gray-600 dark:text-gray-400';
}
});
// 生成高级波形高度
const getWaveHeight = (index) => {
const baseHeight = 4;
const maxHeight = 20;
// 使用更平滑的正弦波叠加
const wavePattern =
Math.sin((index * 0.4) + (Date.now() * 0.008)) * 0.4 +
Math.cos((index * 0.7) + (Date.now() * 0.01)) * 0.3;
const normalized = Math.max(0, wavePattern * 0.5 + 0.5);
return baseHeight + (maxHeight - baseHeight) * normalized;
};
const togglePlay = () => {
if (!audio.value) {
audio.value = new Audio(props.message.content);
// 确保获取音频时长
audio.value.addEventListener('loadedmetadata', () => {
if (audio.value.duration && audio.value.duration > 0) {
audioDuration.value = Math.round(audio.value.duration);
}
});
audio.value.addEventListener('timeupdate', () => {
if (audio.value.duration) {
currentTime.value = Math.round(audio.value.currentTime);
progress.value = (currentTime.value / audioDuration.value) * 100;
}
});
audio.value.addEventListener('ended', () => {
isPlaying.value = false;
progress.value = 0;
currentTime.value = 0;
});
}
if (isPlaying.value) {
audio.value.pause();
isPlaying.value = false;
} else {
audio.value.play();
isPlaying.value = true;
}
};
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
onUnmounted(() => {
if (audio.value) {
audio.value.pause();
audio.value = null;
}
});
</script>
<style scoped>
@keyframes wave {
0%, 100% {
transform: scaleY(0.7);
opacity: 0.7;
}
25% {
transform: scaleY(1.2);
opacity: 1;
}
50% {
transform: scaleY(0.8);
opacity: 0.8;
}
75% {
transform: scaleY(1.1);
opacity: 0.9;
}
}
.animate-wave {
animation: wave 1.2s ease-in-out infinite;
transform-origin: center bottom;
}
</style>

View File

@@ -0,0 +1,495 @@
<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.nick_name.charAt(0) }}
</div>
<div class="caller-details">
<h3 class="caller-name">{{ callerInfo.nick_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.ts'
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

@@ -0,0 +1,18 @@
<template>
<div class="flex flex-col h-full">
<!-- 聊天头部 -->
<ChatHeader/>
<!-- 消息区域 -->
<MessageList class="flex-1"/>
<!-- 输入区域 -->
<MessageInput/>
</div>
</template>
<script setup>
import ChatHeader from './ChatHeader.vue';
import MessageList from './MessageList.vue';
import MessageInput from './MessageInput.vue';
</script>

View File

@@ -0,0 +1,434 @@
<template>
<div class="chat-header" :class="{ 'dark': themeStore.isDarkMode }">
<div class="header-content">
<div class="friend-info">
<div
class="friend-avatar"
:style="{ background: chatStore.currentFriend?.color }"
>
{{ chatStore.currentFriend?.nick_name.charAt(0) }}
</div>
<div class="friend-details">
<h3 class="friend-name">{{ chatStore.currentFriend?.nick_name }}</h3>
<p class="friend-status">
<span
class="status-dot"
:class="isFriendOnline ? 'online' : 'offline'"
></span>
<span class="status-text">
{{ isFriendOnline ? '在线' : '离线' }}
</span>
<span v-if="callStatus" class="call-status-text">
{{ callStatusText }}
</span>
</p>
</div>
</div>
<div class="header-actions">
<!-- 语音通话按钮 -->
<button
class="action-btn voice-call-btn"
@click="startVoiceCall"
title="语音通话"
>
<i class="fas fa-phone"></i>
</button>
<!-- 视频通话按钮 -->
<button
class="action-btn video-call-btn"
@click="startVideoCall"
title="视频通话"
>
<i class="fas fa-video"></i>
</button>
<!-- 更多操作 -->
<button
class="action-btn more-btn"
@click="showMoreActions = !showMoreActions"
title="更多操作"
>
<i class="fas fa-ellipsis-v"></i>
</button>
<!-- 更多操作菜单 -->
<div v-if="showMoreActions" class="more-actions-menu">
<div class="menu-item" @click="clearChatHistory">
<i class="fas fa-trash"></i>
<span>清空聊天记录</span>
</div>
<div class="menu-item" @click="viewFriendProfile">
<i class="fas fa-user"></i>
<span>查看资料</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed} from 'vue';
import {useChatStore} from '../stores/chat.ts';
import {useThemeStore} from '../stores/theme.ts';
const chatStore = useChatStore();
const themeStore = useThemeStore();
const isFriendOnline = ref(true);
const showMoreActions = ref(false);
// 获取当前通话状态
const callStatus = computed(() => {
if (!chatStore.currentFriend) return null;
// 检查是否正在与当前好友通话
if (chatStore.currentCall && chatStore.currentCall.peerId === chatStore.currentFriend.id) {
return chatStore.callConnectionStatus || '通话中';
}
return null;
});
// 获取通话状态文本
const callStatusText = computed(() => {
if (!callStatus.value) return '';
switch (callStatus.value) {
case '等待对方接听...':
return '📞 呼叫中...';
case '通话中':
return '📞 通话中';
case '对方已拒绝':
return '❌ 已拒绝';
case '对方无人接听':
return '🕒 未接听';
case '对方忙线中':
return '🚫 忙线中';
default:
return '📞 ' + callStatus.value;
}
});
// 点击外部关闭菜单
const handleClickOutside = (e) => {
if (!e.target.closest('.more-btn') && !e.target.closest('.more-actions-menu')) {
showMoreActions.value = false;
}
};
document.addEventListener('click', handleClickOutside);
// 开始语音通话
const startVoiceCall = () => {
if (!chatStore.currentFriend) {
alert('请先选择一个好友');
return;
}
chatStore.startCall(chatStore.currentFriend.id, 'audio');
};
// 开始视频通话
const startVideoCall = () => {
if (!chatStore.currentFriend) {
alert('请先选择一个好友');
return;
}
chatStore.startCall(chatStore.currentFriend.id, 'video');
};
// 清空聊天记录
const clearChatHistory = () => {
if (confirm('确定要清空与该好友的聊天记录吗?')) {
chatStore.messages = [];
showMoreActions.value = false;
console.log('聊天记录已清空');
}
};
// 查看好友资料
const viewFriendProfile = () => {
showMoreActions.value = false;
console.log('查看好友资料:', chatStore.currentFriend);
};
</script>
<style scoped>
.chat-header {
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
padding: 16px 24px;
position: relative;
}
.chat-header.dark {
background: #2d2d2d;
border-bottom-color: #374151;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.friend-info {
display: flex;
align-items: center;
gap: 12px;
}
.friend-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.friend-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.friend-name {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0;
}
.chat-header.dark .friend-name {
color: #f7fafc;
}
.friend-status {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 14px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.online {
background: #10b981;
}
.status-dot.offline {
background: #ef4444;
}
.status-text {
color: #64748b;
}
.chat-header.dark .status-text {
color: #9ca3af;
}
.status-text.online {
color: #10b981;
}
.status-text.offline {
color: #ef4444;
}
/* 添加通话状态样式 */
.call-status-text {
margin-left: 8px;
font-size: 13px;
color: #3b82f6;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.chat-header.dark .call-status-text {
color: #93c5fd;
background: rgba(59, 130, 246, 0.2);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
.action-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
position: relative;
}
.voice-call-btn {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.voice-call-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
}
.video-call-btn {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.video-call-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.more-btn {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.more-btn:hover {
background: #e2e8f0;
color: #475569;
}
.chat-header.dark .more-btn {
background: #374151;
color: #9ca3af;
border-color: #4b5563;
}
.chat-header.dark .more-btn:hover {
background: #4b5563;
color: #d1d5db;
}
.more-actions-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 8px 0;
min-width: 160px;
z-index: 1000;
margin-top: 8px;
}
.chat-header.dark .more-actions-menu {
background: #374151;
border-color: #4b5563;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #374151;
font-size: 14px;
}
.menu-item:hover {
background: #f8fafc;
}
.chat-header.dark .menu-item {
color: #d1d5db;
}
.chat-header.dark .menu-item:hover {
background: #4b5563;
}
.menu-item i {
width: 16px;
font-size: 14px;
color: #64748b;
}
.chat-header.dark .menu-item i {
color: #9ca3af;
}
@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) {
.chat-header {
padding: 12px 16px;
}
.friend-avatar {
width: 40px;
height: 40px;
font-size: 16px;
}
.friend-name {
font-size: 16px;
}
.action-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
.header-actions {
gap: 8px;
}
.call-status-text {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
import {
DisconnectOutlined,
LoadingOutlined,
WifiOutlined,
} from '@ant-design/icons-vue';
import { useChatStore } from '../stores/chat.ts';
const chatStore = useChatStore();
const statusClass = computed(() => {
switch (chatStore.connectionStatus) {
case 'connected': {
return 'bg-green-500 text-white';
}
case 'connecting': {
return 'bg-yellow-500 text-white';
}
case 'disconnected': {
return 'bg-red-500 text-white';
}
default: {
return 'bg-gray-500 text-white';
}
}
});
const iconComponent = computed(() => {
switch (chatStore.connectionStatus) {
case 'connected': {
return WifiOutlined;
}
case 'connecting': {
return LoadingOutlined;
}
case 'disconnected': {
return DisconnectOutlined;
}
default: {
return WifiOutlined;
}
}
});
</script>
<template>
<div
:class="statusClass"
class="fixed left-5 bottom-5 z-50 flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all"
>
<component
:is="iconComponent"
:class="{ 'animate-spin': chatStore.connectionStatus === 'connecting' }"
/>
{{ chatStore.connectionStatusText }}
</div>
</template>

View File

@@ -0,0 +1,245 @@
<template>
<div class="custom-input-container"
:class="{ 'dark': isDark, 'focused': isFocused, 'disabled': disabled }">
<div class="input-prefix" v-if="prefixIcon || $slots.prefix">
<slot name="prefix">
<i :class="prefixIcon" v-if="prefixIcon"></i>
</slot>
</div>
<input
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
class="custom-input"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<div class="input-suffix" v-if="suffixIcon || $slots.suffix || clearable">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="suffix">
<i :class="suffixIcon" v-if="suffixIcon"></i>
</slot>
</div>
</div>
</template>
<script setup>
import {ref, computed} from 'vue';
import {useThemeStore} from '../stores/theme.ts';
const themeStore = useThemeStore();
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
prefixIcon: {
type: String,
default: ''
},
suffixIcon: {
type: String,
default: ''
},
maxlength: {
type: Number,
default: undefined
}
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'clear', 'keydown']);
const inputRef = ref(null);
const isFocused = ref(false);
const isDark = computed(() => themeStore.isDarkMode);
const handleInput = (event) => {
emit('update:modelValue', event.target.value);
};
const handleFocus = (event) => {
isFocused.value = true;
emit('focus', event);
};
const handleBlur = (event) => {
isFocused.value = false;
emit('blur', event);
};
const handleClear = () => {
emit('update:modelValue', '');
emit('clear');
inputRef.value?.focus();
};
const handleKeydown = (event) => {
emit('keydown', event);
};
const focus = () => {
inputRef.value?.focus();
};
const blur = () => {
inputRef.value?.blur();
};
defineExpose({
focus,
blur
});
</script>
<style scoped>
.custom-input-container {
position: relative;
display: flex;
align-items: center;
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.custom-input-container:hover {
border-color: #cbd5e1;
}
.custom-input-container.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.1);
}
.custom-input-container.disabled {
background: #f8fafc;
border-color: #e2e8f0;
cursor: not-allowed;
}
.custom-input-container.dark {
background: #374151;
border-color: #4b5563;
}
.custom-input-container.dark:hover {
border-color: #6b7280;
}
.custom-input-container.dark.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.2);
}
.custom-input-container.dark.disabled {
background: #1f2937;
border-color: #374151;
}
.input-prefix,
.input-suffix {
display: flex;
align-items: center;
padding: 0 12px;
color: #64748b;
font-size: 14px;
}
.custom-input-container.dark .input-prefix,
.custom-input-container.dark .input-suffix {
color: #9ca3af;
}
.custom-input {
flex: 1;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
font-size: 14px;
color: #1a202c;
line-height: 1.5;
}
.custom-input::placeholder {
color: #94a3b8;
}
.custom-input:disabled {
cursor: not-allowed;
color: #94a3b8;
}
.custom-input-container.dark .custom-input {
color: #f7fafc;
}
.custom-input-container.dark .custom-input::placeholder {
color: #6b7280;
}
.custom-input-container.dark .custom-input:disabled {
color: #6b7280;
}
.clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: #64748b;
background: #f1f5f9;
}
.custom-input-container.dark .clear-btn:hover {
color: #d1d5db;
background: #4b5563;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div v-if="visible" class="modal-overlay" @click="handleOverlayClick">
<div
class="modal-container"
:class="[sizeClass, { 'dark': themeStore.isDarkMode }]"
@click.stop
>
<!-- 模态框头部 -->
<div class="modal-header">
<slot name="header">
<h2 class="modal-title">{{ title }}</h2>
</slot>
<button class="close-btn" @click="$emit('close')" v-if="closable">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 模态框内容 -->
<div class="modal-body">
<slot></slot>
</div>
<!-- 模态框底部 -->
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script setup>
import {computed, onMounted, onUnmounted} from 'vue';
import {useThemeStore} from '../stores/theme.ts';
const themeStore = useThemeStore();
const props = defineProps({
visible: {
type: Boolean,
default: true
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large', 'full'].includes(value)
},
closable: {
type: Boolean,
default: true
},
maskClosable: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['close']);
const sizeClass = computed(() => {
return `modal-${props.size}`;
});
const handleOverlayClick = () => {
if (props.maskClosable) {
emit('close');
}
};
const handleEscKey = (event) => {
if (event.key === 'Escape' && props.closable) {
emit('close');
}
};
onMounted(() => {
document.addEventListener('keydown', handleEscKey);
document.body.style.overflow = 'hidden';
});
onUnmounted(() => {
document.removeEventListener('keydown', handleEscKey);
document.body.style.overflow = '';
});
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
padding: 20px;
}
.modal-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
overflow: hidden;
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-container.dark {
background: #2d2d2d;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
/* 尺寸变体 */
.modal-small {
width: 100%;
max-width: 400px;
}
.modal-medium {
width: 100%;
max-width: 600px;
}
.modal-large {
width: 100%;
max-width: 800px;
}
.modal-full {
width: 95vw;
height: 95vh;
max-width: none;
max-height: none;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 30px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
flex-shrink: 0;
}
.modal-container.dark .modal-header {
border-bottom-color: #4b5563;
}
.modal-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
color: white;
font-size: 14px;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.modal-body {
padding: 24px 30px;
overflow-y: auto;
flex: 1;
color: #374151;
}
.modal-container.dark .modal-body {
color: #d1d5db;
}
.modal-footer {
padding: 20px 30px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
display: flex;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
.modal-container.dark .modal-footer {
background: #374151;
border-top-color: #4b5563;
}
/* 按钮样式 */
:deep(.modal-btn) {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
:deep(.modal-btn-cancel) {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
:deep(.modal-btn-cancel:hover) {
background: #e2e8f0;
color: #475569;
}
:deep(.modal-btn-confirm) {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
:deep(.modal-btn-confirm:hover) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.modal-container.dark :deep(.modal-btn-cancel) {
background: #4b5563;
color: #d1d5db;
border-color: #6b7280;
}
.modal-container.dark :deep(.modal-btn-cancel:hover) {
background: #6b7280;
color: #f3f4f6;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.modal-overlay {
padding: 10px;
}
.modal-small,
.modal-medium,
.modal-large {
width: 100%;
max-width: none;
}
.modal-full {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.modal-header,
.modal-body,
.modal-footer {
padding-left: 20px;
padding-right: 20px;
}
}
</style>

View File

@@ -0,0 +1,311 @@
<template>
<div
class="custom-textarea-container"
:class="{ 'dark': isDark, 'focused': isFocused, 'disabled': disabled }"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<textarea
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:rows="rows"
class="custom-textarea"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<div class="textarea-actions" v-if="clearable || $slots.actions">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup>
import {ref, computed, nextTick, watch} from 'vue';
import {useThemeStore} from '../stores/theme.ts';
const themeStore = useThemeStore();
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
maxlength: {
type: Number,
default: undefined
},
rows: {
type: Number,
default: 3
},
autoResize: {
type: Boolean,
default: false
},
maxRows: {
type: Number,
default: 6
},
detectPaste: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'clear', 'keydown', 'paste-file']);
const textareaRef = ref(null);
const isFocused = ref(false);
const isDark = computed(() => themeStore.isDarkMode);
const handleInput = (event) => {
emit('update:modelValue', event.target.value);
if (props.autoResize) {
autoResize();
}
};
const handleFocus = (event) => {
isFocused.value = true;
emit('focus', event);
};
const handleBlur = (event) => {
isFocused.value = false;
emit('blur', event);
};
const handleClear = () => {
emit('update:modelValue', '');
emit('clear');
textareaRef.value?.focus();
if (props.autoResize) {
autoResize();
}
};
const handleKeydown = (event) => {
emit('keydown', event);
};
const handlePaste = (event) => {
if (!props.detectPaste) return;
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
event.preventDefault();
const file = item.getAsFile();
if (file) {
emit('paste-file', file);
}
break;
}
}
};
const handleDrop = (event) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
emit('paste-file', files[0]);
}
};
const handleDragOver = (event) => {
event.preventDefault();
};
const handleDragEnter = (event) => {
event.preventDefault();
};
const handleDragLeave = (event) => {
event.preventDefault();
};
const autoResize = () => {
nextTick(() => {
if (!textareaRef.value) return;
const textarea = textareaRef.value;
textarea.style.height = 'auto';
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
const maxHeight = lineHeight * props.maxRows;
const scrollHeight = textarea.scrollHeight;
textarea.style.height = Math.min(scrollHeight, maxHeight) + 'px';
});
};
const focus = () => {
textareaRef.value?.focus();
};
const blur = () => {
textareaRef.value?.blur();
};
// 监听内容变化自动调整高度
watch(() => props.modelValue, () => {
if (props.autoResize) {
autoResize();
}
});
defineExpose({
focus,
blur
});
</script>
<style scoped>
.custom-textarea-container {
position: relative;
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.custom-textarea-container:hover {
border-color: #cbd5e1;
}
.custom-textarea-container.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.1);
}
.custom-textarea-container.disabled {
background: #f8fafc;
border-color: #e2e8f0;
cursor: not-allowed;
}
.custom-textarea-container.dark {
background: #374151;
border-color: #4b5563;
}
.custom-textarea-container.dark:hover {
border-color: #6b7280;
}
.custom-textarea-container.dark.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.2);
}
.custom-textarea-container.dark.disabled {
background: #1f2937;
border-color: #374151;
}
.custom-textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
font-size: 14px;
color: #1a202c;
line-height: 1.5;
resize: none;
font-family: inherit;
}
.custom-textarea::placeholder {
color: #94a3b8;
}
.custom-textarea:disabled {
cursor: not-allowed;
color: #94a3b8;
}
.custom-textarea-container.dark .custom-textarea {
color: #f7fafc;
}
.custom-textarea-container.dark .custom-textarea::placeholder {
color: #6b7280;
}
.custom-textarea-container.dark .custom-textarea:disabled {
color: #6b7280;
}
.textarea-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 8px;
}
.clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: #64748b;
background: #f1f5f9;
}
.custom-textarea-container.dark .clear-btn:hover {
color: #d1d5db;
background: #4b5563;
}
</style>

View File

@@ -0,0 +1,81 @@
<script setup>
import { ref } from 'vue';
defineEmits(['select']);
const emojiList = ref([
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'☹️',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
]);
</script>
<template>
<div
class="emoji-picker-container max-w-xs rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-600 dark:bg-gray-700"
>
<div class="grid max-w-xs grid-cols-8 gap-2">
<div
v-for="emoji in emojiList"
:key="emoji"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg transition-all hover:scale-110 hover:bg-gray-100 dark:hover:bg-gray-600"
@click="$emit('select', emoji)"
>
{{ emoji }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,198 @@
<script setup>
import { onMounted, ref } from 'vue';
const isDark = ref(false);
// 检查系统偏好或本地存储
onMounted(() => {
const savedPreference = localStorage.getItem('darkMode');
const systemPreference = window.matchMedia(
'(prefers-color-scheme: dark)',
).matches;
isDark.value =
savedPreference === null ? systemPreference : savedPreference === 'true';
updateDarkClass();
});
// 切换模式
function toggleDarkMode() {
isDark.value = !isDark.value;
localStorage.setItem('darkMode', isDark.value);
updateDarkClass();
}
// 更新HTML class
function updateDarkClass() {
if (isDark.value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
</script>
<template>
<div
class="flex h-full flex-col items-center justify-center bg-white dark:bg-[#1a1a1a]"
>
<!-- 模式切换按钮 -->
<!-- <div class="absolute top-4 right-4">-->
<!-- <button @click="toggleDarkMode" class="p-2 rounded-full bg-gray-200 dark:bg-gray-700">-->
<!-- <svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300"-->
<!-- viewBox="0 0 20 20" fill="currentColor">-->
<!-- <path fill-rule="evenodd"-->
<!-- d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"-->
<!-- clip-rule="evenodd"/>-->
<!-- </svg>-->
<!-- <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700"-->
<!-- viewBox="0 0 20 20" fill="currentColor">-->
<!-- <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>-->
<!-- </svg>-->
<!-- </button>-->
<!-- </div>-->
<!-- SVG 插画 -->
<div class="mb-8">
<svg
fill="none"
height="200"
viewBox="0 0 200 200"
width="200"
xmlns="http://www.w3.org/2000/svg"
>
<!-- 聊天气泡 -->
<ellipse
class="fill-blue-100 stroke-blue-500 dark:fill-blue-900 dark:stroke-blue-300"
cx="70"
cy="80"
rx="35"
ry="25"
stroke-width="2"
/>
<ellipse
class="fill-purple-100 stroke-purple-500 dark:fill-purple-900 dark:stroke-purple-300"
cx="130"
cy="120"
rx="35"
ry="25"
stroke-width="2"
/>
<!-- 消息线条 -->
<line
class="stroke-blue-500 dark:stroke-blue-300"
stroke-linecap="round"
stroke-width="2"
x1="45"
x2="55"
y1="85"
y2="85"
/>
<line
class="stroke-blue-500 dark:stroke-blue-300"
stroke-linecap="round"
stroke-width="2"
x1="45"
x2="65"
y1="90"
y2="90"
/>
<line
class="stroke-blue-500 dark:stroke-blue-300"
stroke-linecap="round"
stroke-width="2"
x1="45"
x2="60"
y1="95"
y2="95"
/>
<line
class="stroke-purple-500 dark:stroke-purple-300"
stroke-linecap="round"
stroke-width="2"
x1="110"
x2="125"
y1="115"
y2="115"
/>
<line
class="stroke-purple-500 dark:stroke-purple-300"
stroke-linecap="round"
stroke-width="2"
x1="110"
x2="140"
y1="120"
y2="120"
/>
<line
class="stroke-purple-500 dark:stroke-purple-300"
stroke-linecap="round"
stroke-width="2"
x1="110"
x2="135"
y1="125"
y2="125"
/>
<!-- 装饰性元素 -->
<circle
class="fill-orange-500 dark:fill-orange-300"
cx="50"
cy="50"
r="3"
/>
<circle
class="fill-green-500 dark:fill-green-300"
cx="150"
cy="60"
r="3"
/>
<circle
class="fill-pink-500 dark:fill-pink-300"
cx="40"
cy="140"
r="3"
/>
<circle
class="fill-blue-500 dark:fill-blue-300"
cx="160"
cy="150"
r="3"
/>
<!-- 连接线 -->
<path
class="stroke-gray-300 dark:stroke-gray-500"
d="M70 105 Q100 110 130 95"
fill="none"
stroke-dasharray="5,5"
stroke-width="2"
/>
</svg>
</div>
<!-- 文字提示 -->
<div class="text-center">
<h3 class="mb-2 text-xl font-semibold text-gray-700 dark:text-gray-200">
开始聊天吧
</h3>
<p class="max-w-md text-gray-600 dark:text-gray-400">
从左侧选择一个联系人开始对话享受愉快的聊天体验
</p>
</div>
<!-- 装饰性动画元素 -->
<div
class="absolute left-20 top-20 h-4 w-4 animate-bounce rounded-full bg-blue-100 opacity-80 dark:bg-blue-800"
></div>
<div
class="absolute bottom-20 right-20 h-6 w-6 animate-pulse rounded-full bg-purple-100 opacity-80 dark:bg-purple-800"
></div>
<div
class="absolute right-40 top-40 h-3 w-3 animate-ping rounded-full bg-green-100 opacity-80 dark:bg-green-800"
></div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<template>
<div
class="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<div class="flex items-center gap-3">
<!-- 文件缩略图 -->
<div
class="w-16 h-16 rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<img
v-if="uploadPreview.type === 'image'"
:src="uploadPreview.url"
alt="预览"
class="w-full h-full object-cover"
/>
<video
v-else-if="uploadPreview.type === 'video'"
:src="uploadPreview.url"
class="w-full h-full object-cover"
preload="metadata"
/>
<FileOutlined v-else class="text-2xl text-gray-400"/>
</div>
<!-- 文件信息 -->
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 dark:text-gray-200 truncate">{{
uploadPreview.nick_name
}}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{
formatFileSize(uploadPreview.size)
}}
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<a-button type="primary" size="small" @click="sendFile">
<template #icon>
<SendOutlined/>
</template>
发送
</a-button>
<a-button size="small" @click="cancelUpload">
<template #icon>
<CloseOutlined/>
</template>
取消
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import {inject} from 'vue';
import {FileOutlined, SendOutlined, CloseOutlined} from '@ant-design/icons-vue';
const uploadPreview = inject('uploadPreview');
const sendFile = inject('sendFile');
const cancelUpload = inject('cancelUpload');
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>

View File

@@ -0,0 +1,353 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { Avatar } from 'ant-design-vue';
import { useChatStore } from '../stores/chat.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore } from '../stores/user.ts';
import CustomInput from './CustomInput.vue';
import FriendsManagement from './FriendsManagement.vue';
import GroupsManagement from './GroupsManagement.vue';
import MomentsView from './MomentsView.vue';
import VirtualList from './VirtualList.vue';
const props = defineProps({
currentView: {
type: String,
default: 'chat',
},
});
const chatStore = useChatStore();
const userStore = useUserStore();
const themeStore = useThemeStore();
const searchQuery = ref('');
const filteredFriends = computed(() => {
if (!searchQuery.value) return chatStore.friends;
const query = searchQuery.value.toLowerCase();
return chatStore.friends.filter(
(friend) =>
friend.nick_name.toLowerCase().includes(query) ||
friend.id.toString().includes(query),
);
});
const getListTitle = () => {
const titles = {
chat: '聊天',
friends: '好友',
groups: '群聊',
moments: '圈子',
};
return titles[props.currentView] || '聊天';
};
const selectFriend = (friend) => {
chatStore.setCurrentFriend(friend);
// 清除未读消息并加载聊天记录
chatStore.switchFriend(friend, userStore.currentUser.id);
};
// 监听当前视图变化,清空搜索
watch(
() => props.currentView,
() => {
searchQuery.value = '';
},
);
</script>
<template>
<div :class="{ dark: themeStore.isDarkMode }" class="friend-list">
<div class="list-header">
<h2 class="list-title">
{{ getListTitle() }}
<button
:title="themeStore.isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
class="theme-toggle"
@click="themeStore.toggleTheme"
>
<i
:class="themeStore.isDarkMode ? 'fa-sun' : 'fa-moon'"
class="fas"
></i>
</button>
</h2>
<div class="search-container">
<CustomInput
v-model="searchQuery"
class="search-input"
placeholder="搜索联系人..."
prefix-icon="fas fa-search"
/>
</div>
</div>
<div class="friends-container">
<VirtualList
v-if="currentView === 'chat'"
:item-height="80"
:items="filteredFriends"
class="friends-list"
>
<template #default="{ item: friend }">
<div
:class="{ active: chatStore.currentFriend?.id === friend.id }"
class="friend-item"
@click="selectFriend(friend)"
>
<div :style="{ background: friend.color }" class="friend-avatar">
<Avatar v-if="friend.avatar" :src="friend.avatar" />
<Avatar v-else>{{ friend.nick_name.charAt(0) }}</Avatar>
</div>
<div class="friend-info">
<div class="friend-name">{{ friend.nick_name }}</div>
<div class="friend-message">
{{ friend.lastMessage || '点击开始聊天' }}
</div>
</div>
<div class="friend-meta">
<div v-if="friend.lastMessageTime" class="message-time">
{{ friend.lastMessageTime }}
</div>
<div v-if="friend.unreadCount > 0" class="unread-badge">
{{ friend.unreadCount > 99 ? '99+' : friend.unreadCount }}
</div>
</div>
</div>
</template>
</VirtualList>
<FriendsManagement v-else-if="currentView === 'friends'" />
<GroupsManagement v-else-if="currentView === 'groups'" />
<MomentsView v-else-if="currentView === 'moments'" />
</div>
</div>
</template>
<style scoped>
.friend-list {
width: 320px;
min-width: 320px;
height: 100vh;
background: #ffffff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.friend-list.dark {
background: #1a1a1a;
border-right-color: #374151;
}
.list-header {
padding: 24px 20px;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
flex-shrink: 0;
}
.friend-list.dark .list-header {
background: #1a1a1a;
border-bottom-color: #374151;
}
.list-title {
font-size: 24px;
font-weight: 700;
color: #1a202c;
margin: 0 0 20px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.friend-list.dark .list-title {
color: #f7fafc;
}
.theme-toggle {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.3s ease;
color: #64748b;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.theme-toggle:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: scale(1.05);
border-color: transparent;
}
.friend-list.dark .theme-toggle {
background: #374151;
border-color: #4b5563;
color: #d1d5db;
}
.search-container {
position: relative;
}
.search-input {
width: 100%;
}
.friends-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.friends-list {
height: 100%;
flex: 1;
}
.friend-item {
display: flex;
align-items: center;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f1f5f9;
position: relative;
}
.friend-item:hover {
background: #f8fafc;
}
.friend-item.active {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
border-left: 4px solid #4361ee;
}
.friend-list.dark .friend-item {
border-bottom-color: #374151;
}
.friend-list.dark .friend-item:hover {
background: #374151;
}
.friend-list.dark .friend-item.active {
background: linear-gradient(
135deg,
rgba(67, 97, 238, 0.2),
rgba(63, 55, 201, 0.1)
);
}
.friend-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
margin-right: 12px;
flex-shrink: 0;
}
.friend-info {
flex: 1;
min-width: 0;
margin-right: 12px;
}
.friend-name {
font-size: 16px;
font-weight: 600;
color: #1a202c;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-list.dark .friend-name {
color: #f7fafc;
}
.friend-message {
font-size: 14px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-list.dark .friend-message {
color: #9ca3af;
}
.friend-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.friend-list.dark .message-time {
color: #6b7280;
}
.unread-badge {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
}
/* 响应式设计 */
@media (max-width: 768px) {
.friend-list {
width: 100%;
min-width: unset;
}
.list-header {
padding: 20px 16px;
}
.friend-item {
padding: 14px 16px;
}
.friend-avatar {
width: 44px;
height: 44px;
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div class="friends-management" :class="{ 'dark': themeStore.isDarkMode }">
<div class="management-content">
<h1 class="page-title">好友管理</h1>
<!-- 搜索和筛选 -->
<div class="search-filter-section">
<CustomInput
v-model="searchQuery"
placeholder="搜索好友..."
prefix-icon="fas fa-search"
clearable
class="search-input"
/>
<select class="filter-select">
<option value="all">全部好友</option>
<option value="online">在线好友</option>
<option value="offline">离线好友</option>
</select>
</div>
<!-- 好友网格 -->
<div class="friends-grid">
<div
v-for="friend in filteredFriends"
:key="friend.id"
class="friend-card"
>
<div class="card-content">
<div
class="friend-avatar"
:style="{ background: friend.color }"
>
{{ friend.nick_name.charAt(0) }}
</div>
<h3 class="friend-name">{{ friend.nick_name }}</h3>
<p class="friend-id">ID: {{ friend.id }}</p>
<div class="friend-actions">
<button class="action-btn primary-btn" @click="startChat(friend)">
聊天
</button>
<button class="action-btn secondary-btn">
详情
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed} from 'vue';
import {useChatStore} from '../stores/chat.ts';
import {useUserStore} from '../stores/user.ts';
import {useThemeStore} from '../stores/theme.ts';
import CustomInput from './CustomInput.vue';
const chatStore = useChatStore();
const userStore = useUserStore();
const themeStore = useThemeStore();
const searchQuery = ref('');
const filteredFriends = computed(() => {
if (!searchQuery.value) return chatStore.friends;
const query = searchQuery.value.toLowerCase();
return chatStore.friends.filter(friend =>
friend.nick_name.toLowerCase().includes(query) ||
friend.id.toString().includes(query)
);
});
const startChat = (friend) => {
chatStore.setCurrentFriend(friend);
chatStore.switchFriend(friend, userStore.currentUser.id);
};
</script>
<style scoped>
.friends-management {
height: 100%;
background: #f8fafc;
overflow-y: auto;
}
.friends-management.dark {
background: #111827;
}
.management-content {
padding: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a202c;
margin-bottom: 24px;
}
.friends-management.dark .page-title {
color: #f7fafc;
}
.search-filter-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.search-input {
flex: 1;
}
.filter-select {
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: white;
color: #1a202c;
font-size: 14px;
min-width: 140px;
cursor: pointer;
}
.friends-management.dark .filter-select {
background: #374151;
border-color: #4b5563;
color: #f7fafc;
}
.friends-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.friend-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
}
.friend-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.friends-management.dark .friend-card {
background: #374151;
border-color: #4b5563;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.friends-management.dark .friend-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.card-content {
padding: 24px;
text-align: center;
}
.friend-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.friend-name {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0 0 8px 0;
}
.friends-management.dark .friend-name {
color: #f7fafc;
}
.friend-id {
font-size: 14px;
color: #64748b;
margin: 0 0 20px 0;
}
.friends-management.dark .friend-id {
color: #9ca3af;
}
.friend-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.action-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.primary-btn {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.secondary-btn {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.secondary-btn:hover {
background: #e2e8f0;
color: #475569;
}
.friends-management.dark .secondary-btn {
background: #4b5563;
color: #d1d5db;
border-color: #6b7280;
}
.friends-management.dark .secondary-btn:hover {
background: #6b7280;
color: #f3f4f6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.management-content {
padding: 16px;
}
.search-filter-section {
flex-direction: column;
}
.friends-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="groups-management" :class="{ 'dark': themeStore.isDarkMode }">
<div class="management-content">
<div class="header-section">
<h1 class="page-title">群聊管理</h1>
<button class="create-btn">
<i class="fas fa-plus"></i>
创建群聊
</button>
</div>
<!-- 群聊列表 -->
<div class="groups-grid">
<div
v-for="group in mockGroups"
:key="group.id"
class="group-card"
>
<div class="card-content">
<div class="group-header">
<div class="group-avatar">
{{ group.nick_name.charAt(0) }}
</div>
<div class="group-info">
<h3 class="group-name">{{ group.nick_name }}</h3>
<p class="group-members">{{ group.memberCount }} </p>
</div>
</div>
<p class="group-description">{{ group.description }}</p>
<div class="group-actions">
<button class="action-btn primary-btn">
进入群聊
</button>
<button class="action-btn secondary-btn">
设置
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useThemeStore} from '../stores/theme.ts';
const themeStore = useThemeStore();
const mockGroups = ref([
{
id: 1,
name: '技术交流群',
memberCount: 128,
description: '分享技术心得,讨论前沿技术'
},
{
id: 2,
name: '项目讨论组',
memberCount: 45,
description: '项目进度讨论和问题解决'
},
{
id: 3,
name: '休闲聊天室',
memberCount: 89,
description: '轻松愉快的日常交流'
}
]);
</script>
<style scoped>
.groups-management {
height: 100%;
background: #f8fafc;
overflow-y: auto;
}
.groups-management.dark {
background: #111827;
}
.management-content {
padding: 24px;
}
.header-section {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a202c;
margin: 0;
}
.groups-management.dark .page-title {
color: #f7fafc;
}
.create-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.create-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(67, 97, 238, 0.3);
}
.groups-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.group-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
}
.group-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.groups-management.dark .group-card {
background: #374151;
border-color: #4b5563;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.groups-management.dark .group-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.card-content {
padding: 24px;
}
.group-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.group-avatar {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
flex-shrink: 0;
}
.group-info {
flex: 1;
}
.group-name {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0 0 4px 0;
}
.groups-management.dark .group-name {
color: #f7fafc;
}
.group-members {
font-size: 14px;
color: #64748b;
margin: 0;
}
.groups-management.dark .group-members {
color: #9ca3af;
}
.group-description {
font-size: 14px;
color: #64748b;
margin: 0 0 20px 0;
line-height: 1.5;
}
.groups-management.dark .group-description {
color: #9ca3af;
}
.group-actions {
display: flex;
gap: 12px;
}
.action-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.primary-btn {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.secondary-btn {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.secondary-btn:hover {
background: #e2e8f0;
color: #475569;
}
.groups-management.dark .secondary-btn {
background: #4b5563;
color: #d1d5db;
border-color: #6b7280;
}
.groups-management.dark .secondary-btn:hover {
background: #6b7280;
color: #f3f4f6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.management-content {
padding: 16px;
}
.header-section {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.groups-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,637 @@
<template>
<!-- 图片预览组件 -->
<!-- <div
v-if="showImagePreview"
class="media-preview-overlay"
:class="{ 'dark': themeStore.isDarkMode }"
@click.self="hidePreview"
>
<div class="preview-container">
<button class="preview-close-btn" @click="hidePreview">
<i class="fas fa-times"></i>
</button>
<div class="image-preview-content">
<img
:src="mediaData"
:alt="'图片预览'"
class="preview-image"
@load="handleImageLoad"
/>
</div>
<div class="preview-actions">
<button class="action-btn" @click="downloadMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="action-btn" @click="shareMedia" title="分享">
<i class="fas fa-share"></i>
</button>
</div>
</div>
</div>-->
<Image
:visible="showImagePreview"
:imgs="[{ src: mediaData }]"
@hide="hidePreview"
/>
<!-- 视频预览组件 -->
<div
v-if="showVideoPreview"
class="media-preview-overlay video-preview"
:class="{ 'dark': themeStore.isDarkMode }"
@click.self="hidePreview"
>
<div class="video-preview-container" :class="{ 'portrait': isPortrait }">
<button class="preview-close-btn" @click="hidePreview">
<i class="fas fa-times"></i>
</button>
<div class="video-content">
<video
ref="videoPlayer"
:src="mediaData"
class="preview-video"
controls
autoplay
@loadedmetadata="handleVideoLoad"
@play="handleVideoPlay"
@pause="handleVideoPause"
/>
<!-- 自定义视频控制栏 -->
<div class="video-controls" v-if="showControls">
<div class="controls-top">
<div class="video-title">视频预览</div>
<div class="video-info">
{{ formatDuration(currentTime) }} / {{ formatDuration(duration) }}
</div>
</div>
<div class="controls-bottom">
<button class="control-btn" @click="togglePlay">
<i class="fas" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
</button>
<div class="progress-container">
<div class="progress-bar" @click="seekTo">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
</div>
</div>
<button class="control-btn" @click="toggleMute">
<i class="fas" :class="isMuted ? 'fa-volume-mute' : 'fa-volume-up'"></i>
</button>
<div class="volume-container">
<input
type="range"
min="0"
max="1"
step="0.1"
v-model="volume"
class="volume-slider"
@input="updateVolume"
/>
</div>
<button class="control-btn" @click="toggleFullscreen">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
</div>
<div class="video-actions">
<button class="action-btn" @click="downloadMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="action-btn" @click="shareMedia" title="分享">
<i class="fas fa-share"></i>
</button>
<button class="action-btn" @click="togglePictureInPicture" title="画中画">
<i class="fas fa-external-link-alt"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed, watch, onMounted, onUnmounted} from 'vue';
import {useMediaPreview} from "../composables/useMediaPreview.ts";
import { Image } from 'ant-design-vue';
// import VueEasyLightbox from 'vue-easy-lightbox';
import {useThemeStore} from '../stores/theme.ts';
const themeStore = useThemeStore();
const {visible, mediaData, hidePreview, previewType} = useMediaPreview();
// 视频相关状态
const videoPlayer = ref(null);
const isPlaying = ref(false);
const isMuted = ref(false);
const volume = ref(1);
const currentTime = ref(0);
const duration = ref(0);
const showControls = ref(true);
const isPortrait = ref(false);
const controlsTimeout = ref(null);
// 计算属性
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;
return (currentTime.value / duration.value) * 100;
});
// 视频加载完成
const handleVideoLoad = () => {
if (videoPlayer.value) {
duration.value = videoPlayer.value.duration;
// 检测视频方向
const video = videoPlayer.value;
isPortrait.value = video.videoHeight > video.videoWidth;
// 监听时间更新
video.addEventListener('timeupdate', updateTime);
}
};
// 图片加载完成
const handleImageLoad = () => {
// 可以在这里添加图片加载完成的逻辑
};
// 更新播放时间
const updateTime = () => {
if (videoPlayer.value) {
currentTime.value = videoPlayer.value.currentTime;
}
};
// 视频播放/暂停事件
const handleVideoPlay = () => {
isPlaying.value = true;
hideControlsAfterDelay();
};
const handleVideoPause = () => {
isPlaying.value = false;
showControls.value = true;
clearTimeout(controlsTimeout.value);
};
// 控制功能
const togglePlay = () => {
if (videoPlayer.value) {
if (isPlaying.value) {
videoPlayer.value.pause();
} else {
videoPlayer.value.play();
}
}
};
const toggleMute = () => {
if (videoPlayer.value) {
videoPlayer.value.muted = !videoPlayer.value.muted;
isMuted.value = videoPlayer.value.muted;
}
};
const updateVolume = () => {
if (videoPlayer.value) {
videoPlayer.value.volume = volume.value;
isMuted.value = volume.value === 0;
}
};
const seekTo = (event) => {
if (videoPlayer.value) {
const rect = event.target.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
videoPlayer.value.currentTime = percent * duration.value;
}
};
const toggleFullscreen = () => {
if (videoPlayer.value) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoPlayer.value.requestFullscreen();
}
}
};
const togglePictureInPicture = async () => {
if (videoPlayer.value) {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoPlayer.value.requestPictureInPicture();
}
} catch (error) {
console.error('画中画功能不支持:', error);
}
}
};
// 隐藏控制栏
const hideControlsAfterDelay = () => {
clearTimeout(controlsTimeout.value);
controlsTimeout.value = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false;
}
}, 3000);
};
// 显示控制栏
const showControlsTemporarily = () => {
showControls.value = true;
if (isPlaying.value) {
hideControlsAfterDelay();
}
};
// 格式化时间
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// 下载媒体
const downloadMedia = () => {
if (mediaData.value?.url) {
const link = document.createElement('a');
link.href = mediaData.value.url;
link.download = `media_${Date.now()}`;
link.click();
}
};
// 分享媒体
const shareMedia = async () => {
if (navigator.share && mediaData.value?.url) {
try {
await navigator.share({
title: '分享媒体',
url: mediaData.value.url
});
} catch (error) {
console.log('分享取消或失败');
}
} else {
// 复制链接到剪贴板
navigator.clipboard.writeText(mediaData.value?.url || '');
alert('链接已复制到剪贴板');
}
};
// 键盘事件处理
const handleKeydown = (event) => {
if (!visible.value) return;
switch (event.code) {
case 'Escape':
hidePreview();
break;
case 'Space':
if (showVideoPreview.value) {
event.preventDefault();
togglePlay();
}
break;
case 'ArrowLeft':
if (showVideoPreview.value && videoPlayer.value) {
event.preventDefault();
videoPlayer.value.currentTime -= 10;
}
break;
case 'ArrowRight':
if (showVideoPreview.value && videoPlayer.value) {
event.preventDefault();
videoPlayer.value.currentTime += 10;
}
break;
}
};
// 监听媒体数据变化
watch(mediaData, () => {
if (previewType.value === 'video') {
isPlaying.value = false;
currentTime.value = 0;
duration.value = 0;
showControls.value = true;
}
});
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(controlsTimeout.value);
});
</script>
<style scoped>
.media-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.3s ease;
}
.media-preview-overlay.dark {
background: rgba(0, 0, 0, 0.95);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
}
.video-preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.video-preview-container.portrait {
max-width: 60vw;
}
.preview-close-btn {
position: absolute;
top: -50px;
right: 0;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s ease;
z-index: 10;
}
.preview-close-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.image-preview-content {
position: relative;
}
.preview-image {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.video-content {
position: relative;
width: 100%;
height: 100%;
}
.preview-video {
width: 100%;
height: 100%;
max-height: 80vh;
object-fit: contain;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: white;
padding: 20px;
transition: all 0.3s ease;
}
.controls-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.video-title {
font-size: 16px;
font-weight: 600;
}
.video-info {
font-size: 14px;
opacity: 0.8;
}
.controls-bottom {
display: flex;
align-items: center;
gap: 16px;
}
.control-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.progress-container {
flex: 1;
margin: 0 16px;
}
.progress-bar {
position: relative;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
}
.progress-fill {
height: 100%;
background: #4361ee;
border-radius: 3px;
transition: width 0.1s ease;
}
.progress-thumb {
position: absolute;
top: -4px;
width: 14px;
height: 14px;
background: #4361ee;
border-radius: 50%;
transform: translateX(-50%);
transition: left 0.1s ease;
}
.volume-container {
width: 80px;
}
.volume-slider {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #4361ee;
border-radius: 50%;
cursor: pointer;
}
.preview-actions,
.video-actions {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 12px;
}
.action-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.preview-container,
.video-preview-container {
max-width: 95vw;
max-height: 95vh;
}
.video-preview-container.portrait {
max-width: 95vw;
}
.preview-close-btn {
top: -40px;
width: 36px;
height: 36px;
font-size: 16px;
}
.video-controls {
padding: 16px;
}
.controls-bottom {
gap: 12px;
}
.control-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
.volume-container {
width: 60px;
}
.preview-actions,
.video-actions {
top: 16px;
right: 50px;
gap: 8px;
}
.action-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,464 @@
<template>
<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 cursor-pointer"
:style="avatarStyle"
@click="showUserProfile"
>
{{ senderInfo.avatar }}
</div>
<!-- 消息内容区域 -->
<div class="flex flex-col max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
:class="isSent ? 'items-end' : 'items-start'">
<!-- 发送者名称 -->
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 px-2">
{{ senderInfo.nick_name }}
</div>
<!-- 消息气泡 -->
<div
class="rounded-2xl p-4 shadow-sm relative"
:class="bubbleClasses"
>
<!-- 图片消息 -->
<div
v-if="message.type === 'image'"
class="cursor-pointer relative"
@click="handlePreviewMedia(message.content, 'image')"
>
<img
:src="message.content"
:alt="'图片消息'"
class="rounded-lg max-w-full"
@error="handleImageError"
/>
<div
class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 rounded-lg">
<i class="fas fa-search-plus text-white text-xl"></i>
</div>
</div>
<!-- 视频消息 -->
<div
v-else-if="message.type === 'video'"
class="cursor-pointer relative video-message"
@click="handlePreviewMedia(message.content, 'video')"
>
<video
:src="message.content"
class="rounded-lg max-w-full video-thumbnail"
preload="metadata"
@error="handleVideoError"
/>
<div class="video-overlay">
<div class="play-button">
<i class="fas fa-play"></i>
</div>
<div class="video-duration" v-if="message.duration">
{{ formatDuration(message.duration) }}
</div>
</div>
</div>
<!-- URL链接消息 -->
<div v-else-if="message.type === 'url'" class="url-message">
<div class="url-preview" @click="openUrl(message.url)">
<div class="url-favicon">
<img :src="message.favicon" :alt="message.domain" v-if="message.favicon"/>
<i class="fas fa-link" v-else></i>
</div>
<div class="url-content">
<div class="url-title">{{ message.title || message.url }}</div>
<div class="url-description" v-if="message.description">
{{ message.description }}
</div>
<div class="url-domain">{{ message.domain }}</div>
</div>
<div class="url-thumbnail" v-if="message.thumbnail">
<img :src="message.thumbnail" :alt="message.title"/>
</div>
</div>
<div class="url-text" v-if="message.text">
{{ message.text }}
</div>
</div>
<!-- 音频消息 -->
<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>
</div>
</div>
<!-- 消息时间和状态 -->
<div class="flex items-center gap-1 mt-1 text-xs text-gray-400 dark:text-gray-500 px-2">
<span>{{ message.time }}</span>
<i v-if="isSent && message.read" class="fas fa-check-double text-green-500"></i>
<i v-else-if="isSent && !message.read" class="fas fa-clock text-gray-400"></i>
</div>
</div>
</div>
</template>
<script setup>
import {computed} from 'vue';
import previewMedia from '../composables/useMediaPreview.ts';
import {useThemeStore} from '../stores/theme.ts';
import AudioMessage from './AudioMessage.vue';
const themeStore = useThemeStore();
const props = defineProps({
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
},
senderInfo: {
type: Object,
required: true
}
});
const emit = defineEmits(['show-user-profile']);
const avatarStyle = computed(() => ({
background: props.senderInfo.color
}));
const bubbleClasses = computed(() => {
const baseClasses = ['message-bubble'];
if (props.isSent) {
baseClasses.push('sent');
} else {
baseClasses.push('received');
}
if (themeStore.isDarkMode) {
baseClasses.push('dark');
}
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;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatTextWithLinks = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>');
};
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>
.message-bubble {
position: relative;
max-width: 100%;
word-wrap: break-word;
transition: all 0.2s ease;
}
.message-bubble.sent {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
border-bottom-right-radius: 4px;
}
.message-bubble.received {
background: white;
color: #1a202c;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
}
.message-bubble.received.dark {
background: #374151;
color: #f7fafc;
border-color: #4b5563;
}
.video-message {
position: relative;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
}
.video-thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.video-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-message:hover .video-overlay {
opacity: 1;
}
.play-button {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
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;
}
.url-preview {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.url-preview:hover {
border-color: #4361ee;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.15);
}
.message-bubble.sent .url-preview {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.message-bubble.received.dark .url-preview {
border-color: #4b5563;
background: #4b5563;
}
.url-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.url-favicon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 2px;
}
.url-content {
flex: 1;
min-width: 0;
}
.url-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #1a202c;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-title {
color: white;
}
.message-bubble.received.dark .url-title {
color: #f7fafc;
}
.url-description {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-description {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received.dark .url-description {
color: #9ca3af;
}
.url-domain {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.message-bubble.sent .url-domain {
color: rgba(255, 255, 255, 0.7);
}
.message-bubble.received.dark .url-domain {
color: #6b7280;
}
.url-thumbnail {
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.url-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.url-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
:deep(.text-link) {
color: #4361ee;
text-decoration: underline;
transition: color 0.2s ease;
}
:deep(.text-link:hover) {
color: #3f37c9;
}
.message-bubble.sent :deep(.text-link) {
color: rgba(255, 255, 255, 0.9);
}
.message-bubble.sent :deep(.text-link:hover) {
color: white;
}
</style>

View File

@@ -0,0 +1,543 @@
<template>
<div class="message-input-area" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 文件上传预览 -->
<FileUploadPreview v-if="uploadPreview"/>
<!-- 表情选择器 -->
<EmojiPicker class="mb-10" v-if="showEmojiPicker" @select="insertEmoji"/>
<div class="input-container">
<!-- 功能按钮区域 -->
<div class="function-buttons">
<button
class="function-btn"
@click="triggerFileInput('image')"
title="发送图片"
>
<i class="fas fa-image"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn"
@click="triggerFileInput('video')"
title="发送视频"
>
<i class="fas fa-video"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn"
:class="{ 'active': showEmojiPicker }"
@click="toggleEmojiPicker"
title="选择表情"
>
<i class="fas fa-smile"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn record-btn"
:class="{ 'recording': isRecording }"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchstart="startRecording"
@touchend="stopRecording"
title="按住录音"
>
<i class="fas fa-microphone"></i>
<div class="btn-ripple"></div>
<div class="recording-wave" v-if="isRecording"></div>
</button>
<!-- 录音状态指示器 -->
<!-- <RecordingIndicator />-->
</div>
<!-- 输入框区域 -->
<div class="input-section">
<CustomTextarea
ref="messageInputRef"
v-model="messageText"
placeholder="输入消息... (支持拖拽文件)"
:auto-resize="true"
:max-rows="4"
:detect-paste="true"
class="message-textarea"
@keydown="handleKeydown"
@paste-file="handlePasteFile"
/>
<button
class="send-btn"
:disabled="!messageText.trim() && !uploadPreview"
@click="sendTextMessage"
title="发送消息"
>
<i class="fas fa-paper-plane"></i>
<div class="send-ripple"></div>
</button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="handleFileUpload"
/>
<input
ref="videoInput"
type="file"
accept="video/*"
class="hidden"
@change="handleFileUpload"
/>
</div>
</template>
<script setup>
import {ref, nextTick, provide, onMounted, onUnmounted} from 'vue';
import {useUserStore} from '../stores/user.ts';
import {useChatStore} from '../stores/chat.ts';
import {useThemeStore} from '../stores/theme.ts';
import {useFileUpload} from '../composables/useFileUpload.ts';
import {useRecording} from '../composables/useRecording.ts';
import {sendMessage} from '../utils/request.ts';
import {message} from 'ant-design-vue';
import CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
import RecordingIndicator from "../components/RecordingIndicator.vue";
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const messageText = ref('');
const messageInputRef = ref(null);
const showEmojiPicker = ref(false);
// 使用文件上传组合式函数
const {
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload,
cancelUpload
} = useFileUpload();
// 使用录音组合式函数
const {isRecording, startRecording, stopRecording} = useRecording();
// 发送上传的文件
const sendUploadedFile = () => {
if (!uploadPreview.value) return;
const messageObj = {
type: uploadPreview.value.type,
content: uploadPreview.value.url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
read: true,
duration: 0
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
type: uploadPreview.value.type,
content: uploadPreview.value.url
}).catch(error => {
console.error('发送文件失败:', error);
message.error('文件发送失败');
});
uploadPreview.value = null;
};
// 提供给子组件
provide('uploadPreview', uploadPreview);
provide('cancelUpload', cancelUpload);
provide('sendFile', sendUploadedFile);
// 发送文本消息
const sendTextMessage = async () => {
const content = messageText.value.trim();
if (!content && !uploadPreview.value) return;
let messageContent = content;
let messageType = 'text';
// 如果有文件预览,发送文件
if (uploadPreview.value) {
messageContent = uploadPreview.value.url;
messageType = uploadPreview.value.type;
uploadPreview.value = null;
}
// 创建消息对象
const messageObj = {
type: messageType,
content: messageContent,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: userStore.currentUser.id,
read: true,
duration: 0
};
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
// 添加到本地消息列表
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
try {
await sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
type: messageType,
content: messageContent
});
} catch (error) {
console.error('发送消息失败:', error);
message.error('消息发送失败');
}
// 清空输入框
messageText.value = '';
// 重置焦点
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 处理键盘事件
const handleKeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendTextMessage();
}
};
// 处理粘贴文件
const handlePasteFile = (file) => {
console.log('检测到粘贴文件:', file);
// 根据文件类型自动选择处理方式
if (file.type.startsWith('image/')) {
handleFileFromPaste(file, 'image');
} else if (file.type.startsWith('video/')) {
handleFileFromPaste(file, 'video');
} else {
message.warning('不支持的文件类型');
}
};
// 处理粘贴的文件
const handleFileFromPaste = (file, type) => {
// 文件大小检查
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024;
message.error(`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
uploadPreview.value = {
type: type,
url: e.target.result,
name: file.nick_name,
size: file.size,
file: file
};
};
reader.onerror = (error) => {
console.error('文件读取失败:', error);
message.error('文件读取失败,请重试');
};
reader.readAsDataURL(file);
};
// 切换表情选择器
const toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
};
// 插入表情
const insertEmoji = (emoji) => {
messageText.value += emoji;
showEmojiPicker.value = false;
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 监听录音完成事件
const handleAudioRecorded = (event) => {
const {url, duration} = event.detail;
const messageObj = {
type: 'audio',
content: url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: userStore.currentUser.id,
read: true,
duration: duration
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
type: 'audio',
content: url
}).catch(error => {
console.error('发送音频失败:', error);
message.error('音频发送失败');
});
};
onMounted(() => {
window.addEventListener('audioRecorded', handleAudioRecorded);
// 点击外部关闭表情选择器
document.addEventListener('click', (event) => {
if (!event.target.closest('.emoji-picker') && !event.target.closest('.function-btn')) {
showEmojiPicker.value = false;
}
});
});
onUnmounted(() => {
window.removeEventListener('audioRecorded', handleAudioRecorded);
});
</script>
<style scoped>
.message-input-area {
padding: 20px;
border-top: 1px solid #e2e8f0;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
position: relative;
}
.message-input-area.dark {
border-top-color: #374151;
background: linear-gradient(135deg, #2d2d2d 0%, #1f2937 100%);
}
.input-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.function-buttons {
display: flex;
gap: 12px;
align-items: center;
}
.function-btn {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.function-btn.active {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: scale(1.1);
}
.function-btn.recording {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
animation: pulse-recording 1s infinite;
}
@keyframes pulse-recording {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.recording-wave {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
animation: wave-expand 1.5s infinite;
}
@keyframes wave-expand {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
.btn-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.function-btn:active .btn-ripple {
width: 100px;
height: 100px;
}
.message-input-area.dark .function-btn {
background: linear-gradient(135deg, #374151, #4b5563);
color: #9ca3af;
}
.message-input-area.dark .function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.input-section {
display: flex;
align-items: end;
gap: 12px;
position: relative;
}
.message-textarea {
flex: 1;
}
.send-btn {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.send-btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
}
.send-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.send-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.send-btn:active:not(:disabled) .send-ripple {
width: 80px;
height: 80px;
}
.hidden {
display: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.function-buttons {
gap: 8px;
}
.function-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.send-btn {
width: 48px;
height: 48px;
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div
class="inline-block max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
:class="isSent ? 'ml-auto' : 'mr-auto'"
>
<div
class="rounded-2xl p-4 shadow-sm"
:class="[
isSent
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-br-sm'
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-sm'
]"
>
<!-- 图片消息 - 已修复 -->
<div
v-if="message.type === 'image'"
class="cursor-pointer relative"
@click="previewMedia(message.content, 'image')"
>
<a-image
:src="message.content"
:alt="'图片消息'"
class="rounded-lg max-w-full"
:preview="false"
@error="handleImageError"
/>
<div
class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 bg-opacity-30 rounded-lg">
<!-- 已移除不必要的放大镜图标 -->
<SearchOutlined class="text-white text-xl"/>
</div>
</div>
<!-- 视频消息 - 已修复 -->
<div
v-else-if="message.type === 'video'"
class="cursor-pointer relative"
@click="previewMedia(message.content, 'video')"
>
<video
:src="message.content"
class="rounded-lg max-w-full max-h-64"
preload="metadata"
@error="handleVideoError"
/>
<div
class="absolute inset-0 flex items-center justify-center bg-black/50 bg-opacity-30 rounded-lg">
<PlayCircleOutlined class="text-white text-3xl"/>
</div>
</div>
<!-- 音频消息 -->
<AudioMessage v-else-if="message.type === 'audio'" :message="message"/>
<!-- 文本消息 -->
<div v-else class="text-base leading-relaxed">
{{ message.content }}
</div>
<!-- 消息时间和状态 -->
<div class="flex items-center justify-end gap-1 mt-2 text-xs opacity-70">
<span>{{ message.time }}</span>
<CheckOutlined v-if="isSent" class="text-green-400"/>
</div>
</div>
</div>
</template>
<script setup>
import {defineProps} from 'vue';
import {PlayCircleOutlined, CheckOutlined, SearchOutlined} from '@ant-design/icons-vue';
import {useMediaPreview} from '../composables/useMediaPreview.ts';
import AudioMessage from './AudioMessage.vue';
const {previewMedia} = useMediaPreview();
const handleImageError = (event) => {
console.error('图片加载失败:', event.target.src);
event.target.style.display = 'none';
};
const handleVideoError = (event) => {
console.error('视频加载失败:', event.target.src);
event.target.style.display = 'none';
};
defineProps({
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
}
});
</script>

View File

@@ -0,0 +1,87 @@
<template>
<div
ref="messagesContainer"
class="flex-1 p-5 overflow-y-auto bg-gray-50 dark:bg-gray-900 message-list-background"
>
<a-spin :spinning="loadingMessages" tip="加载消息中...">
<!-- 空状态 -->
<div v-if="!chatStore.messages.length && chatStore.currentFriend"
class="text-center text-gray-500 dark:text-gray-400 py-10">
<MessageOutlined class="text-4xl mb-2"/>
<p>开始与 {{ chatStore.currentFriend.nick_name }} 对话吧</p>
</div>
<!-- 消息列表 -->
<div v-if="!loadingMessages">
<div
v-for="(msg, index) in chatStore.messages"
:key="index"
class="mb-6 clear-both"
>
<MessageBubble
:message="msg"
:is-sent="msg.senderId === userStore.currentUser.id"
:sender-info="getSenderInfo(msg)"
/>
</div>
</div>
</a-spin>
</div>
</template>
<script setup>
import {ref, nextTick, watch, computed} from 'vue';
import {useUserStore} from '../stores/user.ts';
import {useChatStore} from '../stores/chat.ts';
import {MessageOutlined} from '@ant-design/icons-vue';
import MessageBubble from './MessageBubble.vue';
const userStore = useUserStore();
const chatStore = useChatStore();
const messagesContainer = ref(null);
const loadingMessages = ref(false);
// 获取发送者信息
const getSenderInfo = (message) => {
if (message.senderId === userStore.currentUser.id) {
return {
name: userStore.currentUser.nick_name,
avatar: userStore.currentUser.nick_name.charAt(0),
color: userStore.currentUser.color || '#4cc9f0'
}
} else {
return {
name: chatStore.currentFriend?.nick_name || '未知用户',
avatar: chatStore.currentFriend?.nick_name.charAt(0) || '?',
color: chatStore.currentFriend?.color || '#ff6b6b'
}
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
nextTick(() => {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
});
}
};
// 监听消息变化,自动滚动到底部
watch(() => chatStore.messages.length, () => {
scrollToBottom();
});
// 监听当前好友变化,重新加载消息
watch(() => chatStore.currentFriend, () => {
scrollToBottom();
});
</script>
<style>
.message-list-background {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" opacity="0.03"><rect width="100" height="100" fill="none"/><path d="M0,0 L100,100 M100,0 L0,100" stroke="currentColor"/></svg>');
background-size: 200px 200px;
}
</style>

View File

@@ -0,0 +1,446 @@
<template>
<div class="moments-view" :class="{ 'dark': themeStore.isDarkMode }">
<div class="moments-content">
<h1 class="page-title">朋友圈</h1>
<!-- 发布动态 -->
<div class="post-composer">
<div class="composer-header">
<div class="user-avatar" :style="{ background: userStore.currentUser?.color }">
{{ userStore.currentUser?.nick_name?.charAt(0) }}
</div>
<CustomTextarea
v-model="newPostContent"
placeholder="分享新鲜事..."
class="post-input"
:rows="3"
/>
</div>
<div class="composer-actions">
<div class="media-buttons">
<button class="media-btn">
<i class="fas fa-image"></i>
图片
</button>
<button class="media-btn">
<i class="fas fa-video"></i>
视频
</button>
</div>
<button class="publish-btn" :disabled="!newPostContent.trim()">
发布
</button>
</div>
</div>
<!-- 动态列表 -->
<div class="moments-list">
<div
v-for="moment in mockMoments"
:key="moment.id"
class="moment-card"
>
<div class="moment-header">
<div class="author-avatar" :style="{ background: moment.author.color }">
{{ moment.author.nick_name.charAt(0) }}
</div>
<div class="author-info">
<h4 class="author-name">{{ moment.author.nick_name }}</h4>
<p class="post-time">{{ moment.time }}</p>
</div>
</div>
<div class="moment-content">
<p class="moment-text">{{ moment.content }}</p>
<div v-if="moment.images" class="moment-images">
<img
v-for="(image, index) in moment.images"
:key="index"
:src="image"
:alt="`图片${index + 1}`"
class="moment-image"
/>
</div>
</div>
<div class="moment-actions">
<button class="action-btn like-btn">
<i class="fas fa-heart"></i>
{{ moment.likes }}
</button>
<button class="action-btn comment-btn">
<i class="fas fa-comment"></i>
{{ moment.comments }}
</button>
<button class="action-btn share-btn">
<i class="fas fa-share"></i>
分享
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useUserStore} from '../stores/user.ts';
import {useThemeStore} from '../stores/theme.ts';
import CustomTextarea from './CustomTextarea.vue';
const userStore = useUserStore();
const themeStore = useThemeStore();
const newPostContent = ref('');
const mockMoments = ref([
{
id: 1,
author: {
name: '张三',
color: '#ff6b6b'
},
content: '今天天气真不错,出去走走心情都变好了!',
time: '2小时前',
likes: 12,
comments: 3,
images: ['/placeholder.svg?height=200&width=200']
},
{
id: 2,
author: {
name: '李四',
color: '#4ecdc4'
},
content: '刚完成了一个新项目,感觉很有成就感!',
time: '5小时前',
likes: 8,
comments: 2
},
{
id: 3,
author: {
name: '王五',
color: '#45b7d1'
},
content: '分享一张美丽的日落照片',
time: '1天前',
likes: 25,
comments: 7,
images: ['/placeholder.svg?height=300&width=400']
}
]);
</script>
<style scoped>
.moments-view {
height: 100%;
background: #f8fafc;
overflow-y: auto;
}
.moments-view.dark {
background: #111827;
}
.moments-content {
padding: 24px;
max-width: 600px;
margin: 0 auto;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a202c;
margin-bottom: 24px;
text-align: center;
}
.moments-view.dark .page-title {
color: #f7fafc;
}
.post-composer {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
}
.moments-view.dark .post-composer {
background: #374151;
border-color: #4b5563;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.composer-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 16px;
flex-shrink: 0;
}
.post-input {
flex: 1;
}
.composer-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.media-buttons {
display: flex;
gap: 12px;
}
.media-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.media-btn:hover {
background: #e2e8f0;
color: #475569;
}
.moments-view.dark .media-btn {
background: #4b5563;
border-color: #6b7280;
color: #d1d5db;
}
.moments-view.dark .media-btn:hover {
background: #6b7280;
color: #f3f4f6;
}
.publish-btn {
padding: 8px 20px;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.publish-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.publish-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.moments-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.moment-card {
background: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
transition: all 0.3s ease;
}
.moment-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.moments-view.dark .moment-card {
background: #374151;
border-color: #4b5563;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.moments-view.dark .moment-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.moment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.author-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 16px;
}
.author-info {
flex: 1;
}
.author-name {
font-size: 16px;
font-weight: 600;
color: #1a202c;
margin: 0 0 4px 0;
}
.moments-view.dark .author-name {
color: #f7fafc;
}
.post-time {
font-size: 12px;
color: #64748b;
margin: 0;
}
.moments-view.dark .post-time {
color: #9ca3af;
}
.moment-content {
margin-bottom: 16px;
}
.moment-text {
font-size: 14px;
color: #374151;
line-height: 1.6;
margin: 0 0 12px 0;
}
.moments-view.dark .moment-text {
color: #d1d5db;
}
.moment-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
margin-top: 12px;
}
.moment-image {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.moment-image:hover {
transform: scale(1.02);
}
.moment-actions {
display: flex;
gap: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.moments-view.dark .moment-actions {
border-top-color: #4b5563;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: transparent;
border: none;
color: #64748b;
font-size: 14px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.action-btn:hover {
background: #f1f5f9;
color: #475569;
}
.moments-view.dark .action-btn {
color: #9ca3af;
}
.moments-view.dark .action-btn:hover {
background: #4b5563;
color: #d1d5db;
}
.like-btn:hover {
color: #ef4444;
}
.comment-btn:hover {
color: #3b82f6;
}
.share-btn:hover {
color: #10b981;
}
/* 响应式设计 */
@media (max-width: 768px) {
.moments-content {
padding: 16px;
}
.composer-actions {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.media-buttons {
justify-content: center;
}
.moment-images {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div
v-if="isRecording"
class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50"
>
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 flex items-center gap-4 shadow-xl">
<div class="w-4 h-4 bg-red-500 rounded-full animate-pulse"></div>
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">正在录音... 松开发送</span>
</div>
</div>
</template>
<script setup>
import {inject} from 'vue';
import {useRecording} from "../composables/useRecording.ts";
// 使用录音组合式函数
const {isRecording} = useRecording();
</script>

View File

@@ -0,0 +1,845 @@
<template>
<CustomModal
:visible="isVisible"
title="设置"
size="large"
@close="closeModal"
>
<div class="settings-content" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 设置导航 -->
<div class="settings-nav">
<div
v-for="section in settingSections"
:key="section.key"
class="nav-item"
:class="{ 'active': currentSection === section.key }"
@click="currentSection = section.key"
>
<i :class="section.icon"></i>
<span>{{ section.label }}</span>
</div>
</div>
<!-- 设置内容 -->
<div class="settings-main">
<!-- 通用设置 -->
<div v-if="currentSection === 'general'" class="setting-section">
<h3 class="section-title">通用设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<label>主题模式</label>
<span class="setting-desc">选择应用的外观主题</span>
</div>
<div class="setting-control">
<select v-model="settings.theme" @change="updateTheme">
<option value="light">亮色模式</option>
<option value="dark">暗色模式</option>
<option value="auto">跟随系统</option>
</select>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>语言设置</label>
<span class="setting-desc">选择应用显示语言</span>
</div>
<div class="setting-control">
<select v-model="settings.language">
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
<option value="ja-JP">日本語</option>
</select>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>字体大小</label>
<span class="setting-desc">调整聊天界面的字体大小</span>
</div>
<div class="setting-control">
<input
type="range"
v-model="settings.fontSize"
min="12"
max="20"
step="1"
class="slider"
/>
<span class="value-display">{{ settings.fontSize }}px</span>
</div>
</div>
</div>
</div>
<!-- 通知设置 -->
<div v-if="currentSection === 'notifications'" class="setting-section">
<h3 class="section-title">通知设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<label>桌面通知</label>
<span class="setting-desc">接收新消息的桌面通知</span>
</div>
<div class="setting-control">
<label class="switch">
<input
type="checkbox"
v-model="settings.desktopNotifications"
@change="updateNotificationPermission"
/>
<span class="slider-switch"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>声音提醒</label>
<span class="setting-desc">新消息时播放提示音</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="settings.soundNotifications"/>
<span class="slider-switch"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>通话通知</label>
<span class="setting-desc">接收语音和视频通话邀请</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="settings.callNotifications"/>
<span class="slider-switch"></span>
</label>
</div>
</div>
</div>
</div>
<!-- 隐私设置 -->
<div v-if="currentSection === 'privacy'" class="setting-section">
<h3 class="section-title">隐私设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<label>在线状态</label>
<span class="setting-desc">是否向其他用户显示在线状态</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="settings.showOnlineStatus"/>
<span class="slider-switch"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>已读回执</label>
<span class="setting-desc">发送消息已读状态给对方</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="settings.readReceipts"/>
<span class="slider-switch"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label>自动下载</label>
<span class="setting-desc">自动下载接收到的图片和文件</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="settings.autoDownload"/>
<span class="slider-switch"></span>
</label>
</div>
</div>
</div>
</div>
<!-- 关于 -->
<div v-if="currentSection === 'about'" class="setting-section">
<h3 class="section-title">关于应用</h3>
<div class="about-content">
<div class="app-info">
<div class="app-icon">
<i class="fas fa-comments"></i>
</div>
<div class="app-details">
<h4>WebSocket 聊天系统</h4>
<p>版本 1.0.0</p>
<p>基于 Vue 3 + WebSocket 构建的现代化聊天应用</p>
</div>
</div>
<div class="feature-list">
<h5>主要功能</h5>
<ul>
<li>实时消息传输</li>
<li>语音视频通话</li>
<li>文件传输</li>
<li>群聊功能</li>
<li>暗色模式</li>
<li>响应式设计</li>
</ul>
</div>
<div class="action-buttons">
<button class="btn secondary" @click="checkUpdate">
<i class="fas fa-sync-alt"></i>
检查更新
</button>
<button class="btn secondary" @click="clearCache">
<i class="fas fa-trash"></i>
清除缓存
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作 -->
<template #footer>
<div class="modal-footer">
<button class="btn secondary" @click="resetSettings">
重置设置
</button>
<div class="footer-actions">
<button class="btn secondary" @click="closeModal">
取消
</button>
<button class="btn primary" @click="saveSettings">
保存设置
</button>
</div>
</div>
</template>
</CustomModal>
</template>
<script setup>
import {ref, reactive, watch, onMounted} from 'vue'
import {useThemeStore} from '../stores/theme.ts'
import CustomModal from './CustomModal.vue'
const themeStore = useThemeStore()
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
const isVisible = ref(false)
const currentSection = ref('general')
const settingSections = [
{key: 'general', label: '通用', icon: 'fas fa-cog'},
{key: 'notifications', label: '通知', icon: 'fas fa-bell'},
{key: 'privacy', label: '隐私', icon: 'fas fa-shield-alt'},
{key: 'about', label: '关于', icon: 'fas fa-info-circle'}
]
const settings = reactive({
theme: 'light',
language: 'zh-CN',
fontSize: 14,
desktopNotifications: true,
soundNotifications: true,
callNotifications: true,
showOnlineStatus: true,
readReceipts: true,
autoDownload: true
})
const loadSettings = () => {
try {
const savedSettings = localStorage.getItem('chat-settings')
if (savedSettings) {
Object.assign(settings, JSON.parse(savedSettings))
}
// 同步主题设置
settings.theme = themeStore.isDarkMode ? 'dark' : 'light'
} catch (error) {
console.error('加载设置失败:', error)
}
}
const saveSettings = () => {
try {
localStorage.setItem('chat-settings', JSON.stringify(settings))
// 应用设置
applySettings()
// 显示保存成功提示
showNotification('设置已保存', 'success')
closeModal()
} catch (error) {
console.error('保存设置失败:', error)
showNotification('保存设置失败', 'error')
}
}
const applySettings = () => {
// 应用主题
// if (settings.theme === 'dark') {
// themeStore.toggleTheme(true)
// } else if (settings.theme === 'light') {
// themeStore.setDarkMode(false)
// } else {
// // 跟随系统
// const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
// themeStore.setDarkMode(prefersDark)
// }
//
// // 应用字体大小
// document.documentElement.style.setProperty('--base-font-size', `${settings.fontSize}px`)
}
const resetSettings = () => {
if (confirm('确定要重置所有设置吗?')) {
Object.assign(settings, {
theme: 'light',
language: 'zh-CN',
fontSize: 14,
desktopNotifications: true,
soundNotifications: true,
callNotifications: true,
showOnlineStatus: true,
readReceipts: true,
autoDownload: true
})
showNotification('设置已重置', 'info')
}
}
const updateTheme = () => {
applySettings()
}
const updateNotificationPermission = async () => {
if (settings.desktopNotifications && 'Notification' in window) {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
settings.desktopNotifications = false
showNotification('通知权限被拒绝', 'warning')
}
}
}
}
const checkUpdate = () => {
showNotification('当前已是最新版本', 'info')
}
const clearCache = () => {
if (confirm('确定要清除所有缓存数据吗?')) {
try {
// 清除localStorage中的聊天数据
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && (key.startsWith('chat-') || key.startsWith('message-'))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
showNotification('缓存已清除', 'success')
} catch (error) {
console.error('清除缓存失败:', error)
showNotification('清除缓存失败', 'error')
}
}
}
const showNotification = (message, type = 'info') => {
// 这里应该调用全局通知组件
console.log(`[${type.toUpperCase()}] ${message}`)
}
const closeModal = () => {
isVisible.value = false
emit('close')
}
watch(() => props.visible, (newValue) => {
isVisible.value = newValue
if (newValue) {
loadSettings()
}
})
onMounted(() => {
loadSettings()
applySettings()
})
</script>
<style scoped>
.settings-content {
display: flex;
height: 500px;
overflow: hidden;
}
.settings-content.dark {
color: #f7fafc;
}
.settings-nav {
width: 200px;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
padding: 16px 0;
overflow-y: auto;
}
.settings-content.dark .settings-nav {
background: #374151;
border-right-color: #4b5563;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 14px;
}
.nav-item:hover {
background: #e2e8f0;
color: #374151;
}
.nav-item.active {
background: #4361ee;
color: white;
}
.settings-content.dark .nav-item {
color: #9ca3af;
}
.settings-content.dark .nav-item:hover {
background: #4b5563;
color: #d1d5db;
}
.settings-main {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #1a202c;
margin: 0 0 24px 0;
padding-bottom: 12px;
border-bottom: 2px solid #e2e8f0;
}
.settings-content.dark .section-title {
color: #f7fafc;
border-bottom-color: #374151;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 20px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
transition: all 0.2s ease;
}
.setting-item:hover {
border-color: #4361ee;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.1);
}
.settings-content.dark .setting-item {
background: #4b5563;
border-color: #6b7280;
}
.settings-content.dark .setting-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.setting-info {
flex: 1;
}
.setting-info label {
display: block;
font-size: 16px;
font-weight: 500;
color: #1a202c;
margin-bottom: 4px;
}
.settings-content.dark .setting-info label {
color: #f7fafc;
}
.setting-desc {
font-size: 14px;
color: #64748b;
}
.settings-content.dark .setting-desc {
color: #9ca3af;
}
.setting-control {
display: flex;
align-items: center;
gap: 12px;
}
.setting-control select {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
color: #1a202c;
font-size: 14px;
min-width: 120px;
}
.settings-content.dark .setting-control select {
background: #374151;
border-color: #4b5563;
color: #f7fafc;
}
.slider {
width: 120px;
height: 6px;
border-radius: 3px;
background: #e2e8f0;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #4361ee;
cursor: pointer;
}
.value-display {
font-size: 14px;
font-weight: 500;
color: #4361ee;
min-width: 40px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider-switch {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #e2e8f0;
transition: 0.3s;
border-radius: 24px;
}
.slider-switch:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider-switch {
background-color: #4361ee;
}
input:checked + .slider-switch:before {
transform: translateX(26px);
}
.about-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.app-info {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.settings-content.dark .app-info {
background: #4b5563;
border-color: #6b7280;
}
.app-icon {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #4361ee, #3f37c9);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.app-details h4 {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0 0 8px 0;
}
.settings-content.dark .app-details h4 {
color: #f7fafc;
}
.app-details p {
font-size: 14px;
color: #64748b;
margin: 0 0 4px 0;
}
.settings-content.dark .app-details p {
color: #9ca3af;
}
.feature-list {
padding: 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.settings-content.dark .feature-list {
background: #4b5563;
border-color: #6b7280;
}
.feature-list h5 {
font-size: 16px;
font-weight: 600;
color: #1a202c;
margin: 0 0 12px 0;
}
.settings-content.dark .feature-list h5 {
color: #f7fafc;
}
.feature-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.feature-list li {
padding: 6px 0;
color: #64748b;
font-size: 14px;
position: relative;
padding-left: 20px;
}
.feature-list li:before {
content: "✓";
position: absolute;
left: 0;
color: #10b981;
font-weight: bold;
}
.settings-content.dark .feature-list li {
color: #9ca3af;
}
.action-buttons {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn.primary {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.btn.secondary {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.btn.secondary:hover {
background: #e2e8f0;
color: #475569;
}
.settings-content.dark .btn.secondary {
background: #4b5563;
color: #d1d5db;
border-color: #6b7280;
}
.settings-content.dark .btn.secondary:hover {
background: #6b7280;
color: #f3f4f6;
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.settings-content.dark .modal-footer {
border-top-color: #374151;
background: #374151;
}
.footer-actions {
display: flex;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.settings-content {
flex-direction: column;
height: auto;
max-height: 70vh;
}
.settings-nav {
width: 100%;
display: flex;
overflow-x: auto;
padding: 8px 0;
}
.nav-item {
flex-shrink: 0;
padding: 8px 16px;
white-space: nowrap;
}
.settings-main {
padding: 16px;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.setting-control {
width: 100%;
justify-content: flex-end;
}
.modal-footer {
flex-direction: column;
gap: 12px;
}
.footer-actions {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,387 @@
<script setup>
import { computed, ref } from 'vue';
import { useChatStore } from '../stores/chat.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore } from '../stores/user.ts';
import SettingsModal from './SettingsModal.vue';
import UserProfileModal from './UserProfileModal.vue';
const props = defineProps({
currentView: {
type: String,
default: 'chat',
},
});
const emit = defineEmits(['view-change']);
const themeStore = useThemeStore();
const userStore = useUserStore();
const chatStore = useChatStore();
const showSettingsModal = ref(false);
const showProfileModal = ref(false);
const navItems = computed(() => [
{
key: 'chat',
label: '聊天',
icon: 'fas fa-comment-dots',
badge: getUnreadCount(),
},
{
key: 'friends',
label: '好友',
icon: 'fas fa-user-friends',
badge: null,
},
{
key: 'groups',
label: '群聊',
icon: 'fas fa-users',
badge: null,
},
{
key: 'moments',
label: '圈子',
icon: 'fas fa-globe',
badge: null,
},
]);
const getUnreadCount = () => {
const totalUnread = chatStore.friends.reduce((total, friend) => {
return total + (friend.unreadCount || 0);
}, 0);
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>
<template>
<div :class="{ dark: themeStore.isDarkMode }" class="side-navigation">
<div class="nav-header">
<!-- <div class="logo">-->
<!-- <i class="fas fa-comments"></i>-->
<!-- </div>-->
<!-- <div-->
<!-- class="nav-item profile-item"-->
<!-- @click="showProfile"-->
<!-- title="个人资料"-->
<!-- >-->
<!-- <div class="nav-icon user-avatar" :style="{ background: userStore.currentUser?.color }">-->
<!-- {{ userStore.currentUser.nick_name.charAt(0) }}-->
<!-- </div>-->
<!-- &lt;!&ndash; <div class="nav-label">我的</div>&ndash;&gt;-->
<!-- </div>-->
</div>
<nav class="nav-menu">
<div
v-for="item in navItems"
:key="item.key"
:class="{ active: currentView === item.key }"
:title="item.label"
class="nav-item"
@click="switchView(item.key)"
>
<div class="nav-icon">
<i :class="item.icon"></i>
</div>
<div class="nav-label">
{{ item.label }}
</div>
<div v-if="item.badge" class="nav-badge">
{{ item.badge }}
</div>
</div>
</nav>
<div class="nav-footer">
<div class="nav-item settings-item" title="设置" @click="showSettings">
<div class="nav-icon">
<i class="fas fa-cog"></i>
</div>
<div class="nav-label">设置</div>
</div>
</div>
<!-- 设置模态框 -->
<SettingsModal
:visible="showSettingsModal"
@close="showSettingsModal = false"
/>
<!-- 用户资料模态框 -->
<UserProfileModal
:user-info="userStore.currentUser"
:visible="showProfileModal"
@close="showProfileModal = false"
/>
</div>
</template>
<style scoped>
.side-navigation {
width: 80px;
height: 100vh;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
position: relative;
transition: all 0.3s ease;
}
.side-navigation.dark {
background: linear-gradient(180deg, #1a1a1a 0%, #2d2d2d 100%);
border-right-color: #374151;
}
.nav-header {
margin-bottom: 24px;
}
.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-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;
margin-bottom: 2px;
}
.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;
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;
}
.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);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.side-navigation {
width: 100%;
height: 60px;
flex-direction: row;
justify-content: space-around;
padding: 8px 16px;
position: fixed;
bottom: 0;
left: 0;
z-index: 1000;
}
.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 {
width: 44px;
height: 44px;
border-radius: 12px;
}
.nav-icon {
font-size: 16px;
}
.nav-label {
font-size: 9px;
}
}
</style>

View File

@@ -0,0 +1,506 @@
<template>
<CustomModal
:visible="isVisible"
title="用户资料"
size="medium"
@close="closeModal"
>
<div class="user-profile-content" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 用户头像和基本信息 -->
<div class="profile-header">
<div class="user-avatar" :style="{ background: userInfo.color }">
{{ userInfo.nick_name?.charAt(0) }}
</div>
<div class="user-basic-info">
<h2 class="user-name">{{ userInfo.nick_name }}</h2>
<p class="user-id">ID: {{ userInfo.id }}</p>
<div class="user-status">
<span class="status-dot" :class="isOnline ? 'online' : 'offline'"></span>
<span class="status-text">{{ isOnline ? '在线' : '离线' }}</span>
</div>
</div>
</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.nick_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="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="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>
</CustomModal>
</template>
<script setup>
import {ref, computed, watch} from 'vue'
import {useThemeStore} from '../stores/theme.ts'
import {useChatStore} from '../stores/chat.ts'
import {useUserStore} from '../stores/user.ts'
import CustomModal from './CustomModal.vue'
const themeStore = useThemeStore()
const chatStore = useChatStore()
const userStore = useUserStore()
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>
.user-profile-content {
padding: 0;
}
.user-profile-content.dark {
color: #f7fafc;
}
.profile-header {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.user-profile-content.dark .profile-header {
border-bottom-color: #374151;
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
}
.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;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
flex-shrink: 0;
}
.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;
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;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.online {
background: #10b981;
}
.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: 24px;
font-weight: 700;
color: #4361ee;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.user-profile-content.dark .stat-label {
color: #9ca3af;
}
.profile-actions {
padding: 24px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.user-profile-content.dark .profile-actions {
border-top-color: #374151;
}
.action-btn {
flex: 1;
min-width: 120px;
padding: 12px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.primary-btn {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.primary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
items: {
type: Array,
required: true,
},
itemHeight: {
type: Number,
default: 80,
},
containerHeight: {
type: Number,
default: 400,
},
});
const containerRef = ref(null);
const scrollTop = ref(0);
// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight);
// 计算可见区域的起始索引
const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight),
);
// 计算可见区域的结束索引
const endIndex = computed(() => {
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
return Math.min(startIndex.value + visibleCount + 1, props.items.length - 1);
});
// 计算可见的项目
const visibleItems = computed(() => {
return props.items
.slice(startIndex.value, endIndex.value + 1)
.map((item, index) => ({
...item,
index: startIndex.value + index,
}));
});
// 计算偏移量
const offsetY = computed(() => startIndex.value * props.itemHeight);
// 处理滚动事件
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// 监听容器大小变化
const resizeObserver = ref(null);
onMounted(() => {
if (containerRef.value) {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect;
if (height !== props.containerHeight) {
// 可以在这里触发高度变化事件
}
}
});
resizeObserver.value.observe(containerRef.value);
}
});
onUnmounted(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
});
</script>
<template>
<div
ref="containerRef"
:style="{ height: `${containerHeight}px` }"
class="virtual-list-container"
@scroll="handleScroll"
>
<div
:style="{ height: `${totalHeight}px` }"
class="virtual-list-phantom"
></div>
<div
:style="{ transform: `translateY(${offsetY}px)` }"
class="virtual-list-content"
>
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ height: `${itemHeight}px` }"
class="virtual-list-item"
>
<slot :index="item.index" :item="item"></slot>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-list-container {
position: relative;
overflow-y: auto;
}
.virtual-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-list-item {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,115 @@
import {nextTick, ref} from 'vue';
import {message} from 'ant-design-vue';
export function useFileUpload() {
const uploadPreview = ref(null);
const imageInput = ref(null);
const videoInput = ref(null);
const currentFileType = ref('');
const triggerFileInput = (type) => {
currentFileType.value = type;
nextTick(() => {
if (type === 'image' && imageInput.value) {
imageInput.value.value = '';
imageInput.value.click();
} else if (type === 'video' && videoInput.value) {
videoInput.value.value = '';
videoInput.value.click();
}
});
};
const handleFileUpload = (event) => {
if (!event.target.files || event.target.files.length === 0) return;
const file = event.target.files[0];
// 确定文件类型
let detectedType = currentFileType.value;
if (event.target === imageInput.value) {
detectedType = 'image';
} else if (event.target === videoInput.value) {
detectedType = 'video';
}
// 文件大小检查
const maxSize =
detectedType === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024;
message.error(
`文件大小超过限制!${detectedType === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
);
event.target.value = '';
return;
}
// 文件类型检查
const validImageTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
];
const validVideoTypes = [
'video/mp4',
'video/avi',
'video/mov',
'video/wmv',
'video/flv',
'video/webm',
'video/mkv',
];
if (detectedType === 'image' && !validImageTypes.includes(file.type)) {
message.error('请选择有效的图片格式 (JPEG, PNG, GIF, WebP, BMP)');
event.target.value = '';
return;
}
if (detectedType === 'video' && !validVideoTypes.includes(file.type)) {
message.error(
'请选择有效的视频格式 (MP4, AVI, MOV, WMV, FLV, WebM, MKV)',
);
event.target.value = '';
return;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadPreview.value = {
type: detectedType,
url: e.target.result,
name: file.name,
size: file.size,
file,
};
});
reader.onerror = (error) => {
console.error('文件读取失败:', error);
message.error('文件读取失败,请重试');
};
reader.readAsDataURL(file);
event.target.value = '';
};
const cancelUpload = () => {
uploadPreview.value = null;
};
return {
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload,
cancelUpload,
};
}

View File

@@ -0,0 +1,34 @@
import {ref} from "vue"
const visible = ref(false)
const mediaData = ref(null)
const previewType = ref("image")
export function useMediaPreview() {
const showPreview = (content, type = "image") => {
console.log("显示媒体预览:", content, type)
mediaData.value = content
previewType.value = type
visible.value = true
}
const hidePreview = () => {
visible.value = false
mediaData.value = null
previewType.value = "image"
}
return {
visible,
mediaData,
previewType,
showPreview,
hidePreview,
}
}
// 导出一个函数供组件直接使用
export default function previewMedia(content, type = "image") {
const {showPreview} = useMediaPreview()
showPreview(content, type)
}

View File

@@ -0,0 +1,67 @@
import {ref} from "vue"
import {message} from "ant-design-vue"
export function useRecording() {
const isRecording = ref(false)
const mediaRecorder = ref(null)
const audioChunks = ref([])
const recordingStartTime = ref(0)
const startRecording = () => {
if (isRecording.value) return
navigator.mediaDevices
.getUserMedia({audio: true})
.then((stream) => {
isRecording.value = true
audioChunks.value = []
recordingStartTime.value = Date.now()
mediaRecorder.value = new MediaRecorder(stream)
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, {type: "audio/wav"})
const audioUrl = URL.createObjectURL(audioBlob)
const duration = Math.floor((Date.now() - recordingStartTime.value) / 1000)
console.log(' aaa', 'ssssssssssssssss')
// 触发音频消息发送事件
const event = new CustomEvent("audioRecorded", {
detail: {
url: audioUrl,
duration: duration,
blob: audioBlob,
},
})
window.dispatchEvent(event)
// 停止所有音频轨道
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.value.start()
})
.catch((error) => {
console.error("录音失败:", error)
message.error("无法访问麦克风,请检查权限设置")
isRecording.value = false
})
}
const stopRecording = () => {
if (!isRecording.value || !mediaRecorder.value) return
isRecording.value = false
mediaRecorder.value.stop()
}
return {
isRecording,
startRecording,
stopRecording,
}
}

View File

@@ -0,0 +1,904 @@
import { ref } from 'vue';
import { useChatStore } from '../stores/chat';
import { useUserStore } from '../stores/user';
import { callAPI } from '../utils/request.ts';
export function useWebRTC() {
const localStream = ref(null);
const remoteStream = ref(null);
const peerConnection = ref(null);
const isConnected = ref(false);
const isConnecting = ref(false);
// ICE候选缓存和状态管理
const pendingIceCandidates = ref([]);
const isRemoteDescriptionSet = ref(false);
const connectionAttempts = ref(0);
const maxConnectionAttempts = 3;
// 修复问题1: 添加视频元素回调注册机制
const videoElementCallbacks = ref(new Set());
const userStore = useUserStore();
const chatStore = useChatStore();
// 改进ICE服务器配置
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' },
],
iceTransportPolicy: 'all',
iceCandidatePoolSize: 10,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
};
// 时间戳日志函数
const logWithTime = (level, message, ...args) => {
const timestamp = new Date().toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
});
switch (level) {
case 'error': {
console.error(`[${timestamp}] ${message}`, ...args);
break;
}
case 'log': {
console.log(`[${timestamp}] ${message}`, ...args);
break;
}
case 'warn': {
console.warn(`[${timestamp}] ${message}`, ...args);
break;
}
default: {
console.log(`[${timestamp}] ${message}`, ...args);
}
}
};
// 获取本地媒体流
const getLocalMedia = async (constraints = { audio: true, video: true }) => {
try {
logWithTime('log', '🎥 开始获取本地媒体流', constraints);
if (localStream.value) {
logWithTime('log', '🔄 停止现有本地媒体流');
localStream.value.getTracks().forEach((track) => {
track.stop();
logWithTime('log', `停止轨道: ${track.kind} - ${track.label}`);
});
localStream.value = null;
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
localStream.value = stream;
logWithTime(
'log',
'✅ 本地媒体流获取成功',
stream.getTracks().map((t) => `${t.kind}:${t.label}`),
);
return stream;
} catch (error) {
logWithTime('error', '❌ 获取本地媒体流失败:', error);
throw error;
}
};
// 处理缓存的ICE候选
const processPendingIceCandidates = async () => {
if (
!peerConnection.value ||
!isRemoteDescriptionSet.value ||
pendingIceCandidates.value.length === 0
) {
logWithTime(
'log',
`🧊 跳过处理ICE候选 - PC:${!!peerConnection.value}, RD:${isRemoteDescriptionSet.value}, 候选数:${pendingIceCandidates.value.length}`,
);
return;
}
logWithTime(
'log',
`🧊 开始处理 ${pendingIceCandidates.value.length} 个缓存的ICE候选`,
);
const candidates = [...pendingIceCandidates.value];
pendingIceCandidates.value = [];
for (const candidate of candidates) {
try {
if (
peerConnection.value &&
peerConnection.value.connectionState !== 'closed'
) {
const iceCandidate = candidate.candidate
? candidate
: new RTCIceCandidate(candidate);
await peerConnection.value.addIceCandidate(iceCandidate);
logWithTime(
'log',
'✅ 缓存ICE候选添加成功:',
`${iceCandidate.candidate?.slice(0, 50)}...`,
);
}
} catch (error) {
logWithTime('error', '❌ 缓存ICE候选添加失败:', error);
}
}
};
// 修复问题1: 注册视频元素更新回调
const registerVideoCallback = (callback) => {
videoElementCallbacks.value.add(callback);
logWithTime(
'log',
'📹 注册视频元素回调,当前回调数量:',
videoElementCallbacks.value.size,
);
// 如果已经有远程流,立即调用回调
if (remoteStream.value) {
logWithTime('log', '📹 立即调用新注册的回调,因为远程流已存在');
try {
callback(remoteStream.value);
} catch (error) {
logWithTime('error', '❌ 调用视频回调失败:', error);
}
}
return () => {
videoElementCallbacks.value.delete(callback);
logWithTime('log', '📹 取消注册视频元素回调');
};
};
// 修复问题1: 通知所有注册的视频元素更新
const notifyVideoElementsUpdate = (stream) => {
logWithTime(
'log',
'📹 通知所有视频元素更新,回调数量:',
videoElementCallbacks.value.size,
);
videoElementCallbacks.value.forEach((callback) => {
try {
callback(stream);
} catch (error) {
logWithTime('error', '❌ 调用视频回调失败:', error);
}
});
};
// 修复问题1: 改进远程轨道处理,直接通知组件更新
const setupRemoteTrackHandling = (pc) => {
pc.ontrack = (event) => {
logWithTime('log', '📹 接收到远程轨道事件:', {
streams: event.streams?.length,
track: event.track?.kind,
trackId: event.track?.id,
trackState: event.track?.readyState,
trackEnabled: event.track?.enabled,
trackMuted: event.track?.muted,
});
if (event.streams && event.streams.length > 0) {
const stream = event.streams[0];
const tracks = stream.getTracks();
if (tracks.length === 0) {
logWithTime('warn', '⚠️ 远程流没有轨道');
return;
}
logWithTime('log', '✅ 设置远程流:', {
id: stream.id,
tracks: tracks.map(
(t) => `${t.kind}:${t.id}:${t.readyState}:${t.enabled}:${t.muted}`,
),
active: stream.active,
});
// 清理旧的远程流
if (remoteStream.value && remoteStream.value.id !== stream.id) {
logWithTime('log', '🔄 清理旧的远程流');
remoteStream.value.getTracks().forEach((track) => {
track.stop();
logWithTime('log', `停止旧远程轨道: ${track.kind}`);
});
}
// 设置新的远程流
remoteStream.value = stream;
// 修复问题1: 直接通知所有注册的视频元素
notifyVideoElementsUpdate(stream);
// 监听流和轨道的状态变化
stream.onactive = () => {
logWithTime('log', '📹 远程流变为活跃状态');
notifyVideoElementsUpdate(stream);
};
stream.oninactive = () => {
logWithTime('warn', '⚠️ 远程流变为非活跃状态');
};
stream.onaddtrack = (trackEvent) => {
logWithTime('log', '📹 远程流添加新轨道:', trackEvent.track.kind);
setupTrackListeners(trackEvent.track);
notifyVideoElementsUpdate(stream);
};
stream.onremovetrack = (trackEvent) => {
logWithTime('warn', '⚠️ 远程流移除轨道:', trackEvent.track.kind);
};
// 为每个轨道设置监听器
tracks.forEach((track, index) => {
setupTrackListeners(track, index);
});
// 延迟通知确保DOM已更新
setTimeout(() => {
notifyVideoElementsUpdate(stream);
}, 100);
setTimeout(() => {
notifyVideoElementsUpdate(stream);
}, 500);
} else if (event.track) {
// 如果没有流,尝试从轨道创建流
logWithTime('log', '🔄 从轨道创建远程流');
const newStream = new MediaStream([event.track]);
remoteStream.value = newStream;
setupTrackListeners(event.track);
logWithTime('log', '✅ 从轨道创建的流:', {
id: newStream.id,
tracks: newStream.getTracks().map((t) => `${t.kind}:${t.id}`),
});
// 通知视频元素更新
notifyVideoElementsUpdate(newStream);
setTimeout(() => {
notifyVideoElementsUpdate(newStream);
}, 100);
} else {
logWithTime('warn', '⚠️ ontrack事件没有接收到有效的媒体流或轨道');
}
};
};
// 为轨道设置完整的状态监听
const setupTrackListeners = (track, index = 0) => {
logWithTime(
'log',
`设置轨道${index}监听器: ${track.kind} - ${track.readyState} - ${track.enabled}`,
);
track.addEventListener('ended', () => {
logWithTime('warn', `远程轨道结束: ${track.kind} - ${track.id}`);
// 轨道结束时尝试重新协商
if (
peerConnection.value &&
peerConnection.value.connectionState === 'connected'
) {
logWithTime('log', '轨道结束,可能需要重新协商');
}
});
track.onmute = () => {
logWithTime('warn', `远程轨道静音: ${track.kind} - ${track.id}`);
};
track.onunmute = () => {
logWithTime('log', `远程轨道取消静音: ${track.kind} - ${track.id}`);
// 取消静音时触发视频更新
if (track.kind === 'video' && remoteStream.value) {
notifyVideoElementsUpdate(remoteStream.value);
}
};
// 确保轨道是启用的
if (!track.enabled) {
logWithTime('warn', `⚠️ 远程轨道未启用,尝试启用: ${track.kind}`);
track.enabled = true;
}
// 定期检查轨道状态
const checkInterval = setInterval(() => {
if (track.readyState === 'ended') {
clearInterval(checkInterval);
return;
}
if (
track.kind === 'video' &&
track.readyState === 'live' &&
track.enabled &&
!track.muted && // 视频轨道状态良好,确保视频元素正在播放
remoteStream.value
) {
notifyVideoElementsUpdate(remoteStream.value);
}
}, 5000);
// 清理定时器
track.addEventListener('ended', () => {
clearInterval(checkInterval);
});
};
// 连接重试机制
const attemptReconnection = async (callId, peerId) => {
if (connectionAttempts.value >= maxConnectionAttempts) {
logWithTime('error', '❌ 达到最大重连次数,停止重试');
chatStore.callStatus = 'failed';
return false;
}
connectionAttempts.value++;
logWithTime(
'log',
`🔄 尝试重新连接 (${connectionAttempts.value}/${maxConnectionAttempts})`,
);
try {
// 清理现有连接
cleanupPeerConnection();
// 重新创建连接
createPeerConnection(callId, peerId);
// 重置状态
isRemoteDescriptionSet.value = false;
pendingIceCandidates.value = [];
return true;
} catch (error) {
logWithTime('error', '❌ 重连失败:', error);
return false;
}
};
// 改进PeerConnection创建逻辑
const createPeerConnection = (callId, peerId) => {
logWithTime('log', '🔗 创建PeerConnection', { callId, peerId });
if (peerConnection.value) {
logWithTime('log', '🔄 关闭现有PeerConnection');
cleanupPeerConnection();
}
// 重置状态
isConnected.value = false;
isConnecting.value = false;
isRemoteDescriptionSet.value = false;
pendingIceCandidates.value = [];
const pc = new RTCPeerConnection(configuration);
peerConnection.value = pc;
// 添加本地流到连接
if (localStream.value) {
logWithTime('log', '📤 添加本地流轨道到PeerConnection');
localStream.value.getTracks().forEach((track) => {
logWithTime('log', `添加轨道: ${track.kind} - ${track.label}`);
pc.addTrack(track, localStream.value);
});
}
// ICE候选事件处理
pc.onicecandidate = async (event) => {
if (event.candidate) {
logWithTime(
'log',
'📡 发送ICE候选:',
`${event.candidate.candidate?.slice(0, 50)}...`,
);
try {
await callAPI.sendCandidate(
userStore.currentUser?.id,
peerId,
callId,
event.candidate,
6,
);
logWithTime('log', '✅ ICE候选发送成功');
} catch (error) {
logWithTime('error', '❌ 发送ICE候选失败:', error);
}
} else {
logWithTime('log', '📡 ICE候选收集完成');
}
};
// 设置改进的远程轨道处理
setupRemoteTrackHandling(pc);
// 连接状态监听
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
logWithTime('log', '🔗 连接状态变化:', state);
switch (state) {
case 'closed': {
isConnected.value = false;
isConnecting.value = false;
logWithTime('log', '🔒 WebRTC连接已关闭');
break;
}
case 'connected': {
isConnected.value = true;
isConnecting.value = false;
connectionAttempts.value = 0; // 重置重试计数
chatStore.callStatus = 'ongoing';
chatStore.callConnectionStatus = '通话已连接';
logWithTime('log', '✅ WebRTC连接已建立');
break;
}
case 'connecting': {
isConnecting.value = true;
chatStore.callConnectionStatus = '正在连接...';
logWithTime('log', '🔄 WebRTC正在连接');
break;
}
case 'disconnected': {
isConnected.value = false;
isConnecting.value = false;
chatStore.callStatus = 'disconnected';
chatStore.callConnectionStatus = '连接已断开';
logWithTime('warn', '⚠️ WebRTC连接断开');
// 连接断开时尝试重连
setTimeout(() => {
if (chatStore.callStatus === 'disconnected') {
attemptReconnection(callId, peerId);
}
}, 2000);
break;
}
case 'failed': {
isConnected.value = false;
isConnecting.value = false;
chatStore.callStatus = 'failed';
chatStore.callConnectionStatus = '连接失败';
logWithTime('error', '❌ WebRTC连接失败');
// 连接失败时尝试重连
setTimeout(() => {
if (chatStore.callStatus === 'failed') {
attemptReconnection(callId, peerId);
}
}, 3000);
break;
}
}
};
// ICE连接状态监听和重连逻辑
pc.oniceconnectionstatechange = () => {
const iceState = pc.iceConnectionState;
logWithTime('log', '🧊 ICE连接状态变化:', iceState);
switch (iceState) {
case 'checking': {
logWithTime('log', '🧊 ICE正在检查连接');
break;
}
case 'closed': {
logWithTime('log', '🔒 ICE连接已关闭');
break;
}
case 'completed': {
logWithTime('log', '✅ ICE连接完成');
break;
}
case 'connected': {
logWithTime('log', '✅ ICE连接成功');
connectionAttempts.value = 0; // 重置重试计数
break;
}
case 'disconnected': {
logWithTime('warn', '⚠️ ICE连接断开');
// ICE断开时等待一段时间看是否能恢复
setTimeout(() => {
if (pc.iceConnectionState === 'disconnected') {
logWithTime('log', '🔄 ICE连接断开超时尝试重连');
attemptReconnection(callId, peerId);
}
}, 5000);
break;
}
case 'failed': {
logWithTime('error', '❌ ICE连接失败');
chatStore.callConnectionStatus = '网络连接失败';
// ICE连接失败时重新协商
setTimeout(() => {
if (pc.iceConnectionState === 'failed') {
logWithTime('log', '🔄 ICE连接失败尝试重新协商');
// 通知需要重新协商
const event = new CustomEvent('iceConnectionFailed', {
detail: { callId, peerId },
});
window.dispatchEvent(event);
}
}, 2000);
break;
}
}
};
// ICE收集状态监听
pc.onicegatheringstatechange = () => {
logWithTime('log', '🧊 ICE收集状态:', pc.iceGatheringState);
};
// 信令状态监听
pc.onsignalingstatechange = () => {
logWithTime('log', '📡 信令状态:', pc.signalingState);
};
return pc;
};
// 清理PeerConnection
const cleanupPeerConnection = () => {
if (!peerConnection.value) return;
try {
const senders = peerConnection.value.getSenders();
senders.forEach((sender) => {
if (sender.track) {
logWithTime('log', `停止发送器轨道: ${sender.track.kind}`);
sender.track.stop();
}
});
const receivers = peerConnection.value.getReceivers();
receivers.forEach((receiver) => {
if (receiver.track) {
logWithTime('log', `停止接收器轨道: ${receiver.track.kind}`);
receiver.track.stop();
}
});
} catch (error) {
logWithTime('error', '❌ 清理发送器/接收器时出错:', error);
}
// 清理事件监听器
peerConnection.value.onicecandidate = null;
peerConnection.value.ontrack = null;
peerConnection.value.onconnectionstatechange = null;
peerConnection.value.oniceconnectionstatechange = null;
peerConnection.value.onicegatheringstatechange = null;
peerConnection.value.onsignalingstatechange = null;
peerConnection.value.ondatachannel = null;
peerConnection.value.close();
peerConnection.value = null;
};
// 创建Offer
const createOffer = async (callId, peerId) => {
logWithTime('log', '📤 创建Offer', { callId, peerId });
if (!peerConnection.value) {
logWithTime('log', '🔗 PeerConnection不存在先创建');
createPeerConnection(callId, peerId);
}
try {
const offerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
voiceActivityDetection: true,
iceRestart: connectionAttempts.value > 0, // 重连时启用ICE重启
};
const offer = await peerConnection.value.createOffer(offerOptions);
await peerConnection.value.setLocalDescription(offer);
logWithTime('log', '✅ Offer创建成功:', {
type: offer.type,
sdpLength: offer.sdp.length,
hasAudio: offer.sdp.includes('m=audio'),
hasVideo: offer.sdp.includes('m=video'),
iceRestart: offerOptions.iceRestart,
});
return offer;
} catch (error) {
logWithTime('error', '❌ 创建Offer失败:', error);
throw error;
}
};
// 创建Answer
const createAnswer = async (offer, callId, peerId) => {
logWithTime('log', '📥 处理Offer并创建Answer');
if (!peerConnection.value) {
logWithTime('log', '🔗 PeerConnection不存在先创建');
createPeerConnection(callId, peerId);
}
try {
logWithTime('log', '📥 设置远程Offer');
const remoteDesc = new RTCSessionDescription(offer);
await peerConnection.value.setRemoteDescription(remoteDesc);
isRemoteDescriptionSet.value = true;
logWithTime('log', '✅ 远程描述设置完成开始处理缓存的ICE候选');
await processPendingIceCandidates();
const answer = await peerConnection.value.createAnswer();
await peerConnection.value.setLocalDescription(answer);
logWithTime('log', '✅ Answer创建成功:', {
type: answer.type,
sdpLength: answer.sdp.length,
hasAudio: answer.sdp.includes('m=audio'),
hasVideo: answer.sdp.includes('m=video'),
});
return answer;
} catch (error) {
logWithTime('error', '❌ 创建Answer失败:', error);
throw error;
}
};
// 设置远程Answer
const setRemoteAnswer = async (answer) => {
logWithTime('log', '📥 设置远程Answer');
if (!peerConnection.value) {
logWithTime('error', '❌ PeerConnection不存在');
throw new Error('PeerConnection不存在');
}
try {
const remoteDesc = new RTCSessionDescription(answer);
await peerConnection.value.setRemoteDescription(remoteDesc);
isRemoteDescriptionSet.value = true;
logWithTime('log', '✅ 远程Answer设置成功开始处理缓存的ICE候选');
await processPendingIceCandidates();
} catch (error) {
logWithTime('error', '❌ 设置远程Answer失败:', error);
throw error;
}
};
// ICE候选添加逻辑
const addIceCandidate = async (candidate) => {
logWithTime(
'log',
'🧊 收到ICE候选:',
`${candidate.candidate?.slice(0, 50)}...`,
);
if (!candidate || !candidate.candidate) {
logWithTime('warn', '⚠️ 无效的ICE候选:', candidate);
return false;
}
if (!peerConnection.value) {
logWithTime('warn', '⚠️ PeerConnection不存在缓存ICE候选');
pendingIceCandidates.value.push(candidate);
return false;
}
if (
peerConnection.value.connectionState === 'closed' ||
peerConnection.value.connectionState === 'failed'
) {
logWithTime(
'warn',
'⚠️ PeerConnection状态异常缓存ICE候选:',
peerConnection.value.connectionState,
);
pendingIceCandidates.value.push(candidate);
return false;
}
if (!isRemoteDescriptionSet.value) {
logWithTime('warn', '⚠️ 远程描述未设置缓存ICE候选');
pendingIceCandidates.value.push(candidate);
return false;
}
try {
const iceCandidate = candidate.candidate
? candidate
: new RTCIceCandidate(candidate);
await peerConnection.value.addIceCandidate(iceCandidate);
logWithTime('log', '✅ ICE候选添加成功');
return true;
} catch (error) {
logWithTime('error', '❌ 添加ICE候选失败:', error);
return false;
}
};
// 切换摄像头
const toggleCamera = (enable) => {
if (!localStream.value) {
logWithTime('warn', '⚠️ 本地流不存在,无法切换摄像头');
return;
}
const videoTracks = localStream.value.getVideoTracks();
if (videoTracks.length > 0) {
videoTracks.forEach((track) => {
track.enabled = enable;
logWithTime(
'log',
`📹 摄像头${enable ? '开启' : '关闭'}: ${track.label}`,
);
});
} else {
logWithTime('warn', '⚠️ 没有找到视频轨道');
}
};
// 切换麦克风
const toggleMicrophone = (enable) => {
if (!localStream.value) {
logWithTime('warn', '⚠️ 本地流不存在,无法切换麦克风');
return;
}
const audioTracks = localStream.value.getAudioTracks();
if (audioTracks.length > 0) {
audioTracks.forEach((track) => {
track.enabled = enable;
logWithTime(
'log',
`🎤 麦克风${enable ? '开启' : '关闭'}: ${track.label}`,
);
});
} else {
logWithTime('warn', '⚠️ 没有找到音频轨道');
}
};
// 释放媒体资源
const releaseMediaResources = () => {
logWithTime('log', '🔄 开始释放媒体资源');
if (localStream.value) {
logWithTime('log', '🔄 释放本地媒体流');
localStream.value.getTracks().forEach((track) => {
logWithTime(
'log',
`停止本地轨道: ${track.kind} - ${track.label} - 状态: ${track.readyState}`,
);
track.stop();
setTimeout(() => {
if (track.readyState === 'ended') {
logWithTime('log', `✅ 轨道已完全停止: ${track.kind}`);
} else {
logWithTime(
'warn',
`⚠️ 轨道未完全停止: ${track.kind} - ${track.readyState}`,
);
try {
track.stop();
} catch (error) {
logWithTime('error', '强制停止轨道失败:', error);
}
}
}, 200);
});
localStream.value = null;
}
if (remoteStream.value) {
logWithTime('log', '🔄 释放远程媒体流');
remoteStream.value.getTracks().forEach((track) => {
logWithTime('log', `停止远程轨道: ${track.kind}`);
track.stop();
});
remoteStream.value = null;
// 通知视频元素清理
notifyVideoElementsUpdate(null);
}
logWithTime('log', '✅ 媒体资源释放完成');
};
// 关闭连接
const closeConnection = () => {
logWithTime('log', '🔄 开始关闭WebRTC连接');
pendingIceCandidates.value = [];
isRemoteDescriptionSet.value = false;
connectionAttempts.value = 0;
logWithTime('log', '🧊 清空ICE候选缓存和状态');
releaseMediaResources();
cleanupPeerConnection();
isConnected.value = false;
isConnecting.value = false;
logWithTime('log', '✅ WebRTC连接已完全关闭所有资源已释放');
};
// 强制清理所有资源
const forceCleanup = () => {
logWithTime('log', '🚨 强制清理所有WebRTC资源');
try {
pendingIceCandidates.value = [];
isRemoteDescriptionSet.value = false;
connectionAttempts.value = 0;
// 清理所有回调
videoElementCallbacks.value.clear();
closeConnection();
setTimeout(() => {
localStream.value = null;
remoteStream.value = null;
peerConnection.value = null;
isConnected.value = false;
isConnecting.value = false;
logWithTime('log', '✅ 强制清理完成');
}, 100);
} catch (error) {
logWithTime('error', '❌ 强制清理时出错:', error);
}
};
return {
localStream,
remoteStream,
peerConnection,
isConnected,
isConnecting,
pendingIceCandidates,
isRemoteDescriptionSet,
connectionAttempts,
getLocalMedia,
createPeerConnection,
createOffer,
createAnswer,
setRemoteAnswer,
addIceCandidate,
toggleCamera,
toggleMicrophone,
closeConnection,
releaseMediaResources,
forceCleanup,
attemptReconnection,
registerVideoCallback, // 修复问题1: 新增方法
};
}

View File

@@ -0,0 +1,191 @@
import { onMounted, onUnmounted, ref } from 'vue';
import { useChatStore } from '../stores/chat';
import { useUserStore } from '../stores/user';
// useVbenUserStore
export function useWebSocket() {
const userStore = useUserStore();
const chatStore = useChatStore();
const socket = ref();
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 {
// const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws"
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:12080/ws';
// const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws"
socket.value = new WebSocket(wsUrl);
socket.value.addEventListener('open', handleOpen);
socket.value.onmessage = handleMessage;
socket.value.addEventListener('close', handleClose);
socket.value.onerror = handleError;
} catch (error) {
console.error('WebSocket连接失败:', error);
isConnecting.value = false;
chatStore.connectionStatus = 'disconnected';
}
};
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;
console.log(userStore.currentUser, 'ssssssssss')
// 绑定当前用户
if (userStore.currentUser?.id) {
bindUser(userStore.currentUser.id);
}
};
const handleMessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('收到WebSocket消息:', data);
// 优先处理通话信令(无论是否在当前聊天窗口)
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 handleClose = (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, 30_000);
console.log(
`尝试重连 (${reconnectAttempts.value}/${maxReconnectAttempts})`,
);
connectWebSocket();
}, reconnectDelay.value);
}
};
const handleError = (error) => {
console.error('WebSocket错误:', error);
isConnecting.value = false;
chatStore.connectionStatus = 'disconnected';
};
const handleCallSignal = (data) => {
console.log('收到通话信令:', data);
chatStore.handleCallSignal(data);
};
const bindUser = (userId) => {
if (!isConnected.value || !userId) return;
const bindMessage = {
request_type: 'bind',
user_type: 'doctor',
sender_user_id: `doctor-${userId}`,
};
send(bindMessage);
};
const send = (message) => {
if (!isConnected.value || !socket.value) {
console.error('WebSocket未连接无法发送消息');
return false;
}
try {
socket.value.send(JSON.stringify(message));
console.log('发送消息:', 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({
request_type: 'call_signal',
...signal,
sender_user_id: userStore.currentUser?.id,
});
};
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).slice(2, 11);
};
// onMounted(() => {
// connectWebSocket();
// });
onUnmounted(() => {
disconnect();
});
return {
socket,
isConnected,
isConnecting,
connectWebSocket,
disconnect,
send,
sendMessage,
sendCallSignal,
bindUser,
generateCallId,
};
}

View File

@@ -0,0 +1,211 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import ChatArea from './components/ChatArea.vue';
import ConnectionStatus from './components/ConnectionStatus.vue';
import EmptyState from './components/EmptyState.vue';
import FriendList from './components/FriendList.vue';
import FriendsManagement from './components/FriendsManagement.vue';
import GroupsManagement from './components/GroupsManagement.vue';
import MediaPreview from './components/MediaPreview.vue';
import MomentsView from './components/MomentsView.vue';
// import SideNavigation from './components/SideNavigation.vue';
import VideoCallComponent from './components/VideoCallComponent.vue';
import { useChatStore } from './stores/chat.ts';
import { useThemeStore } from './stores/theme.ts';
const chatStore = useChatStore();
const themeStore = useThemeStore();
const currentNav = ref('chat');
// 视频通话相关状态
const showVideoCall = ref(false);
const callType = ref('video');
const isIncoming = ref(false);
const callerInfo = ref(null);
// 初始化录音功能
// const { isRecording } = useRecording();
// 处理导航切换
const handleNavChange = (nav) => {
currentNav.value = nav;
};
// 监听通话状态变化
watch(
() => chatStore.callStatus,
(status) => {
// 只有当通话状态是活跃状态时才显示视频组件
showVideoCall.value =
status === 'connecting' ||
status === 'calling' ||
status === 'ringing' ||
status === 'ongoing';
},
);
// 监听来电
watch(
() => chatStore.incomingCall,
(call) => {
if (call) {
isIncoming.value = true;
callerInfo.value = chatStore.friends.find((f) => f.id === call.callerId);
callType.value = call.callType;
} else {
isIncoming.value = false;
}
},
);
// 监听当前通话
watch(
() => chatStore.currentCall,
(call) => {
if (call) {
callerInfo.value = chatStore.friends.find((f) => f.id === call.peerId);
callType.value = call.callType;
isIncoming.value = false;
} else {
// 当通话结束时重置状态
showVideoCall.value = false;
}
},
);
// 处理接听通话
const handleAcceptCall = () => {
chatStore.acceptCall();
};
// 处理拒绝通话
const handleRejectCall = () => {
chatStore.rejectCall();
};
// 处理结束通话 - 修复挂断逻辑
const handleEndCall = () => {
chatStore.endCall();
};
// 加载主题
themeStore.loadTheme();
onUnmounted(() => {
// disconnectWebSocket();
});
</script>
<template>
<div
style="height: 95vh;"
class="flex bg-gradient-to-br from-blue-900 via-purple-900 to-pink-300 p-5"
>
<!-- &lt;!&ndash; 背景装饰 &ndash;&gt;-->
<!-- <div class="pointer-events-none fixed inset-0 z-0">-->
<!-- <div-->
<!-- class="animate-float absolute left-10 top-10 h-72 w-72 rounded-full bg-blue-500 opacity-10"-->
<!-- ></div>-->
<!-- <div-->
<!-- class="right-15 animate-float-delayed absolute top-60 h-48 w-48 rounded-full bg-cyan-400 opacity-10"-->
<!-- ></div>-->
<!-- <div-->
<!-- class="animate-float-slow absolute bottom-10 left-20 h-36 w-36 rounded-full bg-red-400 opacity-10"-->
<!-- ></div>-->
<!-- </div>-->
<!-- &lt;!&ndash; 录音状态指示器 &ndash;&gt;-->
<!-- <RecordingIndicator />-->
<!-- 视频通话组件移动到此处 -->
<VideoCallComponent
v-if="showVideoCall"
:call-type="callType"
:caller-info="callerInfo"
:is-incoming="isIncoming"
@accept-call="handleAcceptCall"
@end-call="handleEndCall"
@reject-call="handleRejectCall"
/>
<!-- 主聊天容器 -->
<div
class="z-10 mx-auto flex flex-1 overflow-hidden rounded-2xl bg-white/90 shadow-2xl backdrop-blur-sm"
>
<!-- 连接状态指示器 -->
<ConnectionStatus />
<!-- &lt;!&ndash; 侧边导航 &ndash;&gt;-->
<!-- <SideNavigation-->
<!-- :current-view="currentNav"-->
<!-- @nav-change="handleNavChange"-->
<!-- />-->
<!-- 好友列表 -->
<FriendList class="w-80 min-w-80" />
<!-- 聊天区域 -->
<div class="min-w-0 flex-1">
<ChatArea v-if="chatStore.currentFriend && currentNav === 'chat'" />
<FriendsManagement v-else-if="currentNav === 'friends'" />
<GroupsManagement v-else-if="currentNav === 'groups'" />
<MomentsView v-else-if="currentNav === 'moments'" />
<EmptyState v-else />
</div>
</div>
<!-- 媒体预览模态框 -->
<MediaPreview />
</div>
</template>
<style scoped>
/*@keyframes float {
0% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(10deg);
}
100% {
transform: translateY(0px) rotate(0deg);
}
}
@keyframes float-delayed {
0% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-15px) rotate(-5deg);
}
100% {
transform: translateY(0px) rotate(0deg);
}
}
@keyframes float-slow {
0% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-10px) rotate(5deg);
}
100% {
transform: translateY(0px) rotate(0deg);
}
}
.animate-float {
animation: float 12s infinite ease-in-out;
}
.animate-float-delayed {
animation: float-delayed 15s infinite ease-in-out;
}
.animate-float-slow {
animation: float-slow 18s infinite ease-in-out;
}*/
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import {defineStore} from "pinia"
import {ref} from "vue"
export const useThemeStore = defineStore("theme", () => {
const isDarkMode = ref(false)
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
localStorage.setItem("chatTheme", isDarkMode.value ? "dark" : "light")
updateTheme()
}
const updateTheme = () => {
if (isDarkMode.value) {
document.documentElement.setAttribute("data-theme", "dark")
document.documentElement.classList.add("dark")
} else {
document.documentElement.removeAttribute("data-theme")
document.documentElement.classList.remove("dark")
}
}
const loadTheme = () => {
const savedTheme = localStorage.getItem("chatTheme")
if (savedTheme) {
isDarkMode.value = savedTheme === "dark"
} else {
isDarkMode.value = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
}
updateTheme()
}
return {
isDarkMode,
toggleTheme,
loadTheme,
}
})

View File

@@ -0,0 +1,114 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
const currentUser = ref();
const isLoggedIn = computed(() => !!currentUser.value);
// 预设用户数据
const presetUsers = ref([
{
id: '1',
room_id: '10001',
name: '张三',
avatar: 'avatar1',
color: '#4cc9f0',
},
{
id: '2',
room_id: '10002',
name: '李四',
avatar: 'avatar2',
color: '#ff6b6b',
},
{
id: '3',
room_id: '10003',
name: '王五',
avatar: 'avatar3',
color: '#6a0dad',
},
{
id: '4',
room_id: '10004',
name: '赵六',
avatar: 'avatar4',
color: '#20b2aa',
},
{
id: '5',
room_id: '10005',
name: '钱七',
avatar: 'avatar5',
color: '#ffa500',
},
{
id: '6',
room_id: '10006',
name: '孙八',
avatar: 'avatar6',
color: '#9acd32',
},
{
id: '7',
room_id: '10007',
name: '周九',
avatar: 'avatar7',
color: '#ff1493',
},
{
id: '8',
room_id: '10008',
name: '吴十',
avatar: 'avatar8',
color: '#4682b4',
},
{
id: '9',
room_id: '10009',
name: '郑十一',
avatar: 'avatar9',
color: '#c71585',
},
{
id: '10',
room_id: '100010',
name: '王十二',
avatar: 'avatar10',
color: '#2e8b57',
},
]);
const login = (user: any) => {
currentUser.value = user;
localStorage.setItem('chatUser', JSON.stringify(user));
};
const logout = () => {
currentUser.value = null;
localStorage.removeItem('chatUser');
localStorage.removeItem('token');
};
const checkSession = () => {
const savedUser = localStorage.getItem('chatUser');
if (savedUser) {
currentUser.value = JSON.parse(savedUser);
}
};
const setClientId = (clientId: number | string) => {
currentUser.value.clientId = clientId;
};
return {
currentUser,
isLoggedIn,
presetUsers,
login,
logout,
checkSession,
setClientId,
};
});

View File

@@ -0,0 +1,153 @@
// IndexedDB 数据库工具类
class ChatDB {
constructor() {
this.dbName = 'ChatApp';
this.version = 1;
this.db = null;
}
// 清空未读消息
async clearUnreadCount(friendId) {
const transaction = this.db.transaction(['friends'], 'readwrite');
const store = transaction.objectStore('friends');
const request = store.get(friendId);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const friend = request.result;
if (friend) {
friend.unreadCount = 0;
store.put(friend);
}
resolve();
};
request.onerror = () => reject(request.error);
});
}
// 获取聊天记录
async getChatHistory(currentUserId, friendId) {
const transaction = this.db.transaction(['messages'], 'readonly');
const store = transaction.objectStore('messages');
const index = store.index('chatKey');
const chatKey = `${currentUserId}_${friendId}`;
const request = index.getAll(chatKey);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
// 获取好友列表
async getFriends(currentUserId) {
const transaction = this.db.transaction(['friends'], 'readonly');
const store = transaction.objectStore('friends');
const index = store.index('userId');
const request = index.getAll(currentUserId);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建聊天记录表
if (!db.objectStoreNames.contains('messages')) {
const messageStore = db.createObjectStore('messages', {
keyPath: 'id',
autoIncrement: true,
});
messageStore.createIndex('chatKey', 'chatKey', { unique: false });
messageStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// 创建用户表
if (!db.objectStoreNames.contains('users')) {
const userStore = db.createObjectStore('users', { keyPath: 'id' });
}
// 创建好友表
if (!db.objectStoreNames.contains('friends')) {
const friendStore = db.createObjectStore('friends', {
keyPath: 'id',
});
friendStore.createIndex('userId', 'userId', { unique: false });
}
};
});
}
// 保存好友信息
async saveFriend(friend, currentUserId) {
const transaction = this.db.transaction(['friends'], 'readwrite');
const store = transaction.objectStore('friends');
const friendData = {
...friend,
userId: currentUserId,
updatedAt: Date.now(),
};
return store.put(friendData);
}
// 保存消息
async saveMessage(message, currentUserId, friendId) {
const transaction = this.db.transaction(['messages'], 'readwrite');
const store = transaction.objectStore('messages');
const messageData = {
...message,
chatKey: `${currentUserId}_${friendId}`,
timestamp: Date.now(),
};
return store.add(messageData);
}
// 更新好友最后消息
async updateFriendLastMessage(
friendId,
currentUserId,
lastMessage,
unreadCount = 0,
) {
const transaction = this.db.transaction(['friends'], 'readwrite');
const store = transaction.objectStore('friends');
const request = store.get(friendId);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const friend = request.result;
if (friend) {
friend.lastMessage = lastMessage;
friend.unreadCount = unreadCount;
friend.updatedAt = Date.now();
store.put(friend);
}
resolve();
};
request.onerror = () => reject(request.error);
});
}
}
export const chatDB = new ChatDB();

View File

@@ -0,0 +1,360 @@
import { message } from 'ant-design-vue';
import axios from 'axios';
// 创建 Axios 实例
const service = axios.create({
baseURL: '/im-api/',
timeout: 15_000,
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('Request error:', error);
return Promise.reject(error);
},
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
const { code, result, message: msg } = response.data;
if (code === 0) {
return result;
} else {
if (code === 401) {
message.error(msg);
localStorage.removeItem('token');
localStorage.removeItem('chatUser');
window.location.href = '/login';
return Promise.reject(new Error(msg));
}
message.error(msg);
return Promise.reject(new Error(msg));
}
},
(error) => {
console.error('Response error:', error);
// 详细的错误处理
if (error.code === 'ECONNABORTED') {
console.log('请求超时,请检查网络连接');
} else if (error.response?.status === 0 || error.message.includes('CORS')) {
console.log('CORS错误检测到尝试备用方案...');
handleCORSError(error.config);
} else if (error.response?.status >= 400 && error.response?.status < 500) {
console.log('客户端错误:', error.response.status, error.response.data);
} else if (error.response?.status >= 500) {
console.log('服务器错误:', error.response.status);
} else {
console.log('网络错误:', error.message);
}
message.error('网络请求失败');
return Promise.reject(error);
},
);
// CORS错误的备用处理方案
const handleCORSError = (config) => {
console.log('执行CORS错误备用方案...');
const apiUrl = config.url.replace('/api', '');
return fetch(apiUrl, {
method: config.method.toUpperCase(),
headers: {
'Content-Type': 'application/json',
...config.headers,
},
mode: 'cors',
body: config.data ? JSON.stringify(config.data) : undefined,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.catch((error) => {
console.error('备用方案也失败:', error);
throw error;
});
};
// 封装请求方法
export const get = (url, params = {}) => {
return service.get(url, { params });
};
export const post = (url, data = {}) => {
return service.post(url, data);
};
export const upload = (url, file) => {
const formData = new FormData();
formData.append('file', file);
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
// 发送普通消息API
export const sendMessage = (data) => {
const apiUrl = '/im-api/send-to-user';
// 消息类型映射 - 根据用户提供的完整映射更新
const messageTypeMap = {
text: 0,
image: 1,
audio: 2,
video: 3,
prescription: 4,
'medical-record': 5,
'video-call': 6,
'audio-call': 7,
file: 8,
};
const requestData = {
room_id: data.roomId,
sender_user_id: 'doctor-' + data.senderId.toString(),
receiver_user_id: data.receiverId.toString(),
message_type: messageTypeMap[data.type] || 0,
message_content: data.content || '',
// 添加来源标记
isFromHttp: true,
};
// 如果包含通话信令添加call_id和call_status
if (data.callId) {
requestData.call_id = data.callId;
}
if (data.callStatus) {
requestData.call_status = data.callStatus;
}
return axios
.post(apiUrl, requestData, {
headers: {
'Content-Type': 'application/json',
},
timeout: 15_000,
withCredentials: false,
})
.then((response) => {
console.log('消息发送成功:', response.data);
return response.data;
})
.catch((error) => {
console.error('消息发送失败:', error);
throw error;
});
};
// 专门的通话信令发送方法
export const sendCallSignal = (signalData) => {
const apiUrl = '/api/send-to-user';
// 验证必要参数
if (!signalData.sender_user_id) {
throw new Error('缺少发送方用户ID');
}
if (!signalData.receiver_user_id) {
throw new Error('缺少接收方用户ID');
}
if (!signalData.call_id) {
throw new Error('缺少通话ID');
}
if (!signalData.call_status) {
throw new Error('缺少通话状态');
}
// 构建通话信令请求数据
const requestData = {
sender_user_id: signalData.sender_user_id,
receiver_user_id: signalData.receiver_user_id,
message_type: signalData.message_type || 6, // 默认视频通话
message_content:
signalData.message_content || JSON.stringify(signalData.data || {}),
call_id: signalData.call_id,
call_status: signalData.call_status,
// 添加来源标记
isFromHttp: true,
};
console.log('发送通话信令到API:', apiUrl, requestData);
return axios
.post(apiUrl, requestData, {
headers: {
'Content-Type': 'application/json',
},
timeout: 15_000,
withCredentials: false,
})
.then((response) => {
console.log('通话信令发送成功:', response.data);
return response.data;
})
.catch((error) => {
console.error('通话信令发送失败:', error);
// 提供更详细的错误信息
if (error.response) {
console.error('响应错误:', error.response.data);
console.error('响应状态:', error.response.status);
console.error('响应头:', error.response.headers);
} else if (error.request) {
console.error('请求错误:', error.request);
} else {
console.error('配置错误:', error.message);
}
throw error;
});
};
// 通话相关的便捷方法
export const callAPI = {
// 发起通话邀请
invite: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'invite',
message_type: callType, // 6=视频, 7=语音
});
},
// 接受通话
accept: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'accepted',
message_type: callType,
});
},
// 拒绝通话
reject: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'rejected',
message_type: callType,
});
},
// 结束通话
end: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'ended',
message_type: callType,
});
},
// 发送WebRTC Offer
sendOffer: (
senderUserId,
receiverUserId,
callId,
offerData,
callType = 6,
) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'offer',
message_type: callType,
data: offerData,
});
},
// 发送WebRTC Answer
sendAnswer: (
senderUserId,
receiverUserId,
callId,
answerData,
callType = 6,
) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'answer',
message_type: callType,
data: answerData,
});
},
// 发送ICE候选
sendCandidate: (
senderUserId,
receiverUserId,
callId,
candidateData,
callType = 6,
) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'candidate',
message_type: callType,
data: candidateData,
});
},
// 发送忙线状态
sendBusy: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'busy',
message_type: callType,
});
},
// 发送无人接听
sendNoAnswer: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'no-answer',
message_type: callType,
});
},
// 发送通话失败
sendFailed: (senderUserId, receiverUserId, callId, callType = 6) => {
return sendCallSignal({
sender_user_id: senderUserId,
receiver_user_id: receiverUserId,
call_id: callId,
call_status: 'failed',
message_type: callType,
});
},
};
export default service;

View File

@@ -51,42 +51,42 @@ const [Modal, modalApi] = useVbenModal({
console.log(values, 'ssssssssssss');
if (values) {
isUpdate.value = update;
// getPharmacistEditInfoApi(values.id).then((res) => {
// formApi.setValues(res);
// });
// formApi.setValues(values);
getPharmacistEditInfoApi(values.id).then((res) => {
formApi.setValues(res);
});
formApi.setValues(values);
const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
formApi.setValues({
name: '测试药师',
mobile: '15110000000',
idcard: '134111000022225555',
type: 1,
sign_type: 2,
qualification: img,
practicing: img,
title: img,
card_up: img,
card_down: img,
sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
avatar: img,
});
// const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
// formApi.setValues({
// name: '测试药师',
// mobile: '15110000000',
// idcard: '134111000022225555',
// type: 1,
// sign_type: 2,
// qualification: img,
// practicing: img,
// title: img,
// card_up: img,
// card_down: img,
// sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
// avatar: img,
// });
} else {
const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
formApi.setValues({
name: '测试药师',
mobile: '15110000000',
idcard: '134111000022225555',
type: 1,
sign_type: 2,
qualification: img,
practicing: img,
title: img,
card_up: img,
card_down: img,
sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
avatar: img,
});
// const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
// formApi.setValues({
// name: '测试药师',
// mobile: '15110000000',
// idcard: '134111000022225555',
// type: 1,
// sign_type: 2,
// qualification: img,
// practicing: img,
// title: img,
// card_up: img,
// card_down: img,
// sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
// avatar: img,
// });
}
}

View File

@@ -15,6 +15,15 @@ export default defineConfig(async () => {
// target: 'http://api.xiaokang88.com/api/admin/',
ws: true,
},
'/im-api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/im-api/, ''),
// mock代理目标地址
// target: 'https://api.xiaokang88.com/api/admin/',
target: 'http://localhost:12080/api/',
// target: 'http://api.xiaokang88.com/api/admin/',
ws: true,
},
},
},
},

View File

@@ -111,7 +111,9 @@
]
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.10.0",
"html2canvas": "^1.4.1",
"js-base64": "^3.7.7",
"lodash-es": "^4.17.21",

550
pnpm-lock.yaml generated
View File

@@ -12,51 +12,387 @@ catalogs:
'@changesets/cli':
specifier: ^2.27.11
version: 2.27.11
'@changesets/git':
specifier: ^3.0.2
version: 3.0.2
'@clack/prompts':
specifier: ^0.9.0
version: 0.9.0
'@commitlint/cli':
specifier: ^19.6.1
version: 19.6.1
'@commitlint/config-conventional':
specifier: ^19.6.0
version: 19.6.0
'@eslint/js':
specifier: ^9.17.0
version: 9.17.0
'@iconify/json':
specifier: ^2.2.286
version: 2.2.286
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
'@iconify/vue':
specifier: ^4.2.0
version: 4.2.0
'@intlify/core-base':
specifier: ^10.0.5
version: 10.0.5
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.1
version: 6.0.1
'@jspm/generator':
specifier: ^2.4.1
version: 2.4.1
'@manypkg/get-packages':
specifier: ^2.2.2
version: 2.2.2
'@playwright/test':
specifier: ^1.49.1
version: 1.49.1
'@pnpm/workspace.read-manifest':
specifier: ^1000.0.1
version: 1000.0.1
'@stylistic/stylelint-plugin':
specifier: ^3.1.1
version: 3.1.1
'@tailwindcss/nesting':
specifier: 0.0.0-insiders.565cd3e
version: 0.0.0-insiders.565cd3e
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15
'@tanstack/vue-query':
specifier: ^5.62.8
version: 5.62.8
'@tanstack/vue-store':
specifier: ^0.6.0
version: 0.6.0
'@types/archiver':
specifier: ^6.0.3
version: 6.0.3
'@types/eslint':
specifier: ^9.6.1
version: 9.6.1
'@types/html-minifier-terser':
specifier: ^7.0.2
version: 7.0.2
'@types/lodash.clonedeep':
specifier: ^4.5.9
version: 4.5.9
'@types/lodash.get':
specifier: ^4.4.9
version: 4.4.9
'@types/lodash.isequal':
specifier: ^4.5.8
version: 4.5.8
'@types/node':
specifier: ^22.10.2
version: 22.10.2
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.5
'@types/sortablejs':
specifier: ^1.15.8
version: 1.15.8
'@typescript-eslint/eslint-plugin':
specifier: ^8.18.1
version: 8.18.1
'@typescript-eslint/parser':
specifier: ^8.18.1
version: 8.18.1
'@vee-validate/zod':
specifier: ^4.14.7
version: 4.14.7
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1
'@vitejs/plugin-vue-jsx':
specifier: ^4.1.1
version: 4.1.1
'@vue/shared':
specifier: ^3.5.13
version: 3.5.13
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
'@vueuse/core':
specifier: ^12.0.0
version: 12.0.0
'@vueuse/integrations':
specifier: ^12.0.0
version: 12.0.0
ant-design-vue:
specifier: ^4.2.6
version: 4.2.6
archiver:
specifier: ^7.0.1
version: 7.0.1
autoprefixer:
specifier: ^10.4.20
version: 10.4.20
axios:
specifier: ^1.7.9
version: 1.7.9
axios-mock-adapter:
specifier: ^2.1.0
version: 2.1.0
cac:
specifier: ^6.7.14
version: 6.7.14
chalk:
specifier: ^5.4.0
version: 5.4.0
cheerio:
specifier: 1.0.0
version: 1.0.0
circular-dependency-scanner:
specifier: ^2.3.0
version: 2.3.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
commitlint-plugin-function-rules:
specifier: ^4.0.1
version: 4.0.1
consola:
specifier: ^3.3.0
version: 3.3.0
cross-env:
specifier: ^7.0.3
version: 7.0.3
cspell:
specifier: ^8.17.1
version: 8.17.1
cssnano:
specifier: ^7.0.6
version: 7.0.6
cz-git:
specifier: ^1.11.0
version: 1.11.0
czg:
specifier: ^1.11.0
version: 1.11.0
dayjs:
specifier: ^1.11.13
version: 1.11.13
defu:
specifier: ^6.1.4
version: 6.1.4
depcheck:
specifier: ^1.4.7
version: 1.4.7
dotenv:
specifier: ^16.4.7
version: 16.4.7
echarts:
specifier: ^5.5.1
version: 5.5.1
eslint:
specifier: ^9.17.0
version: 9.17.0
eslint-config-turbo:
specifier: ^2.3.3
version: 2.3.3
eslint-plugin-command:
specifier: ^0.2.7
version: 0.2.7
eslint-plugin-eslint-comments:
specifier: ^3.2.0
version: 3.2.0
eslint-plugin-import-x:
specifier: ^4.6.1
version: 4.6.1
eslint-plugin-jsdoc:
specifier: ^50.6.1
version: 50.6.1
eslint-plugin-jsonc:
specifier: ^2.18.2
version: 2.18.2
eslint-plugin-n:
specifier: ^17.15.1
version: 17.15.1
eslint-plugin-no-only-tests:
specifier: ^3.3.0
version: 3.3.0
eslint-plugin-perfectionist:
specifier: ^3.9.1
version: 3.9.1
eslint-plugin-prettier:
specifier: ^5.2.1
version: 5.2.1
eslint-plugin-regexp:
specifier: ^2.7.0
version: 2.7.0
eslint-plugin-unicorn:
specifier: ^56.0.1
version: 56.0.1
eslint-plugin-unused-imports:
specifier: ^4.1.4
version: 4.1.4
eslint-plugin-vitest:
specifier: ^0.5.4
version: 0.5.4
eslint-plugin-vue:
specifier: ^9.32.0
version: 9.32.0
execa:
specifier: ^9.5.2
version: 9.5.2
find-up:
specifier: ^7.0.0
version: 7.0.0
get-port:
specifier: ^7.1.0
version: 7.1.0
globals:
specifier: ^15.14.0
version: 15.14.0
happy-dom:
specifier: ^15.11.7
version: 15.11.7
html-minifier-terser:
specifier: ^7.2.0
version: 7.2.0
husky:
specifier: ^9.1.7
version: 9.1.7
is-ci:
specifier: ^4.1.0
version: 4.1.0
jsonc-eslint-parser:
specifier: ^2.4.0
version: 2.4.0
lint-staged:
specifier: ^15.2.11
version: 15.2.11
lodash.clonedeep:
specifier: ^4.5.0
version: 4.5.0
lodash.get:
specifier: ^4.4.2
version: 4.4.2
lodash.isequal:
specifier: ^4.5.0
version: 4.5.0
lucide-vue-next:
specifier: ^0.469.0
version: 0.469.0
nitropack:
specifier: ^2.10.4
version: 2.10.4
nprogress:
specifier: ^0.2.0
version: 0.2.0
ora:
specifier: ^8.1.1
version: 8.1.1
pinia-plugin-persistedstate:
specifier: ^4.2.0
version: 4.2.0
pkg-types:
specifier: ^1.2.1
version: 1.2.1
playwright:
specifier: ^1.49.1
version: 1.49.1
postcss:
specifier: ^8.4.49
version: 8.4.49
postcss-antd-fixes:
specifier: ^0.2.0
version: 0.2.0
postcss-html:
specifier: ^1.7.0
version: 1.7.0
postcss-import:
specifier: ^16.1.0
version: 16.1.0
postcss-preset-env:
specifier: ^10.1.2
version: 10.1.2
postcss-scss:
specifier: ^4.0.9
version: 4.0.9
prettier:
specifier: ^3.4.2
version: 3.4.2
prettier-plugin-tailwindcss:
specifier: ^0.6.9
version: 0.6.9
publint:
specifier: ^0.2.12
version: 0.2.12
qrcode:
specifier: ^1.5.4
version: 1.5.4
radix-vue:
specifier: ^1.9.11
version: 1.9.11
resolve.exports:
specifier: ^2.0.3
version: 2.0.3
rimraf:
specifier: ^6.0.1
version: 6.0.1
rollup:
specifier: ^4.28.1
version: 4.28.1
rollup-plugin-visualizer:
specifier: ^5.12.0
version: 5.12.0
sass:
specifier: 1.80.6
version: 1.80.6
sortablejs:
specifier: ^1.15.6
version: 1.15.6
stylelint:
specifier: ^16.12.0
version: 16.12.0
stylelint-config-recess-order:
specifier: ^5.1.1
version: 5.1.1
stylelint-config-recommended:
specifier: ^14.0.1
version: 14.0.1
stylelint-config-recommended-scss:
specifier: ^14.1.0
version: 14.1.0
stylelint-config-recommended-vue:
specifier: ^1.5.0
version: 1.5.0
stylelint-config-standard:
specifier: ^36.0.1
version: 36.0.1
stylelint-order:
specifier: ^6.0.4
version: 6.0.4
stylelint-prettier:
specifier: ^5.0.2
version: 5.0.2
stylelint-scss:
specifier: ^6.10.0
version: 6.10.0
tailwind-merge:
specifier: ^2.5.5
version: 2.5.5
tailwindcss:
specifier: ^3.4.17
version: 3.4.17
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7
theme-colors:
specifier: ^0.1.0
version: 0.1.0
turbo:
specifier: ^2.3.3
version: 2.3.3
@@ -66,15 +402,60 @@ catalogs:
unbuild:
specifier: ^3.0.1
version: 3.0.1
vee-validate:
specifier: ^4.14.7
version: 4.14.7
vite:
specifier: ^6.0.5
version: 6.0.5
vite-plugin-compression:
specifier: ^0.5.1
version: 0.5.1
vite-plugin-dts:
specifier: 4.2.1
version: 4.2.1
vite-plugin-html:
specifier: ^3.2.2
version: 3.2.2
vite-plugin-lazy-import:
specifier: ^1.0.7
version: 1.0.7
vite-plugin-pwa:
specifier: ^0.21.1
version: 0.21.1
vite-plugin-vue-devtools:
specifier: ^7.6.8
version: 7.6.8
vitest:
specifier: ^2.1.8
version: 2.1.8
vue-eslint-parser:
specifier: ^9.4.3
version: 9.4.3
vue-i18n:
specifier: ^10.0.5
version: 10.0.5
vue-router:
specifier: ^4.5.0
version: 4.5.0
vue-tsc:
specifier: ^2.1.10
version: 2.1.10
vxe-pc-ui:
specifier: ^4.3.40
version: 4.3.40
vxe-table:
specifier: ^4.9.33
version: 4.9.33
watermark-js-plus:
specifier: ^1.5.7
version: 1.5.7
zod:
specifier: ^3.24.1
version: 3.24.1
zod-defaults:
specifier: ^0.1.3
version: 0.1.3
overrides:
'@ast-grep/napi': ^0.31.1
@@ -88,9 +469,15 @@ importers:
.:
dependencies:
'@fortawesome/fontawesome-free':
specifier: ^6.7.2
version: 6.7.2
'@vueup/vue-quill':
specifier: ^1.2.0
version: 1.2.0(vue@3.5.13(typescript@5.6.3))
axios:
specifier: ^1.10.0
version: 1.10.0
html2canvas:
specifier: ^1.4.1
version: 1.4.1
@@ -196,10 +583,10 @@ importers:
version: 3.0.1(typescript@5.6.3)(vue-tsc@2.1.10(typescript@5.6.3))(vue@3.5.13(typescript@5.6.3))
vite:
specifier: 'catalog:'
version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
vitest:
specifier: 'catalog:'
version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0)
version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.6.3)
@@ -930,7 +1317,7 @@ importers:
version: 12.0.0(typescript@5.7.2)
'@vueuse/integrations':
specifier: 'catalog:'
version: 12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)
version: 12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)
qrcode:
specifier: 'catalog:'
version: 1.5.4
@@ -2804,6 +3191,10 @@ packages:
'@floating-ui/vue@1.1.5':
resolution: {integrity: sha512-ynL1p5Z+woPVSwgMGqeDrx6HrJfGIDzFyESFkyqJKilGW1+h/8yVY29Khn0LaU6wHBRwZ13ntG6reiHWK6jyzw==}
'@fortawesome/fontawesome-free@6.7.2':
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
engines: {node: '>=6'}
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
@@ -2867,20 +3258,20 @@ packages:
resolution: {integrity: sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==}
engines: {node: '>= 16'}
'@intlify/message-compiler@12.0.0-alpha.2':
resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==}
'@intlify/message-compiler@12.0.0-alpha.3':
resolution: {integrity: sha512-mDDTN3gfYOHhBnpnlby19UHyvMaOnzdlpsIrxUfs44R/vCATfn8pMOkE8PXD2t410xkocEj3FpDcC9XC/0v4Dg==}
engines: {node: '>= 16'}
'@intlify/shared@10.0.5':
resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==}
engines: {node: '>= 16'}
'@intlify/shared@11.1.3':
resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==}
'@intlify/shared@11.1.10':
resolution: {integrity: sha512-6ZW/f3Zzjxfa1Wh0tYQI5pLKUtU+SY7l70pEG+0yd0zjcsYcK0EBt6Fz30Dy0tZhEqemziQQy2aNU3GJzyrMUA==}
engines: {node: '>= 16'}
'@intlify/shared@12.0.0-alpha.2':
resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==}
'@intlify/shared@12.0.0-alpha.3':
resolution: {integrity: sha512-ryaNYBvxQjyJUmVuBBg+HHUsmGnfxcEUPR0NCeG4/K9N2qtyFE35C80S15IN6iYFE2MGWLN7HfOSyg0MXZIc9w==}
engines: {node: '>= 16'}
'@intlify/unplugin-vue-i18n@6.0.1':
@@ -4137,6 +4528,9 @@ packages:
peerDependencies:
axios: '>= 0.17.0'
axios@1.10.0:
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
axios@1.7.9:
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
@@ -9505,7 +9899,7 @@ snapshots:
'@babel/traverse': 7.26.4
'@babel/types': 7.26.3
convert-source-map: 2.0.0
debug: 4.4.0
debug: 4.4.0(supports-color@9.4.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -10166,7 +10560,7 @@ snapshots:
'@babel/parser': 7.26.3
'@babel/template': 7.25.9
'@babel/types': 7.26.3
debug: 4.4.0
debug: 4.4.0(supports-color@9.4.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -11302,6 +11696,8 @@ snapshots:
- '@vue/composition-api'
- vue
'@fortawesome/fontawesome-free@6.7.2': {}
'@gar/promisify@1.1.3': {}
'@humanfs/core@0.19.1': {}
@@ -11343,8 +11739,8 @@ snapshots:
'@intlify/bundle-utils@10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))':
dependencies:
'@intlify/message-compiler': 12.0.0-alpha.2
'@intlify/shared': 12.0.0-alpha.2
'@intlify/message-compiler': 12.0.0-alpha.3
'@intlify/shared': 12.0.0-alpha.3
acorn: 8.14.0
escodegen: 2.1.0
estree-walker: 2.0.2
@@ -11365,23 +11761,23 @@ snapshots:
'@intlify/shared': 10.0.5
source-map-js: 1.2.1
'@intlify/message-compiler@12.0.0-alpha.2':
'@intlify/message-compiler@12.0.0-alpha.3':
dependencies:
'@intlify/shared': 12.0.0-alpha.2
'@intlify/shared': 12.0.0-alpha.3
source-map-js: 1.2.1
'@intlify/shared@10.0.5': {}
'@intlify/shared@11.1.3': {}
'@intlify/shared@11.1.10': {}
'@intlify/shared@12.0.0-alpha.2': {}
'@intlify/shared@12.0.0-alpha.3': {}
'@intlify/unplugin-vue-i18n@6.0.1(@vue/compiler-dom@3.5.13)(eslint@9.17.0(jiti@2.4.2))(rollup@4.28.1)(typescript@5.7.2)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
'@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))
'@intlify/shared': 11.1.3
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
'@intlify/shared': 11.1.10
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
'@rollup/pluginutils': 5.1.4(rollup@4.28.1)
'@typescript-eslint/scope-manager': 8.18.1
'@typescript-eslint/typescript-estree': 8.18.1(typescript@5.7.2)
@@ -11403,11 +11799,11 @@ snapshots:
- supports-color
- typescript
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@babel/parser': 7.26.3
optionalDependencies:
'@intlify/shared': 11.1.3
'@intlify/shared': 11.1.10
'@vue/compiler-dom': 3.5.13
vue: 3.5.13(typescript@5.7.2)
vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.2))
@@ -12391,7 +12787,7 @@ snapshots:
'@babel/core': 7.26.0
'@babel/plugin-transform-typescript': 7.26.3(@babel/core@7.26.0)
'@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0)
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
vue: 3.5.13(typescript@5.6.3)
transitivePeerDependencies:
- supports-color
@@ -12403,7 +12799,7 @@ snapshots:
'@vitejs/plugin-vue@5.2.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1))(vue@3.5.13(typescript@5.6.3))':
dependencies:
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
vue: 3.5.13(typescript@5.6.3)
'@vitest/expect@2.1.8':
@@ -12420,15 +12816,6 @@ snapshots:
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)
optional: true
'@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0))':
dependencies:
'@vitest/spy': 2.1.8
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
'@vitest/pretty-format@2.1.8':
dependencies:
@@ -12650,14 +13037,14 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)':
'@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)':
dependencies:
'@vueuse/core': 12.0.0(typescript@5.7.2)
'@vueuse/shared': 12.0.0(typescript@5.7.2)
vue: 3.5.13(typescript@5.7.2)
optionalDependencies:
async-validator: 4.2.5
axios: 1.7.9
axios: 1.10.0
change-case: 5.4.4
focus-trap: 7.6.2
nprogress: 0.2.0
@@ -12918,6 +13305,14 @@ snapshots:
fast-deep-equal: 3.1.3
is-buffer: 2.0.5
axios@1.10.0:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.1
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.7.9:
dependencies:
follow-redirects: 1.15.9
@@ -13694,10 +14089,6 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.0:
dependencies:
ms: 2.1.3
debug@4.4.0(supports-color@9.4.0):
dependencies:
ms: 2.1.3
@@ -15375,7 +15766,7 @@ snapshots:
dependencies:
chalk: 5.3.0
commander: 12.1.0
debug: 4.4.0
debug: 4.4.0(supports-color@9.4.0)
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.2.5
@@ -18124,25 +18515,6 @@ snapshots:
- sugarss
- supports-color
- terser
optional: true
vite-node@2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite-plugin-compression@0.5.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)):
dependencies:
@@ -18264,18 +18636,6 @@ snapshots:
less: 4.2.1
sass: 1.80.6
terser: 5.37.0
optional: true
vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0):
dependencies:
esbuild: 0.24.0
postcss: 8.4.49
rollup: 4.28.1
optionalDependencies:
'@types/node': 22.10.2
fsevents: 2.3.3
less: 4.2.1
terser: 5.37.0
vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1):
dependencies:
@@ -18291,19 +18651,6 @@ snapshots:
terser: 5.37.0
yaml: 2.6.1
vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1):
dependencies:
esbuild: 0.24.0
postcss: 8.4.49
rollup: 4.28.1
optionalDependencies:
'@types/node': 22.10.2
fsevents: 2.3.3
jiti: 2.4.2
less: 4.2.1
terser: 5.37.0
yaml: 2.6.1
vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0):
dependencies:
'@vitest/expect': 2.1.8
@@ -18339,43 +18686,6 @@ snapshots:
- sugarss
- supports-color
- terser
optional: true
vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0):
dependencies:
'@vitest/expect': 2.1.8
'@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0))
'@vitest/pretty-format': 2.1.8
'@vitest/runner': 2.1.8
'@vitest/snapshot': 2.1.8
'@vitest/spy': 2.1.8
'@vitest/utils': 2.1.8
chai: 5.1.2
debug: 4.4.0
expect-type: 1.1.0
magic-string: 0.30.17
pathe: 1.1.2
std-env: 3.8.0
tinybench: 2.9.0
tinyexec: 0.3.1
tinypool: 1.0.2
tinyrainbow: 1.2.0
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
vite-node: 2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.2
happy-dom: 15.11.7
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vscode-languageserver-textdocument@1.0.12: {}