适配微信小程序

This commit is contained in:
2025-12-15 11:06:42 +08:00
parent 8266de4902
commit 8e434a2b50
27 changed files with 746 additions and 190 deletions

View File

@@ -7,8 +7,18 @@ const { theme } = useTheme()
<template>
<wd-config-provider :theme="theme">
<!-- #ifdef MP-WEIXIN -->
<view class="mp-safe-container">
<KuRootView />
</view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<KuRootView />
<!-- #endif -->
<!-- 全局通话组件单聊 + 群通话 -->
<global-call-provider />
</wd-config-provider>
</template>
<style scoped>
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onLaunch, onShow } from '@dcloudio/uni-app'
import { useTheme } from '@/composables/useTheme'
import { initSafeArea } from '@/composables/useSafeArea'
import { useAuthStore, useConversationStore } from '@/stores'
import { wsManager } from '@/api/websocket'
import type { ChatMessage } from '@/types/api'
@@ -37,6 +38,8 @@ async function initWebSocket() {
onLaunch(() => {
console.log('App Launch')
// 初始化安全区域(获取胶囊按钮位置等)
initSafeArea()
// 初始化主题
initTheme()
})

View File

@@ -1,5 +1,5 @@
<template>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<view v-if="call.active" class="call-window" :class="{ minimized: call.minimized }">
<!-- 最小化悬浮窗 -->
<view v-if="call.minimized" class="minimized-bar" @click="$emit('toggleMinimize')">
@@ -79,13 +79,13 @@
<template v-if="call.status === 'incoming'">
<view class="action-item" @click="$emit('reject')">
<view class="control-btn decline">
<wd-icon name="close-bold" size="56rpx" color="#fff" />
<wd-icon name="close" size="56rpx" color="#fff" />
</view>
<text class="action-label">拒绝</text>
</view>
<view class="action-item" @click="$emit('accept')">
<view class="control-btn accept">
<wd-icon name="phone-filled" size="64rpx" color="#fff" />
<wd-icon name="phone" size="64rpx" color="#fff" />
</view>
<text class="action-label">接听</text>
</view>
@@ -97,7 +97,7 @@
<view class="action-item" @click="$emit('toggleMute')">
<view class="control-btn" :class="{ active: call.muted }">
<wd-icon
:name="call.muted ? 'mic-off' : 'mic-on'"
:name="call.muted ? 'mute' : 'sound'"
size="56rpx"
:color="call.muted ? '#000' : '#fff'"
/>
@@ -109,7 +109,7 @@
<view v-if="call.type === 'video'" class="action-item" @click="$emit('toggleCamera')">
<view class="control-btn" :class="{ active: call.camOff }">
<wd-icon
:name="call.camOff ? 'video-off' : 'video'"
name="video"
size="56rpx"
:color="call.camOff ? '#000' : '#fff'"
/>
@@ -120,7 +120,7 @@
<!-- 挂断 -->
<view class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
<wd-icon name="phone" size="64rpx" color="#fff" custom-style="transform: rotate(135deg);" />
</view>
<text class="action-label">挂断</text>
</view>
@@ -130,7 +130,7 @@
<template v-else-if="call.status === 'outgoing'">
<view class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
<wd-icon name="phone" size="64rpx" color="#fff" custom-style="transform: rotate(135deg);" />
</view>
<text class="action-label">挂断</text>
</view>
@@ -140,11 +140,11 @@
</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<!-- #ifndef H5 || APP-PLUS -->
<view v-if="call.active" class="call-unsupported">
<view class="unsupported-content">
<view class="icon-box">
<wd-icon name="video-off" size="80rpx" color="#f97316" />
<wd-icon name="video" size="80rpx" color="#f97316" />
</view>
<text class="unsupported-title">暂不支持音视频通话</text>
<text class="unsupported-desc">当前平台暂不支持音视频通话功能</text>
@@ -207,7 +207,7 @@ function copyWebUrl() {
}
watch(() => props.localStream, (stream) => {
// #ifdef H5
// #ifdef H5 || APP-PLUS
nextTick(() => {
if (localVideoRef.value && stream) {
localVideoRef.value.srcObject = stream
@@ -217,7 +217,7 @@ watch(() => props.localStream, (stream) => {
}, { immediate: true })
watch(() => props.remoteStream, (stream) => {
// #ifdef H5
// #ifdef H5 || APP-PLUS
nextTick(() => {
if (remoteVideoRef.value && stream) {
remoteVideoRef.value.srcObject = stream
@@ -239,7 +239,6 @@ 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) + var(--mp-safe-top, 0px) + 240rpx);
right: 32rpx;
left: auto;
bottom: auto;
@@ -249,6 +248,14 @@ watch(() => props.remoteStream, (stream) => {
border: 2rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
overflow: hidden;
/* #ifdef MP-WEIXIN */
top: calc(var(--status-bar-height, 44px) + 88rpx + 240rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
top: calc(var(--status-bar-height, 0) + 240rpx);
/* #endif */
}
}
@@ -346,7 +353,6 @@ watch(() => props.remoteStream, (stream) => {
.minimize-btn {
position: absolute;
top: calc(28rpx + var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
right: 40rpx;
z-index: 20;
width: 72rpx;
@@ -355,6 +361,14 @@ watch(() => props.remoteStream, (stream) => {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
/* #ifdef MP-WEIXIN */
top: calc(28rpx + var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
top: calc(28rpx + var(--status-bar-height, 0));
/* #endif */
align-items: center;
justify-content: center;
transition: all 0.15s;

View File

@@ -57,13 +57,13 @@
<!-- 顶部栏 -->
<view class="ui-header">
<view class="header-left">
<view class="status-dot"></view>
<text class="header-title">通话中</text>
<view class="status-dot" :class="statusDotClass"></view>
<text class="header-title">{{ headerTitle }}</text>
<text class="header-time">{{ formatDuration(call.duration) }}</text>
</view>
<view class="minimize-btn" @click="toggleMinimize">
<!-- 最小化图标 -->
<wd-icon name="arrow-down-circle" size="60rpx" color="rgba(255,255,255,0.8)" />
<wd-icon name="arrow-down" size="60rpx" color="rgba(255,255,255,0.8)" />
</view>
</view>
@@ -73,13 +73,13 @@
<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" />
<wd-icon name="close" 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" />
<wd-icon name="phone" size="56rpx" color="#fff" />
</view>
<text class="action-text">接听</text>
</view>
@@ -90,7 +90,7 @@
<!-- 静音 -->
<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'" />
<wd-icon :name="call.muted ? 'mute' : 'sound'" size="50rpx" :color="call.muted ? '#000' : '#fff'" />
</view>
<text class="ctrl-text">静音</text>
</view>
@@ -98,7 +98,7 @@
<!-- 挂断 -->
<view class="ctrl-btn-wrap" @click="endCall">
<view class="ctrl-btn hangup">
<wd-icon name="phone-off-filled" size="50rpx" color="#fff" />
<wd-icon name="phone" size="50rpx" color="#fff" custom-style="transform: rotate(135deg);" />
</view>
<text class="ctrl-text">挂断</text>
</view>
@@ -106,7 +106,7 @@
<!-- 摄像头 -->
<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'" />
<wd-icon name="video" size="50rpx" :color="call.camOff ? '#000' : '#fff'" />
</view>
<text class="ctrl-text">摄像头</text>
</view>
@@ -148,6 +148,33 @@ const instance = getCurrentInstance()
const gridClass = computed(() => remoteStreams.value.length > 1 ? 'grid-multi' : 'grid-single')
// 顶部状态显示
const headerTitle = computed(() => {
switch (call.status) {
case 'outgoing':
return '正在呼叫...'
case 'incoming':
return '来电...'
case 'connected':
return '通话中'
default:
return call.statusText || '通话'
}
})
// 状态指示点样式
const statusDotClass = computed(() => {
switch (call.status) {
case 'connected':
return 'status-connected'
case 'outgoing':
case 'incoming':
return 'status-pending'
default:
return ''
}
})
// 监听通话激活,初始化 Context
watch(() => call.active, (newVal) => {
if (newVal) {
@@ -236,8 +263,8 @@ const onPlayerError = (e: any, userId: string) => {
top: 0;
left: 0;
width: 100%;
height: 160rpx;
padding-top: 60rpx;
height: calc(var(--status-bar-height, 44px) + var(--mp-navbar-height, 48px) + 20rpx);
padding-top: calc(var(--status-bar-height, 44px) + 20rpx);
display: flex;
justify-content: space-between;
padding-left: 30rpx;
@@ -247,7 +274,19 @@ const onPlayerError = (e: any, userId: string) => {
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; }
.status-dot {
width: 12rpx;
height: 12rpx;
background: #666;
border-radius: 50%;
margin-right: 16rpx;
}
.status-dot.status-connected { background: #10b981; } /* 绿色 = 已连接 */
.status-dot.status-pending { background: #f59e0b; animation: pulse 1.5s infinite; } /* 橙色 = 呼叫中 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.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; }

View File

@@ -29,7 +29,7 @@
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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"/>
@@ -64,7 +64,7 @@
<view class="right-actions">
<!-- 表情按钮 -->
<view class="icon-btn" @click="toggleEmoji">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
@@ -79,7 +79,7 @@
<!-- 发送按钮 (有文字时显示) -->
<view v-if="inputValue.trim()" class="send-btn" @click="onSend">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
@@ -92,7 +92,7 @@
<!-- 更多按钮 (无文字时显示) -->
<view v-else class="icon-btn" @click="showMore = true">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
@@ -112,7 +112,7 @@
<view class="tools-grid">
<view class="tool-item" @click="emitAction('send-image')">
<view class="tool-icon album">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -123,7 +123,7 @@
</view>
<view class="tool-item" @click="emitAction('send-camera')">
<view class="tool-icon camera">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -134,7 +134,7 @@
</view>
<view class="tool-item" @click="emitAction('send-file')">
<view class="tool-icon file">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -150,7 +150,7 @@
<view v-if="isRecording" class="recording-overlay">
<view class="recording-content" :class="{ 'cancel-mode': isCancelRecording }">
<view class="recording-icon">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="!isCancelRecording" viewBox="0 0 24 24" fill="currentColor"><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" stroke="currentColor" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="currentColor" stroke-width="2"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<!-- #endif -->

View File

@@ -7,7 +7,7 @@
:left-arrow="leftArrow"
:fixed="fixed"
:placeholder="placeholder"
:safe-area-inset-top="safeAreaInsetTop"
:safe-area-inset-top="true"
:bordered="bordered"
custom-class="app-nav-bar"
@click-left="handleClickLeft"
@@ -81,6 +81,7 @@ function handleClickRight() {
<style lang="scss" scoped>
:deep(.app-nav-bar) {
position: relative;
// 适配暗黑模式的背景色和文字颜色
// 假设 src/uni.scss 或 variables.scss 中定义了这些 CSS 变量
background-color: rgba(255, 255, 255, 0.85) !important;
@@ -101,6 +102,18 @@ function handleClickRight() {
}
}
/* #ifdef MP-WEIXIN */
:deep(.app-nav-bar) {
top: 130rpx !important;
}
/* #endif */
/* #ifndef MP-WEIXIN */
:deep(.app-nav-bar) {
top: 0 !important;
}
/* #endif */
// 暗黑模式下的特殊覆盖(如果 CSS 变量不够用)
.dark {
:deep(.app-nav-bar) {
@@ -111,7 +124,7 @@ function handleClickRight() {
.wd-navbar__title {
color: #f5f5f4 !important;
}
.wd-icon {
color: #e7e5e4 !important;
}

View File

@@ -11,7 +11,7 @@
<view class="tabbar-icon">
<!-- 消息图标 (message-circle) -->
<template v-if="tab.name === 'messages'">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
@@ -30,7 +30,7 @@
<!-- 联系人图标 (users) -->
<template v-else-if="tab.name === 'contacts'">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4" fill="currentColor"/>
@@ -51,7 +51,7 @@
<!-- 发现图标 (compass) -->
<template v-else-if="tab.name === 'moments'">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="white"/>
@@ -68,7 +68,7 @@
<!-- 我的图标 (user) -->
<template v-else-if="tab.name === 'profile'">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>

View File

@@ -247,8 +247,6 @@ function handleClose() {
top: 0;
left: 0;
right: 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;
@@ -256,6 +254,16 @@ function handleClose() {
padding-right: 24rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
z-index: 10;
/* #ifdef MP-WEIXIN */
height: calc(88rpx + var(--status-bar-height, 44px) + 88rpx);
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
height: calc(88rpx + var(--status-bar-height, 0));
padding-top: var(--status-bar-height, 0);
/* #endif */
}
.close-btn,

View File

@@ -113,7 +113,6 @@ function handleClick(key: string) {
// ==========================================
.plus-menu {
position: fixed;
top: calc(var(--status-bar-height, 20px) + var(--mp-safe-top, 0px) + 180rpx);
right: 32rpx;
width: 288rpx;
background: var(--menu-bg);
@@ -122,6 +121,14 @@ function handleClick(key: string) {
z-index: 999;
box-shadow: var(--menu-shadow);
border: 2rpx solid var(--menu-border);
/* #ifdef MP-WEIXIN */
top: calc(var(--status-bar-height, 44px) + 88rpx + 180rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
top: calc(var(--status-bar-height, 20px) + 180rpx);
/* #endif */
transform-origin: top right;
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
overflow: hidden;

View File

@@ -142,7 +142,7 @@
<view class="action-grid">
<view class="action-item" @click="handleSendMessage">
<view class="action-icon blue">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
@@ -156,7 +156,7 @@
<view class="action-item" @click="handleAudioCall">
<view class="action-icon green">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<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>
@@ -170,7 +170,7 @@
<view class="action-item" @click="handleVideoCall">
<view class="action-icon purple">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<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"/>
@@ -185,7 +185,7 @@
<view class="action-item" @click="handleViewMoments">
<view class="action-icon orange">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>

View File

@@ -219,11 +219,34 @@ export function useMiniProgramCall() {
}
}
/**
* 验证 RTMP URL 格式
*/
function validateRtmpUrl(url: string): { valid: boolean; error?: string } {
if (!url) {
return { valid: false, error: '推流地址为空' }
}
// 检查是否为 RTMP 协议
if (!url.startsWith('rtmp://')) {
return { valid: false, error: `推流地址格式错误,期望 rtmp://` }
}
return { valid: true }
}
/**
* 加入通话房间
*/
async function joinCallRoom() {
if (!call.roomId || !userId.value) return
if (!call.roomId || !userId.value) {
console.error('[MiniProgramCall] 缺少 roomId 或 userId')
return
}
console.log('[MiniProgramCall] 加入房间:', {
roomId: call.roomId,
userId: userId.value,
platform: 'miniprogram'
})
try {
const response = await callApi.joinCallRoom({
@@ -232,18 +255,37 @@ export function useMiniProgramCall() {
platform: 'miniprogram'
})
// 设置推流地址
console.log('[MiniProgramCall] 加入房间响应:', JSON.stringify(response, null, 2))
// 验证并设置推流地址
if (response.push_url) {
pushUrl.value = response.push_url
const validation = validateRtmpUrl(response.push_url)
if (validation.valid) {
pushUrl.value = response.push_url
console.log('[MiniProgramCall] ✅ 推流地址:', response.push_url)
} else {
console.error('[MiniProgramCall] ❌ 推流地址验证失败:', validation.error)
uni.showToast({ title: validation.error || '推流地址无效', icon: 'none' })
pushUrl.value = response.push_url
}
} else {
console.error('[MiniProgramCall] ❌ 服务器未返回推流地址')
uni.showToast({ title: '服务器未返回推流地址', icon: 'none' })
}
// 设置拉流地址
if (response.pull_urls) {
if (response.pull_urls && response.pull_urls.length > 0) {
remoteStreams.value = response.pull_urls.map(p => ({
userId: p.user_id,
pullUrl: p.url,
flvUrl: p.flv_url,
}))
console.log('[MiniProgramCall] 拉流地址:', remoteStreams.value)
}
// 参与者信息
if (response.participants) {
console.log('[MiniProgramCall] 房间参与者:', response.participants)
}
// 关键修复:使用 nextTick 确保 DOM 更新live-pusher 的 url 属性生效后再启动
@@ -258,8 +300,9 @@ export function useMiniProgramCall() {
call.status = 'connected'
call.statusText = '通话中'
} catch (error) {
} catch (error: any) {
console.error('[MiniProgramCall] 加入房间失败:', error)
uni.showToast({ title: error?.message || '加入房间失败', icon: 'none' })
throw error
}
}
@@ -349,31 +392,55 @@ export function useMiniProgramCall() {
// #ifdef MP-WEIXIN
// 检测是否在开发者工具中运行
const systemInfo = uni.getSystemInfoSync()
if (systemInfo.platform === 'devtools') {
console.warn('[MiniProgramCall] 开发者工具不支持 live-pusher请在真机上测试')
// 仅提示一次,避免阻塞流程
const isDevtools = systemInfo.platform === 'devtools'
if (isDevtools) {
console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher请在真机上测试')
uni.showToast({ title: '开发者工具不支持推流,请真机测试', icon: 'none', duration: 3000 })
}
if (!pusherContext) {
console.error('[MiniProgramCall] Pusher Context 未初始化,无法推流')
console.error('[MiniProgramCall] Pusher Context 未初始化')
console.error('[MiniProgramCall] 请确保:')
console.error(' 1. 组件中有 <live-pusher id="local-pusher" ...>')
console.error(' 2. 调用了 initPusherContext(getCurrentInstance())')
uni.showToast({ title: 'live-pusher 组件未初始化', icon: 'none' })
return
}
// 确保 URL 存在
if (!pushUrl.value) {
console.error('[MiniProgramCall] 推流地址为空')
console.error('[MiniProgramCall] 推流地址为空,无法推流')
return
}
console.log('[MiniProgramCall] 开始推流URL:', pushUrl.value)
pusherContext.start({
success: () => {
console.log('[MiniProgramCall] 推流启动成功')
console.log('[MiniProgramCall] 推流启动成功')
},
fail: (err: any) => {
console.error('[MiniProgramCall] 推流启动失败:', err)
// 提示用户可能的原因
if (err.errMsg?.includes('operateLivePusher:fail')) {
uni.showToast({ title: '推流失败,请检查网络或权限', icon: 'none' })
console.error('[MiniProgramCall] 推流启动失败:', err)
console.error('[MiniProgramCall] 错误信息:', JSON.stringify(err))
// 详细的错误提示
let errorMsg = '推流失败'
if (err.errMsg) {
if (err.errMsg.includes('internal error')) {
errorMsg = '推流内部错误请检查RTMP服务器'
} else if (err.errMsg.includes('operateLivePusher:fail')) {
errorMsg = '推流操作失败'
} else if (err.errMsg.includes('permission')) {
errorMsg = '缺少摄像头/麦克风权限'
}
}
uni.showToast({ title: errorMsg, icon: 'none', duration: 3000 })
// 在开发者工具中,提示用户
if (isDevtools) {
console.warn('[MiniProgramCall] 💡 提示:开发者工具不支持 live-pusher')
console.warn('[MiniProgramCall] 请使用手机扫码在真机预览中测试')
}
}
})
@@ -517,29 +584,87 @@ export function useMiniProgramCall() {
/**
* 处理推流状态变化
* 状态码参考https://developers.weixin.qq.com/miniprogram/dev/component/live-pusher.html
*/
function onPusherStateChange(e: any) {
const code = e.detail?.code
console.log('[MiniProgramCall] 推流状态:', code)
const message = e.detail?.message || ''
console.log('[MiniProgramCall] 推流状态:', code, message)
switch (code) {
case 1002: // 成功连接到服务器
// 成功状态
case 1001: // 已连接到云端推流服务器
console.log('[MiniProgramCall] ✅ 已连接到推流服务器')
break
case 1002: // 已与云端推流服务器握手完毕
console.log('[MiniProgramCall] ✅ 握手完成,开始推流')
call.statusText = '通话中'
break
case 1003: // 已开始推流
case 1003: // 已成功打开摄像头
console.log('[MiniProgramCall] ✅ 摄像头已打开')
break
case 1004: // 推流断开,正在重连
call.statusText = '重新连接中...'
case 1004: // 自动调整分辨率
break
case -1301: // 打开摄像头失败
uni.showToast({ title: '摄像头打开失败', icon: 'none' })
case 1005: // 成功打开麦克风
console.log('[MiniProgramCall] ✅ 麦克风已打开')
break
case -1302: // 打开麦克风失败
uni.showToast({ title: '麦克风打开失败', icon: 'none' })
// 警告状态
case 1101: // 网络状况不佳:上行带宽不足
call.statusText = '网络不佳...'
break
case -1307: // 推流连接断开
case 1102: // 视频编码器启动失败
console.warn('[MiniProgramCall] ⚠️ 视频编码器启动失败')
break
case 1103: // 摄像头被占用
uni.showToast({ title: '摄像头被占用', icon: 'none' })
break
case 3001: // RTMP 服务器建立连接失败
console.error('[MiniProgramCall] ❌ RTMP 服务器连接失败')
call.statusText = '服务器连接失败'
uni.showToast({ title: 'RTMP服务器连接失败', icon: 'none' })
break
case 3002: // RTMP 服务器握手失败
console.error('[MiniProgramCall] ❌ RTMP 服务器握手失败')
uni.showToast({ title: 'RTMP服务器握手失败', icon: 'none' })
break
case 3003: // RTMP 服务器连接已断开
console.warn('[MiniProgramCall] ⚠️ RTMP 连接断开')
call.statusText = '连接已断开'
break
case 3004: // RTMP 推流地址格式不正确
console.error('[MiniProgramCall] ❌ RTMP 推流地址格式错误:', pushUrl.value)
uni.showToast({ title: '推流地址格式错误', icon: 'none' })
break
case 3005: // RTMP 服务器连接异常断开
console.error('[MiniProgramCall] ❌ RTMP 连接异常断开')
call.statusText = '连接异常断开'
break
// 错误状态
case -1301: // 打开摄像头失败
console.error('[MiniProgramCall] ❌ 摄像头打开失败')
uni.showToast({ title: '摄像头打开失败,请检查权限', icon: 'none' })
break
case -1302: // 打开麦克风失败
console.error('[MiniProgramCall] ❌ 麦克风打开失败')
uni.showToast({ title: '麦克风打开失败,请检查权限', icon: 'none' })
break
case -1303: // 视频编码失败
console.error('[MiniProgramCall] ❌ 视频编码失败')
break
case -1304: // 音频编码失败
console.error('[MiniProgramCall] ❌ 音频编码失败')
break
case -1307: // 推流连接断开
console.error('[MiniProgramCall] ❌ 推流连接断开')
call.statusText = '连接已断开'
break
default:
if (code < 0) {
console.error('[MiniProgramCall] ❌ 未知错误:', code, message)
}
}
}

View File

@@ -0,0 +1,139 @@
/**
* 安全区域和导航栏尺寸组合式函数
* 用于获取微信小程序的状态栏、胶囊按钮、导航栏高度
*/
import { ref, computed, onMounted } from 'vue'
// 胶囊按钮信息
interface MenuButtonInfo {
width: number
height: number
top: number
right: number
bottom: number
left: number
}
// 全局单例状态(避免重复计算)
const systemInfo = ref<UniApp.GetSystemInfoResult | null>(null)
const menuButtonInfo = ref<MenuButtonInfo | null>(null)
const isInitialized = ref(false)
/**
* 初始化安全区域数据
*/
function initSafeArea() {
if (isInitialized.value) return
try {
// 获取系统信息
systemInfo.value = uni.getSystemInfoSync()
// #ifdef MP-WEIXIN
// 获取微信小程序胶囊按钮位置信息
try {
const rect = uni.getMenuButtonBoundingClientRect()
menuButtonInfo.value = {
width: rect.width,
height: rect.height,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left
}
console.log('📱 [SafeArea] 胶囊按钮信息:', menuButtonInfo.value)
} catch (e) {
console.warn('⚠️ [SafeArea] 获取胶囊按钮信息失败:', e)
}
// #endif
isInitialized.value = true
} catch (e) {
console.error('❌ [SafeArea] 初始化失败:', e)
}
}
export function useSafeArea() {
// 状态栏高度
const statusBarHeight = computed(() => {
return systemInfo.value?.statusBarHeight || 0
})
// 导航栏内容高度(不含状态栏)
const navBarContentHeight = computed(() => {
// #ifdef MP-WEIXIN
if (menuButtonInfo.value) {
// 导航栏高度 = (胶囊按钮距顶部距离 - 状态栏高度) * 2 + 胶囊按钮高度
const paddingTop = menuButtonInfo.value.top - statusBarHeight.value
return paddingTop * 2 + menuButtonInfo.value.height
}
return 48 // 微信小程序默认值
// #endif
// #ifndef MP-WEIXIN
return 44 // H5/App 默认值
// #endif
})
// 导航栏总高度(状态栏 + 导航栏内容)
const navBarTotalHeight = computed(() => {
return statusBarHeight.value + navBarContentHeight.value
})
// 胶囊按钮右侧边距(用于计算导航栏右侧安全区域)
const menuButtonRight = computed(() => {
// #ifdef MP-WEIXIN
if (menuButtonInfo.value && systemInfo.value) {
// 屏幕宽度 - 胶囊按钮右边距
return systemInfo.value.windowWidth - menuButtonInfo.value.right
}
return 10 // 默认 10px
// #endif
// #ifndef MP-WEIXIN
return 0
// #endif
})
// 安全区域右边距(避开胶囊按钮的宽度)
const safeAreaRight = computed(() => {
// #ifdef MP-WEIXIN
if (menuButtonInfo.value && systemInfo.value) {
// 胶囊按钮宽度 + 右边距 + 额外间距
return menuButtonInfo.value.width + menuButtonRight.value + 10
}
return 100 // 默认 100px
// #endif
// #ifndef MP-WEIXIN
return 0
// #endif
})
// 初始化(在组件挂载时调用)
onMounted(() => {
initSafeArea()
})
// 也可以手动初始化
function init() {
initSafeArea()
}
return {
systemInfo,
menuButtonInfo,
statusBarHeight,
navBarContentHeight,
navBarTotalHeight,
menuButtonRight,
safeAreaRight,
init
}
}
// 导出初始化函数,可在 App.vue 中调用
export { initSafeArea }
export default useSafeArea

View File

@@ -74,7 +74,7 @@ export function useWebRTC() {
function playRingtone() {
stopRingtone()
// #ifdef H5
// #ifdef H5 || APP-PLUS
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
const ctx = new AudioContext()
@@ -230,7 +230,7 @@ export function useWebRTC() {
// 初始化媒体设备
async function initMedia(videoEnabled: boolean): Promise<void> {
// #ifdef H5
// #ifdef H5 || APP-PLUS
try {
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
@@ -247,7 +247,7 @@ export function useWebRTC() {
}
// #endif
// #ifndef H5
// #ifndef H5 || APP-PLUS
throw new Error('当前平台暂不支持音视频通话')
// #endif
}
@@ -277,7 +277,7 @@ export function useWebRTC() {
// 创建 RTCPeerConnection
async function createPC(): Promise<void> {
// #ifdef H5
// #ifdef H5 || APP-PLUS
// 优先使用服务器配置的 ICE 服务器
let servers: RTCIceServer[] = sfuIceServers.length > 0
? sfuIceServers
@@ -562,7 +562,7 @@ export function useWebRTC() {
call.remoteCamOff = false
stopCallTimer()
// #ifdef H5
// #ifdef H5 || APP-PLUS
if (pc) {
pc.close()
pc = null
@@ -609,7 +609,7 @@ export function useWebRTC() {
function toggleMute() {
call.muted = !call.muted
sendSyncState('mic-toggle', call.muted)
// #ifdef H5
// #ifdef H5 || APP-PLUS
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
// #endif
}
@@ -618,7 +618,7 @@ export function useWebRTC() {
function toggleCamera() {
call.camOff = !call.camOff
sendSyncState('cam-toggle', call.camOff)
// #ifdef H5
// #ifdef H5 || APP-PLUS
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
// #endif
}

View File

@@ -8,7 +8,7 @@
<view class="navbar-content">
<view class="nav-left" @click="goBack">
<view class="back-btn">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -26,7 +26,7 @@
<view class="nav-actions">
<view class="icon-btn" @click="startAudioCall">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -34,7 +34,7 @@
<!-- #endif -->
</view>
<view class="icon-btn" @click="startVideoCall">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -43,7 +43,7 @@
</view>
<!-- 群聊显示群成员图标 -->
<view v-if="isGroupChat" class="icon-btn" @click="goGroupInfo">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -52,7 +52,7 @@
</view>
<!-- 单聊显示更多图标 -->
<view v-else class="icon-btn" @click="showChatInfo">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -145,7 +145,7 @@
<view class="dialog-header">确认发送</view>
<view class="file-preview-box">
<view class="preview-icon">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
@@ -165,9 +165,6 @@
</wd-popup>
<wd-toast />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -194,7 +191,6 @@ import type { ChatMessage, GroupMember } from '@/types/api'
import MessageBubble from '@/components/chat/MessageBubble.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import GroupCallBanner from '@/components/call/GroupCallBanner.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
const authStore = useAuthStore()
@@ -668,6 +664,7 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
display: flex;
flex-direction: column;
background: var(--bg-base);
padding-top: 130rpx;
&.theme-dark {
--bg-base: #0c0a09;
@@ -689,7 +686,16 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
/* 导航栏 */
.glass-navbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
/* #ifdef MP-WEIXIN */
/* 微信小程序:状态栏 + 胶囊按钮区域 */
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
/* H5/App仅状态栏 */
padding-top: var(--status-bar-height, 0);
/* #endif */
.navbar-bg {
position: absolute; inset: 0; background: var(--nav-bg);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -723,10 +729,17 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
/* 群通话 Banner 位置 */
.call-banner-wrapper {
position: fixed;
top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
left: 0;
right: 0;
z-index: 99;
/* #ifdef MP-WEIXIN */
top: calc(88rpx + var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
top: calc(88rpx + var(--status-bar-height, 0));
/* #endif */
}
/* 消息列表 */
@@ -737,15 +750,29 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
&.has-banner {
.message-feed {
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 160rpx);
/* #ifdef MP-WEIXIN */
padding-top: calc(88rpx + var(--status-bar-height, 44px) + 88rpx + 160rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
padding-top: calc(88rpx + var(--status-bar-height, 0) + 160rpx);
/* #endif */
}
}
}
.message-feed {
padding: 32rpx;
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 32rpx);
/* 增加底部 Padding 以防输入框遮挡 */
padding-bottom: 240rpx;
/* #ifdef MP-WEIXIN */
/* 微信小程序:导航栏高度 + 状态栏 + 胶囊区域 */
padding-top: calc(88rpx + var(--status-bar-height, 44px) + 88rpx + 32rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
/* H5/App导航栏高度 + 状态栏 */
padding-top: calc(88rpx + var(--status-bar-height, 0) + 32rpx);
/* #endif */
}
.feed-status { text-align: center; padding: 24rpx; margin-bottom: 24rpx; .link-text { color: var(--color-primary); font-size: 24rpx; } }

View File

@@ -19,7 +19,7 @@
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-btn" @click="goBack">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-7 h-7">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
@@ -30,7 +30,7 @@
</view>
<text class="nav-title">添加好友</text>
<view class="nav-btn">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
@@ -53,7 +53,7 @@
<view class="search-wrapper">
<view class="search-box" @click="focusSearch">
<view class="search-icon-wrapper">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
@@ -74,7 +74,7 @@
@confirm="doSearch"
/>
<view v-if="searchKeyword" class="clear-btn" @click.stop="clearSearch">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
@@ -96,7 +96,7 @@
<!-- 扫一扫卡片 -->
<view class="scan-card animate-fade-in-up" @click="handleScan">
<view class="scan-icon-box">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 text-white">
<path d="M3 7V5a2 2 0 0 1 2-2h2"></path>
<path d="M17 3h2a2 2 0 0 1 2 2v2"></path>
@@ -113,7 +113,7 @@
<text class="scan-desc">扫描二维码名片</text>
</view>
<view class="scan-arrow">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-white">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
@@ -168,7 +168,7 @@
<template v-else-if="viewState === 'empty'">
<view class="state-card animate-fade-in-up">
<view class="empty-icon-box">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
@@ -504,8 +504,17 @@ function goUserDetail(user: User) {
.header-content {
position: relative;
z-index: 10;
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
padding-bottom: 40rpx;
/* #ifdef MP-WEIXIN */
/* 微信小程序:状态栏 + 胶囊按钮区域 */
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
/* H5/App仅状态栏 */
padding-top: var(--status-bar-height, 0);
/* #endif */
}
.nav-bar {

View File

@@ -14,7 +14,7 @@
<!-- 导航栏 (与设计稿一致) -->
<view class="nav-header">
<view class="nav-back" @click="goBack">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
@@ -24,7 +24,7 @@
<!-- #endif -->
</view>
<view class="nav-more">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
@@ -71,7 +71,7 @@
<text class="row-label">设置备注</text>
<view class="row-right">
<text class="row-value">{{ contact.remark_name || '未设置' }}</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -170,7 +170,7 @@
<view class="dock-buttons">
<!-- 电话: flex-1 -->
<view class="dock-btn secondary" @click="startAudioCall">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.05 12.05 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.05 12.05 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
@@ -181,7 +181,7 @@
</view>
<!-- 发消息: flex-[2] -->
<view class="dock-btn primary" @click="goChat">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
@@ -193,7 +193,7 @@
</view>
<!-- 视频: flex-1 -->
<view class="dock-btn secondary" @click="startVideoCall">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<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"/>
@@ -214,7 +214,7 @@
<!-- 底部悬浮操作栏 - 陌生人状态 -->
<view class="floating-dock" v-else-if="!isFriend && strangerUser">
<view class="add-friend-btn" @click="addFriend">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
@@ -445,11 +445,18 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
.nav-header {
position: relative;
z-index: 10;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 24rpx) 32rpx 16rpx;
display: flex;
justify-content: space-between;
align-items: center;
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 24rpx) 32rpx 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 24rpx) 32rpx 16rpx;
/* #endif */
.nav-back, .nav-more {
width: 56rpx;
height: 56rpx;

View File

@@ -20,7 +20,7 @@
</view>
<!-- 右侧: 添加好友按钮 -->
<view class="icon-btn" @click="goAddFriend">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="add-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
@@ -36,7 +36,7 @@
<view class="search-box-wrap">
<view class="search-inner" @click="goSearch">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
@@ -85,7 +85,7 @@
<view v-if="activeTab === 'groups'" class="feature-grid">
<view class="feature-card" @click="goFriendRequests">
<view class="icon-wrap is-orange">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
@@ -104,7 +104,7 @@
</view>
<view class="feature-card" @click="goGroupNotify">
<view class="icon-wrap is-blue">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
@@ -131,7 +131,7 @@
<view class="group-section-header">
<text class="section-title">我的分组</text>
<view class="add-group-btn" @click="showCreateGroupModal = true">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
@@ -148,7 +148,7 @@
<view v-if="ungroupedContacts.length" class="collapse-item animate-fade-in-up">
<view class="collapse-header" @click="toggleCollapse(0)">
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes(0) }">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
@@ -175,7 +175,7 @@
<view v-for="(group, index) in contactGroups" :key="group.id" class="collapse-item animate-fade-in-up" :style="{ animationDelay: `${(index + 1) * 50}ms` }">
<view class="collapse-header" @click="toggleCollapse(group.id)" @longpress="handleGroupLongPress(group)">
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes(group.id) }">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
@@ -219,7 +219,7 @@
<view v-if="createdGroups.length" class="collapse-item animate-fade-in-up">
<view class="collapse-header" @click="toggleCollapse('created')">
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('created') }">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
@@ -242,7 +242,7 @@
<view v-if="managedGroups.length" class="collapse-item animate-fade-in-up" style="animation-delay: 50ms;">
<view class="collapse-header" @click="toggleCollapse('managed')">
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('managed') }">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
@@ -265,7 +265,7 @@
<view v-if="joinedGroups.length" class="collapse-item animate-fade-in-up" style="animation-delay: 100ms;">
<view class="collapse-header" @click="toggleCollapse('joined')">
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('joined') }">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
@@ -344,9 +344,6 @@
<wd-toast />
<wd-message-box />
<app-tab-bar current="contacts" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -362,7 +359,6 @@ import * as roomApi from '@/api/modules/room'
import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue'
import AppDrawer from '@/components/common/AppDrawer.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Contact, ContactGroup } from '@/types/api'
// --- 逻辑完全不变 ---
@@ -492,9 +488,18 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
right: 0;
z-index: 100;
background: var(--bg-page);
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
// 与消息页一致,不要下边框
border-bottom: none;
/* #ifdef MP-WEIXIN */
// 微信小程序:状态栏 + 胶囊按钮区域
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
// H5/App仅状态栏
padding-top: var(--status-bar-height, 0);
/* #endif */
transition: background 0.3s;
}
@@ -641,8 +646,15 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
// 滚动区
.main-scroll {
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);
/* #ifdef MP-WEIXIN */
margin-top: calc(var(--status-bar-height, 44px) + 88rpx + 290rpx);
height: calc(100vh - var(--status-bar-height, 44px) - 88rpx - 290rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
margin-top: calc(var(--status-bar-height, 0) + 290rpx);
height: calc(100vh - var(--status-bar-height, 0) - 290rpx);
/* #endif */
}
.scroll-spacer {

View File

@@ -23,7 +23,7 @@
<!-- 右侧: 加号按钮 -->
<view class="nav-right">
<view class="icon-btn-circle" @click="showPlusMenu = true">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="plus-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
@@ -49,7 +49,7 @@
<!-- 2. 搜索栏 (扁平化设计) -->
<view class="search-wrapper">
<view class="search-bar" @click="goSearch">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
@@ -72,7 +72,7 @@
</view>
<view v-else-if="conversations.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/>
@@ -124,7 +124,7 @@
</view>
<view class="row-bottom">
<text class="msg-preview text-ellipsis">{{ item.last_message || '暂无消息' }}</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg v-if="item.is_muted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
<line x1="23" y1="9" x2="17" y2="15"/>
@@ -141,7 +141,7 @@
<template #right>
<view class="swipe-actions">
<view class="action-btn top" @click.stop="toggleTop(item)">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/>
</svg>
@@ -151,7 +151,7 @@
<!-- #endif -->
</view>
<view class="action-btn delete" @click.stop="deleteConversation(item)">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
@@ -193,9 +193,6 @@
<wd-toast />
<wd-message-box :z-index="11000" />
<app-tab-bar current="messages" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -213,7 +210,6 @@ import AppTabBar from '@/components/common/AppTabBar.vue'
import AppDrawer from '@/components/common/AppDrawer.vue'
import PlusMenu from '@/components/common/PlusMenu.vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Conversation } from '@/types/conversation'
const authStore = useAuthStore()
@@ -406,10 +402,19 @@ async function logout() {
position: sticky;
top: 0;
z-index: 100;
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
// 确保没有任何边框
border: none;
/* #ifdef MP-WEIXIN */
// 微信小程序:状态栏 + 胶囊按钮区域 (约88rpx)
padding-top: calc(var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
// H5/App仅状态栏
padding-top: var(--status-bar-height, 0);
/* #endif */
.navbar-bg {
position: absolute;
inset: 0;

View File

@@ -8,7 +8,7 @@
<view class="nav-right">
<view class="nav-icon-btn" @click="goNotifications">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="icon" :style="{ color: iconColor }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
@@ -53,7 +53,7 @@
</view>
<view v-else-if="moments.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
@@ -113,7 +113,7 @@
<view class="feed-footer">
<text class="time">{{ formatTime(moment.created_at) }}</text>
<view class="action-btn" @click="showActionMenu(moment)">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
</svg>
@@ -130,7 +130,7 @@
<!-- 点赞列表 -->
<view v-if="moment.likes && moment.likes.length > 0" class="likes-list">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="heart-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
@@ -167,7 +167,7 @@
<!-- 悬浮发布按钮 -->
<view class="fab-btn" @click="goPublish">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
@@ -190,9 +190,6 @@
<wd-message-box />
<app-tab-bar current="moments" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -208,7 +205,6 @@ import { parseMediaUrls } from '@/types/moment'
import { useToast, useMessage } from 'wot-design-uni'
import AppTabBar from '@/components/common/AppTabBar.vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Moment } from '@/types/moment'
const authStore = useAuthStore()
@@ -394,7 +390,6 @@ function getImageSize(moment: Moment): string {
top: 0;
left: 0;
right: 0;
height: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
z-index: 100;
display: flex;
align-items: flex-end;
@@ -403,6 +398,16 @@ function getImageSize(moment: Moment): string {
transition: background 0.3s;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
/* #ifdef MP-WEIXIN */
// 微信小程序:导航栏 + 状态栏 + 胶囊区域
height: calc(88rpx + var(--status-bar-height, 44px) + 88rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
// H5/App导航栏 + 状态栏
height: calc(88rpx + var(--status-bar-height, 0));
/* #endif */
}
.nav-content {

View File

@@ -178,9 +178,16 @@ async function save() {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 16rpx) 24rpx 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
/* #endif */
.nav-back {
width: 72rpx;
height: 72rpx;

View File

@@ -7,7 +7,7 @@
<view class="header-top">
<text class="page-title">我的</text>
<view class="icon-btn" @click="uni.navigateTo({ url: '/pages/settings/index' })">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
@@ -27,7 +27,7 @@
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<text class="account">账号: {{ userInfo.account || '--' }}</text>
</view>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -59,7 +59,7 @@
<view class="menu-group animate-fade-in-up" style="animation-delay: 150ms;">
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/moment/my' })">
<view class="icon-box indigo">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
@@ -72,7 +72,7 @@
<!-- #endif -->
</view>
<text class="menu-text">我的动态</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -83,7 +83,7 @@
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/collection/index' })">
<view class="icon-box amber">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
@@ -93,7 +93,7 @@
<!-- #endif -->
</view>
<text class="menu-text">我的收藏</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -104,7 +104,7 @@
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/album/index' })">
<view class="icon-box emerald">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
@@ -116,7 +116,7 @@
<!-- #endif -->
</view>
<text class="menu-text">相册</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -129,9 +129,6 @@
</view>
<app-tab-bar current="profile" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -142,7 +139,6 @@ import { useTheme } from '@/composables/useTheme'
import { useAuthStore } from '@/stores'
import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
const { isDark } = useTheme()
const authStore = useAuthStore()
@@ -197,10 +193,17 @@ function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
// 头部
.profile-header {
background: var(--bg-surface);
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);
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 24rpx) 40rpx 60rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 24rpx) 40rpx 60rpx;
/* #endif */
}
.header-top {

View File

@@ -3,7 +3,7 @@
<!-- 搜索栏 -->
<view class="search-header">
<view class="search-box">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
@@ -21,7 +21,7 @@
@confirm="doSearch"
/>
<view v-if="keyword" class="clear-btn" @click="clearKeyword">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>
</svg>
@@ -39,7 +39,7 @@
<view class="section-header">
<text class="title">搜索历史</text>
<view class="clear" @click="clearHistory">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@@ -88,7 +88,7 @@
<!-- 空结果 -->
<view v-else-if="getCurrentResults.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
@@ -120,7 +120,7 @@
<text class="name" v-html="highlightKeyword(item.remark_name || item.user?.name || '')"></text>
<text class="desc">{{ item.user?.desc || '暂无签名' }}</text>
</view>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -153,7 +153,7 @@
<text class="desc">{{ item.member_count }}</text>
</view>
<view class="group-badge">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
@@ -165,7 +165,7 @@
<wd-icon name="user" size="28rpx" />
<!-- #endif -->
</view>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -436,11 +436,18 @@ function formatTime(time: string): string {
display: flex;
align-items: center;
gap: 20rpx;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
position: sticky;
top: 0;
z-index: 100;
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 16rpx) 24rpx 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
/* #endif */
}
.search-box {

View File

@@ -4,7 +4,7 @@
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
@@ -24,7 +24,7 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/account')">
<view class="icon-box blue">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
@@ -34,7 +34,7 @@
<!-- #endif -->
</view>
<text class="item-text">账号管理</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -45,7 +45,7 @@
</view>
<view class="setting-item" @click="goTo('/pages/settings/privacy')">
<view class="icon-box purple">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
@@ -56,7 +56,7 @@
<!-- #endif -->
</view>
<text class="item-text">隐私设置</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -74,7 +74,7 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/notification')">
<view class="icon-box red">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
@@ -85,7 +85,7 @@
<!-- #endif -->
</view>
<text class="item-text">新消息通知</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -96,7 +96,7 @@
</view>
<view class="setting-item" @click="goTo('/pages/settings/theme')">
<view class="icon-box indigo">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
@@ -115,7 +115,7 @@
</view>
<text class="item-text">主题设置</text>
<text class="item-value">{{ themeText }}</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -126,7 +126,7 @@
</view>
<view class="setting-item" @click="handleClearCache">
<view class="icon-box green">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@@ -138,7 +138,7 @@
</view>
<text class="item-text">清理缓存</text>
<text class="item-value">{{ cacheSize }}</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -156,7 +156,7 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/feedback')">
<view class="icon-box orange">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
@@ -166,7 +166,7 @@
<!-- #endif -->
</view>
<text class="item-text">帮助与反馈</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -177,7 +177,7 @@
</view>
<view class="setting-item" @click="goTo('/pages/settings/about')">
<view class="icon-box gray">
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
@@ -190,7 +190,7 @@
</view>
<text class="item-text">关于我们</text>
<text class="item-value">v1.0.0</text>
<!-- #ifdef H5 -->
<!-- #ifdef H5 || APP-PLUS -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
@@ -305,9 +305,16 @@ function handleClearCache() {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 16rpx) 24rpx 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
/* #endif */
.nav-back {
width: 72rpx;
height: 72rpx;

View File

@@ -169,9 +169,16 @@ function selectTheme(mode: 'system' | 'light' | 'dark') {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
/* #ifdef MP-WEIXIN */
padding: calc(var(--status-bar-height, 44px) + 88rpx + 16rpx) 24rpx 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
/* #endif */
.nav-back {
width: 72rpx;
height: 72rpx;

View File

@@ -2,10 +2,20 @@
* 应用全局状态管理
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
export type TabType = 'chat' | 'contact' | 'moment'
// 微信小程序胶囊按钮信息类型
export interface MenuButtonInfo {
width: number
height: number
top: number
right: number
bottom: number
left: number
}
export const useAppStore = defineStore('app', () => {
// 当前激活的 Tab
const currentTab = ref<TabType>('chat')
@@ -16,9 +26,30 @@ export const useAppStore = defineStore('app', () => {
// 系统信息
const systemInfo = ref<UniApp.GetSystemInfoResult | null>(null)
// 微信小程序胶囊按钮信息
const menuButtonInfo = ref<MenuButtonInfo | null>(null)
// 左侧抽屉是否打开
const drawerVisible = ref(false)
// 状态栏高度
const statusBarHeight = computed(() => systemInfo.value?.statusBarHeight || 0)
// 导航栏高度(不含状态栏)
const navBarHeight = computed(() => {
// #ifdef MP-WEIXIN
if (menuButtonInfo.value) {
// 导航栏高度 = (胶囊按钮距顶部距离 - 状态栏高度) * 2 + 胶囊按钮高度
const paddingTop = menuButtonInfo.value.top - statusBarHeight.value
return paddingTop * 2 + menuButtonInfo.value.height
}
// #endif
return 44 // 默认导航栏高度
})
// 导航栏总高度(含状态栏)
const navBarTotalHeight = computed(() => statusBarHeight.value + navBarHeight.value)
/**
* 初始化应用
*/
@@ -26,6 +57,25 @@ export const useAppStore = defineStore('app', () => {
// 获取系统信息
systemInfo.value = uni.getSystemInfoSync()
// #ifdef MP-WEIXIN
// 获取微信小程序胶囊按钮位置信息
try {
const rect = uni.getMenuButtonBoundingClientRect()
menuButtonInfo.value = {
width: rect.width,
height: rect.height,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left
}
console.log('📱 [App] 胶囊按钮信息:', menuButtonInfo.value)
console.log('📱 [App] 状态栏高度:', statusBarHeight.value, '导航栏高度:', navBarHeight.value)
} catch (e) {
console.warn('⚠️ [App] 获取胶囊按钮信息失败:', e)
}
// #endif
// 初始化主题
initTheme()
@@ -34,6 +84,22 @@ export const useAppStore = defineStore('app', () => {
if (cachedTab && ['chat', 'contact', 'moment'].includes(cachedTab)) {
currentTab.value = cachedTab as TabType
}
// 注入 CSS 变量到页面
injectCSSVariables()
}
/**
* 注入 CSS 变量(用于微信小程序安全区域)
*/
function injectCSSVariables() {
// #ifdef MP-WEIXIN
const pages = getCurrentPages()
if (pages.length > 0) {
// 通过 page 元素设置 CSS 变量无法直接操作,但可以通过 JS 计算后在组件中使用
console.log('📱 [App] CSS 变量 - statusBarHeight:', statusBarHeight.value, 'navBarHeight:', navBarHeight.value)
}
// #endif
}
/**
@@ -127,6 +193,10 @@ export const useAppStore = defineStore('app', () => {
currentTab,
isDark,
systemInfo,
menuButtonInfo,
statusBarHeight,
navBarHeight,
navBarTotalHeight,
drawerVisible,
init,
initTheme,

View File

@@ -185,17 +185,30 @@
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
// 微信小程序额外安全距离(胶囊按钮区域)
// 默认值胶囊按钮高度约32px上下间距约8px总计约48px
--mp-safe-top: 0px;
// 微信小程序导航栏内容高度(不含状态栏)
--mp-navbar-height: 44px;
}
/* #ifdef MP-WEIXIN */
page {
--mp-safe-top: env(safe-area-inset-top, 20px);
// 微信小程序:状态栏 + 导航栏区域
// 胶囊按钮通常距状态栏底部约 4-8px高度约 32px
// 导航栏高度 = (胶囊top - 状态栏高度) * 2 + 胶囊高度 ≈ 8 * 2 + 32 = 48px
--mp-safe-top: 8px; // 胶囊按钮上方间距
--mp-navbar-height: 48px; // 导航栏内容区高度
}
/* #endif */
.safe-area-top {
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
/* #ifdef MP-WEIXIN */
padding-top: calc(var(--status-bar-height, 44px) + 95rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
padding-top: var(--status-bar-height, 0);
/* #endif */
}
.safe-area-bottom {
@@ -207,6 +220,11 @@ page {
height: calc(160rpx + env(safe-area-inset-bottom));
}
// 微信小程序导航栏占位(含状态栏 + 导航栏)
.mp-navbar-spacer {
height: calc(var(--status-bar-height, 0) + var(--mp-navbar-height, 44px));
}
// 响应式断点
@mixin respond-to($breakpoint) {
@if $breakpoint == 'sm' {
@@ -449,14 +467,28 @@ page {
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
border-bottom: 1rpx solid var(--border-subtle);
/* #ifdef MP-WEIXIN */
// 微信小程序:状态栏 + 胶囊按钮区域
padding-top: calc(var(--status-bar-height, 44px) + 20rpx);
/* #endif */
/* #ifndef MP-WEIXIN */
// H5/App仅状态栏高度
padding-top: var(--status-bar-height, 0);
/* #endif */
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
height: var(--mp-navbar-height, 88rpx);
padding: 0 32rpx;
/* #ifdef MP-WEIXIN */
// 微信小程序:避开右侧胶囊按钮,预留约 100px 空间
padding-right: 200rpx;
/* #endif */
}
.navbar-title {

View File

@@ -3,7 +3,7 @@
*/
// 图片资源基础域名
const IMAGE_BASE_URL = 'http://127.0.0.1:12080'
const IMAGE_BASE_URL = 'https://g-ws.nailaoyun.cn'
/**
* 解析图片 URL为相对路径添加域名前缀