初始化

This commit is contained in:
2025-07-02 16:54:52 +08:00
parent 67f9f51816
commit adf9f73431
29 changed files with 1866 additions and 174 deletions

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

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

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

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

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

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

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

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

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

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

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

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