初始化
This commit is contained in:
95
src/components/AudioMessage.vue
Normal file
95
src/components/AudioMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="bg-gray-100 rounded-full p-3 flex items-center gap-3 min-w-60">
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="large"
|
||||
@click="togglePlay"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<component :is="isPlaying ? PauseOutlined : CaretRightOutlined" />
|
||||
</a-button>
|
||||
|
||||
<div class="flex-1">
|
||||
<!-- 音波动画 -->
|
||||
<div class="flex items-center gap-1 h-8 mb-1">
|
||||
<div
|
||||
v-for="i in 20"
|
||||
:key="i"
|
||||
class="bg-blue-500 rounded-full transition-all duration-150"
|
||||
:class="isPlaying ? 'animate-pulse' : ''"
|
||||
:style="{
|
||||
width: '3px',
|
||||
height: isPlaying ? `${Math.random() * 20 + 10}px` : '4px'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="w-full bg-gray-300 rounded-full h-1">
|
||||
<div
|
||||
class="bg-blue-500 h-1 rounded-full transition-all duration-100"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-600 flex-shrink-0">
|
||||
{{ formatDuration(message.duration || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const progress = ref(0);
|
||||
const audio = ref(null);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audio.value) {
|
||||
audio.value = new Audio(props.message.content);
|
||||
|
||||
audio.value.addEventListener('timeupdate', () => {
|
||||
if (audio.value.duration) {
|
||||
progress.value = (audio.value.currentTime / audio.value.duration) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
audio.value.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
progress.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>
|
||||
21
src/components/ChatArea.vue
Normal file
21
src/components/ChatArea.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 聊天头部 -->
|
||||
<ChatHeader />
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<MessageList class="flex-1" />
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<MessageInput />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import ChatHeader from './ChatHeader.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
import MessageInput from './MessageInput.vue';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
</script>
|
||||
33
src/components/ChatHeader.vue
Normal file
33
src/components/ChatHeader.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="p-5 border-b border-gray-200 bg-white">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4"
|
||||
:style="{ background: chatStore.currentFriend?.color }"
|
||||
>
|
||||
{{ chatStore.currentFriend?.name.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{ chatStore.currentFriend?.name }}</h3>
|
||||
<p class="text-sm flex items-center gap-1">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="isFriendOnline ? 'bg-green-500' : 'bg-red-500'"
|
||||
></span>
|
||||
<span :class="isFriendOnline ? 'text-green-600' : 'text-red-600'">
|
||||
{{ isFriendOnline ? '在线' : '离线' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const isFriendOnline = ref(true); // 简化处理,实际应该根据WebSocket状态判断
|
||||
</script>
|
||||
43
src/components/ConnectionStatus.vue
Normal file
43
src/components/ConnectionStatus.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed top-5 right-5 px-3 py-2 rounded-full text-sm font-medium z-50 flex items-center gap-2 transition-all"
|
||||
:class="statusClass"
|
||||
>
|
||||
<component :is="iconComponent" :class="{ 'animate-spin': chatStore.connectionStatus === 'connecting' }" />
|
||||
{{ chatStore.connectionStatusText }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { WifiOutlined, DisconnectOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
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>
|
||||
30
src/components/EmojiPicker.vue
Normal file
30
src/components/EmojiPicker.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="emoji-picker-container bg-white border border-gray-200 rounded-lg p-3 shadow-lg">
|
||||
<div class="grid grid-cols-8 gap-2 max-w-xs">
|
||||
<div
|
||||
v-for="emoji in emojiList"
|
||||
:key="emoji"
|
||||
class="w-8 h-8 flex items-center justify-center cursor-pointer rounded hover:bg-gray-100 text-lg transition-all hover:scale-110"
|
||||
@click="$emit('select', emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
const emojiList = ref([
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
|
||||
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
|
||||
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
|
||||
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
|
||||
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠',
|
||||
'😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨'
|
||||
]);
|
||||
</script>
|
||||
58
src/components/FileUploadPreview.vue
Normal file
58
src/components/FileUploadPreview.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
{{uploadPreview.type}}
|
||||
<!-- 文件缩略图 -->
|
||||
<div class="w-16 h-16 rounded-lg overflow-hidden bg-gray-200 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 truncate">{{ uploadPreview.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ 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>
|
||||
109
src/components/FriendList.vue
Normal file
109
src/components/FriendList.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-white border-r border-gray-200">
|
||||
<!-- 头部 -->
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-blue-600">聊天</h2>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="themeStore.toggleTheme"
|
||||
:title="themeStore.isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
class="flex items-center gap-1 px-3 py-1 rounded-full bg-gray-100 hover:bg-blue-500 hover:text-white transition-all"
|
||||
>
|
||||
<span v-if="themeStore.isDarkMode">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 40 40"><g fill="none" stroke-miterlimit="10"><path fill="#ffe236" stroke="#231f20" d="M10.82 19.9a9.179 9.179 0 0 0 15.671 6.491a9.18 9.18 0 0 0 0-12.982a9.18 9.18 0 0 0-12.982 0A9.18 9.18 0 0 0 10.82 19.9ZM23.43 6s.13-.72-1.1-2.93A11 11 0 0 0 20.72.79a1.05 1.05 0 0 0-1.45 0c-.623.667-1.16 1.41-1.6 2.21a8.8 8.8 0 0 0-1.11 3a1.1 1.1 0 0 0 .75 1.11c.88.208 1.785.299 2.69.27a10.3 10.3 0 0 0 2.68-.32A1.09 1.09 0 0 0 23.43 6Zm-6.86 28.05s-.13.72 1.1 2.93a11 11 0 0 0 1.61 2.23a1.05 1.05 0 0 0 1.45 0c.622-.668 1.16-1.41 1.6-2.21a8.9 8.9 0 0 0 1.11-2.93a1.1 1.1 0 0 0-.75-1.11a10.3 10.3 0 0 0-2.69-.34a10.3 10.3 0 0 0-2.69.32a1.09 1.09 0 0 0-.75 1.1zM12.49 7.64S12.07 7 9.64 6.35a11.6 11.6 0 0 0-2.72-.45a1.06 1.06 0 0 0-1 1c.037.922.188 1.835.45 2.72a8.8 8.8 0 0 0 1.26 2.88a1.1 1.1 0 0 0 1.37.26a10.6 10.6 0 0 0 2.12-1.68A10 10 0 0 0 12.75 9a1.09 1.09 0 0 0-.25-1.3zm15.02 24.72s.42.61 2.85 1.29c.886.258 1.799.409 2.72.45a1.06 1.06 0 0 0 1-1a11.2 11.2 0 0 0-.45-2.72a8.8 8.8 0 0 0-1.28-2.85a1.1 1.1 0 0 0-1.32-.26a10.6 10.6 0 0 0-2.12 1.68a10 10 0 0 0-1.68 2.13a1.09 1.09 0 0 0 .25 1.3zM6 16.57s-.72-.13-2.93 1.1a11 11 0 0 0-2.28 1.61a1.05 1.05 0 0 0 0 1.45c.667.623 1.41 1.16 2.21 1.6a8.8 8.8 0 0 0 3 1.11a1.1 1.1 0 0 0 1.11-.75c.208-.88.299-1.785.27-2.69a10.3 10.3 0 0 0-.32-2.68A1.09 1.09 0 0 0 6 16.57Zm28.05 6.86s.72.13 2.93-1.1a11 11 0 0 0 2.23-1.61a1.05 1.05 0 0 0 0-1.45a11.2 11.2 0 0 0-2.21-1.6a8.9 8.9 0 0 0-2.93-1.11a1.1 1.1 0 0 0-1.11.75a10.3 10.3 0 0 0-.34 2.69c-.013.907.095 1.811.32 2.69a1.09 1.09 0 0 0 1.1.75zM7.64 27.51s-.61.42-1.29 2.85a11.6 11.6 0 0 0-.45 2.72a1.06 1.06 0 0 0 1 1a11.2 11.2 0 0 0 2.72-.45a8.8 8.8 0 0 0 2.85-1.28a1.1 1.1 0 0 0 .26-1.32a10.6 10.6 0 0 0-1.68-2.12A10 10 0 0 0 9 27.25a1.09 1.09 0 0 0-1.3.25zm24.72-15.02s.61-.42 1.29-2.85c.258-.886.409-1.799.45-2.72a1.06 1.06 0 0 0-1-1a11.2 11.2 0 0 0-2.72.45a8.8 8.8 0 0 0-2.88 1.26A1.1 1.1 0 0 0 27.24 9a10.6 10.6 0 0 0 1.68 2.12a10 10 0 0 0 2.13 1.68a1.09 1.09 0 0 0 1.3-.25z" stroke-width="1"/><path stroke="#fff" stroke-linecap="round" d="M23.44 13.86a4.8 4.8 0 0 1 2.3 2.06" stroke-width="1"/></g></svg>
|
||||
</span>
|
||||
<span v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 64 64"><circle cx="32" cy="32.12" r="31.875" fill="#f5eb35"/><g fill="#e0cf35"><circle cx="29.32" cy="53.02" r="9.226"/><path d="M41.904 24.487a3.918 3.918 0 1 1-7.836-.002a3.918 3.918 0 0 1 7.836.002"/><circle cx="5.967" cy="36.54" r="3.845"/><circle cx="6.313" cy="18.917" r="2.195"/><path d="M20.967 19.656a3.433 3.433 0 1 1-6.866 0a3.433 3.433 0 0 1 6.866 0"/><circle cx="42.896" cy="11.07" r="4.835"/></g></svg>
|
||||
</span>
|
||||
<span class="text-xs">{{ themeStore.isDarkMode ? '亮色' : '暗色' }}</span>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<a-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索联系人..."
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="text-gray-400" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<!-- 好友列表 -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<a-spin :spinning="loadingFriends" tip="加载好友中...">
|
||||
<div v-if="!loadingFriends">
|
||||
<div
|
||||
v-for="friend in filteredFriends"
|
||||
:key="friend.id"
|
||||
class="friend-item p-3 rounded-xl mb-2 cursor-pointer transition-all duration-200 hover:bg-gray-100"
|
||||
:class="{ 'bg-blue-50 border-l-4 border-blue-500': chatStore.currentFriend?.id === friend.id }"
|
||||
@click="handleSwitchFriend(friend)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg mr-3 relative"
|
||||
:style="{ background: friend.color }"
|
||||
>
|
||||
{{ friend.name.charAt(0) }}
|
||||
<!-- 未读消息徽章 -->
|
||||
<a-badge
|
||||
v-if="friend.unreadCount > 0"
|
||||
:count="friend.unreadCount"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-gray-800 truncate">{{ friend.name }}</h4>
|
||||
<p class="text-sm text-gray-500 truncate">{{ friend.lastMessage || '点击开始聊天' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<UserOutlined />
|
||||
<span>当前用户: <span class="font-semibold text-blue-600">{{ userStore.currentUser?.name }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const loadingFriends = ref(false);
|
||||
|
||||
// 过滤后的好友列表
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchQuery.value) return chatStore.friends;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return chatStore.friends.filter(friend =>
|
||||
friend.name.toLowerCase().includes(query) ||
|
||||
friend.id.toString().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// 切换好友
|
||||
const handleSwitchFriend = (friend) => {
|
||||
chatStore.switchFriend(friend, userStore.currentUser.id);
|
||||
};
|
||||
</script>
|
||||
91
src/components/MediaPreview.vue
Normal file
91
src/components/MediaPreview.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<!-- 图片预览组件(使用 vue-easy-lightbox) -->
|
||||
<vue-easy-lightbox
|
||||
:visible="showImagePreview"
|
||||
:imgs="[{ src: mediaData?.url }]"
|
||||
@hide="closePreview"
|
||||
/>
|
||||
|
||||
<!-- 视频预览组件(带毛玻璃效果和关闭按钮) -->
|
||||
<div
|
||||
v-if="showVideoPreview"
|
||||
class="fixed inset-0 z-50 overflow-auto"
|
||||
@click.self="closePreview"
|
||||
>
|
||||
<!-- 毛玻璃背景 -->
|
||||
<div class="fixed inset-0 backdrop-filter backdrop-blur-lg bg-black bg-opacity-70"></div>
|
||||
|
||||
<!-- 主内容容器 -->
|
||||
<div class="relative flex items-center justify-center min-h-screen w-full p-4">
|
||||
<!-- 毛玻璃关闭按钮 -->
|
||||
<button
|
||||
class="fixed top-6 right-6 z-50 rounded-full p-3 backdrop-filter backdrop-blur-md bg-white/20 hover:bg-white/30 transition-all"
|
||||
@click="closePreview"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 视频播放器 -->
|
||||
<div class="relative z-10 max-w-4xl w-full">
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
:src="mediaData?.url"
|
||||
class="w-full rounded-xl shadow-2xl aspect-video"
|
||||
controls
|
||||
autoplay
|
||||
/>
|
||||
|
||||
<!-- 大播放按钮(非播放状态时显示) -->
|
||||
<div
|
||||
v-show="!playing"
|
||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||
@click="playVideo"
|
||||
>
|
||||
<div class="backdrop-filter backdrop-blur-md bg-black/30 p-6 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import VueEasyLightbox from 'vue-easy-lightbox';
|
||||
import {useMediaPreview} from "@/composables/useMediaPreview.js";
|
||||
|
||||
// 引入组合函数
|
||||
const { visible, mediaData, closePreview } = useMediaPreview();
|
||||
|
||||
// 区分图片和视频预览状态
|
||||
const showImagePreview = computed(() => visible.value && mediaData.value?.type === 'image');
|
||||
const showVideoPreview = computed(() => visible.value && mediaData.value?.type === 'video');
|
||||
|
||||
// 视频播放控制
|
||||
const videoPlayer = ref(null);
|
||||
const playing = ref(false);
|
||||
|
||||
const playVideo = () => {
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.play();
|
||||
playing.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 当媒体数据变化时重置播放状态
|
||||
watch(mediaData, () => {
|
||||
playing.value = false;
|
||||
});
|
||||
</script>
|
||||
221
src/components/MessageInput.vue
Normal file
221
src/components/MessageInput.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="p-5 border-t border-gray-200 bg-white">
|
||||
<!-- 文件上传预览 -->
|
||||
<FileUploadPreview v-if="uploadPreview" />
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- 功能按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a-tooltip title="发送图片">
|
||||
<a-button type="text" shape="circle" @click="triggerFileInput('image')">
|
||||
<PictureOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="发送视频">
|
||||
<a-button type="text" shape="circle" @click="triggerFileInput('video')">
|
||||
<VideoCameraOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="选择表情">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
:class="{ 'text-blue-500': showEmojiPicker }"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
<SmileOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="按住录音">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="large"
|
||||
:class="{ 'text-red-500 animate-pulse': isRecording }"
|
||||
@mousedown="startRecording"
|
||||
@mouseup="stopRecording"
|
||||
@mouseleave="stopRecording"
|
||||
@touchstart="startRecording"
|
||||
@touchend="stopRecording"
|
||||
>
|
||||
<AudioOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择器 -->
|
||||
<EmojiPicker v-if="showEmojiPicker" @select="insertEmoji" />
|
||||
|
||||
<!-- 输入框和发送按钮 -->
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<a-textarea
|
||||
ref="messageInput"
|
||||
v-model:value="messageText"
|
||||
placeholder="输入消息..."
|
||||
:auto-size="{ minRows: 1, maxRows: 4 }"
|
||||
class="resize-none"
|
||||
@keydown.enter.prevent="handleEnterKey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="large"
|
||||
:disabled="!messageText.trim() && !uploadPreview"
|
||||
@click="sendTextMessage"
|
||||
>
|
||||
<SendOutlined />
|
||||
</a-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, inject } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useFileUpload } from '@/composables/useFileUpload';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { sendMessage } from '@/utils/request';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
SmileOutlined,
|
||||
AudioOutlined,
|
||||
SendOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import EmojiPicker from './EmojiPicker.vue';
|
||||
import FileUploadPreview from './FileUploadPreview.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const messageText = ref('');
|
||||
const messageInput = ref(null);
|
||||
const showEmojiPicker = ref(false);
|
||||
|
||||
// 使用文件上传组合式函数
|
||||
const {
|
||||
uploadPreview,
|
||||
imageInput,
|
||||
videoInput,
|
||||
triggerFileInput,
|
||||
handleFileUpload
|
||||
} = useFileUpload();
|
||||
|
||||
// 使用录音组合式函数
|
||||
const { isRecording, startRecording, stopRecording } = useRecording();
|
||||
|
||||
// 发送文本消息
|
||||
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
|
||||
};
|
||||
|
||||
// 添加到本地消息列表
|
||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||
|
||||
// 发送到服务器
|
||||
try {
|
||||
await sendMessage({
|
||||
senderId: userStore.currentUser.id,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
type: messageType,
|
||||
content: messageContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
message.error('消息发送失败');
|
||||
}
|
||||
|
||||
// 清空输入框
|
||||
messageText.value = '';
|
||||
|
||||
// 重置文本框高度
|
||||
nextTick(() => {
|
||||
if (messageInput.value) {
|
||||
messageInput.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理回车键
|
||||
const handleEnterKey = (event) => {
|
||||
if (event.shiftKey) {
|
||||
// Shift + Enter 换行
|
||||
return;
|
||||
} else {
|
||||
// Enter 发送消息
|
||||
event.preventDefault();
|
||||
sendTextMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 切换表情选择器
|
||||
const toggleEmojiPicker = () => {
|
||||
showEmojiPicker.value = !showEmojiPicker.value;
|
||||
};
|
||||
|
||||
// 插入表情
|
||||
const insertEmoji = (emoji) => {
|
||||
messageText.value += emoji;
|
||||
showEmojiPicker.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
if (messageInput.value) {
|
||||
messageInput.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 点击外部关闭表情选择器
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest('.emoji-picker-container')) {
|
||||
showEmojiPicker.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
inject('clickOutsideHandler', handleClickOutside);
|
||||
</script>
|
||||
95
src/components/MessageItem.vue
Normal file
95
src/components/MessageItem.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<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';
|
||||
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>
|
||||
64
src/components/MessageList.vue
Normal file
64
src/components/MessageList.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div
|
||||
ref="messagesContainer"
|
||||
class="flex-1 p-5 overflow-y-auto bg-gray-50 message-list-background"
|
||||
>
|
||||
<a-spin :spinning="loadingMessages" tip="加载消息中...">
|
||||
<div v-if="!loadingMessages">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!chatStore.messages.length && chatStore.currentFriend" class="text-center text-gray-500 py-10">
|
||||
<MessageOutlined class="text-4xl mb-2" />
|
||||
<p>开始与 {{ chatStore.currentFriend.name }} 对话吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div
|
||||
v-for="(msg, index) in chatStore.messages"
|
||||
:key="index"
|
||||
class="mb-4 clear-both"
|
||||
:class="msg.senderId === userStore.currentUser.id ? 'text-right' : 'text-left'"
|
||||
>
|
||||
<MessageItem :message="msg" :is-sent="msg.senderId === userStore.currentUser.id" />
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, watch } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { MessageOutlined } from '@ant-design/icons-vue';
|
||||
import MessageItem from './MessageItem.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const messagesContainer = ref(null);
|
||||
const loadingMessages = ref(false);
|
||||
|
||||
// 滚动到底部
|
||||
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>')
|
||||
}
|
||||
</style>
|
||||
12
src/components/RecordingIndicator.vue
Normal file
12
src/components/RecordingIndicator.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50">
|
||||
<div class="bg-white 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">正在录音... 松开发送</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 录音状态指示器组件
|
||||
</script>
|
||||
Reference in New Issue
Block a user