【m】拆分文件
This commit is contained in:
1278
WebApp/client/public/onebyone/index1.html
Normal file
1278
WebApp/client/public/onebyone/index1.html
Normal file
File diff suppressed because it is too large
Load Diff
703
WebApp/client/public/onebyone/script.js
Normal file
703
WebApp/client/public/onebyone/script.js
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* ==========================================
|
||||
* 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);
|
||||
125
WebApp/client/public/onebyone/style.css
Normal file
125
WebApp/client/public/onebyone/style.css
Normal file
@@ -0,0 +1,125 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(20px, 20px); }
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.end-call-pulse {
|
||||
animation: pulse-red 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
||||
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
animation: messageSlide 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messageSlide {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.audio-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.audio-wave span {
|
||||
width: 3px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
animation: wave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
|
||||
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
|
||||
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
|
||||
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
|
||||
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
.video-fade-in {
|
||||
animation: videoFadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes videoFadeIn {
|
||||
from { opacity: 0; transform: scale(1.05); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* 数据绑定标记 - 开发调试时显示 */
|
||||
[data-field]::after {
|
||||
content: attr(data-field);
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
right: 0;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-field]:hover::after { opacity: 1; }
|
||||
[data-field] { position: relative; }
|
||||
Reference in New Issue
Block a user