初始化

This commit is contained in:
2026-04-29 15:18:30 +08:00
commit e47eee39ed
111 changed files with 44168 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
OneByOne 视频通话应用代码结构分析
1. 项目概述
OneByOne 是一个基于 WebRTC 和 WebSocket 的一对一视频通话应用,具有现代化的用户界面和丰富的功能。
2. 目录结构
plainText
onebyone/
├── index.html # 主HTML文件包含页面结构
├── main.js # 主入口文件,初始化应用
├── renderer.js # UI渲染器负责将状态映射到DOM
├── store.js # 状态管理使用Observable模式
├── models.js # 数据模型定义
├── websocket.js # WebSocket管理
├── utils.js # 工具函数
└── style.css # 样式文件
3. 核心模块分析
3.1 数据模型 (models.js)
定义了应用的核心数据结构:
CallSession: 通话会话包含通话ID、类型、状态、时长等信息
LocalUser: 本地用户包含用户ID、名称、头像、媒体状态等
RemoteUser: 远端用户包含用户ID、名称、头像、状态、网络质量等
MediaState: 媒体状态,包含音频、视频、屏幕共享、录屏、说话状态等
ChatMessage: 聊天消息包含消息ID、发送者信息、内容、类型、时间戳等
3.2 状态管理 (store.js)
使用简单的 Observable 模式实现状态管理:
核心状态:通话会话、消息列表、侧边栏状态、未读消息数、媒体流等
状态更新方法:
updateLocalMedia(): 更新本地媒体状态
updateRemoteMedia(): 更新远端媒体状态
addMessage(): 添加消息
toggleSidebar(): 切换侧边栏
endCall(): 结束通话
事件通知:通过 notify() 方法通知所有监听器状态变化
3.3 UI渲染器 (renderer.js)
负责将状态映射到DOM
DOM元素缓存缓存常用DOM元素提高性能
渲染方法:
renderHeader(): 渲染头部
renderCallDuration(): 渲染通话时长
renderRemoteVideo(): 渲染远端视频
renderLocalVideo(): 渲染本地视频
renderControlButtons(): 渲染控制按钮
renderChatMessages(): 渲染聊天消息
renderLocalUserStatus(): 渲染本地用户状态
消息渲染:支持文本消息和图片消息的渲染
3.4 WebSocket管理 (websocket.js)
管理WebSocket连接和事件
连接管理:连接、断开、重连
消息处理:处理服务器发送的消息
事件订阅:支持事件订阅和触发
心跳检测支持ping/pong心跳机制
3.5 主入口 (main.js)
初始化应用,连接各个模块:
应用初始化初始化渲染器、WebSocket连接、事件绑定
WebSocket事件绑定处理服务器事件
DOM事件绑定处理用户交互
功能实现:
麦克风、视频、录屏控制
聊天消息发送
图片上传和发送
通话结束确认
3.6 样式文件 (style.css)
提供应用的样式:
基础样式:布局、颜色、字体等
组件样式:玻璃效果、控制按钮、聊天消息等
动画效果:消息滑入、音频波形、视频淡入等
4. 核心功能
4.1 视频通话
支持一对一视频通话
本地视频预览(画中画模式)
远端视频显示
视频开关控制
4.2 音频控制
麦克风开关控制
说话状态检测
音频波形动画
4.3 聊天功能
文本消息发送
图片消息发送(支持文件选择和预览)
消息时间戳
消息类型区分(系统消息、自己的消息、对方的消息)
4.4 录屏功能
录屏开关控制
录制状态通知
4.5 网络状态
网络质量检测和显示
连接状态提示
4.6 其他功能
端到端加密提示
通话时长显示
键盘快捷键空格键静音Ctrl+V切换视频
通知系统
5. 技术特点
5.1 模块化设计
代码按功能模块分离,职责清晰
使用ES6模块系统便于维护和扩展
5.2 状态管理
使用Observable模式实现简单的状态管理
状态变化自动触发UI更新
5.3 响应式设计
使用Tailwind CSS实现响应式布局
适配不同屏幕尺寸
5.4 现代化UI
玻璃拟态效果
平滑动画
清晰的视觉层次
5.5 事件驱动
使用事件机制处理用户交互和系统通知
松耦合的组件通信
6. API接口
应用定义了以下API接口
GET /api/call/:callId - 获取通话信息
POST /api/call/:callId/join - 加入通话
POST /api/call/:callId/leave - 离开通话
POST /api/call/:callId/media - 更新媒体状态
GET /api/call/:callId/messages - 获取历史消息
POST /api/call/:callId/message - 发送消息
7. WebSocket事件
应用使用WebSocket处理实时通信支持以下事件
connect - 连接建立
disconnect - 连接断开
user-joined - 用户加入
user-left - 用户离开
media-state-changed - 媒体状态变化
message-received - 收到消息
network-quality - 网络质量变化
call-ended - 通话结束
8. 代码优化建议
错误处理增加更完善的错误处理机制特别是WebSocket连接和API调用
性能优化:
减少DOM操作使用虚拟DOM或批量更新
优化图片加载和处理
安全性:
增加输入验证防止XSS攻击
实现真正的端到端加密
可扩展性:
考虑使用更成熟的状态管理库如Redux
增加单元测试和集成测试
用户体验:
增加更多的动画和过渡效果
优化移动端体验
增加更多的用户反馈
9. 总结
OneByOne 是一个功能完整、界面现代化的一对一视频通话应用,采用了模块化设计和现代化的前端技术。它具有视频通话、音频控制、聊天功能、录屏功能等核心功能,并且支持实时通信和状态管理。
应用的代码结构清晰职责分离明确使用了Observable模式进行状态管理WebSocket进行实时通信Tailwind CSS进行样式设计。这些技术选择使得应用具有良好的可维护性和可扩展性。
通过进一步的优化和扩展OneByOne可以成为一个功能更加强大、用户体验更加出色的视频通话应用。

View File

