diff --git a/WebApp/client/public/onebyone/api.js b/WebApp/client/public/onebyone/api.js new file mode 100644 index 0000000..29a6e93 --- /dev/null +++ b/WebApp/client/public/onebyone/api.js @@ -0,0 +1,155 @@ +/** + * API客户端 + * 封装所有API调用,处理HTTP请求 + */ + +class ApiClient { + constructor(baseUrl = '') { + this.baseUrl = baseUrl || location.origin; + } + + /** + * 获取通话信息 + * @param {string} callId - 通话ID + * @returns {Promise} 通话信息 + */ + async getCallInfo(callId) { + try { + const response = await fetch(`${this.baseUrl}/api/call/${callId}`); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error getting call info:', error); + throw error; + } + } + + /** + * 加入通话 + * @param {string} callId - 通话ID + * @returns {Promise} 加入结果 + */ + async joinCall(callId) { + try { + const response = await fetch(`${this.baseUrl}/api/call/${callId}/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error joining call:', error); + throw error; + } + } + + /** + * 离开通话 + * @param {string} callId - 通话ID + * @returns {Promise} 离开结果 + */ + async leaveCall(callId) { + try { + const response = await fetch(`${this.baseUrl}/api/call/${callId}/leave`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error leaving call:', error); + throw error; + } + } + + /** + * 更新媒体状态 + * @param {string} callId - 通话ID + * @param {Object} mediaState - 媒体状态 {audio?: boolean, video?: boolean} + * @returns {Promise} 更新结果 + */ + async updateMediaState(callId, mediaState) { + try { + const response = await fetch(`${this.baseUrl}/api/call/${callId}/media`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(mediaState) + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error updating media state:', error); + throw error; + } + } + + /** + * 获取历史消息 + * @param {string} callId - 通话ID + * @param {number} limit - 消息数量限制 + * @param {string} before - 时间戳 + * @returns {Promise} 消息列表 + */ + async getMessages(callId, limit = 50, before = null) { + try { + let url = `${this.baseUrl}/api/call/${callId}/messages?limit=${limit}`; + if (before) { + url += `&before=${before}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error getting messages:', error); + throw error; + } + } + + /** + * 发送消息 + * @param {string} callId - 通话ID + * @param {string} content - 消息内容 + * @param {string} type - 消息类型 + * @returns {Promise} 发送结果 + */ + async sendMessage(callId, content, type = 'text') { + try { + const response = await fetch(`${this.baseUrl}/api/call/${callId}/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ content, type }) + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Error sending message:', error); + throw error; + } + } +} + +// 创建单例实例 +const apiClient = new ApiClient(); + +export default apiClient; diff --git a/WebApp/client/public/onebyone/index.html b/WebApp/client/public/onebyone/index.html new file mode 100644 index 0000000..70aa3e9 --- /dev/null +++ b/WebApp/client/public/onebyone/index.html @@ -0,0 +1,509 @@ + + + + + + VideoCall - 一对一视频通话 + + + + + + + + + +
+
+
+ +
+
+ +

+ 与 Sarah 的通话 +

+
+ + + 00:00 +
+
+
+ +
+ + + + +
+
+ +
+ + +
+ + +
+ + + 对方视频 + + +
+
+ + + + + +
+
+ +
Sarah Chen
+
+ + 正在通话 + + + +
+
+
+ + + + + + + + +
+ + +
+ + + 本地视频 + + + + +
+ + + +
+ + +
+ +
+
+ +
+ + + + +
+ + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ +
+ + +
+ + 通知内容 +
+ + + + + + + + + + diff --git a/WebApp/client/public/onebyone/index1.html b/WebApp/client/public/onebyone/index1.html deleted file mode 100644 index d858fda..0000000 --- a/WebApp/client/public/onebyone/index1.html +++ /dev/null @@ -1,1278 +0,0 @@ - - - - - - VideoCall - 一对一视频通话 - - - - - - - - - -
-
-
- -
-
- -

- 与 Sarah 的通话 -

-
- - - 00:00 -
-
-
- -
- - - - -
-
- -
- - -
- - -
- - - 对方视频 - - -
-
- - - - - -
-
- -
Sarah Chen
-
- - 正在通话 - - - -
-
-
- - - - - - - - -
- - -
- - - 本地视频 - - - - -
- - - -
- - -
- -
-
- -
- - - - -
- - -
- - - - - -
- - - - - - - - - - - - - - - - - - - -
- - -
- -
- -
- - -
- - 通知内容 -
- - - - - diff --git a/WebApp/client/public/onebyone/main.js b/WebApp/client/public/onebyone/main.js new file mode 100644 index 0000000..7ee1ec4 --- /dev/null +++ b/WebApp/client/public/onebyone/main.js @@ -0,0 +1,215 @@ +/** + * 主入口文件 + * 初始化应用,连接各个模块 + */ +import store from './store.js'; +import UIRenderer from './renderer.js'; +import apiClient from './api.js'; +import wsManager from './websocket.js'; +import { showNotification, generateId } from './utils.js'; + +// 全局变量 +let renderer = null; + +/** + * 初始化应用 + */ +function initApp() { + // 初始化渲染器 + renderer = new UIRenderer(store); + + // 初始化WebSocket连接 + wsManager.connect(); + + // 绑定WebSocket事件 + bindWebSocketEvents(); + + // 绑定DOM事件 + bindDomEvents(); + + // 初始化WebRTC (如果需要) + // initWebRTC(); + + console.log('App initialized'); +} + +/** + * 绑定WebSocket事件 + */ +function bindWebSocketEvents() { + wsManager.on('connect', () => { + console.log('WebSocket connected'); + showNotification('已连接到服务器'); + }); + + wsManager.on('disconnect', () => { + console.log('WebSocket disconnected'); + showNotification('与服务器的连接已断开', 5000); + }); + + wsManager.on('message-received', (data) => { + console.log('Message received:', data); + store.addMessage(data.message); + }); + + wsManager.on('user-joined', (data) => { + console.log('User joined:', data); + showNotification(`${data.userId} 加入了通话`); + }); + + wsManager.on('user-left', (data) => { + console.log('User left:', data); + showNotification(`${data.userId} 离开了通话`); + }); + + wsManager.on('media-state-changed', (data) => { + console.log('Media state changed:', data); + // 更新远端媒体状态 + if (data.userId !== store.getLocalUser().id) { + store.updateRemoteMedia(data); + } + }); + + wsManager.on('network-quality', (data) => { + console.log('Network quality changed:', data); + // 更新网络质量 + const state = store.getState(); + if (data.userId === state.session.remoteUser.id) { + state.session.remoteUser.networkQuality = data.quality; + store.notify({ type: 'NETWORK_CHANGE', quality: data.quality }); + } + }); + + wsManager.on('call-ended', (data) => { + console.log('Call ended:', data); + store.endCall(); + showNotification('通话已结束', 3000); + }); +} + +/** + * 绑定DOM事件 + */ +function bindDomEvents() { + // 切换侧边栏 + window.toggleSidebar = function() { + store.toggleSidebar(); + }; + + // 切换麦克风 + window.toggleMute = function(button) { + const state = store.getState(); + const currentState = state.session.localUser.mediaState.audio; + store.updateLocalMedia('audio', !currentState); + }; + + // 切换视频 + window.toggleVideo = function(button) { + const state = store.getState(); + const currentState = state.session.localUser.mediaState.video; + store.updateLocalMedia('video', !currentState); + }; + + // 切换本地视频(用于悬停控制) + window.toggleLocalVideo = function() { + window.toggleVideo(); + }; + + // 切换录屏 + window.toggleRecording = function(button) { + const state = store.getState(); + const currentState = state.session.localUser.mediaState.recording || false; + store.updateLocalMedia('recording', !currentState); + + // 显示录制状态通知 + if (!currentState) { + showNotification('开始录制'); + } else { + showNotification('停止录制'); + } + }; + + // 结束通话 + window.endCall = function() { + // 显示确认对话框 + document.getElementById('endCallDialog').classList.remove('hidden'); + }; + + // 取消结束通话 + window.cancelEndCall = function() { + document.getElementById('endCallDialog').classList.add('hidden'); + }; + + // 确认结束通话 + window.confirmEndCall = function() { + document.getElementById('endCallDialog').classList.add('hidden'); + store.endCall(); + showNotification('通话已结束'); + }; + + // 发送消息 + window.sendMessage = function() { + const chatInput = document.getElementById('chatInput'); + const content = chatInput.value.trim(); + + if (content) { + const state = store.getState(); + const newMessage = { + id: generateId(), + senderId: state.session.localUser.id, + senderName: state.session.localUser.name, + senderAvatar: state.session.localUser.avatar, + content: content, + type: 'text', + timestamp: new Date().toISOString(), + isSelf: true + }; + + store.addMessage(newMessage); + chatInput.value = ''; + + // 发送消息到服务器 + // wsManager.send('send-message', newMessage); + } + }; + + // 处理聊天输入回车 + window.handleChatSubmit = function(event) { + if (event.key === 'Enter') { + window.sendMessage(); + } + }; + + // 键盘快捷键 + document.addEventListener('keydown', (event) => { + // 空格键静音 + if (event.code === 'Space' && !event.target.matches('input, textarea')) { + event.preventDefault(); + window.toggleMute(); + } + + // Ctrl+V 切换视频 + if (event.ctrlKey && event.key === 'v') { + event.preventDefault(); + window.toggleVideo(); + } + }); + + // 绑定对话框事件 + document.getElementById('cancelEndCall').addEventListener('click', window.cancelEndCall); + document.getElementById('confirmEndCall').addEventListener('click', window.confirmEndCall); +} + +/** + * 初始化WebRTC + */ +function initWebRTC() { + // 这里可以添加WebRTC初始化代码 + console.log('Initializing WebRTC...'); +} + +// 页面加载完成后初始化应用 +document.addEventListener('DOMContentLoaded', initApp); + +// 导出全局变量 +export { store, renderer, apiClient, wsManager }; diff --git a/WebApp/client/public/onebyone/models.js b/WebApp/client/public/onebyone/models.js new file mode 100644 index 0000000..b0d012d --- /dev/null +++ b/WebApp/client/public/onebyone/models.js @@ -0,0 +1,132 @@ +/** + * 类型定义和数据模型 + */ + +/** + * @typedef {Object} CallSession + * @property {string} id - 通话唯一标识 (UUID) + * @property {'video'|'audio'} type - 通话类型 + * @property {'connecting'|'ongoing'|'ended'|'failed'} status - 通话状态 + * @property {string} startTime - ISO 8601 时间戳 + * @property {number} duration - 已进行秒数 + * @property {boolean} isEncrypted - 是否启用端到端加密 + * @property {LocalUser} localUser - 本地用户信息 + * @property {RemoteUser} remoteUser - 远端用户信息 + */ + +/** + * @typedef {Object} LocalUser + * @property {string} id - 用户ID + * @property {string} name - 显示名称 + * @property {string} avatar - 头像URL + * @property {boolean} isHost - 是否主持人 + * @property {MediaState} mediaState - 媒体状态 + */ + +/** + * @typedef {Object} RemoteUser + * @property {string} id - 用户ID + * @property {string} name - 显示名称 + * @property {string} avatar - 头像URL + * @property {'online'|'offline'|'connecting'} status - 在线状态 + * @property {MediaState} mediaState - 媒体状态 + * @property {'excellent'|'good'|'fair'|'poor'} networkQuality - 网络质量 + */ + +/** + * @typedef {Object} MediaState + * @property {boolean} audio - 音频是否开启 + * @property {boolean} video - 视频是否开启 + * @property {boolean} screenShare - 是否屏幕共享 + * @property {boolean} recording - 是否正在录屏 + * @property {boolean} isSpeaking - 是否正在说话(VAD) + */ + +/** + * @typedef {Object} ChatMessage + * @property {string} id - 消息唯一ID + * @property {string} senderId - 发送者ID + * @property {string} senderName - 发送者名称 + * @property {string} senderAvatar - 发送者头像URL + * @property {string} content - 消息内容 + * @property {'text'|'file'|'system'} type - 消息类型 + * @property {string} timestamp - ISO 8601 时间戳 + * @property {boolean} isSelf - 是否为自己发送 + */ + +// 模拟通话会话数据 +const mockCallSession = { + id: "call-8842-2024-001", + type: "video", + status: "ongoing", // connecting | ongoing | ended | failed + startTime: "2024-01-15T14:30:00.000Z", + duration: 0, // 秒数,后端可不返回,前端本地计算 + isEncrypted: true, + + // 本地用户信息 + localUser: { + id: "user-local-001", + name: "我", + avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop", + isHost: true, + mediaState: { + audio: true, + video: true, + screenShare: false, + recording: false, + isSpeaking: false + } + }, + + // 远端用户信息 + remoteUser: { + id: "user-remote-002", + name: "Sarah Chen", + avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop", + status: "online", // online | offline | connecting + networkQuality: "excellent", // excellent | good | fair | poor + mediaState: { + audio: true, + video: true, + screenShare: false, + recording: false, + isSpeaking: false + } + } +}; + +// 模拟聊天消息数据 +const mockMessages = [ + { + id: "msg-001", + senderId: "system", + senderName: "系统", + senderAvatar: "https://via.placeholder.com/100", + content: "通话已建立连接", + type: "system", + timestamp: "2024-01-15T14:30:00.000Z", + isSelf: false + }, + { + id: "msg-002", + senderId: "user-remote-002", + senderName: "Sarah Chen", + senderAvatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop", + content: "嗨,能听到我说话吗?", + type: "text", + timestamp: "2024-01-15T14:32:15.000Z", + isSelf: false + }, + { + id: "msg-003", + senderId: "user-local-001", + senderName: "我", + senderAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop", + content: "很清楚!你的画面也很清晰 👍", + type: "text", + timestamp: "2024-01-15T14:32:45.000Z", + isSelf: true + } +]; + +export { mockCallSession, mockMessages }; diff --git a/WebApp/client/public/onebyone/renderer.js b/WebApp/client/public/onebyone/renderer.js new file mode 100644 index 0000000..fffc1a3 --- /dev/null +++ b/WebApp/client/public/onebyone/renderer.js @@ -0,0 +1,293 @@ +/** + * UI渲染器 + * 负责将状态映射到DOM,与状态管理解耦 + */ +import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js'; + +class UIRenderer { + constructor(stateManager) { + this.stateManager = stateManager; + this.unsubscribe = stateManager.subscribe(this.render.bind(this)); + + // 缓存 DOM 元素 + this.elements = { + // 头部 + headerTitle: document.getElementById('headerTitle'), + callDuration: document.getElementById('callDuration'), + encryptionBadge: document.getElementById('encryptionBadge'), + unreadBadge: document.getElementById('unreadBadge'), + + // 远端视频 + remoteVideo: document.getElementById('remoteVideo'), + remoteAvatar: document.getElementById('remoteAvatar'), + remoteName: document.getElementById('remoteName'), + remoteStatus: document.getElementById('remoteStatus'), + remoteSpeakingIndicator: document.getElementById('remoteSpeakingIndicator'), + remoteAudioWave: document.getElementById('remoteAudioWave'), + networkStatus: document.getElementById('networkStatus'), + networkStatusText: document.getElementById('networkStatusText'), + connectingOverlay: document.getElementById('connectingOverlay'), + + // 本地视频 + localVideo: document.getElementById('localVideo'), + localVideoPlaceholder: document.getElementById('localVideoPlaceholder'), + localAudioWave: document.getElementById('localAudioWave'), + localInitials: document.getElementById('localInitials'), + + // 侧边栏 + sidebar: document.getElementById('sidebar'), + chatContent: document.getElementById('chatContent'), + userList: document.getElementById('userList'), + + // 控制按钮 + micBtn: document.getElementById('micBtn'), + videoBtn: document.getElementById('videoBtn'), + recordBtn: document.getElementById('recordBtn'), + connectionQuality: document.getElementById('connectionQuality') + }; + + // 初始化渲染 + this.render(this.stateManager.getState(), { type: 'INIT' }); + } + + // 渲染状态变化 + render(state, changes) { + switch (changes.type) { + case 'INIT': + this.renderHeader(state.session); + this.renderRemoteVideo(state.session.remoteUser); + this.renderLocalVideo(state.session.localUser); + this.renderControlButtons(state.session.localUser.mediaState); + this.renderChatMessages(state.messages); + break; + case 'DURATION_UPDATE': + this.renderCallDuration(changes.duration); + break; + case 'LOCAL_MEDIA_CHANGE': + this.renderControlButtons(state.session.localUser.mediaState); + this.renderLocalVideo(state.session.localUser); + break; + case 'REMOTE_MEDIA_CHANGE': + this.renderRemoteVideo(state.session.remoteUser); + break; + case 'NEW_MESSAGE': + this.renderChatMessages(state.messages); + this.renderUnreadCount(changes.unreadCount); + break; + case 'SIDEBAR_TOGGLE': + this.renderSidebar(changes.isOpen); + break; + case 'NETWORK_CHANGE': + this.renderNetworkStatus(changes.quality); + break; + case 'CALL_ENDED': + this.renderCallEnded(); + break; + } + } + + // 渲染头部 + renderHeader(session) { + if (this.elements.headerTitle) { + this.elements.headerTitle.textContent = `与 ${session.remoteUser.name} 的通话`; + } + + if (this.elements.encryptionBadge) { + toggleElement(this.elements.encryptionBadge, session.isEncrypted); + } + + this.renderCallDuration(session.duration); + } + + // 渲染通话时长 + renderCallDuration(duration) { + if (this.elements.callDuration) { + this.elements.callDuration.textContent = formatTime(duration); + } + } + + // 渲染远端视频 + renderRemoteVideo(remoteUser) { + if (this.elements.remoteName) { + this.elements.remoteName.textContent = remoteUser.name; + } + + if (this.elements.remoteAvatar) { + this.elements.remoteAvatar.src = remoteUser.avatar; + } + + if (this.elements.remoteStatus) { + this.elements.remoteStatus.textContent = this.getStatusText(remoteUser.status); + } + + // 渲染说话状态 + if (this.elements.remoteSpeakingIndicator) { + toggleElement(this.elements.remoteSpeakingIndicator, remoteUser.mediaState.isSpeaking); + } + + if (this.elements.remoteAudioWave) { + toggleElement(this.elements.remoteAudioWave, remoteUser.mediaState.isSpeaking && remoteUser.mediaState.audio); + } + + // 渲染网络状态 + this.renderNetworkStatus(remoteUser.networkQuality); + } + + // 渲染本地视频 + renderLocalVideo(localUser) { + if (this.elements.localVideoPlaceholder) { + toggleElement(this.elements.localVideoPlaceholder, !localUser.mediaState.video); + } + + if (this.elements.localAudioWave) { + toggleElement(this.elements.localAudioWave, localUser.mediaState.isSpeaking); + } + } + + // 渲染控制按钮 + renderControlButtons(mediaState) { + if (this.elements.micBtn) { + toggleButtonState(this.elements.micBtn, !mediaState.audio); + } + + if (this.elements.videoBtn) { + toggleButtonState(this.elements.videoBtn, !mediaState.video); + } + + if (this.elements.recordBtn) { + toggleButtonState(this.elements.recordBtn, mediaState.recording); + } + } + + // 渲染聊天消息 + renderChatMessages(messages) { + if (!this.elements.chatContent) return; + + // 清空聊天内容 + this.elements.chatContent.innerHTML = ''; + + // 添加通话开始时间 + const startTimeElement = document.createElement('div'); + startTimeElement.className = 'text-center text-xs text-gray-500 my-4'; + const startTime = messages[0]?.timestamp || new Date().toISOString(); + startTimeElement.textContent = `通话开始 ${formatTimestamp(startTime)}`; + this.elements.chatContent.appendChild(startTimeElement); + + // 添加消息 + messages.forEach(message => { + const messageElement = this.createMessageElement(message); + this.elements.chatContent.appendChild(messageElement); + }); + + // 滚动到底部 + this.elements.chatContent.scrollTop = this.elements.chatContent.scrollHeight; + } + + // 创建消息元素 + createMessageElement(message) { + const messageDiv = document.createElement('div'); + + // 根据消息类型设置不同的CSS类 + let messageClass = 'chat-bubble'; + if (message.type === 'system') { + messageClass += ' message-system'; + } else if (message.isSelf) { + messageClass += ' message-self'; + } else { + messageClass += ' message-other'; + } + + messageDiv.className = messageClass; + messageDiv.dataset.messageId = message.id; + + messageDiv.innerHTML = ` +
+ +
+ ${message.senderName} + ${formatTimestamp(message.timestamp)} +
+
+
+ ${message.content} +
+ `; + + return messageDiv; + } + + // 渲染未读消息数 + renderUnreadCount(count) { + if (this.elements.unreadBadge) { + if (count > 0) { + this.elements.unreadBadge.textContent = count; + this.elements.unreadBadge.classList.remove('hidden'); + } else { + this.elements.unreadBadge.classList.add('hidden'); + } + } + } + + // 渲染侧边栏 + renderSidebar(isOpen) { + if (this.elements.sidebar) { + if (isOpen) { + this.elements.sidebar.classList.remove('hidden'); + } else { + this.elements.sidebar.classList.add('hidden'); + } + } + } + + // 渲染网络状态 + renderNetworkStatus(quality) { + if (this.elements.networkStatus && this.elements.networkStatusText) { + const showNetworkStatus = quality !== 'excellent'; + toggleElement(this.elements.networkStatus, showNetworkStatus); + + if (showNetworkStatus) { + this.elements.networkStatusText.textContent = this.getNetworkQualityText(quality); + } + } + + if (this.elements.connectionQuality) { + this.elements.connectionQuality.textContent = `连接质量: ${this.getNetworkQualityText(quality)}`; + } + } + + // 渲染通话结束 + renderCallEnded() { + // 可以在这里添加通话结束的UI处理 + console.log('Call ended'); + } + + // 获取状态文本 + getStatusText(status) { + const statusMap = { + 'online': '在线', + 'offline': '离线', + 'connecting': '连接中' + }; + return statusMap[status] || status; + } + + // 获取网络质量文本 + getNetworkQualityText(quality) { + const qualityMap = { + 'excellent': '优秀', + 'good': '良好', + 'fair': '一般', + 'poor': '较差' + }; + return qualityMap[quality] || quality; + } + + // 销毁 + destroy() { + if (this.unsubscribe) { + this.unsubscribe(); + } + } +} + +export default UIRenderer; diff --git a/WebApp/client/public/onebyone/script.js b/WebApp/client/public/onebyone/script.js deleted file mode 100644 index 38b3d6e..0000000 --- a/WebApp/client/public/onebyone/script.js +++ /dev/null @@ -1,703 +0,0 @@ -/** - * ========================================== - * 1. 类型定义 (Type Definitions) - * 后端可参考此部分设计数据库模型和 API 响应格式 - * ========================================== - */ - -/** - * @typedef {Object} CallSession - * @property {string} id - 通话唯一标识 (UUID) - * @property {'video'|'audio'} type - 通话类型 - * @property {'connecting'|'ongoing'|'ended'|'failed'} status - 通话状态 - * @property {string} startTime - ISO 8601 时间戳 - * @property {number} duration - 已进行秒数 - * @property {boolean} isEncrypted - 是否启用端到端加密 - * @property {LocalUser} localUser - 本地用户信息 - * @property {RemoteUser} remoteUser - 远端用户信息 - */ - -/** - * @typedef {Object} LocalUser - * @property {string} id - 用户ID - * @property {string} name - 显示名称 - * @property {string} avatar - 头像URL - * @property {boolean} isHost - 是否主持人 - * @property {MediaState} mediaState - 媒体状态 - */ - -/** - * @typedef {Object} RemoteUser - * @property {string} id - 用户ID - * @property {string} name - 显示名称 - * @property {string} avatar - 头像URL - * @property {'online'|'offline'|'connecting'} status - 在线状态 - * @property {MediaState} mediaState - 媒体状态 - * @property {'excellent'|'good'|'fair'|'poor'} networkQuality - 网络质量 - */ - -/** - * @typedef {Object} MediaState - * @property {boolean} audio - 音频是否开启 - * @property {boolean} video - 视频是否开启 - * @property {boolean} screenShare - 是否屏幕共享 - * @property {boolean} isSpeaking - 是否正在说话(VAD) - */ - -/** - * @typedef {Object} ChatMessage - * @property {string} id - 消息唯一ID - * @property {string} senderId - 发送者ID - * @property {string} senderName - 发送者名称 - * @property {string} senderAvatar - 发送者头像URL - * @property {string} content - 消息内容 - * @property {'text'|'file'|'system'} type - 消息类型 - * @property {string} timestamp - ISO 8601 时间戳 - * @property {boolean} isSelf - 是否为自己发送 - */ - -/** - * ========================================== - * 2. 模拟数据层 (Mock Data Layer) - * 后端接口返回的数据格式应与此结构一致 - * ========================================== - */ - -// [API_RESPONSE: GET /api/call/:callId] -const mockCallSession = { - id: "call-8842-2024-001", - type: "video", - status: "ongoing", // connecting | ongoing | ended | failed - startTime: "2024-01-15T14:30:00.000Z", - duration: 0, // 秒数,后端可不返回,前端本地计算 - isEncrypted: true, - - // [API_RESPONSE: 嵌套用户信息] - localUser: { - id: "user-local-001", - name: "我", - avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop", - isHost: true, - mediaState: { - audio: true, - video: true, - screenShare: false, - isSpeaking: false - } - }, - - remoteUser: { - id: "user-remote-002", - name: "Sarah Chen", - avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop", - status: "online", // online | offline | connecting - networkQuality: "excellent", // excellent | good | fair | poor - mediaState: { - audio: true, - video: true, - screenShare: false, - isSpeaking: false - } - } -}; - -// [API_RESPONSE: GET /api/call/:callId/messages] -const mockMessages = [ - { - id: "msg-001", - senderId: "system", - senderName: "系统", - senderAvatar: "/assets/system.png", - content: "通话已建立连接", - type: "system", - timestamp: "2024-01-15T14:30:00.000Z", - isSelf: false - }, - { - id: "msg-002", - senderId: "user-remote-002", - senderName: "Sarah Chen", - senderAvatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop", - content: "嗨,能听到我说话吗?", - type: "text", - timestamp: "2024-01-15T14:32:15.000Z", - isSelf: false - }, - { - id: "msg-003", - senderId: "user-local-001", - senderName: "我", - senderAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop", - content: "很清楚!你的画面也很清晰 👍", - type: "text", - timestamp: "2024-01-15T14:32:45.000Z", - isSelf: true - } -]; - -/** - * ========================================== - * 3. 状态管理 (State Management) - * 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia - * ========================================== - */ - -class CallStateManager { - constructor() { - // 核心状态 - this.state = { - session: { ...mockCallSession }, - messages: [...mockMessages], - isSidebarOpen: false, - unreadCount: 0, - localStream: null, // MediaStream 对象 - remoteStream: null // MediaStream 对象 - }; - - // 监听器数组 - this.listeners = []; - - // 初始化 - this.init(); - } - - // 订阅状态变化 - subscribe(callback) { - this.listeners.push(callback); - return () => { - this.listeners = this.listeners.filter(cb => cb !== callback); - }; - } - - // 通知所有监听器 - notify(changes) { - this.listeners.forEach(cb => cb(this.state, changes)); - } - - // 初始化 - init() { - // 启动通话时长计时器 - this.durationInterval = setInterval(() => { - this.state.session.duration++; - this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); - }, 1000); - - // 模拟远端音频活动 (实际应由 WebRTC VAD 检测触发) - this.simulateRemoteActivity(); - } - - // 更新本地媒体状态 - updateLocalMedia(mediaType, value) { - this.state.session.localUser.mediaState[mediaType] = value; - this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); - - // [API_CALL: POST /api/call/:callId/media] - // [WEBSOCKET_EMIT: media-state-changed] - this.emitMediaStateChange(); - } - - // 更新远端媒体状态 (由 WebSocket 消息触发) - updateRemoteMedia(mediaState) { - this.state.session.remoteUser.mediaState = { - ...this.state.session.remoteUser.mediaState, - ...mediaState - }; - this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState }); - } - - // 添加消息 - addMessage(message) { - this.state.messages.push(message); - - // 如果侧边栏关闭且不是自己发的,增加未读 - if (!this.state.isSidebarOpen && !message.isSelf) { - this.state.unreadCount++; - } - - this.notify({ type: 'NEW_MESSAGE', message, unreadCount: this.state.unreadCount }); - } - - // 切换侧边栏 - toggleSidebar() { - this.state.isSidebarOpen = !this.state.isSidebarOpen; - if (this.state.isSidebarOpen) { - this.state.unreadCount = 0; - } - this.notify({ type: 'SIDEBAR_TOGGLE', isOpen: this.state.isSidebarOpen }); - } - - // 结束通话 - endCall() { - clearInterval(this.durationInterval); - this.state.session.status = 'ended'; - this.notify({ type: 'CALL_ENDED' }); - - // [API_CALL: POST /api/call/:callId/leave] - // [WEBSOCKET_EMIT: leave-call] - } - - // 模拟远端活动 (开发测试用) - simulateRemoteActivity() { - setInterval(() => { - if (Math.random() > 0.7) { - const isSpeaking = Math.random() > 0.5; - this.updateRemoteMedia({ isSpeaking }); - } - }, 800); - } - - // 模拟网络质量变化 (开发测试用) - simulateNetworkChange() { - const qualities = ['excellent', 'good', 'fair', 'poor']; - setInterval(() => { - if (Math.random() > 0.8) { - const quality = qualities[Math.floor(Math.random() * qualities.length)]; - this.state.session.remoteUser.networkQuality = quality; - this.notify({ type: 'NETWORK_CHANGE', quality }); - } - }, 5000); - } - - // 发送媒体状态到服务器 - emitMediaStateChange() { - const payload = { - userId: this.state.session.localUser.id, - ...this.state.session.localUser.mediaState - }; - console.log('[WebSocket Emit] media-state-changed:', payload); - // socket.emit('media-state-changed', payload); - } - - // Getters - getState() { return this.state; } - getLocalUser() { return this.state.session.localUser; } - getRemoteUser() { return this.state.session.remoteUser; } - getMessages() { return this.state.messages; } -} - -/** - * ========================================== - * 4. UI 渲染器 (UI Renderer) - * 负责将状态映射到 DOM,与状态管理解耦 - * ========================================== - */ - -class UIRenderer { - constructor(stateManager) { - this.stateManager = stateManager; - this.unsubscribe = stateManager.subscribe(this.render.bind(this)); - - // 缓存 DOM 元素 - this.elements = { - // 头部 - headerTitle: document.getElementById('headerTitle'), - callDuration: document.getElementById('callDuration'), - encryptionBadge: document.getElementById('encryptionBadge'), - unreadBadge: document.getElementById('unreadBadge'), - - // 远端视频 - remoteVideo: document.getElementById('remoteVideo'), - remoteAvatar: document.getElementById('remoteAvatar'), - remoteName: document.getElementById('remoteName'), - remoteStatus: document.getElementById('remoteStatus'), - remoteSpeakingIndicator: document.getElementById('remoteSpeakingIndicator'), - remoteAudioWave: document.getElementById('remoteAudioWave'), - networkStatus: document.getElementById('networkStatus'), - networkStatusText: document.getElementById('networkStatusText'), - connectingOverlay: document.getElementById('connectingOverlay'), - - // 本地视频 - localVideo: document.getElementById('localVideo'), - localVideoPlaceholder: document.getElementById('localVideoPlaceholder'), - localAudioWave: document.getElementById('localAudioWave'), - localInitials: document.getElementById('localInitials'), - - // 侧边栏 - sidebar: document.getElementById('sidebar'), - chatContent: document.getElementById('chatContent'), - userList: document.getElementById('userList'), - - // 控制按钮 - micBtn: document.getElementById('micBtn'), - videoBtn: document.getElementById('videoBtn'), - screenBtn: document.getElementById('screenBtn'), - connectionQuality: document.getElementById('connectionQuality') - }; - } - - // 主渲染入口 - render(state, changes) { - if (!changes) { - // 初始化全量渲染 - this.renderHeader(state); - this.renderRemoteVideo(state); - this.renderLocalVideo(state); - this.renderUserList(state); - this.renderMessages(state); - this.renderControls(state); - return; - } - - // 增量更新 - switch (changes.type) { - case 'DURATION_UPDATE': - this.updateDuration(changes.duration); - break; - case 'LOCAL_MEDIA_CHANGE': - this.updateLocalControl(changes.mediaType, changes.value); - this.updateLocalVideoUI(state); - break; - case 'REMOTE_MEDIA_CHANGE': - this.updateRemoteVideoUI(state); - break; - case 'NEW_MESSAGE': - this.appendMessage(changes.message); - this.updateUnreadBadge(changes.unreadCount); - break; - case 'SIDEBAR_TOGGLE': - this.toggleSidebarUI(changes.isOpen); - break; - case 'NETWORK_CHANGE': - this.updateNetworkStatus(changes.quality); - break; - case 'CALL_ENDED': - this.showCallEnded(state.session.duration); - break; - } - } - - // 渲染头部信息 - renderHeader(state) { - const { session } = state; - this.elements.headerTitle.textContent = `与 ${session.remoteUser.name} 的通话`; - this.elements.encryptionBadge.style.display = session.isEncrypted ? 'flex' : 'none'; - this.updateDuration(session.duration); - } - - updateDuration(seconds) { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - this.elements.callDuration.textContent = - `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; - } - - // 渲染远端视频区域 - renderRemoteVideo(state) { - const { remoteUser } = state.session; - this.elements.remoteName.textContent = remoteUser.name; - this.elements.remoteAvatar.src = remoteUser.avatar; - this.elements.remoteVideo.src = remoteUser.avatar; // 实际应为视频流 - - this.updateRemoteVideoUI(state); - this.updateNetworkStatus(remoteUser.networkQuality); - } - - updateRemoteVideoUI(state) { - const { remoteUser } = state.session; - const media = remoteUser.mediaState; - - // 音频状态 - if (media.isSpeaking && media.audio) { - this.elements.remoteAudioWave.classList.remove('hidden'); - this.elements.remoteSpeakingIndicator.classList.remove('hidden'); - } else { - this.elements.remoteAudioWave.classList.add('hidden'); - this.elements.remoteSpeakingIndicator.classList.add('hidden'); - } - - // 连接状态 - const statusText = { - 'online': '正在通话', - 'connecting': '连接中...', - 'offline': '已断开' - }; - this.elements.remoteStatus.textContent = statusText[remoteUser.status]; - - // 视频占位 (实际应根据 video track 判断) - if (!media.video) { - this.elements.remoteVideo.style.opacity = '0.3'; - } else { - this.elements.remoteVideo.style.opacity = '1'; - } - } - - // 渲染本地视频 - renderLocalVideo(state) { - const { localUser } = state.session; - this.elements.localVideo.src = localUser.avatar; // 实际应为视频流 - this.elements.localInitials.textContent = localUser.name.substring(0, 2); - this.updateLocalVideoUI(state); - } - - updateLocalVideoUI(state) { - const { localUser } = state.session; - const media = localUser.mediaState; - - // 视频开关 - if (!media.video) { - this.elements.localVideoPlaceholder.classList.remove('hidden'); - this.elements.localVideo.style.opacity = '0'; - } else { - this.elements.localVideoPlaceholder.classList.add('hidden'); - this.elements.localVideo.style.opacity = '1'; - } - - // 音频波形 - if (media.isSpeaking && media.audio) { - this.elements.localAudioWave.classList.remove('hidden'); - } else { - this.elements.localAudioWave.classList.add('hidden'); - } - } - - // 渲染用户列表 - renderUserList(state) { - // 实际项目中应根据用户列表动态生成 - // 这里使用静态HTML,仅更新状态 - } - - // 渲染消息列表 - renderMessages(state) { - // 实际项目中应根据消息列表动态生成 - // 这里使用静态HTML,仅更新新消息 - } - - // 追加新消息 - appendMessage(message) { - // 实际项目中应动态生成消息元素 - console.log('New message:', message); - } - - // 渲染控制按钮 - renderControls(state) { - const { localUser } = state.session; - const media = localUser.mediaState; - - this.updateLocalControl('audio', media.audio); - this.updateLocalControl('video', media.video); - this.updateLocalControl('screenShare', media.screenShare); - } - - // 更新本地控制按钮状态 - updateLocalControl(mediaType, value) { - const btnMap = { - 'audio': this.elements.micBtn, - 'video': this.elements.videoBtn, - 'screenShare': this.elements.screenBtn - }; - - const btn = btnMap[mediaType]; - if (btn) { - btn.dataset.active = !value; - btn.querySelector('[data-icon="default"]').classList.toggle('hidden', !value); - btn.querySelector('[data-icon="active"]').classList.toggle('hidden', value); - } - } - - // 更新未读消息徽章 - updateUnreadBadge(count) { - if (count > 0) { - this.elements.unreadBadge.textContent = count; - this.elements.unreadBadge.classList.remove('hidden'); - } else { - this.elements.unreadBadge.classList.add('hidden'); - } - } - - // 切换侧边栏UI - toggleSidebarUI(isOpen) { - this.elements.sidebar.classList.toggle('hidden', !isOpen); - } - - // 更新网络状态 - updateNetworkStatus(quality) { - const qualityText = { - 'excellent': '优秀', - 'good': '良好', - 'fair': '一般', - 'poor': '较差' - }; - - if (quality !== 'excellent') { - this.elements.networkStatus.classList.remove('hidden'); - this.elements.networkStatusText.textContent = `网络${qualityText[quality]}`; - } else { - this.elements.networkStatus.classList.add('hidden'); - } - - this.elements.connectionQuality.textContent = `连接质量: ${qualityText[quality]}`; - } - - // 显示通话结束 - showCallEnded(duration) { - const mins = Math.floor(duration / 60); - const secs = duration % 60; - alert(`通话已结束,时长: ${mins}分${secs}秒`); - } -} - -/** - * ========================================== - * 5. 事件处理 (Event Handlers) - * 处理用户交互事件 - * ========================================== - */ - -// 全局状态管理器实例 -let stateManager; -let uiRenderer; - -// 初始化应用 -function initApp() { - stateManager = new CallStateManager(); - uiRenderer = new UIRenderer(stateManager); - - // 绑定键盘事件 - document.addEventListener('keydown', handleKeyPress); -} - -// 处理键盘事件 -function handleKeyPress(e) { - // 空格键静音 - if (e.code === 'Space') { - e.preventDefault(); - const micBtn = document.getElementById('micBtn'); - toggleMute(micBtn); - } - - // Ctrl+V 切换视频 - if (e.ctrlKey && e.key === 'v') { - e.preventDefault(); - const videoBtn = document.getElementById('videoBtn'); - toggleVideo(videoBtn); - } -} - -// 切换静音 -function toggleMute(btn) { - const isActive = btn.dataset.active === 'true'; - stateManager.updateLocalMedia('audio', !isActive); -} - -// 切换视频 -function toggleVideo(btn) { - const isActive = btn.dataset.active === 'true'; - stateManager.updateLocalMedia('video', !isActive); -} - -// 切换屏幕共享 -function toggleScreenShare(btn) { - const isActive = btn.dataset.active === 'true'; - stateManager.updateLocalMedia('screenShare', !isActive); -} - -// 切换本地视频 -function toggleLocalVideo() { - const videoBtn = document.getElementById('videoBtn'); - toggleVideo(videoBtn); -} - -// 切换侧边栏 -function toggleSidebar() { - stateManager.toggleSidebar(); -} - -// 结束通话 -function endCall() { - stateManager.endCall(); -} - -// 发送消息 -function sendMessage() { - const input = document.getElementById('chatInput'); - const content = input.value.trim(); - - if (content) { - const newMessage = { - id: `msg-${Date.now()}`, - senderId: stateManager.getLocalUser().id, - senderName: stateManager.getLocalUser().name, - senderAvatar: stateManager.getLocalUser().avatar, - content: content, - type: 'text', - timestamp: new Date().toISOString(), - isSelf: true - }; - - stateManager.addMessage(newMessage); - input.value = ''; - - // [API_CALL: POST /api/call/:callId/message] - // [WEBSOCKET_EMIT: send-message] - console.log('[WebSocket Emit] send-message:', newMessage); - } -} - -// 处理聊天输入提交 -function handleChatSubmit(e) { - if (e.key === 'Enter') { - sendMessage(); - } -} - -// 本地视频窗口拖拽功能 -document.addEventListener('DOMContentLoaded', function() { - const videoContainer = document.getElementById('localVideoContainer'); - let isDragging = false; - let offsetX, offsetY; - const edgeThreshold = 20; // 边缘吸附阈值 - - videoContainer.addEventListener('mousedown', function(e) { - // 只有在点击容器本身而不是内部按钮时才开始拖拽 - if (e.target === videoContainer || e.target === videoContainer.querySelector('#localVideo') || e.target === videoContainer.querySelector('#localVideoPlaceholder')) { - isDragging = true; - offsetX = e.clientX - videoContainer.getBoundingClientRect().left; - offsetY = e.clientY - videoContainer.getBoundingClientRect().top; - videoContainer.style.cursor = 'grabbing'; - videoContainer.style.zIndex = '100'; - } - }); - - document.addEventListener('mousemove', function(e) { - if (!isDragging) return; - - let newX = e.clientX - offsetX; - let newY = e.clientY - offsetY; - - // 计算屏幕边界 - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; - const containerWidth = videoContainer.offsetWidth; - const containerHeight = videoContainer.offsetHeight; - - // 边缘吸附逻辑 - if (newX < edgeThreshold) { - newX = 0; - } else if (newX > windowWidth - containerWidth - edgeThreshold) { - newX = windowWidth - containerWidth; - } - - if (newY < edgeThreshold) { - newY = 0; - } else if (newY > windowHeight - containerHeight - edgeThreshold) { - newY = windowHeight - containerHeight; - } - - // 更新位置 - videoContainer.style.left = newX + 'px'; - videoContainer.style.top = newY + 'px'; - videoContainer.style.bottom = 'auto'; - videoContainer.style.right = 'auto'; - }); - - document.addEventListener('mouseup', function() { - if (isDragging) { - isDragging = false; - videoContainer.style.cursor = 'move'; - videoContainer.style.zIndex = '10'; - } - }); -}); - -// 页面加载完成后初始化 -window.addEventListener('DOMContentLoaded', initApp); diff --git a/WebApp/client/public/onebyone/store.js b/WebApp/client/public/onebyone/store.js new file mode 100644 index 0000000..6203827 --- /dev/null +++ b/WebApp/client/public/onebyone/store.js @@ -0,0 +1,146 @@ +/** + * 状态管理 + * 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia + */ +import { mockCallSession, mockMessages } from './models.js'; + +class CallStateManager { + constructor() { + // 核心状态 + this.state = { + session: { ...mockCallSession }, + messages: [...mockMessages], + isSidebarOpen: false, + unreadCount: 0, + localStream: null, // MediaStream 对象 + remoteStream: null // MediaStream 对象 + }; + + // 监听器数组 + this.listeners = []; + + // 初始化 + this.init(); + } + + // 订阅状态变化 + subscribe(callback) { + this.listeners.push(callback); + return () => { + this.listeners = this.listeners.filter(cb => cb !== callback); + }; + } + + // 通知所有监听器 + notify(changes) { + this.listeners.forEach(cb => cb(this.state, changes)); + } + + // 初始化 + init() { + // 启动通话时长计时器 + this.durationInterval = setInterval(() => { + this.state.session.duration++; + this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); + }, 1000); + + // 模拟远端音频活动 (实际应由 WebRTC VAD 检测触发) + this.simulateRemoteActivity(); + + // 模拟网络质量变化 + this.simulateNetworkChange(); + } + + // 更新本地媒体状态 + updateLocalMedia(mediaType, value) { + this.state.session.localUser.mediaState[mediaType] = value; + this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); + + // 发送媒体状态到服务器 + this.emitMediaStateChange(); + } + + // 更新远端媒体状态 (由 WebSocket 消息触发) + updateRemoteMedia(mediaState) { + this.state.session.remoteUser.mediaState = { + ...this.state.session.remoteUser.mediaState, + ...mediaState + }; + this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState }); + } + + // 添加消息 + addMessage(message) { + this.state.messages.push(message); + + // 如果侧边栏关闭且不是自己发的,增加未读 + if (!this.state.isSidebarOpen && !message.isSelf) { + this.state.unreadCount++; + } + + this.notify({ type: 'NEW_MESSAGE', message, unreadCount: this.state.unreadCount }); + } + + // 切换侧边栏 + toggleSidebar() { + this.state.isSidebarOpen = !this.state.isSidebarOpen; + if (this.state.isSidebarOpen) { + this.state.unreadCount = 0; + } + this.notify({ type: 'SIDEBAR_TOGGLE', isOpen: this.state.isSidebarOpen }); + } + + // 结束通话 + endCall() { + clearInterval(this.durationInterval); + this.state.session.status = 'ended'; + this.notify({ type: 'CALL_ENDED' }); + + // 发送结束通话请求到服务器 + // [API_CALL: POST /api/call/:callId/leave] + // [WEBSOCKET_EMIT: leave-call] + } + + // 模拟远端活动 (开发测试用) + simulateRemoteActivity() { + setInterval(() => { + if (Math.random() > 0.7) { + const isSpeaking = Math.random() > 0.5; + this.updateRemoteMedia({ isSpeaking }); + } + }, 800); + } + + // 模拟网络质量变化 (开发测试用) + simulateNetworkChange() { + const qualities = ['excellent', 'good', 'fair', 'poor']; + setInterval(() => { + if (Math.random() > 0.8) { + const quality = qualities[Math.floor(Math.random() * qualities.length)]; + this.state.session.remoteUser.networkQuality = quality; + this.notify({ type: 'NETWORK_CHANGE', quality }); + } + }, 5000); + } + + // 发送媒体状态到服务器 + emitMediaStateChange() { + const payload = { + userId: this.state.session.localUser.id, + ...this.state.session.localUser.mediaState + }; + console.log('[WebSocket Emit] media-state-changed:', payload); + // socket.emit('media-state-changed', payload); + } + + // Getters + getState() { return this.state; } + getLocalUser() { return this.state.session.localUser; } + getRemoteUser() { return this.state.session.remoteUser; } + getMessages() { return this.state.messages; } +} + +// 创建单例实例 +const store = new CallStateManager(); + +export default store; diff --git a/WebApp/client/public/onebyone/style.css b/WebApp/client/public/onebyone/style.css index 70e0082..e40fb88 100644 --- a/WebApp/client/public/onebyone/style.css +++ b/WebApp/client/public/onebyone/style.css @@ -52,6 +52,7 @@ body { .chat-bubble { animation: messageSlide 0.3s ease-out; + margin-bottom: 12px; } @keyframes messageSlide { @@ -59,6 +60,80 @@ body { to { opacity: 1; transform: translateX(0); } } +/* 消息样式 */ +.message-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.message-header img { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; +} + +.message-sender { + font-size: 12px; + font-weight: 500; +} + +.message-time { + font-size: 12px; + color: #94a3b8; +} + +.message-content { + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + max-width: 70%; +} + +/* 系统消息 */ +.message-system .message-sender { + color: #60a5fa; +} + +.message-system .message-content { + background-color: rgba(30, 64, 175, 0.3); + color: #ffffff; +} + +/* 对方消息 */ +.message-other .message-sender { + color: #a5b4fc; +} + +.message-other .message-content { + background-color: #1e293b; + color: #ffffff; + border-top-left-radius: 0; +} + +/* 自己的消息 */ +.message-self { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.message-self .message-header { + flex-direction: row-reverse; +} + +.message-self .message-sender { + color: #4ade80; +} + +.message-self .message-content { + background-color: #4f46e5; + color: #ffffff; + border-top-right-radius: 0; +} + .custom-scrollbar::-webkit-scrollbar { width: 6px; } @@ -104,7 +179,7 @@ body { to { opacity: 1; transform: scale(1); } } -/* 数据绑定标记 - 开发调试时显示 */ +/* 数据绑定标记 - 开发调试时显示 [data-field]::after { content: attr(data-field); position: absolute; @@ -122,4 +197,4 @@ body { white-space: nowrap; } [data-field]:hover::after { opacity: 1; } -[data-field] { position: relative; } +[data-field] { position: relative; }*/ diff --git a/WebApp/client/public/onebyone/utils.js b/WebApp/client/public/onebyone/utils.js new file mode 100644 index 0000000..213b170 --- /dev/null +++ b/WebApp/client/public/onebyone/utils.js @@ -0,0 +1,94 @@ +/** + * 工具函数 + */ + +/** + * 格式化时间为 MM:SS 格式 + * @param {number} seconds - 秒数 + * @returns {string} 格式化后的时间字符串 + */ +export function formatTime(seconds) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +} + +/** + * 格式化时间戳为 HH:MM 格式 + * @param {string} timestamp - ISO 8601 时间戳 + * @returns {string} 格式化后的时间字符串 + */ +export function formatTimestamp(timestamp) { + const date = new Date(timestamp); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; +} + +/** + * 生成唯一ID + * @returns {string} 唯一ID + */ +export function generateId() { + return 'id-' + Math.random().toString(36).substr(2, 9); +} + +/** + * 显示通知 + * @param {string} message - 通知内容 + * @param {number} duration - 显示时长(毫秒) + */ +export function showNotification(message, duration = 3000) { + const notification = document.getElementById('notification'); + const notificationText = document.getElementById('notificationText'); + + if (notification && notificationText) { + notificationText.textContent = message; + notification.classList.remove('opacity-0', 'translate-y-[-20px]'); + notification.classList.add('opacity-100', 'translate-y-0'); + + setTimeout(() => { + notification.classList.remove('opacity-100', 'translate-y-0'); + notification.classList.add('opacity-0', 'translate-y-[-20px]'); + }, duration); + } +} + +/** + * 切换元素的显示/隐藏 + * @param {HTMLElement} element - DOM元素 + * @param {boolean} show - 是否显示 + */ +export function toggleElement(element, show) { + if (element) { + if (show) { + element.classList.remove('hidden'); + } else { + element.classList.add('hidden'); + } + } +} + +/** + * 切换按钮状态 + * @param {HTMLElement} button - 按钮元素 + * @param {boolean} active - 是否激活 + */ +export function toggleButtonState(button, active) { + if (button) { + button.dataset.active = active; + + const defaultIcon = button.querySelector('[data-icon="default"]'); + const activeIcon = button.querySelector('[data-icon="active"]'); + + if (defaultIcon && activeIcon) { + if (active) { + defaultIcon.classList.add('hidden'); + activeIcon.classList.remove('hidden'); + } else { + defaultIcon.classList.remove('hidden'); + activeIcon.classList.add('hidden'); + } + } + } +} diff --git a/WebApp/client/public/onebyone/websocket.js b/WebApp/client/public/onebyone/websocket.js new file mode 100644 index 0000000..3c8ad4a --- /dev/null +++ b/WebApp/client/public/onebyone/websocket.js @@ -0,0 +1,206 @@ +/** + * WebSocket管理 + * 管理WebSocket连接,处理WebSocket事件 + */ + +class WebSocketManager { + constructor(url = null) { + this.url = url || this.getDefaultWebSocketUrl(); + this.socket = null; + this.isConnected = false; + this.listeners = new Map(); + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 1000; + } + + /** + * 获取默认WebSocket URL + * @returns {string} WebSocket URL + */ + getDefaultWebSocketUrl() { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${location.host}`; + } + + /** + * 连接WebSocket + */ + connect() { + try { + this.socket = new WebSocket(this.url); + + this.socket.onopen = () => { + console.log('WebSocket connected'); + this.isConnected = true; + this.reconnectAttempts = 0; + this.emit('connect'); + }; + + this.socket.onclose = () => { + console.log('WebSocket disconnected'); + this.isConnected = false; + this.emit('disconnect'); + this.attemptReconnect(); + }; + + this.socket.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleMessage(message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + this.socket.onerror = (error) => { + console.error('WebSocket error:', error); + this.emit('error', error); + }; + } catch (error) { + console.error('Error connecting to WebSocket:', error); + this.emit('error', error); + } + } + + /** + * 断开WebSocket连接 + */ + disconnect() { + if (this.socket) { + this.socket.close(); + this.socket = null; + this.isConnected = false; + } + } + + /** + * 发送消息 + * @param {string} event - 事件名称 + * @param {Object} data - 消息数据 + */ + send(event, data) { + if (this.isConnected && this.socket) { + try { + const message = JSON.stringify({ event, data }); + this.socket.send(message); + } catch (error) { + console.error('Error sending WebSocket message:', error); + } + } else { + console.warn('WebSocket not connected, cannot send message'); + } + } + + /** + * 处理接收到的消息 + * @param {Object} message - 消息对象 + */ + handleMessage(message) { + switch (message.type) { + case 'user-joined': + this.emit('user-joined', message.data); + break; + case 'user-left': + this.emit('user-left', message.data); + break; + case 'media-state-changed': + this.emit('media-state-changed', message.data); + break; + case 'message-received': + this.emit('message-received', message.data); + break; + case 'network-quality': + this.emit('network-quality', message.data); + break; + case 'call-ended': + this.emit('call-ended', message.data); + break; + case 'ping': + // 处理心跳请求,回复pong + this.send('pong', {}); + break; + case 'pong': + // 处理心跳响应 + this.emit('pong'); + break; + default: + this.emit('message', message); + break; + } + } + + /** + * 尝试重连 + */ + attemptReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + console.log(`Attempting to reconnect in ${delay}ms...`); + + setTimeout(() => { + console.log(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`); + this.connect(); + }, delay); + } else { + console.error('Max reconnect attempts reached'); + this.emit('reconnect-failed'); + } + } + + /** + * 订阅事件 + * @param {string} event - 事件名称 + * @param {Function} callback - 回调函数 + */ + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } + + /** + * 取消订阅事件 + * @param {string} event - 事件名称 + * @param {Function} callback - 回调函数 + */ + off(event, callback) { + if (this.listeners.has(event)) { + const callbacks = this.listeners.get(event); + this.listeners.set(event, callbacks.filter(cb => cb !== callback)); + } + } + + /** + * 触发事件 + * @param {string} event - 事件名称 + * @param {*} data - 事件数据 + */ + emit(event, data) { + if (this.listeners.has(event)) { + this.listeners.get(event).forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in event listener for ${event}:`, error); + } + }); + } + } + + /** + * 检查连接状态 + * @returns {boolean} 是否连接 + */ + getIsConnected() { + return this.isConnected; + } +} + +// 创建单例实例 +const wsManager = new WebSocketManager(); + +export default wsManager;