【m】优化拆分,拆分为多个脚本
This commit is contained in:
155
WebApp/client/public/onebyone/api.js
Normal file
155
WebApp/client/public/onebyone/api.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* API客户端
|
||||
* 封装所有API调用,处理HTTP请求
|
||||
*/
|
||||
|
||||
class ApiClient {
|
||||
constructor(baseUrl = '') {
|
||||
this.baseUrl = baseUrl || location.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通话信息
|
||||
* @param {string} callId - 通话ID
|
||||
* @returns {Promise<Object>} 通话信息
|
||||
*/
|
||||
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<Object>} 加入结果
|
||||
*/
|
||||
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<Object>} 离开结果
|
||||
*/
|
||||
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<Object>} 更新结果
|
||||
*/
|
||||
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<Array>} 消息列表
|
||||
*/
|
||||
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<Object>} 发送结果
|
||||
*/
|
||||
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;
|
||||
509
WebApp/client/public/onebyone/index.html
Normal file
509
WebApp/client/public/onebyone/index.html
Normal file
@@ -0,0 +1,509 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 一对一视频通话</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
数据模型定义 (Data Models)
|
||||
============================================================
|
||||
|
||||
1. CallSession 通话会话
|
||||
interface CallSession {
|
||||
id: string; // 通话唯一ID [PRIMARY_KEY]
|
||||
type: 'video' | 'audio'; // 通话类型
|
||||
status: 'connecting' | 'ongoing' | 'ended' | 'failed';
|
||||
startTime: string; // ISO 8601 格式
|
||||
duration: number; // 已进行秒数
|
||||
isEncrypted: boolean; // 是否端到端加密
|
||||
localUser: LocalUser; // 本地用户信息
|
||||
remoteUser: RemoteUser; // 远端用户信息
|
||||
}
|
||||
|
||||
2. LocalUser 本地用户
|
||||
interface LocalUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
isHost: boolean; // 是否主持人
|
||||
mediaState: MediaState; // 媒体状态
|
||||
}
|
||||
|
||||
3. RemoteUser 远端用户
|
||||
interface RemoteUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
status: 'online' | 'offline' | 'connecting';
|
||||
mediaState: MediaState; // 媒体状态
|
||||
networkQuality: 'excellent' | 'good' | 'fair' | 'poor'; // 网络质量
|
||||
}
|
||||
|
||||
4. MediaState 媒体状态
|
||||
interface MediaState {
|
||||
audio: boolean; // 音频是否开启
|
||||
video: boolean; // 视频是否开启
|
||||
screenShare: boolean; // 是否屏幕共享
|
||||
isSpeaking: boolean; // 是否正在说话(VAD检测)
|
||||
}
|
||||
|
||||
5. ChatMessage 聊天消息
|
||||
interface ChatMessage {
|
||||
id: string; // 消息ID
|
||||
senderId: string; // 发送者ID
|
||||
senderName: string; // 发送者名称
|
||||
senderAvatar: string; // 发送者头像
|
||||
content: string; // 消息内容
|
||||
type: 'text' | 'file' | 'system';
|
||||
timestamp: string; // ISO 8601 格式
|
||||
isSelf: boolean; // 是否自己发送
|
||||
}
|
||||
|
||||
============================================================
|
||||
API 接口定义 (API Endpoints)
|
||||
============================================================
|
||||
|
||||
[GET] /api/call/:callId // 获取通话信息
|
||||
[POST] /api/call/:callId/join // 加入通话
|
||||
[POST] /api/call/:callId/leave // 离开通话
|
||||
[POST] /api/call/:callId/media // 更新媒体状态 {audio?: boolean, video?: boolean}
|
||||
[GET] /api/call/:callId/messages?limit=50&before=timestamp // 获取历史消息
|
||||
[POST] /api/call/:callId/message // 发送消息 {content: string, type: 'text'}
|
||||
|
||||
WebSocket Events:
|
||||
- connect: 连接建立
|
||||
- disconnect: 连接断开
|
||||
- user-joined: {userId, timestamp}
|
||||
- user-left: {userId, timestamp}
|
||||
- media-state-changed: {userId, audio, video, screenShare, isSpeaking}
|
||||
- message-received: {message: ChatMessage}
|
||||
- network-quality: {userId, quality: 'excellent' | 'good' | 'fair' | 'poor'}
|
||||
- call-ended: {reason: 'user_hangup' | 'network_error' | 'timeout'}
|
||||
-->
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 顶部栏 (Header)
|
||||
数据源: CallSession
|
||||
更新频率: 实时 (WebSocket + 本地计时器)
|
||||
============================================================
|
||||
-->
|
||||
<header class="glass-strong h-16 flex items-center justify-between px-6 z-50 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-video text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<!-- [DATA_FIELD: callSession.remoteUser.name] [TYPE: string] [REQUIRED] -->
|
||||
<h1 class="font-bold text-lg tracking-tight" data-field="remoteUser.name" id="headerTitle">
|
||||
与 Sarah 的通话
|
||||
</h1>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<!-- [DATA_FIELD: callSession.duration] [TYPE: string] [FORMAT: MM:SS] [UPDATE: 每秒] -->
|
||||
<span data-field="callSession.duration" id="callDuration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- [CONDITIONAL_RENDER: callSession.isEncrypted === true] -->
|
||||
<div class="hidden md:flex items-center gap-2 px-4 py-2 glass rounded-full text-sm" id="encryptionBadge">
|
||||
<i class="fas fa-shield-alt text-green-400"></i>
|
||||
<span class="text-gray-300">端到端加密</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden relative">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 视频区域 (Video Area)
|
||||
数据源: CallSession.remoteUser (对方) + CallSession.localUser (自己)
|
||||
更新频率: 实时 (WebRTC MediaStream + WebSocket 状态)
|
||||
============================================================
|
||||
-->
|
||||
<div class="flex-1 relative bg-black/40 overflow-hidden" id="videoArea">
|
||||
|
||||
<!--
|
||||
子区域: 远端视频 (Remote Video)
|
||||
数据源: RemoteUser
|
||||
-->
|
||||
<div class="absolute inset-0 video-fade-in">
|
||||
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
|
||||
<img id="remoteVideo"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1280&h=720&fit=crop"
|
||||
alt="对方视频"
|
||||
class="w-full h-full object-cover"
|
||||
data-field="remoteUser.videoStream">
|
||||
|
||||
<!-- 远端信息覆盖层 -->
|
||||
<div class="absolute top-6 left-6 glass px-4 py-2 rounded-full flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<!-- [DATA_FIELD: remoteUser.avatar] [TYPE: string] [URL] [REQUIRED] -->
|
||||
<img id="remoteAvatar"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
data-field="remoteUser.avatar">
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
|
||||
<div id="remoteSpeakingIndicator" class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900 hidden" data-field="remoteUser.isSpeaking"></div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- [DATA_FIELD: remoteUser.name] [TYPE: string] -->
|
||||
<div class="text-sm font-medium" data-field="remoteUser.name" id="remoteName">Sarah Chen</div>
|
||||
<div class="text-xs text-gray-400 flex items-center gap-2">
|
||||
<!-- [DATA_FIELD: remoteUser.status] [TYPE: string] [ENUM: online/offline/connecting] -->
|
||||
<span id="remoteStatus" data-field="remoteUser.status">正在通话</span>
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true && remoteUser.mediaState.audio === true] -->
|
||||
<div id="remoteAudioWave" class="audio-wave w-6 hidden" data-field="remoteUser.audioActivity">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络状态提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.networkQuality !== 'excellent'] -->
|
||||
<div id="networkStatus" class="absolute top-6 right-6 glass px-3 py-1.5 rounded-full flex items-center gap-2 text-xs hidden" data-field="remoteUser.networkQuality">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TYPE: string] [TRANSFORM: quality => text] -->
|
||||
<span class="text-gray-300" id="networkStatusText">网络不稳定</span>
|
||||
</div>
|
||||
|
||||
<!-- 连接中/重连提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: callSession.status === 'connecting'] -->
|
||||
<div id="connectingOverlay" class="absolute inset-0 bg-black/60 flex items-center justify-center hidden" data-field="callSession.status">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="text-white font-medium">正在连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-1" id="connectingText">等待对方接受邀请</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 本地视频 (Local Video - Picture in Picture)
|
||||
数据源: LocalUser
|
||||
-->
|
||||
<div class="absolute bottom-6 right-6 w-64 h-48 rounded-2xl overflow-hidden shadow-2xl border-2 border-white/20 video-fade-in z-10">
|
||||
<!-- [DATA_FIELD: localUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: localUser.avatar] [TYPE: string] [URL] -->
|
||||
<img id="localVideo"
|
||||
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop"
|
||||
alt="本地视频"
|
||||
class="w-full h-full object-cover"
|
||||
data-field="localUser.videoStream">
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.video === false] -->
|
||||
<div id="localVideoPlaceholder" class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 hidden" data-field="localUser.videoOff">
|
||||
<span class="text-4xl font-bold" id="localInitials">我</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-3 left-3 glass px-2 py-1 rounded text-xs flex items-center gap-2">
|
||||
<span>我</span>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.isSpeaking === true] -->
|
||||
<div id="localAudioWave" class="audio-wave w-4 hidden" data-field="localUser.isSpeaking">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地视频悬停控制 -->
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button onclick="toggleLocalVideo()" class="w-8 h-8 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-video text-xs" id="localVideoIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 侧边栏 (Sidebar)
|
||||
数据源: ChatMessage[] + User[]
|
||||
更新频率: 实时 (WebSocket)
|
||||
============================================================
|
||||
-->
|
||||
<aside class="w-80 glass-strong border-l border-white/10 flex flex-col hidden" id="sidebar">
|
||||
|
||||
<!--
|
||||
子区域: 用户列表 (User List)
|
||||
数据源: [localUser, remoteUser]
|
||||
-->
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">
|
||||
通话成员 (<!-- [DATA_FIELD: userCount] [TYPE: number] [VALUE: 2] -->2)
|
||||
</h3>
|
||||
<div class="space-y-2" id="userList">
|
||||
<!-- [LOOP_START: users as user] -->
|
||||
|
||||
<!-- 远端用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-white/5" data-user-id="remote">
|
||||
<div class="relative">
|
||||
<!-- [DATA_FIELD: remoteUser.avatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
data-field="remoteUser.avatar">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
|
||||
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- [DATA_FIELD: remoteUser.name] -->
|
||||
<div class="text-sm font-medium" data-field="remoteUser.name">Sarah Chen</div>
|
||||
<!-- [DATA_FIELD: remoteUser.status] [TRANSFORM: status => displayText] -->
|
||||
<div class="text-xs text-green-400" data-field="remoteUser.statusText">在线</div>
|
||||
</div>
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
|
||||
<div class="audio-wave w-6" data-field="remoteUser.speakingIndicator">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5" data-user-id="local">
|
||||
<!-- [DATA_FIELD: localUser.avatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
data-field="localUser.avatar">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
<!-- [DATA_FIELD: localUser.name] -->我
|
||||
<!-- [CONDITIONAL_RENDER: localUser.isHost === true] -->
|
||||
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: localUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" id="localMediaStatus" data-field="localUser.mediaStatus">静音中</div>
|
||||
</div>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs" data-field="localUser.muteIcon"></i>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: users] -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 聊天消息列表 (Chat Messages)
|
||||
数据源: ChatMessage[]
|
||||
排序: 按 timestamp 升序
|
||||
-->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar" id="chatContent">
|
||||
<!-- [STATIC] 通话开始时间 -->
|
||||
<div class="text-center text-xs text-gray-500 my-4">
|
||||
通话开始 <!-- [DATA_FIELD: callSession.startTime] [FORMAT: HH:MM] -->14:30
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_START: messages as message] -->
|
||||
|
||||
<!-- 消息模板 (对方) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === false] -->
|
||||
<div class="chat-bubble" data-message-id="${message.id}">
|
||||
<div class="flex gap-3">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
data-field="message.senderAvatar">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<!-- [DATA_FIELD: message.senderName] -->
|
||||
<span class="text-sm font-medium text-indigo-400" data-field="message.senderName">Sarah Chen</span>
|
||||
<!-- [DATA_FIELD: message.timestamp] [FORMAT: HH:MM] -->
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: message.content] -->
|
||||
<div class="glass px-3 py-2 rounded-2xl rounded-tl-none text-sm text-gray-200" data-field="message.content">
|
||||
嗨,能听到我说话吗?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息模板 (自己) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === true] -->
|
||||
<div class="chat-bubble">
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
data-field="message.senderAvatar">
|
||||
<div class="flex-1 flex flex-col items-end">
|
||||
<div class="flex items-baseline gap-2 mb-1 flex-row-reverse">
|
||||
<span class="text-sm font-medium text-green-400">我</span>
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<div class="bg-indigo-600 px-3 py-2 rounded-2xl rounded-tr-none text-sm text-white" data-field="message.content">
|
||||
很清楚!你的画面也很清晰 👍
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: messages] -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 消息输入 (Message Input)
|
||||
API: [POST] /api/call/:callId/message
|
||||
-->
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div class="glass rounded-2xl flex items-center gap-2 p-2">
|
||||
<button class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-gray-400 transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<!-- [INPUT_FIELD] [BIND: inputValue] [EVENT: onEnter => sendMessage()] -->
|
||||
<input type="text"
|
||||
id="chatInput"
|
||||
placeholder="输入消息..."
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm text-white placeholder-gray-500 px-2"
|
||||
data-field="chatInput"
|
||||
onkeypress="handleChatSubmit(event)">
|
||||
<!-- [BUTTON] [EVENT: onclick => sendMessage()] -->
|
||||
<button onclick="sendMessage()" class="w-8 h-8 rounded-full bg-indigo-600 hover:bg-indigo-700 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 底部控制栏 (Control Bar)
|
||||
数据源: LocalUser.mediaState
|
||||
API: [POST] /api/call/:callId/media
|
||||
WebSocket: emit 'media-state-changed'
|
||||
============================================================
|
||||
-->
|
||||
<footer class="glass-strong h-20 border-t border-white/10 flex items-center justify-center px-6 gap-4 z-50">
|
||||
|
||||
<!-- 左侧连接信息 -->
|
||||
<div class="absolute left-6 hidden md:flex items-center gap-3">
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium">一对一通话</div>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TRANSFORM: quality => displayText] -->
|
||||
<div class="text-xs text-gray-400" id="connectionQuality" data-field="connectionQualityText">
|
||||
连接质量: 优秀
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间控制按钮组 -->
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- 麦克风控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.audio] [TYPE: boolean] -->
|
||||
<button class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleMute(this)"
|
||||
id="micBtn"
|
||||
data-field="localUser.audio"
|
||||
data-active="false">
|
||||
<i class="fas fa-microphone text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-microphone-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
静音 (Space)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 摄像头控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.video] [TYPE: boolean] -->
|
||||
<button class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleVideo(this)"
|
||||
id="videoBtn"
|
||||
data-field="localUser.video"
|
||||
data-active="false">
|
||||
<i class="fas fa-video text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-video-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
关闭视频 (Ctrl+V)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 录屏控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.recording] [TYPE: boolean] -->
|
||||
<button class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleRecording(this)"
|
||||
id="recordBtn"
|
||||
data-field="localUser.recording"
|
||||
data-active="false">
|
||||
<i class="fas fa-circle text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-stop text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
录制
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 更多选项 -->
|
||||
<button class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
更多选项
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 结束通话 -->
|
||||
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->
|
||||
<button class="control-btn w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white end-call-pulse ml-4 relative group"
|
||||
onclick="endCall()">
|
||||
<i class="fas fa-phone-slash text-xl"></i>
|
||||
<span class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
结束通话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天按钮 -->
|
||||
<div class="absolute right-6 flex items-center gap-3">
|
||||
<button class="control-btn w-10 h-10 rounded-full glass flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 transition-colors" onclick="toggleSidebar()">
|
||||
<i class="fas fa-comment-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification" class="fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 通话结束确认对话框 -->
|
||||
<div id="endCallDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-phone-slash text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">结束通话</h3>
|
||||
<p class="text-gray-400 text-sm">确定要结束当前通话吗?</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="cancelEndCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmEndCall" class="flex-1 py-2 rounded-lg bg-red-500 hover:bg-red-600 transition-colors">
|
||||
结束通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="main.js"></script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
215
WebApp/client/public/onebyone/main.js
Normal file
215
WebApp/client/public/onebyone/main.js
Normal file
@@ -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 };
|
||||
132
WebApp/client/public/onebyone/models.js
Normal file
132
WebApp/client/public/onebyone/models.js
Normal file
@@ -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 };
|
||||
293
WebApp/client/public/onebyone/renderer.js
Normal file
293
WebApp/client/public/onebyone/renderer.js
Normal file
@@ -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 = `
|
||||
<div class="message-header">
|
||||
<img src="${message.senderAvatar}" class="message-avatar">
|
||||
<div>
|
||||
<span class="message-sender">${message.senderName}</span>
|
||||
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
${message.content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
146
WebApp/client/public/onebyone/store.js
Normal file
146
WebApp/client/public/onebyone/store.js
Normal file
@@ -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;
|
||||
@@ -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; }*/
|
||||
|
||||
94
WebApp/client/public/onebyone/utils.js
Normal file
94
WebApp/client/public/onebyone/utils.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
WebApp/client/public/onebyone/websocket.js
Normal file
206
WebApp/client/public/onebyone/websocket.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user