@@ -0,0 +1,248 @@
/**
* 消息模块
* 处理聊天消息的发送、接收和显示
*/
import { showNotification, generateId } from './utils.js';
import store from './store.js';
import { mockMessages } from './models.js';
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
// 消息相关的状态管理方法
let messageState = {
messages: [...mockMessages],
unreadCount: 0,
isSidebarOpen: false
};
let listeners = [];
/**
* 订阅状态变化
* @param {Function} callback - 回调函数
* @returns {Function} 取消订阅的函数
*/
function subscribe(callback) {
listeners.push(callback);
return () => {
listeners = listeners.filter(cb => cb !== callback);
};
}
/**
* 通知所有监听器
* @param {Object} changes - 变化对象
*/
function notify(changes) {
listeners.forEach(cb => cb(messageState, changes));
}
/**
* 添加消息
* @param {Object} message - 消息对象
*/
function addMessage (message) {
messageState.messages.push(message);
// 如果侧边栏关闭且不是自己发的,增加未读
if (!messageState.isSidebarOpen && !message.isSelf) {
messageState.unreadCount++;
notify({ type: 'SIDEBAR_TOGGLE', unreadCount: messageState.unreadCount });
}
notify({ type: 'NEW_MESSAGE', message, unreadCount: messageState.unreadCount });
}
/**
* 发送聊天消息
* @param {Object} message - 消息对象
* @param {Object} renderstreaming - WebRTC连接管理实例
*/
function sendChatMessage(message) {
if (store.getRenderStreaming()) {
store.getRenderStreaming().sendMessage({
type: 'chat-message',
message: message,
});
}
}
/**
* 处理接收到的聊天消息
* @param {Object} data - 消息数据
*/
function handleChatMessage(data) {
console.log('处理聊天:', data);
addMessage(data);
const isImage = data.content && data.content.startsWith('data:image/');
// 显示通知
if (!data.isSelf) {
const content = isImage ? '[图片]' : data.content;
showNotification(`${data.senderName}: ${content.substring(0, 20)}${content.length > 20 ? '...' : ''}`);
}
}
/**
* 切换侧边栏
* @returns {boolean} 切换后的状态
*/
function toggleSidebar() {
messageState.isSidebarOpen = !messageState.isSidebarOpen;
if (messageState.isSidebarOpen) {
messageState.unreadCount = 0;
}
notify({ type: 'SIDEBAR_TOGGLE', isOpen: messageState.isSidebarOpen, unreadCount: messageState.unreadCount });
return messageState.isSidebarOpen;
}
/**
* 获取消息状态
* @returns {Object} 消息状态
*/
function getMessageState() {
return messageState;
}
/**
* 发送消息
*/
function sendMessage() {
const chatInput = document.getElementById('chatInput');
const content = chatInput.value.trim();
if (content) {
const state = store.getState();
const message = {
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
};
addMessage(message);
const newMessage = { ...message };
newMessage.isSelf = false;
chatInput.value = '';
// 发送消息到服务器
sendChatMessage(newMessage);
//wsManager.send('chat-message', message);
}
}
/**
* 处理聊天输入回车
* @param {KeyboardEvent} event - 键盘事件
*/
function handleChatSubmit(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
/**
* 打开图片选择器
*/
function openImagePicker() {
document.getElementById('imageInput').click();
}
/**
* 处理图片上传
* @param {Event} event - 事件对象
*/
function handleImageUpload(event) {
const file = event.target.files[0];
if (file) {
// 检查文件类型
if (!file.type.startsWith('image/')) {
showNotification('请选择图片文件', 3000);
return;
}
// 检查文件大小限制为5MB
if (file.size > MAX_IMAGE_SIZE) {
showNotification('图片文件不能超过5MB', 3000);
return;
}
// 读取图片文件
const reader = new FileReader();
reader.onload = function (e) {
const imageUrl = e.target.result;
sendImageMessage(imageUrl, file.name);
};
reader.readAsDataURL(file);
// 重置文件输入
event.target.value = '';
}
}
/**
* 发送图片消息
* @param {string} imageUrl - 图片URL
* @param {string} fileName - 文件名
*/
function sendImageMessage(imageUrl, fileName) {
const state = store.getState();
const newMessage = {
id: generateId(),
senderId: state.session.localUser.id,
senderName: state.session.localUser.name,
senderAvatar: state.session.localUser.avatar,
content: imageUrl,
fileName: fileName,
type: 'file',
timestamp: new Date().toISOString(),
isSelf: true
};
// 添加消息到本地列表
addMessage(newMessage);
// 发送消息到服务器
const messageToSend = { ...newMessage };
messageToSend.isSelf = false;
sendChatMessage(messageToSend);
}
/**
* 绑定消息相关的DOM事件
*/
function bindMessageEvents() {
// 发送消息
window.sendMessage = sendMessage;
// 处理聊天输入回车
window.handleChatSubmit = handleChatSubmit;
// 打开图片选择器
window.openImagePicker = openImagePicker;
// 处理图片上传
window.handleImageUpload = handleImageUpload;
}
// 导出所有函数
export default {
sendMessage,
handleChatSubmit,
openImagePicker,
handleImageUpload,
sendImageMessage,
bindMessageEvents,
addMessage,
sendChatMessage,
handleChatMessage,
toggleSidebar,
getMessageState,
subscribe
};

View File

@@ -0,0 +1,458 @@
# onebyone 模块代码调用结构图
> 本文档基于优化后的代码自动生成,反映当前实际架构。
---
## 3.1 文件依赖关系图
```mermaid
graph TD
main[main.js] --> store[store.js]
main --> renderer[renderer.js]
main --> utils[utils.js]
main --> chatmsg[chatmessage.js]
store --> models[models.js]
store --> utils
store --> chatmsg
store --> signaling[../../module/signaling.js]
store --> rs[../../module/renderstreaming.js]
store --> config[../js/config.js]
renderer --> utils
renderer --> models
renderer --> chatmsg
renderer -.-> store
chatmsg --> utils
chatmsg --> store
chatmsg --> models
connect[connect/connect.js] --> store
connect --> utils
endcall[endcall/endcall.js] --> utils
index[index.html] --> main
connectHtml[connect/connect.html] --> connect
endcallHtml[endcall/endcall.html] --> endcall
style utils fill:#e1f5fe
style models fill:#e1f5fe
style signaling fill:#fff3e0
style rs fill:#fff3e0
style config fill:#fff3e0
```
**图例说明**
- 蓝色:内部工具/数据模块
- 橙色外部依赖模块signaling.js、renderstreaming.js、config.js
---
## 3.2 核心调用链
### 通话初始化流程
```mermaid
sequenceDiagram
autonumber
participant Browser
participant main as main.js
participant store as store.js
participant render as renderer.js
participant RS as RenderStreaming
participant Sig as Signaling
Browser->>main: DOMContentLoaded
main->>main: 检查 localStorage.connectionId
alt 无连接ID
main->>Browser: 跳转 connect/connect.html
else 有连接ID
main->>render: new UIRenderer(store)
render->>store: subscribe(render)
main->>store: joinCall(connectionId)
store->>store: init()
store->>store: setupConfig() / getServerConfig()
store->>store: loadUserSettings()
store->>store: getLocalStream()
store->>Browser: getUserMedia(MEDIA_CONSTRAINTS)
Browser-->>store: MediaStream
store->>store: notify(LOCAL_STREAM_OBTAINED)
store->>store: notify(LOCAL_MEDIA_CHANGE x2)
store->>store: emitMediaStateChange()
main->>store: setUp(connectionId)
store->>store: _createSignalingAndRTC()
store->>Sig: new WebSocketSignaling() / new Signaling()
store->>RS: new RenderStreaming(signaling, config)
store->>store: _registerCallbacks()
store->>store: _startConnection()
store->>RS: start()
store->>RS: createConnection(connectionId)
RS-->>store: onConnect(role, participantId)
store->>store: notify(CALL_STATUS_CHANGE, ongoing)
store->>store: sendMessage(user-info)
store->>store: emitMediaStateChange()
main->>main: bindDomEvents()
end
```
### 媒体控制流程
```mermaid
sequenceDiagram
autonumber
participant User
participant main as main.js
participant store as store.js
participant render as renderer.js
participant RS as RenderStreaming
participant Remote as 远端
User->>main: 点击 toggleMute / toggleVideo
main->>store: updateLocalMedia(type, value)
alt 开启视频
store->>Browser: getUserMedia(VIDEO_ONLY_CONSTRAINT)
Browser-->>store: newVideoTrack
store->>RS: replaceTrack / addTransceiver
store->>store: notify(LOCAL_STREAM_OBTAINED)
store->>store: notify(LOCAL_MEDIA_CHANGE, video=true)
store->>store: emitMediaStateChange()
else 关闭视频
store->>store: track.stop()
store->>store: notify(LOCAL_MEDIA_CHANGE, video=false)
store->>store: emitMediaStateChange()
else 切换音频
store->>store: track.enabled = value
store->>store: notify(LOCAL_MEDIA_CHANGE, audio=value)
store->>store: emitMediaStateChange()
end
store->>render: notify(USER_LIST_UPDATE)
render->>render: renderUserList()
render->>render: renderControlButtons()
render->>render: renderLocalVideo()
store->>RS: sendMessage(media-state-changed)
RS->>Remote: WebSocket 信令
Remote-->>store: onMessage(media-state-changed)
store->>store: updateRemoteMedia()
store->>render: notify(REMOTE_MEDIA_CHANGE)
render->>render: renderRemoteVideo()
render->>render: renderUserList()
render->>render: renderParticipantVideoPlaceholder()
```
### 消息发送流程
```mermaid
sequenceDiagram
autonumber
participant User
participant chat as chatmessage.js
participant store as store.js
participant render as renderer.js
participant RS as RenderStreaming
participant Remote as 远端
User->>chat: 输入消息 / 回车
chat->>chat: sendMessage()
chat->>chat: addMessage() 本地
chat->>chat: notify(NEW_MESSAGE)
render->>render: renderMessageState(NEW_MESSAGE)
render->>render: renderChatMessages()
render->>render: renderUnreadCount()
chat->>store: getRenderStreaming()
chat->>RS: sendMessage(chat-message)
RS->>Remote: WebSocket 信令
Remote-->>chat: handleChatMessage(data)
chat->>chat: addMessage() 远端
chat->>chat: notify(NEW_MESSAGE)
render->>render: renderMessageState(NEW_MESSAGE)
render->>render: renderChatMessages()
render->>render: renderUnreadCount()
User->>chat: 点击 toggleSidebar()
chat->>chat: notify(SIDEBAR_TOGGLE)
render->>render: renderMessageState(SIDEBAR_TOGGLE)
render->>render: renderSidebar()
```
### Participant 管理流程
```mermaid
sequenceDiagram
autonumber
participant store as store.js
participant render as renderer.js
participant RS as RenderStreaming
participant Remote as 远端Participant
Note over RS,Remote: 新 Participant 加入
RS-->>store: onParticipantJoined(participantId)
store->>store: 初始化 participant 默认信息
store->>store: notify(PARTICIPANTS_UPDATE)
store->>RS: broadcastParticipantsList()
render->>render: renderUserList()
render->>render: syncParticipantTileNames()
Note over RS,Remote: Participant 离开
RS-->>store: onParticipantLeft(participantId)
store->>store: 清理 remoteStreams / participants
store->>store: notify(PARTICIPANT_LEFT)
store->>store: notify(PARTICIPANTS_UPDATE)
store->>RS: broadcastParticipantsList()
render->>render: renderParticipantLeft()
render->>render: renderUserList()
Note over RS,Remote: 成员列表同步
RS-->>store: onMessage(participants-sync)
store->>store: 过滤自身条目 -> state.participants
store->>store: notify(PARTICIPANTS_UPDATE)
render->>render: renderUserList()
render->>render: syncParticipantTileNames()
```
---
## 3.3 状态变化流转图
### Store -> Renderer 状态流转
```mermaid
graph LR
subgraph StoreNotify[store.js notify 类型]
A[INIT]
B[LOCAL_STREAM_OBTAINED]
C[LOCAL_MEDIA_CHANGE]
D[REMOTE_STREAM_OBTAINED]
E[REMOTE_MEDIA_CHANGE]
F[USER_LIST_UPDATE]
G[PARTICIPANTS_UPDATE]
H[NETWORK_CHANGE]
I[CALL_STATUS_CHANGE]
J[CALL_ENDED]
K[PARTICIPANT_LEFT]
L[DURATION_UPDATE]
end
subgraph RenderMethod[renderer.js 渲染方法]
RM1[renderRemoteVideo]
RM2[renderLocalVideo]
RM3[renderLocalStream]
RM4[renderRemoteStream]
RM5[renderControlButtons]
RM6[renderUserList]
RM7[renderNetworkStatus]
RM8[renderCallStatus]
RM9[renderCallEnded]
RM10[renderParticipantLeft]
RM11[renderCallDuration]
RM12[renderHeader]
RM13[renderParticipantVideoPlaceholder]
RM14[syncParticipantTileNames]
end
A --> RM1 & RM2 & RM5 & RM6 & RM12
B --> RM3 & RM2
C --> RM5 & RM2 & RM6
D --> RM4
E --> RM1 & RM6 & RM13
F --> RM6
G --> RM6 & RM14
H --> RM7
I --> RM8
J --> RM9
K --> RM10
L --> RM11
```
### chatMessage -> Renderer 状态流转
```mermaid
graph LR
subgraph ChatNotify[chatmessage.js notify 类型]
N[NEW_MESSAGE]
S[SIDEBAR_TOGGLE]
end
subgraph MsgRender[renderer.js 消息渲染]
MR1[renderChatMessages]
MR2[renderUnreadCount]
MR3[renderSidebar]
end
N --> MR1 & MR2
S --> MR3 & MR2
```
---
## 3.4 各模块导出函数清单
### main.js
| 导出 | 类型 | 说明 |
|------|------|------|
| store | 变量 | 重新导出 store 单例,供外部调试使用 |
**内部全局函数(绑定到 window**
| 函数 | 说明 |
|------|------|
| toggleSidebar | 切换侧边栏显示 |
| toggleMute | 切换麦克风状态 |
| toggleVideo | 切换摄像头状态 |
| toggleLocalVideo | 本地视频悬停控制 |
| toggleRecording | 切换录屏状态 |
| endCall | 显示结束通话确认对话框 |
| cancelEndCall | 取消结束通话 |
| confirmEndCall | 确认结束通话,调用 store.endCall() |
| showCallRequest | 显示通话请求弹窗 |
| rejectCall | 拒绝通话请求 |
| acceptCall | 接受通话请求,初始化通话 |
### store.js (CallStateManager 类 / store 单例)
| 方法 | 说明 |
|------|------|
| subscribe(callback) | 订阅状态变化 |
| notify(changes) | 通知所有监听器 |
| init() | 初始化配置、用户设置、本地流 |
| loadUserSettings() | 从 localStorage 加载用户设置 |
| setupConfig() | 获取服务器配置WebSocket 模式) |
| getLocalStream() | 获取本地摄像头媒体流 |
| updateLocalMedia(mediaType, value) | 更新本地媒体状态(音频/视频/录屏) |
| _createSignalingAndRTC(connectionId) | 创建信令和 RTC 实例 |
| setUp(connectionId) | 设置 WebRTC 连接入口 |
| _registerCallbacks() | 注册所有 WebRTC 回调 |
| _startConnection(connectionId) | 启动连接和检测 |
| hangUp() | 挂断连接,清理资源 |
| sendMessage(type, data) | 通过 RenderStreaming 发送消息 |
| broadcastParticipantsList() | Host 端广播成员列表 |
| setCodecPreferences(participantId) | 设置 H264 编解码器偏好 |
| updateRemoteMedia(mediaState, participantId) | 更新远端媒体状态 |
| updateRemoteUserStatus(status) | 更新远端用户在线状态 |
| updateRemoteUserNetworkQuality(q) | 更新远端网络质量 |
| endCall() | 用户主动结束通话入口 |
| joinCall(connectionId) | 加入通话 |
| createCall() | 创建通话 |
| detectNetworkQuality() | 基于 WebRTC stats 检测网络质量 |
| startActivityDetection(stream, opts) | 启动音频活动检测VAD |
| startNetworkQualityDetection() | 启动定时网络质量检测 |
| stopNetworkQualityDetection() | 停止网络质量检测 |
| emitMediaStateChange() | 发送媒体状态变化信令 |
| showStatsMessage() | 显示并定时输出 WebRTC 统计信息 |
| clearStatsMessage() | 清除统计定时器 |
| getState / getLocalUser / getRemoteUser / getConnectionId / getRenderStreaming | Getter 方法 |
### renderer.js (UIRenderer 类)
| 方法 | 说明 |
|------|------|
| constructor(stateManager) | 构造函数,订阅 store 和 chatMessage |
| render(state, changes) | 核心渲染分发器,根据 changes.type 路由 |
| renderMessageState(msgState, changes) | 消息状态渲染分发器 |
| renderCallStatus(status) | 渲染通话状态(连接中覆盖层) |
| renderHeader(session) | 渲染头部信息 |
| renderHeaderTitle() | 渲染标题(含 connectionId |
| renderCallDuration(duration) | 渲染通话时长 |
| renderRemoteVideo(remoteUser) | 渲染远端视频和占位符 |
| renderHeaderNetworkStatus(q) | 渲染头部网络质量文本 |
| renderLocalVideo(localUser, stream) | 渲染本地视频和占位符 |
| renderLocalStream(stream) | 将本地流绑定到 video 元素 |
| renderRemoteStream(stream, id, isHost) | 远端流渲染分发 |
| renderParticipantStream(stream, id) | Host 端多 participant 视频网格渲染 |
| renderParticipantVideoPlaceholder(id, show) | 精准更新 participant tile 占位背景 |
| syncParticipantTileNames(participants) | 同步所有 tile 名称标签 |
| updateParticipantTileName(id, name) | 更新指定 tile 名称 |
| renderSingleRemoteStream(stream) | Participant 端单路远端视频渲染 |
| renderLocalUserStatus(localUser) | 渲染本地用户媒体状态文本 |
| renderUserList(local, remote, participants) | 渲染侧边栏用户列表(支持多 participant |
| createUserEntry(options) | 创建通用用户条目 DOM |
| getVideoResolution(track) | 获取视频轨道分辨率 |
| adjustVideoSize(video, resolution) | 调整视频元素尺寸 |
| renderControlButtons(mediaState) | 渲染底部控制按钮状态 |
| renderChatMessages(messages) | 渲染聊天消息列表 |
| createMessageElement(message) | 创建单条消息 DOM |
| renderUnreadCount(count) | 渲染未读消息角标 |
| renderSidebar(isOpen) | 渲染侧边栏显隐 |
| renderNetworkStatus(quality) | 渲染网络状态提示 |
| updateHeaderNetworkIndicator(q) | 更新头部网络指示器颜色 |
| renderCallEnded() | 渲染通话结束,跳转 endcall 页面 |
| renderParticipantLeft(id) | 清理 participant tile |
| getStatusText / getNetworkQualityText | 状态/质量文本映射 |
| destroy() | 销毁,取消订阅 |
### chatmessage.js
| 导出函数 | 说明 |
|----------|------|
| subscribe(callback) | 订阅消息状态变化 |
| addMessage(message) | 添加消息到列表 |
| sendChatMessage(message) | 通过 RenderStreaming 发送聊天消息 |
| handleChatMessage(data) | 处理接收到的聊天消息 |
| toggleSidebar() | 切换侧边栏,重置未读数 |
| getMessageState() | 获取当前消息状态 |
| sendMessage() | 用户发送消息入口(文本) |
| handleChatSubmit(event) | 回车发送处理 |
| openImagePicker() | 打开图片选择器 |
| handleImageUpload(event) | 处理图片上传(限制 5MB |
| sendImageMessage(url, fileName) | 发送图片消息 |
| bindMessageEvents() | 绑定消息相关 DOM 事件到 window |
### utils.js
| 导出函数 | 说明 |
|----------|------|
| formatTime(seconds) | 格式化为 MM:SS |
| formatTimestamp(timestamp) | 格式化为 HH:MM |
| generateId() | 生成随机唯一 ID |
| showNotification(message, duration) | 显示顶部通知 |
| toggleElement(element, show) | 切换元素显隐hidden 类) |
| toggleButtonState(button, active) | 切换按钮图标状态 |
### models.js
| 导出 | 类型 | 说明 |
|------|------|------|
| mockCallSession | 常量对象 | 默认通话会话数据结构(含 localUser / remoteUser |
| mockMessages | 常量数组 | 默认聊天消息数组(含系统消息) |
### connect/connect.js
| 全局函数 | 说明 |
|----------|------|
| joinCall() | 读取输入框 connectionId保存并跳转 |
| createCall() | 生成随机 connectionId保存并跳转 |
| getAllConnectionIds() | 从服务器获取所有连接 ID 列表 |
| displayConnectionIds(ids) | 渲染连接 ID 列表到 DOM |
| selectConnectionId(id) | 选择指定 ID 填充输入框 |
| saveSettings() | 保存用户设置到 localStorage |
| handleAvatarUpload(event) | 上传头像(限制 2MB |
| copyUserId() | 复制用户 ID 到剪贴板 |
| toggleSettingsMenu() | 切换设置菜单显隐 |
### endcall/endcall.js
| 全局函数 | 说明 |
|----------|------|
| reconnectCall() | 重新连接,跳转 index.html |
| leaveCall() | 清除 connectionId跳转 connect.html |
---
## 附录:页面跳转关系
```mermaid
graph LR
connect[connect/connect.html] -->|joinCall / createCall| index[index.html]
index -->|endCall| endcall[endcall/endcall.html]
endcall -->|reconnectCall| index
endcall -->|leaveCall| connect
index -->|无 connectionId| connect
```

View File

@@ -0,0 +1,114 @@
<!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="../css/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!-- 用户设置区域 -->
<div class="absolute top-4 right-4 z-10">
<button id="userSettingsBtn" class="flex items-center gap-2 glass px-3 py-2 rounded-full hover:bg-white/10 transition-colors">
<img id="userAvatar" src="/images/p1.png" class="w-8 h-8 rounded-full object-cover">
<span id="userName" class="text-sm font-medium"></span>
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
</button>
<!-- 设置菜单 -->
<div id="settingsMenu" class="hidden absolute top-full right-0 mt-2 glass rounded-xl shadow-lg w-48 z-20">
<div class="p-4 border-b border-white/10">
<h3 class="text-sm font-medium mb-2">个人设置</h3>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-400 mb-1">昵称</label>
<input type="text" id="nicknameInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="输入昵称">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">头像</label>
<div class="flex items-center gap-3">
<img id="avatarPreview" src="/images/p1.png" class="w-10 h-10 rounded-full object-cover">
<input type="file" id="avatarInput" accept="image/*" class="hidden" onchange="handleAvatarUpload(event)">
<button onclick="document.getElementById('avatarInput').click()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">更换头像</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">用户ID</label>
<div class="flex items-center gap-2">
<input type="text" id="userIdInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" readonly>
<button onclick="copyUserId()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">复制</button>
</div>
</div>
</div>
</div>
<div class="p-2">
<button onclick="saveSettings()" class="w-full px-4 py-2 text-sm text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors">保存设置</button>
</div>
</div>
</div>
<!--
============================================================
初始连接界面
============================================================
-->
<div class="h-full w-full flex items-center justify-center bg-black/90">
<div class="text-center max-w-md px-8">
<div class="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-8 shadow-lg">
<i class="fas fa-video text-white text-4xl"></i>
</div>
<h1 class="text-3xl font-bold text-white mb-2">VideoCall</h1>
<p class="text-gray-400 mb-8">一对一视频通话</p>
<div class="space-y-4 mb-8">
<div class="glass rounded-xl p-4">
<label class="block text-sm font-medium text-gray-300 mb-2">连接ID</label>
<input type="text"
id="connectionIdInput"
placeholder="输入连接ID"
class="w-full bg-transparent border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
autocomplete="off">
</div>
<p class="text-xs text-gray-500">
连接ID是用于建立点对点通话的唯一标识由发起方生成并分享给接收方。
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-6">
<button id="connectBtn" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-phone"></i>
<span>加入通话</span>
</button>
<button id="createCallBtn" class="flex-1 px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-plus"></i>
<span>创建通话</span>
</button>
</div>
<!-- 浏览全部ID按钮 -->
<button id="browseIdsBtn" class="w-full px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2 mb-4">
<i class="fas fa-list"></i>
<span>浏览全部ID</span>
</button>
<!-- 连接ID列表 -->
<div id="connectionIdsList" class="glass rounded-xl p-4 mb-6 hidden">
<h3 class="text-sm font-medium text-gray-300 mb-2">可用的连接ID</h3>
<div id="idsContainer" class="max-h-40 overflow-y-auto space-y-2">
<!-- 连接ID将在这里动态生成 -->
</div>
</div>
</div>
</div>
<!-- 通知组件 -->
<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>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="connect.js"></script>
</body>
</html>

View File

@@ -0,0 +1,323 @@
/**
* 连接界面逻辑
* 处理初始连接、创建通话和加入通话的功能
*/
import { showNotification } from '../utils.js';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
// 加入通话
function joinCall() {
const connectionId = document.getElementById('connectionIdInput').value.trim();
if (connectionId) {
showNotification(`正在加入通话 (${connectionId})`);
// 保存连接ID到本地存储
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
} else {
showNotification('请输入连接ID', 'error');
}
}
// 创建通话
function createCall() {
showNotification('正在创建通话...');
// 生成随机连接ID
const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
// 保存连接ID到本地存储
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
}
// 获取所有连接ID
async function getAllConnectionIds() {
showNotification('正在获取连接ID列表...');
try {
const response = await fetch('/signaling/connection-ids');
if (!response.ok) {
throw new Error('Failed to fetch connection IDs');
}
const data = await response.json();
displayConnectionIds(data.connectionIds);
} catch (error) {
console.error('Error fetching connection IDs:', error);
showNotification('获取连接ID失败', 'error');
}
}
// 显示连接ID列表
function displayConnectionIds(connectionIds) {
const idsContainer = document.getElementById('idsContainer');
const connectionIdsList = document.getElementById('connectionIdsList');
if (idsContainer) {
// 清空容器
idsContainer.innerHTML = '';
if (connectionIds.length === 0) {
idsContainer.innerHTML = '<p class="text-gray-500 text-sm">暂无可用的连接ID</p>';
} else {
// 为每个连接ID创建一个元素
connectionIds.forEach(id => {
const idElement = document.createElement('div');
idElement.className = 'flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 cursor-pointer transition-colors';
idElement.innerHTML = `
<span class="text-sm">${id}</span>
<button class="text-xs bg-indigo-600 hover:bg-indigo-700 px-2 py-1 rounded" onclick="selectConnectionId('${id}')">选择</button>
`;
idsContainer.appendChild(idElement);
});
}
// 显示连接ID列表
if (connectionIdsList) {
connectionIdsList.classList.remove('hidden');
}
showNotification(`找到 ${connectionIds.length} 个连接ID`);
}
}
// 选择连接ID
function selectConnectionId(id) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = id;
showNotification(`已选择连接ID: ${id}`);
}
}
// 绑定事件监听器
function bindEvents() {
// 连接按钮
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) {
connectBtn.addEventListener('click', joinCall);
}
// 创建通话按钮
const createCallBtn = document.getElementById('createCallBtn');
if (createCallBtn) {
createCallBtn.addEventListener('click', createCall);
}
// 浏览全部ID按钮
const browseIdsBtn = document.getElementById('browseIdsBtn');
if (browseIdsBtn) {
browseIdsBtn.addEventListener('click', getAllConnectionIds);
}
// 输入框回车事件
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
joinCall();
}
});
}
}
// 生成8位的用户ID
function generateUserId() {
// 生成8位随机字符串包含字母和数字
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = 'user_';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// 加载用户设置
function loadUserSettings() {
const defaultAvatar = '/images/p1.png';
const userSettings = localStorage.getItem('userSettings');
if (userSettings) {
try {
const settings = JSON.parse(userSettings);
// 设置用户ID
if (settings.userId) {
document.getElementById('userIdInput').value = settings.userId;
}
// 设置昵称
if (settings.name) {
document.getElementById('nicknameInput').value = settings.name;
document.getElementById('userName').textContent = settings.name;
}
// 设置头像
const avatar = settings.avatar || defaultAvatar;
document.getElementById('userAvatar').src = avatar;
document.getElementById('avatarPreview').src = avatar;
} catch (error) {
console.error('Error loading user settings:', error);
// 加载失败时使用默认头像
const defaultAvatar = '/images/p1.png';
document.getElementById('userAvatar').src = defaultAvatar;
document.getElementById('avatarPreview').src = defaultAvatar;
}
} else {
// 生成新的用户ID
const newUserId = generateUserId();
document.getElementById('userIdInput').value = newUserId;
// 使用默认头像
const defaultAvatar = '/images/p1.png';
document.getElementById('userAvatar').src = defaultAvatar;
document.getElementById('avatarPreview').src = defaultAvatar;
// 保存默认设置
saveSettings();
}
}
// 保存用户设置
function saveSettings() {
const defaultAvatar = '/images/p1.png';
const settings = {
userId: document.getElementById('userIdInput').value,
name: document.getElementById('nicknameInput').value || '我',
avatar: document.getElementById('avatarPreview').src || defaultAvatar
};
localStorage.setItem('userSettings', JSON.stringify(settings));
// 更新界面显示
document.getElementById('userName').textContent = settings.name;
document.getElementById('userAvatar').src = settings.avatar;
showNotification('设置已保存', 'success');
}
// 处理头像上传
function handleAvatarUpload(event) {
const file = event.target.files[0];
if (file) {
// 检查文件类型
if (!file.type.startsWith('image/')) {
showNotification('请选择图片文件', 'error');
return;
}
// 检查文件大小
if (file.size > MAX_AVATAR_SIZE) { // 2MB限制
showNotification('图片大小不能超过2MB', 'error');
return;
}
// 创建FormData对象
const formData = new FormData();
formData.append('avatar', file);
formData.append('userId', document.getElementById('userIdInput').value);
// 显示上传中通知
showNotification('正在上传头像...');
// 上传头像到服务器
fetch('/api/upload/avatar', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('上传失败');
}
return response.json();
})
.then(data => {
if (data.success && data.avatarUrl) {
// 更新预览和本地存储
const avatarUrl = data.avatarUrl;
document.getElementById('avatarPreview').src = avatarUrl;
document.getElementById('userAvatar').src = avatarUrl;
// 保存设置
saveSettings();
showNotification('头像上传成功', 'success');
} else {
throw new Error('上传失败:' + (data.message || '未知错误'));
}
})
.catch(error => {
console.error('Error uploading avatar:', error);
showNotification('头像上传失败,请重试', 'error');
// 上传失败时,使用默认头像
const defaultAvatar = '/images/p1.png';
document.getElementById('avatarPreview').src = defaultAvatar;
});
}
}
// 复制用户ID到剪贴板
function copyUserId() {
const userIdInput = document.getElementById('userIdInput');
userIdInput.select();
document.execCommand('copy');
showNotification('用户ID已复制到剪贴板', 'success');
}
// 切换设置菜单
function toggleSettingsMenu() {
const settingsMenu = document.getElementById('settingsMenu');
settingsMenu.classList.toggle('hidden');
}
// 点击外部关闭设置菜单
document.addEventListener('click', function(event) {
const settingsMenu = document.getElementById('settingsMenu');
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (!settingsMenu.contains(event.target) && !userSettingsBtn.contains(event.target)) {
settingsMenu.classList.add('hidden');
}
});
// 绑定用户设置相关事件
function bindUserSettingsEvents() {
// 设置按钮点击事件
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (userSettingsBtn) {
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
}
}
// 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', () => {
bindEvents();
bindUserSettingsEvents();
// 检查本地存储中是否有连接ID
const savedConnectionId = localStorage.getItem('connectionId');
if (savedConnectionId) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = savedConnectionId;
}
}
// 加载用户设置
loadUserSettings();
});
// 导出全局函数
window.joinCall = joinCall;
window.createCall = createCall;
window.selectConnectionId = selectConnectionId;
window.saveSettings = saveSettings;
window.handleAvatarUpload = handleAvatarUpload;
window.copyUserId = copyUserId;
window.toggleSettingsMenu = toggleSettingsMenu;

