chat/frontend/app.js

587 lines
20 KiB
JavaScript
Raw Normal View History

2025-12-31 09:36:02 +00:00
// 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');