适配微信小程序

This commit is contained in:
2025-12-14 20:55:39 +08:00
parent 8c2ab88790
commit 46f16017ea
39 changed files with 3494 additions and 151 deletions

27
components.d.ts vendored
View File

@@ -7,6 +7,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ActionMenu: typeof import('./src/components/moment/ActionMenu.vue')['default']
AppAvatar: typeof import('./src/components/common/AppAvatar.vue')['default']
AppDrawer: typeof import('./src/components/common/AppDrawer.vue')['default']
AppEmpty: typeof import('./src/components/common/AppEmpty.vue')['default']
@@ -16,6 +17,7 @@ declare module 'vue' {
AppNavBar: typeof import('./src/components/common/AppNavBar.vue')['default']
AppTabBar: typeof import('./src/components/common/AppTabBar.vue')['default']
CallWindow: typeof import('./src/components/call/CallWindow.vue')['default']
CommentInput: typeof import('./src/components/moment/CommentInput.vue')['default']
EmojiPicker: typeof import('./src/components/chat/EmojiPicker.vue')['default']
GlobalCallProvider: typeof import('./src/components/call/GlobalCallProvider.vue')['default']
GroupCallBanner: typeof import('./src/components/call/GroupCallBanner.vue')['default']
@@ -27,15 +29,40 @@ declare module 'vue' {
MentionPicker: typeof import('./src/components/chat/MentionPicker.vue')['default']
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
MessageInput: typeof import('./src/components/chat/MessageInput.vue')['default']
MiniProgramCallWindow: typeof import('./src/components/call/MiniProgramCallWindow.vue')['default']
MomentCard: typeof import('./src/components/moment/MomentCard.vue')['default']
MomentCommentInput: typeof import('./src/components/moment/MomentCommentInput.vue')['default']
MomentCommentList: typeof import('./src/components/moment/MomentCommentList.vue')['default']
MomentNotificationPanel: typeof import('./src/components/moment/MomentNotificationPanel.vue')['default']
PlusMenu: typeof import('./src/components/common/PlusMenu.vue')['default']
SelectContactsModal: typeof import('./src/components/chat/SelectContactsModal.vue')['default']
UnifiedCallWindow: typeof import('./src/components/call/UnifiedCallWindow.vue')['default']
UserInfoCard: typeof import('./src/components/common/UserInfoCard.vue')['default']
UserMomentsModal: typeof import('./src/components/moment/UserMomentsModal.vue')['default']
VideoPlayer: typeof import('./src/components/common/VideoPlayer.vue')['default']
VisibilitySelector: typeof import('./src/components/moment/VisibilitySelector.vue')['default']
WdActionSheet: typeof import('wot-design-uni/components/wd-action-sheet/wd-action-sheet.vue')['default']
WdBadge: typeof import('wot-design-uni/components/wd-badge/wd-badge.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
WdCell: typeof import('wot-design-uni/components/wd-cell/wd-cell.vue')['default']
WdCellGroup: typeof import('wot-design-uni/components/wd-cell-group/wd-cell-group.vue')['default']
WdCheckbox: typeof import('wot-design-uni/components/wd-checkbox/wd-checkbox.vue')['default']
WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
WdDivider: typeof import('wot-design-uni/components/wd-divider/wd-divider.vue')['default']
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdImg: typeof import('wot-design-uni/components/wd-img/wd-img.vue')['default']
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
WdLoadmore: typeof import('wot-design-uni/components/wd-loadmore/wd-loadmore.vue')['default']
WdMessageBox: typeof import('wot-design-uni/components/wd-message-box/wd-message-box.vue')['default']
WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
WdSearch: typeof import('wot-design-uni/components/wd-search/wd-search.vue')['default']
WdStatusTip: typeof import('wot-design-uni/components/wd-status-tip/wd-status-tip.vue')['default']
WdSwipeAction: typeof import('wot-design-uni/components/wd-swipe-action/wd-swipe-action.vue')['default']
WdSwitch: typeof import('wot-design-uni/components/wd-switch/wd-switch.vue')['default']
WdTag: typeof import('wot-design-uni/components/wd-tag/wd-tag.vue')['default']
WdTextarea: typeof import('wot-design-uni/components/wd-textarea/wd-textarea.vue')['default']
WdToast: typeof import('wot-design-uni/components/wd-toast/wd-toast.vue')['default']
}
}

View File

@@ -10,6 +10,8 @@ export * as roomApi from './modules/room'
export * as momentApi from './modules/moment'
export * as attachmentApi from './modules/attachment'
export * as systemApi from './modules/system'
export * as searchApi from './modules/search'
export * as callApi from './modules/call'
export { wsManager } from './websocket'
export type { WebSocketMessage, MessageHandler, SignalHandler, MomentNotifHandler } from './websocket'

115
src/api/modules/call.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* 通话相关 API
* 用于与媒体服务器交互
*/
import request from '../request'
// ICE 服务器配置
export interface ICEServerConfig {
urls: string[]
username?: string
credential?: string
}
// 参与者信息
export interface ParticipantInfo {
user_id: string
platform: string
has_audio: boolean
has_video: boolean
}
// 拉流地址信息
export interface PullURLInfo {
user_id: string
url: string
flv_url?: string
}
// 加入房间响应
export interface JoinCallRoomResponse {
room_id: string
platform: string
ice_servers?: ICEServerConfig[]
push_url?: string
pull_urls?: PullURLInfo[]
participants: ParticipantInfo[]
}
// 房间信息响应
export interface RoomInfoResponse {
room_id: string
call_type: string
is_group_call: boolean
participant_count: number
participants: ParticipantInfo[]
}
/**
* 创建通话房间
*/
export function createCallRoom(data: {
room_id: string
call_type: 'audio' | 'video'
is_group_call?: boolean
}) {
return request.post('/call/room', data)
}
/**
* 获取通话房间信息
*/
export function getCallRoom(roomId: string): Promise<RoomInfoResponse> {
return request.get(`/call/room/${roomId}`)
}
/**
* 加入通话房间
*/
export function joinCallRoom(data: {
room_id: string
user_id: string
platform: 'h5' | 'app' | 'miniprogram'
}): Promise<JoinCallRoomResponse> {
return request.post('/call/join', data)
}
/**
* 离开通话房间
*/
export function leaveCallRoom(data: {
room_id: string
user_id: string
}) {
return request.post('/call/leave', data)
}
/**
* 发送 WebRTC Offer
*/
export function sendOffer(data: {
room_id: string
user_id: string
sdp: string
}): Promise<{ sdp: string }> {
return request.post('/call/offer', data)
}
/**
* 发送 ICE Candidate
*/
export function sendICECandidate(data: {
room_id: string
user_id: string
candidate: string
}) {
return request.post('/call/ice', data)
}
/**
* 获取 ICE 服务器配置
*/
export function getICEServers(userId?: string): Promise<{ ice_servers: ICEServerConfig[] }> {
return request.get('/call/ice-servers', { params: { user_id: userId } })
}

88
src/api/modules/search.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* 搜索相关 API
*/
import request from '../request'
import type { User } from '@/types/api'
// 联系人搜索结果
export interface ContactSearchResult {
user: User
remark_name: string
room_id: string
}
// 群聊搜索结果
export interface GroupSearchResult {
room_id: string
room_name: string
room_avatar: string
owner_id: string
member_count: number
}
// 消息搜索结果
export interface MessageSearchResult {
id: number
room_id: string
room_name: string
content: string
message_type: number
created_at: string
sender: User
is_group_chat: boolean
match_content: string
}
// 聚合搜索结果
export interface GlobalSearchResult {
contacts: ContactSearchResult[]
groups: GroupSearchResult[]
messages: MessageSearchResult[]
}
// 搜索类型
export type SearchType = 'all' | 'contacts' | 'groups' | 'messages'
/**
* 聚合搜索
* @param keyword 搜索关键词
* @param type 搜索类型:all(默认)、contacts、groups、messages
* @param limit 每类结果的最大数量,默认20
*/
export function globalSearch(keyword: string, type: SearchType = 'all', limit: number = 20) {
return request.get<GlobalSearchResult>('/search', { keyword, type, limit })
}
/**
* 搜索联系人
* @param keyword 搜索关键词
* @param limit 最大数量,默认20
*/
export function searchContacts(keyword: string, limit: number = 20) {
return request.get<ContactSearchResult[]>('/search', { keyword, type: 'contacts', limit })
}
/**
* 搜索群聊
* @param keyword 搜索关键词
* @param limit 最大数量,默认20
*/
export function searchGroups(keyword: string, limit: number = 20) {
return request.get<GroupSearchResult[]>('/search', { keyword, type: 'groups', limit })
}
/**
* 搜索聊天记录
* @param keyword 搜索关键词
* @param roomId 可选的房间ID,限定搜索范围
* @param limit 最大数量,默认20
*/
export function searchMessages(keyword: string, roomId?: string, limit: number = 20) {
return request.get<MessageSearchResult[]>('/search', {
keyword,
type: 'messages',
room_id: roomId,
limit
})
}

View File

@@ -9,6 +9,11 @@ export function getMyInfo() {
return request.get<User>('/user/my-info')
}
// 获取指定用户信息
export function getUserInfo(userId: string) {
return request.get<User>(`/user/${userId}`)
}
// 获取用户列表
export function getUserList(page = 1, pageSize = 20) {
return request.get<PaginatedResponse<User>>('/user/list', {

View File

@@ -5,7 +5,8 @@ import type { ApiResponse, RequestConfig } from './types'
import { storage } from '@/utils/storage'
// API 基础路径
const BASE_URL = 'http://127.0.0.1:12080/api'
// const BASE_URL = 'http://127.0.0.1:12080/api'
const BASE_URL = 'https://g-ws.nailaoyun.cn/api'
// 默认超时时间
const TIMEOUT = 30000

View File

@@ -39,7 +39,8 @@ class WebSocketManager {
this.userId = userId
// TODO: 替换为实际的 WebSocket 地址
const wsUrl = `ws://localhost:12080/ws?user_id=${userId}`
// const wsUrl = `ws://g-ws.nailaoyun.cn/ws?user_id=${userId}`
const wsUrl = `wss://g-ws.nailaoyun.cn/ws?user_id=${userId}`
this.socketTask = uni.connectSocket({
url: wsUrl,

View File

@@ -157,8 +157,28 @@
<!-- #ifndef H5 -->
<view v-if="call.active" class="call-unsupported">
<text>当前平台暂不支持音视频通话</text>
<wd-button type="primary" @click="$emit('end')">关闭</wd-button>
<view class="unsupported-content">
<view class="icon-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<text class="unsupported-title">暂不支持音视频通话</text>
<text class="unsupported-desc">当前平台暂不支持音视频通话功能</text>
<text class="unsupported-desc">请使用网页版体验完整功能</text>
<view class="unsupported-actions">
<wd-button type="info" plain @click="copyWebUrl" custom-style="margin-right: 20rpx;">
复制网页链接
</wd-button>
<wd-button type="primary" @click="$emit('end')">
关闭
</wd-button>
</view>
</view>
<wd-toast />
</view>
<!-- #endif -->
</template>
@@ -167,6 +187,7 @@
import { ref, watch, nextTick } from 'vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import { resolveImageUrl } from '@/utils/image'
import { useToast } from 'wot-design-uni'
import type { CallState } from '@/composables/useWebRTC'
interface Props {
@@ -189,6 +210,22 @@ defineEmits<{
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
const toast = useToast()
// 复制网页链接(用于非H5平台引导用户)
function copyWebUrl() {
// TODO: 替换为实际的网页版地址
const webUrl = 'https://your-web-domain.com/chat'
uni.setClipboardData({
data: webUrl,
success: () => {
toast.success('链接已复制,请在浏览器中打开')
},
fail: () => {
toast.error('复制失败')
}
})
}
watch(() => props.localStream, (stream) => {
// #ifdef H5
@@ -542,19 +579,60 @@ watch(() => props.remoteStream, (stream) => {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--bg-page);
background: linear-gradient(180deg, #1c1917 0%, #0c0a09 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
}
.unsupported-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40rpx;
text-align: center;
max-width: 600rpx;
}
text {
font-size: 32rpx;
color: var(--text-secondary);
.icon-box {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: rgba(249, 115, 22, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
svg {
width: 80rpx;
height: 80rpx;
color: #f97316;
}
}
.unsupported-title {
font-size: 40rpx;
font-weight: 700;
color: #f5f5f4;
margin-bottom: 24rpx;
}
.unsupported-desc {
font-size: 28rpx;
color: #a8a29e;
line-height: 1.6;
margin-bottom: 8rpx;
}
.unsupported-actions {
display: flex;
align-items: center;
justify-content: center;
margin-top: 48rpx;
gap: 24rpx;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }

View File

@@ -1,20 +1,27 @@
<template>
<!-- ========== 单聊通话组件 ========== -->
<!-- ========== H5/App 单聊通话组件 ========== -->
<!-- #ifdef H5 || APP-PLUS -->
<call-window
v-if="callActive"
:call="callState"
:local-stream="localStreamValue"
:remote-stream="remoteStreamValue"
:format-duration="webrtc.formatDuration"
@accept="webrtc.acceptCall()"
@reject="webrtc.rejectCall()"
@end="webrtc.endCall()"
@toggle-mute="webrtc.toggleMute()"
@toggle-camera="webrtc.toggleCamera()"
@toggle-minimize="webrtc.toggleMinimize()"
v-if="h5CallActive"
:call="h5CallState"
:local-stream="h5LocalStream"
:remote-stream="h5RemoteStream"
:format-duration="h5FormatDuration"
@accept="h5AcceptCall"
@reject="h5RejectCall"
@end="h5EndCall"
@toggle-mute="h5ToggleMute"
@toggle-camera="h5ToggleCamera"
@toggle-minimize="h5ToggleMinimize"
/>
<!-- #endif -->
<!-- ========== 群通话组件 ========== -->
<!-- ========== 微信小程序单聊通话组件 ========== -->
<!-- #ifdef MP-WEIXIN -->
<mini-program-call-window />
<!-- #endif -->
<!-- ========== 群通话组件(所有平台共用) ========== -->
<!-- 全局群通话来电覆盖层 -->
<group-call-incoming />
@@ -27,29 +34,55 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useWebRTC } from '@/composables/useWebRTC'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import CallWindow from './CallWindow.vue'
import GroupCallIncoming from './GroupCallIncoming.vue'
import GroupCallWindow from './GroupCallWindow.vue'
import GroupCallBanner from './GroupCallBanner.vue'
// 单聊通话
const webrtc = useWebRTC()
// #ifdef H5 || APP-PLUS
import { useWebRTC } from '@/composables/useWebRTC'
import CallWindow from './CallWindow.vue'
// 使用计算属性确保响应式
const callActive = computed(() => webrtc.call.active)
const callState = computed(() => webrtc.call)
const localStreamValue = computed(() => webrtc.localStream.value)
const remoteStreamValue = computed(() => webrtc.remoteStream.value)
const webrtc = useWebRTC()
const h5CallActive = computed(() => webrtc.call.active)
const h5CallState = computed(() => webrtc.call)
const h5LocalStream = computed(() => webrtc.localStream.value)
const h5RemoteStream = computed(() => webrtc.remoteStream.value)
const h5FormatDuration = (seconds: number) => webrtc.formatDuration(seconds)
const h5AcceptCall = () => webrtc.acceptCall()
const h5RejectCall = () => webrtc.rejectCall()
const h5EndCall = () => webrtc.endCall()
const h5ToggleMute = () => webrtc.toggleMute()
const h5ToggleCamera = () => webrtc.toggleCamera()
const h5ToggleMinimize = () => webrtc.toggleMinimize()
// #endif
// #ifdef MP-WEIXIN
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
import MiniProgramCallWindow from './MiniProgramCallWindow.vue'
const mpCall = useMiniProgramCall()
// #endif
// 群通话
const { initListener: initGroupListener } = useGroupWebRTC()
onMounted(() => {
// 初始化单聊通话信令监听器
// #ifdef H5 || APP-PLUS
// 初始化单聊通话信令监听器 (H5/App)
webrtc.initListener()
// 初始化群通话信令监听器
console.log('✅ [GlobalCallProvider] H5/App WebRTC 信令监听器已初始化')
// #endif
// #ifdef MP-WEIXIN
// 初始化小程序通话信令监听器
mpCall.initListener()
mpCall.initPusherContext()
console.log('✅ [GlobalCallProvider] 微信小程序通话监听器已初始化')
// #endif
// 初始化群通话信令监听器(所有平台)
initGroupListener()
console.log('✅ [GlobalCallProvider] 群通话信令监听器已初始化')
})
</script>

View File

@@ -0,0 +1,448 @@
<!--
小程序音视频通话窗口组件
使用 live-pusher/live-player 实现
适用平台:微信小程序
-->
<template>
<!-- #ifdef MP-WEIXIN -->
<view v-if="call.active" class="call-window" :class="{ minimized: call.minimized }">
<!-- 最小化状态 -->
<view v-if="call.minimized" class="minimized-view" @click="toggleMinimize">
<view class="mini-avatar">
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
</view>
<view class="mini-info">
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
<text class="mini-status">{{ call.statusText }}</text>
</view>
</view>
<!-- 全屏状态 -->
<view v-else class="fullscreen-view">
<!-- 视频区域 -->
<view class="video-container">
<!-- 远端视频 -->
<view class="remote-videos">
<live-player
v-for="stream in remoteStreams"
:key="stream.userId"
class="remote-video"
:src="stream.pullUrl"
mode="RTC"
autoplay
muted="{{false}}"
:object-fit="'contain'"
@statechange="(e: any) => onPlayerStateChange(e, stream.userId)"
>
<view class="video-overlay">
<text class="user-name">{{ stream.userName || '对方' }}</text>
</view>
</live-player>
<!-- 无远端视频时的占位 -->
<view v-if="remoteStreams.length === 0" class="remote-placeholder">
<view class="caller-avatar-large">
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
</view>
<text class="caller-name">{{ call.callerName || '未知' }}</text>
<text class="call-status">{{ call.statusText }}</text>
</view>
</view>
<!-- 本地视频(小窗) -->
<view v-if="call.type === 'video' && !call.camOff" class="local-video-container">
<live-pusher
class="local-video"
:url="pushUrl"
mode="RTC"
autopush
:muted="call.muted"
:enable-camera="!call.camOff"
aspect="9:16"
beauty="5"
whiteness="3"
@statechange="onPusherStateChange"
/>
</view>
</view>
<!-- 通话信息 -->
<view class="call-info">
<text class="duration">{{ formatDuration(call.duration) }}</text>
<text class="status-text">{{ call.statusText }}</text>
</view>
<!-- 控制按钮 -->
<view class="controls">
<!-- 来电状态 -->
<view v-if="call.status === 'incoming'" class="incoming-controls">
<view class="control-btn reject" @click="rejectCall">
<wd-icon name="close" size="60rpx" color="#fff" />
<text>拒绝</text>
</view>
<view class="control-btn accept" @click="acceptCall">
<wd-icon name="phone" size="60rpx" color="#fff" />
<text>接听</text>
</view>
</view>
<!-- 呼叫/通话中状态 -->
<view v-else class="call-controls">
<!-- 静音 -->
<view class="control-btn" :class="{ active: call.muted }" @click="toggleMute">
<wd-icon :name="call.muted ? 'mute' : 'sound'" size="48rpx" />
<text>{{ call.muted ? '取消静音' : '静音' }}</text>
</view>
<!-- 摄像头 -->
<view v-if="call.type === 'video'" class="control-btn" :class="{ active: call.camOff }" @click="toggleCamera">
<wd-icon :name="call.camOff ? 'camera-off' : 'camera'" size="48rpx" />
<text>{{ call.camOff ? '开启摄像头' : '关闭摄像头' }}</text>
</view>
<!-- 切换摄像头 -->
<view v-if="call.type === 'video' && !call.camOff" class="control-btn" @click="switchCamera">
<wd-icon name="refresh" size="48rpx" />
<text>翻转</text>
</view>
<!-- 挂断 -->
<view class="control-btn hangup" @click="endCall">
<wd-icon name="phone" size="48rpx" color="#fff" />
<text>挂断</text>
</view>
</view>
</view>
<!-- 最小化按钮 -->
<view class="minimize-btn" @click="toggleMinimize">
<wd-icon name="arrow-down" size="40rpx" />
</view>
</view>
</view>
<!-- #endif -->
<!-- 非小程序平台提示 -->
<!-- #ifndef MP-WEIXIN -->
<view v-if="showUnsupportedTip" class="unsupported-tip">
<text>当前平台请使用 WebRTC 通话</text>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
const {
call,
pushUrl,
remoteStreams,
acceptCall,
rejectCall,
endCall,
toggleMute,
toggleCamera,
switchCamera,
toggleMinimize,
formatDuration,
onPusherStateChange,
onPlayerStateChange,
} = useMiniProgramCall()
const showUnsupportedTip = ref(false)
// 注意:信令监听器在 GlobalCallProvider 中已初始化
// 这里不需要再次调用 initListener 和 initPusherContext
onMounted(() => {
// #ifndef MP-WEIXIN
showUnsupportedTip.value = true
// #endif
})
onUnmounted(() => {
// 清理资源
})
</script>
<style lang="scss" scoped>
.call-window {
position: fixed;
z-index: 9999;
&.minimized {
top: 100rpx;
right: 20rpx;
width: 240rpx;
height: 80rpx;
border-radius: 40rpx;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(20rpx);
}
&:not(.minimized) {
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
}
}
.minimized-view {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
height: 100%;
.mini-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
background: var(--wd-color-primary);
display: flex;
align-items: center;
justify-content: center;
image {
width: 100%;
height: 100%;
}
.avatar-text {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
}
.mini-info {
margin-left: 16rpx;
flex: 1;
.mini-duration {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
.mini-status {
color: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
display: block;
}
}
}
.fullscreen-view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.video-container {
flex: 1;
position: relative;
background: #1a1a1a;
}
.remote-videos {
width: 100%;
height: 100%;
.remote-video {
width: 100%;
height: 100%;
}
.video-overlay {
position: absolute;
bottom: 20rpx;
left: 20rpx;
.user-name {
color: #fff;
font-size: 28rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
}
}
.remote-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.caller-avatar-large {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
overflow: hidden;
background: var(--wd-color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
image {
width: 100%;
height: 100%;
}
.avatar-text {
color: #fff;
font-size: 80rpx;
font-weight: bold;
}
}
.caller-name {
color: #fff;
font-size: 40rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.call-status {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
}
}
.local-video-container {
position: absolute;
top: 100rpx;
right: 20rpx;
width: 200rpx;
height: 280rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.3);
.local-video {
width: 100%;
height: 100%;
}
}
.call-info {
position: absolute;
top: 40rpx;
left: 0;
right: 0;
text-align: center;
padding-top: env(safe-area-inset-top);
.duration {
color: #fff;
font-size: 48rpx;
font-weight: bold;
display: block;
}
.status-text {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
margin-top: 10rpx;
display: block;
}
}
.controls {
padding: 40rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
}
.incoming-controls {
display: flex;
justify-content: space-around;
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx;
border-radius: 50%;
&.reject {
background: #ff4d4f;
}
&.accept {
background: #52c41a;
}
text {
color: #fff;
font-size: 24rpx;
margin-top: 10rpx;
}
}
}
.call-controls {
display: flex;
justify-content: space-around;
align-items: center;
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
border-radius: 20rpx;
color: #fff;
&.active {
background: rgba(255, 255, 255, 0.2);
}
&.hangup {
background: #ff4d4f;
padding: 24rpx 40rpx;
border-radius: 50rpx;
}
text {
font-size: 22rpx;
margin-top: 8rpx;
opacity: 0.9;
}
}
}
.minimize-btn {
position: absolute;
top: 40rpx;
left: 40rpx;
padding: 16rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: #fff;
padding-top: calc(16rpx + env(safe-area-inset-top));
}
.unsupported-tip {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 40rpx;
background: rgba(0, 0, 0, 0.8);
border-radius: 20rpx;
text {
color: #fff;
font-size: 28rpx;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<!--
统一音视频通话窗口组件
自动根据平台选择合适的实现:
- H5/App: 使用 WebRTC (CallWindow.vue)
- 微信小程序: 使用 live-pusher/player (MiniProgramCallWindow.vue)
-->
<template>
<!-- #ifdef H5 || APP-PLUS -->
<CallWindow />
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<MiniProgramCallWindow />
<!-- #endif -->
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
// #ifdef H5 || APP-PLUS
import CallWindow from './CallWindow.vue'
import { useWebRTC } from '@/composables/useWebRTC'
const { initListener: initWebRTCListener } = useWebRTC()
// #endif
// #ifdef MP-WEIXIN
import MiniProgramCallWindow from './MiniProgramCallWindow.vue'
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
const { initListener: initMPListener, initPusherContext } = useMiniProgramCall()
// #endif
onMounted(() => {
// #ifdef H5 || APP-PLUS
initWebRTCListener()
// #endif
// #ifdef MP-WEIXIN
initMPListener()
initPusherContext()
// #endif
})
</script>

View File

@@ -29,13 +29,17 @@
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
<!-- Lucide Icon: Mic -->
<!-- #ifdef H5 -->
<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"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="mike" size="56rpx" />
<!-- #endif -->
</view>
<!-- 中间: 输入框 -->
@@ -60,31 +64,44 @@
<view class="right-actions">
<!-- 表情按钮 -->
<view class="icon-btn" @click="toggleEmoji">
<!-- Lucide Icon: Smile -->
<!-- #ifdef H5 -->
<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"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="smile" size="56rpx" />
<!-- #endif -->
</view>
<!-- 发送按钮 (有文字时显示) -->
<view v-if="inputValue.trim()" class="send-btn" @click="onSend">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="send" size="36rpx" color="#fff" />
<!-- #endif -->
</view>
<!-- 更多按钮 (无文字时显示) -->
<view v-else class="icon-btn" @click="showMore = true">
<!-- Lucide Icon: Plus Circle -->
<!-- #ifdef H5 -->
<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"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add-circle" size="56rpx" />
<!-- #endif -->
</view>
</view>
</view>
@@ -95,19 +112,34 @@
<view class="tools-grid">
<view class="tool-item" @click="emitAction('send-image')">
<view class="tool-icon album">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="picture" size="56rpx" color="#fff" />
<!-- #endif -->
</view>
<text>相册</text>
</view>
<view class="tool-item" @click="emitAction('send-camera')">
<view class="tool-icon camera">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="camera" size="56rpx" color="#fff" />
<!-- #endif -->
</view>
<text>拍摄</text>
</view>
<view class="tool-item" @click="emitAction('send-file')">
<view class="tool-icon file">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="folder" size="56rpx" color="#fff" />
<!-- #endif -->
</view>
<text>文件</text>
</view>
@@ -118,8 +150,14 @@
<view v-if="isRecording" class="recording-overlay">
<view class="recording-content" :class="{ 'cancel-mode': isCancelRecording }">
<view class="recording-icon">
<!-- #ifdef H5 -->
<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 -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon v-if="!isCancelRecording" name="mike" size="64rpx" color="#fff" />
<wd-icon v-else name="close" size="64rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="wave-bars">
<view class="wave-bar" v-for="i in 5" :key="i" :style="{ animationDelay: `${i * 0.1}s` }"></view>
@@ -403,12 +441,62 @@ function formatRecordDuration(s: number) { return `${Math.floor(s/60).toString()
display: flex; flex-direction: column; align-items: center;
&.cancel-mode { .recording-icon { background: #ef4444; } }
}
.recording-icon { width: 128rpx; height: 128rpx; background: #10b981; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; margin-bottom: 32rpx; svg { width: 64rpx; height: 64rpx; } }
.wave-bars { display: flex; gap: 8rpx; height: 48rpx; margin-bottom: 24rpx; .wave-bar { width: 8rpx; height: 16rpx; background: #10b981; border-radius: 8rpx; animation: wave 1s infinite; } }
.recording-icon {
width: 128rpx;
height: 128rpx;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
margin-bottom: 32rpx;
box-shadow: 0 0 60rpx rgba(16, 185, 129, 0.4);
animation: pulse-glow 2s ease-in-out infinite;
svg { width: 64rpx; height: 64rpx; }
}
.wave-bars {
display: flex;
align-items: center;
gap: 12rpx;
height: 64rpx;
margin-bottom: 24rpx;
.wave-bar {
width: 8rpx;
height: 8rpx;
min-height: 8rpx;
background: #10b981;
border-radius: 99px;
animation: wave 0.8s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.1s; }
&:nth-child(3) { animation-delay: 0.2s; }
&:nth-child(4) { animation-delay: 0.3s; }
&:nth-child(5) { animation-delay: 0.4s; }
}
.cancel-mode & .wave-bar {
background: #ef4444;
}
}
.recording-duration { font-size: 36rpx; font-weight: 700; color: var(--text-input); margin-bottom: 16rpx; }
.recording-tip { font-size: 24rpx; color: var(--icon-normal); }
}
@keyframes wave { 0%, 100% { height: 16rpx; } 50% { height: 48rpx; } }
@keyframes wave {
0%, 100% { height: 8rpx; }
50% { height: 48rpx; }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 40rpx rgba(16, 185, 129, 0.3); }
50% { box-shadow: 0 0 80rpx rgba(16, 185, 129, 0.5); }
}
/* 更多面板 Grid (复用) */
.tools-grid {

View File

@@ -11,12 +11,17 @@
<view class="tabbar-icon">
<!-- 消息图标 (message-circle) -->
<template v-if="tab.name === 'messages'">
<!-- #ifdef H5 -->
<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>
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="chat" size="48rpx" :color="current === tab.name ? (isDark ? '#f97316' : '#4F46E5') : (isDark ? '#78716c' : '#9ca3af')" />
<!-- #endif -->
<!-- 未读角标 -->
<view v-if="unreadCount > 0" class="badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
@@ -25,6 +30,7 @@
<!-- 联系人图标 (users) -->
<template v-else-if="tab.name === 'contacts'">
<!-- #ifdef H5 -->
<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"/>
@@ -37,10 +43,15 @@
<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 -->
<wd-icon name="user" size="48rpx" :color="current === tab.name ? (isDark ? '#f97316' : '#4F46E5') : (isDark ? '#78716c' : '#9ca3af')" />
<!-- #endif -->
</template>
<!-- 发现图标 (compass) -->
<template v-else-if="tab.name === 'moments'">
<!-- #ifdef H5 -->
<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"/>
@@ -49,10 +60,15 @@
<circle cx="12" cy="12" r="10"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="photo" size="48rpx" :color="current === tab.name ? (isDark ? '#f97316' : '#4F46E5') : (isDark ? '#78716c' : '#9ca3af')" />
<!-- #endif -->
</template>
<!-- 我的图标 (user) -->
<template v-else-if="tab.name === 'profile'">
<!-- #ifdef H5 -->
<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"/>
@@ -61,6 +77,10 @@
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="person" size="48rpx" :color="current === tab.name ? (isDark ? '#f97316' : '#4F46E5') : (isDark ? '#78716c' : '#9ca3af')" />
<!-- #endif -->
</template>
</view>
<text class="tabbar-text">{{ tab.label }}</text>

View File

@@ -137,19 +137,68 @@
</view>
</scroll-view>
<!-- 底部操作栏 -->
<!-- 底部操作栏 - 四宫格布局 -->
<view class="action-bar">
<view class="action-btn primary" @click="$emit('send-message')">
<wd-icon name="chat" size="40rpx" color="#fff" />
<text>发消息</text>
</view>
<view class="action-btn" @click="$emit('audio-call')">
<wd-icon name="phone" size="40rpx" />
<text>语音</text>
</view>
<view class="action-btn" @click="$emit('video-call')">
<wd-icon name="video" size="40rpx" />
<text>视频</text>
<view class="action-grid">
<view class="action-item" @click="handleSendMessage">
<view class="action-icon blue">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="chat" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<text>发消息</text>
</view>
<view class="action-item" @click="handleAudioCall">
<view class="action-icon green">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="phone" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<text>语音</text>
</view>
<view class="action-item" @click="handleVideoCall">
<view class="action-icon purple">
<!-- #ifdef H5 -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="video" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<text>视频</text>
</view>
<view class="action-item" @click="handleViewMoments">
<view class="action-icon orange">
<!-- #ifdef H5 -->
<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"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="smile" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<text>朋友圈</text>
</view>
</view>
</view>
</view>
@@ -265,6 +314,35 @@ function viewAvatar() {
function handleClose() {
emit('update:modelValue', false)
}
// 发送消息 - 跳转到聊天页
function handleSendMessage() {
handleClose()
if (props.user?.id) {
uni.navigateTo({
url: `/pages/chat/index?targetId=${props.user.id}&name=${encodeURIComponent(props.user.name || '')}&avatar=${encodeURIComponent(props.user.avatar || '')}`
})
}
emit('send-message')
}
// 语音通话
function handleAudioCall() {
handleClose()
emit('audio-call')
}
// 视频通话
function handleVideoCall() {
handleClose()
emit('video-call')
}
// 查看朋友圈
function handleViewMoments() {
handleClose()
emit('view-moments')
}
</script>
<style lang="scss" scoped>
@@ -502,25 +580,23 @@ function handleClose() {
}
.action-bar {
display: flex;
justify-content: center;
gap: 32rpx;
padding: 24rpx 40rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid var(--divider-color);
background: var(--bg-content);
}
.action-btn {
.action-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 20rpx 32rpx;
border-radius: 24rpx;
background: var(--bg-hover);
color: var(--text-secondary);
min-width: 140rpx;
gap: 12rpx;
transition: all 0.2s;
&:active {
@@ -528,13 +604,43 @@ function handleClose() {
}
text {
font-size: 22rpx;
font-size: 24rpx;
color: var(--text-secondary);
}
}
.action-icon {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
svg {
width: 44rpx;
height: 44rpx;
}
&.primary {
background: var(--color-primary);
color: #fff;
box-shadow: 0 8rpx 20rpx rgba(var(--color-primary-rgb), 0.3);
&.blue {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
box-shadow: 0 8rpx 20rpx rgba(59, 130, 246, 0.3);
}
&.green {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.3);
}
&.purple {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
box-shadow: 0 8rpx 20rpx rgba(139, 92, 246, 0.3);
}
&.orange {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
box-shadow: 0 8rpx 20rpx rgba(249, 115, 22, 0.3);
}
}
</style>

View File

@@ -18,6 +18,9 @@ export { default as MentionPicker } from './chat/MentionPicker.vue'
// 通话组件
export { default as CallWindow } from './call/CallWindow.vue'
export { default as MiniProgramCallWindow } from './call/MiniProgramCallWindow.vue'
export { default as GlobalCallProvider } from './call/GlobalCallProvider.vue'
export { default as UnifiedCallWindow } from './call/UnifiedCallWindow.vue'
export { default as GroupCallBanner } from './call/GroupCallBanner.vue'
export { default as GroupCallIncoming } from './call/GroupCallIncoming.vue'
export { default as GroupCallModal } from './call/GroupCallModal.vue'

View File

@@ -0,0 +1,104 @@
<template>
<view
class="action-menu"
:class="{ active: visible, dark: isDark }"
v-if="visible"
>
<view class="action-btn" @click="handleLike">
<svg viewBox="0 0 24 24" fill="none" :stroke="isLiked ? '#ef4444' : 'currentColor'" stroke-width="2">
<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" :fill="isLiked ? '#ef4444' : 'none'"/>
</svg>
<text :class="{ liked: isLiked }">{{ isLiked ? '取消' : '赞' }}</text>
</view>
<view class="divider"></view>
<view class="action-btn" @click="handleComment">
<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>
<text>评论</text>
</view>
</view>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
defineProps<{
visible: boolean
isLiked: boolean
}>()
const emit = defineEmits<{
like: []
comment: []
}>()
const { isDark } = useTheme()
function handleLike() {
emit('like')
}
function handleComment() {
emit('comment')
}
</script>
<style lang="scss" scoped>
.action-menu {
display: flex;
align-items: center;
background: #1f2937;
border-radius: 12rpx;
padding: 12rpx 0;
opacity: 0;
transform: translateX(10rpx) scale(0.95);
transition: all 0.2s ease;
pointer-events: none;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
&.active {
opacity: 1;
transform: translateX(0) scale(1);
pointer-events: auto;
}
&.dark {
background: #292524;
}
}
.action-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
color: #fff;
transition: opacity 0.15s;
&:active {
opacity: 0.7;
}
svg {
width: 32rpx;
height: 32rpx;
}
text {
font-size: 24rpx;
font-weight: 500;
&.liked {
color: #ef4444;
}
}
}
.divider {
width: 1rpx;
height: 32rpx;
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<view
class="comment-input-bar"
:class="{ show: visible, dark: isDark }"
>
<view class="input-wrapper">
<input
ref="inputRef"
v-model="text"
type="text"
:placeholder="placeholder"
:focus="visible"
@confirm="handleSubmit"
/>
</view>
<wd-button
size="small"
type="primary"
:disabled="!text.trim() || loading"
:loading="loading"
@click="handleSubmit"
>
发送
</wd-button>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
visible: boolean
replyTo?: { id: number; name: string } | null
loading?: boolean
}>()
const emit = defineEmits<{
submit: [data: { content: string; replyToId?: number }]
close: []
}>()
const { isDark } = useTheme()
const text = ref('')
const placeholder = computed(() =>
props.replyTo ? `回复 ${props.replyTo.name}` : '写评论...'
)
// 显示时清空内容
watch(() => props.visible, (val) => {
if (val) {
text.value = ''
}
})
function handleSubmit() {
if (!text.value.trim()) return
emit('submit', {
content: text.value.trim(),
replyToId: props.replyTo?.id
})
text.value = ''
}
</script>
<style lang="scss" scoped>
.comment-input-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: var(--bg-card);
border-top: 1rpx solid var(--border-color);
display: flex;
align-items: center;
gap: 16rpx;
transform: translateY(100%);
transition: transform 0.3s ease;
z-index: 100;
&.show {
transform: translateY(0);
}
&.dark {
--bg-card: #1c1917;
--bg-input: #292524;
--border-color: #44403c;
--text-primary: #f5f5f4;
}
.input-wrapper {
flex: 1;
background: var(--bg-input, #f3f4f6);
border-radius: 36rpx;
padding: 16rpx 24rpx;
input {
width: 100%;
font-size: 28rpx;
color: var(--text-primary, #1f2937);
background: transparent;
border: none;
}
}
}
</style>

View File

@@ -104,7 +104,7 @@ const mediaUrls = computed(() => parseMediaUrls(props.moment.media_urls))
const imageSize = computed(() => {
const count = mediaUrls.value.length
if (count === 1) return '400rpx'
if (count === 1) return '300rpx'
if (count <= 4) return '200rpx'
return '150rpx'
})

View File

@@ -214,7 +214,7 @@ function getImageCount(moment: Moment): number {
// 获取图片尺寸
function getImageSize(moment: Moment): string {
const count = getImageCount(moment)
if (count === 1) return '400rpx'
if (count === 1) return '300rpx'
if (count <= 4) return '200rpx'
return '150rpx'
}

View File

@@ -1,6 +1,7 @@
export { useTheme } from './useTheme'
export { useWebRTC } from './useWebRTC'
export { useGroupWebRTC } from './useGroupWebRTC'
export { useMiniProgramCall } from './useMiniProgramCall'

View File

@@ -0,0 +1,553 @@
/**
* 小程序音视频通话组合式函数
* 使用 live-pusher/live-player 组件
*
* 适用平台:微信小程序
*/
import { reactive, ref, computed } from 'vue'
import * as callApi from '@/api/modules/call'
import * as messageApi from '@/api/modules/message'
import { wsManager } from '@/api/websocket'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import type { ChatMessage, Contact } from '@/types/api'
export interface MiniProgramCallState {
active: boolean
minimized: boolean
type: 'audio' | 'video'
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
statusText: string
roomId: string | null
callId: string | null
muted: boolean
camOff: boolean
duration: number
startTime: number | null
// 来电信息
callerName?: string
callerAvatar?: string
callerId?: string
}
export interface RemoteStream {
userId: string
pullUrl: string
flvUrl?: string
userName?: string
userAvatar?: string
}
// --- 全局单例状态 ---
const call = reactive<MiniProgramCallState>({
active: false,
minimized: false,
type: 'video',
status: 'idle',
statusText: '',
roomId: null,
callId: null,
muted: false,
camOff: false,
duration: 0,
startTime: null,
})
const pushUrl = ref<string>('')
const remoteStreams = ref<RemoteStream[]>([])
let durationTimer: ReturnType<typeof setInterval> | null = null
let currentReceiverUserId = ''
// live-pusher 组件上下文
let pusherContext: UniApp.LivePusherContext | null = null
export function useMiniProgramCall() {
const authStore = useAuthStore()
const chatStore = useChatStore()
const userId = computed(() => authStore.user?.id || '')
const isActive = computed(() => call.active)
/**
* 初始化 live-pusher 上下文
*/
function initPusherContext() {
// #ifdef MP-WEIXIN
pusherContext = uni.createLivePusherContext()
// #endif
}
/**
* 初始化信令监听
*/
function initListener() {
wsManager.offSignal(handleSignaling)
wsManager.onSignal(handleSignaling)
}
/**
* 处理信令消息
*/
async function handleSignaling(message: ChatMessage) {
try {
const content = message.content ? JSON.parse(message.content) : {}
const signal = message.call_status as string
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
if (extra.type) call.type = extra.type
if (signal === 'invite') {
// 收到来电
if (call.active) return
currentReceiverUserId = message.sender_user_id
call.roomId = message.room_id
call.callId = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你${call.type === 'video' ? '视频' : '语音'}通话`
call.callerId = message.sender_user_id
// 获取来电者信息
const contact = chatStore.contacts.find(c => c.contact_user_id === message.sender_user_id)
if (contact?.user) {
call.callerName = contact.remark_name || contact.user.name
call.callerAvatar = contact.user.avatar
} else if (extra.senderName) {
call.callerName = extra.senderName
call.callerAvatar = extra.senderAvatar
}
// 播放来电铃声
playRingtone()
} else if (signal === 'accepted') {
// 对方接听
stopRingtone()
call.status = 'connected'
call.statusText = '正在连接...'
await joinCallRoom()
} 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,
})
}
} else if (signal === 'participant_left') {
// 参与者离开
remoteStreams.value = remoteStreams.value.filter(s => s.userId !== content.user_id)
} else if (['hangup', 'ended', 'reject'].includes(signal)) {
// 通话结束
closeCall()
if (signal === 'reject') {
uni.showToast({ title: '对方已拒绝', icon: 'none' })
} else {
uni.showToast({ title: '通话已结束', icon: 'none' })
}
}
} catch (error) {
console.error('[MiniProgramCall] 处理信令失败:', error)
}
}
/**
* 发起通话
*/
async function startCall(
type: 'audio' | 'video',
receiverUserId: string,
roomId: string,
contact?: Contact
): Promise<boolean> {
if (call.active) {
uni.showToast({ title: '当前有正在进行的通话', icon: 'none' })
return false
}
currentReceiverUserId = receiverUserId
call.type = type
call.roomId = roomId
call.callId = Date.now().toString()
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = type === 'audio'
call.callerId = receiverUserId
// 设置对方信息
if (contact) {
call.callerName = contact.remark_name || contact.user?.name
call.callerAvatar = contact.user?.avatar
}
try {
// 加入房间获取推流地址
await joinCallRoom()
// 发送邀请信令
sendSignal('invite')
playRingtone()
return true
} catch (error: any) {
uni.showToast({ title: error.message || '发起通话失败', icon: 'none' })
closeCall()
return false
}
}
/**
* 加入通话房间
*/
async function joinCallRoom() {
if (!call.roomId || !userId.value) return
try {
const response = await callApi.joinCallRoom({
room_id: call.roomId,
user_id: userId.value,
platform: 'miniprogram'
})
// 设置推流地址
if (response.push_url) {
pushUrl.value = response.push_url
}
// 设置拉流地址
if (response.pull_urls) {
remoteStreams.value = response.pull_urls.map(p => ({
userId: p.user_id,
pullUrl: p.url,
flvUrl: p.flv_url,
}))
}
// 开始推流
startPushing()
// 开始计时
startCallTimer()
call.status = 'connected'
call.statusText = '通话中'
} catch (error) {
console.error('[MiniProgramCall] 加入房间失败:', error)
throw error
}
}
/**
* 接听来电
*/
async function acceptCall() {
stopRingtone()
call.status = 'connected'
call.statusText = '正在连接...'
try {
await joinCallRoom()
sendSignal('accepted')
} catch (error: any) {
uni.showToast({ title: error.message || '接听失败', icon: 'none' })
endCall()
}
}
/**
* 拒绝来电
*/
function rejectCall() {
stopRingtone()
sendSignal('reject')
closeCall()
}
/**
* 结束通话
*/
function endCall() {
stopRingtone()
sendSignal('hangup')
leaveRoom()
closeCall()
}
/**
* 离开房间
*/
async function leaveRoom() {
if (!call.roomId || !userId.value) return
try {
await callApi.leaveCallRoom({
room_id: call.roomId,
user_id: userId.value
})
} catch (error) {
console.error('[MiniProgramCall] 离开房间失败:', error)
}
}
/**
* 关闭通话
*/
function closeCall() {
stopRingtone()
stopPushing()
stopCallTimer()
call.active = false
call.status = 'idle'
call.statusText = ''
call.roomId = null
call.callId = null
call.callerName = undefined
call.callerAvatar = undefined
call.callerId = undefined
call.muted = false
call.camOff = false
call.duration = 0
call.startTime = null
pushUrl.value = ''
remoteStreams.value = []
currentReceiverUserId = ''
}
/**
* 开始推流
*/
function startPushing() {
// #ifdef MP-WEIXIN
if (!pusherContext) {
initPusherContext()
}
pusherContext?.start({
success: () => {
console.log('[MiniProgramCall] 推流成功')
},
fail: (err: any) => {
console.error('[MiniProgramCall] 推流失败:', err)
}
})
// #endif
}
/**
* 停止推流
*/
function stopPushing() {
// #ifdef MP-WEIXIN
pusherContext?.stop({
success: () => {
console.log('[MiniProgramCall] 停止推流')
}
})
// #endif
}
/**
* 发送信令
*/
function sendSignal(status: string, data?: any) {
if (!call.callId) return
const roomId = call.roomId
if (!roomId) return
const currentUser = authStore.user
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 6,
content: JSON.stringify(data || {}),
call_id: call.callId,
call_status: status,
extra: JSON.stringify({
type: call.type,
senderName: currentUser?.name,
senderAvatar: currentUser?.avatar,
platform: 'miniprogram'
}),
}
messageApi.sendMessage(payload).catch(console.error)
}
/**
* 播放来电铃声
*/
function playRingtone() {
// #ifdef MP-WEIXIN
uni.vibrateLong({})
// #endif
}
/**
* 停止来电铃声
*/
function stopRingtone() {
// 小程序没有持续振动 API,无需停止
}
/**
* 开始通话计时
*/
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
durationTimer = setInterval(() => {
if (call.startTime) {
call.duration = Math.floor((Date.now() - call.startTime) / 1000)
}
}, 1000)
}
/**
* 停止通话计时
*/
function stopCallTimer() {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
call.startTime = null
call.duration = 0
}
/**
* 切换静音
*/
function toggleMute() {
call.muted = !call.muted
// #ifdef MP-WEIXIN
pusherContext?.mute(call.muted)
// #endif
}
/**
* 切换摄像头
*/
function toggleCamera() {
call.camOff = !call.camOff
// #ifdef MP-WEIXIN
if (call.camOff) {
pusherContext?.pause()
} else {
pusherContext?.resume()
}
// #endif
}
/**
* 切换前后摄像头
*/
function switchCamera() {
// #ifdef MP-WEIXIN
pusherContext?.switchCamera({})
// #endif
}
/**
* 最小化/恢复窗口
*/
function toggleMinimize() {
call.minimized = !call.minimized
}
/**
* 格式化通话时长
*/
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
const h = Math.floor(seconds / 3600)
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
/**
* 处理推流状态变化
*/
function onPusherStateChange(e: any) {
const code = e.detail?.code
console.log('[MiniProgramCall] 推流状态:', code)
switch (code) {
case 1002: // 已成功连接到服务器
call.statusText = '通话中'
break
case 1003: // 已开始推流
break
case 1004: // 推流断开,正在重连
call.statusText = '重新连接中...'
break
case -1301: // 打开摄像头失败
uni.showToast({ title: '摄像头打开失败', icon: 'none' })
break
case -1302: // 打开麦克风失败
uni.showToast({ title: '麦克风打开失败', icon: 'none' })
break
case -1303: // 视频编码失败
break
case -1304: // 音频编码失败
break
case -1307: // 推流连接断开
call.statusText = '连接已断开'
break
}
}
/**
* 处理拉流状态变化
*/
function onPlayerStateChange(e: any, userId: string) {
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 {
call,
pushUrl,
remoteStreams,
isActive,
initListener,
initPusherContext,
startCall,
acceptCall,
rejectCall,
endCall,
toggleMute,
toggleCamera,
switchCamera,
toggleMinimize,
formatDuration,
onPusherStateChange,
onPlayerStateChange,
}
}
export default useMiniProgramCall

View File

@@ -1,9 +1,11 @@
/**
* WebRTC 通话组合式函数 - 全局单例模式
* 支持在任意页面接听来电
* 支持 SFU 模式和 P2P 模式
*/
import { reactive, shallowRef, ref, computed } from 'vue'
import * as messageApi from '@/api/modules/message'
import * as callApi from '@/api/modules/call'
import { wsManager } from '@/api/websocket'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
@@ -11,6 +13,9 @@ import { useConversationStore } from '@/stores/conversation'
import type { ChatMessage, Contact } from '@/types/api'
import type { CallStatus } from '@/types/message'
// 连接模式
type ConnectionMode = 'p2p' | 'sfu'
export interface CallState {
active: boolean
minimized: boolean
@@ -55,6 +60,10 @@ let currentReceiverUserId = ''
let currentRoomId = ''
let audioContext: UniApp.InnerAudioContext | null = null
// SFU 模式配置
let connectionMode: ConnectionMode = 'p2p'
let sfuIceServers: RTCIceServer[] = []
export function useWebRTC() {
const authStore = useAuthStore()
const chatStore = useChatStore()
@@ -243,10 +252,37 @@ export function useWebRTC() {
// #endif
}
// 设置连接模式
function setConnectionMode(mode: ConnectionMode) {
connectionMode = mode
console.log(`📡 [WebRTC] 连接模式: ${mode}`)
}
// 获取 ICE 服务器配置
async function fetchICEServers() {
try {
const response = await callApi.getICEServers(userId.value)
if (response.ice_servers?.length) {
sfuIceServers = response.ice_servers.map(s => ({
urls: s.urls,
username: s.username,
credential: s.credential,
}))
console.log('📡 [WebRTC] 获取 ICE 服务器配置:', sfuIceServers.length)
}
} catch (error) {
console.warn('⚠️ [WebRTC] 获取 ICE 服务器配置失败:', error)
}
}
// 创建 RTCPeerConnection
async function createPC(): Promise<void> {
// #ifdef H5
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
// 优先使用服务器配置的 ICE 服务器
let servers: RTCIceServer[] = sfuIceServers.length > 0
? sfuIceServers
: [{ urls: 'stun:stun.l.google.com:19302' }]
if (pc) { pc.close(); pc = null }
pc = new RTCPeerConnection({ iceServers: servers })
@@ -269,12 +305,67 @@ export function useWebRTC() {
}
}
pc.onicecandidate = (e) => {
if (e.candidate && call.id) sendSignal('candidate', e.candidate)
pc.onicecandidate = async (e) => {
if (e.candidate && call.id) {
// SFU 模式下发送 ICE 候选到服务器
if (connectionMode === 'sfu' && currentRoomId) {
try {
await callApi.sendICECandidate({
room_id: currentRoomId,
user_id: userId.value,
candidate: JSON.stringify(e.candidate)
})
} catch (error) {
console.warn('⚠️ [WebRTC] 发送 ICE 候选失败:', error)
}
} else {
// P2P 模式下通过信令发送
sendSignal('candidate', e.candidate)
}
}
}
// #endif
}
// SFU 模式:连接到 SFU 服务器
async function connectToSFU(): Promise<void> {
if (!currentRoomId || !userId.value) return
try {
// 获取 ICE 服务器配置
await fetchICEServers()
// 创建 PeerConnection
await initMedia(call.type === 'video')
await createPC()
// 创建 Offer
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
// 发送 Offer 到 SFU,获取 Answer
const response = await callApi.sendOffer({
room_id: currentRoomId,
user_id: userId.value,
sdp: offer.sdp!
})
// 设置远端描述
await pc!.setRemoteDescription({
type: 'answer',
sdp: response.sdp
})
// 处理待定的 ICE 候选
processPendingCandidates()
console.log('✅ [WebRTC] 已连接到 SFU')
} catch (error) {
console.error('❌ [WebRTC] 连接 SFU 失败:', error)
throw error
}
}
// 发送信令
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
if (!call.id) return
@@ -368,6 +459,9 @@ export function useWebRTC() {
remoteStream.value = null
try {
// 获取 ICE 服务器配置
await fetchICEServers()
await initMedia(type === 'video')
await createPC()
playRingtone()
@@ -380,6 +474,42 @@ export function useWebRTC() {
}
}
// SFU 模式发起通话
async function startCallSFU(type: 'audio' | 'video', receiverUserId: string, roomId: string, targetName?: string, targetAvatar?: string) {
setConnectionMode('sfu')
currentRoomId = roomId
currentReceiverUserId = receiverUserId
call.type = type
call.id = Date.now().toString()
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false
call.callerId = receiverUserId
if (targetName) call.callerName = targetName
if (targetAvatar) call.callerAvatar = targetAvatar
remoteStream.value = null
try {
// 连接到 SFU
await connectToSFU()
playRingtone()
sendSignal('invite', undefined, receiverUserId)
return true
} catch (error: any) {
uni.showToast({ title: error.message || '无法启动通话', icon: 'none' })
closeCall()
return false
}
}
// 接听来电
async function acceptCall(senderUserId?: string) {
isCaller.value = false
@@ -505,6 +635,7 @@ export function useWebRTC() {
isActive,
initListener,
startCall,
startCallSFU,
acceptCall,
rejectCall,
endCall,
@@ -512,7 +643,9 @@ export function useWebRTC() {
toggleMute,
toggleCamera,
toggleMinimize,
formatDuration
formatDuration,
setConnectionMode,
fetchICEServers,
}
}

View File

@@ -49,7 +49,7 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"appid": "wx54452d2c36bec8ee",
"setting": {
"urlCheck": false
},

View File

@@ -8,7 +8,12 @@
<view class="navbar-content">
<view class="nav-left" @click="goBack">
<view class="back-btn">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="arrow-left" size="44rpx" />
<!-- #endif -->
</view>
<view class="nav-info">
<text class="nav-name">{{ displayName }}</text>
@@ -20,16 +25,55 @@
</view>
<view class="nav-actions">
<view class="icon-btn" @click="startAudioCall"><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></view>
<view class="icon-btn" @click="startVideoCall"><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></view>
<view class="icon-btn" @click="showChatInfo"><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></view>
<view class="icon-btn" @click="startAudioCall">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="phone" size="44rpx" />
<!-- #endif -->
</view>
<view class="icon-btn" @click="startVideoCall">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="video" size="44rpx" />
<!-- #endif -->
</view>
<!-- 群聊:显示群成员图标 -->
<view v-if="isGroupChat" class="icon-btn" @click="goGroupInfo">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="user" size="44rpx" />
<!-- #endif -->
</view>
<!-- 单聊:显示更多图标 -->
<view v-else class="icon-btn" @click="showChatInfo">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="more" size="44rpx" />
<!-- #endif -->
</view>
</view>
</view>
</view>
<!-- 群通话 Banner -->
<group-call-banner
v-if="isGroupChat && hasActiveGroupCall"
:room-id="roomId"
class="call-banner-wrapper"
/>
<!-- 2. 消息列表区 -->
<scroll-view
class="chat-scroll-area custom-scrollbar"
:class="{ 'has-banner': hasActiveGroupCall && isGroupChat }"
scroll-y
:scroll-into-view="scrollToId"
:scroll-top="scrollTop"
@@ -101,7 +145,12 @@
<view class="dialog-header">确认发送</view>
<view class="file-preview-box">
<view class="preview-icon">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="folder" size="48rpx" />
<!-- #endif -->
</view>
<view class="f-info">
<text class="f-name">{{ pendingFile.name }}</text>
@@ -116,6 +165,9 @@
</wd-popup>
<wd-toast />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -133,18 +185,38 @@ import { resolveImageUrl } from '@/utils/image'
import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'
import { useWebRTC } from '@/composables/useWebRTC'
// #ifdef MP-WEIXIN
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
// #endif
import * as roomApi from '@/api/modules/room'
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()
const chatStore = useChatStore()
const conversationStore = useConversationStore()
const toast = useToast()
const { isDark } = useTheme()
// #ifdef H5 || APP-PLUS
const webrtc = useWebRTC()
// #endif
// #ifdef MP-WEIXIN
const mpCall = useMiniProgramCall()
// #endif
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 !== ''
})
// 获取当前组件实例,用于 SelectorQuery
const instance = getCurrentInstance()
@@ -157,6 +229,8 @@ const playingAudioId = ref<number | null>(null); const page = ref(1);
const showFileConfirm = ref(false); const uploading = ref(false); const pendingFile = ref<{ path: string; name: string; size: number; type: 'image' | 'video' | 'file'; messageType: number }>({ path: '', name: '', size: 0, type: 'file', messageType: 8 });
const showMsgActions = ref(false); const selectedMessage = ref<ChatMessage | null>(null);
const groupMembers = ref<GroupMember[]>([]); const isGroupChat = ref(false);
const membersLoading = ref(false);
const groupMembersMap = ref<Map<string, GroupMember>>(new Map());
const userCache = ref<Map<string, { name: string; avatar: string }>>(new Map());
const currentUser = computed(() => authStore.user);
@@ -164,17 +238,21 @@ const msgActionItems = computed(() => { if (!selectedMessage.value) return []; c
const displayName = computed(() => targetUser.value?.name || chatName.value || '聊天');
const targetUser = computed(() => { const contact = chatStore.contacts.find(c => c.contact_user_id === targetId.value); if (contact?.user) { return contact.user } const conv = conversationStore.conversations.find(c => c.target_id === targetId.value || c.room_id === roomId.value); if (conv) { return { id: conv.target_id, name: conv.name || chatName.value, avatar: conv.avatar || targetAvatar.value } } return { id: targetId.value, name: chatName.value, avatar: targetAvatar.value } });
onLoad((options: any) => {
onLoad(async (options: any) => {
roomId.value = options?.roomId || '';
targetId.value = options?.targetId || '';
chatName.value = decodeURIComponent(options?.name || '聊天');
targetAvatar.value = decodeURIComponent(options?.avatar || '');
isGroupChat.value = !targetId.value && !!roomId.value;
loadMessages();
setupWebSocket();
if (isGroupChat.value && roomId.value) { loadGroupMembers() }
// 群聊时先加载成员,确保消息渲染时能获取到发送者信息
if (isGroupChat.value && roomId.value) {
await loadGroupMembers()
}
loadMessages();
});
onUnmounted(() => { wsManager.offMessage(handleNewMessage); stopAudio(); });
@@ -345,14 +423,107 @@ function handleNewMessage(msg: ChatMessage) {
}
}
// ... (Helper Functions 保持不变) ...
async function loadGroupMembers() { if (!roomId.value) return; try { const members = await roomApi.getGroupMembers(roomId.value); groupMembers.value = members; members.forEach(member => { if (member.user_id) { const userData = { name: member.nickname || member.user?.name || (member as any).name || '', avatar: member.user?.avatar || (member as any).avatar || (member as any).user_avatar || '' }; if (userData.name || userData.avatar) { userCache.value.set(member.user_id, userData) } } }) } catch (e) { console.error('加载群成员失败:', e) } }
// 群成员加载 - 使用Map提高查找效率
async function loadGroupMembers() {
if (!roomId.value) return
membersLoading.value = true
try {
const members = await roomApi.getGroupMembers(roomId.value)
groupMembers.value = members
// 建立 user_id 到成员的映射,提高查找效率
const memberMap = new Map<string, GroupMember>()
members.forEach(member => {
if (member.user_id) {
memberMap.set(member.user_id, member)
// 同时更新 userCache
const userData = {
name: member.nickname || member.user?.name || (member as any).name || '',
avatar: member.user?.avatar || (member as any).avatar || (member as any).user_avatar || ''
}
if (userData.name || userData.avatar) {
userCache.value.set(member.user_id, userData)
}
}
})
groupMembersMap.value = memberMap
} catch (e) {
console.error('加载群成员失败:', e)
} finally {
membersLoading.value = false
}
}
async function loadMoreMessages() { if (loadingMore.value || !hasMore.value) { return } loadingMore.value = true; page.value++; const firstMsgId = messages.value.length > 0 ? messages.value[0].id : null; try { const res = await messageApi.getMessages(roomId.value, page.value, 50); const newMessages = mapMessages((res.data || []).reverse()); messages.value = [...newMessages, ...messages.value]; hasMore.value = res.data.length >= 50; if (firstMsgId) { nextTick(() => { scrollWithAnimation.value = false; scrollToId.value = `msg-${firstMsgId}` }) } } catch { page.value-- } finally { loadingMore.value = false } }
function setupWebSocket() { wsManager.onMessage(handleNewMessage) }
function onScrollToUpper() { if (!loadingMore.value && hasMore.value) { loadMoreMessages() } }
function onScrollClick() { /* 点击空白处收起键盘 */ }
function getMessageSenderAvatar(msg: ChatMessage): string { const msgAny = msg as any; const senderId = msg.sender_user_id; 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); if (isGroupChat.value && senderId) { const cachedUser = userCache.value.get(senderId); if (cachedUser?.avatar) return resolveImageUrl(cachedUser.avatar); const member = groupMembers.value.find(m => m.user_id === senderId); if (member) { const avatar = member.user?.avatar || (member as any).avatar || (member as any).user_avatar; if (avatar) return resolveImageUrl(avatar) } const contact = chatStore.contacts.find(c => c.contact_user_id === senderId); if (contact?.user?.avatar) return resolveImageUrl(contact.user.avatar); return '' } return resolveImageUrl(targetUser.value?.avatar || '') }
function getMessageSenderName(msg: ChatMessage): string { const msgAny = msg as any; const senderId = msg.sender_user_id; 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; if (isGroupChat.value && senderId) { const cachedUser = userCache.value.get(senderId); if (cachedUser?.name) return cachedUser.name; const member = groupMembers.value.find(m => m.user_id === senderId); if (member) { const name = member.nickname || member.user?.name || (member as any).name || (member as any).user_name; if (name) return name } const contact = chatStore.contacts.find(c => c.contact_user_id === senderId); if (contact?.user?.name) return contact.remark_name || contact.user.name; return `用户${senderId.slice(-4)}` } return targetUser.value?.name || chatName.value || '未知' }
// 获取消息发送者头像 - 使用Map优化查找
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 || '')
}
// 获取消息发送者名称 - 使用Map优化查找
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 || '未知'
}
function onAvatarClick(userId?: string) { if (userId) uni.navigateTo({ url: `/pages/contact/detail?userId=${userId}` }) }
function handleMessageLongPress(msg: ChatMessage) { selectedMessage.value = msg; showMsgActions.value = true }
function previewImage(url: string) { if (url) uni.previewImage({ urls: [url], current: url }) }
@@ -360,8 +531,30 @@ 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() { if (!targetId.value) return; webrtc.startCall('audio', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar) }
function startVideoCall() { if (!targetId.value) return; webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar) }
function startAudioCall() {
if (!targetId.value) return
// #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 }
} as any)
// #endif
}
function startVideoCall() {
if (!targetId.value) return
// #ifdef H5 || APP-PLUS
webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar)
// #endif
// #ifdef MP-WEIXIN
mpCall.startCall('video', targetId.value, roomId.value, {
user_id: targetId.value,
user: { name: targetUser.value?.name, avatar: targetUser.value?.avatar }
} 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 */ }
@@ -372,6 +565,7 @@ function getFileInfo(filePath: string): Promise<{ name: string; size: number }>
async function onMsgActionSelect(action: { value: string }) { if (!selectedMessage.value) return; const msg = selectedMessage.value; showMsgActions.value = false; switch (action.value) { case 'copy': uni.setClipboardData({ data: msg.content }); break; case 'recall': await messageApi.recallMessage(msg.id); msg.message_type = 5; msg.content = '你撤回了一条消息'; break; case 'delete': messages.value = messages.value.filter(m => m.id !== msg.id); chatStore.removeMessage(roomId.value, msg.id); break; } }
function goBack() { uni.navigateBack() }
function showChatInfo() { if (roomId.value) { uni.navigateTo({ url: `/pages/group/info?roomId=${roomId.value}` }) } }
function goGroupInfo() { if (roomId.value) { uni.navigateTo({ url: `/pages/group/info?roomId=${roomId.value}` }) } }
function shouldShowTime(msg: ChatMessage, index: number): boolean { if (index === 0) return true; const prevMsg = messages.value[index - 1]; const currentTime = new Date(msg.created_at).getTime(); const prevTime = new Date(prevMsg.created_at).getTime(); return currentTime - prevTime > 5 * 60 * 1000 }
function isSystemMessage(type: number): boolean { return checkSystemMessage(type) }
function formatRecordDuration(seconds: number): string { const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}` }
@@ -451,11 +645,26 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
}
}
/* 群通话 Banner 位置 */
.call-banner-wrapper {
position: fixed;
top: calc(88rpx + var(--status-bar-height));
left: 0;
right: 0;
z-index: 99;
}
/* 消息列表 */
.chat-scroll-area {
flex: 1;
background: var(--bg-base);
transition: background-color 0.5s ease;
&.has-banner {
.message-feed {
padding-top: calc(88rpx + var(--status-bar-height) + 160rpx);
}
}
}
.message-feed {
padding: 32rpx;

View File

@@ -19,19 +19,27 @@
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-btn" @click="goBack">
<!-- Icon: chevron-left -->
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-left" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="nav-title">添加好友</text>
<view class="nav-btn">
<!-- Icon: more-horizontal -->
<!-- #ifdef H5 -->
<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>
<circle cx="5" cy="12" r="1"></circle>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="more" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
</view>
@@ -45,11 +53,15 @@
<view class="search-wrapper">
<view class="search-box" @click="focusSearch">
<view class="search-icon-wrapper">
<!-- Icon: search -->
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="40rpx" />
<!-- #endif -->
</view>
<input
ref="searchInput"
@@ -62,11 +74,15 @@
@confirm="doSearch"
/>
<view v-if="searchKeyword" class="clear-btn" @click.stop="clearSearch">
<!-- Icon: x -->
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="close" size="32rpx" />
<!-- #endif -->
</view>
</view>
</view>
@@ -80,23 +96,31 @@
<!-- 扫一扫卡片 -->
<view class="scan-card animate-fade-in-up" @click="handleScan">
<view class="scan-icon-box">
<!-- Icon: scan-line -->
<!-- #ifdef H5 -->
<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>
<path d="M21 17v2a2 2 0 0 1-2 2h-2"></path>
<path d="M7 21H5a2 2 0 0 1-2-2v-2"></path>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="scan" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="scan-info">
<text class="scan-title">扫一扫</text>
<text class="scan-desc">扫描二维码名片</text>
</view>
<view class="scan-arrow">
<!-- Icon: chevron-left (rotated) -->
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
</view>
@@ -144,10 +168,15 @@
<template v-else-if="viewState === 'empty'">
<view class="state-card animate-fade-in-up">
<view class="empty-icon-box">
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="48rpx" />
<!-- #endif -->
</view>
<text class="state-text">未找到该用户</text>
</view>

View File

@@ -14,21 +14,31 @@
<!-- 导航栏 (与设计稿一致) -->
<view class="nav-header">
<view class="nav-back" @click="goBack">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="arrow-left" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="nav-more">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="more" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
</view>
<!-- 主体内容 -->
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-if="!loading && contact">
<!-- 主体内容 - 好友状态 -->
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-if="!loading && isFriend && contact">
<!-- 身份卡片区 (与设计稿一致) -->
<view class="identity-section">
<!-- 头像: w-32 h-32 rounded-full border-[6px] border-white shadow-xl -->
@@ -46,7 +56,7 @@
<!-- 标签: text-xs bg-gray-100 px-2 py-0.5 rounded -->
<view class="tag-row">
<view class="info-tag">ID: {{ contact.user?.id || '-' }}</view>
<view class="info-tag">ID: {{ contact.user?.id?.slice(-6) || '-' }}</view>
<view class="info-tag">{{ contact.user?.region || '未知' }}</view>
</view>
@@ -61,9 +71,14 @@
<text class="row-label">设置备注</text>
<view class="row-right">
<text class="row-value">{{ contact.remark_name || '未设置' }}</text>
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-icon-mp" />
<!-- #endif -->
</view>
</view>
@@ -99,33 +114,94 @@
<view class="bottom-spacer" />
</scroll-view>
<!-- 主体内容 - 陌生人状态 -->
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-else-if="!loading && !isFriend && strangerUser">
<!-- 身份卡片区 -->
<view class="identity-section">
<view class="avatar-wrapper">
<app-avatar
:src="strangerUser.avatar"
:name="strangerUser.name"
:size="256"
round
/>
</view>
<text class="user-name">{{ strangerUser.name || '未知' }}</text>
<view class="tag-row">
<view class="info-tag">ID: {{ strangerUser.id?.slice(-6) || '-' }}</view>
<view class="info-tag">{{ strangerUser.region || '未知' }}</view>
</view>
<text class="bio-text">{{ strangerUser.desc || '这个人很懒,什么都没写' }}</text>
</view>
<!-- 陌生人信息卡片 -->
<view class="stranger-card animate-fade-in-up">
<text class="section-title">个人简介</text>
<view class="info-row" v-if="sourceText">
<text class="info-label">来源</text>
<text class="info-value">{{ sourceText }}</text>
</view>
<view class="info-row">
<text class="info-label">签名</text>
<text class="info-value">{{ strangerUser.desc || '暂无签名' }}</text>
</view>
<view class="info-row" v-if="strangerUser.region">
<text class="info-label">地区</text>
<text class="info-value">{{ strangerUser.region }}</text>
</view>
</view>
<view class="bottom-spacer" />
</scroll-view>
<!-- Loading -->
<view v-else-if="loading" class="center-loading">
<wd-loading size="60rpx" :color="isDark ? '#f97316' : '#4F46E5'"/>
</view>
<!-- 底部悬浮操作栏 (与设计稿一致: rounded-t-[32px] shadow-[0_-10px_40px]) -->
<view class="floating-dock" v-if="contact">
<!-- 底部悬浮操作栏 - 好友状态 -->
<view class="floating-dock" v-if="isFriend && contact">
<view class="dock-buttons">
<!-- 电话: flex-1 -->
<view class="dock-btn secondary" @click="startAudioCall">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="phone" size="44rpx" />
<!-- #endif -->
</view>
<!-- 发消息: flex-[2] -->
<view class="dock-btn primary" @click="goChat">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="chat" size="44rpx" />
<!-- #endif -->
<text>发消息</text>
</view>
<!-- 视频: flex-1 -->
<view class="dock-btn secondary" @click="startVideoCall">
<!-- #ifdef H5 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="video" size="44rpx" />
<!-- #endif -->
</view>
</view>
@@ -135,6 +211,24 @@
</view>
</view>
<!-- 底部悬浮操作栏 - 陌生人状态 -->
<view class="floating-dock" v-else-if="!isFriend && strangerUser">
<view class="add-friend-btn" @click="addFriend">
<!-- #ifdef H5 -->
<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"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add-circle" size="40rpx" color="#fff" />
<!-- #endif -->
<text>添加好友</text>
</view>
</view>
<wd-toast />
<wd-message-box />
</view>
@@ -149,14 +243,103 @@ import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { Contact } from '@/types/api'
// --- 逻辑保持不变 ---
const toast = useToast(); const messageBox = useMessage(); const { isDark } = useTheme();
const loading = ref(true); const contact = ref<Contact | null>(null); const contactId = ref(''); const userId = ref('');
import * as userApi from '@/api/modules/user'
import type { User } from '@/types/api'
onLoad((options: any) => { contactId.value = options?.id || ''; userId.value = options?.userId || '' })
onMounted(async () => { if (contactId.value) { await loadContact() } else if (userId.value) { await loadContactByUserId() } })
async function loadContact() { loading.value = true; try { contact.value = await contactApi.getContactDetail(contactId.value) } catch (error) { console.error('加载联系人失败:', error); toast.error('加载失败') } finally { loading.value = false } }
async function loadContactByUserId() { loading.value = true; try { const contacts = await contactApi.getContacts(); const found = contacts.find((c: Contact) => c.contact_user_id === userId.value || c.user_id === userId.value); if (found) { contact.value = await contactApi.getContactDetail(found.id?.toString() || '') } else { toast.warning('未找到该联系人'); setTimeout(() => uni.navigateBack(), 1500) } } catch (error) { console.error('加载联系人失败:', error); toast.error('加载失败') } finally { loading.value = false } }
// --- 状态定义 ---
const toast = useToast()
const messageBox = useMessage()
const { isDark } = useTheme()
const loading = ref(true)
const contact = ref<Contact | null>(null)
const strangerUser = ref<User | null>(null)
const contactId = ref('')
const userId = ref('')
const isFriend = ref(false)
const sourceText = ref('')
onLoad((options: any) => {
contactId.value = options?.id || ''
userId.value = options?.userId || ''
// 来源信息(如果有)
sourceText.value = options?.source ? decodeURIComponent(options.source) : ''
})
onMounted(async () => {
if (contactId.value) {
await loadContact()
} else if (userId.value) {
await loadContactByUserId()
}
})
// 加载好友联系人
async function loadContact() {
loading.value = true
try {
contact.value = await contactApi.getContactDetail(contactId.value)
isFriend.value = true
} catch (error) {
console.error('加载联系人失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
// 通过用户ID加载 - 可能是好友也可能是陌生人
async function loadContactByUserId() {
loading.value = true
try {
// 先尝试从好友列表查找
const contacts = await contactApi.getContacts()
const found = contacts.find((c: Contact) =>
c.contact_user_id === userId.value || c.user_id === userId.value
)
if (found) {
// 是好友
contact.value = await contactApi.getContactDetail(found.id?.toString() || '')
isFriend.value = true
} else {
// 不是好友,加载用户基本信息
try {
strangerUser.value = await userApi.getUserInfo(userId.value)
isFriend.value = false
} catch {
// 用户不存在
toast.warning('用户不存在')
setTimeout(() => uni.navigateBack(), 1500)
}
}
} catch (error) {
console.error('加载失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
// 添加好友
async function addFriend() {
try {
const { value } = await messageBox.prompt({
title: '添加好友',
inputValue: '你好,我想加你为好友',
inputPlaceholder: '请输入验证消息'
})
await contactApi.addFriend({
to_user_id: userId.value,
message: value || ''
})
toast.success('好友请求已发送')
} catch (e: any) {
if (e !== 'cancel') {
toast.error(e?.message || '发送失败')
}
}
}
function goBack() { uni.navigateBack() }
function goChat() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}` }) }
function startAudioCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio` }) }
@@ -566,6 +749,91 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
height: 360rpx;
}
// ==========================================
// 陌生人信息卡片 (参考设计稿)
// ==========================================
.stranger-card {
margin: 0 24rpx 48rpx;
background: var(--bg-card);
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
border: 2rpx solid var(--border-color);
.theme-dark & {
box-shadow: none;
border-color: rgba(41, 37, 36, 0.5);
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-main);
margin-bottom: 24rpx;
display: block;
}
.info-row {
display: flex;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.info-label {
width: 100rpx;
font-size: 26rpx;
color: var(--text-tertiary);
flex-shrink: 0;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: var(--text-sub);
line-height: 1.5;
}
}
}
// ==========================================
// 添加好友按钮 (参考设计稿)
// ==========================================
.add-friend-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
height: 96rpx;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
border-radius: 48rpx;
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.3);
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
stroke: #fff;
}
text {
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
&:active {
transform: scale(0.98);
opacity: 0.9;
}
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #ea580c 100%);
box-shadow: 0 8rpx 24rpx rgba(234, 88, 12, 0.3);
}
}
// 加载中
.center-loading {
position: absolute;

View File

@@ -20,21 +20,31 @@
</view>
<!-- 右侧: 添加好友按钮 -->
<view class="icon-btn" @click="goAddFriend">
<!-- #ifdef H5 -->
<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"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add-circle" size="44rpx" />
<!-- #endif -->
</view>
</view>
<view class="search-box-wrap">
<view class="search-inner" @click="goSearch">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="32rpx" custom-class="search-icon-mp" />
<!-- #endif -->
<text class="search-placeholder">搜索联系人/群聊</text>
</view>
</view>
@@ -75,12 +85,17 @@
<view v-if="activeTab === 'groups'" class="feature-grid">
<view class="feature-card" @click="goFriendRequests">
<view class="icon-wrap is-orange">
<!-- #ifdef H5 -->
<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"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add-circle" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="text-wrap">
<text class="feature-title">新朋友</text>
@@ -89,10 +104,15 @@
</view>
<view class="feature-card" @click="goGroupNotify">
<view class="icon-wrap is-blue">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="bell" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="text-wrap">
<text class="feature-title">群通知</text>
@@ -111,10 +131,15 @@
<view class="group-section-header">
<text class="section-title">我的分组</text>
<view class="add-group-btn" @click="showCreateGroupModal = true">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add" size="28rpx" />
<!-- #endif -->
<text>新建</text>
</view>
</view>
@@ -123,9 +148,14 @@
<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 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-down" size="24rpx" />
<!-- #endif -->
</view>
<text class="collapse-title">未分组</text>
<text class="collapse-count">{{ ungroupedContacts.length }}</text>
@@ -145,9 +175,14 @@
<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 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-down" size="24rpx" />
<!-- #endif -->
</view>
<text class="collapse-title">{{ group.group_name }}</text>
<text class="collapse-count">{{ getGroupContacts(group.id).length }}</text>
@@ -184,9 +219,14 @@
<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 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-down" size="24rpx" />
<!-- #endif -->
</view>
<text class="collapse-title">我创建的群</text>
<text class="collapse-count">{{ createdGroups.length }}</text>
@@ -202,9 +242,14 @@
<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 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-down" size="24rpx" />
<!-- #endif -->
</view>
<text class="collapse-title">我管理的群</text>
<text class="collapse-count">{{ managedGroups.length }}</text>
@@ -220,9 +265,14 @@
<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 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-down" size="24rpx" />
<!-- #endif -->
</view>
<text class="collapse-title">我加入的群</text>
<text class="collapse-count">{{ joinedGroups.length }}</text>
@@ -294,6 +344,9 @@
<wd-toast />
<wd-message-box />
<app-tab-bar current="contacts" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -309,6 +362,7 @@ 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'
// --- 逻辑完全不变 ---
@@ -854,6 +908,16 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
color: var(--color-brand);
background: var(--index-hover);
}
// 深色模式下提高索引条对比度
.dark & {
color: #d6d3d1; // warm-300 - 更亮的颜色提高对比度
&:active {
color: #f97316; // orange-500
background: rgba(249, 115, 22, 0.15); // orange-500/15
}
}
}
}

View File

@@ -29,7 +29,7 @@
<view class="card-content">
<view class="info-top">
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
<text class="time">刚刚</text> <!-- 这里可以加个时间字段 -->
<text class="time">{{ formatTime(item.created_at) }}</text>
</view>
<view class="msg-box">
<text class="message">{{ item.message || '请求添加您为好友' }}</text>
@@ -62,6 +62,7 @@ import { useContactStore } from '@/stores'
import * as contactApi from '@/api/modules/contact'
import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'
import { formatTime } from '@/utils/format'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { FriendRequest } from '@/types/api'

View File

@@ -290,6 +290,7 @@ import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import * as roomApi from '@/api/modules/room'
import * as contactApi from '@/api/modules/contact'
import * as conversationApi from '@/api/modules/conversation'
import { useAuthStore } from '@/stores/auth'
import { useTheme } from '@/composables/useTheme'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
@@ -432,11 +433,13 @@ onShow(() => {
async function loadGroupInfo() {
loading.value = true
try {
const [info, memberList, announcementRes, contacts] = await Promise.all([
const [info, memberList, announcementRes, contacts, conversationRes] = await Promise.all([
roomApi.getGroup(roomId.value),
roomApi.getGroupMembers(roomId.value),
roomApi.getGroupAnnouncement(roomId.value).catch(() => ({ announcement: '' })),
contactApi.getContacts()
contactApi.getContacts(),
// 获取会话设置,用于初始化免打扰和置顶状态
conversationApi.getConversationByRoom(roomId.value).catch(() => null)
])
groupInfo.value = info
members.value = memberList
@@ -444,6 +447,12 @@ async function loadGroupInfo() {
members.value.sort((a, b) => b.role - a.role)
announcement.value = announcementRes.announcement
// 初始化免打扰和置顶状态
if (conversationRes) {
isMuted.value = conversationRes.is_muted || false
isTop.value = conversationRes.is_top || false
}
// 过滤可邀请的好友
const memberIds = new Set(memberList.map(m => m.user_id))
availableContacts.value = (contacts || []).filter(c => {
@@ -542,12 +551,34 @@ async function editMyNickname() {
}
// 免打扰/置顶
function toggleMute() {
toast.show(isMuted.value ? '已开启免打扰' : '已关闭免打扰')
async function toggleMute() {
const newValue = isMuted.value
try {
await conversationApi.updateConversation({
target_id: roomId.value,
is_muted: newValue
})
toast.success(newValue ? '已开启免打扰' : '已关闭免打扰')
} catch (e) {
// 回滚状态
isMuted.value = !newValue
toast.error('操作失败')
}
}
function toggleTop() {
toast.show(isTop.value ? '已置顶' : '已取消置顶')
async function toggleTop() {
const newValue = isTop.value
try {
await conversationApi.updateConversation({
target_id: roomId.value,
is_top: newValue
})
toast.success(newValue ? '已置顶' : '已取消置顶')
} catch (e) {
// 回滚状态
isTop.value = !newValue
toast.error('操作失败')
}
}
// 成员操作

View File

@@ -17,8 +17,16 @@
class="notify-item"
:class="{ unread: !item.is_read }"
>
<view class="notify-content">{{ item.content }}</view>
<view class="notify-time">{{ formatTime(item.created_at) }}</view>
<!-- 事件图标 -->
<view class="notify-icon" :class="getNotificationIconClass(item)">
<wd-icon :name="getNotificationIcon(item)" size="36rpx" color="#fff" />
</view>
<!-- 通知内容 -->
<view class="notify-body">
<view class="notify-content">{{ getNotificationContent(item) }}</view>
<view v-if="getEventDetails(item)" class="notify-detail">{{ getEventDetails(item) }}</view>
<view class="notify-time">{{ formatTime(item.created_at) }}</view>
</view>
</view>
<wd-loadmore :state="loadState" @loadmore="loadMore" />
@@ -35,6 +43,8 @@ interface GroupNotification {
id: number
room_id: string
content: string
message_type?: number
extra?: string | object
is_read?: boolean
created_at: string
}
@@ -45,6 +55,110 @@ const loadState = ref<'loading' | 'finished' | 'error'>('loading')
const page = ref(1)
const hasMore = ref(true)
// 获取通知图标
function getNotificationIcon(notification: GroupNotification): string {
const extra = parseExtra(notification.extra)
const event = extra.event
switch (event) {
case 'member_join':
return 'add-circle'
case 'member_leave':
return 'arrow-left'
case 'member_remove':
return 'delete'
case 'group_dissolve':
return 'close-circle'
case 'group_update':
return 'edit'
case 'role_change':
return 'user'
default:
return 'bell'
}
}
// 获取通知图标样式类
function getNotificationIconClass(notification: GroupNotification): string {
const extra = parseExtra(notification.extra)
const event = extra.event
switch (event) {
case 'member_join':
return 'icon-green'
case 'member_leave':
case 'member_remove':
return 'icon-red'
case 'group_dissolve':
return 'icon-red'
case 'group_update':
return 'icon-blue'
case 'role_change':
return 'icon-purple'
default:
return 'icon-primary'
}
}
// 获取通知内容
function getNotificationContent(notification: GroupNotification): string {
// 优先使用原有 content
if (notification.content) {
return notification.content
}
// 根据 extra 解析内容
const extra = parseExtra(notification.extra)
const event = extra.event
switch (event) {
case 'member_join':
return '新成员加入了群聊'
case 'member_leave':
return '成员退出了群聊'
case 'member_remove':
return '成员被移出群聊'
case 'group_dissolve':
return '群聊已解散'
case 'group_update':
return '群信息已更新'
case 'role_change':
return '成员角色已变更'
default:
return '群通知'
}
}
// 获取事件详情
function getEventDetails(notification: GroupNotification): string | null {
const extra = parseExtra(notification.extra)
const event = extra.event
if (['member_join', 'member_leave', 'member_remove', 'role_change'].includes(event)) {
const parts: string[] = []
if (extra.operator_name) {
parts.push(`操作者: ${extra.operator_name}`)
}
if (extra.target_name) {
parts.push(`目标: ${extra.target_name}`)
}
if (parts.length > 0) {
return parts.join(' ')
}
}
return null
}
// 解析 extra 字段
function parseExtra(extra: string | object | undefined): Record<string, any> {
if (!extra) return {}
if (typeof extra === 'string') {
try {
return JSON.parse(extra)
} catch {
return {}
}
}
return extra
}
onMounted(async () => {
await loadNotifications()
})
@@ -97,6 +211,9 @@ async function loadMore() {
}
.notify-item {
display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
@@ -104,16 +221,57 @@ async function loadMore() {
&.unread {
background: var(--bg-hover);
}
}
.notify-content {
font-size: 30rpx;
color: var(--text-primary);
margin-bottom: 12rpx;
.notify-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.icon-primary {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.notify-time {
font-size: 24rpx;
color: var(--text-tertiary);
&.icon-green {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
&.icon-red {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
&.icon-blue {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
&.icon-purple {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
}
}
.notify-body {
flex: 1;
min-width: 0;
}
.notify-content {
font-size: 30rpx;
color: var(--text-primary);
margin-bottom: 8rpx;
line-height: 1.4;
}
.notify-detail {
font-size: 24rpx;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 8rpx 16rpx;
border-radius: 8rpx;
margin-bottom: 8rpx;
}
.notify-time {
font-size: 24rpx;
color: var(--text-tertiary);
}
</style>

View File

@@ -23,10 +23,15 @@
<!-- 右侧: 加号按钮 -->
<view class="nav-right">
<view class="icon-btn-circle" @click="showPlusMenu = true">
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="add" size="44rpx" />
<!-- #endif -->
</view>
</view>
</view>
@@ -44,10 +49,15 @@
<!-- 2. 搜索栏 (扁平化设计) -->
<view class="search-wrapper">
<view class="search-bar" @click="goSearch">
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="32rpx" custom-class="search-icon-mp" />
<!-- #endif -->
<input
type="text"
class="search-input"
@@ -62,10 +72,15 @@
</view>
<view v-else-if="conversations.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="chat" size="120rpx" color="#9ca3af" custom-class="empty-icon-mp" />
<!-- #endif -->
<text class="empty-text">暂无消息</text>
</view>
@@ -109,11 +124,16 @@
</view>
<view class="row-bottom">
<text class="msg-preview text-ellipsis">{{ item.last_message || '暂无消息' }}</text>
<!-- #ifdef H5 -->
<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"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon v-if="item.is_muted" name="mute" size="28rpx" color="#9ca3af" />
<!-- #endif -->
</view>
</view>
</view>
@@ -121,15 +141,25 @@
<template #right>
<view class="swipe-actions">
<view class="action-btn top" @click.stop="toggleTop(item)">
<!-- #ifdef H5 -->
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="top" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<view class="action-btn delete" @click.stop="deleteConversation(item)">
<!-- #ifdef H5 -->
<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>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="delete" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
</view>
</template>
@@ -163,6 +193,9 @@
<wd-toast />
<wd-message-box :z-index="11000" />
<app-tab-bar current="messages" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -180,6 +213,7 @@ 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()

View File

@@ -96,8 +96,8 @@ const { isDark } = useTheme()
// 表单数据
const form = ref({
account: '',
password: '',
account: 'user003@example.com',
password: '12345678',
remember: true
})

View File

@@ -8,10 +8,15 @@
<view class="nav-right">
<view class="nav-icon-btn" @click="goNotifications">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="bell" size="44rpx" :color="iconColor" />
<!-- #endif -->
<view v-if="unreadCount > 0" class="badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
</view>
</view>
@@ -48,12 +53,17 @@
</view>
<view v-else-if="moments.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<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"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="smile" size="120rpx" color="#9ca3af" custom-class="empty-icon-mp" />
<!-- #endif -->
<text class="empty-text">暂无动态,快去发布吧</text>
</view>
@@ -103,9 +113,14 @@
<view class="feed-footer">
<text class="time">{{ formatTime(moment.created_at) }}</text>
<view class="action-btn" @click="showActionMenu(moment)">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="more" size="32rpx" />
<!-- #endif -->
</view>
</view>
@@ -115,9 +130,14 @@
<!-- 点赞列表 -->
<view v-if="moment.likes && moment.likes.length > 0" class="likes-list">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="heart-fill" size="24rpx" color="#ef4444" custom-class="heart-icon-mp" />
<!-- #endif -->
<text class="names">{{ moment.likes.map(l => l.user?.name || '').join('、') }}</text>
</view>
@@ -147,10 +167,15 @@
<!-- 悬浮发布按钮 -->
<view class="fab-btn" @click="goPublish">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="camera" size="48rpx" color="#fff" />
<!-- #endif -->
</view>
<!-- 操作菜单 -->
@@ -165,6 +190,9 @@
<wd-message-box />
<app-tab-bar current="moments" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -180,6 +208,7 @@ 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()
@@ -301,7 +330,7 @@ function getImageCount(moment: Moment): number {
function getImageSize(moment: Moment): string {
const count = getImageCount(moment)
if (count === 1) return '400rpx'
if (count === 1) return '300rpx'
if (count === 4) return '200rpx'
return '180rpx'
}

View File

@@ -89,6 +89,7 @@ import { ref, computed } from 'vue'
import { useMomentStore } from '@/stores'
import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'
import * as attachmentApi from '@/api/modules/attachment'
const momentStore = useMomentStore()
const toast = useToast()
@@ -223,11 +224,24 @@ async function publish() {
mediaType = mediaList.value[0].type === 'image' ? 1 : 2
}
// TODO: 先上传媒体文件获取 URL
// 上传媒体文件获取服务器 URL
const mediaUrls: string[] = []
for (const item of mediaList.value) {
// 这里应该调用上传接口
mediaUrls.push(item.path) // 暂时使用本地路径
if (mediaList.value.length > 0) {
toast.loading('上传中...')
for (const item of mediaList.value) {
try {
const uploadType = item.type === 'image' ? 'image' : 'video'
const attachment = await attachmentApi.uploadAttachment(item.path, uploadType)
if (attachment && attachment.file_url) {
mediaUrls.push(attachment.file_url)
}
} catch (uploadError) {
console.error('上传媒体文件失败:', uploadError)
toast.error('上传媒体文件失败')
return
}
}
toast.close()
}
await momentStore.publishMoment({
@@ -243,6 +257,7 @@ async function publish() {
}, 1000)
} catch (error) {
console.error('发布失败:', error)
toast.error('发布失败')
} finally {
publishing.value = false
}

View File

@@ -7,10 +7,15 @@
<view class="header-top">
<text class="page-title">我的</text>
<view class="icon-btn" @click="uni.navigateTo({ url: '/pages/settings/index' })">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="setting" size="44rpx" />
<!-- #endif -->
</view>
</view>
@@ -22,9 +27,14 @@
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<text class="account">账号: {{ userInfo.account || '--' }}</text>
</view>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
<!-- 数据概览 -->
@@ -49,46 +59,79 @@
<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 -->
<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"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="smile" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="menu-text">我的动态</text>
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="menu-arrow-mp" />
<!-- #endif -->
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/collection/index' })">
<view class="icon-box amber">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="star" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="menu-text">我的收藏</text>
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="menu-arrow-mp" />
<!-- #endif -->
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/album/index' })">
<view class="icon-box emerald">
<!-- #ifdef H5 -->
<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"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="picture" size="44rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="menu-text">相册</text>
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="menu-arrow-mp" />
<!-- #endif -->
</view>
</view>
</view>
<app-tab-bar current="profile" />
<!-- 全局通话组件 -->
<global-call-provider />
</view>
</wd-config-provider>
</template>
@@ -99,6 +142,7 @@ 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()

View File

@@ -3,10 +3,15 @@
<!-- 搜索栏 -->
<view class="search-header">
<view class="search-box">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="36rpx" custom-class="search-icon-mp" />
<!-- #endif -->
<input
class="search-input"
type="text"
@@ -16,9 +21,14 @@
@confirm="doSearch"
/>
<view v-if="keyword" class="clear-btn" @click="clearKeyword">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="close-circle" size="36rpx" />
<!-- #endif -->
</view>
</view>
<text class="cancel-btn" @click="goBack">取消</text>
@@ -29,10 +39,15 @@
<view class="section-header">
<text class="title">搜索历史</text>
<view class="clear" @click="clearHistory">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="delete" size="36rpx" />
<!-- #endif -->
</view>
</view>
<view class="history-tags">
@@ -47,97 +62,253 @@
</view>
</view>
<!-- 搜索结果 -->
<view v-if="keyword" class="results-section">
<!-- 搜索结果区域 -->
<view v-if="keyword && searched" class="results-section">
<!-- 分类Tab -->
<view class="search-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
<text>{{ tab.label }}</text>
<view v-if="getResultCount(tab.key) > 0" class="tab-badge">
{{ getResultCount(tab.key) }}
</view>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-state">
<wd-loading :color="isDark ? '#f97316' : '#4F46E5'" />
<text>搜索中...</text>
</view>
<view v-else-if="results.length === 0 && searched" class="empty-state">
<!-- 空结果 -->
<view v-else-if="getCurrentResults.length === 0" class="empty-state">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="search" size="120rpx" color="#9ca3af" custom-class="empty-icon-mp" />
<!-- #endif -->
<text class="empty-text">未找到相关结果</text>
</view>
<template v-else-if="results.length > 0">
<!-- 联系人结果 -->
<template v-else-if="activeTab === 'contacts'">
<view class="result-list">
<view
v-for="(item, index) in results"
:key="item.id"
v-for="(item, index) in searchResults.contacts"
:key="item.room_id"
class="result-item animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
@click="goDetail(item)"
@click="goContactDetail(item)"
>
<app-avatar
:src="item.avatar"
:name="item.name"
:src="item.user?.avatar"
:name="item.user?.name || item.remark_name"
:size="88"
radius="20rpx"
/>
<view class="result-info">
<text class="name" v-html="highlightKeyword(item.name)"></text>
<text class="desc">{{ item.desc || '暂无签名' }}</text>
<text class="name" v-html="highlightKeyword(item.remark_name || item.user?.name || '')"></text>
<text class="desc">{{ item.user?.desc || '暂无签名' }}</text>
</view>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
</view>
</template>
<!-- 群聊结果 -->
<template v-else-if="activeTab === 'groups'">
<view class="result-list">
<view
v-for="(item, index) in searchResults.groups"
:key="item.room_id"
class="result-item animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
@click="goGroupChat(item)"
>
<app-avatar
:src="item.room_avatar"
:name="item.room_name"
:size="88"
radius="20rpx"
/>
<view class="result-info">
<text class="name" v-html="highlightKeyword(item.room_name)"></text>
<text class="desc">{{ item.member_count }}人</text>
</view>
<view class="group-badge">
<!-- #ifdef H5 -->
<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"/>
<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 -->
<wd-icon name="user" size="28rpx" />
<!-- #endif -->
</view>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
</view>
</template>
<!-- 聊天记录结果 -->
<template v-else-if="activeTab === 'messages'">
<view class="result-list">
<view
v-for="(item, index) in searchResults.messages"
:key="item.id"
class="result-item message-item animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
@click="goMessage(item)"
>
<app-avatar
:src="item.sender?.avatar"
:name="item.sender?.name || item.room_name"
:size="88"
radius="20rpx"
/>
<view class="result-info">
<view class="message-header">
<text class="name">{{ item.room_name }}</text>
<text class="time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="message-sender">{{ item.sender?.name }}:</text>
<text class="message-content" v-html="highlightKeyword(item.match_content || item.content)"></text>
</view>
</view>
</view>
</template>
</view>
<!-- 未搜索时显示的搜索提示 -->
<view v-else-if="keyword && !searched" class="search-hint">
<text>按回车键搜索 "{{ keyword }}"</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { ref, computed, watch } from 'vue'
import * as searchApi from '@/api/modules/search'
import type { GlobalSearchResult, ContactSearchResult, GroupSearchResult, MessageSearchResult } from '@/api/modules/search'
import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { User } from '@/types/api'
const { isDark } = useTheme()
const keyword = ref('')
const loading = ref(false)
const searched = ref(false)
const results = ref<User[]>([])
const activeTab = ref<'contacts' | 'groups' | 'messages'>('contacts')
const history = ref<string[]>([])
// 搜索结果
const searchResults = ref<GlobalSearchResult>({
contacts: [],
groups: [],
messages: []
})
// Tab配置
const tabs = [
{ key: 'contacts' as const, label: '联系人' },
{ key: 'groups' as const, label: '群聊' },
{ key: 'messages' as const, label: '聊天记录' }
]
// 加载搜索历史
const savedHistory = uni.getStorageSync('search_history')
if (savedHistory) {
try { history.value = JSON.parse(savedHistory) } catch { history.value = [] }
}
// 监听关键词变化
// 获取各分类结果数量
function getResultCount(key: string): number {
switch (key) {
case 'contacts': return searchResults.value.contacts?.length || 0
case 'groups': return searchResults.value.groups?.length || 0
case 'messages': return searchResults.value.messages?.length || 0
default: return 0
}
}
// 获取当前Tab的结果
const getCurrentResults = computed(() => {
switch (activeTab.value) {
case 'contacts': return searchResults.value.contacts || []
case 'groups': return searchResults.value.groups || []
case 'messages': return searchResults.value.messages || []
default: return []
}
})
// 监听关键词变化 - 防抖搜索
let searchTimer: number
watch(keyword, (val) => {
clearTimeout(searchTimer)
if (val.trim()) {
searchTimer = setTimeout(() => { doSearch() }, 500) as unknown as number
} else {
results.value = []
searchResults.value = { contacts: [], groups: [], messages: [] }
searched.value = false
}
})
// 执行搜索
async function doSearch() {
if (!keyword.value.trim()) return
loading.value = true
searched.value = true
try {
results.value = await contactApi.searchUsers(keyword.value.trim())
const result = await searchApi.globalSearch(keyword.value.trim(), 'all', 50)
searchResults.value = result || { contacts: [], groups: [], messages: [] }
// 自动切换到有结果的Tab
if (result) {
if (result.contacts?.length > 0) {
activeTab.value = 'contacts'
} else if (result.groups?.length > 0) {
activeTab.value = 'groups'
} else if (result.messages?.length > 0) {
activeTab.value = 'messages'
}
}
saveHistory(keyword.value.trim())
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = { contacts: [], groups: [], messages: [] }
} finally {
loading.value = false
}
}
// 保存搜索历史
function saveHistory(word: string) {
const index = history.value.indexOf(word)
if (index > -1) { history.value.splice(index, 1) }
@@ -146,26 +317,45 @@ function saveHistory(word: string) {
uni.setStorageSync('search_history', JSON.stringify(history.value))
}
// 点击历史记录搜索
function searchHistory(word: string) {
keyword.value = word
doSearch()
}
// 清空关键词
function clearKeyword() {
keyword.value = ''
results.value = []
searchResults.value = { contacts: [], groups: [], messages: [] }
searched.value = false
}
// 清空历史
function clearHistory() {
history.value = []
uni.removeStorageSync('search_history')
}
// 返回
function goBack() { uni.navigateBack() }
function goDetail(item: User) {
uni.navigateTo({ url: `/pages/contact/detail?userId=${item.id}` })
// 跳转联系人详情
function goContactDetail(item: ContactSearchResult) {
uni.navigateTo({ url: `/pages/contact/detail?userId=${item.user?.id || ''}` })
}
// 跳转群聊
function goGroupChat(item: GroupSearchResult) {
uni.navigateTo({
url: `/pages/chat/index?roomId=${item.room_id}&name=${encodeURIComponent(item.room_name)}&isGroup=true`
})
}
// 跳转到消息位置
function goMessage(item: MessageSearchResult) {
uni.navigateTo({
url: `/pages/chat/index?roomId=${item.room_id}&name=${encodeURIComponent(item.room_name)}&isGroup=${item.is_group_chat}&messageId=${item.id}`
})
}
// 高亮匹配文字
@@ -174,6 +364,24 @@ function highlightKeyword(text: string): string {
const regex = new RegExp(`(${keyword.value})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}
// 格式化时间
function formatTime(time: string): string {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) return '昨天'
if (diffDays < 7) return `${diffDays}天前`
return date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
}
</script>
<style lang="scss" scoped>
@@ -186,6 +394,7 @@ function highlightKeyword(text: string): string {
--text-tertiary: #9ca3af;
--search-bg: #f1f1f1;
--color-brand: #4F46E5;
--border-color: #e5e7eb;
min-height: 100vh;
background: var(--bg-page);
@@ -202,6 +411,7 @@ function highlightKeyword(text: string): string {
--text-tertiary: #78716c;
--search-bg: #292524;
--color-brand: #f97316;
--border-color: #44403c;
}
// 动画
@@ -342,6 +552,56 @@ function highlightKeyword(text: string): string {
}
}
// 搜索Tab
.search-tabs {
display: flex;
padding: 0 32rpx;
gap: 48rpx;
border-bottom: 1rpx solid var(--border-color);
background: var(--bg-page);
.tab-item {
position: relative;
display: flex;
align-items: center;
gap: 8rpx;
padding: 24rpx 0;
font-size: 28rpx;
color: var(--text-secondary);
transition: all 0.2s;
&.active {
color: var(--color-brand);
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6rpx;
background: var(--color-brand);
border-radius: 3rpx;
}
}
.tab-badge {
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background: var(--color-brand);
color: #fff;
font-size: 20rpx;
font-weight: 600;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
}
// 结果区
.results-section {
flex: 1;
@@ -368,6 +628,16 @@ function highlightKeyword(text: string): string {
font-size: 28rpx;
}
// 搜索提示
.search-hint {
display: flex;
align-items: center;
justify-content: center;
padding-top: 80rpx;
color: var(--text-tertiary);
font-size: 26rpx;
}
// 结果列表
.result-list {
padding: 20rpx 32rpx;
@@ -392,6 +662,7 @@ function highlightKeyword(text: string): string {
margin-left: 24rpx;
display: flex;
flex-direction: column;
min-width: 0;
.name {
font-size: 32rpx;
@@ -408,6 +679,26 @@ function highlightKeyword(text: string): string {
font-size: 24rpx;
color: var(--text-tertiary);
margin-top: 6rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.group-badge {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: rgba(79, 70, 229, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
svg {
width: 28rpx;
height: 28rpx;
color: var(--color-brand);
}
}
@@ -417,4 +708,49 @@ function highlightKeyword(text: string): string {
color: var(--text-tertiary);
}
}
// 消息结果特殊样式
.message-item {
.result-info {
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
.name {
font-size: 28rpx;
}
.time {
font-size: 22rpx;
color: var(--text-tertiary);
}
}
.message-sender {
font-size: 24rpx;
color: var(--text-secondary);
margin-bottom: 4rpx;
}
.message-content {
font-size: 26rpx;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
:deep(.highlight) {
color: var(--color-brand);
font-weight: 600;
}
}
}
}
// 深色模式群聊图标颜色调整
.dark .group-badge {
background: rgba(249, 115, 22, 0.15);
}
</style>

View File

@@ -4,9 +4,14 @@
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<!-- #ifdef H5 -->
<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 -->
<wd-icon name="arrow-left" size="44rpx" />
<!-- #endif -->
</view>
<text class="nav-title">设置</text>
<view class="nav-placeholder"></view>
@@ -19,26 +24,46 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/account')">
<view class="icon-box blue">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="user" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">账号管理</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
<view class="setting-item" @click="goTo('/pages/settings/privacy')">
<view class="icon-box purple">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="lock" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">隐私设置</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
</view>
</view>
@@ -49,18 +74,29 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/notification')">
<view class="icon-box red">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="bell" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">新消息通知</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
<view class="setting-item" @click="goTo('/pages/settings/theme')">
<view class="icon-box indigo">
<!-- #ifdef H5 -->
<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"/>
@@ -72,25 +108,44 @@
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="photo" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">主题设置</text>
<text class="item-value">{{ themeText }}</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
<view class="setting-item" @click="handleClearCache">
<view class="icon-box green">
<!-- #ifdef H5 -->
<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"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="delete" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">清理缓存</text>
<text class="item-value">{{ cacheSize }}</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
</view>
</view>
@@ -101,28 +156,48 @@
<view class="group-card">
<view class="setting-item" @click="goTo('/pages/settings/feedback')">
<view class="icon-box orange">
<!-- #ifdef H5 -->
<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>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="chat" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">帮助与反馈</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
<view class="setting-item" @click="goTo('/pages/settings/about')">
<view class="icon-box gray">
<!-- #ifdef H5 -->
<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"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="info" size="40rpx" color="#fff" />
<!-- #endif -->
</view>
<text class="item-text">关于我们</text>
<text class="item-value">v1.0.0</text>
<!-- #ifdef H5 -->
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-mp" />
<!-- #endif -->
</view>
</view>
</view>

View File

@@ -7,3 +7,4 @@ export { useContactStore } from './contact'
export { useConversationStore } from './conversation'
export { useMomentStore } from './moment'
export { useAppStore } from './app'
export { useWebRTCStore } from './webrtc'

87
src/stores/webrtc.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* WebRTC Store - 管理 WebRTC 状态和回调
* 参考 nl-im-vue-ts/src/stores/webrtc.ts 实现
*/
import { defineStore } from 'pinia'
import { useWebRTC } from '@/composables/useWebRTC'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useConversationStore } from '@/stores/conversation'
import { storage } from '@/utils/storage'
export const useWebRTCStore = defineStore('webrtc', () => {
const authStore = useAuthStore()
const chatStore = useChatStore()
const conversationStore = useConversationStore()
// --- 回调:处理收到来电 ---
function handleIncomingCall(senderUserId: string) {
// 查找来电者的联系人信息
const contact = chatStore.contacts.find(
(c) => c.user_id === senderUserId || c.id === senderUserId || c.contact_user_id === senderUserId
)
// 在 UniApp 中,来电处理由 useWebRTC composable 内部完成
// 这里可以做额外的业务逻辑,如跳转到聊天页面
if (contact) {
console.log('📞 来电来自:', contact.remark_name || contact.user?.name)
}
}
// --- 回调:获取房间ID策略 ---
// 这个函数定义了如何找到两个人聊天的房间ID
function getRoomId(receiverUserId?: string): string {
// 策略 1: 优先从本地存储获取当前选中的房间ID (最准确)
const cachedRoomId = storage.getSelectedRoomId()
if (cachedRoomId) {
return cachedRoomId
}
// 策略 2: 如果提供了接收者ID,尝试从联系人列表中查找对应的 room_id
if (receiverUserId) {
const contact = chatStore.contacts.find(
(c) =>
c.user_id === receiverUserId ||
c.id === receiverUserId ||
c.contact_user_id === receiverUserId
)
if (contact && contact.room_id) {
return contact.room_id
}
}
// 策略 3: 尝试从会话列表中查找
if (receiverUserId) {
const conversation = conversationStore.conversations.find(
(c) => c.target_id === receiverUserId
)
if (conversation && conversation.room_id) {
return conversation.room_id
}
}
// 策略 4: 生成临时房间ID(两个用户ID排序后拼接)
if (receiverUserId && authStore.user?.id) {
return [authStore.user.id, receiverUserId].sort().join('_')
}
// 如果都找不到,返回空字符串
return ''
}
// --- 获取 WebRTC 实例 ---
// useWebRTC 是全局单例,直接返回
const webrtc = useWebRTC()
// --- 初始化监听器 ---
function initListener() {
webrtc.initListener()
}
return {
webrtc,
getRoomId,
handleIncomingCall,
initListener,
}
})