<template>
|
<div class="call-container" :class="{'incoming-call': state === 'ringing'} ">
|
<div class="content-wrapper">
|
<!-- <div class="input-group">
|
<div class="input-item">
|
<span class="input-label">我的 userId:</span>
|
<input v-model="uid" class="input-field" />
|
</div>
|
|
<div class="input-item">
|
<span class="input-label">对方 userId:</span>
|
<input v-model="peerUid" class="input-field" />
|
</div>
|
</div>-->
|
|
<div class="button-group" v-if="false">
|
<button @click="connectWS" :disabled="connected" class="btn btn-connect">连接WS</button>
|
</div>
|
<!-- 头像-->
|
<div class="contactAvatar">
|
<img :src="defaultAvatar" alt=""></img>
|
</div>
|
<div class="status-group">
|
<strong v-if="state==='ringing'" class="status-text incoming-status">来电中...</strong>
|
<strong v-if="state==='calling'" class="status-text calling-status">呼叫中...</strong>
|
<strong v-if="state==='in-call'" class="status-text">通话时长:{{ durationText }}</strong>
|
<!-- 通话结束提示-->
|
<strong v-if="callEnded" class="status-text ended-status">{{ endMessage }}</strong>
|
</div>
|
<div class="button-group">
|
<button v-if="state === 'idle' && !receiveParameters" @click="requestCall" :disabled="!connected" class="btn btn-call">呼叫</button>
|
|
<button v-if="state==='ringing'" @click="acceptCall" class="btn btn-accept incoming-accept">接听</button>
|
<button v-if="state==='ringing'" @click="rejectCall" class="btn btn-reject incoming-reject">拒绝</button>
|
|
<button v-if="state==='calling' || state==='in-call'" @click="hangup" class="btn btn-hangup">挂断</button>
|
</div>
|
|
|
</div>
|
|
<!-- <div class="tip-message">
|
<small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
|
</div>-->
|
|
<!-- <div class="log-container">
|
<div class="log-box">
|
<div class="log-title">日志信息:</div>
|
<pre class="log-content">{{ logText }}</pre>
|
</div>
|
</div>-->
|
|
<audio ref="remoteAudio" autoplay class="hidden-audio"></audio>
|
</div>
|
</template>
|
|
<script setup>
|
import { useRoute } from 'vue-router'
|
import defaultAvatar from '@/appDataSource/appwork/defaultAvatar.svg'
|
const route = useRoute()
|
/** ✅ 你的 WS 地址前缀(后面拼 userId) */
|
const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId='
|
|
// 解析URL参数
|
const parseUrlParams = () => {
|
// 1. 优先从route.query.voiceparams获取参数
|
if (route.query && route.query.voiceparams) {
|
console.log('从route.query.voiceparams获取参数:', route.query.voiceparams);
|
try {
|
// 解码并解析voiceparams参数
|
const params = JSON.parse(decodeURIComponent(route.query.voiceparams));
|
console.log('解析后的voiceparams:', params);
|
|
if (params.peerUid) {
|
peerUid.value = String(params.peerUid);
|
log('🔗 从voiceparams获取peerUid:', params.peerUid);
|
}
|
|
// 如果是来电类型,直接触发来电状态
|
if (params.type === 'incoming') {
|
log('📞 voiceparams检测到来电,直接进入来电状态');
|
incomingFrom = String(params.from);
|
peerUid.value = incomingFrom;
|
acceptedByMe = false;
|
offeredByPeer = false;
|
|
state.value = 'ringing';
|
log('📞 收到来电 call,来自', incomingFrom);
|
|
// 开始来电提示(铃声和震动)
|
playRingtone();
|
startVibration();
|
}
|
} catch (e) {
|
log('❌ 解析voiceparams参数失败:', String(e));
|
}
|
}
|
|
// 2. 同时保留原有的URL参数解析逻辑,作为备选
|
const url = new URL(window.location.href);
|
const paramsStr = url.searchParams.get('params');
|
if (paramsStr) {
|
try {
|
const params = JSON.parse(decodeURIComponent(paramsStr));
|
if (params.peerUid) {
|
peerUid.value = String(params.peerUid);
|
log('🔗 从URL参数获取peerUid:', params.peerUid);
|
}
|
} catch (e) {
|
log('❌ 解析URL参数失败:', String(e));
|
}
|
}
|
}
|
|
// 从URL参数或本地存储获取用户ID
|
const getUserIdFromStorage = () => {
|
const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId')
|
if (userId) {
|
log('🔗 从本地存储获取userId:', userId)
|
return userId
|
}
|
return '3' // 默认值
|
}
|
|
const uid = ref('3')
|
const peerUid = ref('2')
|
|
const connected = ref(false)
|
const state = ref('idle') // idle | calling | ringing | in-call
|
const logText = ref('')
|
const remoteAudio = ref(null)
|
// 通话结束相关
|
const callEnded = ref(false)
|
const endMessage = ref('对方已挂断')
|
const endTimer = ref(null)
|
|
/**
|
* 根据状态返回对应颜色
|
*/
|
const getStateColor = (state) => {
|
const colors = {
|
'idle': '#666',
|
'calling': '#2196F3',
|
'ringing': '#FF9800',
|
'in-call': '#4CAF50'
|
}
|
return colors[state] || '#666'
|
}
|
|
let ws = null
|
let pc = null
|
let localStream = null
|
const pendingCandidates = []
|
|
// 来电暂存
|
let incomingFrom = null
|
let acceptedByMe = false // 我是否已经点了接听
|
let offeredByPeer = false // 是否已收到对方 offer(用于流程判断)
|
|
// 计时
|
let timer = null
|
const seconds = ref(0)
|
const durationText = computed(() => {
|
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
|
const s = String(seconds.value % 60).padStart(2, '0')
|
return `${m}:${s}`
|
})
|
function startTimer() {
|
stopTimer()
|
seconds.value = 0
|
timer = setInterval(() => seconds.value++, 1000)
|
}
|
function stopTimer() {
|
if (timer) clearInterval(timer)
|
timer = null
|
}
|
|
// 心跳
|
let pingTimer = null
|
function startPing() {
|
stopPing()
|
// 每 25 秒发一次 ping(你后端有 PING/PONG)
|
pingTimer = setInterval(() => {
|
if (connected.value) send('ping', 'system', null)
|
}, 25000)
|
}
|
|
// 来电提示相关
|
let ringtoneAudio = null
|
let vibrationTimer = null
|
|
// 播放来电铃声
|
function playRingtone() {
|
// 创建音频对象(使用默认的系统提示音)
|
ringtoneAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA==');
|
ringtoneAudio.loop = true;
|
ringtoneAudio.volume = 0.5;
|
|
try {
|
ringtoneAudio.play();
|
log('🔊 开始播放来电铃声')
|
} catch (e) {
|
log('❌ 播放来电铃声失败:', String(e))
|
}
|
}
|
|
// 停止来电铃声
|
function stopRingtone() {
|
if (ringtoneAudio) {
|
try {
|
ringtoneAudio.pause();
|
ringtoneAudio.currentTime = 0;
|
ringtoneAudio = null;
|
log('🔇 停止播放来电铃声')
|
} catch (e) {
|
log('❌ 停止来电铃声失败:', String(e))
|
}
|
}
|
}
|
|
// 开始震动
|
function startVibration() {
|
// 检查浏览器是否支持震动 API
|
if ('vibrate' in navigator) {
|
// 震动模式:震动1秒,暂停0.5秒,重复
|
const vibrationPattern = [1000, 500];
|
|
try {
|
navigator.vibrate(vibrationPattern);
|
vibrationTimer = setInterval(() => {
|
navigator.vibrate(vibrationPattern);
|
}, 1500); // 每1.5秒重复一次震动模式
|
log('📳 开始震动提示')
|
} catch (e) {
|
log('❌ 震动失败:', String(e))
|
}
|
} else {
|
log('ℹ️ 当前浏览器不支持震动功能')
|
}
|
}
|
|
// 停止震动
|
function stopVibration() {
|
if (vibrationTimer) {
|
clearInterval(vibrationTimer);
|
vibrationTimer = null;
|
}
|
|
if ('vibrate' in navigator) {
|
try {
|
navigator.vibrate(0); // 停止震动
|
log('📳 停止震动提示')
|
} catch (e) {
|
log('❌ 停止震动失败:', String(e))
|
}
|
}
|
}
|
function stopPing() {
|
if (pingTimer) clearInterval(pingTimer)
|
pingTimer = null
|
}
|
|
function log(...args) {
|
const line = args.map(v => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
|
logText.value += `[${new Date().toLocaleTimeString()}] ${line}\n`
|
console.log(...args)
|
}
|
|
/**
|
* ✅ 严格按后端 ChatMessage 字段发:
|
* { type, from, to, payload, timestamp }
|
* 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
|
*/
|
function send(type, to, payload) {
|
if (!type) return
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
const msg = {
|
type, // "call/accept/busy/offer/answer/candidate/hangup/ping"
|
from: String(uid.value),
|
to: String(to),
|
payload: payload ?? null,
|
timestamp: Date.now(),
|
}
|
ws.send(JSON.stringify(msg))
|
}
|
|
/* ---------------- WebRTC ---------------- */
|
|
function createPC() {
|
pc = new RTCPeerConnection({
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
})
|
|
pc.onicecandidate = (e) => {
|
if (e.candidate) {
|
send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
|
}
|
}
|
|
pc.ontrack = (e) => {
|
log('✅ 收到远端音频流')
|
const stream = e.streams[0]
|
if (remoteAudio.value) {
|
remoteAudio.value.srcObject = stream
|
remoteAudio.value.play().catch(() => {
|
})
|
}
|
}
|
|
pc.onconnectionstatechange = () => {
|
log('pc.connectionState =', pc.connectionState)
|
}
|
}
|
|
async function getLocalAudio() {
|
if (localStream) return localStream
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面')
|
throw new Error('getUserMedia not available')
|
}
|
|
try {
|
localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
log('✅ 已获取本地麦克风')
|
return localStream
|
} catch (e) {
|
log('❌ 获取麦克风失败:', String(e))
|
throw e
|
}
|
}
|
|
/** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
|
async function ensureLocalTracksAdded() {
|
if (!pc) createPC()
|
const stream = await getLocalAudio()
|
|
const existingTrackIds = new Set(
|
pc.getSenders().map(s => (s.track ? s.track.id : null)).filter(Boolean)
|
)
|
|
stream.getTracks().forEach(track => {
|
if (!existingTrackIds.has(track.id)) {
|
pc.addTrack(track, stream)
|
log('addTrack ok:', track.id)
|
} else {
|
// log('skip addTrack:', track.id)
|
}
|
})
|
|
return stream
|
}
|
|
async function flushCandidates() {
|
if (!pc || !pc.remoteDescription) return
|
while (pendingCandidates.length) {
|
const c = pendingCandidates.shift()
|
try {
|
await pc.addIceCandidate(new RTCIceCandidate(c))
|
} catch (e) {
|
log('addIceCandidate failed:', String(e))
|
}
|
}
|
}
|
|
/* ---------------- 信令处理 ---------------- */
|
|
async function onSignal(msg) {
|
if (!msg) return
|
const t = (msg.type || '').toString()
|
|
// ✅ 忽略后端回显给自己的消息(你的后端会回显 sender)
|
if (msg.from && String(msg.from) === String(uid.value)) {
|
return
|
}
|
|
// system 消息:login/pong/busy 仅记录
|
if (msg.from === 'system') {
|
log('system msg:', msg)
|
return
|
}
|
|
if (!t) return
|
|
if (t === 'call') {
|
// 收到来电:弹接听按钮
|
incomingFrom = String(msg.from)
|
peerUid.value = incomingFrom
|
acceptedByMe = false
|
offeredByPeer = false
|
|
state.value = 'ringing'
|
log('📞 收到来电 call,来自', incomingFrom)
|
|
// 开始来电提示(铃声和震动)
|
playRingtone()
|
startVibration()
|
|
return
|
}
|
|
if (t === 'accept') {
|
// 主叫收到 accept:开始 offer
|
log('✅ 对方接听 accept,开始建立连接(offer)')
|
await startOffer()
|
return
|
}
|
|
if (t === 'busy') {
|
log('❌ 对方忙线/拒绝')
|
cleanup(false)
|
return
|
}
|
|
if (t === 'offer') {
|
// 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
|
offeredByPeer = true
|
incomingFrom = String(msg.from)
|
peerUid.value = incomingFrom
|
|
if (!acceptedByMe) {
|
state.value = 'ringing'
|
log('📩 收到 offer(但未接听),已等待用户点击接听')
|
// 暂存 offer 到一个变量
|
lastOfferSdp = msg.payload?.sdp
|
return
|
}
|
|
// 已接听:直接 answer
|
await answerOffer(msg.payload?.sdp, incomingFrom)
|
return
|
}
|
|
if (t === 'answer') {
|
log('✅ 收到 answer,通话建立')
|
if (!pc) createPC()
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
|
await flushCandidates()
|
|
state.value = 'in-call'
|
startTimer()
|
return
|
}
|
|
if (t === 'candidate') {
|
const c = msg.payload?.candidate
|
if (!c) return
|
if (pc && pc.remoteDescription) {
|
await pc.addIceCandidate(new RTCIceCandidate(c))
|
} else {
|
pendingCandidates.push(c)
|
}
|
return
|
}
|
|
if (t === 'hangup') {
|
log('📴 对方挂断')
|
cleanup(false)
|
return
|
}
|
|
if (t === 'pong') {
|
// 心跳响应
|
// log('pong')
|
return
|
}
|
}
|
|
// 暂存 offer(用于“先点接听再 answer”)
|
let lastOfferSdp = null
|
|
/* ---------------- 业务操作 ---------------- */
|
|
function connectWS() {
|
// 确保获取最新的用户ID
|
uid.value = getUserIdFromStorage()
|
|
if (ws) ws.close()
|
|
ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
|
|
ws.onopen = () => {
|
connected.value = true
|
log('WS 已连接 userId=', uid.value)
|
startPing()
|
}
|
|
ws.onclose = () => {
|
connected.value = false
|
log('WS 已关闭')
|
stopPing()
|
}
|
|
ws.onerror = (e) => log('WS error', e)
|
|
ws.onmessage = (e) => {
|
const msg = JSON.parse(e.data)
|
log('收到信令', msg)
|
onSignal(msg)
|
}
|
}
|
|
function requestCall() {
|
state.value = 'calling'
|
incomingFrom = null
|
acceptedByMe = false
|
offeredByPeer = false
|
lastOfferSdp = null
|
|
send('call', peerUid.value, null)
|
log('➡️ 发起呼叫 call 给', peerUid.value)
|
}
|
|
function acceptCall() {
|
if (!incomingFrom) return
|
acceptedByMe = true
|
|
// 停止来电提示
|
stopRingtone()
|
stopVibration()
|
|
// 先告诉对方我接听了(后端会把双方置忙)
|
send('accept', incomingFrom, null)
|
log('✅ 已点击接听,发送 accept 给', incomingFrom)
|
|
state.value = 'calling'
|
|
// 如果 offer 已经提前到达(我们暂存了),立刻 answer
|
if (lastOfferSdp) {
|
answerOffer(lastOfferSdp, incomingFrom)
|
lastOfferSdp = null
|
}
|
}
|
|
function rejectCall() {
|
if (!incomingFrom) return
|
|
// 停止来电提示
|
stopRingtone()
|
stopVibration()
|
|
// 你后端没有 reject,用 busy 表示拒绝/不可接听
|
send('busy', incomingFrom, null)
|
log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
|
|
incomingFrom = null
|
acceptedByMe = false
|
offeredByPeer = false
|
lastOfferSdp = null
|
state.value = 'idle'
|
|
// 返回上一页(uni-app的语音通话列表页面)
|
if (window.uni) {
|
window.uni.navigateBack()
|
} else {
|
log('❌ uni对象不存在,无法返回上一页')
|
}
|
}
|
|
async function startOffer() {
|
if (!pc) createPC()
|
|
// ✅ 不会重复 addTrack
|
await ensureLocalTracksAdded()
|
|
const offer = await pc.createOffer()
|
await pc.setLocalDescription(offer)
|
|
send('offer', peerUid.value, { sdp: pc.localDescription })
|
log('➡️ 已发送 offer 给', peerUid.value)
|
}
|
|
async function answerOffer(offerSdp, from) {
|
if (!offerSdp) {
|
log('❌ offer sdp 为空,无法接听')
|
return
|
}
|
if (!pc) createPC()
|
|
await pc.setRemoteDescription(new RTCSessionDescription(offerSdp))
|
await flushCandidates()
|
|
await ensureLocalTracksAdded()
|
|
const answer = await pc.createAnswer()
|
await pc.setLocalDescription(answer)
|
|
send('answer', from, { sdp: pc.localDescription })
|
log('⬅️ 已发送 answer 给', from)
|
|
state.value = 'in-call'
|
startTimer()
|
}
|
|
function hangup() {
|
cleanup(true)
|
}
|
|
/* ---------------- 清理 ---------------- */
|
|
function cleanup(sendToPeer) {
|
try {
|
if (sendToPeer) send('hangup', peerUid.value, null)
|
} catch {}
|
|
stopTimer()
|
// 停止来电提示
|
stopRingtone()
|
stopVibration()
|
|
if (pc) {
|
pc.getSenders().forEach(s => s.track && s.track.stop())
|
pc.close()
|
}
|
if (localStream) {
|
localStream.getTracks().forEach(t => t.stop())
|
}
|
|
pc = null
|
localStream = null
|
pendingCandidates.length = 0
|
|
incomingFrom = null
|
acceptedByMe = false
|
offeredByPeer = false
|
lastOfferSdp = null
|
|
state.value = 'idle'
|
|
// 清除之前的定时器
|
if (endTimer.value) {
|
clearTimeout(endTimer.value)
|
endTimer.value = null
|
}
|
|
// 根据是谁发起的挂断决定不同的行为
|
if (sendToPeer) {
|
// 自己主动挂断:不提示,不延迟,直接跳转
|
log('⏰ 自己主动挂断,直接返回')
|
callEnded.value = false
|
// 返回上一页(uni-app的语音通话列表页面)
|
if (window.uni) {
|
window.uni.navigateBack()
|
} else {
|
log('❌ uni对象不存在,无法返回上一页')
|
}
|
} else {
|
// 对方挂断:显示提示,延迟10秒跳转
|
callEnded.value = true
|
endMessage.value = '对方已挂断'
|
|
// 延迟10秒后跳转页面
|
endTimer.value = setTimeout(() => {
|
log('⏰ 通话结束10秒后自动返回')
|
// 返回上一页(uni-app的语音通话列表页面)
|
if (window.uni) {
|
window.uni.navigateBack()
|
} else {
|
log('❌ uni对象不存在,无法返回上一页')
|
}
|
// 重置通话结束状态
|
callEnded.value = false
|
}, 5000)
|
}
|
}
|
|
// 定义receiveParameters变量,用于模板中判断是否显示呼叫按钮
|
const receiveParameters = ref(route.query.contact || null);
|
|
onMounted(() => {
|
console.log('收拾收拾',route.query.voiceparams);
|
console.log('接收参数', receiveParameters.value)
|
|
// 解析并使用contact参数
|
if (receiveParameters.value) {
|
try {
|
const contact = JSON.parse(decodeURIComponent(receiveParameters.value))
|
if (contact.friendId) {
|
peerUid.value = contact.friendId
|
log('🔗 从contact参数获取peerUid:', contact.friendId)
|
log('👤 联系人名称:', contact.friendNickName || '未知联系人')
|
}
|
} catch (e) {
|
log('❌ 解析contact参数失败:', String(e))
|
}
|
}
|
|
// 解析URL参数
|
parseUrlParams()
|
// 自动连接WebSocket
|
connectWS()
|
});
|
|
// 监听连接状态,当connected变为true且有receiveParameters时自动呼叫
|
watch(() => connected.value, (newVal) => {
|
if (newVal && state.value === 'idle' && receiveParameters.value) {
|
log('📞 连接成功且有contact参数,自动发起呼叫');
|
requestCall();
|
}
|
});
|
|
|
onBeforeUnmount(() => {
|
cleanup(false)
|
stopPing()
|
if (ws) ws.close()
|
})
|
</script>
|
<style scoped lang="scss">
|
.call-container {
|
padding: 20px;
|
min-height: 100vh;
|
font-family: Arial, sans-serif;
|
background-color: #f5f5f5;
|
transition: all 0.3s ease;
|
}
|
|
/* 来电时的全屏样式 */
|
.call-container.incoming-call {
|
position: fixed;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
z-index: 99999;
|
background-color: #ffffff;
|
padding: 0;
|
}
|
|
/* 通话结束状态样式 */
|
.status-text.ended-status {
|
color: #e74c3c;
|
font-size: 18px;
|
font-weight: bold;
|
margin-top: 10px;
|
text-align: center;
|
}
|
|
.content-wrapper {
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
max-width: 600px;
|
margin: 0 auto;
|
height: 100vh;
|
justify-content: center;
|
align-items: center;
|
}
|
|
.input-group {
|
display: flex;
|
flex-direction: column;
|
gap: 10px;
|
}
|
|
.input-item {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.input-label {
|
font-weight: bold;
|
color: #333;
|
}
|
|
.input-field {
|
width: 120px;
|
padding: 8px;
|
border: 1px solid #ddd;
|
border-radius: 4px;
|
font-size: 14px;
|
}
|
|
.button-group {
|
display: flex;
|
gap: 50px;
|
flex-wrap: wrap;
|
justify-content: center;
|
margin-top: 80px;
|
}
|
|
.btn {
|
padding: 15px 30px;
|
color: white;
|
border: none;
|
border-radius: 8px;
|
cursor: pointer;
|
font-size: 16px;
|
font-weight: bold;
|
transition: all 0.3s ease;
|
min-width: 120px;
|
|
&:disabled {
|
opacity: 0.6;
|
cursor: not-allowed;
|
}
|
}
|
|
/* 来电时的接听和拒绝按钮样式 */
|
.btn.incoming-accept, .btn.incoming-reject {
|
width: 80px;
|
height: 80px;
|
border-radius: 50%;
|
padding: 0;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
}
|
|
.btn.incoming-accept {
|
background-color: #4CAF50;
|
transform: scale(1.1);
|
}
|
|
.btn.incoming-reject {
|
background-color: #f44336;
|
}
|
|
.btn.incoming-accept:active {
|
transform: scale(0.95);
|
}
|
|
.btn.incoming-reject:active {
|
transform: scale(0.95);
|
}
|
|
.btn-connect {
|
background-color: #4CAF50;
|
}
|
|
.btn-call {
|
background-color: #2196F3;
|
}
|
|
.btn-accept {
|
background-color: #4CAF50;
|
}
|
|
.btn-reject {
|
background-color: #f44336;
|
}
|
|
.btn-hangup {
|
background-color: #9E9E9E;
|
padding: 15px 40px;
|
min-width: 150px;
|
}
|
|
.status-group {
|
display: flex;
|
flex-direction: column;
|
gap: 10px;
|
align-items: center;
|
}
|
|
.status-text {
|
font-size: 18px;
|
color: #333;
|
text-align: center;
|
margin-bottom: 20px;
|
}
|
|
/* 来电状态样式 */
|
.status-text.incoming-status {
|
font-size: 24px;
|
color: #333;
|
font-weight: bold;
|
animation: pulse 1.5s infinite;
|
}
|
|
.status-text.calling-status {
|
font-size: 20px;
|
color: #666;
|
}
|
|
/* 呼吸灯动画 */
|
@keyframes pulse {
|
0% {
|
opacity: 1;
|
transform: scale(1);
|
}
|
50% {
|
opacity: 0.7;
|
transform: scale(1.05);
|
}
|
100% {
|
opacity: 1;
|
transform: scale(1);
|
}
|
}
|
|
.tip-message {
|
margin-top: 15px;
|
color: #666;
|
text-align: center;
|
}
|
|
.log-container {
|
margin-top: 20px;
|
max-width: 600px;
|
margin-left: auto;
|
margin-right: auto;
|
}
|
|
.log-box {
|
background-color: white;
|
border: 1px solid #ddd;
|
border-radius: 8px;
|
padding: 10px;
|
}
|
|
.log-title {
|
font-weight: bold;
|
color: #333;
|
margin-bottom: 8px;
|
}
|
|
.log-content {
|
max-height: 200px;
|
overflow: auto;
|
background-color: #fafafa;
|
padding: 10px;
|
border-radius: 4px;
|
font-size: 12px;
|
line-height: 1.4;
|
}
|
|
.hidden-audio {
|
display: none;
|
}
|
|
/* 头像样式 */
|
.contactAvatar {
|
width: 180px;
|
height: 180px;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
margin-bottom: 40px;
|
border-radius: 50%;
|
background-color: #f0f0f0;
|
overflow: hidden;
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
|
img {
|
width: 100%;
|
height: 100%;
|
object-fit: cover;
|
}
|
}
|
|
/* 来电时的头像放大效果 */
|
.call-container.incoming-call .contactAvatar {
|
width: 220px;
|
height: 220px;
|
margin-bottom: 60px;
|
}
|
</style>
|