优化显示

This commit is contained in:
kura 2026-05-07 21:21:50 +08:00
parent ae36cb183b
commit 0de25a0544

View File

@ -1,53 +1,293 @@
<template> <template>
<div <div>
class="desktop-view-wrapper" <!-- Desktop Fullscreen Overlay -->
:class="{ <Transition name="panel-slide">
'is-collapsed': isCollapsed, <div v-if="isDesktopActive" class="desktop-overlay">
'has-stream': hasMedia, <div class="overlay-header">
'has-call-only': !!callStream && !desktopStream, <div class="header-left">
}" <svg
> width="18"
<div class="collapse-header" v-if="hasMedia" @click="toggleCollapse"> height="18"
<span>{{ headerTitle }}{{ isCollapsed ? "(已收起)" : "" }}</span> viewBox="0 0 24 24"
<a-button type="link"> fill="none"
<template #icon> stroke="currentColor"
<UpOutlined v-if="!isCollapsed" /> stroke-width="2"
<DownOutlined v-else /> >
</template> <rect x="2" y="3" width="20" height="14" rx="2" />
</a-button> <line x1="8" y1="21" x2="16" y2="21" />
</div> <line x1="12" y1="17" x2="12" y2="21" />
<div </svg>
v-if="desktopStream" <span>远程桌面</span>
class="desktop-view" </div>
:class="{ 'is-fullscreen': isFullscreen }" <div class="header-right">
> <button
<div class="video-container" ref="videoContainer"> v-if="desktopStream"
<video ref="videoRef" autoplay playsinline></video> class="header-btn"
<div class="controls"> @click="toggleFullscreen"
<a-button :title="isFullscreen ? '退出全屏' : '全屏'"
type="primary" >
ghost <svg
@click="toggleFullscreen" v-if="!isFullscreen"
> width="16"
{{ isFullscreen ? "退出全屏" : "全屏" }} height="16"
</a-button> viewBox="0 0 24 24"
<a-button danger ghost @click="endDesktop">结束桌面</a-button> fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" y1="10" x2="21" y2="3" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<span>{{ isFullscreen ? "退出全屏" : "全屏" }}</span>
</button>
<button class="header-btn danger" @click="endDesktop">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span>结束共享</span>
</button>
</div>
</div>
<div class="overlay-body" ref="videoContainer">
<div v-if="desktopStream" class="video-wrapper">
<video ref="videoRef" autoplay playsinline></video>
</div>
<div v-else class="loading-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="31.4 31.4"
stroke-linecap="round"
/>
</svg>
<span>等待桌面共享...</span>
</div>
</div>
<div v-if="isCallActive" class="desktop-call-bar">
<div class="call-bar-info">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
<span>语音通话</span>
<span class="call-duration-text">{{ callDurationStr }}</span>
</div>
<div class="call-bar-actions">
<button
class="bar-action-btn"
:class="{ 'is-muted': isCallMuted }"
@click="toggleCallMuted"
:title="isCallMuted ? '取消静音' : '静音'"
>
<svg
v-if="!isCallMuted"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"
/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M19 10v2a7 7 0 0 1-8.93 6.69" />
<path d="M12 19v4" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
<span>{{ isCallMuted ? "已静音" : "静音" }}</span>
</button>
<button
class="bar-action-btn danger"
@click="endCall"
title="结束通话"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span>结束</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </Transition>
<div v-if="callStream" class="call-view">
<audio ref="audioRef" autoplay playsinline></audio> <!-- Hidden audio for remote call stream -->
<div class="call-info"> <audio ref="audioRef" autoplay playsinline></audio>
<PhoneOutlined />
<span>语音通话中</span> <!-- Call-Only Floating Panel -->
<Transition name="panel-slide">
<div v-if="isCallActive && !isDesktopActive" class="call-floating-panel">
<div class="call-panel-header">
<div class="panel-header-left">
<span class="panel-header-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</span>
<span>语音通话</span>
</div>
<button class="panel-close-btn" @click="endCall" title="结束通话">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="call-panel-body">
<div class="call-status-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</div>
<div class="call-status-text">
{{ callStream ? "通话中" : "正在建立连接..." }}
</div>
<div class="call-duration">{{ callDurationStr }}</div>
</div>
<div class="call-panel-footer">
<button
class="control-btn mute-btn"
:class="{ 'is-muted': isCallMuted }"
@click="toggleCallMuted"
:title="isCallMuted ? '取消静音' : '静音'"
>
<svg
v-if="!isCallMuted"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
<svg
v-else
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M19 10v2a7 7 0 0 1-8.93 6.69" />
<path d="M12 19v4" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</button>
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</button>
</div>
</div> </div>
<div class="call-controls"> </Transition>
<a-button ghost @click="toggleCallMuted">
{{ isCallMuted ? "取消静音" : "静音" }}
</a-button>
<a-button danger ghost @click="endCall">结束通话</a-button>
</div>
</div>
</div> </div>
</template> </template>
@ -55,7 +295,6 @@
import { computed, nextTick, ref, onMounted, onUnmounted } from "vue"; import { computed, nextTick, ref, onMounted, onUnmounted } from "vue";
import { peer } from "../utils/peer"; import { peer } from "../utils/peer";
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
import { UpOutlined, DownOutlined, PhoneOutlined } from "@ant-design/icons-vue";
const props = defineProps<{ const props = defineProps<{
peerId: string; peerId: string;
@ -76,67 +315,101 @@ interface MediaEndedEvent extends CustomEvent {
}; };
} }
interface MediaStartedEvent extends CustomEvent {
detail: {
peerId: string;
};
}
const videoRef = ref<HTMLVideoElement | null>(null); const videoRef = ref<HTMLVideoElement | null>(null);
const audioRef = ref<HTMLAudioElement | null>(null); const audioRef = ref<HTMLAudioElement | null>(null);
const videoContainer = ref<HTMLElement | null>(null); const videoContainer = ref<HTMLElement | null>(null);
const desktopStream = ref<MediaStream | null>(null); const desktopStream = ref<MediaStream | null>(null);
const callStream = ref<MediaStream | null>(null); const callStream = ref<MediaStream | null>(null);
const isDesktopActive = ref(false);
const isCallActive = ref(false);
const isFullscreen = ref(false); const isFullscreen = ref(false);
const isCollapsed = ref(false);
const isCallMuted = ref(false); const isCallMuted = ref(false);
const callDuration = ref(0);
let durationTimer: number | null = null;
const hasMedia = computed(() => !!desktopStream.value || !!callStream.value); const callDurationStr = computed(() => {
const headerTitle = computed(() => { const mins = Math.floor(callDuration.value / 60);
if (desktopStream.value && callStream.value) return "远程桌面与通话"; const secs = callDuration.value % 60;
if (desktopStream.value) return "远程桌面"; return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return "语音通话";
}); });
// / const startDurationTimer = () => {
const toggleCollapse = () => { callDuration.value = 0;
isCollapsed.value = !isCollapsed.value; durationTimer = window.setInterval(() => {
callDuration.value++;
}, 1000);
};
const stopDurationTimer = () => {
if (durationTimer !== null) {
clearInterval(durationTimer);
durationTimer = null;
}
callDuration.value = 0;
}; };
//
const handleStream = async (event: StreamEvent) => { const handleStream = async (event: StreamEvent) => {
const { stream: remoteStream, type } = event.detail; const { stream: remoteStream, type } = event.detail;
if (props.peerId !== event.detail.peerId) return; if (props.peerId !== event.detail.peerId) return;
if (type === "desktop") { if (type === "desktop") {
isDesktopActive.value = true;
desktopStream.value = remoteStream; desktopStream.value = remoteStream;
isCollapsed.value = false; //
await nextTick(); await nextTick();
if (videoRef.value) { if (videoRef.value) {
videoRef.value.srcObject = remoteStream; videoRef.value.srcObject = remoteStream;
videoRef.value.muted = true;
videoRef.value.play().catch(() => {
message.warning("浏览器阻止了自动播放,请点击桌面画面恢复播放");
});
} }
} else if (type === "call") { } else if (type === "call") {
isCallActive.value = true;
callStream.value = remoteStream; callStream.value = remoteStream;
isCollapsed.value = false; startDurationTimer();
await nextTick(); await nextTick();
if (audioRef.value) { if (audioRef.value) {
audioRef.value.srcObject = remoteStream; audioRef.value.srcObject = remoteStream;
audioRef.value.muted = isCallMuted.value;
audioRef.value.play().catch(() => {
message.warning("浏览器阻止了自动播放,请点击通话区域恢复声音");
});
} }
} }
}; };
// const handleDesktopStarted = (event: MediaStartedEvent) => {
if (props.peerId !== event.detail.peerId) return;
isDesktopActive.value = true;
};
const handleCallStarted = (event: MediaStartedEvent) => {
if (props.peerId !== event.detail.peerId) return;
isCallActive.value = true;
startDurationTimer();
};
const handleMediaEnded = (event: MediaEndedEvent) => { const handleMediaEnded = (event: MediaEndedEvent) => {
const { type } = event.detail; const { type } = event.detail;
if (props.peerId !== event.detail.peerId) return; if (props.peerId !== event.detail.peerId) return;
if (type === "desktop") { if (type === "desktop") {
isDesktopActive.value = false;
desktopStream.value = null; desktopStream.value = null;
if (videoRef.value) { if (videoRef.value) {
videoRef.value.srcObject = null; videoRef.value.srcObject = null;
} }
message.info("远程桌面共享已结束"); message.info("远程桌面共享已结束");
if (!isCallActive.value) {
stopDurationTimer();
}
} else if (type === "call") { } else if (type === "call") {
isCallActive.value = false;
callStream.value = null; callStream.value = null;
if (audioRef.value) { if (audioRef.value) {
audioRef.value.srcObject = null; audioRef.value.srcObject = null;
} }
stopDurationTimer();
message.info("语音通话已结束"); message.info("语音通话已结束");
} }
}; };
@ -147,6 +420,7 @@ const endDesktop = () => {
const endCall = () => { const endCall = () => {
peer.endMedia(props.peerId, "call"); peer.endMedia(props.peerId, "call");
stopDurationTimer();
}; };
const toggleCallMuted = () => { const toggleCallMuted = () => {
@ -156,10 +430,8 @@ const toggleCallMuted = () => {
} }
}; };
//
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!videoContainer.value) return; if (!videoContainer.value) return;
try { try {
if (!isFullscreen.value) { if (!isFullscreen.value) {
if (videoContainer.value.requestFullscreen) { if (videoContainer.value.requestFullscreen) {
@ -170,12 +442,11 @@ const toggleFullscreen = async () => {
await document.exitFullscreen(); await document.exitFullscreen();
} }
} }
} catch (err) { } catch {
message.error("切换全屏失败"); message.error("切换全屏失败");
} }
}; };
//
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement; isFullscreen.value = !!document.fullscreenElement;
}; };
@ -186,132 +457,398 @@ const streamListener: EventListener = (event) => {
const mediaEndedListener: EventListener = (event) => { const mediaEndedListener: EventListener = (event) => {
handleMediaEnded(event as MediaEndedEvent); handleMediaEnded(event as MediaEndedEvent);
}; };
const desktopStartedListener: EventListener = (event) => {
handleDesktopStarted(event as MediaStartedEvent);
};
const callStartedListener: EventListener = (event) => {
handleCallStarted(event as MediaStartedEvent);
};
onMounted(() => { onMounted(() => {
peer.on("stream", streamListener); peer.on("stream", streamListener);
peer.on("desktop-started", desktopStartedListener);
peer.on("call-started", callStartedListener);
peer.on("media-ended", mediaEndedListener); peer.on("media-ended", mediaEndedListener);
document.addEventListener("fullscreenchange", handleFullscreenChange); document.addEventListener("fullscreenchange", handleFullscreenChange);
}); });
onUnmounted(() => { onUnmounted(() => {
peer.off("stream", streamListener); peer.off("stream", streamListener);
peer.off("desktop-started", desktopStartedListener);
peer.off("call-started", callStartedListener);
peer.off("media-ended", mediaEndedListener); peer.off("media-ended", mediaEndedListener);
document.removeEventListener("fullscreenchange", handleFullscreenChange); document.removeEventListener("fullscreenchange", handleFullscreenChange);
stopDurationTimer();
//
desktopStream.value?.getTracks().forEach((track) => track.stop()); desktopStream.value?.getTracks().forEach((track) => track.stop());
callStream.value?.getTracks().forEach((track) => track.stop()); callStream.value?.getTracks().forEach((track) => track.stop());
}); });
</script> </script>
<style scoped> <style scoped>
.desktop-view-wrapper { /* Slide Animation */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.panel-slide-enter-from,
.panel-slide-leave-to {
transform: translateX(100%);
}
/* Desktop Overlay - Fullscreen */
.desktop-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; bottom: 0;
background: #000; z-index: 1000;
transition: all 0.3s ease; background: #0a0a0a;
height: 0; display: flex;
overflow: hidden; flex-direction: column;
}
.desktop-view-wrapper.has-stream {
height: 300px;
}
.desktop-view-wrapper.has-call-only {
height: 104px;
}
.desktop-view-wrapper.is-collapsed {
height: 40px !important;
}
.collapse-header {
height: 40px;
background: #1a1a1a;
color: #fff; color: #fff;
}
.overlay-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 16px; padding: 12px 20px;
background: rgba(0, 0, 0, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
z-index: 10;
-webkit-app-region: drag;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 500;
color: #e0e0e0;
-webkit-app-region: no-drag;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
}
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
color: #ccc;
font-size: 13px;
cursor: pointer; cursor: pointer;
user-select: none; transition: all 0.2s;
} }
.collapse-header:hover { .header-btn:hover {
background: #2a2a2a; background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
} }
.desktop-view { .header-btn.danger:hover {
width: 100%; background: rgba(245, 34, 45, 0.15);
height: calc(100% - 40px); border-color: #f5222d;
background: #000; color: #f5222d;
}
.overlay-body {
flex: 1;
position: relative; position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.video-container { .video-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
} }
video { video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block;
} }
.controls { .loading-state {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
opacity: 0;
transition: opacity 0.3s;
display: flex; display: flex;
gap: 10px; flex-direction: column;
align-items: center;
gap: 16px;
color: #888;
font-size: 14px;
} }
.video-container:hover .controls { .spin-icon {
opacity: 1; animation: spin 1.5s linear infinite;
} }
.is-fullscreen { @keyframes spin {
position: fixed; to {
top: 0; transform: rotate(360deg);
left: 0; }
right: 0;
bottom: 0;
z-index: 9999;
} }
.call-view { /* Desktop Call Bar */
height: 64px; .desktop-call-bar {
padding: 0 16px;
background: #111;
color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-top: 1px solid #2a2a2a; padding: 10px 20px;
background: rgba(0, 0, 0, 0.85);
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
} }
.call-info, .call-bar-info {
.call-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
font-size: 13px;
color: #aaa;
} }
:deep(.ant-empty-description) { .call-duration-text {
color: #666;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.call-bar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.bar-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.bar-action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff; color: #fff;
} }
:deep(.anticon) { .bar-action-btn.is-muted {
vertical-align: 0; background: rgba(250, 173, 20, 0.15);
border-color: #faad14;
color: #faad14;
}
.bar-action-btn.danger:hover {
background: rgba(245, 34, 45, 0.15);
border-color: #f5222d;
color: #f5222d;
}
/* Call-Only Floating Panel */
.call-floating-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: 320px;
z-index: 1000;
background: linear-gradient(145deg, #1a1a2e, #16213e);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
overflow: hidden;
color: #fff;
}
.call-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.panel-header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
}
.panel-header-icon {
display: flex;
color: #52c41a;
}
.panel-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: #888;
cursor: pointer;
transition: all 0.2s;
}
.panel-close-btn:hover {
background: rgba(245, 34, 45, 0.2);
color: #f5222d;
}
.call-panel-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px 24px;
gap: 8px;
}
.call-status-icon {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(82, 196, 26, 0.1);
border-radius: 50%;
color: #52c41a;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.3);
}
50% {
box-shadow: 0 0 0 12px rgba(82, 196, 26, 0);
}
}
.call-status-text {
font-size: 15px;
color: #ddd;
margin-top: 8px;
}
.call-duration {
font-size: 32px;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: #fff;
letter-spacing: 2px;
margin-top: 4px;
}
.call-panel-footer {
display: flex;
justify-content: center;
gap: 24px;
padding: 16px 24px 20px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.25s;
}
.mute-btn {
background: rgba(255, 255, 255, 0.08);
color: #ccc;
}
.mute-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.mute-btn.is-muted {
background: rgba(250, 173, 20, 0.2);
color: #faad14;
}
.mute-btn.is-muted:hover {
background: rgba(250, 173, 20, 0.3);
}
.end-call-btn {
background: #f5222d;
color: #fff;
transform: rotate(135deg);
}
.end-call-btn:hover {
background: #ff4d4f;
transform: rotate(135deg) scale(1.05);
}
@media screen and (max-width: 768px) {
.call-floating-panel {
right: 12px;
bottom: 12px;
width: 280px;
}
.call-panel-body {
padding: 24px 20px 20px;
}
.call-duration {
font-size: 26px;
}
.control-btn {
width: 46px;
height: 46px;
}
.overlay-header {
padding: 10px 14px;
}
.header-btn span {
display: none;
}
.desktop-call-bar {
flex-direction: column;
gap: 8px;
padding: 10px 14px;
}
} }
</style> </style>