View File

@@ -0,0 +1,255 @@
@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;
margin-bottom: 12px;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
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;
}
/* 图片消息样式 */
.message-image-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
.message-image-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.message-text {
word-wrap: break-word;
}
.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; }*/
/* 分辨率选项样式 */
.resolution-option {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
}
.resolution-option:hover {
color: white;
}
.resolution-option.active {
background: rgba(99, 102, 241, 0.3);
color: white;
}
.resolution-option.active::before {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 10px;
margin-right: 6px;
color: #818cf8;
}
/* 更多选项菜单动画 */
#moreOptionsMenu {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}

View File

@@ -0,0 +1,50 @@
<!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="../css/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--
============================================================
结束通话界面
============================================================
-->
<div class="h-full w-full flex items-center justify-center bg-black/80">
<div class="text-center max-w-md px-8">
<div class="w-20 h-20 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<i class="fas fa-video-slash text-white text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-white mb-4">通话已结束</h2>
<p class="text-gray-400 mb-8" id="disconnectReason">连接已断开,请检查网络连接后重试</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button id="reconnectBtn" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-redo"></i>
<span>重新连接</span>
</button>
<button id="leaveBtn" class="px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-sign-out-alt"></i>
<span>返回</span>
</button>
</div>
<div class="mt-8 text-xs text-gray-500">
<p>连接ID: <span id="disconnectConnectionId">--</span></p>
<p>断开时间: <span id="disconnectTime">--</span></p>
</div>
</div>
</div>
<!-- 通知组件 -->
<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>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="endcall.js"></script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
/**
* 结束通话界面逻辑
* 处理通话结束后的操作,如重新连接或返回连接界面
*/
import { showNotification } from '../utils.js';
// 重新连接
function reconnectCall() {
showNotification('正在重新连接...');
// 跳转到通话界面
window.location.href = '../index.html';
}
// 离开
function leaveCall() {
// 清除本地存储中的连接ID
localStorage.removeItem('connectionId');
// 跳转到连接界面
window.location.href = '../connect/connect.html';
}
// 绑定事件监听器
function bindEvents() {
// 重新连接按钮
const reconnectBtn = document.getElementById('reconnectBtn');
if (reconnectBtn) {
reconnectBtn.addEventListener('click', reconnectCall);
}
// 离开按钮
const leaveBtn = document.getElementById('leaveBtn');
if (leaveBtn) {
leaveBtn.addEventListener('click', leaveCall);
}
}
// 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', () => {
bindEvents();
// 更新断开连接信息
const disconnectConnectionId = document.getElementById('disconnectConnectionId');
const disconnectTime = document.getElementById('disconnectTime');
if (disconnectConnectionId) {
disconnectConnectionId.textContent = localStorage.getItem('connectionId') || '--';
}
if (disconnectTime) {
disconnectTime.textContent = new Date().toLocaleString();
}
});
// 导出全局函数
window.reconnectCall = reconnectCall;
window.leaveCall = leaveCall;

