diff --git a/components.d.ts b/components.d.ts index 4acaedd..b9d0628 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] } } diff --git a/src/api/index.ts b/src/api/index.ts index 5dae98c..90bf4b5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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' diff --git a/src/api/modules/call.ts b/src/api/modules/call.ts new file mode 100644 index 0000000..d182b98 --- /dev/null +++ b/src/api/modules/call.ts @@ -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 { + return request.get(`/call/room/${roomId}`) +} + +/** + * 加入通话房间 + */ +export function joinCallRoom(data: { + room_id: string + user_id: string + platform: 'h5' | 'app' | 'miniprogram' +}): Promise { + 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 } }) +} + diff --git a/src/api/modules/search.ts b/src/api/modules/search.ts new file mode 100644 index 0000000..4fc15eb --- /dev/null +++ b/src/api/modules/search.ts @@ -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('/search', { keyword, type, limit }) +} + +/** + * 搜索联系人 + * @param keyword 搜索关键词 + * @param limit 最大数量,默认20 + */ +export function searchContacts(keyword: string, limit: number = 20) { + return request.get('/search', { keyword, type: 'contacts', limit }) +} + +/** + * 搜索群聊 + * @param keyword 搜索关键词 + * @param limit 最大数量,默认20 + */ +export function searchGroups(keyword: string, limit: number = 20) { + return request.get('/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('/search', { + keyword, + type: 'messages', + room_id: roomId, + limit + }) +} + diff --git a/src/api/modules/user.ts b/src/api/modules/user.ts index a3cb5f7..b17b03f 100644 --- a/src/api/modules/user.ts +++ b/src/api/modules/user.ts @@ -9,6 +9,11 @@ export function getMyInfo() { return request.get('/user/my-info') } +// 获取指定用户信息 +export function getUserInfo(userId: string) { + return request.get(`/user/${userId}`) +} + // 获取用户列表 export function getUserList(page = 1, pageSize = 20) { return request.get>('/user/list', { diff --git a/src/api/request/index.ts b/src/api/request/index.ts index d599186..6099ce0 100644 --- a/src/api/request/index.ts +++ b/src/api/request/index.ts @@ -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 diff --git a/src/api/websocket/index.ts b/src/api/websocket/index.ts index 9a69603..00f6d79 100644 --- a/src/api/websocket/index.ts +++ b/src/api/websocket/index.ts @@ -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, diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue index 7ff3caa..dcfde67 100644 --- a/src/components/call/CallWindow.vue +++ b/src/components/call/CallWindow.vue @@ -157,8 +157,28 @@ - 当前平台暂不支持音视频通话 - 关闭 + + + + + + + + + 暂不支持音视频通话 + 当前平台暂不支持音视频通话功能 + 请使用网页版体验完整功能 + + + + 复制网页链接 + + + 关闭 + + + + @@ -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(null) const remoteVideoRef = ref(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; } diff --git a/src/components/call/GlobalCallProvider.vue b/src/components/call/GlobalCallProvider.vue index a13ffa7..c6c7ca8 100644 --- a/src/components/call/GlobalCallProvider.vue +++ b/src/components/call/GlobalCallProvider.vue @@ -1,20 +1,27 @@ @@ -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() diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue index 6eb5a01..b253f9d 100644 --- a/src/pages/login/index.vue +++ b/src/pages/login/index.vue @@ -96,8 +96,8 @@ const { isDark } = useTheme() // 表单数据 const form = ref({ - account: '', - password: '', + account: 'user003@example.com', + password: '12345678', remember: true }) diff --git a/src/pages/moment/index.vue b/src/pages/moment/index.vue index b5d3e18..51f7433 100644 --- a/src/pages/moment/index.vue +++ b/src/pages/moment/index.vue @@ -8,10 +8,15 @@ + + + + + {{ unreadCount > 99 ? '99+' : unreadCount }} @@ -48,12 +53,17 @@ + + + + + 暂无动态,快去发布吧 @@ -103,9 +113,14 @@ {{ formatTime(moment.created_at) }} + + + + + @@ -115,9 +130,14 @@ @@ -147,10 +167,15 @@ + + + + + @@ -165,6 +190,9 @@ + + + @@ -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' } diff --git a/src/pages/moment/publish.vue b/src/pages/moment/publish.vue index 87f3a03..5541c35 100644 --- a/src/pages/moment/publish.vue +++ b/src/pages/moment/publish.vue @@ -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 } diff --git a/src/pages/profile/index.vue b/src/pages/profile/index.vue index 28ef615..a162afe 100644 --- a/src/pages/profile/index.vue +++ b/src/pages/profile/index.vue @@ -7,10 +7,15 @@ 我的 + + + + + @@ -22,9 +27,14 @@ {{ userInfo.nickname || '未设置昵称' }} + + + + + @@ -49,46 +59,79 @@ + + + + + 我的动态 + + + + + + + + + + 我的收藏 + + + + + + + + + + 相册 + + + + + + + + @@ -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() diff --git a/src/pages/search/index.vue b/src/pages/search/index.vue index 2790964..2bf06fa 100644 --- a/src/pages/search/index.vue +++ b/src/pages/search/index.vue @@ -3,10 +3,15 @@ + + + + + + + + + + 取消 @@ -29,10 +39,15 @@ 搜索历史 + + + + + @@ -47,97 +62,253 @@ - - + + + + + + {{ tab.label }} + + {{ getResultCount(tab.key) }} + + + + + 搜索中... - + + + + + + + 未找到相关结果 - diff --git a/src/pages/settings/index.vue b/src/pages/settings/index.vue index 30ff53e..e5ed65f 100644 --- a/src/pages/settings/index.vue +++ b/src/pages/settings/index.vue @@ -4,9 +4,14 @@ + + + + + 设置 @@ -19,26 +24,46 @@ + + + + + 账号管理 + + + + + + + + + + 隐私设置 + + + + + @@ -49,18 +74,29 @@ + + + + + 新消息通知 + + + + + + @@ -72,25 +108,44 @@ + + + + 主题设置 {{ themeText }} + + + + + + + + + + 清理缓存 {{ cacheSize }} + + + + + @@ -101,28 +156,48 @@ + + + + + 帮助与反馈 + + + + + + + + + + 关于我们 v1.0.0 + + + + + diff --git a/src/stores/index.ts b/src/stores/index.ts index 76d36d2..6702a91 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -7,3 +7,4 @@ export { useContactStore } from './contact' export { useConversationStore } from './conversation' export { useMomentStore } from './moment' export { useAppStore } from './app' +export { useWebRTCStore } from './webrtc' diff --git a/src/stores/webrtc.ts b/src/stores/webrtc.ts new file mode 100644 index 0000000..5150f5b --- /dev/null +++ b/src/stores/webrtc.ts @@ -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, + } +}) +