适配微信小程序

This commit is contained in:
2025-12-15 09:01:02 +08:00
parent 46f16017ea
commit 8266de4902
21 changed files with 767 additions and 1100 deletions

View File

@@ -4,10 +4,14 @@
<!-- 最小化悬浮窗 -->
<view v-if="call.minimized" class="minimized-bar" @click="$emit('toggleMinimize')">
<view class="pulse-dot"></view>
<svg class="mini-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="call.type === 'video'" d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
<path v-else 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>
<!-- 替换 SVG wd-icon -->
<view class="mini-icon-wrap">
<wd-icon
:name="call.type === 'video' ? 'video' : 'phone'"
size="48rpx"
color="#10b981"
/>
</view>
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
</view>
@@ -21,24 +25,19 @@
<!-- 最小化按钮 (右上角) -->
<view class="minimize-btn" @click="$emit('toggleMinimize')">
<svg 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>
<wd-icon name="arrow-down" size="48rpx" color="#fff" />
</view>
<!-- 视频区域 -->
<view v-if="call.type === 'video'" class="video-area">
<!-- 远端视频 - 使用原生 video 元素以支持 srcObject -->
<!-- 远端视频 -->
<video
v-if="remoteStream"
ref="remoteVideoRef"
class="remote-video"
autoplay
playsinline
:srcObject="remoteStream"
v-if="remoteStream"
ref="remoteVideoRef"
class="remote-video"
autoplay
playsinline
:srcObject="remoteStream"
></video>
<view v-else class="video-placeholder">
<view class="avatar-section">
@@ -49,15 +48,15 @@
<text class="caller-name">{{ call.callerName || '对方' }}</text>
</view>
<!-- 本地视频 - 使用原生 video 元素以支持 srcObject -->
<!-- 本地视频 -->
<video
v-if="localStream && !call.camOff"
ref="localVideoRef"
class="local-video"
autoplay
muted
playsinline
:srcObject="localStream"
v-if="localStream && !call.camOff"
ref="localVideoRef"
class="local-video"
autoplay
muted
playsinline
:srcObject="localStream"
></video>
</view>
@@ -80,17 +79,13 @@
<template v-if="call.status === 'incoming'">
<view class="action-item" @click="$emit('reject')">
<view class="control-btn decline">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
<wd-icon name="close-bold" size="56rpx" color="#fff" />
</view>
<text class="action-label">拒绝</text>
</view>
<view class="action-item" @click="$emit('accept')">
<view class="control-btn accept">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
<wd-icon name="phone-filled" size="64rpx" color="#fff" />
</view>
<text class="action-label">接听</text>
</view>
@@ -98,42 +93,34 @@
<!-- 通话中状态 -->
<template v-else-if="call.status === 'connected'">
<!-- 静音 -->
<view class="action-item" @click="$emit('toggleMute')">
<view class="control-btn" :class="{ active: call.muted }">
<svg v-if="call.muted" 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.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else 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>
<wd-icon
:name="call.muted ? 'mic-off' : 'mic-on'"
size="56rpx"
:color="call.muted ? '#000' : '#fff'"
/>
</view>
<text class="action-label">{{ call.muted ? '已静音' : '静音' }}</text>
</view>
<!-- 摄像头 (仅视频通话) -->
<view v-if="call.type === 'video'" class="action-item" @click="$emit('toggleCamera')">
<view class="control-btn" :class="{ active: call.camOff }">
<svg v-if="call.camOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<wd-icon
:name="call.camOff ? 'video-off' : 'video'"
size="56rpx"
:color="call.camOff ? '#000' : '#fff'"
/>
</view>
<text class="action-label">{{ call.camOff ? '已关闭' : '摄像头' }}</text>
</view>
<!-- 挂断 -->
<view class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
</view>
<text class="action-label">挂断</text>
</view>
@@ -143,9 +130,7 @@
<template v-else-if="call.status === 'outgoing'">
<view class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
</view>
<text class="action-label">挂断</text>
</view>
@@ -159,16 +144,12 @@
<view v-if="call.active" class="call-unsupported">
<view class="unsupported-content">
<view class="icon-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<wd-icon name="video-off" size="80rpx" color="#f97316" />
</view>
<text class="unsupported-title">暂不支持音视频通话</text>
<text class="unsupported-desc">当前平台暂不支持音视频通话功能</text>
<text class="unsupported-desc">请使用网页版体验完整功能</text>
<view class="unsupported-actions">
<wd-button type="info" plain @click="copyWebUrl" custom-style="margin-right: 20rpx;">
复制网页链接
@@ -212,9 +193,7 @@ const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
const toast = useToast()
// 复制网页链接用于非H5平台引导用户
function copyWebUrl() {
// TODO: 替换为实际的网页版地址
const webUrl = 'https://your-web-domain.com/chat'
uni.setClipboardData({
data: webUrl,
@@ -260,7 +239,7 @@ watch(() => props.remoteStream, (stream) => {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.minimized {
top: calc(var(--status-bar-height, 0) + 240rpx);
top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px) + 240rpx);
right: 32rpx;
left: auto;
bottom: auto;
@@ -294,10 +273,12 @@ watch(() => props.remoteStream, (stream) => {
animation: pulse 2s infinite;
}
.mini-icon {
.mini-icon-wrap {
width: 64rpx;
height: 64rpx;
color: #10b981;
display: flex;
align-items: center;
justify-content: center;
}
.mini-duration {
@@ -318,7 +299,7 @@ watch(() => props.remoteStream, (stream) => {
position: absolute;
inset: 0;
overflow: hidden;
background: #0f172a; // 设计稿: 来电背景
background: #0f172a;
.blur-img {
width: 100%;
@@ -332,12 +313,11 @@ watch(() => props.remoteStream, (stream) => {
.blur-overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.8); // 设计稿: bg-[#0f172a]/80
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(48px);
-webkit-backdrop-filter: blur(48px);
}
// 设计稿: 呼吸光球动画
&::before, &::after {
content: '';
position: absolute;
@@ -348,25 +328,25 @@ watch(() => props.remoteStream, (stream) => {
opacity: 0.4;
animation: glow-breathe 8s ease-in-out infinite;
}
&::before {
top: 10%;
left: -100rpx;
background: #9333ea; // purple-600
background: #9333ea;
animation-delay: 0s;
}
&::after {
bottom: 10%;
right: -100rpx;
background: #2563eb; // blue-600
background: #2563eb;
animation-delay: 4s;
}
}
.minimize-btn {
position: absolute;
top: calc(28rpx + var(--status-bar-height, 0));
top: calc(28rpx + var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
right: 40rpx;
z-index: 20;
width: 72rpx;
@@ -379,12 +359,6 @@ watch(() => props.remoteStream, (stream) => {
justify-content: center;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: #fff;
}
&:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.2);
@@ -432,17 +406,16 @@ watch(() => props.remoteStream, (stream) => {
position: relative;
margin-bottom: 48rpx;
// 设计稿: 头像波纹效果
.avatar-ring {
position: absolute;
inset: -20rpx;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.3); // 设计稿: border-white/30
border: 2rpx solid rgba(255, 255, 255, 0.3);
&.ring-1 {
animation: ripple 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
&.ring-2 {
animation: ripple 2s cubic-bezier(0, 0, 0.2, 1) infinite;
animation-delay: 1s;
@@ -518,11 +491,6 @@ watch(() => props.remoteStream, (stream) => {
color: #fff;
transition: all 0.3s;
svg {
width: 56rpx;
height: 56rpx;
}
&:active { transform: scale(0.95); }
&.active {
@@ -537,19 +505,12 @@ watch(() => props.remoteStream, (stream) => {
background: #10b981;
border: none;
box-shadow: 0 0 80rpx rgba(16, 185, 129, 0.4);
svg {
width: 72rpx;
height: 72rpx;
}
&:active { background: #059669; }
}
&.decline {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.1);
&:active { background: #ef4444; }
}
@@ -560,12 +521,6 @@ watch(() => props.remoteStream, (stream) => {
border: none;
border-radius: 50%;
box-shadow: 0 8rpx 40rpx rgba(239, 68, 68, 0.3);
svg {
width: 72rpx;
height: 72rpx;
}
&:active { background: #dc2626; }
}
}
@@ -603,12 +558,6 @@ watch(() => props.remoteStream, (stream) => {
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
svg {
width: 80rpx;
height: 80rpx;
color: #f97316;
}
}
.unsupported-title {
@@ -648,13 +597,11 @@ watch(() => props.remoteStream, (stream) => {
to { transform: translateY(0); opacity: 1; }
}
// 设计稿: 呼吸光球动画
@keyframes glow-breathe {
0%, 100% { transform: scale(1); opacity: 0.3; }
50% { transform: scale(1.2); opacity: 0.5; }
}
// 设计稿: 头像波纹动画
@keyframes ripple {
0% { transform: scale(1); opacity: 0.4; }
100% { transform: scale(2.5); opacity: 0; }

View File

@@ -1,18 +1,16 @@
<template>
<view
v-if="showBanner"
class="group-call-banner"
:class="{ dark: isDark }"
v-if="showBanner"
class="group-call-banner"
:class="{ dark: isDark }"
>
<view class="banner-left">
<!-- 动态图标 -->
<view class="call-icon">
<view class="pulse-ring"></view>
<view class="icon-inner">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<!-- 使用 wot-ui icon 替代 SVG/Image -->
<wd-icon name="video" size="40rpx" color="#fff" />
</view>
</view>
@@ -27,21 +25,21 @@
<!-- 头像堆叠 -->
<view class="avatar-stack">
<view
v-for="(user, index) in previewParticipants"
:key="user.userId"
class="stacked-avatar"
:style="{ marginLeft: index > 0 ? '-24rpx' : '0', zIndex: 10 - index }"
v-for="(user, index) in previewParticipants"
:key="user.userId"
class="stacked-avatar"
:style="{ marginLeft: index > 0 ? '-24rpx' : '0', zIndex: 10 - index }"
>
<app-avatar
:src="user.avatar"
:name="user.name"
:size="40"
round
:src="user.avatar"
:name="user.name"
:size="40"
round
/>
</view>
<view
v-if="participantsCount > 5"
class="more-count"
v-if="participantsCount > 5"
class="more-count"
>
+{{ participantsCount - 5 }}
</view>
@@ -71,9 +69,9 @@ const { callState, participants } = groupWebRTC
const showBanner = computed(() => {
return callState.groupId === props.roomId &&
!callState.joined &&
!callState.incoming &&
callState.roomId !== ''
!callState.joined &&
!callState.incoming &&
callState.roomId !== ''
})
const participantsCount = computed(() => participants.value.length)
@@ -154,13 +152,6 @@ function handleJoin() {
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
}
svg {
width: 40rpx;
height: 40rpx;
position: relative;
z-index: 1;
}
}
}

View File

@@ -1,502 +1,219 @@
<template>
<view
v-if="callState.joined && !callState.incoming"
class="call-window"
:class="{ minimized: callState.minimized }"
>
<!-- 最小化内容 -->
<view v-if="callState.minimized" class="minimized-content" @click="toggleMinimize">
<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<text class="mini-duration">{{ formatDuration(callState.duration) }}</text>
<view class="mini-pulse"></view>
</view>
<!-- 顶部功能栏 (渐变遮罩) -->
<view v-if="!callState.minimized" class="window-header">
<view class="header-left">
<view class="status-dot"></view>
<text class="header-title">群聊通话</text>
<text class="duration">{{ formatDuration(callState.duration) }}</text>
</view>
<view class="minimize-btn" @click="toggleMinimize">
<!-- minimize-2 icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
</view>
</view>
<!-- 视频网格 -->
<scroll-view v-if="!callState.minimized" class="video-grid" scroll-y>
<!-- 自己的画面 -->
<view class="video-item self-video">
<!-- #ifdef H5 -->
<video
v-if="localStream && !callState.isSelfCamOff"
ref="localVideoRef"
class="video-element mirror"
autoplay
muted
playsinline
:srcObject="localStream"
></video>
<!-- #endif -->
<view v-if="callState.isSelfCamOff" class="cam-off-placeholder">
<app-avatar :name="'我'" :size="160" round />
<text class="cam-off-text">摄像头已关闭</text>
<view v-if="showBanner" class="banner-wrapper" :class="{ 'banner-hide': !showBanner }">
<view class="glass-capsule" @click="handleJoin">
<view class="left-section">
<view class="icon-container">
<view class="pulse-ring"></view>
<view class="icon-box">
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
</view>
<view class="video-label">
<text class="label-name"></text>
<svg v-if="callState.isSelfMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
</view>
</view>
<!-- 其他成员 -->
<view
v-for="user in participants"
:key="user.userId"
class="video-item"
>
<!-- #ifdef H5 -->
<video
v-if="user.stream && !user.isCamOff"
class="video-element"
autoplay
playsinline
:srcObject="user.stream"
></video>
<!-- #endif -->
<view v-if="!user.stream || user.isCamOff" class="cam-off-placeholder">
<view class="avatar-wrap">
<app-avatar :src="user.avatar" :name="user.name" :size="160" round />
<view v-if="user.status === 'connecting'" class="connecting-overlay">
<wd-loading color="#fff" size="32rpx" />
<view class="info-box">
<view class="title-row">
<text class="title">通话进行中</text>
<view class="count-badge">
<text>{{ participantsCount }}</text>
</view>
</view>
<text class="participant-name">{{ user.name }}</text>
<text class="participant-status">
{{ user.status === 'connecting' ? '连接中...' : user.status === 'rejected' ? '已拒绝' : '摄像头关闭' }}
</text>
</view>
<view class="video-label">
<text class="label-name">{{ user.name }}</text>
<svg v-if="user.isMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
<view class="avatar-stack">
<view
v-for="(user, index) in previewParticipants"
:key="user.userId"
class="stack-item"
:style="{ zIndex: 10 - index, marginLeft: index === 0 ? '0' : '-16rpx' }"
>
<app-avatar :src="user.avatar" :name="user.name" :size="32" round />
</view>
<view v-if="participantsCount > 3" class="more-badge" :style="{ zIndex: 0 }">
<text>+{{ participantsCount - 3 }}</text>
</view>
</view>
</view>
<!-- 说话指示 -->
<view
v-if="!user.isMuted && user.volume && user.volume > 0.05"
class="speaking-indicator"
></view>
</view>
</scroll-view>
<!-- 底部控制栏 -->
<view v-if="!callState.minimized" class="control-bar">
<view class="control-item" @click="webRTC.toggleSelfMute()">
<view class="control-btn" :class="{ active: callState.isSelfMuted }">
<svg v-if="callState.isSelfMuted" 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.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else 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>
</view>
<text class="control-label">{{ callState.isSelfMuted ? '已静音' : '静音' }}</text>
</view>
<view class="control-item" @click="webRTC.leaveCall()">
<view class="control-btn hangup-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 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.91"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<text class="control-label">挂断</text>
</view>
<view class="control-item" @click="webRTC.toggleSelfCamera()">
<view class="control-btn" :class="{ active: callState.isSelfCamOff }">
<svg v-if="callState.isSelfCamOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
<text class="control-label">{{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}</text>
<view class="join-btn">
<text>加入</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
// ... 保持原有 script 逻辑不变 ...
import { computed } from 'vue'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import AppAvatar from '@/components/common/AppAvatar.vue'
const webRTC = useGroupWebRTC()
const { callState, participants, formatDuration, localStream } = webRTC
const props = defineProps<{ roomId: string }>()
const groupWebRTC = useGroupWebRTC()
const { callState, participants } = groupWebRTC
const localVideoRef = ref<HTMLVideoElement | null>(null)
watchEffect(() => {
const stream = localStream.value
const videoEl = localVideoRef.value
if (videoEl && stream) {
videoEl.muted = true
if ((videoEl as any).srcObject !== stream) {
(videoEl as any).srcObject = stream
videoEl.play().catch(e => console.error(e))
}
}
const showBanner = computed(() => {
return callState.groupId === props.roomId && !callState.joined && !callState.incoming && callState.roomId !== ''
})
function toggleMinimize() {
callState.minimized = !callState.minimized
}
const participantsCount = computed(() => participants.value.length)
const previewParticipants = computed(() => participants.value.slice(0, 3)) // 只显示前3个
function handleJoin() { groupWebRTC.joinCurrentCall() }
</script>
<style lang="scss" scoped>
.call-window {
position: fixed;
z-index: 9999;
background: #1c1c1e;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:not(.minimized) {
inset: 0;
}
&.minimized {
top: calc(var(--status-bar-height, 0) + 240rpx);
right: 32rpx;
width: 200rpx;
height: 280rpx;
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.15);
}
.banner-wrapper {
padding: 24rpx 32rpx;
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.minimized-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: #2c2c2e;
position: relative;
cursor: pointer;
svg {
width: 64rpx;
height: 64rpx;
}
.mini-duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
.mini-pulse {
position: absolute;
bottom: 16rpx;
width: 8rpx;
height: 8rpx;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}
&:active {
opacity: 0.9;
}
}
.window-header {
height: 200rpx;
padding: 0 40rpx;
padding-top: calc(24rpx + var(--status-bar-height, 0));
.glass-capsule {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 50;
pointer-events: none;
padding: 24rpx;
background: #fff;
border-radius: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06), 0 0 0 1rpx rgba(0,0,0,0.05);
transition: all 0.2s;
.dark & {
background: #292524;
border-color: rgba(255, 255, 255, 0.1);
}
}
.header-left {
.left-section {
display: flex;
align-items: center;
gap: 24rpx;
pointer-events: auto;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 20rpx #10b981;
animation: pulse 2s infinite;
}
.header-title {
font-size: 28rpx;
font-weight: 500;
color: #fff;
letter-spacing: 0.5rpx;
}
.duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
font-family: monospace;
padding-left: 24rpx;
border-left: 2rpx solid rgba(255, 255, 255, 0.1);
}
.minimize-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
.icon-container {
position: relative;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
pointer-events: auto;
svg {
width: 32rpx;
height: 32rpx;
.pulse-ring {
position: absolute;
inset: 0;
background: rgba(16, 185, 129, 0.2);
border-radius: 50%;
animation: pulse-ring 2s infinite;
}
&:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.2);
.icon-box {
position: relative;
width: 100%;
height: 100%;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(16, 185, 129, 0.3);
z-index: 10;
}
.icon-svg {
width: 40rpx;
height: 40rpx;
color: #fff;
}
}
.video-grid {
flex: 1;
padding: calc(var(--status-bar-height, 0) + 200rpx) 24rpx 240rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
align-content: flex-start;
overflow-y: auto;
}
.video-item {
position: relative;
width: calc(50% - 8rpx);
aspect-ratio: 3 / 4;
background: #2c2c2e;
border-radius: 32rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.05);
}
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
&.mirror { transform: scaleX(-1); }
}
.cam-off-placeholder {
position: absolute;
inset: 0;
.info-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: #2c2c2e;
gap: 8rpx;
}
.avatar-wrap {
position: relative;
}
.connecting-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cam-off-text, .participant-name {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.participant-status {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.video-label {
position: absolute;
bottom: 24rpx;
left: 24rpx;
.title-row {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 16rpx;
background: rgba(0, 0, 0, 0.4);
border-radius: 16rpx;
backdrop-filter: blur(16rpx);
border: 1rpx solid rgba(255, 255, 255, 0.1);
.label-name {
font-size: 20rpx;
.title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
.dark & { color: #f5f5f4; }
}
.count-badge {
padding: 2rpx 10rpx;
background: rgba(16, 185, 129, 0.1);
border: 1rpx solid rgba(16, 185, 129, 0.2);
border-radius: 8rpx;
text {
font-size: 20rpx;
font-weight: 600;
color: #10b981;
}
}
}
.avatar-stack {
display: flex;
align-items: center;
padding-left: 8rpx;
.stack-item {
position: relative;
border: 4rpx solid #fff;
border-radius: 50%;
.dark & { border-color: #292524; }
}
.more-badge {
margin-left: -16rpx;
width: 36rpx;
height: 36rpx;
background: #f3f4f6;
border: 4rpx solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.dark & {
background: #44403c;
border-color: #292524;
}
text {
font-size: 16rpx;
font-weight: 700;
color: #6b7280;
.dark & { color: #d6d3d1; }
}
}
}
.join-btn {
background: #10b981;
padding: 16rpx 32rpx;
border-radius: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.25);
text {
color: #fff;
font-weight: 500;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mute-icon {
width: 24rpx;
height: 24rpx;
font-size: 24rpx;
font-weight: 700;
}
}
.speaking-indicator {
position: absolute;
inset: 0;
border: 4rpx solid #10b981;
border-radius: 32rpx;
pointer-events: none;
animation: speakPulse 0.5s ease-in-out;
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.5; }
100% { transform: scale(1.4); opacity: 0; }
}
.control-bar {
height: 320rpx;
padding: 40rpx 0;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: center;
gap: 80rpx;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.5) 50%, transparent 100%);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
}
.control-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.control-btn {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: all 0.3s;
backdrop-filter: blur(8rpx);
svg {
width: 48rpx;
height: 48rpx;
}
&:active { transform: scale(0.95); }
&.active {
background: #fff;
color: #000;
border-color: #fff;
}
}
.hangup-btn {
width: 144rpx;
height: 72rpx;
border-radius: 56rpx;
background: #ef4444;
border: none;
box-shadow: 0 8rpx 32rpx rgba(239, 68, 68, 0.3);
padding: 0 32rpx;
svg {
width: 64rpx;
height: 64rpx;
}
&:active { background: #dc2626; }
}
.control-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.5);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes speakPulse {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
@keyframes slideUp {
from { transform: translateY(20rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@@ -1,139 +1,130 @@
<!--
小程序音视频通话窗口组件
使用 live-pusher/live-player 实现
适用平台微信小程序
小程序音视频通话窗口组件 - 同层渲染 + wot-ui Icon
适用平台微信小程序 (需基础库 2.9.1+)
-->
<template>
<!-- #ifdef MP-WEIXIN -->
<view v-if="call.active" class="call-window" :class="{ minimized: call.minimized }">
<!-- 最小化状态 -->
<view v-if="call.minimized" class="minimized-view" @click="toggleMinimize">
<view class="mini-avatar">
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
</view>
<view class="mini-info">
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
<text class="mini-status">{{ call.statusText }}</text>
</view>
</view>
<view v-if="call.active" class="mp-call-container">
<!-- 全屏状态 -->
<view v-else class="fullscreen-view">
<!-- 视频区域 -->
<view class="video-container">
<!-- 远端视频 -->
<view class="remote-videos">
<live-player
<!-- 1. 视频层 -->
<view class="video-layer">
<!-- 远端视频流 -->
<view class="remote-grid" :class="gridClass">
<live-player
v-for="stream in remoteStreams"
:key="stream.userId"
class="remote-video"
class="live-player-item"
:src="stream.pullUrl"
mode="RTC"
autoplay
muted="{{false}}"
:object-fit="'contain'"
@statechange="(e: any) => onPlayerStateChange(e, stream.userId)"
>
<view class="video-overlay">
<text class="user-name">{{ stream.userName || '对方' }}</text>
</view>
</live-player>
<!-- 无远端视频时的占位 -->
<view v-if="remoteStreams.length === 0" class="remote-placeholder">
<view class="caller-avatar-large">
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
</view>
<text class="caller-name">{{ call.callerName || '未知' }}</text>
<text class="call-status">{{ call.statusText }}</text>
:muted="false"
object-fit="fillCrop"
@statechange="(e) => onPlayerStateChange(e, stream.userId)"
@error="(e) => onPlayerError(e, stream.userId)"
>
<!-- 名字标签 (同层渲染直接使用 view) -->
<view class="player-tag">
<text class="tag-text">{{ stream.userName || '连线中...' }}</text>
</view>
</view>
</live-player>
<!-- 本地视频小窗 -->
<view v-if="call.type === 'video' && !call.camOff" class="local-video-container">
<live-pusher
class="local-video"
:url="pushUrl"
mode="RTC"
autopush
:muted="call.muted"
:enable-camera="!call.camOff"
aspect="9:16"
beauty="5"
whiteness="3"
@statechange="onPusherStateChange"
/>
<!-- 等待中的占位符 -->
<view v-if="remoteStreams.length === 0" class="waiting-placeholder">
<image :src="call.callerAvatar || '/static/default-avatar.png'" class="placeholder-avatar" mode="aspectFill" />
<text class="placeholder-text">{{ call.statusText }}</text>
</view>
</view>
<!-- 通话信息 -->
<view class="call-info">
<text class="duration">{{ formatDuration(call.duration) }}</text>
<text class="status-text">{{ call.statusText }}</text>
<!-- 本地推流 -->
<live-pusher
id="local-pusher"
class="local-pusher"
:url="pushUrl"
mode="RTC"
:autopush="true"
:enable-camera="!call.camOff"
:enable-mic="!call.muted"
aspect="9:16"
beauty="5"
whiteness="3"
@statechange="onPusherStateChange"
@error="onPusherError"
/>
</view>
<!-- 2. UI 交互层 (标准 View 层级) -->
<!-- 顶部栏 -->
<view class="ui-header">
<view class="header-left">
<view class="status-dot"></view>
<text class="header-title">通话中</text>
<text class="header-time">{{ formatDuration(call.duration) }}</text>
</view>
<!-- 控制按钮 -->
<view class="controls">
<!-- 来电状态 -->
<view v-if="call.status === 'incoming'" class="incoming-controls">
<view class="control-btn reject" @click="rejectCall">
<wd-icon name="close" size="60rpx" color="#fff" />
<text>拒绝</text>
</view>
<view class="control-btn accept" @click="acceptCall">
<wd-icon name="phone" size="60rpx" color="#fff" />
<text>接听</text>
</view>
</view>
<!-- 呼叫/通话中状态 -->
<view v-else class="call-controls">
<!-- 静音 -->
<view class="control-btn" :class="{ active: call.muted }" @click="toggleMute">
<wd-icon :name="call.muted ? 'mute' : 'sound'" size="48rpx" />
<text>{{ call.muted ? '取消静音' : '静音' }}</text>
</view>
<!-- 摄像头 -->
<view v-if="call.type === 'video'" class="control-btn" :class="{ active: call.camOff }" @click="toggleCamera">
<wd-icon :name="call.camOff ? 'camera-off' : 'camera'" size="48rpx" />
<text>{{ call.camOff ? '开启摄像头' : '关闭摄像头' }}</text>
</view>
<!-- 切换摄像头 -->
<view v-if="call.type === 'video' && !call.camOff" class="control-btn" @click="switchCamera">
<wd-icon name="refresh" size="48rpx" />
<text>翻转</text>
</view>
<!-- 挂断 -->
<view class="control-btn hangup" @click="endCall">
<wd-icon name="phone" size="48rpx" color="#fff" />
<text>挂断</text>
</view>
</view>
</view>
<!-- 最小化按钮 -->
<view class="minimize-btn" @click="toggleMinimize">
<wd-icon name="arrow-down" size="40rpx" />
<!-- 最小化图标 -->
<wd-icon name="arrow-down-circle" size="60rpx" color="rgba(255,255,255,0.8)" />
</view>
</view>
</view>
<!-- #endif -->
<!-- 非小程序平台提示 -->
<!-- #ifndef MP-WEIXIN -->
<view v-if="showUnsupportedTip" class="unsupported-tip">
<text>当前平台请使用 WebRTC 通话</text>
<!-- 底部控制栏 -->
<view class="ui-controls">
<!-- 呼叫中状态 (接听/拒绝) -->
<view v-if="call.status === 'incoming'" class="incoming-actions">
<view class="action-btn reject" @click="rejectCall">
<view class="btn-circle reject-bg">
<wd-icon name="close-bold" size="50rpx" color="#fff" />
</view>
<text class="action-text">拒绝</text>
</view>
<view class="action-btn accept" @click="acceptCall">
<view class="btn-circle accept-bg">
<wd-icon name="phone-filled" size="56rpx" color="#fff" />
</view>
<text class="action-text">接听</text>
</view>
</view>
<!-- 通话中状态 -->
<view v-else class="active-actions">
<!-- 静音 -->
<view class="ctrl-btn-wrap" @click="toggleMute">
<view class="ctrl-btn" :class="{ active: call.muted }">
<wd-icon :name="call.muted ? 'mic-off' : 'mic-on'" size="50rpx" :color="call.muted ? '#000' : '#fff'" />
</view>
<text class="ctrl-text">静音</text>
</view>
<!-- 挂断 -->
<view class="ctrl-btn-wrap" @click="endCall">
<view class="ctrl-btn hangup">
<wd-icon name="phone-off-filled" size="50rpx" color="#fff" />
</view>
<text class="ctrl-text">挂断</text>
</view>
<!-- 摄像头 -->
<view class="ctrl-btn-wrap" @click="toggleCamera">
<view class="ctrl-btn" :class="{ active: call.camOff }">
<wd-icon :name="call.camOff ? 'video-off' : 'video'" size="50rpx" :color="call.camOff ? '#000' : '#fff'" />
</view>
<text class="ctrl-text">摄像头</text>
</view>
</view>
</view>
<!-- 最小化悬浮球 -->
<view v-if="call.minimized" class="minimized-ball" @click="toggleMinimize">
<image :src="call.callerAvatar || '/static/default-avatar.png'" class="ball-avatar" mode="aspectFill" />
<text class="ball-time">{{ formatDuration(call.duration) }}</text>
</view>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, watch, nextTick, getCurrentInstance } from 'vue'
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
const {
@@ -145,304 +136,191 @@ const {
endCall,
toggleMute,
toggleCamera,
switchCamera,
toggleMinimize,
formatDuration,
onPusherStateChange,
onPlayerStateChange,
initPusherContext
} = useMiniProgramCall()
const showUnsupportedTip = ref(false)
// 获取当前组件实例,用于绑定 Context
const instance = getCurrentInstance()
// 注意:信令监听器在 GlobalCallProvider 中已初始化
// 这里不需要再次调用 initListener 和 initPusherContext
const gridClass = computed(() => remoteStreams.value.length > 1 ? 'grid-multi' : 'grid-single')
onMounted(() => {
// #ifndef MP-WEIXIN
showUnsupportedTip.value = true
// #endif
// 监听通话激活,初始化 Context
watch(() => call.active, (newVal) => {
if (newVal) {
nextTick(() => {
// 传入当前组件实例,确保 live-pusher 能被找到
// 在同层渲染模式下,这点尤为重要
initPusherContext(instance)
})
}
})
onUnmounted(() => {
// 清理资源
})
const onPusherError = (e: any) => {
console.error('Pusher Error:', e)
uni.showToast({ title: '推流失败,请检查摄像头权限', icon: 'none' })
}
const onPlayerError = (e: any, userId: string) => {
console.error(`Player Error [${userId}]:`, e)
}
</script>
<style lang="scss" scoped>
.call-window {
.mp-call-container {
position: fixed;
inset: 0;
background: #1c1c1e;
z-index: 9999;
&.minimized {
top: 100rpx;
right: 20rpx;
width: 240rpx;
height: 80rpx;
border-radius: 40rpx;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(20rpx);
}
&:not(.minimized) {
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
}
}
.minimized-view {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
height: 100%;
.mini-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
background: var(--wd-color-primary);
display: flex;
align-items: center;
justify-content: center;
image {
width: 100%;
height: 100%;
}
.avatar-text {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
}
.mini-info {
margin-left: 16rpx;
flex: 1;
.mini-duration {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
.mini-status {
color: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
display: block;
}
}
}
.fullscreen-view {
.video-layer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.video-container {
flex: 1;
position: relative;
background: #1a1a1a;
z-index: 1; /* 视频层在底层 */
}
.remote-videos {
.remote-grid {
width: 100%;
height: 100%;
.remote-video {
width: 100%;
height: 100%;
}
.video-overlay {
position: absolute;
bottom: 20rpx;
left: 20rpx;
.user-name {
color: #fff;
font-size: 28rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
}
display: flex;
flex-wrap: wrap;
&.grid-single .live-player-item { width: 100%; height: 100%; }
&.grid-multi .live-player-item { width: 50%; height: 50%; }
}
.remote-placeholder {
width: 100%;
height: 100%;
.live-player-item { position: relative; }
.player-tag {
position: absolute;
bottom: 20rpx;
left: 20rpx;
background: rgba(0,0,0,0.5);
padding: 8rpx 16rpx;
border-radius: 8rpx;
z-index: 10;
}
.tag-text { font-size: 24rpx; color: #fff; }
.local-pusher {
position: absolute;
right: 30rpx;
top: 180rpx;
width: 200rpx;
height: 300rpx;
border-radius: 16rpx;
z-index: 10;
border: 2rpx solid rgba(255,255,255,0.2);
overflow: hidden;
}
.waiting-placeholder {
position: absolute;
inset: 0;
background: #2c2c2e;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.caller-avatar-large {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
overflow: hidden;
background: var(--wd-color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
image {
width: 100%;
height: 100%;
}
.avatar-text {
color: #fff;
font-size: 80rpx;
font-weight: bold;
}
}
.caller-name {
color: #fff;
font-size: 40rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.call-status {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
}
}
.placeholder-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; margin-bottom: 20rpx; }
.placeholder-text { color: #aaa; font-size: 30rpx; }
.local-video-container {
/* 顶部栏 - 使用标准 view */
.ui-header {
position: absolute;
top: 100rpx;
right: 20rpx;
width: 200rpx;
height: 280rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.3);
.local-video {
width: 100%;
height: 100%;
}
}
.call-info {
position: absolute;
top: 40rpx;
top: 0;
left: 0;
right: 0;
text-align: center;
padding-top: env(safe-area-inset-top);
.duration {
color: #fff;
font-size: 48rpx;
font-weight: bold;
display: block;
}
.status-text {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
margin-top: 10rpx;
display: block;
}
}
.controls {
padding: 40rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
}
.incoming-controls {
width: 100%;
height: 160rpx;
padding-top: 60rpx;
display: flex;
justify-content: space-around;
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx;
border-radius: 50%;
&.reject {
background: #ff4d4f;
}
&.accept {
background: #52c41a;
}
text {
color: #fff;
font-size: 24rpx;
margin-top: 10rpx;
}
}
justify-content: space-between;
padding-left: 30rpx;
padding-right: 30rpx;
background: linear-gradient(180deg, rgba(0,0,0,0.6) 0%, transparent 100%);
z-index: 100; /* 高于视频层 */
pointer-events: none; /* 让点击穿透到视频层(如果有交互) */
}
.header-left { display: flex; align-items: center; pointer-events: auto; }
.status-dot { width: 12rpx; height: 12rpx; background: #10b981; border-radius: 50%; margin-right: 16rpx; }
.header-title { color: #fff; font-size: 32rpx; font-weight: bold; }
.header-time { color: rgba(255,255,255,0.8); font-size: 26rpx; margin-left: 20rpx; }
.minimize-btn { pointer-events: auto; padding: 10rpx; }
/* 底部栏 */
.ui-controls {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 240rpx;
background: linear-gradient(0deg, rgba(0,0,0,0.8) 0%, transparent 100%);
z-index: 100;
display: flex;
justify-content: center;
padding-bottom: 40rpx;
pointer-events: auto;
}
.call-controls {
.active-actions, .incoming-actions {
display: flex;
width: 100%;
justify-content: space-around;
align-items: center;
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
border-radius: 20rpx;
color: #fff;
&.active {
background: rgba(255, 255, 255, 0.2);
}
&.hangup {
background: #ff4d4f;
padding: 24rpx 40rpx;
border-radius: 50rpx;
}
text {
font-size: 22rpx;
margin-top: 8rpx;
opacity: 0.9;
}
}
}
.minimize-btn {
position: absolute;
top: 40rpx;
left: 40rpx;
padding: 16rpx;
.ctrl-btn-wrap { display: flex; flex-direction: column; align-items: center; }
.ctrl-btn {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: #fff;
padding-top: calc(16rpx + env(safe-area-inset-top));
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.ctrl-btn.active { background: #fff; }
.ctrl-btn.hangup { background: #ef4444; }
.unsupported-tip {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 40rpx;
background: rgba(0, 0, 0, 0.8);
border-radius: 20rpx;
text {
color: #fff;
font-size: 28rpx;
}
.ctrl-text { font-size: 24rpx; color: rgba(255,255,255,0.8); }
.action-btn { display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
.btn-circle {
width: 130rpx;
height: 130rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.reject-bg { background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); }
.accept-bg { background: #10b981; box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.4); }
.action-text { color: #fff; font-size: 28rpx; }
/* 最小化球 */
.minimized-ball {
position: absolute;
top: 200rpx;
right: 30rpx;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(0,0,0,0.8);
border: 2rpx solid rgba(255,255,255,0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 101;
}
.ball-avatar { width: 60rpx; height: 60rpx; border-radius: 50%; margin-bottom: 4rpx; }
.ball-time { color: #10b981; font-size: 20rpx; }
</style>

View File

@@ -247,8 +247,8 @@ function handleClose() {
top: 0;
left: 0;
right: 0;
height: calc(88rpx + var(--status-bar-height, 0));
padding-top: var(--status-bar-height, 0);
height: calc(88rpx + var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -113,7 +113,7 @@ function handleClick(key: string) {
// ==========================================
.plus-menu {
position: fixed;
top: calc(var(--status-bar-height, 20px) + 180rpx);
top: calc(var(--status-bar-height, 20px) + var(--mp-safe-top, 0px) + 180rpx);
right: 32rpx;
width: 288rpx;
background: var(--menu-bg);

View File

@@ -1,10 +1,9 @@
/**
* 小程序音视频通话组合式函数
* 使用 live-pusher/live-player 组件
*
* 适用平台:微信小程序
* * 适用平台:微信小程序
*/
import { reactive, ref, computed } from 'vue'
import { reactive, ref, computed, nextTick } from 'vue'
import * as callApi from '@/api/modules/call'
import * as messageApi from '@/api/modules/message'
import { wsManager } from '@/api/websocket'
@@ -70,10 +69,20 @@ export function useMiniProgramCall() {
/**
* 初始化 live-pusher 上下文
* 修复:接收组件实例,并绑定具体的 ID 'local-pusher'
* @param componentInstance Vue组件实例 (getCurrentInstance())
*/
function initPusherContext() {
function initPusherContext(componentInstance?: any) {
// #ifdef MP-WEIXIN
pusherContext = uni.createLivePusherContext()
// 关键修复:必须指定 id="local-pusher" 和 组件实例
// 如果不传 componentInstance在自定义组件中会找不到 live-pusher
if (!componentInstance) {
console.warn('[MiniProgramCall] initPusherContext: 缺少组件实例,推流可能失败')
}
// 这里的 'local-pusher' 必须与 template 中的 <live-pusher id="local-pusher"> 一致
pusherContext = uni.createLivePusherContext('local-pusher', componentInstance)
console.log('[MiniProgramCall] Pusher Context Initialized', !!pusherContext)
// #endif
}
@@ -99,7 +108,7 @@ export function useMiniProgramCall() {
if (signal === 'invite') {
// 收到来电
if (call.active) return
currentReceiverUserId = message.sender_user_id
call.roomId = message.room_id
call.callId = message.call_id
@@ -132,13 +141,16 @@ export function useMiniProgramCall() {
} else if (signal === 'participant_joined') {
// 新参与者加入
if (content.pull_url) {
remoteStreams.value.push({
userId: content.user_id,
pullUrl: content.pull_url,
flvUrl: content.flv_url,
userName: content.user_name,
userAvatar: content.user_avatar,
})
const exists = remoteStreams.value.some(s => s.userId === content.user_id)
if (!exists) {
remoteStreams.value.push({
userId: content.user_id,
pullUrl: content.pull_url,
flvUrl: content.flv_url,
userName: content.user_name,
userAvatar: content.user_avatar,
})
}
}
} else if (signal === 'participant_left') {
@@ -163,10 +175,10 @@ export function useMiniProgramCall() {
* 发起通话
*/
async function startCall(
type: 'audio' | 'video',
receiverUserId: string,
roomId: string,
contact?: Contact
type: 'audio' | 'video',
receiverUserId: string,
roomId: string,
contact?: Contact
): Promise<boolean> {
if (call.active) {
uni.showToast({ title: '当前有正在进行的通话', icon: 'none' })
@@ -234,8 +246,12 @@ export function useMiniProgramCall() {
}))
}
// 开始推流
startPushing()
// 关键修复:使用 nextTick 确保 DOM 更新live-pusher 的 url 属性生效后再启动
nextTick(() => {
setTimeout(() => {
startPushing()
}, 500) // 延迟500ms确保组件渲染完成
})
// 开始计时
startCallTimer()
@@ -331,15 +347,34 @@ export function useMiniProgramCall() {
*/
function startPushing() {
// #ifdef MP-WEIXIN
if (!pusherContext) {
initPusherContext()
// 检测是否在开发者工具中运行
const systemInfo = uni.getSystemInfoSync()
if (systemInfo.platform === 'devtools') {
console.warn('[MiniProgramCall] 开发者工具不支持 live-pusher请在真机上测试')
// 仅提示一次,避免阻塞流程
}
pusherContext?.start({
if (!pusherContext) {
console.error('[MiniProgramCall] Pusher Context 未初始化,无法推流')
return
}
// 确保 URL 存在
if (!pushUrl.value) {
console.error('[MiniProgramCall] 推流地址为空')
return
}
pusherContext.start({
success: () => {
console.log('[MiniProgramCall] 推流成功')
console.log('[MiniProgramCall] 推流启动成功')
},
fail: (err: any) => {
console.error('[MiniProgramCall] 推流失败:', err)
console.error('[MiniProgramCall] 推流启动失败:', err)
// 提示用户可能的原因
if (err.errMsg?.includes('operateLivePusher:fail')) {
uni.showToast({ title: '推流失败,请检查网络或权限', icon: 'none' })
}
}
})
// #endif
@@ -433,7 +468,8 @@ export function useMiniProgramCall() {
function toggleMute() {
call.muted = !call.muted
// #ifdef MP-WEIXIN
pusherContext?.mute(call.muted)
// 依赖 template 中的 :enable-mic="!call.muted"
// 或者可以使用 API: pusherContext?.setMicVolume({ volume: call.muted ? 0 : 1 })
// #endif
}
@@ -443,6 +479,8 @@ export function useMiniProgramCall() {
function toggleCamera() {
call.camOff = !call.camOff
// #ifdef MP-WEIXIN
// 依赖 template 中的 :enable-camera="!call.camOff"
// 也可以手动控制 pause/resume
if (call.camOff) {
pusherContext?.pause()
} else {
@@ -499,10 +537,6 @@ export function useMiniProgramCall() {
case -1302: // 打开麦克风失败
uni.showToast({ title: '麦克风打开失败', icon: 'none' })
break
case -1303: // 视频编码失败
break
case -1304: // 音频编码失败
break
case -1307: // 推流连接断开
call.statusText = '连接已断开'
break
@@ -516,16 +550,7 @@ export function useMiniProgramCall() {
const code = e.detail?.code
console.log(`[MiniProgramCall] 拉流状态 [${userId}]:`, code)
switch (code) {
case 2002: // 已连接到服务器
break
case 2003: // 收到首帧视频
break
case 2004: // 收到首帧音频
break
case -2301: // 网络断开,正在重连
break
}
// 状态码参考微信文档
}
return {
@@ -550,4 +575,3 @@ export function useMiniProgramCall() {
}
export default useMiniProgramCall

View File

@@ -1,70 +1,90 @@
{
"name": "",
"appid": "",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"uniStatistics": {
"enable": false
},
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "奶酪IM",
"appid" : "__UNI__C5C6003",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
"uniStatistics" : {
"enable" : false
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"h5": {
"darkmode": true,
"themeLocation": "theme.json"
},
"quickapp": {},
"mp-weixin": {
"appid": "wx54452d2c36bec8ee",
"setting": {
"urlCheck": false
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {},
"distribute" : {
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios" : {
"dSYMs" : false
},
"sdkConfigs" : {}
}
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"vueVersion": "3"
"h5" : {
"darkmode" : true,
"themeLocation" : "theme.json"
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "wx1eb90a487ca945d2",
"__usePrivacyCheck__" : true,
"setting" : {
"urlCheck" : false,
"preloadBackgroundData" : false
},
"libVersion" : "2.7.0",
"usingComponents" : true,
"darkmode" : true,
"themeLocation" : "theme.json",
"permission" : {
"scope.userLocation" : {
"desc" : "用于获取您的位置信息"
},
"scope.record" : {
"desc" : "用于语音消息录制"
},
"scope.camera" : {
"desc" : "用于拍摄照片和视频通话"
},
"scope.writePhotosAlbum" : {
"desc" : "用于保存图片到相册"
}
},
"requiredPrivateInfos" : [ "chooseImage", "chooseVideo", "chooseLocation", "getLocation" ]
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"vueVersion" : "3"
}

View File

@@ -65,9 +65,9 @@
<!-- 群通话 Banner -->
<group-call-banner
v-if="isGroupChat && hasActiveGroupCall"
:room-id="roomId"
class="call-banner-wrapper"
v-if="isGroupChat && hasActiveGroupCall"
:room-id="roomId"
class="call-banner-wrapper"
/>
<!-- 2. 消息列表区 -->
@@ -165,7 +165,7 @@
</wd-popup>
<wd-toast />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
@@ -213,9 +213,9 @@ const groupWebRTC = useGroupWebRTC()
// 检查群聊是否有进行中的通话
const hasActiveGroupCall = computed(() => {
if (!isGroupChat.value || !roomId.value) return false
return groupWebRTC.callState.groupId === roomId.value &&
!groupWebRTC.callState.joined &&
groupWebRTC.callState.roomId !== ''
return groupWebRTC.callState.groupId === roomId.value &&
!groupWebRTC.callState.joined &&
groupWebRTC.callState.roomId !== ''
})
// 获取当前组件实例,用于 SelectorQuery
const instance = getCurrentInstance()
@@ -246,12 +246,12 @@ onLoad(async (options: any) => {
isGroupChat.value = !targetId.value && !!roomId.value;
setupWebSocket();
// 群聊时先加载成员,确保消息渲染时能获取到发送者信息
if (isGroupChat.value && roomId.value) {
await loadGroupMembers()
}
loadMessages();
});
@@ -460,32 +460,32 @@ function onScrollClick() { /* 点击空白处收起键盘 */ }
function getMessageSenderAvatar(msg: ChatMessage): string {
const msgAny = msg as any
const senderId = msg.sender_user_id
// 1. 优先从消息体中获取后端可能返回sender信息
if (msgAny.sender?.avatar) return resolveImageUrl(msgAny.sender.avatar)
if (msgAny.sender_avatar) return resolveImageUrl(msgAny.sender_avatar)
if (msgAny.from_user?.avatar) return resolveImageUrl(msgAny.from_user.avatar)
// 2. 群聊场景
if (isGroupChat.value && senderId) {
// 2.1 从缓存获取
const cachedUser = userCache.value.get(senderId)
if (cachedUser?.avatar) return resolveImageUrl(cachedUser.avatar)
// 2.2 从成员映射获取O(1)查找)
const member = groupMembersMap.value.get(senderId)
if (member) {
const avatar = member.user?.avatar || (member as any).avatar || (member as any).user_avatar
if (avatar) return resolveImageUrl(avatar)
}
// 2.3 从联系人列表获取
const contact = chatStore.contacts.find(c => c.contact_user_id === senderId)
if (contact?.user?.avatar) return resolveImageUrl(contact.user.avatar)
return ''
}
// 3. 单聊场景
return resolveImageUrl(targetUser.value?.avatar || '')
}
@@ -494,33 +494,33 @@ function getMessageSenderAvatar(msg: ChatMessage): string {
function getMessageSenderName(msg: ChatMessage): string {
const msgAny = msg as any
const senderId = msg.sender_user_id
// 1. 优先从消息体中获取
if (msgAny.sender?.name) return msgAny.sender.name
if (msgAny.sender_name) return msgAny.sender_name
if (msgAny.from_user?.name) return msgAny.from_user.name
// 2. 群聊场景
if (isGroupChat.value && senderId) {
// 2.1 从缓存获取
const cachedUser = userCache.value.get(senderId)
if (cachedUser?.name) return cachedUser.name
// 2.2 从成员映射获取O(1)查找)
const member = groupMembersMap.value.get(senderId)
if (member) {
const name = member.nickname || member.user?.name || (member as any).name || (member as any).user_name
if (name) return name
}
// 2.3 从联系人列表获取
const contact = chatStore.contacts.find(c => c.contact_user_id === senderId)
if (contact?.user?.name) return contact.remark_name || contact.user.name
// 2.4 兜底显示用户ID末4位
return `用户${senderId.slice(-4)}`
}
// 3. 单聊场景
return targetUser.value?.name || chatName.value || '未知'
}
@@ -531,20 +531,94 @@ function playVideo(url: string) { if (url) { /* #ifdef H5 */ window.open(url, '_
function openFile(url: string) { if (!url) return; /* #ifdef H5 */ window.open(url, '_blank'); /* #endif */ /* #ifndef H5 */ toast.loading('正在打开文件...'); uni.downloadFile({ url: url, success: (res) => { toast.close(); if (res.statusCode === 200) { uni.openDocument({ filePath: res.tempFilePath, showMenu: true }) } }, fail: () => { toast.close(); toast.error('下载失败') } }); /* #endif */ }
let audioContext: UniApp.InnerAudioContext | null = null; function playAudio(msg: ChatMessage) { const audioUrl = typeof msg.extra === 'string' ? JSON.parse(msg.extra).url : msg.extra?.url || msg.content; const url = resolveImageUrl(audioUrl); if (!url) return; if (playingAudioId.value === msg.id) { stopAudio(); return } stopAudio(); audioContext = uni.createInnerAudioContext(); audioContext.src = url; audioContext.autoplay = true; playingAudioId.value = msg.id; audioContext.onEnded(() => { playingAudioId.value = null }); audioContext.onError(() => { playingAudioId.value = null; toast.error('播放失败') }) }
function stopAudio() { if (audioContext) { audioContext.stop(); audioContext.destroy(); audioContext = null } playingAudioId.value = null }
function startAudioCall() {
// --- 3. 小程序权限检查与通话启动逻辑 ---
/**
* 检查小程序通话权限
* 顺序scope.record (麦克风) -> scope.camera (摄像头,仅视频通话需要)
*/
async function checkCallPermissions(type: 'audio' | 'video'): Promise<boolean> {
// #ifndef MP-WEIXIN
return true // 非小程序环境默认认为有权限(由浏览器控制)
// #endif
// #ifdef MP-WEIXIN
const scopes = ['scope.record']
if (type === 'video') {
scopes.push('scope.camera')
}
for (const scope of scopes) {
try {
await new Promise((resolve, reject) => {
uni.authorize({
scope,
success: resolve,
fail: reject
})
})
} catch (e) {
// 授权失败(用户拒绝或未授权)
const scopeName = scope === 'scope.camera' ? '摄像头' : '麦克风'
const res = await uni.showModal({
title: '权限申请',
content: `需要获取您的${scopeName}权限才能进行通话,请在设置中开启`,
confirmText: '去设置',
showCancel: true
})
if (res.confirm) {
// 引导用户打开设置页
const settingRes = await new Promise<any>((resolve) => {
uni.openSetting({
success: resolve,
fail: () => resolve({ authSetting: {} })
})
})
// 再次检查设置结果
if (!settingRes.authSetting[scope]) {
toast.error(`未获取${scopeName}权限,无法通话`)
return false
}
} else {
// 用户点击取消
return false
}
}
}
return true
// #endif
}
async function startAudioCall() {
if (!targetId.value) return
// 1. 权限检查
const granted = await checkCallPermissions('audio')
if (!granted) return
// 2. 启动通话
// #ifdef H5 || APP-PLUS
webrtc.startCall('audio', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar)
// #endif
// #ifdef MP-WEIXIN
mpCall.startCall('audio', targetId.value, roomId.value, {
user_id: targetId.value,
user: { name: targetUser.value?.name, avatar: targetUser.value?.avatar }
mpCall.startCall('audio', targetId.value, roomId.value, {
user_id: targetId.value,
user: { name: targetUser.value?.name, avatar: targetUser.value?.avatar }
} as any)
// #endif
}
function startVideoCall() {
async function startVideoCall() {
if (!targetId.value) return
// 1. 权限检查
const granted = await checkCallPermissions('video')
if (!granted) return
// 2. 启动通话
// #ifdef H5 || APP-PLUS
webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar)
// #endif
@@ -555,6 +629,7 @@ function startVideoCall() {
} as any)
// #endif
}
function chooseImage() { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: handleFileSelect('image', 1) }) }
function shootCamera() { uni.chooseImage({ count: 1, sourceType: ['camera'], success: handleFileSelect('image', 1) }) }
function chooseFile() { /* #ifdef MP-WEIXIN */ wx.chooseMessageFile({ count: 1, type: 'file', success: (res: any) => handleFileSelect('file', 8)({ tempFilePaths: [res.tempFiles[0].path], tempFiles: res.tempFiles }) }); /* #endif */ /* #ifdef APP-PLUS */ uni.chooseFile({ count: 1, success: handleFileSelect('file', 8) }); /* #endif */ /* #ifdef H5 */ const input = document.createElement('input'); input.type = 'file'; input.onchange = (e: any) => { const file = e.target.files[0]; if(file) { pendingFile.value = { path: URL.createObjectURL(file), name: file.name, size: file.size, type: 'file', messageType: 8 }; showFileConfirm.value = true } }; input.click(); /* #endif */ }
@@ -614,7 +689,7 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
/* 导航栏 */
.glass-navbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding-top: var(--status-bar-height);
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
.navbar-bg {
position: absolute; inset: 0; background: var(--nav-bg);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -648,7 +723,7 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
/* 群通话 Banner 位置 */
.call-banner-wrapper {
position: fixed;
top: calc(88rpx + var(--status-bar-height));
top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
left: 0;
right: 0;
z-index: 99;
@@ -662,13 +737,13 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
&.has-banner {
.message-feed {
padding-top: calc(88rpx + var(--status-bar-height) + 160rpx);
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 160rpx);
}
}
}
.message-feed {
padding: 32rpx;
padding-top: calc(88rpx + var(--status-bar-height) + 32rpx);
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 32rpx);
/* 增加底部 Padding 以防输入框遮挡 */
padding-bottom: 240rpx;
}

View File

@@ -504,7 +504,7 @@ function goUserDetail(user: User) {
.header-content {
position: relative;
z-index: 10;
padding-top: var(--status-bar-height);
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
padding-bottom: 40rpx;
}

View File

@@ -445,7 +445,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
.nav-header {
position: relative;
z-index: 10;
padding: calc(var(--status-bar-height) + 24rpx) 32rpx 16rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 24rpx) 32rpx 16rpx;
display: flex;
justify-content: space-between;
align-items: center;

View File

@@ -492,7 +492,7 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
right: 0;
z-index: 100;
background: var(--bg-page);
padding-top: var(--status-bar-height);
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
// 与消息页一致,不要下边框
border-bottom: none;
transition: background 0.3s;
@@ -641,8 +641,8 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
// 滚动区
.main-scroll {
margin-top: calc(var(--status-bar-height) + 290rpx);
height: calc(100vh - var(--status-bar-height) - 290rpx);
margin-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 290rpx);
height: calc(100vh - var(--status-bar-height) - var(--mp-safe-top, 0px) - 290rpx);
}
.scroll-spacer {

View File

@@ -406,7 +406,7 @@ async function logout() {
position: sticky;
top: 0;
z-index: 100;
padding-top: var(--status-bar-height, 0);
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
// 确保没有任何边框
border: none;

View File

@@ -394,7 +394,7 @@ function getImageSize(moment: Moment): string {
top: 0;
left: 0;
right: 0;
height: calc(88rpx + var(--status-bar-height));
height: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
z-index: 100;
display: flex;
align-items: flex-end;

View File

@@ -178,7 +178,7 @@ async function save() {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {

View File

@@ -197,7 +197,7 @@ function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
// 头部
.profile-header {
background: var(--bg-surface);
padding: calc(var(--status-bar-height) + 24rpx) 40rpx 60rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 24rpx) 40rpx 60rpx;
border-bottom-left-radius: 80rpx;
border-bottom-right-radius: 80rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.05);

View File

@@ -436,7 +436,7 @@ function formatTime(time: string): string {
display: flex;
align-items: center;
gap: 20rpx;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
position: sticky;
top: 0;

View File

@@ -305,7 +305,7 @@ function handleClearCache() {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {

View File

@@ -169,7 +169,7 @@ function selectTheme(mode: 'system' | 'light' | 'dark') {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {

7
src/privacy.json Normal file
View File

@@ -0,0 +1,7 @@
{
"_comment": "微信小程序隐私协议配置文件",
"privacyContractInfo": {
"desc": "请在微信公众平台后台设置《用户隐私保护指引》"
}
}

View File

@@ -184,10 +184,18 @@
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
// 微信小程序额外安全距离(胶囊按钮区域)
--mp-safe-top: 0px;
}
/* #ifdef MP-WEIXIN */
page {
--mp-safe-top: env(safe-area-inset-top, 20px);
}
/* #endif */
.safe-area-top {
padding-top: var(--status-bar-height, 0);
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
}
.safe-area-bottom {
@@ -441,7 +449,7 @@
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding-top: var(--status-bar-height, 0);
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
border-bottom: 1rpx solid var(--border-subtle);
.navbar-content {