|
|
// 全局变量
|
|
|
let ws = null;
|
|
|
let username = null;
|
|
|
let currentChat = null;
|
|
|
let chatHistory = {}; // 存储每个用户的聊天记录 { username: [messages] }
|
|
|
let mediaRecorder = null;
|
|
|
let audioChunks = [];
|
|
|
let isRecording = false;
|
|
|
|
|
|
// 服务器配置 - 自动检测或使用配置的地址
|
|
|
const SERVER_CONFIG = {
|
|
|
// 云服务器公网IP
|
|
|
publicIP: '120.46.87.202',
|
|
|
port: '8080',
|
|
|
|
|
|
// 获取WebSocket地址
|
|
|
getWebSocketUrl: function() {
|
|
|
// 如果通过浏览器访问,使用当前host
|
|
|
// 否则使用配置的公网IP
|
|
|
const host = window.location.host || `${this.publicIP}:${this.port}`;
|
|
|
return `ws://${host}`;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 登录函数
|
|
|
function login() {
|
|
|
const usernameInput = document.getElementById('usernameInput');
|
|
|
|
|
|
username = usernameInput.value.trim();
|
|
|
if (!username) {
|
|
|
alert('请输入用户名');
|
|
|
usernameInput.focus();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 连接WebSocket
|
|
|
connectWebSocket();
|
|
|
}
|
|
|
|
|
|
// 连接WebSocket
|
|
|
function connectWebSocket() {
|
|
|
try {
|
|
|
// 构建WebSocket URL
|
|
|
const wsUrl = SERVER_CONFIG.getWebSocketUrl();
|
|
|
console.log('连接到:', wsUrl);
|
|
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
console.log('WebSocket连接成功');
|
|
|
|
|
|
// 发送登录消息
|
|
|
const loginMsg = {
|
|
|
type: 'LOGIN',
|
|
|
sender: username
|
|
|
};
|
|
|
ws.send(JSON.stringify(loginMsg));
|
|
|
|
|
|
// 注意:不在这里切换界面,等待服务器确认
|
|
|
};
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
console.log('收到消息:', event.data);
|
|
|
try {
|
|
|
const message = JSON.parse(event.data);
|
|
|
handleMessage(message);
|
|
|
} catch (e) {
|
|
|
console.error('解析消息失败:', e);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
ws.onerror = (error) => {
|
|
|
console.error('WebSocket错误:', error);
|
|
|
if (document.getElementById('loginModal').classList.contains('hidden')) {
|
|
|
// 已登录状态下的错误
|
|
|
console.error('连接错误,可能是消息过大或网络问题');
|
|
|
} else {
|
|
|
// 登录过程中的错误
|
|
|
alert('连接失败,请检查网络连接');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
ws.onclose = (event) => {
|
|
|
console.log('WebSocket连接关闭, code:', event.code, 'reason:', event.reason);
|
|
|
if (!document.getElementById('chatContainer').classList.contains('hidden')) {
|
|
|
// 如果已经登录,显示断开提示
|
|
|
if (event.code === 1006) {
|
|
|
alert('连接异常断开,可能是发送的文件过大。请尝试发送较小的文件(建议小于5MB)');
|
|
|
} else {
|
|
|
alert('连接已断开,请重新登录');
|
|
|
}
|
|
|
logout();
|
|
|
}
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('创建WebSocket失败:', e);
|
|
|
alert('连接失败: ' + e.message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 处理接收到的消息
|
|
|
function handleMessage(message) {
|
|
|
console.log('收到服务器消息:', message);
|
|
|
console.log('消息类型:', message.type);
|
|
|
|
|
|
switch (message.type) {
|
|
|
case 'LOGIN_SUCCESS':
|
|
|
console.log('登录成功');
|
|
|
document.getElementById('loginModal').classList.add('hidden');
|
|
|
document.getElementById('chatContainer').classList.remove('hidden');
|
|
|
document.getElementById('currentUser').textContent = username;
|
|
|
break;
|
|
|
|
|
|
case 'LOGIN_FAILED':
|
|
|
console.log('登录失败:', message.content);
|
|
|
alert(message.content);
|
|
|
// 重置状态,允许重新登录
|
|
|
ws.close();
|
|
|
ws = null;
|
|
|
username = null;
|
|
|
break;
|
|
|
|
|
|
case 'ACK':
|
|
|
console.log('服务器确认:', message.content);
|
|
|
break;
|
|
|
|
|
|
case 'USER_LIST':
|
|
|
updateUserList(message.users);
|
|
|
break;
|
|
|
|
|
|
case 'PRIVATE_MSG':
|
|
|
case 'FILE':
|
|
|
case 'IMAGE':
|
|
|
case 'VIDEO':
|
|
|
case 'AUDIO':
|
|
|
case 'VOICE':
|
|
|
console.log('收到多媒体消息:', message.type, '来自:', message.sender);
|
|
|
displayMessage(message, false);
|
|
|
break;
|
|
|
|
|
|
case 'ERROR':
|
|
|
alert('错误: ' + message.content);
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
console.log('未知消息类型:', message.type);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 更新用户列表
|
|
|
function updateUserList(users) {
|
|
|
console.log('更新用户列表:', users);
|
|
|
|
|
|
const userList = document.getElementById('userList');
|
|
|
userList.innerHTML = '';
|
|
|
|
|
|
// 去重并过滤掉自己
|
|
|
const uniqueUsers = [...new Set(users)]; // 使用Set去重
|
|
|
const otherUsers = uniqueUsers.filter(u => u !== username && u); // 过滤掉自己和空值
|
|
|
|
|
|
console.log('去重后的其他用户:', otherUsers);
|
|
|
|
|
|
if (otherUsers.length === 0) {
|
|
|
userList.innerHTML = '<div class="empty-users">暂无其他用户在线</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
otherUsers.forEach(user => {
|
|
|
const userItem = document.createElement('div');
|
|
|
userItem.className = 'user-item';
|
|
|
if (user === currentChat) {
|
|
|
userItem.classList.add('active');
|
|
|
}
|
|
|
|
|
|
// 计算未读消息数
|
|
|
const unreadCount = getUnreadCount(user);
|
|
|
const unreadBadge = unreadCount > 0 ? `<span class="unread-badge">${unreadCount}</span>` : '';
|
|
|
|
|
|
userItem.innerHTML = `
|
|
|
<span class="status"></span>
|
|
|
<span class="user-name">${escapeHtml(user)}</span>
|
|
|
${unreadBadge}
|
|
|
`;
|
|
|
|
|
|
userItem.onclick = () => selectUser(user);
|
|
|
userList.appendChild(userItem);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// 获取未读消息数量
|
|
|
function getUnreadCount(user) {
|
|
|
if (!chatHistory[user]) return 0;
|
|
|
const unreadCount = chatHistory[user].filter(msg => !msg.read && !msg.isSent).length;
|
|
|
console.log(`用户 ${user} 的未读消息数:`, unreadCount);
|
|
|
return unreadCount;
|
|
|
}
|
|
|
|
|
|
// 选择聊天用户
|
|
|
function selectUser(user) {
|
|
|
console.log('选择用户:', user);
|
|
|
currentChat = user;
|
|
|
|
|
|
// 更新标题
|
|
|
document.getElementById('chatTitle').textContent = `与 ${user} 聊天`;
|
|
|
|
|
|
// 启用输入框
|
|
|
const messageInput = document.getElementById('messageInput');
|
|
|
const sendBtn = document.getElementById('sendBtn');
|
|
|
const attachBtn = document.getElementById('attachBtn');
|
|
|
const voiceBtn = document.getElementById('voiceBtn');
|
|
|
messageInput.disabled = false;
|
|
|
sendBtn.disabled = false;
|
|
|
attachBtn.disabled = false;
|
|
|
voiceBtn.disabled = false;
|
|
|
messageInput.focus();
|
|
|
|
|
|
// 标记该用户的消息为已读
|
|
|
if (chatHistory[user]) {
|
|
|
const beforeCount = chatHistory[user].filter(msg => !msg.read && !msg.isSent).length;
|
|
|
chatHistory[user].forEach(msg => msg.read = true);
|
|
|
const afterCount = chatHistory[user].filter(msg => !msg.read && !msg.isSent).length;
|
|
|
console.log(`标记已读: ${user}, 之前未读: ${beforeCount}, 之后未读: ${afterCount}`);
|
|
|
}
|
|
|
|
|
|
// 更新用户列表选中状态和移除未读徽章
|
|
|
document.querySelectorAll('.user-item').forEach(item => {
|
|
|
const userName = item.querySelector('.user-name');
|
|
|
if (userName) {
|
|
|
const itemUser = userName.textContent.trim();
|
|
|
const isActive = itemUser === user;
|
|
|
item.classList.toggle('active', isActive);
|
|
|
|
|
|
// 如果是当前选中的用户,移除未读徽章
|
|
|
if (isActive) {
|
|
|
const badge = item.querySelector('.unread-badge');
|
|
|
if (badge) {
|
|
|
console.log(`移除 ${user} 的未读徽章`);
|
|
|
badge.remove();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 加载该用户的聊天记录
|
|
|
loadChatHistory(user);
|
|
|
}
|
|
|
|
|
|
// 加载聊天记录
|
|
|
function loadChatHistory(user) {
|
|
|
const messagesDiv = document.getElementById('messages');
|
|
|
messagesDiv.innerHTML = ''; // 清空当前显示
|
|
|
|
|
|
// 如果该用户没有聊天记录,初始化
|
|
|
if (!chatHistory[user]) {
|
|
|
chatHistory[user] = [];
|
|
|
}
|
|
|
|
|
|
// 如果有聊天记录,显示
|
|
|
if (chatHistory[user].length > 0) {
|
|
|
chatHistory[user].forEach(msg => {
|
|
|
displayMessageInDOM(msg);
|
|
|
});
|
|
|
} else {
|
|
|
// 没有聊天记录时显示提示
|
|
|
messagesDiv.innerHTML = `
|
|
|
<div class="welcome-screen">
|
|
|
<div class="welcome-icon">💬</div>
|
|
|
<p>开始与 ${user} 聊天吧</p>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 发送消息
|
|
|
function sendMessage() {
|
|
|
const messageInput = document.getElementById('messageInput');
|
|
|
const content = messageInput.value.trim();
|
|
|
|
|
|
if (!content) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!currentChat) {
|
|
|
alert('请先选择聊天对象');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
|
alert('连接已断开,请重新登录');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
console.log('发送消息:', content, '给:', currentChat);
|
|
|
|
|
|
// 发送消息到服务器
|
|
|
const message = {
|
|
|
type: 'PRIVATE_MSG',
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
content: content
|
|
|
};
|
|
|
|
|
|
ws.send(JSON.stringify(message));
|
|
|
|
|
|
// 显示发送的消息
|
|
|
displayMessage({
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
content: content,
|
|
|
timestamp: Date.now()
|
|
|
}, true);
|
|
|
|
|
|
// 清空输入框
|
|
|
messageInput.value = '';
|
|
|
messageInput.focus();
|
|
|
}
|
|
|
|
|
|
// 显示消息
|
|
|
// 显示消息
|
|
|
function displayMessage(message, isSent) {
|
|
|
console.log('displayMessage 被调用:', message, 'isSent:', isSent);
|
|
|
|
|
|
// 确保 timestamp 存在
|
|
|
if (!message.timestamp) {
|
|
|
message.timestamp = Date.now();
|
|
|
console.log('添加默认 timestamp:', message.timestamp);
|
|
|
}
|
|
|
|
|
|
// 确定这条消息属于哪个聊天对象
|
|
|
let chatUser;
|
|
|
if (isSent) {
|
|
|
chatUser = message.receiver;
|
|
|
} else {
|
|
|
chatUser = message.sender;
|
|
|
}
|
|
|
|
|
|
console.log('聊天对象:', chatUser, '当前聊天:', currentChat);
|
|
|
|
|
|
// 保存到聊天记录
|
|
|
if (!chatHistory[chatUser]) {
|
|
|
chatHistory[chatUser] = [];
|
|
|
}
|
|
|
chatHistory[chatUser].push({
|
|
|
type: message.type,
|
|
|
content: message.content,
|
|
|
fileName: message.fileName,
|
|
|
fileType: message.fileType,
|
|
|
fileSize: message.fileSize,
|
|
|
fileData: message.fileData,
|
|
|
duration: message.duration,
|
|
|
timestamp: message.timestamp,
|
|
|
isSent: isSent,
|
|
|
read: chatUser === currentChat // 如果是当前聊天对象,标记为已读
|
|
|
});
|
|
|
|
|
|
console.log('聊天记录已保存:', chatHistory[chatUser].length, '条消息');
|
|
|
|
|
|
// 只有当前聊天对象的消息才显示
|
|
|
if (chatUser === currentChat) {
|
|
|
console.log('显示消息到DOM');
|
|
|
displayMessageInDOM({
|
|
|
type: message.type,
|
|
|
content: message.content,
|
|
|
fileName: message.fileName,
|
|
|
fileType: message.fileType,
|
|
|
fileSize: message.fileSize,
|
|
|
fileData: message.fileData,
|
|
|
duration: message.duration,
|
|
|
timestamp: message.timestamp,
|
|
|
isSent: isSent
|
|
|
});
|
|
|
} else {
|
|
|
console.log('消息不属于当前聊天对象,不显示');
|
|
|
// 如果不是当前聊天对象,更新用户列表显示未读数
|
|
|
const userListItems = document.querySelectorAll('.user-item');
|
|
|
userListItems.forEach(item => {
|
|
|
const userName = item.querySelector('.user-name');
|
|
|
if (userName) {
|
|
|
const itemUser = userName.textContent.trim();
|
|
|
if (itemUser === chatUser) {
|
|
|
const unreadCount = getUnreadCount(chatUser);
|
|
|
let existingBadge = item.querySelector('.unread-badge');
|
|
|
|
|
|
if (unreadCount > 0) {
|
|
|
if (existingBadge) {
|
|
|
existingBadge.textContent = unreadCount;
|
|
|
} else {
|
|
|
const badge = document.createElement('span');
|
|
|
badge.className = 'unread-badge';
|
|
|
badge.textContent = unreadCount;
|
|
|
item.appendChild(badge);
|
|
|
}
|
|
|
} else if (existingBadge) {
|
|
|
existingBadge.remove();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 在DOM中显示消息
|
|
|
function displayMessageInDOM(msg) {
|
|
|
console.log('displayMessageInDOM 被调用:', msg);
|
|
|
console.log('消息类型:', msg.type, '文件名:', msg.fileName, '时长:', msg.duration);
|
|
|
|
|
|
const messagesDiv = document.getElementById('messages');
|
|
|
|
|
|
// 移除欢迎屏幕(如果存在)
|
|
|
const welcomeScreen = messagesDiv.querySelector('.welcome-screen');
|
|
|
if (welcomeScreen) {
|
|
|
console.log('移除欢迎屏幕');
|
|
|
welcomeScreen.remove();
|
|
|
}
|
|
|
|
|
|
// 创建消息元素
|
|
|
const messageDiv = document.createElement('div');
|
|
|
messageDiv.className = `message ${msg.isSent ? 'sent' : 'received'}`;
|
|
|
|
|
|
const time = new Date(msg.timestamp).toLocaleTimeString('zh-CN', {
|
|
|
hour: '2-digit',
|
|
|
minute: '2-digit'
|
|
|
});
|
|
|
|
|
|
// 确定发送者名字
|
|
|
const senderName = msg.isSent ? username : currentChat;
|
|
|
console.log('发送者名字:', senderName);
|
|
|
|
|
|
let contentHtml = '';
|
|
|
|
|
|
// 根据消息类型生成不同的内容
|
|
|
if (msg.type === 'IMAGE') {
|
|
|
console.log('渲染图片消息');
|
|
|
const imageUrl = `data:${msg.fileType};base64,${msg.fileData}`;
|
|
|
contentHtml = `
|
|
|
<div class="message-file">
|
|
|
<img src="${imageUrl}" alt="${escapeHtml(msg.fileName)}" class="message-image" onclick="window.open(this.src)">
|
|
|
<div class="file-info">
|
|
|
<span class="file-name">${escapeHtml(msg.fileName)}</span>
|
|
|
<span class="file-size">${formatFileSize(msg.fileSize)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
} else if (msg.type === 'VIDEO') {
|
|
|
console.log('渲染视频消息');
|
|
|
const videoUrl = `data:${msg.fileType};base64,${msg.fileData}`;
|
|
|
contentHtml = `
|
|
|
<div class="message-file">
|
|
|
<video controls class="message-video">
|
|
|
<source src="${videoUrl}" type="${msg.fileType}">
|
|
|
您的浏览器不支持视频播放
|
|
|
</video>
|
|
|
<div class="file-info">
|
|
|
<span class="file-name">${escapeHtml(msg.fileName)}</span>
|
|
|
<span class="file-size">${formatFileSize(msg.fileSize)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
} else if (msg.type === 'AUDIO') {
|
|
|
console.log('渲染音频消息');
|
|
|
const audioUrl = `data:${msg.fileType};base64,${msg.fileData}`;
|
|
|
contentHtml = `
|
|
|
<div class="message-file">
|
|
|
<audio controls class="message-audio">
|
|
|
<source src="${audioUrl}" type="${msg.fileType}">
|
|
|
您的浏览器不支持音频播放
|
|
|
</audio>
|
|
|
<div class="file-info">
|
|
|
<span class="file-name">${escapeHtml(msg.fileName)}</span>
|
|
|
<span class="file-size">${formatFileSize(msg.fileSize)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
} else if (msg.type === 'VOICE') {
|
|
|
console.log('渲染语音消息, 时长:', msg.duration);
|
|
|
const audioUrl = `data:${msg.fileType};base64,${msg.fileData}`;
|
|
|
contentHtml = `
|
|
|
<div class="message-voice">
|
|
|
<span class="voice-icon">🎤</span>
|
|
|
<audio controls class="voice-audio">
|
|
|
<source src="${audioUrl}" type="${msg.fileType}">
|
|
|
您的浏览器不支持音频播放
|
|
|
</audio>
|
|
|
<span class="voice-duration">${msg.duration || 0}秒</span>
|
|
|
</div>
|
|
|
`;
|
|
|
} else if (msg.type === 'FILE') {
|
|
|
console.log('渲染文件消息');
|
|
|
contentHtml = `
|
|
|
<div class="message-file">
|
|
|
<div class="file-icon">📄</div>
|
|
|
<div class="file-info">
|
|
|
<span class="file-name">${escapeHtml(msg.fileName)}</span>
|
|
|
<span class="file-size">${formatFileSize(msg.fileSize)}</span>
|
|
|
</div>
|
|
|
<button class="download-btn" onclick='downloadFile("${escapeHtml(msg.fileName)}", "${msg.fileData}", "${msg.fileType}")'>下载</button>
|
|
|
</div>
|
|
|
`;
|
|
|
} else {
|
|
|
// 普通文本消息
|
|
|
console.log('渲染文本消息');
|
|
|
contentHtml = `<div class="message-text">${escapeHtml(msg.content)}</div>`;
|
|
|
}
|
|
|
|
|
|
messageDiv.innerHTML = `
|
|
|
<div class="message-wrapper">
|
|
|
<div class="message-sender">${escapeHtml(senderName)}</div>
|
|
|
<div class="message-content">
|
|
|
${contentHtml}
|
|
|
<div class="message-info">${time}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
messagesDiv.appendChild(messageDiv);
|
|
|
console.log('消息已添加到DOM, 类型:', msg.type);
|
|
|
|
|
|
// 滚动到底部
|
|
|
messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
|
}
|
|
|
|
|
|
// 退出登录
|
|
|
function logout() {
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
const logoutMsg = {
|
|
|
type: 'LOGOUT',
|
|
|
sender: username
|
|
|
};
|
|
|
ws.send(JSON.stringify(logoutMsg));
|
|
|
ws.close();
|
|
|
}
|
|
|
|
|
|
// 重置状态
|
|
|
ws = null;
|
|
|
username = null;
|
|
|
currentChat = null;
|
|
|
chatHistory = {}; // 清空聊天记录
|
|
|
|
|
|
// 切换到登录界面
|
|
|
document.getElementById('chatContainer').classList.add('hidden');
|
|
|
document.getElementById('loginModal').classList.remove('hidden');
|
|
|
|
|
|
// 清空输入
|
|
|
document.getElementById('usernameInput').value = '';
|
|
|
document.getElementById('messageInput').value = '';
|
|
|
|
|
|
// 清空消息
|
|
|
const messagesDiv = document.getElementById('messages');
|
|
|
messagesDiv.innerHTML = `
|
|
|
<div class="welcome-screen">
|
|
|
<div class="welcome-icon">💬</div>
|
|
|
<p>选择左侧用户开始聊天</p>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
// HTML转义函数
|
|
|
function escapeHtml(text) {
|
|
|
const div = document.createElement('div');
|
|
|
div.textContent = text;
|
|
|
return div.innerHTML;
|
|
|
}
|
|
|
|
|
|
// 回车发送消息
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
const messageInput = document.getElementById('messageInput');
|
|
|
const usernameInput = document.getElementById('usernameInput');
|
|
|
|
|
|
messageInput.addEventListener('keypress', (e) => {
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
e.preventDefault();
|
|
|
sendMessage();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
usernameInput.addEventListener('keypress', (e) => {
|
|
|
if (e.key === 'Enter') {
|
|
|
e.preventDefault();
|
|
|
login();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 自动聚焦用户名输入框
|
|
|
usernameInput.focus();
|
|
|
|
|
|
// 显示连接信息
|
|
|
console.log('服务器地址:', SERVER_CONFIG.getWebSocketUrl());
|
|
|
|
|
|
// 检查浏览器功能支持
|
|
|
checkBrowserSupport();
|
|
|
});
|
|
|
|
|
|
// 检查浏览器功能支持
|
|
|
function checkBrowserSupport() {
|
|
|
const features = {
|
|
|
webSocket: typeof WebSocket !== 'undefined',
|
|
|
mediaDevices: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
|
|
mediaRecorder: typeof MediaRecorder !== 'undefined',
|
|
|
fileReader: typeof FileReader !== 'undefined'
|
|
|
};
|
|
|
|
|
|
console.log('浏览器功能支持:', features);
|
|
|
|
|
|
if (!features.webSocket) {
|
|
|
console.warn('浏览器不支持 WebSocket');
|
|
|
}
|
|
|
|
|
|
if (!features.mediaDevices) {
|
|
|
console.warn('浏览器不支持麦克风访问(需要 HTTPS 或 localhost)');
|
|
|
const voiceBtn = document.getElementById('voiceBtn');
|
|
|
if (voiceBtn) {
|
|
|
voiceBtn.title = '您的浏览器不支持语音录制';
|
|
|
voiceBtn.style.opacity = '0.5';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!features.mediaRecorder) {
|
|
|
console.warn('浏览器不支持 MediaRecorder API');
|
|
|
}
|
|
|
|
|
|
// 检查是否在安全上下文中
|
|
|
const isSecureContext = window.isSecureContext ||
|
|
|
location.protocol === 'https:' ||
|
|
|
location.hostname === 'localhost' ||
|
|
|
location.hostname === '127.0.0.1';
|
|
|
|
|
|
if (!isSecureContext) {
|
|
|
console.warn('当前不在安全上下文中,某些功能可能无法使用');
|
|
|
console.warn('建议使用 HTTPS 或通过 localhost 访问');
|
|
|
}
|
|
|
|
|
|
console.log('安全上下文:', isSecureContext);
|
|
|
console.log('协议:', location.protocol);
|
|
|
console.log('主机:', location.hostname);
|
|
|
}
|
|
|
|
|
|
// 处理文件选择
|
|
|
function handleFileSelect(event) {
|
|
|
const file = event.target.files[0];
|
|
|
if (!file) return;
|
|
|
|
|
|
if (!currentChat) {
|
|
|
alert('请先选择聊天对象');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 检查文件大小(限制2MB以避免WebSocket断联)
|
|
|
const maxSize = 2 * 1024 * 1024;
|
|
|
if (file.size > maxSize) {
|
|
|
alert('文件大小不能超过2MB\n' +
|
|
|
'当前文件大小: ' + formatFileSize(file.size) + '\n\n' +
|
|
|
'建议:\n' +
|
|
|
'1. 压缩图片/视频后再发送\n' +
|
|
|
'2. 使用 TCP 客户端发送大文件');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
console.log('准备发送文件:', file.name, '大小:', formatFileSize(file.size));
|
|
|
|
|
|
// 读取文件
|
|
|
const reader = new FileReader();
|
|
|
reader.onload = function(e) {
|
|
|
try {
|
|
|
const fileData = e.target.result;
|
|
|
const base64Data = fileData.split(',')[1]; // 移除data:xxx;base64,前缀
|
|
|
|
|
|
// 确定消息类型
|
|
|
let messageType = 'FILE';
|
|
|
if (file.type.startsWith('image/')) {
|
|
|
messageType = 'IMAGE';
|
|
|
} else if (file.type.startsWith('video/')) {
|
|
|
messageType = 'VIDEO';
|
|
|
} else if (file.type.startsWith('audio/')) {
|
|
|
messageType = 'AUDIO';
|
|
|
}
|
|
|
|
|
|
console.log('文件类型:', messageType, 'MIME:', file.type);
|
|
|
console.log('Base64 数据大小:', base64Data.length, '字符');
|
|
|
|
|
|
// 检查编码后的大小
|
|
|
const encodedSize = base64Data.length;
|
|
|
if (encodedSize > 100000) { // 100KB
|
|
|
alert('文件编码后过大,可能导致传输失败\n' +
|
|
|
'建议压缩文件或使用 TCP 客户端');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 发送文件消息
|
|
|
const message = {
|
|
|
type: messageType,
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
fileName: file.name,
|
|
|
fileType: file.type,
|
|
|
fileSize: file.size,
|
|
|
fileData: base64Data
|
|
|
};
|
|
|
|
|
|
console.log('发送文件消息');
|
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
ws.send(JSON.stringify(message));
|
|
|
console.log('文件消息已发送');
|
|
|
|
|
|
// 显示发送的文件消息
|
|
|
displayMessage({
|
|
|
type: messageType,
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
fileName: file.name,
|
|
|
fileType: file.type,
|
|
|
fileSize: file.size,
|
|
|
fileData: base64Data,
|
|
|
timestamp: Date.now()
|
|
|
}, true);
|
|
|
} else {
|
|
|
alert('连接已断开,请重新登录');
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('发送文件失败:', error);
|
|
|
alert('发送文件失败: ' + error.message);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
reader.onerror = function(error) {
|
|
|
console.error('读取文件失败:', error);
|
|
|
alert('读取文件失败');
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
// 清空文件选择
|
|
|
event.target.value = '';
|
|
|
}
|
|
|
|
|
|
// 切换语音录制
|
|
|
async function toggleVoiceRecording() {
|
|
|
if (!currentChat) {
|
|
|
alert('请先选择聊天对象');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const voiceBtn = document.getElementById('voiceBtn');
|
|
|
|
|
|
if (!isRecording) {
|
|
|
// 开始录音
|
|
|
try {
|
|
|
// 检查浏览器是否支持
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
alert('您的浏览器不支持录音功能\n请使用 Chrome、Firefox 或 Edge 浏览器');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 检查是否支持 MediaRecorder
|
|
|
if (typeof MediaRecorder === 'undefined') {
|
|
|
alert('您的浏览器不支持 MediaRecorder API\n请更新浏览器到最新版本');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
console.log('请求麦克风权限...');
|
|
|
|
|
|
// 请求麦克风权限
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
audio: {
|
|
|
echoCancellation: true,
|
|
|
noiseSuppression: true,
|
|
|
autoGainControl: true
|
|
|
}
|
|
|
});
|
|
|
|
|
|
console.log('麦克风权限已授予');
|
|
|
|
|
|
// 检查支持的 MIME 类型
|
|
|
let mimeType = 'audio/webm';
|
|
|
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
|
mimeType = 'audio/webm;codecs=opus';
|
|
|
} else if (MediaRecorder.isTypeSupported('audio/webm')) {
|
|
|
mimeType = 'audio/webm';
|
|
|
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
|
|
mimeType = 'audio/ogg;codecs=opus';
|
|
|
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
|
|
mimeType = 'audio/mp4';
|
|
|
}
|
|
|
|
|
|
console.log('使用 MIME 类型:', mimeType);
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType });
|
|
|
audioChunks = [];
|
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
|
if (event.data.size > 0) {
|
|
|
audioChunks.push(event.data);
|
|
|
console.log('录音数据块:', event.data.size, '字节');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
|
const duration = Math.floor((Date.now() - startTime) / 1000);
|
|
|
console.log('录音结束,时长:', duration, '秒');
|
|
|
|
|
|
if (audioChunks.length === 0) {
|
|
|
alert('录音失败:没有录制到音频数据');
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const audioBlob = new Blob(audioChunks, { type: mimeType });
|
|
|
console.log('音频大小:', formatFileSize(audioBlob.size));
|
|
|
|
|
|
// 检查文件大小(限制2MB)
|
|
|
if (audioBlob.size > 2 * 1024 * 1024) {
|
|
|
alert('语音消息过大(超过2MB),请录制较短的语音');
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.onload = function(e) {
|
|
|
const fileData = e.target.result;
|
|
|
const base64Data = fileData.split(',')[1];
|
|
|
|
|
|
// 确定文件扩展名
|
|
|
let extension = 'webm';
|
|
|
if (mimeType.includes('ogg')) {
|
|
|
extension = 'ogg';
|
|
|
} else if (mimeType.includes('mp4')) {
|
|
|
extension = 'm4a';
|
|
|
}
|
|
|
|
|
|
// 发送语音消息
|
|
|
const message = {
|
|
|
type: 'VOICE',
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
fileName: `voice_${Date.now()}.${extension}`,
|
|
|
fileType: mimeType,
|
|
|
fileSize: audioBlob.size,
|
|
|
fileData: base64Data,
|
|
|
duration: duration
|
|
|
};
|
|
|
|
|
|
console.log('发送语音消息');
|
|
|
ws.send(JSON.stringify(message));
|
|
|
|
|
|
// 显示发送的语音消息
|
|
|
displayMessage({
|
|
|
type: 'VOICE',
|
|
|
sender: username,
|
|
|
receiver: currentChat,
|
|
|
fileName: message.fileName,
|
|
|
fileType: message.fileType,
|
|
|
fileSize: audioBlob.size,
|
|
|
fileData: base64Data,
|
|
|
duration: duration,
|
|
|
timestamp: Date.now()
|
|
|
}, true);
|
|
|
};
|
|
|
|
|
|
reader.onerror = function(error) {
|
|
|
console.error('读取音频失败:', error);
|
|
|
alert('处理语音消息失败');
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(audioBlob);
|
|
|
|
|
|
// 停止所有音轨
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
|
};
|
|
|
|
|
|
mediaRecorder.onerror = (event) => {
|
|
|
console.error('MediaRecorder 错误:', event.error);
|
|
|
alert('录音出错: ' + event.error.name);
|
|
|
isRecording = false;
|
|
|
voiceBtn.textContent = '🎤';
|
|
|
voiceBtn.style.backgroundColor = '';
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
|
};
|
|
|
|
|
|
mediaRecorder.start(100); // 每100ms收集一次数据
|
|
|
isRecording = true;
|
|
|
voiceBtn.textContent = '⏹️';
|
|
|
voiceBtn.style.backgroundColor = '#f44336';
|
|
|
voiceBtn.title = '点击停止录音';
|
|
|
|
|
|
console.log('开始录音...');
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('无法访问麦克风:', error);
|
|
|
|
|
|
let errorMessage = '无法访问麦克风\n\n';
|
|
|
|
|
|
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
|
|
|
errorMessage += '原因:您拒绝了麦克风权限\n\n';
|
|
|
errorMessage += '解决方法:\n';
|
|
|
errorMessage += '1. 点击地址栏左侧的锁图标\n';
|
|
|
errorMessage += '2. 找到"麦克风"权限\n';
|
|
|
errorMessage += '3. 选择"允许"\n';
|
|
|
errorMessage += '4. 刷新页面重试';
|
|
|
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
|
|
|
errorMessage += '原因:未检测到麦克风设备\n\n';
|
|
|
errorMessage += '解决方法:\n';
|
|
|
errorMessage += '1. 检查麦克风是否已连接\n';
|
|
|
errorMessage += '2. 检查系统声音设置\n';
|
|
|
errorMessage += '3. 确保麦克风未被其他程序占用';
|
|
|
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
|
|
|
errorMessage += '原因:麦克风被其他程序占用\n\n';
|
|
|
errorMessage += '解决方法:\n';
|
|
|
errorMessage += '1. 关闭其他使用麦克风的程序\n';
|
|
|
errorMessage += '2. 重启浏览器\n';
|
|
|
errorMessage += '3. 检查系统麦克风设置';
|
|
|
} else if (error.name === 'OverconstrainedError') {
|
|
|
errorMessage += '原因:麦克风不支持请求的参数\n\n';
|
|
|
errorMessage += '解决方法:\n';
|
|
|
errorMessage += '1. 尝试使用其他麦克风\n';
|
|
|
errorMessage += '2. 更新音频驱动程序';
|
|
|
} else if (error.name === 'SecurityError') {
|
|
|
errorMessage += '原因:安全限制\n\n';
|
|
|
errorMessage += '解决方法:\n';
|
|
|
errorMessage += '1. 确保使用 HTTPS 或 localhost\n';
|
|
|
errorMessage += '2. 检查浏览器安全设置';
|
|
|
} else {
|
|
|
errorMessage += '错误详情:' + error.name + '\n';
|
|
|
errorMessage += error.message;
|
|
|
}
|
|
|
|
|
|
alert(errorMessage);
|
|
|
}
|
|
|
} else {
|
|
|
// 停止录音
|
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
|
console.log('停止录音');
|
|
|
mediaRecorder.stop();
|
|
|
isRecording = false;
|
|
|
voiceBtn.textContent = '🎤';
|
|
|
voiceBtn.style.backgroundColor = '';
|
|
|
voiceBtn.title = '语音消息';
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 格式化文件大小
|
|
|
function formatFileSize(bytes) {
|
|
|
if (bytes < 1024) return bytes + ' B';
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
|
}
|
|
|
|
|
|
// 下载文件
|
|
|
function downloadFile(fileName, fileData, fileType) {
|
|
|
const byteCharacters = atob(fileData);
|
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
|
}
|
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
|
const blob = new Blob([byteArray], { type: fileType });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
const a = document.createElement('a');
|
|
|
a.href = url;
|
|
|
a.download = fileName;
|
|
|
document.body.appendChild(a);
|
|
|
a.click();
|
|
|
document.body.removeChild(a);
|
|
|
URL.revokeObjectURL(url);
|
|
|
}
|