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
CI / CI OK (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
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
CI / CI OK (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
This commit is contained in:
BIN
apps/web-antd/public/audio/sys.mp3
Normal file
BIN
apps/web-antd/public/audio/sys.mp3
Normal file
Binary file not shown.
@@ -7,6 +7,13 @@ import { requestClient } from '#/api/request';
|
|||||||
export async function uploadFile(data: any) {
|
export async function uploadFile(data: any) {
|
||||||
return requestClient.upload('upload/image', data);
|
return requestClient.upload('upload/image', data);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 文件上传 - 聊天信息
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function uploadChatFile(data: any) {
|
||||||
|
return requestClient.upload('upload/chat-file', data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过文件id集合获取文件信息
|
* 通过文件id集合获取文件信息
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ import type { DescItem } from '#/components/description';
|
|||||||
|
|
||||||
import { isFunction } from '@vben/utils';
|
import { isFunction } from '@vben/utils';
|
||||||
|
|
||||||
|
import SysAudioMp3 from '/public/audio/sys.mp3';
|
||||||
|
|
||||||
|
|
||||||
|
export const playOnceAndDestroySys = () => {
|
||||||
|
const audio = new Audio(SysAudioMp3);
|
||||||
|
|
||||||
|
// 播放音频
|
||||||
|
audio.play().catch((error) => {
|
||||||
|
console.error('音频播放失败:', error);
|
||||||
|
audio.remove(); // 如果播放失败,也移除音频对象
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放结束后销毁音频对象
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
audio.remove(); // 移除DOM中的audio元素(如果是动态创建的)
|
||||||
|
audio.src = ''; // 清空音频源以释放内存
|
||||||
|
});
|
||||||
|
};
|
||||||
export const omit = (obj: any, keysToOmit: string[]) => {
|
export const omit = (obj: any, keysToOmit: string[]) => {
|
||||||
// 如果 obj 不是对象或者 keysToOmit 不是数组,则直接返回 obj
|
// 如果 obj 不是对象或者 keysToOmit 不是数组,则直接返回 obj
|
||||||
if (typeof obj !== 'object' || !Array.isArray(keysToOmit)) {
|
if (typeof obj !== 'object' || !Array.isArray(keysToOmit)) {
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ const prefix = 'chat-friends/';
|
|||||||
export async function getChatFriendsListApi(data: any = {}) {
|
export async function getChatFriendsListApi(data: any = {}) {
|
||||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
}
|
}
|
||||||
|
// chat-friends/messages-by-room-id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录的诊所信息
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function getChatMessageListApi(data: any = {}) {
|
||||||
|
// return requestClient.post<any>(`${prefix}messages-by-room-id`, { params: data });
|
||||||
|
return requestClient.post<any>(`${prefix}messages-by-room-id`, data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,77 +1,8 @@
|
|||||||
<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>
|
<script setup>
|
||||||
import {ref, computed} from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Avatar } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useChatStore } from '../stores/chat.ts';
|
import { useChatStore } from '../stores/chat.ts';
|
||||||
import { useThemeStore } from '../stores/theme.ts';
|
import { useThemeStore } from '../stores/theme.ts';
|
||||||
|
|
||||||
@@ -86,7 +17,10 @@ const callStatus = computed(() => {
|
|||||||
if (!chatStore.currentFriend) return null;
|
if (!chatStore.currentFriend) return null;
|
||||||
|
|
||||||
// 检查是否正在与当前好友通话
|
// 检查是否正在与当前好友通话
|
||||||
if (chatStore.currentCall && chatStore.currentCall.peerId === chatStore.currentFriend.id) {
|
if (
|
||||||
|
chatStore.currentCall &&
|
||||||
|
chatStore.currentCall.peerId === chatStore.currentFriend.id
|
||||||
|
) {
|
||||||
return chatStore.callConnectionStatus || '通话中';
|
return chatStore.callConnectionStatus || '通话中';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,24 +32,33 @@ const callStatusText = computed(() => {
|
|||||||
if (!callStatus.value) return '';
|
if (!callStatus.value) return '';
|
||||||
|
|
||||||
switch (callStatus.value) {
|
switch (callStatus.value) {
|
||||||
case '等待对方接听...':
|
case '对方已拒绝': {
|
||||||
return '📞 呼叫中...';
|
|
||||||
case '通话中':
|
|
||||||
return '📞 通话中';
|
|
||||||
case '对方已拒绝':
|
|
||||||
return '❌ 已拒绝';
|
return '❌ 已拒绝';
|
||||||
case '对方无人接听':
|
}
|
||||||
return '🕒 未接听';
|
case '对方忙线中': {
|
||||||
case '对方忙线中':
|
|
||||||
return '🚫 忙线中';
|
return '🚫 忙线中';
|
||||||
default:
|
}
|
||||||
return '📞 ' + callStatus.value;
|
case '对方无人接听': {
|
||||||
|
return '🕒 未接听';
|
||||||
|
}
|
||||||
|
case '等待对方接听...': {
|
||||||
|
return '📞 呼叫中...';
|
||||||
|
}
|
||||||
|
case '通话中': {
|
||||||
|
return '📞 通话中';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return `📞 ${callStatus.value}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 点击外部关闭菜单
|
// 点击外部关闭菜单
|
||||||
const handleClickOutside = (e) => {
|
const handleClickOutside = (e) => {
|
||||||
if (!e.target.closest('.more-btn') && !e.target.closest('.more-actions-menu')) {
|
if (
|
||||||
|
!e.target.closest('.more-btn') &&
|
||||||
|
!e.target.closest('.more-actions-menu')
|
||||||
|
) {
|
||||||
showMoreActions.value = false;
|
showMoreActions.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -158,6 +101,85 @@ const viewFriendProfile = () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="{ dark: themeStore.isDarkMode }" class="chat-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="friend-info">
|
||||||
|
<div
|
||||||
|
:style="{ background: chatStore.currentFriend?.color }"
|
||||||
|
class="friend-avatar"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
v-if="chatStore.currentFriend?.avatar"
|
||||||
|
:size="48"
|
||||||
|
:src="chatStore.currentFriend?.avatar"
|
||||||
|
/>
|
||||||
|
<Avatar v-else :size="48">
|
||||||
|
{{ chatStore.currentFriend?.nick_name.charAt(0) }}
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="friend-details">
|
||||||
|
<h3 class="friend-name">{{ chatStore.currentFriend?.nick_name }}</h3>
|
||||||
|
<p class="friend-status">
|
||||||
|
<span
|
||||||
|
:class="isFriendOnline ? 'online' : 'offline'"
|
||||||
|
class="status-dot"
|
||||||
|
></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"
|
||||||
|
title="语音通话"
|
||||||
|
@click="startVoiceCall"
|
||||||
|
>
|
||||||
|
<i class="fas fa-phone"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 视频通话按钮 -->
|
||||||
|
<button
|
||||||
|
class="action-btn video-call-btn"
|
||||||
|
title="视频通话"
|
||||||
|
@click="startVideoCall"
|
||||||
|
>
|
||||||
|
<i class="fas fa-video"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 更多操作 -->
|
||||||
|
<button
|
||||||
|
class="action-btn more-btn"
|
||||||
|
title="更多操作"
|
||||||
|
@click="showMoreActions = !showMoreActions"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-header {
|
.chat-header {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|||||||
@@ -99,23 +99,23 @@ watch(
|
|||||||
@click="selectFriend(friend)"
|
@click="selectFriend(friend)"
|
||||||
>
|
>
|
||||||
<div :style="{ background: friend.color }" class="friend-avatar">
|
<div :style="{ background: friend.color }" class="friend-avatar">
|
||||||
<Avatar v-if="friend.avatar" :src="friend.avatar" />
|
<Avatar :size="48" v-if="friend.avatar" :src="friend.avatar" />
|
||||||
<Avatar v-else>{{ friend.nick_name.charAt(0) }}</Avatar>
|
<Avatar :size="48" v-else>{{ friend.nick_name.charAt(0) }}</Avatar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="friend-info">
|
<div class="friend-info">
|
||||||
<div class="friend-name">{{ friend.nick_name }}</div>
|
<div class="friend-name">{{ friend.nick_name }}</div>
|
||||||
<div class="friend-message">
|
<div class="friend-message">
|
||||||
{{ friend.lastMessage || '点击开始聊天' }}
|
{{ friend.last_message || '点击开始聊天' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="friend-meta">
|
<div class="friend-meta">
|
||||||
<div v-if="friend.lastMessageTime" class="message-time">
|
<div v-if="friend.un_read_count > 0" class="unread-badge">
|
||||||
{{ friend.lastMessageTime }}
|
{{ friend.un_read_count > 99 ? '99+' : friend.un_read_count }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="friend.unreadCount > 0" class="unread-badge">
|
<div v-if="friend.last_message_time" class="message-time">
|
||||||
{{ friend.unreadCount > 99 ? '99+' : friend.unreadCount }}
|
{{ friend.last_message_time }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,128 +1,9 @@
|
|||||||
<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>
|
<script setup>
|
||||||
import {ref, computed, watch, onMounted, onUnmounted} from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import {useMediaPreview} from "../composables/useMediaPreview.ts";
|
|
||||||
import { Image } from 'ant-design-vue';
|
import { Image } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useMediaPreview } from '../composables/useMediaPreview.ts';
|
||||||
// import VueEasyLightbox from 'vue-easy-lightbox';
|
// import VueEasyLightbox from 'vue-easy-lightbox';
|
||||||
import { useThemeStore } from '../stores/theme.ts';
|
import { useThemeStore } from '../stores/theme.ts';
|
||||||
|
|
||||||
@@ -141,8 +22,12 @@ const isPortrait = ref(false);
|
|||||||
const controlsTimeout = ref(null);
|
const controlsTimeout = ref(null);
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const showImagePreview = computed(() => visible.value && previewType.value === 'image');
|
const showImagePreview = computed(
|
||||||
const showVideoPreview = computed(() => visible.value && previewType.value === 'video');
|
() => visible.value && previewType.value === 'image',
|
||||||
|
);
|
||||||
|
const showVideoPreview = computed(
|
||||||
|
() => visible.value && previewType.value === 'video',
|
||||||
|
);
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
const progressPercent = computed(() => {
|
||||||
if (duration.value === 0) return 0;
|
if (duration.value === 0) return 0;
|
||||||
@@ -233,11 +118,9 @@ const toggleFullscreen = () => {
|
|||||||
const togglePictureInPicture = async () => {
|
const togglePictureInPicture = async () => {
|
||||||
if (videoPlayer.value) {
|
if (videoPlayer.value) {
|
||||||
try {
|
try {
|
||||||
if (document.pictureInPictureElement) {
|
await (document.pictureInPictureElement
|
||||||
await document.exitPictureInPicture();
|
? document.exitPictureInPicture()
|
||||||
} else {
|
: videoPlayer.value.requestPictureInPicture());
|
||||||
await videoPlayer.value.requestPictureInPicture();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('画中画功能不支持:', error);
|
console.error('画中画功能不支持:', error);
|
||||||
}
|
}
|
||||||
@@ -285,9 +168,9 @@ const shareMedia = async () => {
|
|||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: '分享媒体',
|
title: '分享媒体',
|
||||||
url: mediaData.value.url
|
url: mediaData.value.url,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log('分享取消或失败');
|
console.log('分享取消或失败');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -302,28 +185,32 @@ const handleKeydown = (event) => {
|
|||||||
if (!visible.value) return;
|
if (!visible.value) return;
|
||||||
|
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
case 'Escape':
|
case 'ArrowLeft': {
|
||||||
hidePreview();
|
|
||||||
break;
|
|
||||||
case 'Space':
|
|
||||||
if (showVideoPreview.value) {
|
|
||||||
event.preventDefault();
|
|
||||||
togglePlay();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
|
||||||
if (showVideoPreview.value && videoPlayer.value) {
|
if (showVideoPreview.value && videoPlayer.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
videoPlayer.value.currentTime -= 10;
|
videoPlayer.value.currentTime -= 10;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
}
|
||||||
|
case 'ArrowRight': {
|
||||||
if (showVideoPreview.value && videoPlayer.value) {
|
if (showVideoPreview.value && videoPlayer.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
videoPlayer.value.currentTime += 10;
|
videoPlayer.value.currentTime += 10;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'Escape': {
|
||||||
|
hidePreview();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Space': {
|
||||||
|
if (showVideoPreview.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听媒体数据变化
|
// 监听媒体数据变化
|
||||||
@@ -346,6 +233,140 @@ onUnmounted(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<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
|
||||||
|
:imgs="[{ src: mediaData }]"
|
||||||
|
:visible="showImagePreview"
|
||||||
|
@hide="hidePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 视频预览组件 -->
|
||||||
|
<div
|
||||||
|
v-if="showVideoPreview"
|
||||||
|
:class="{ dark: themeStore.isDarkMode }"
|
||||||
|
class="media-preview-overlay video-preview"
|
||||||
|
@click.self="hidePreview"
|
||||||
|
>
|
||||||
|
<div :class="{ portrait: isPortrait }" class="video-preview-container">
|
||||||
|
<button class="preview-close-btn" @click="hidePreview">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="video-content">
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
:src="mediaData"
|
||||||
|
autoplay
|
||||||
|
class="preview-video"
|
||||||
|
controls
|
||||||
|
@loadedmetadata="handleVideoLoad"
|
||||||
|
@pause="handleVideoPause"
|
||||||
|
@play="handleVideoPlay"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- 自定义视频控制栏 -->
|
||||||
|
<div v-if="showControls" class="video-controls">
|
||||||
|
<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="isPlaying ? 'fa-pause' : 'fa-play'" class="fas"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar" @click="seekTo">
|
||||||
|
<div
|
||||||
|
:style="{ width: `${progressPercent}%` }"
|
||||||
|
class="progress-fill"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
:style="{ left: `${progressPercent}%` }"
|
||||||
|
class="progress-thumb"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="control-btn" @click="toggleMute">
|
||||||
|
<i
|
||||||
|
:class="isMuted ? 'fa-volume-mute' : 'fa-volume-up'"
|
||||||
|
class="fas"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="volume-container">
|
||||||
|
<input
|
||||||
|
v-model="volume"
|
||||||
|
class="volume-slider"
|
||||||
|
max="1"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
type="range"
|
||||||
|
@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" title="下载" @click="downloadMedia">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" title="分享" @click="shareMedia">
|
||||||
|
<i class="fas fa-share"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title="画中画"
|
||||||
|
@click="togglePictureInPicture"
|
||||||
|
>
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.media-preview-overlay {
|
.media-preview-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,129 +1,14 @@
|
|||||||
<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>
|
<script setup>
|
||||||
import {computed} from 'vue';
|
import {computed} from 'vue';
|
||||||
|
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import {Avatar, Image} from 'ant-design-vue';
|
||||||
|
|
||||||
import previewMedia from '../composables/useMediaPreview.ts';
|
import previewMedia from '../composables/useMediaPreview.ts';
|
||||||
import {useThemeStore} from '../stores/theme.ts';
|
import {useThemeStore} from '../stores/theme.ts';
|
||||||
import AudioMessage from './AudioMessage.vue';
|
import AudioMessage from './AudioMessage.vue';
|
||||||
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -141,8 +26,12 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['show-user-profile']);
|
const emit = defineEmits(['show-user-profile']);
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
const avatarStyle = computed(() => ({
|
const avatarStyle = computed(() => ({
|
||||||
background: props.senderInfo.color
|
background: props.senderInfo.color,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const bubbleClasses = computed(() => {
|
const bubbleClasses = computed(() => {
|
||||||
@@ -176,8 +65,11 @@ const formatDuration = (seconds) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatTextWithLinks = (text) => {
|
const formatTextWithLinks = (text) => {
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
const urlRegex = /(https?:\/\/\S+)/g;
|
||||||
return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>');
|
return text.replaceAll(
|
||||||
|
urlRegex,
|
||||||
|
'<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>',
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUrl = (url) => {
|
const openUrl = (url) => {
|
||||||
@@ -186,11 +78,11 @@ const openUrl = (url) => {
|
|||||||
|
|
||||||
const getCallStatusText = (status) => {
|
const getCallStatusText = (status) => {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
'invite': '发起通话',
|
invite: '发起通话',
|
||||||
'accepted': '通话已接通',
|
accepted: '通话已接通',
|
||||||
'rejected': '通话被拒绝',
|
rejected: '通话被拒绝',
|
||||||
'ended': '通话已结束',
|
ended: '通话已结束',
|
||||||
'missed': '未接通话'
|
missed: '未接通话',
|
||||||
};
|
};
|
||||||
return statusMap[status] || '通话消息';
|
return statusMap[status] || '通话消息';
|
||||||
};
|
};
|
||||||
@@ -204,6 +96,163 @@ const handleVideoError = (event) => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="isSent ? 'flex-row-reverse' : 'flex-row'"
|
||||||
|
class="mb-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<!-- <div-->
|
||||||
|
<!-- :style="avatarStyle"-->
|
||||||
|
<!-- class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0 cursor-pointer"-->
|
||||||
|
<!-- @click="showUserProfile"-->
|
||||||
|
<!-- >-->
|
||||||
|
<div
|
||||||
|
v-if="isSent"
|
||||||
|
:style="avatarStyle"
|
||||||
|
class="rounded-full cursor-pointer"
|
||||||
|
@click="showUserProfile"
|
||||||
|
>
|
||||||
|
<Avatar v-if="userStore.userInfo?.avatar" :size="48" :src="userStore.userInfo?.avatar" />
|
||||||
|
<Avatar v-else :size="48">{{ userStore.userInfo?.nick_name.charAt(0) }}</Avatar>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:style="avatarStyle"
|
||||||
|
class="rounded-full cursor-pointer"
|
||||||
|
@click="showUserProfile"
|
||||||
|
>
|
||||||
|
<Avatar v-if="senderInfo.avatar" :size="48" :src="senderInfo.avatar" />
|
||||||
|
<Avatar v-else :size="48">{{ senderInfo.nick_name.charAt(0) }}</Avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息内容区域 -->
|
||||||
|
<div
|
||||||
|
:class="isSent ? 'items-end' : 'items-start'"
|
||||||
|
class="flex flex-col max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
|
||||||
|
>
|
||||||
|
<!-- 发送者名称 -->
|
||||||
|
<div v-if="isSent" class="mb-1 px-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ userStore.userInfo?.nick_name }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-1 px-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ senderInfo.nick_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息气泡 -->
|
||||||
|
<div :class="bubbleClasses" class="relative rounded-2xl p-4 shadow-sm">
|
||||||
|
<!-- 图片消息 -->
|
||||||
|
<div
|
||||||
|
v-if="message.type === 'image'"
|
||||||
|
class="relative cursor-pointer"
|
||||||
|
@click="handlePreviewMedia(message.content, 'image')"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
:src="message.content"
|
||||||
|
alt="图片消息"
|
||||||
|
class="rounded-lg max-w-full"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频消息 -->
|
||||||
|
<div
|
||||||
|
v-else-if="message.type === 'video'"
|
||||||
|
class="video-message relative cursor-pointer"
|
||||||
|
@click="handlePreviewMedia(message.content, 'video')"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:src="message.content"
|
||||||
|
class="rounded-lg max-w-full video-thumbnail"
|
||||||
|
preload="metadata"
|
||||||
|
@error="handleVideoError"
|
||||||
|
></video>
|
||||||
|
<div class="video-overlay">
|
||||||
|
<div class="play-button">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.duration" class="video-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
|
||||||
|
v-if="message.favicon"
|
||||||
|
:alt="message.domain"
|
||||||
|
:src="message.favicon"
|
||||||
|
/>
|
||||||
|
<i v-else class="fas fa-link"></i>
|
||||||
|
</div>
|
||||||
|
<div class="url-content">
|
||||||
|
<div class="url-title">{{ message.title || message.url }}</div>
|
||||||
|
<div v-if="message.description" class="url-description">
|
||||||
|
{{ message.description }}
|
||||||
|
</div>
|
||||||
|
<div class="url-domain">{{ message.domain }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.thumbnail" class="url-thumbnail">
|
||||||
|
<img :alt="message.title" :src="message.thumbnail" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.text" class="url-text">
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音频消息 -->
|
||||||
|
<AudioMessage
|
||||||
|
v-else-if="message.type === 'audio'"
|
||||||
|
:is-sent="isSent"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 通话消息 -->
|
||||||
|
<div
|
||||||
|
v-else-if="message.type === 'video-call' || message.type === 'voice-call'"
|
||||||
|
"
|
||||||
|
class="call-message"
|
||||||
|
>
|
||||||
|
<div class="call-info">
|
||||||
|
<i
|
||||||
|
:class="message.type === 'video-call' ? 'fa-video' : 'fa-phone'"
|
||||||
|
class="fas"
|
||||||
|
></i>
|
||||||
|
<span>{{ getCallStatusText(message.callStatus) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.duration" class="call-duration">
|
||||||
|
通话时长: {{ formatDuration(message.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本消息 -->
|
||||||
|
<div v-else class="whitespace-pre-wrap text-base leading-relaxed">
|
||||||
|
<span v-html="formatTextWithLinks(message.content)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息时间和状态 -->
|
||||||
|
<div
|
||||||
|
class="mt-1 flex items-center gap-1 px-2 text-xs text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -249,7 +298,11 @@ const handleVideoError = (event) => {
|
|||||||
.video-overlay {
|
.video-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(0, 0, 0, 0.3) 100%
|
||||||
|
);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,119 +1,17 @@
|
|||||||
<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>
|
<script setup>
|
||||||
import {ref, nextTick, provide, onMounted, onUnmounted} from 'vue';
|
import { nextTick, onMounted, onUnmounted, provide, ref } from 'vue';
|
||||||
import {useUserStore} from '../stores/user.ts';
|
|
||||||
import {useChatStore} from '../stores/chat.ts';
|
import { message } from 'ant-design-vue';
|
||||||
import {useThemeStore} from '../stores/theme.ts';
|
|
||||||
import { useFileUpload } from '../composables/useFileUpload.ts';
|
import { useFileUpload } from '../composables/useFileUpload.ts';
|
||||||
import { useRecording } from '../composables/useRecording.ts';
|
import { useRecording } from '../composables/useRecording.ts';
|
||||||
|
import { useChatStore } from '../stores/chat.ts';
|
||||||
|
import { useThemeStore } from '../stores/theme.ts';
|
||||||
|
import { useUserStore } from '../stores/user.ts';
|
||||||
import { sendMessage } from '../utils/request.ts';
|
import { sendMessage } from '../utils/request.ts';
|
||||||
import {message} from 'ant-design-vue';
|
|
||||||
import CustomTextarea from './CustomTextarea.vue';
|
import CustomTextarea from './CustomTextarea.vue';
|
||||||
import EmojiPicker from './EmojiPicker.vue';
|
import EmojiPicker from './EmojiPicker.vue';
|
||||||
import FileUploadPreview from './FileUploadPreview.vue';
|
import FileUploadPreview from './FileUploadPreview.vue';
|
||||||
import RecordingIndicator from "../components/RecordingIndicator.vue";
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
@@ -130,7 +28,7 @@ const {
|
|||||||
videoInput,
|
videoInput,
|
||||||
triggerFileInput,
|
triggerFileInput,
|
||||||
handleFileUpload,
|
handleFileUpload,
|
||||||
cancelUpload
|
cancelUpload,
|
||||||
} = useFileUpload();
|
} = useFileUpload();
|
||||||
|
|
||||||
// 使用录音组合式函数
|
// 使用录音组合式函数
|
||||||
@@ -144,10 +42,10 @@ const sendUploadedFile = () => {
|
|||||||
type: uploadPreview.value.type,
|
type: uploadPreview.value.type,
|
||||||
content: uploadPreview.value.url,
|
content: uploadPreview.value.url,
|
||||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||||
senderId: userStore.currentUser.id,
|
senderId: `${userStore.currentUser.id}`,
|
||||||
receiverId: chatStore.currentFriend.id,
|
receiverId: chatStore.currentFriend.id,
|
||||||
read: true,
|
read: true,
|
||||||
duration: 0
|
duration: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||||
@@ -157,8 +55,8 @@ const sendUploadedFile = () => {
|
|||||||
senderId: userStore.currentUser.id,
|
senderId: userStore.currentUser.id,
|
||||||
receiverId: chatStore.currentFriend.id,
|
receiverId: chatStore.currentFriend.id,
|
||||||
type: uploadPreview.value.type,
|
type: uploadPreview.value.type,
|
||||||
content: uploadPreview.value.url
|
content: uploadPreview.value.url,
|
||||||
}).catch(error => {
|
}).catch((error) => {
|
||||||
console.error('发送文件失败:', error);
|
console.error('发送文件失败:', error);
|
||||||
message.error('文件发送失败');
|
message.error('文件发送失败');
|
||||||
});
|
});
|
||||||
@@ -191,9 +89,11 @@ const sendTextMessage = async () => {
|
|||||||
type: messageType,
|
type: messageType,
|
||||||
content: messageContent,
|
content: messageContent,
|
||||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||||
senderId: userStore.currentUser.id,
|
senderId: `${userStore.currentUser.id}`,
|
||||||
|
receiverId: chatStore.currentFriend.id,
|
||||||
|
isSent: true,
|
||||||
read: true,
|
read: true,
|
||||||
duration: 0
|
duration: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
|
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
|
||||||
@@ -207,7 +107,7 @@ const sendTextMessage = async () => {
|
|||||||
receiverId: chatStore.currentFriend.id,
|
receiverId: chatStore.currentFriend.id,
|
||||||
roomId: chatStore.currentFriend.room_id,
|
roomId: chatStore.currentFriend.room_id,
|
||||||
type: messageType,
|
type: messageType,
|
||||||
content: messageContent
|
content: messageContent,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('发送消息失败:', error);
|
||||||
@@ -253,20 +153,22 @@ const handleFileFromPaste = (file, type) => {
|
|||||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
const maxSizeMB = maxSize / 1024 / 1024;
|
const maxSizeMB = maxSize / 1024 / 1024;
|
||||||
message.error(`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`);
|
message.error(
|
||||||
|
`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.addEventListener('load', (e) => {
|
||||||
uploadPreview.value = {
|
uploadPreview.value = {
|
||||||
type: type,
|
type,
|
||||||
url: e.target.result,
|
url: e.target.result,
|
||||||
name: file.nick_name,
|
name: file.nick_name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
file: file
|
file,
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
reader.onerror = (error) => {
|
reader.onerror = (error) => {
|
||||||
console.error('文件读取失败:', error);
|
console.error('文件读取失败:', error);
|
||||||
@@ -301,9 +203,11 @@ const handleAudioRecorded = (event) => {
|
|||||||
type: 'audio',
|
type: 'audio',
|
||||||
content: url,
|
content: url,
|
||||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||||
senderId: userStore.currentUser.id,
|
senderId: `${userStore.currentUser.id}`,
|
||||||
|
receiverId: chatStore.currentFriend.id,
|
||||||
|
isSent: true,
|
||||||
read: true,
|
read: true,
|
||||||
duration: duration
|
duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||||
@@ -313,9 +217,11 @@ const handleAudioRecorded = (event) => {
|
|||||||
senderId: userStore.currentUser.id,
|
senderId: userStore.currentUser.id,
|
||||||
receiverId: chatStore.currentFriend.id,
|
receiverId: chatStore.currentFriend.id,
|
||||||
roomId: chatStore.currentFriend.room_id,
|
roomId: chatStore.currentFriend.room_id,
|
||||||
|
isSent: true,
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
content: url
|
content: url,
|
||||||
}).catch(error => {
|
duration,
|
||||||
|
}).catch((error) => {
|
||||||
console.error('发送音频失败:', error);
|
console.error('发送音频失败:', error);
|
||||||
message.error('音频发送失败');
|
message.error('音频发送失败');
|
||||||
});
|
});
|
||||||
@@ -326,7 +232,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 点击外部关闭表情选择器
|
// 点击外部关闭表情选择器
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
if (!event.target.closest('.emoji-picker') && !event.target.closest('.function-btn')) {
|
if (
|
||||||
|
!event.target.closest('.emoji-picker') &&
|
||||||
|
!event.target.closest('.function-btn')
|
||||||
|
) {
|
||||||
showEmojiPicker.value = false;
|
showEmojiPicker.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -337,6 +246,107 @@ onUnmounted(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="{ dark: themeStore.isDarkMode }" class="message-input-area">
|
||||||
|
<!-- 文件上传预览 -->
|
||||||
|
<FileUploadPreview v-if="uploadPreview" />
|
||||||
|
<!-- 表情选择器 -->
|
||||||
|
<EmojiPicker v-if="showEmojiPicker" class="mb-10" @select="insertEmoji" />
|
||||||
|
|
||||||
|
<div class="input-container">
|
||||||
|
<!-- 功能按钮区域 -->
|
||||||
|
<div class="function-buttons">
|
||||||
|
<button
|
||||||
|
class="function-btn"
|
||||||
|
title="发送图片"
|
||||||
|
@click="triggerFileInput('image')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
<div class="btn-ripple"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="function-btn"
|
||||||
|
title="发送视频"
|
||||||
|
@click="triggerFileInput('video')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-video"></i>
|
||||||
|
<div class="btn-ripple"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:class="{ active: showEmojiPicker }"
|
||||||
|
class="function-btn"
|
||||||
|
title="选择表情"
|
||||||
|
@click="toggleEmojiPicker"
|
||||||
|
>
|
||||||
|
<i class="fas fa-smile"></i>
|
||||||
|
<div class="btn-ripple"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:class="{ recording: isRecording }"
|
||||||
|
class="function-btn record-btn"
|
||||||
|
title="按住录音"
|
||||||
|
@mousedown="startRecording"
|
||||||
|
@mouseleave="stopRecording"
|
||||||
|
@mouseup="stopRecording"
|
||||||
|
@touchend="stopRecording"
|
||||||
|
@touchstart="startRecording"
|
||||||
|
>
|
||||||
|
<i class="fas fa-microphone"></i>
|
||||||
|
<div class="btn-ripple"></div>
|
||||||
|
<div v-if="isRecording" class="recording-wave"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 录音状态指示器 -->
|
||||||
|
<!-- <RecordingIndicator />-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入框区域 -->
|
||||||
|
<div class="input-section">
|
||||||
|
<CustomTextarea
|
||||||
|
ref="messageInputRef"
|
||||||
|
v-model="messageText"
|
||||||
|
:auto-resize="true"
|
||||||
|
:detect-paste="true"
|
||||||
|
:max-rows="4"
|
||||||
|
class="message-textarea"
|
||||||
|
placeholder="输入消息... (支持拖拽文件)"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@paste-file="handlePasteFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="!messageText.trim() && !uploadPreview"
|
||||||
|
class="send-btn"
|
||||||
|
title="发送消息"
|
||||||
|
@click="sendTextMessage"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
<div class="send-ripple"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件输入框 -->
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="videoInput"
|
||||||
|
accept="video/*"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-input-area {
|
.message-input-area {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,39 +1,11 @@
|
|||||||
<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>
|
<script setup>
|
||||||
import {ref, nextTick, watch, computed} from 'vue';
|
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import {useUserStore} from '../stores/user.ts';
|
|
||||||
import {useChatStore} from '../stores/chat.ts';
|
|
||||||
import { MessageOutlined } from '@ant-design/icons-vue';
|
import { MessageOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useChatStore } from '../stores/chat.ts';
|
||||||
|
import { useUserStore } from '../stores/user.ts';
|
||||||
import MessageBubble from './MessageBubble.vue';
|
import MessageBubble from './MessageBubble.vue';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@@ -41,26 +13,43 @@ const chatStore = useChatStore();
|
|||||||
|
|
||||||
const messagesContainer = ref(null);
|
const messagesContainer = ref(null);
|
||||||
const loadingMessages = ref(false);
|
const loadingMessages = ref(false);
|
||||||
|
const loadingOlderMessages = ref(false); // 加载历史消息状态
|
||||||
|
const isLoading = ref(false); // 加载历史消息状态
|
||||||
|
// const chatStore.hasMore = ref(true); // 是否有更多历史消息
|
||||||
|
const oldScrollHeight = ref(0); // 已加载次数
|
||||||
|
|
||||||
// 获取发送者信息
|
// 获取发送者信息
|
||||||
const getSenderInfo = (message) => {
|
const getSenderInfo = (message) => {
|
||||||
if (message.senderId === userStore.currentUser.id) {
|
return message.senderId === userStore.currentUser.id
|
||||||
return {
|
? {
|
||||||
name: userStore.currentUser.nick_name,
|
nick_name: userStore.currentUser.nick_name,
|
||||||
avatar: userStore.currentUser.nick_name.charAt(0),
|
avatar: userStore.currentUser.nick_name.charAt(0),
|
||||||
color: userStore.currentUser.color || '#4cc9f0'
|
color: userStore.currentUser.color || '#4cc9f0',
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: chatStore.currentFriend?.nick_name || '未知用户',
|
|
||||||
avatar: chatStore.currentFriend?.nick_name.charAt(0) || '?',
|
|
||||||
color: chatStore.currentFriend?.color || '#ff6b6b'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
nick_name: chatStore.currentFriend?.nick_name || '未知用户',
|
||||||
|
avatar:
|
||||||
|
chatStore.currentFriend?.avatar ||
|
||||||
|
chatStore.currentFriend?.nick_name.charAt(0) ||
|
||||||
|
'?',
|
||||||
|
color: chatStore.currentFriend?.color || '#ff6b6b',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = (type = 0) => {
|
||||||
|
if (type === 1) {
|
||||||
|
nextTick(() => {
|
||||||
|
console.table('高度', {
|
||||||
|
o: oldScrollHeight.value,
|
||||||
|
n: messagesContainer.value.scrollHeight,
|
||||||
|
'-': messagesContainer.value.scrollHeight - oldScrollHeight.value,
|
||||||
|
});
|
||||||
|
messagesContainer.value.scrollTop =
|
||||||
|
messagesContainer.value.scrollHeight - oldScrollHeight.value;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||||
@@ -68,17 +57,119 @@ const scrollToBottom = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 加载历史消息
|
||||||
|
const loadOlderMessages = async () => {
|
||||||
|
if (loadingOlderMessages.value || !chatStore.hasMore) return;
|
||||||
|
|
||||||
|
oldScrollHeight.value = messagesContainer.value.scrollHeight;
|
||||||
|
loadingOlderMessages.value = true;
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 模拟网络请求延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||||
|
|
||||||
|
// 生成模拟的历史消息
|
||||||
|
chatStore.generateOldMessages();
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
loadingOlderMessages.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理滚动事件
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!messagesContainer.value) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
|
||||||
|
|
||||||
|
// 滚动到顶部时加载更多
|
||||||
|
if (
|
||||||
|
scrollTop < 100 &&
|
||||||
|
!loadingOlderMessages.value &&
|
||||||
|
chatStore.hasMore
|
||||||
|
) {
|
||||||
|
loadOlderMessages();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 监听消息变化,自动滚动到底部
|
// 监听消息变化,自动滚动到底部
|
||||||
watch(() => chatStore.messages.length, () => {
|
watch(
|
||||||
|
() => chatStore.messages.length,
|
||||||
|
() => {
|
||||||
|
if (isLoading.value === true) {
|
||||||
|
isLoading.value = false;
|
||||||
|
scrollToBottom(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 监听当前好友变化,重新加载消息
|
// 监听当前好友变化,重新加载消息
|
||||||
watch(() => chatStore.currentFriend, () => {
|
watch(
|
||||||
|
() => chatStore.currentFriend,
|
||||||
|
(newFriend, oldFriend) => {
|
||||||
|
if (newFriend?.id !== oldFriend?.id) {
|
||||||
|
// 重置加载状态
|
||||||
|
chatStore.hasMore = true;
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="messagesContainer"
|
||||||
|
class="message-list-background flex-1 overflow-y-auto bg-gray-50 p-5 dark:bg-gray-900"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<Spin :spinning="loadingMessages" tip="加载消息中...">
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="chatStore.messages.length === 0 && chatStore.currentFriend"
|
||||||
|
class="py-10 text-center text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<MessageOutlined class="mb-2 text-4xl" />
|
||||||
|
<p>开始与 {{ chatStore.currentFriend.nick_name }} 对话吧!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div v-if="!loadingMessages">
|
||||||
|
<!-- 顶部加载提示 -->
|
||||||
|
<div v-if="loadingOlderMessages" class="py-3 text-center">
|
||||||
|
<Spin size="small" tip="加载历史消息中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 没有更多消息提示 -->
|
||||||
|
<div
|
||||||
|
v-if="!chatStore.hasMore"
|
||||||
|
class="py-3 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
没有更多消息了
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(msg, index) in chatStore.messages"
|
||||||
|
:key="msg.id || index"
|
||||||
|
class="clear-both mb-6"
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
|
:is-sent="msg.isSent"
|
||||||
|
:message="msg"
|
||||||
|
:sender-info="getSenderInfo(msg)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.message-list-background {
|
.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-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>');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {nextTick, ref} from 'vue';
|
import {nextTick, ref} from 'vue';
|
||||||
|
|
||||||
import {message} from 'ant-design-vue';
|
import {message} from 'ant-design-vue';
|
||||||
|
import {uploadChatFile} from "#/api/core/upload";
|
||||||
|
|
||||||
export function useFileUpload() {
|
export function useFileUpload() {
|
||||||
const uploadPreview = ref(null);
|
const uploadPreview = ref(null);
|
||||||
@@ -80,23 +81,37 @@ export function useFileUpload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
uploadChatFile({
|
||||||
reader.addEventListener('load', (e) => {
|
file,
|
||||||
|
}).then((res) => {
|
||||||
uploadPreview.value = {
|
uploadPreview.value = {
|
||||||
type: detectedType,
|
type: detectedType,
|
||||||
url: e.target.result,
|
url: res.url,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
file,
|
file,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// const reader = new FileReader();
|
||||||
reader.onerror = (error) => {
|
// reader.addEventListener('load', (e) => {
|
||||||
console.error('文件读取失败:', error);
|
// console.log(file, '上传file')
|
||||||
message.error('文件读取失败,请重试');
|
// console.log(e, '上传')
|
||||||
};
|
// uploadPreview.value = {
|
||||||
|
// type: detectedType,
|
||||||
reader.readAsDataURL(file);
|
// url: e.target.result,
|
||||||
|
// name: file.name,
|
||||||
|
// size: file.size,
|
||||||
|
// file,
|
||||||
|
// };
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// console.log('上传文件')
|
||||||
|
// reader.onerror = (error) => {
|
||||||
|
// console.error('文件读取失败:', error);
|
||||||
|
// message.error('文件读取失败,请重试');
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// reader.readAsDataURL(file);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import {uploadChatFile} from "#/api/core/upload";
|
||||||
|
|
||||||
export function useRecording() {
|
export function useRecording() {
|
||||||
const isRecording = ref(false);
|
const isRecording = ref(false);
|
||||||
@@ -26,21 +27,23 @@ export function useRecording() {
|
|||||||
|
|
||||||
mediaRecorder.value.onstop = () => {
|
mediaRecorder.value.onstop = () => {
|
||||||
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
|
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
|
||||||
const audioUrl = URL.createObjectURL(audioBlob);
|
|
||||||
const duration = Math.floor(
|
const duration = Math.floor(
|
||||||
(Date.now() - recordingStartTime.value) / 1000,
|
(Date.now() - recordingStartTime.value) / 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(' aaa', 'ssssssssssssssss');
|
uploadChatFile({
|
||||||
|
file: audioBlob,
|
||||||
|
}).then((res) => {
|
||||||
// 触发音频消息发送事件
|
// 触发音频消息发送事件
|
||||||
const event = new CustomEvent('audioRecorded', {
|
const event = new CustomEvent('audioRecorded', {
|
||||||
detail: {
|
detail: {
|
||||||
url: audioUrl,
|
url: res.url,
|
||||||
duration,
|
duration,
|
||||||
blob: audioBlob,
|
blob: audioBlob,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
// 停止所有音频轨道
|
// 停止所有音频轨道
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
const login = (user: any) => {
|
const login = (user: any) => {
|
||||||
currentUser.value = user;
|
currentUser.value = user;
|
||||||
|
currentUser.value.id = `doctor-${user.id}`;
|
||||||
localStorage.setItem('chatUser', JSON.stringify(user));
|
localStorage.setItem('chatUser', JSON.stringify(user));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user