diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue
index dcfde67..feb0f23 100644
--- a/src/components/call/CallWindow.vue
+++ b/src/components/call/CallWindow.vue
@@ -4,10 +4,14 @@
-
+
+
+
+
{{ formatDuration(call.duration) }}
@@ -21,24 +25,19 @@
-
+
-
+
@@ -49,15 +48,15 @@
{{ call.callerName || '对方' }}
-
+
@@ -80,17 +79,13 @@
-
+
拒绝
-
+
接听
@@ -98,42 +93,34 @@
+
-
-
+
{{ call.muted ? '已静音' : '静音' }}
+
+
-
-
+
{{ call.camOff ? '已关闭' : '摄像头' }}
+
+
-
+
挂断
@@ -143,9 +130,7 @@
-
+
挂断
@@ -159,16 +144,12 @@
-
+
暂不支持音视频通话
当前平台暂不支持音视频通话功能
请使用网页版体验完整功能
-
+
复制网页链接
@@ -212,9 +193,7 @@ const localVideoRef = ref(null)
const remoteVideoRef = ref(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; }
diff --git a/src/components/call/GroupCallBanner.vue b/src/components/call/GroupCallBanner.vue
index 64a06af..e9fb90b 100644
--- a/src/components/call/GroupCallBanner.vue
+++ b/src/components/call/GroupCallBanner.vue
@@ -1,18 +1,16 @@
-
+
+
@@ -27,21 +25,21 @@
+{{ participantsCount - 5 }}
@@ -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;
- }
}
}
diff --git a/src/components/call/GroupCallWindow.vue b/src/components/call/GroupCallWindow.vue
index 1f737a6..2894fb0 100644
--- a/src/components/call/GroupCallWindow.vue
+++ b/src/components/call/GroupCallWindow.vue
@@ -1,502 +1,219 @@
-
-
-
-
- {{ formatDuration(callState.duration) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 摄像头已关闭
+
+
+
+
+
+
+
+
-
- 我
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ 通话进行中
+
+ {{ participantsCount }}人
- {{ user.name }}
-
- {{ user.status === 'connecting' ? '连接中...' : user.status === 'rejected' ? '已拒绝' : '摄像头关闭' }}
-
-
-
- {{ user.name }}
-
+
+
+
+
+
+ +{{ participantsCount - 3 }}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ callState.isSelfMuted ? '已静音' : '静音' }}
-
-
-
-
- 挂断
-
-
-
-
-
-
-
- {{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}
+
+ 加入
diff --git a/src/components/call/MiniProgramCallWindow.vue b/src/components/call/MiniProgramCallWindow.vue
index 6301d1a..87686d4 100644
--- a/src/components/call/MiniProgramCallWindow.vue
+++ b/src/components/call/MiniProgramCallWindow.vue
@@ -1,139 +1,130 @@
-
-
-
-
-
- {{ call.callerName?.charAt(0) || '?' }}
-
-
- {{ formatDuration(call.duration) }}
- {{ call.statusText }}
-
-
+
-
-
-
-
-
-
-
+
+
+
+ onPlayerStateChange(e, stream.userId)"
- >
-
- {{ stream.userName || '对方' }}
-
-
-
-
-
-
-
- {{ call.callerName?.charAt(0) || '?' }}
-
- {{ call.callerName || '未知' }}
- {{ call.statusText }}
+ :muted="false"
+ object-fit="fillCrop"
+ @statechange="(e) => onPlayerStateChange(e, stream.userId)"
+ @error="(e) => onPlayerError(e, stream.userId)"
+ >
+
+
+ {{ stream.userName || '连线中...' }}
-
+
-
-
-
+
+
+
+ {{ call.statusText }}
-
-
- {{ formatDuration(call.duration) }}
- {{ call.statusText }}
+
+
+
+
+
+
+
+
-
-
-
-
-
- 当前平台请使用 WebRTC 通话
+
+
+
+
+
+
+
+
+ 拒绝
+
+
+
+
+
+ 接听
+
+
+
+
+
+
+
+
+
+
+ 静音
+
+
+
+
+
+
+
+ 挂断
+
+
+
+
+
+
+
+ 摄像头
+
+
+
+
+
+
+
+ {{ formatDuration(call.duration) }}
+
+
-
diff --git a/src/components/common/ImagePreview.vue b/src/components/common/ImagePreview.vue
index 11e312f..68e45b1 100644
--- a/src/components/common/ImagePreview.vue
+++ b/src/components/common/ImagePreview.vue
@@ -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;
diff --git a/src/components/common/PlusMenu.vue b/src/components/common/PlusMenu.vue
index 7287161..6aefa48 100644
--- a/src/components/common/PlusMenu.vue
+++ b/src/components/common/PlusMenu.vue
@@ -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);
diff --git a/src/composables/useMiniProgramCall.ts b/src/composables/useMiniProgramCall.ts
index 7148ef2..c9656c4 100644
--- a/src/composables/useMiniProgramCall.ts
+++ b/src/composables/useMiniProgramCall.ts
@@ -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 中的 一致
+ 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 {
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
-
diff --git a/src/manifest.json b/src/manifest.json
index 276cb00..3b765f3 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -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": [
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- ""
- ]
- },
- "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" : [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ "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"
}
diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue
index abc4412..6a2469d 100644
--- a/src/pages/chat/index.vue
+++ b/src/pages/chat/index.vue
@@ -65,9 +65,9 @@
@@ -165,7 +165,7 @@
-
+
@@ -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 {
+ // #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((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;
}
diff --git a/src/pages/contact/add.vue b/src/pages/contact/add.vue
index 535929c..cc1de98 100644
--- a/src/pages/contact/add.vue
+++ b/src/pages/contact/add.vue
@@ -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;
}
diff --git a/src/pages/contact/detail.vue b/src/pages/contact/detail.vue
index 7b811f2..41acfeb 100644
--- a/src/pages/contact/detail.vue
+++ b/src/pages/contact/detail.vue
@@ -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;
diff --git a/src/pages/contact/index.vue b/src/pages/contact/index.vue
index 082745e..26f4899 100644
--- a/src/pages/contact/index.vue
+++ b/src/pages/contact/index.vue
@@ -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 {
diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue
index bfc0a2f..ab77ac6 100644
--- a/src/pages/index/index.vue
+++ b/src/pages/index/index.vue
@@ -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;
diff --git a/src/pages/moment/index.vue b/src/pages/moment/index.vue
index 51f7433..8570178 100644
--- a/src/pages/moment/index.vue
+++ b/src/pages/moment/index.vue
@@ -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;
diff --git a/src/pages/profile/edit.vue b/src/pages/profile/edit.vue
index ea948f4..4621a60 100644
--- a/src/pages/profile/edit.vue
+++ b/src/pages/profile/edit.vue
@@ -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 {
diff --git a/src/pages/profile/index.vue b/src/pages/profile/index.vue
index a162afe..dfd6918 100644
--- a/src/pages/profile/index.vue
+++ b/src/pages/profile/index.vue
@@ -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);
diff --git a/src/pages/search/index.vue b/src/pages/search/index.vue
index 2bf06fa..85e0e38 100644
--- a/src/pages/search/index.vue
+++ b/src/pages/search/index.vue
@@ -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;
diff --git a/src/pages/settings/index.vue b/src/pages/settings/index.vue
index e5ed65f..4952691 100644
--- a/src/pages/settings/index.vue
+++ b/src/pages/settings/index.vue
@@ -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 {
diff --git a/src/pages/settings/theme.vue b/src/pages/settings/theme.vue
index 725c420..8469226 100644
--- a/src/pages/settings/theme.vue
+++ b/src/pages/settings/theme.vue
@@ -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 {
diff --git a/src/privacy.json b/src/privacy.json
new file mode 100644
index 0000000..0a974b0
--- /dev/null
+++ b/src/privacy.json
@@ -0,0 +1,7 @@
+{
+ "_comment": "微信小程序隐私协议配置文件",
+ "privacyContractInfo": {
+ "desc": "请在微信公众平台后台设置《用户隐私保护指引》"
+ }
+}
+
diff --git a/src/styles/common.scss b/src/styles/common.scss
index 7a3a9de..4a0f976 100644
--- a/src/styles/common.scss
+++ b/src/styles/common.scss
@@ -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 {