|
|
|
@ -66,45 +66,53 @@
|
|
|
|
|
v-for="msg in messages"
|
|
|
|
|
:key="msg.id"
|
|
|
|
|
class="message-item"
|
|
|
|
|
:class="{ 'current-user-message': isCurrentUser(msg.user_id) }"
|
|
|
|
|
:class="{ 'system-notification': msg.isSystem }"
|
|
|
|
|
>
|
|
|
|
|
<div class="message-header" :class="{ 'current-user-header': isCurrentUser(msg.user_id) }">
|
|
|
|
|
<span class="username" :class="{ 'current-user': isCurrentUser(msg.user_id) }">
|
|
|
|
|
{{ msg.username }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="timestamp">{{ formatTime(msg.created_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 文本消息 -->
|
|
|
|
|
<div v-if="msg.message_type === 'text' || !msg.message_type"
|
|
|
|
|
class="message-content"
|
|
|
|
|
:class="{ 'current-user-content': isCurrentUser(msg.user_id) }">
|
|
|
|
|
<!-- 系统消息的特殊渲染 -->
|
|
|
|
|
<div v-if="msg.isSystem" class="notification-content">
|
|
|
|
|
{{ msg.message }}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 语音消息 -->
|
|
|
|
|
<div v-else-if="msg.message_type === 'audio'"
|
|
|
|
|
class="message-content audio-message"
|
|
|
|
|
:class="{
|
|
|
|
|
'current-user-content': isCurrentUser(msg.user_id),
|
|
|
|
|
'local-message': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<div class="audio-player">
|
|
|
|
|
<button @click="playAudio(msg)"
|
|
|
|
|
class="play-button"
|
|
|
|
|
:class="{
|
|
|
|
|
'playing': isMessagePlaying(msg.id),
|
|
|
|
|
'local': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<i class="audio-icon">{{ isMessagePlaying(msg.id) ? '⏸️' : '🔊' }}</i>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="audio-waveform"
|
|
|
|
|
:class="{
|
|
|
|
|
'playing': isMessagePlaying(msg.id),
|
|
|
|
|
'local': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<div class="duration">
|
|
|
|
|
{{ formatAudioDuration(msg.audio_duration) }}
|
|
|
|
|
<!-- 用户消息的正常渲染 -->
|
|
|
|
|
<div v-else class="user-message-content" :class="{ 'current-user-message': isCurrentUser(msg.user_id) }">
|
|
|
|
|
<div class="message-header" :class="{ 'current-user-header': isCurrentUser(msg.user_id) }">
|
|
|
|
|
<span class="username" :class="{ 'current-user': isCurrentUser(msg.user_id) }">
|
|
|
|
|
{{ msg.username }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="timestamp">{{ formatTime(msg.created_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 文本消息 -->
|
|
|
|
|
<div v-if="msg.message_type === 'text' || !msg.message_type"
|
|
|
|
|
class="message-content"
|
|
|
|
|
:class="{ 'current-user-content': isCurrentUser(msg.user_id) }">
|
|
|
|
|
{{ msg.message }}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 语音消息 -->
|
|
|
|
|
<div v-else-if="msg.message_type === 'audio'"
|
|
|
|
|
class="message-content audio-message"
|
|
|
|
|
:class="{
|
|
|
|
|
'current-user-content': isCurrentUser(msg.user_id),
|
|
|
|
|
'local-message': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<div class="audio-player">
|
|
|
|
|
<button @click="playAudio(msg)"
|
|
|
|
|
class="play-button"
|
|
|
|
|
:class="{
|
|
|
|
|
'playing': isMessagePlaying(msg.id),
|
|
|
|
|
'local': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<i class="audio-icon">{{ isMessagePlaying(msg.id) ? '⏸️' : '🔊' }}</i>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="audio-waveform"
|
|
|
|
|
:class="{
|
|
|
|
|
'playing': isMessagePlaying(msg.id),
|
|
|
|
|
'local': msg.isLocal
|
|
|
|
|
}">
|
|
|
|
|
<div class="duration">
|
|
|
|
|
{{ formatAudioDuration(msg.audio_duration) }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@ -114,10 +122,6 @@
|
|
|
|
|
<div v-if="messages.length === 0" class="no-messages">
|
|
|
|
|
<p>暂无消息,开始聊天吧!</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-for="notification in notifications" :key="notification.id" class="notification">
|
|
|
|
|
{{ notification.message }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 语音聊天参与者列表 -->
|
|
|
|
@ -215,6 +219,7 @@
|
|
|
|
|
<script>
|
|
|
|
|
import axios from 'axios'
|
|
|
|
|
import { io } from 'socket.io-client'
|
|
|
|
|
// 修复:移除未使用的 'computed'
|
|
|
|
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
@ -225,7 +230,7 @@ export default {
|
|
|
|
|
const rooms = ref([])
|
|
|
|
|
const currentRoom = ref(null)
|
|
|
|
|
const messages = ref([])
|
|
|
|
|
const notifications = ref([])
|
|
|
|
|
// 修复:移除未使用的 'notifications' 和 'notificationTimeout'
|
|
|
|
|
const newMessage = ref('')
|
|
|
|
|
const newRoomName = ref('')
|
|
|
|
|
const showCreateRoomModal = ref(false)
|
|
|
|
@ -262,7 +267,6 @@ export default {
|
|
|
|
|
|
|
|
|
|
let socket = null
|
|
|
|
|
let currentUser = null
|
|
|
|
|
let notificationCounter = 0
|
|
|
|
|
let audioAnalyser = null
|
|
|
|
|
let audioAnalyserInterval = null
|
|
|
|
|
|
|
|
|
@ -548,25 +552,23 @@ export default {
|
|
|
|
|
|
|
|
|
|
// 加入聊天室
|
|
|
|
|
const joinRoom = async (room) => {
|
|
|
|
|
if (currentRoom.value && currentRoom.value.id === room.id) return;
|
|
|
|
|
|
|
|
|
|
if (currentRoom.value) {
|
|
|
|
|
leaveRoom()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentRoom.value = room
|
|
|
|
|
messages.value = [] // 清空旧消息
|
|
|
|
|
// notifications.value = [] // 清空旧通知
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
currentRoom.value = room
|
|
|
|
|
messages.value = []
|
|
|
|
|
|
|
|
|
|
// 加入Socket.IO房间
|
|
|
|
|
socket.emit('join_room', { room_id: room.id })
|
|
|
|
|
|
|
|
|
|
// 获取历史消息
|
|
|
|
|
const response = await axios.get(`/api/chat/rooms/${room.id}/messages`)
|
|
|
|
|
messages.value = response.data.messages
|
|
|
|
|
|
|
|
|
|
// 如果启用了WebRTC功能,初始化
|
|
|
|
|
if (rtcActive.value) {
|
|
|
|
|
initWebRTC()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('加入聊天室失败:', error)
|
|
|
|
|
messages.value = response.data.messages.map(m => ({ ...m, isSystem: false }))
|
|
|
|
|
socket.emit('join_room', { room_id: room.id })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error.value = '无法加载聊天记录'
|
|
|
|
|
currentRoom.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -750,46 +752,23 @@ export default {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
const stopRecording = () => {
|
|
|
|
|
if (!mediaRecorder.value || mediaRecorder.value.state === 'inactive') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
mediaRecorder.value.stop()
|
|
|
|
|
|
|
|
|
|
// 清除计时器
|
|
|
|
|
if (recordingTimer.value) {
|
|
|
|
|
clearInterval(recordingTimer.value)
|
|
|
|
|
recordingTimer.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 取消录音
|
|
|
|
|
const cancelRecording = () => {
|
|
|
|
|
if (isRecording.value) {
|
|
|
|
|
const stopRecording = (forceStop = false) => {
|
|
|
|
|
if (mediaRecorder.value && isRecording.value) {
|
|
|
|
|
mediaRecorder.value.stop()
|
|
|
|
|
isRecording.value = false
|
|
|
|
|
|
|
|
|
|
if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
|
|
|
|
|
mediaRecorder.value.stop()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 清除计时器
|
|
|
|
|
if (recordingTimer.value) {
|
|
|
|
|
clearInterval(recordingTimer.value)
|
|
|
|
|
recordingTimer.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recordingDuration.value = 0
|
|
|
|
|
audioChunks.value = []
|
|
|
|
|
// 如果不是强制停止(即用户正常完成录音),则处理音频数据
|
|
|
|
|
if (!forceStop) {
|
|
|
|
|
// onstop事件会处理后续逻辑
|
|
|
|
|
} else {
|
|
|
|
|
audioChunks.value = [] // 强制停止时清空数据
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 停止所有媒体轨道
|
|
|
|
|
const stopMediaTracks = (stream) => {
|
|
|
|
|
stream.getTracks().forEach(track => track.stop())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 播放音频
|
|
|
|
|
const playAudio = (message) => {
|
|
|
|
|
if (!message || message.message_type !== 'audio') return
|
|
|
|
@ -938,12 +917,11 @@ export default {
|
|
|
|
|
|
|
|
|
|
// 添加系统通知
|
|
|
|
|
const addNotification = (message) => {
|
|
|
|
|
notifications.value.push({
|
|
|
|
|
id: `notification-${notificationCounter++}`,
|
|
|
|
|
messages.value.push({
|
|
|
|
|
id: `system-${Date.now()}`,
|
|
|
|
|
message,
|
|
|
|
|
timestamp: new Date()
|
|
|
|
|
isSystem: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -1401,13 +1379,19 @@ export default {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 修复:恢复被错误删除的 stopMediaTracks 函数
|
|
|
|
|
const stopMediaTracks = (stream) => {
|
|
|
|
|
if (stream) {
|
|
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
loading,
|
|
|
|
|
error,
|
|
|
|
|
rooms,
|
|
|
|
|
currentRoom,
|
|
|
|
|
messages,
|
|
|
|
|
notifications,
|
|
|
|
|
newMessage,
|
|
|
|
|
newRoomName,
|
|
|
|
|
showCreateRoomModal,
|
|
|
|
@ -1415,32 +1399,29 @@ export default {
|
|
|
|
|
audioPlayer,
|
|
|
|
|
isRecording,
|
|
|
|
|
recordingDuration,
|
|
|
|
|
currentPlayingId,
|
|
|
|
|
isPlaying,
|
|
|
|
|
rtcActive,
|
|
|
|
|
localStream,
|
|
|
|
|
peerConnections,
|
|
|
|
|
mediaDevices,
|
|
|
|
|
selectedAudioInput,
|
|
|
|
|
selectedAudioOutput,
|
|
|
|
|
audioLevel,
|
|
|
|
|
isMuted,
|
|
|
|
|
isLoadingRTC,
|
|
|
|
|
rtcError,
|
|
|
|
|
audioLevel,
|
|
|
|
|
rtcParticipants,
|
|
|
|
|
rtcError,
|
|
|
|
|
fetchRooms,
|
|
|
|
|
createRoom,
|
|
|
|
|
joinRoom,
|
|
|
|
|
leaveRoom,
|
|
|
|
|
sendMessage,
|
|
|
|
|
createRoom,
|
|
|
|
|
isCurrentUser,
|
|
|
|
|
formatTime,
|
|
|
|
|
toggleRecording,
|
|
|
|
|
stopRecording,
|
|
|
|
|
cancelRecording,
|
|
|
|
|
playAudio,
|
|
|
|
|
isMessagePlaying,
|
|
|
|
|
formatAudioDuration,
|
|
|
|
|
isCurrentUser,
|
|
|
|
|
formatTime,
|
|
|
|
|
toggleVoiceChat,
|
|
|
|
|
toggleMute
|
|
|
|
|
toggleMute,
|
|
|
|
|
// 修复:移除未定义的 'latestNotification'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@ -1622,12 +1603,32 @@ export default {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.message-item {
|
|
|
|
|
max-width: 80%;
|
|
|
|
|
align-self: flex-start;
|
|
|
|
|
margin: 10px 0;
|
|
|
|
|
display: flex; /* Use flexbox for alignment */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.current-user-message {
|
|
|
|
|
align-self: flex-end;
|
|
|
|
|
.message-item.system-notification {
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.notification-content {
|
|
|
|
|
background-color: #e9ecef;
|
|
|
|
|
color: #888;
|
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
padding: 5px 12px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.user-message-content {
|
|
|
|
|
max-width: 70%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.user-message-content.current-user-message {
|
|
|
|
|
margin-left: auto; /* Push to the right */
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.message-header {
|
|
|
|
@ -2275,4 +2276,16 @@ export default {
|
|
|
|
|
box-shadow: 0 0 0 0 rgba(139, 69, 19, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.top-notification {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 8px;
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
color: #6c757d;
|
|
|
|
|
font-size: 0.9em;
|
|
|
|
|
border-bottom: 1px solid #dee2e6;
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
}
|
|
|
|
|
</style>
|