适配微信小程序
This commit is contained in:
27
components.d.ts
vendored
27
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
115
src/api/modules/call.ts
Normal 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
88
src/api/modules/search.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
448
src/components/call/MiniProgramCallWindow.vue
Normal file
448
src/components/call/MiniProgramCallWindow.vue
Normal 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>
|
||||
|
||||
45
src/components/call/UnifiedCallWindow.vue
Normal file
45
src/components/call/UnifiedCallWindow.vue
Normal 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
104
src/components/moment/ActionMenu.vue
Normal file
104
src/components/moment/ActionMenu.vue
Normal 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>
|
||||
|
||||
111
src/components/moment/CommentInput.vue
Normal file
111
src/components/moment/CommentInput.vue
Normal 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>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { useTheme } from './useTheme'
|
||||
export { useWebRTC } from './useWebRTC'
|
||||
export { useGroupWebRTC } from './useGroupWebRTC'
|
||||
export { useMiniProgramCall } from './useMiniProgramCall'
|
||||
|
||||
|
||||
|
||||
|
||||
553
src/composables/useMiniProgramCall.ts
Normal file
553
src/composables/useMiniProgramCall.ts
Normal 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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"appid": "wx54452d2c36bec8ee",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 成员操作
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -96,8 +96,8 @@ const { isDark } = useTheme()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
account: '',
|
||||
password: '',
|
||||
account: 'user003@example.com',
|
||||
password: '12345678',
|
||||
remember: true
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
87
src/stores/webrtc.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user