chat/frontend/app.js

587 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// API 配置
const API_BASE_URL = 'http://localhost:8888/api/v1';
// Centrifugo WebSocket URL - 默認使用 8000 端口(與 HTTP API 同端口)
// 如果配置了不同的 WebSocket 端口,請修改此處
const CENTRIFUGO_WS_URL = 'ws://localhost:8000/connection/websocket';
// 應用狀態
let appState = {
uid: null,
token: null,
centrifugoToken: null,
expireAt: null,
roomID: null,
centrifugoClient: null,
matchStatus: null,
wsRetryCount: 0
};
// 工具函數
function log(message, type = 'info') {
const logContainer = document.getElementById('logContainer');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logContainer.appendChild(entry);
logContainer.scrollTop = logContainer.scrollHeight;
console.log(`[${type.toUpperCase()}]`, message);
}
function showSection(sectionId) {
document.querySelectorAll('.section').forEach(section => {
section.classList.add('hidden');
});
document.getElementById(sectionId).classList.remove('hidden');
}
// API 調用函數
async function apiCall(endpoint, method = 'GET', body = null, needAuth = false) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
}
};
if (needAuth && appState.token) {
options.headers['Authorization'] = `Bearer ${appState.token}`;
}
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
// 先讀取響應文本
const text = await response.text();
// 嘗試解析 JSON
let data;
try {
data = text ? JSON.parse(text) : {};
} catch (parseError) {
// 如果不是有效的 JSON使用原始文本作為錯誤信息
log(`響應不是有效的 JSON: ${text}`, 'error');
if (!response.ok) {
throw new Error(text || `HTTP ${response.status}`);
}
data = {};
}
if (!response.ok) {
const errorMsg = data.message || data.error || data.error?.message || text || `HTTP ${response.status}`;
log(`API 錯誤: ${errorMsg}`, 'error');
throw new Error(errorMsg);
}
return data;
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
log(`網路錯誤: ${error.message}`, 'error');
throw new Error('網路連接失敗,請檢查後端服務是否運行');
}
log(`API 錯誤: ${error.message}`, 'error');
throw error;
}
}
// 匿名登入
async function handleLogin() {
const userName = document.getElementById('userName').value || 'Anonymous';
const btn = document.getElementById('loginBtn');
btn.disabled = true;
btn.textContent = '登入中...';
try {
log('開始匿名登入...', 'info');
const response = await apiCall('/auth/anon', 'POST', { name: userName });
// 調試:檢查響應內容
log(`登入響應: uid=${response.uid}, hasToken=${!!response.token}, hasCentrifugoToken=${!!response.centrifugo_token}`, 'info');
appState.uid = response.uid;
appState.token = response.token;
appState.centrifugoToken = response.centrifugo_token;
appState.expireAt = response.expire_at;
// 驗證 token 是否正確獲取
if (!appState.centrifugoToken) {
log('警告:未獲取到 Centrifugo token請檢查後端響應', 'error');
log(`完整響應: ${JSON.stringify(response)}`, 'error');
} else {
const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...';
log(`已獲取 Centrifugo token (前20字符): ${tokenPreview}`, 'info');
}
document.getElementById('userUID').textContent = appState.uid;
log(`登入成功UID: ${appState.uid}`, 'success');
showSection('matchingSection');
} catch (error) {
log(`登入失敗: ${error.message}`, 'error');
alert('登入失敗,請重試');
} finally {
btn.disabled = false;
btn.textContent = '開始聊天';
}
}
// 加入配對
async function handleJoinMatch() {
const btn = document.getElementById('joinMatchBtn');
btn.disabled = true;
btn.textContent = '配對中...';
try {
log('加入配對佇列...', 'info');
const response = await apiCall('/matchmaking/join', 'POST', null, true);
appState.matchStatus = response.status;
document.getElementById('matchStatus').textContent = response.status === 'waiting' ? '等待配對中...' : '已配對!';
log(`配對狀態: ${response.status}`, response.status === 'matched' ? 'success' : 'info');
if (response.status === 'matched') {
// 如果立即配對成功,需要查詢 roomID
await handleCheckStatus();
} else {
// 開始輪詢狀態
startStatusPolling();
}
} catch (error) {
log(`加入配對失敗: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '加入配對';
}
}
// 檢查配對狀態
async function handleCheckStatus() {
try {
const response = await apiCall('/matchmaking/status', 'GET', null, true);
appState.matchStatus = response.status;
document.getElementById('matchStatus').textContent =
response.status === 'waiting' ? '等待配對中...' : '已配對!';
if (response.status === 'matched' && response.room_id) {
appState.roomID = response.room_id;
document.getElementById('roomID').textContent = response.room_id;
log(`配對成功!房間 ID: ${response.room_id}`, 'success');
// 停止輪詢,進入聊天室
stopStatusPolling();
await enterChatRoom();
}
} catch (error) {
log(`檢查狀態失敗: ${error.message}`, 'error');
}
}
// 開始狀態輪詢
let pollingInterval = null;
function startStatusPolling() {
if (pollingInterval) return;
log('開始輪詢配對狀態...', 'info');
pollingInterval = setInterval(async () => {
await handleCheckStatus();
}, 2000); // 每 2 秒檢查一次
}
function stopStatusPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
log('停止輪詢配對狀態', 'info');
}
}
// 進入聊天室
async function enterChatRoom() {
showSection('chatSection');
// 進入聊天室前先刷新 token以確保獲取到最新的房間權限
// 因為匿名登入時還不知道房間ID所以當時的 token 沒有房間權限
await handleRefreshToken();
// 連接到 Centrifugo
connectToCentrifugo();
// 載入歷史訊息
await loadHistoryMessages();
}
// 連接到 Centrifugo
function connectToCentrifugo() {
if (!appState.centrifugoToken || !appState.roomID) {
log(`無法連接 Centrifugo缺少 token 或 roomID (token: ${appState.centrifugoToken ? '存在' : '不存在'}, roomID: ${appState.roomID || '不存在'})`, 'error');
return;
}
try {
// 關閉舊連接
if (appState.centrifugoClient) {
appState.centrifugoClient.close();
}
// 檢查 token 是否過期
if (appState.expireAt) {
const now = Math.floor(Date.now() / 1000);
if (now >= appState.expireAt) {
log('Centrifugo token 已過期,請先刷新 token', 'error');
return;
}
const timeUntilExpiry = appState.expireAt - now;
log(`Centrifugo token 將在 ${Math.floor(timeUntilExpiry / 60)} 分鐘後過期`, 'info');
}
// 記錄 token 前幾個字符用於調試(不記錄完整 token 以保護安全)
const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...';
log(`正在連接 Centrifugo使用 token: ${tokenPreview}`, 'info');
// Centrifugo WebSocket 連線(使用 JSON 格式)
// Centrifugo v5: 必須先發送 connect 命令進行認證
const wsUrl = `${CENTRIFUGO_WS_URL}?format=json`;
log(`WebSocket URL: ${wsUrl}`, 'info');
const ws = new WebSocket(wsUrl);
let messageId = 1;
let isConnected = false;
ws.onopen = () => {
log('WebSocket 連接已建立,正在發送認證請求...', 'info');
// 重置重試計數
appState.wsRetryCount = 0;
// Centrifugo v5: 必須先發送 connect 命令進行認證
const connectMsg = {
id: messageId++,
connect: {
token: appState.centrifugoToken
}
};
ws.send(JSON.stringify(connectMsg));
log('已發送 connect 認證請求', 'info');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// 跳過空回應(心跳 pong和只有 id 的回應
const isEmptyOrPong = Object.keys(data).length === 0 ||
(Object.keys(data).length === 1 && data.id);
// 只記錄有意義的訊息(非心跳、非空回應)
if (!isEmptyOrPong && !data.subscribe) {
log(`收到 Centrifugo 訊息: ${JSON.stringify(data)}`, 'info');
}
// Centrifugo v5 JSON 協議回應格式
// 處理 connect 回應
if (data.connect) {
isConnected = true;
appState.wsRetryCount = 0; // 重置重連計數
log(`已成功連接到 Centrifugoclient: ${data.connect.client}`, 'success');
// 設置心跳定時器Centrifugo 要求客戶端發送 ping
const pingInterval = (data.connect.ping || 25) * 1000;
if (appState.pingTimer) {
clearInterval(appState.pingTimer);
}
appState.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
// 發送空物件作為 ping
ws.send('{}');
}
}, pingInterval);
log(`已設置心跳間隔: ${pingInterval / 1000}`, 'info');
// 檢查是否已經通過 token 自動訂閱了房間頻道
const roomChannel = `room:${appState.roomID}`;
if (data.connect.subs && data.connect.subs[roomChannel]) {
log(`已通過 token 自動訂閱頻道: ${roomChannel}`, 'success');
} else {
// 需要手動訂閱
const subscribeMsg = {
id: messageId++,
subscribe: {
channel: roomChannel
}
};
ws.send(JSON.stringify(subscribeMsg));
log(`已發送訂閱請求: ${roomChannel}`, 'info');
}
}
// 處理 subscribe 回應
else if (data.subscribe) {
log(`成功訂閱頻道`, 'success');
}
// 處理錯誤
else if (data.error) {
// code 105 = already subscribed這不是真正的錯誤
if (data.error.code === 105) {
log(`頻道已訂閱 (這是正常的)`, 'info');
} else {
log(`Centrifugo 錯誤: code=${data.error.code}, message=${data.error.message}`, 'error');
if (data.error.code === 109) {
log('Token 驗證失敗,請檢查 token 是否正確', 'error');
} else if (data.error.code === 103) {
log('頻道訂閱權限不足', 'error');
}
}
}
// 處理 push 訊息(新訊息推送)
else if (data.push) {
const push = data.push;
if (push.pub) {
// 收到發布的訊息
const message = push.pub.data;
if (message && typeof message === 'object') {
displayMessage(message);
log(`收到新訊息: ${message.content || JSON.stringify(message)}`, 'info');
}
} else if (push.join) {
log(`用戶 ${push.join.user} 加入了房間`, 'info');
} else if (push.leave) {
log(`用戶 ${push.leave.user} 離開了房間`, 'info');
}
}
// 處理 disconnect
else if (data.disconnect) {
log(`Centrifugo 服務器主動斷開連接: ${data.disconnect.reason}`, 'error');
}
} catch (error) {
log(`處理 WebSocket 訊息錯誤: ${error.message}`, 'error');
log(`原始訊息: ${event.data}`, 'error');
}
};
ws.onerror = (error) => {
log(`WebSocket 錯誤: ${error.message || error}`, 'error');
// 記錄更多錯誤信息
if (error.target && error.target.readyState === WebSocket.CLOSED) {
log('WebSocket 連接已關閉', 'error');
}
};
ws.onclose = (event) => {
const reason = event.reason || '無';
log(`WebSocket 連線已關閉 (code: ${event.code}, reason: ${reason})`, 'info');
// 清除心跳定時器
if (appState.pingTimer) {
clearInterval(appState.pingTimer);
appState.pingTimer = null;
}
// 1000 表示正常關閉(主動斷開)
const normalCloseCodes = [1000];
if (event.code === 1006) {
log('WebSocket 異常關閉,可能是網路問題或服務中斷', 'error');
}
// 非正常關閉且還在房間中,自動重連
if (!normalCloseCodes.includes(event.code) && appState.roomID && appState.centrifugoToken) {
const retryCount = appState.wsRetryCount || 0;
const maxRetries = 10; // 增加最大重試次數
if (retryCount < maxRetries) {
appState.wsRetryCount = retryCount + 1;
// 使用指數退避策略,最長等待 30 秒
const delay = Math.min(1000 * Math.pow(1.5, retryCount), 30000);
log(`將在 ${Math.round(delay / 1000)} 秒後重新連接... (${retryCount + 1}/${maxRetries})`, 'info');
setTimeout(() => {
if (appState.roomID && appState.centrifugoToken) {
connectToCentrifugo();
}
}, delay);
} else {
log('WebSocket 重連次數已達上限,請刷新頁面重試', 'error');
}
}
};
appState.centrifugoClient = ws;
} catch (error) {
log(`連接 Centrifugo 失敗: ${error.message}`, 'error');
}
}
// 載入歷史訊息
async function loadHistoryMessages() {
try {
log('載入歷史訊息...', 'info');
// 使用查詢參數傳遞 page_size 和 page_index
const response = await apiCall(
`/rooms/${appState.roomID}/messages?page_size=20&page_index=1`,
'GET',
null,
true
);
const messages = response.data || [];
messages.reverse(); // 從舊到新顯示
messages.forEach(msg => {
displayMessage(msg);
});
log(`已載入 ${messages.length} 條歷史訊息`, 'success');
} catch (error) {
log(`載入歷史訊息失敗: ${error.message}`, 'error');
// 即使載入失敗也不影響聊天功能
}
}
// 顯示訊息
function displayMessage(message) {
const container = document.getElementById('messagesContainer');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${message.uid === appState.uid ? 'own' : ''}`;
const date = new Date(message.timestamp);
messageDiv.innerHTML = `
<div class="message-header">
<span>${message.uid === appState.uid ? '我' : '對方'}</span>
<span>${date.toLocaleTimeString()}</span>
</div>
<div class="message-content">${escapeHtml(message.content)}</div>
`;
container.appendChild(messageDiv);
container.scrollTop = container.scrollHeight;
}
// 發送訊息
async function handleSendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content) {
alert('請輸入訊息內容');
return;
}
if (!appState.roomID) {
alert('尚未加入房間');
return;
}
const btn = document.getElementById('sendBtn');
btn.disabled = true;
try {
const clientMsgID = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await apiCall(
`/rooms/${appState.roomID}/messages`,
'POST',
{
content: content,
client_msg_id: clientMsgID
},
true
);
log(`訊息已發送: ${content}`, 'success');
input.value = '';
} catch (error) {
log(`發送訊息失敗: ${error.message}`, 'error');
alert('發送訊息失敗,請重試');
} finally {
btn.disabled = false;
input.focus();
}
}
// 刷新 Token
async function handleRefreshToken() {
if (!appState.token) {
alert('請先登入');
return;
}
const btn = document.getElementById('refreshTokenBtn');
btn.disabled = true;
btn.textContent = '刷新中...';
try {
log('刷新 Token...', 'info');
const response = await apiCall('/auth/refresh', 'POST', {
token: appState.token
});
appState.token = response.token;
appState.centrifugoToken = response.centrifugo_token;
appState.expireAt = response.expire_at;
// 驗證新 token 是否正確獲取
if (!appState.centrifugoToken) {
log('警告:刷新後未獲取到 Centrifugo token', 'error');
} else {
const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...';
log(`已獲取新的 Centrifugo token: ${tokenPreview}`, 'info');
}
log('Token 刷新成功!', 'success');
// 重新連接 Centrifugo
if (appState.centrifugoClient) {
appState.centrifugoClient.close();
}
if (appState.roomID) {
connectToCentrifugo();
}
} catch (error) {
log(`刷新 Token 失敗: ${error.message}`, 'error');
alert('Token 刷新失敗,請重新登入');
} finally {
btn.disabled = false;
btn.textContent = '刷新 Token';
}
}
// 鍵盤事件處理
function handleKeyPress(event) {
if (event.key === 'Enter') {
handleSendMessage();
}
}
// HTML 轉義
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 檢查 Token 過期
function checkTokenExpiry() {
if (appState.expireAt) {
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = appState.expireAt - now;
if (timeUntilExpiry < 0) {
log('Token 已過期', 'error');
alert('Token 已過期,請刷新或重新登入');
} else if (timeUntilExpiry < 300) { // 5 分鐘內過期
log(`Token 將在 ${Math.floor(timeUntilExpiry / 60)} 分鐘後過期,建議刷新`, 'info');
}
}
}
// 定期檢查 Token 過期
setInterval(checkTokenExpiry, 60000); // 每分鐘檢查一次
// 初始化
log('應用程式已載入', 'info');