View File

@@ -0,0 +1,614 @@
<!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="css/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--
============================================================
注意:此文件为视频通话界面
初始连接界面请访问 connect.html
============================================================
-->
<!--
============================================================
数据模型定义 (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-3 text-xs text-gray-400">
<span id="remoteNetworkIndicator" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
<span id="remoteNetworkQuality" class="flex items-center gap-1">
<i class="fas fa-signal"></i>
<span>优秀</span>
</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">
<!--
子区域: 多Participant视频网格Host端显示
动态生成每个participant一个视频格子
-->
<div id="participantGrid" class="hidden absolute inset-0 grid gap-3 p-3 auto-rows-fr" style="grid-template-columns: 1fr;">
<!-- 动态生成的 participant 视频格子将插入这里 -->
</div>
<!--
子区域: 远端视频 (Remote Video) - 单路Participant端显示Host画面
数据源: RemoteUser
-->
<div class="absolute inset-0 video-fade-in">
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
<video id="remoteVideo" alt="对方视频" class="w-full h-full object-contain" autoplay
data-field="remoteUser.videoStream">
</video>
<!-- 远端未连接时的占位背景 -->
<div id="remoteVideoPlaceholder"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80">
<div class="text-center">
<div
class="w-32 h-32 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-4">
<i class="fas fa-user text-4xl text-white/70"></i>
</div>
<p class="text-white text-lg font-medium">等待对方连接...</p>
<p class="text-sm text-gray-400 mt-2">请确保对方已加入通话</p>
</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] -->
<video id="localVideo"
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop" alt="本地视频"
class="w-full h-full object-cover" autoplay muted data-field="localUser.videoStream">
</video>
<!-- [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" id="userCountDisplay">
通话成员 (1)
</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="" 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.mediaState] [TRANSFORM: state => statusText] -->
<div class="text-xs text-gray-500" data-field="remoteUser.mediaStatus">在线</div>
</div>
<div class="flex items-center gap-2">
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.audio === false] -->
<i class="fas fa-microphone-slash text-gray-500 text-xs hidden"
data-field="remoteUser.muteIcon"></i>
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
<div class="audio-wave w-6 hidden" data-field="remoteUser.speakingIndicator">
<span></span><span></span><span></span><span></span><span></span>
</div>
</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="" 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"
onclick="openImagePicker()">
<i class="fas fa-plus"></i>
</button>
<!-- 隐藏的文件输入元素 -->
<input type="file" id="imageInput" accept="image/*" class="hidden"
onchange="handleImageUpload(event)">
<!-- [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>
<!-- 更多选项 -->
<div class="relative">
<button id="moreOptionsBtn"
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>
<!-- 更多选项下拉菜单 -->
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
<!-- 分辨率选项 -->
<div class="p-3 border-b border-white/10">
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
<i class="fas fa-desktop text-xs"></i>
视频分辨率
</h4>
<div class="space-y-1" id="resolutionOptions">
<button onclick="changeResolution(480, 270)" data-resolution="480"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>流畅 480p</span>
<span class="text-xs text-gray-500">省流量</span>
</button>
<button onclick="changeResolution(1280, 720)" data-resolution="720"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>高清 720p</span>
<span class="text-xs text-gray-500">推荐</span>
</button>
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>超清 1080p</span>
<span class="text-xs text-gray-500"></span>
</button>
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>2K 1440p</span>
<span class="text-xs text-gray-500">最高画质</span>
</button>
</div>
</div>
<!-- 当前分辨率指示 -->
<div class="px-3 py-2 flex items-center gap-2">
<i class="fas fa-info-circle text-xs text-gray-500"></i>
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
</div>
</div>
</div>
<!-- 结束通话 -->
<!-- [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 relative"
onclick="toggleSidebar()">
<i class="fas fa-comment-alt"></i>
<!-- 未读消息计数角标 -->
<span id="unreadBadge"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs font-bold text-white hidden">0</span>
</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>
<!-- 通话请求弹窗 -->
<div id="callRequestDialog" 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-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-video text-indigo-500 text-2xl"></i>
</div>
<h3 class="text-xl font-bold mb-2" id="callRequestName">Sarah Chen</h3>
<p class="text-gray-400 text-sm" id="callRequestText">正在请求与您进行视频通话</p>
<div class="mt-4 flex items-center justify-center gap-4">
<img id="callRequestAvatar"
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
class="w-16 h-16 rounded-full object-cover border-4 border-indigo-500">
</div>
</div>
<div class="flex gap-3">
<button id="rejectCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
<div class="flex items-center justify-center gap-2">
<i class="fas fa-phone-slash"></i>
<span>拒绝</span>
</div>
</button>
<button id="acceptCall"
class="flex-1 py-2 rounded-lg bg-green-500 hover:bg-green-600 transition-colors">
<div class="flex items-center justify-center gap-2">
<i class="fas fa-phone"></i>
<span>接受</span>
</div>
</button>
</div>
</div>
</div>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,365 @@
# OneByOne 知识图谱
## 1. 模块依赖关系图
```mermaid
graph TB
subgraph Page["📄 页面层"]
INDEX[index.html<br/>主通话页面]
CONN[connect/connect.html<br/>连接页面]
END[endcall/endcall.html<br/>结束页面]
end
subgraph Core["⚙️ 核心模块"]
MAIN[main.js<br/>应用入口]
STORE[store.js<br/>状态管理]
RENDER[renderer.js<br/>UI渲染器]
CHAT[chatmessage.js<br/>消息模块]
end
subgraph Base["📦 数据与工具"]
MODELS[models.js<br/>数据模型]
UTILS[utils.js<br/>工具函数]
end
subgraph External["🔗 外部依赖"]
SIGNALING[signaling.js<br/>信令管理]
RENDERSTR[renderstreaming.js<br/>WebRTC管理]
CONFIG[config.js<br/>配置管理]
end
INDEX --> MAIN
CONN --> CONN_JS[connect.js]
END --> END_JS[endcall.js]
MAIN --> STORE
MAIN --> RENDER
MAIN --> UTILS
MAIN --> CHAT
STORE --> MODELS
STORE --> SIGNALING
STORE --> RENDERSTR
STORE --> CONFIG
STORE --> UTILS
STORE --> CHAT
RENDER --> UTILS
RENDER --> MODELS
RENDER --> CHAT
CHAT --> UTILS
CHAT --> STORE
CHAT --> MODELS
CONN_JS --> STORE
style STORE fill:#f9f,stroke:#333,stroke-width:2px
style RENDER fill:#bbf,stroke:#333,stroke-width:2px
style CHAT fill:#bfb,stroke:#333,stroke-width:2px
```
## 2. 数据模型关系图
```mermaid
graph TB
subgraph Models["数据模型结构"]
SESSION[CallSession<br/>通话会话]
LOCAL[LocalUser<br/>本地用户]
REMOTE[RemoteUser<br/>远端用户]
MEDIA[MediaState<br/>媒体状态]
MSG[ChatMessage<br/>聊天消息]
end
SESSION --> LOCAL
SESSION --> REMOTE
LOCAL --> MEDIA
REMOTE --> MEDIA
style SESSION fill:#f96,stroke:#333,stroke-width:2px
style MEDIA fill:#9f6,stroke:#333,stroke-width:2px
```
### 模型属性说明
| 模型 | 属性 |
|-----|------|
| **CallSession** | id, type, status, startTime, duration, isEncrypted |
| **LocalUser** | id, name, avatar, isHost, mediaState |
| **RemoteUser** | id, name, avatar, status, networkQuality, mediaState |
| **MediaState** | audio, video, screenShare, recording, isSpeaking |
| **ChatMessage** | id, senderId, senderName, content, type, timestamp, isSelf |
## 3. 状态更新时序图
```mermaid
sequenceDiagram
autonumber
participant USER as 👤 用户
participant DOM as 🖱️ DOM事件
participant MAIN as ⚙️ main.js
participant STORE as 📦 store.js
participant RENDER as 🎨 renderer.js
participant UI as 🖥️ UI界面
USER->>DOM: 点击麦克风按钮
DOM->>MAIN: toggleMute()
MAIN->>STORE: updateLocalMedia('audio', false)
STORE->>STORE: 更新 mediaState.audio
STORE->>STORE: notify({type: 'LOCAL_MEDIA_CHANGE'})
STORE->>RENDER: 回调 render(state, changes)
RENDER->>RENDER: renderControlButtons(mediaState)
RENDER->>RENDER: renderLocalUserStatus(localUser)
RENDER->>UI: 更新DOM
UI->>USER: 显示更新后的界面
```
## 4. WebRTC 连接建立时序图
```mermaid
sequenceDiagram
autonumber
participant USER as 👤 用户
participant MAIN as ⚙️ main.js
participant STORE as 📦 store.js
participant SIGNAL as 📡 signaling.js
participant RTC as 🔗 renderstreaming.js
participant PEER as 👤 远端对等端
USER->>MAIN: 访问页面
MAIN->>STORE: joinCall(connectionId)
STORE->>STORE: init()
STORE->>STORE: getLocalStream()<br/>获取本地摄像头
MAIN->>STORE: setUp(connectionId)
STORE->>SIGNAL: 创建信令实例
STORE->>RTC: new RenderStreaming<br/>(signaling, config)
RTC->>SIGNAL: start()
RTC->>SIGNAL: createConnection(connectionId)
loop WebSocket信令交换
PEER->>SIGNAL: 交换SDP/ICE
SIGNAL->>RTC: 信令数据
end
RTC->>STORE: onConnect 回调
STORE->>STORE: 状态更新为 ongoing
PEER->>RTC: 接收媒体流
RTC->>STORE: onTrackEvent 回调
STORE->>STORE: notify(REMOTE_STREAM_OBTAINED)
STORE->>RENDER: 触发渲染更新
RENDER->>UI: 显示远端视频
```
## 5. 消息发送流程
```mermaid
sequenceDiagram
autonumber
participant USER as 👤 用户
participant DOM as 🖱️ DOM
participant CHAT as 💬 chatmessage.js
participant STORE as 📦 store.js
participant RTC as 🔗 renderstreaming.js
participant PEER as 👤 远端用户
USER->>DOM: 输入消息并回车
DOM->>CHAT: handleChatSubmit(event)
CHAT->>CHAT: sendMessage()
CHAT->>CHAT: addMessage(message)<br/>添加到本地列表
CHAT->>STORE: getRenderStreaming()
STORE-->>CHAT: 返回实例
CHAT->>RTC: sendMessage()<br/>发送chat-message
RTC->>PEER: 通过WebRTC数据通道发送
PEER->>RTC: 收到消息
RTC->>STORE: onMessage 回调
STORE->>CHAT: handleChatMessage(data)
CHAT->>CHAT: addMessage()<br/>添加到消息列表
CHAT->>RENDER: 更新聊天UI
```
## 6. 页面流转状态图
```mermaid
stateDiagram-v2
[*] --> 连接页面: 首次访问/无connectionId
连接页面: connect.html
连接页面: - 输入连接ID
连接页面: - 创建新通话
连接页面: - 用户设置
通话页面: index.html
通话页面: - 视频通话
通话页面: - 聊天功能
通话页面: - 媒体控制
结束页面: endcall.html
结束页面: - 显示断开原因
结束页面: - 重新连接选项
连接页面 --> 通话页面: 加入/创建通话
通话页面 --> 结束页面: 通话结束/断开
通话页面 --> 连接页面: 无有效connectionId
结束页面 --> 通话页面: 重新连接
结束页面 --> 连接页面: 返回
```
## 7. 通话状态机
```mermaid
stateDiagram-v2
[*] --> idle: 初始化
idle: 空闲状态
idle --> connecting: joinCall()<br/>加入通话
idle --> connecting: createCall()<br/>创建通话
connecting: 连接中
connecting --> ongoing: onConnect<br/>连接成功
connecting --> failed: 连接失败
ongoing: 通话中
ongoing --> ended: hangUp()<br/>挂断
ongoing --> ended: endCall()<br/>结束通话
failed: 连接失败
failed --> connecting: 重试连接
failed --> ended: 放弃连接
ended: 已结束
ended --> [*]: 清理资源
```
## 8. 组件层次结构
```mermaid
graph TD
subgraph index["index.html 组件结构"]
HEADER["🧭 Header 顶部栏"]
MAIN["📺 Main 主内容区"]
FOOTER["🎛️ Footer 底部控制栏"]
end
subgraph header["Header 内容"]
TITLE["标题: 与xxx的通话"]
NETWORK["网络质量指示"]
DURATION["通话时长"]
ENCRYPT["端到端加密标识"]
end
subgraph main["Main 内容"]
VIDEO_AREA["VideoArea 视频区"]
SIDEBAR["Sidebar 侧边栏"]
end
subgraph video["VideoArea"]
REMOTE["RemoteVideo 远端视频"]
LOCAL["LocalVideo 本地视频画中画"]
end
subgraph sidebar["Sidebar"]
USER_LIST["UserList 用户列表"]
CHAT_MSG["ChatMessages 聊天消息"]
CHAT_INPUT["ChatInput 消息输入"]
end
subgraph footer["Footer 控制栏"]
CTRL_MIC["🎤 麦克风"]
CTRL_VIDEO["📹 视频"]
CTRL_RECORD["🔴 录制"]
CTRL_END["📞 结束通话"]
CTRL_CHAT["💬 聊天"]
end
HEADER --> TITLE
HEADER --> NETWORK
HEADER --> DURATION
HEADER --> ENCRYPT
MAIN --> VIDEO_AREA
MAIN --> SIDEBAR
VIDEO_AREA --> REMOTE
VIDEO_AREA --> LOCAL
SIDEBAR --> USER_LIST
SIDEBAR --> CHAT_MSG
SIDEBAR --> CHAT_INPUT
FOOTER --> CTRL_MIC
FOOTER --> CTRL_VIDEO
FOOTER --> CTRL_RECORD
FOOTER --> CTRL_END
FOOTER --> CTRL_CHAT
style CTRL_END fill:#f66,stroke:#333,stroke-width:2px
style ENCRYPT fill:#9f6,stroke:#333,stroke-width:2px
```
## 9. 文件引用汇总
```mermaid
graph LR
subgraph Import["导入关系"]
direction TB
M[main.js]
S[store.js]
R[renderer.js]
C[chatmessage.js]
CONN[connect.js]
M --> S
M --> R
M --> C
S --> C
R --> C
CONN --> S
end
subgraph Export["导出内容"]
direction TB
ST[store.js<br/>export default store]
RE[renderer.js<br/>export default UIRenderer]
UT[utils.js<br/>export { formatTime, showNotification, ... }]
MO[models.js<br/>export { mockCallSession, mockMessages }]
CH[chatmessage.js<br/>export { sendMessage, toggleSidebar, ... }]
end
```
## 10. API 接口调用图
```mermaid
graph LR
subgraph Client["客户端"]
CONN_JS[connect.js]
end
subgraph Server["服务器"]
API_CALL[/api/call/:callId]
API_JOIN[/api/call/:callId/join]
API_LEAVE[/api/call/:callId/leave]
API_MEDIA[/api/call/:callId/media]
API_MSG[/api/call/:callId/messages]
API_SEND[/api/call/:callId/message]
API_CONN[/signaling/connection-ids]
API_UPLOAD[/api/upload/avatar]
end
CONN_JS -.-> API_CONN
CONN_JS -.-> API_UPLOAD
style API_CALL fill:#bbf,stroke:#333
style API_JOIN fill:#bbf,stroke:#333
style API_LEAVE fill:#bbf,stroke:#333
style API_MEDIA fill:#bbf,stroke:#333
style API_MSG fill:#bbf,stroke:#333
style API_SEND fill:#bbf,stroke:#333
style API_CONN fill:#fbf,stroke:#333
style API_UPLOAD fill:#fbf,stroke:#333
```
---
*文档生成时间: 2026-04-11*
*适用于 OneByOne 视频通话应用*

View File

@@ -0,0 +1,215 @@
/**
* 主入口文件
* 初始化应用,连接各个模块
*/
import store from './store.js';
import UIRenderer from './renderer.js';
import { showNotification } from './utils.js';
import chatMessage from './chatmessage.js';
// 全局变量
let connectionId = "";
/**
* 绑定DOM事件
*/
function bindDomEvents() {
// 切换侧边栏
window.toggleSidebar = function () {
chatMessage.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.toggleMoreOptions = function () {
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.toggle('hidden');
}
};
// 切换视频分辨率
window.changeResolution = function (width, height) {
store.changeResolution(width, height);
// 关闭菜单
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.add('hidden');
}
};
// 结束通话
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.showCallRequest = function (caller) {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
// 设置通话请求信息
if (document.getElementById('callRequestName')) {
document.getElementById('callRequestName').textContent = caller.name;
}
if (document.getElementById('callRequestAvatar')) {
document.getElementById('callRequestAvatar').src = caller.avatar;
}
// 显示弹窗
dialog.classList.remove('hidden');
}
};
// 拒绝通话
window.rejectCall = function () {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
showNotification('已拒绝通话请求');
// 可以在这里添加发送拒绝通话请求到服务器的逻辑
};
// 接受通话
window.acceptCall = function () {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
showNotification('已接受通话请求');
// 可以在这里添加发送接受通话请求到服务器的逻辑
// 然后初始化通话
store.initCall();
store.setUp(connectionId);
};
// 绑定消息相关事件
chatMessage.bindMessageEvents();
// 键盘快捷键
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);
// 更多选项按钮事件
const moreOptionsBtn = document.getElementById('moreOptionsBtn');
if (moreOptionsBtn) {
moreOptionsBtn.addEventListener('click', window.toggleMoreOptions);
}
// 点击外部关闭更多选项菜单
document.addEventListener('click', function(event) {
const moreOptionsMenu = document.getElementById('moreOptionsMenu');
const moreOptionsBtnEl = document.getElementById('moreOptionsBtn');
if (moreOptionsMenu && moreOptionsBtnEl &&
!moreOptionsMenu.contains(event.target) &&
!moreOptionsBtnEl.contains(event.target)) {
moreOptionsMenu.classList.add('hidden');
}
});
// 绑定通话请求对话框事件
if (document.getElementById('rejectCall')) {
document.getElementById('rejectCall').addEventListener('click', window.rejectCall);
}
if (document.getElementById('acceptCall')) {
document.getElementById('acceptCall').addEventListener('click', window.acceptCall);
}
}
// 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', async () => {
try {
// 检查本地存储中是否有连接ID
const connectionId = localStorage.getItem('connectionId');
if (!connectionId) {
// 如果没有连接ID跳转到连接界面
window.location.href = './connect/connect.html';
return;
}
// 初始化 store
//await store.init();
// 初始化渲染器
const renderer = new UIRenderer(store);
// 加入通话
await store.joinCall(connectionId);
// 设置WebRTC连接
await store.setUp(connectionId);
renderer.renderHeaderTitle();
// 绑定DOM事件
bindDomEvents();
console.log('Video call app initialized successfully');
} catch (error) {
console.error('Error initializing app:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
});
// 导出全局变量
export { store };

View File

@@ -0,0 +1,112 @@
/**
* 类型定义和数据模型
*/
/**
* @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: "/images/p1.png",
isHost: true,
mediaState: {
audio: true,
video: true,
screenShare: false,
recording: false,
isSpeaking: false
}
},
// 远端用户信息
remoteUser: {
id: "user-remote-002",
name: "Unity",
avatar: "/images/p2.png",
status: "offline", // online | offline | connecting
networkQuality: "no_signal", // excellent | good | fair | poor | no_signal
mediaState: {
audio: true,
video: true,
screenShare: false,
recording: false,
isSpeaking: false
}
}
};
// 模拟聊天消息数据
const mockMessages = [
{
id: "msg-001",
senderId: "system",
senderName: "系统",
senderAvatar: "/images/screenshot.png",
content: "通话已建立连接",
type: "system",
timestamp: "2024-01-15T14:30:00.000Z",
isSelf: false
}
];
export { mockCallSession, mockMessages };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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');
}
}
}
}