|
|
@ -67,9 +67,27 @@
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
<span class="timestamp">{{ formatTime(msg.created_at) }}</span>
|
|
|
|
<span class="timestamp">{{ formatTime(msg.created_at) }}</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="message-content" :class="{ 'current-user-content': isCurrentUser(msg.user_id) }">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 文本消息 -->
|
|
|
|
|
|
|
|
<div v-if="msg.message_type === 'text' || !msg.message_type"
|
|
|
|
|
|
|
|
class="message-content"
|
|
|
|
|
|
|
|
:class="{ 'current-user-content': isCurrentUser(msg.user_id) }">
|
|
|
|
{{ msg.message }}
|
|
|
|
{{ msg.message }}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 语音消息 -->
|
|
|
|
|
|
|
|
<div v-else-if="msg.message_type === 'audio'"
|
|
|
|
|
|
|
|
class="message-content audio-message"
|
|
|
|
|
|
|
|
:class="{ 'current-user-content': isCurrentUser(msg.user_id) }">
|
|
|
|
|
|
|
|
<div class="audio-player">
|
|
|
|
|
|
|
|
<button @click="playAudio(msg)" class="play-button">
|
|
|
|
|
|
|
|
<i class="audio-icon">🔊</i>
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<div class="audio-waveform">
|
|
|
|
|
|
|
|
<div class="duration">{{ formatAudioDuration(msg.audio_duration) }}</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="messages.length === 0" class="no-messages">
|
|
|
|
<div v-if="messages.length === 0" class="no-messages">
|
|
|
@ -88,7 +106,20 @@
|
|
|
|
placeholder="输入消息..."
|
|
|
|
placeholder="输入消息..."
|
|
|
|
@keyup.enter="sendMessage"
|
|
|
|
@keyup.enter="sendMessage"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
<button @click="sendMessage" :disabled="!newMessage.trim()">发送</button>
|
|
|
|
<button @click="sendMessage" :disabled="!newMessage.trim()" class="send-button">发送</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 语音消息按钮 -->
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
@mousedown="startRecording"
|
|
|
|
|
|
|
|
@mouseup="stopRecording"
|
|
|
|
|
|
|
|
@mouseleave="cancelRecording"
|
|
|
|
|
|
|
|
class="voice-button"
|
|
|
|
|
|
|
|
:class="{ 'recording': isRecording }"
|
|
|
|
|
|
|
|
title="按住说话"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<i class="mic-icon">🎤</i>
|
|
|
|
|
|
|
|
<span v-if="isRecording" class="recording-text">正在录音... {{ recordingDuration }}s</span>
|
|
|
|
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
@ -113,6 +144,9 @@
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 音频播放元素(隐藏) -->
|
|
|
|
|
|
|
|
<audio ref="audioPlayer" style="display: none;"></audio>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
@ -134,6 +168,14 @@ export default {
|
|
|
|
const newRoomName = ref('')
|
|
|
|
const newRoomName = ref('')
|
|
|
|
const showCreateRoomModal = ref(false)
|
|
|
|
const showCreateRoomModal = ref(false)
|
|
|
|
const messagesContainer = ref(null)
|
|
|
|
const messagesContainer = ref(null)
|
|
|
|
|
|
|
|
const audioPlayer = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 语音录制相关
|
|
|
|
|
|
|
|
const isRecording = ref(false)
|
|
|
|
|
|
|
|
const recordingDuration = ref(0)
|
|
|
|
|
|
|
|
const mediaRecorder = ref(null)
|
|
|
|
|
|
|
|
const audioChunks = ref([])
|
|
|
|
|
|
|
|
const recordingTimer = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
let socket = null
|
|
|
|
let socket = null
|
|
|
|
let currentUser = null
|
|
|
|
let currentUser = null
|
|
|
@ -159,14 +201,25 @@ export default {
|
|
|
|
if (socket) {
|
|
|
|
if (socket) {
|
|
|
|
socket.disconnect()
|
|
|
|
socket.disconnect()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 清除录音计时器
|
|
|
|
|
|
|
|
if (recordingTimer.value) {
|
|
|
|
|
|
|
|
clearInterval(recordingTimer.value)
|
|
|
|
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化Socket连接
|
|
|
|
// 初始化Socket连接
|
|
|
|
const initSocketConnection = () => {
|
|
|
|
const initSocketConnection = () => {
|
|
|
|
// 创建Socket连接
|
|
|
|
// 创建Socket连接
|
|
|
|
socket = io('//', {
|
|
|
|
const currentHost = window.location.hostname;
|
|
|
|
withCredentials: true
|
|
|
|
const url = `http://${currentHost}:5000`;
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 使用轮询模式避免WebSocket连接问题
|
|
|
|
|
|
|
|
socket = io(url, {
|
|
|
|
|
|
|
|
withCredentials: true,
|
|
|
|
|
|
|
|
transports: ['polling'],
|
|
|
|
|
|
|
|
path: '/socket.io'
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监听连接事件
|
|
|
|
// 监听连接事件
|
|
|
|
socket.on('connect', () => {
|
|
|
|
socket.on('connect', () => {
|
|
|
@ -282,7 +335,7 @@ export default {
|
|
|
|
notifications.value = []
|
|
|
|
notifications.value = []
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送消息
|
|
|
|
// 发送文本消息
|
|
|
|
const sendMessage = () => {
|
|
|
|
const sendMessage = () => {
|
|
|
|
if (!currentRoom.value || !newMessage.value.trim()) return
|
|
|
|
if (!currentRoom.value || !newMessage.value.trim()) return
|
|
|
|
|
|
|
|
|
|
|
@ -296,6 +349,142 @@ export default {
|
|
|
|
newMessage.value = ''
|
|
|
|
newMessage.value = ''
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 开始录音
|
|
|
|
|
|
|
|
const startRecording = async () => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
// 请求用户的麦克风权限
|
|
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 创建媒体录制器
|
|
|
|
|
|
|
|
mediaRecorder.value = new MediaRecorder(stream)
|
|
|
|
|
|
|
|
audioChunks.value = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 监听数据可用事件
|
|
|
|
|
|
|
|
mediaRecorder.value.addEventListener('dataavailable', event => {
|
|
|
|
|
|
|
|
if (event.data.size > 0) {
|
|
|
|
|
|
|
|
audioChunks.value.push(event.data)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 监听录制完成事件
|
|
|
|
|
|
|
|
mediaRecorder.value.addEventListener('stop', () => {
|
|
|
|
|
|
|
|
// 如果没有录制数据或录制被取消,直接返回
|
|
|
|
|
|
|
|
if (audioChunks.value.length === 0 || !isRecording.value) {
|
|
|
|
|
|
|
|
isRecording.value = false
|
|
|
|
|
|
|
|
recordingDuration.value = 0
|
|
|
|
|
|
|
|
stopMediaTracks(stream)
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 将录制的音频块转换为Blob
|
|
|
|
|
|
|
|
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 将Blob转换为Base64
|
|
|
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
|
|
|
reader.readAsDataURL(audioBlob)
|
|
|
|
|
|
|
|
reader.onloadend = () => {
|
|
|
|
|
|
|
|
const base64Audio = reader.result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 发送语音消息
|
|
|
|
|
|
|
|
socket.emit('send_audio_message', {
|
|
|
|
|
|
|
|
room_id: currentRoom.value.id,
|
|
|
|
|
|
|
|
audio_data: base64Audio,
|
|
|
|
|
|
|
|
audio_duration: recordingDuration.value
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 重置录音状态
|
|
|
|
|
|
|
|
isRecording.value = false
|
|
|
|
|
|
|
|
recordingDuration.value = 0
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 停止所有媒体轨道
|
|
|
|
|
|
|
|
stopMediaTracks(stream)
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 开始录音
|
|
|
|
|
|
|
|
mediaRecorder.value.start()
|
|
|
|
|
|
|
|
isRecording.value = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 启动计时器
|
|
|
|
|
|
|
|
recordingTimer.value = setInterval(() => {
|
|
|
|
|
|
|
|
recordingDuration.value++
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 限制最大录音时间为60秒
|
|
|
|
|
|
|
|
if (recordingDuration.value >= 60) {
|
|
|
|
|
|
|
|
stopRecording()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, 1000)
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
console.error('无法访问麦克风:', err)
|
|
|
|
|
|
|
|
alert('无法访问麦克风,请检查浏览器权限设置。')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
|
|
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 = []
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 停止所有媒体轨道
|
|
|
|
|
|
|
|
const stopMediaTracks = (stream) => {
|
|
|
|
|
|
|
|
stream.getTracks().forEach(track => track.stop())
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 播放音频
|
|
|
|
|
|
|
|
const playAudio = (message) => {
|
|
|
|
|
|
|
|
if (!message || message.message_type !== 'audio') return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (audioPlayer.value) {
|
|
|
|
|
|
|
|
// 停止当前正在播放的音频
|
|
|
|
|
|
|
|
audioPlayer.value.pause()
|
|
|
|
|
|
|
|
audioPlayer.value.currentTime = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 设置新的音频源
|
|
|
|
|
|
|
|
audioPlayer.value.src = message.message
|
|
|
|
|
|
|
|
audioPlayer.value.play()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化音频时长
|
|
|
|
|
|
|
|
const formatAudioDuration = (seconds) => {
|
|
|
|
|
|
|
|
if (!seconds) return '0:00'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const mins = Math.floor(seconds / 60)
|
|
|
|
|
|
|
|
const secs = Math.floor(seconds % 60)
|
|
|
|
|
|
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加系统通知
|
|
|
|
// 添加系统通知
|
|
|
|
const addNotification = (message) => {
|
|
|
|
const addNotification = (message) => {
|
|
|
|
notifications.value.push({
|
|
|
|
notifications.value.push({
|
|
|
@ -343,11 +532,19 @@ export default {
|
|
|
|
newRoomName,
|
|
|
|
newRoomName,
|
|
|
|
showCreateRoomModal,
|
|
|
|
showCreateRoomModal,
|
|
|
|
messagesContainer,
|
|
|
|
messagesContainer,
|
|
|
|
|
|
|
|
audioPlayer,
|
|
|
|
|
|
|
|
isRecording,
|
|
|
|
|
|
|
|
recordingDuration,
|
|
|
|
fetchRooms,
|
|
|
|
fetchRooms,
|
|
|
|
createRoom,
|
|
|
|
createRoom,
|
|
|
|
joinRoom,
|
|
|
|
joinRoom,
|
|
|
|
leaveRoom,
|
|
|
|
leaveRoom,
|
|
|
|
sendMessage,
|
|
|
|
sendMessage,
|
|
|
|
|
|
|
|
startRecording,
|
|
|
|
|
|
|
|
stopRecording,
|
|
|
|
|
|
|
|
cancelRecording,
|
|
|
|
|
|
|
|
playAudio,
|
|
|
|
|
|
|
|
formatAudioDuration,
|
|
|
|
isCurrentUser,
|
|
|
|
isCurrentUser,
|
|
|
|
formatTime
|
|
|
|
formatTime
|
|
|
|
}
|
|
|
|
}
|
|
|
@ -576,6 +773,53 @@ export default {
|
|
|
|
color: white;
|
|
|
|
color: white;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* 语音消息样式 */
|
|
|
|
|
|
|
|
.audio-message {
|
|
|
|
|
|
|
|
min-width: 120px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.audio-player {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.play-button {
|
|
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.audio-icon {
|
|
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.audio-waveform {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
height: 24px;
|
|
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
|
|
padding-right: 10px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.current-user-content .audio-waveform {
|
|
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.2);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.duration {
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.no-messages {
|
|
|
|
.no-messages {
|
|
|
|
text-align: center;
|
|
|
|
text-align: center;
|
|
|
|
padding: 20px;
|
|
|
|
padding: 20px;
|
|
|
@ -613,20 +857,74 @@ export default {
|
|
|
|
border-color: #8B4513;
|
|
|
|
border-color: #8B4513;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-input button {
|
|
|
|
.send-button {
|
|
|
|
background-color: #8B4513;
|
|
|
|
background-color: #8B4513;
|
|
|
|
color: white;
|
|
|
|
color: white;
|
|
|
|
border: none;
|
|
|
|
border: none;
|
|
|
|
padding: 10px 20px;
|
|
|
|
padding: 10px 20px;
|
|
|
|
border-radius: 4px;
|
|
|
|
border-radius: 4px;
|
|
|
|
cursor: pointer;
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
margin-right: 10px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-input button:disabled {
|
|
|
|
.send-button:disabled {
|
|
|
|
background-color: #ccc;
|
|
|
|
background-color: #ccc;
|
|
|
|
cursor: not-allowed;
|
|
|
|
cursor: not-allowed;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* 语音录制按钮样式 */
|
|
|
|
|
|
|
|
.voice-button {
|
|
|
|
|
|
|
|
background-color: #f1f1f1;
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.voice-button:hover {
|
|
|
|
|
|
|
|
background-color: #e0e0e0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.voice-button.recording {
|
|
|
|
|
|
|
|
background-color: #e74c3c;
|
|
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
|
|
box-shadow: 0 0 0 5px rgba(231, 76, 60, 0.3);
|
|
|
|
|
|
|
|
animation: pulse 1.5s infinite;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.mic-icon {
|
|
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.recording-text {
|
|
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
|
|
bottom: -25px;
|
|
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
color: #e74c3c;
|
|
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
|
|
0% {
|
|
|
|
|
|
|
|
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
70% {
|
|
|
|
|
|
|
|
box-shadow: 0 0 0 10px rgba(231, 76, 60, 0);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
100% {
|
|
|
|
|
|
|
|
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 模态框 */
|
|
|
|
/* 模态框 */
|
|
|
|
.modal-overlay {
|
|
|
|
.modal-overlay {
|
|
|
|
position: fixed;
|
|
|
|
position: fixed;
|
|
|
|