Files
CamTalk/frontend/src/lib/websocket.ts
cfy666 8de956719c feat: 实现前端认证系统,WebSocket 连接携带 JWT token
- 新增 AuthContext/AuthProvider:登录/注册状态管理,JWT 自动刷新
- 新增 AuthPage 组件:登录/注册表单,支持模式切换和前端校验
- 新增 api.ts:封装 auth REST API 客户端(register/login/refresh/logout)
- WebSocket 连接时拼接 ?token=<jwt>,重连自动携带
- useVisionSession 接受 accessToken 参数并透传
- App.tsx 包裹 AuthProvider,未登录时显示登录页
- storage.ts 新增 token/user 的 localStorage 存储
- i18n 新增中/英/日三语 auth 翻译
- App.css 新增 auth 页面和用户 badge 样式
2026-06-14 18:38:45 +08:00

156 lines
4.5 KiB
TypeScript
Raw 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.
// ============================================================
// WebSocket 连接管理
// 职责:心跳保活、指数退避重连、类型安全的消息收发
// 来源docs/03-接口文档.md §六 连接管理
// ============================================================
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
const WS_URL =
import.meta.env.VITE_WS_URL ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
const PING_INTERVAL = 30_000; // 30 秒心跳
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
type MessageHandler = (msg: ServerMessage) => void;
type StatusHandler = (status: ConnectionStatus) => void;
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
export class CamTalkWebSocket {
private ws: WebSocket | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true;
private token: string | undefined;
private messageHandlers = new Set<MessageHandler>();
private statusHandlers = new Set<StatusHandler>();
private _status: ConnectionStatus = "disconnected";
get status(): ConnectionStatus {
return this._status;
}
/** 注册消息回调 */
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
/** 注册连接状态回调 */
onStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler);
return () => this.statusHandlers.delete(handler);
}
/** 建立连接,可选传入 JWT token 用于认证 */
connect(token?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.token = token;
this.shouldReconnect = true;
this.setStatus("connecting");
const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL;
const ws = new WebSocket(url);
ws.onopen = () => {
this.reconnectAttempt = 0;
this.setStatus("connected");
this.startPing();
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WsMessage;
// 忽略 pong心跳由服务端自动回复
if (msg.type === "pong") return;
this.messageHandlers.forEach((h) => h(msg as ServerMessage));
} catch {
console.error("[WS] 无法解析消息:", event.data);
}
};
ws.onclose = () => {
this.stopPing();
this.setStatus("disconnected");
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
ws.onerror = (err) => {
console.error("[WS] 连接错误:", err);
ws.close();
};
this.ws = ws;
}
/** 断开连接,不再自动重连 */
disconnect(): void {
this.shouldReconnect = false;
this.clearTimers();
this.ws?.close();
this.ws = null;
this.setStatus("disconnected");
}
/** 发送客户端消息 */
send(msg: ClientMessage): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn("[WS] 连接未就绪,消息丢弃:", msg.type);
return;
}
this.ws.send(JSON.stringify(msg));
}
/** 发送 ping 心跳 */
private startPing(): void {
this.stopPing();
this.pingTimer = setInterval(() => {
this.send({ type: "ping" });
}, PING_INTERVAL);
}
private stopPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
/** 指数退避 + 抖动重连 */
private scheduleReconnect(): void {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_RECONNECT_DELAY);
const jitter = Math.random() * 1000;
const totalDelay = delay + jitter;
console.log(`[WS] ${totalDelay.toFixed(0)}ms 后重连 (attempt ${this.reconnectAttempt})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempt++;
this.connect(this.token);
}, totalDelay);
}
private setStatus(status: ConnectionStatus): void {
this._status = status;
this.statusHandlers.forEach((h) => h(status));
}
private clearTimers(): void {
this.stopPing();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
/** 创建单例实例 */
export const wsClient = new CamTalkWebSocket();