diff --git a/frontend/src/App.css b/frontend/src/App.css index 544d2d0..6d1d39c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1534,3 +1534,199 @@ body { .header__menu-btn:active { transform: scale(0.92); } + +/* ---- Auth Page ---- */ + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg); + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 32px; + box-shadow: 0 16px 64px rgba(10, 10, 15, 0.4); +} + +.auth-card__header { + text-align: center; + margin-bottom: 28px; +} + +.auth-card__logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; + margin-bottom: 6px; +} + +.auth-card__subtitle { + font-size: 0.82rem; + color: var(--color-text-muted); +} + +/* Auth form tabs */ +.auth-form__tabs { + display: flex; + gap: 2px; + background: var(--color-surface-2); + border-radius: var(--radius-sm); + padding: 3px; + margin-bottom: 24px; +} + +.auth-tab { + flex: 1; + padding: 8px 0; + border: none; + border-radius: 6px; + background: none; + color: var(--color-text-muted); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.auth-tab:hover { + color: var(--color-text); +} + +.auth-tab--active { + background: var(--color-primary); + color: white; +} + +/* Auth form fields */ +.auth-form__fields { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-field__label { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.auth-field__input { + padding: 10px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + color: var(--color-text); + font-size: 0.85rem; + outline: none; + transition: border-color var(--transition-fast); +} + +.auth-field__input::placeholder { + color: var(--color-text-muted); + opacity: 0.6; +} + +.auth-field__input:focus { + border-color: var(--color-primary); +} + +/* Error message */ +.auth-form__error { + padding: 8px 12px; + margin-bottom: 12px; + border-radius: var(--radius-sm); + background: rgba(248, 113, 113, 0.1); + color: var(--color-error); + font-size: 0.78rem; + font-weight: 500; + border: 1px solid rgba(248, 113, 113, 0.2); +} + +/* Submit button */ +.auth-form__submit { + width: 100%; + padding: 11px 0; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: white; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + letter-spacing: 0.01em; +} + +.auth-form__submit:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.auth-form__submit:active:not(:disabled) { + transform: translateY(1px); +} + +.auth-form__submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Footer link */ +.auth-card__footer { + text-align: center; + margin-top: 20px; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.auth-card__link { + background: none; + border: none; + color: var(--color-primary); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + margin-left: 4px; + text-decoration: underline; + text-underline-offset: 2px; +} + +.auth-card__link:hover { + color: var(--color-primary-hover); +} + +/* ---- Logout button in config panel ---- */ + +.config-logout-btn { + width: 100%; + margin-top: 12px; + padding: 9px 0; + border: 1px solid rgba(248, 113, 113, 0.25); + border-radius: var(--radius-sm); + background: rgba(248, 113, 113, 0.08); + color: var(--color-error); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.config-logout-btn:hover { + background: rgba(248, 113, 113, 0.15); + border-color: rgba(248, 113, 113, 0.4); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7eecfe6..d0e9473 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { SessionSidebar } from "./components/SessionSidebar"; import { ToastContainer } from "./components/Toast"; +import { AuthPage } from "./components/AuthPage"; +import { AuthProvider, useAuth } from "./lib/auth"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; import type { Locale } from "./lib/i18n"; @@ -22,6 +24,7 @@ type VisionMode = "realtime" | "ondemand" | "chat"; /** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ function AppContent() { + const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth(); const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); @@ -82,7 +85,7 @@ function AppContent() { toggleCamera, toggleMic, sendTextMessage, - } = useVisionSession(); + } = useVisionSession(accessToken); const isConnected = connectionStatus === "connected"; @@ -194,6 +197,21 @@ function AppContent() { return () => window.removeEventListener("keydown", handler); }, [sidebarOpen]); + // Auth guard:未登录时显示登录页(放在所有 hooks 之后以遵守 Rules of Hooks) + if (isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + return (
{/* ---- 顶部导航栏 ---- */} @@ -249,8 +267,10 @@ function AppContent() { setShowConfig(false)} /> )} @@ -489,7 +509,9 @@ function App() { return ( - + + + ); } diff --git a/frontend/src/components/AuthPage/index.tsx b/frontend/src/components/AuthPage/index.tsx new file mode 100644 index 0000000..99e0965 --- /dev/null +++ b/frontend/src/components/AuthPage/index.tsx @@ -0,0 +1,130 @@ +// ============================================================ +// AuthPage — 登录 / 注册页面 +// 职责:用户认证表单,支持登录和注册模式切换 +// ============================================================ + +import { useCallback, useState } from "react"; +import { useAuth } from "../../lib/auth"; +import { useI18n } from "../../lib/i18n"; + +type AuthMode = "login" | "register"; + +export function AuthPage() { + const { login, register } = useAuth(); + const { t } = useI18n(); + const [mode, setMode] = useState("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // 前端校验 + if (username.length < 3 || username.length > 64) { + setError(t("auth.error.usernameLength")); + return; + } + if (password.length < 8 || password.length > 72) { + setError(t("auth.error.passwordLength")); + return; + } + + setIsSubmitting(true); + const fn = mode === "login" ? login : register; + const result = await fn(username, password); + setIsSubmitting(false); + + if (result.error) { + setError(result.error); + } + }, + [username, password, mode, login, register, t] + ); + + const toggleMode = useCallback(() => { + setMode((m) => (m === "login" ? "register" : "login")); + setError(""); + }, []); + + return ( +
+
+
+

CamTalk

+

{t("auth.subtitle")}

+
+ +
+
+ + +
+ +
+ + + +
+ + {error &&
{error}
} + + +
+ +

+ {mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")} + +

+
+
+ ); +} diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx index 9523ec6..031106e 100644 --- a/frontend/src/components/ConfigPanel/index.tsx +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -9,12 +9,14 @@ import type { SessionConfig, Theme } from "../../types"; interface ConfigPanelProps { config: SessionConfig; theme: Theme; + username?: string; onUpdate: (partial: Partial) => void; onThemeChange: (theme: Theme) => void; + onLogout?: () => void; onClose: () => void; } -export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) { +export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) { const { t } = useI18n(); return ( @@ -88,6 +90,24 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
+ + {username && onLogout && ( +
+
{t("settings.account")}
+
+
+ {t("settings.account.user")} + {username} +
+
+ +
+ )} diff --git a/frontend/src/components/WebSocketManager/index.tsx b/frontend/src/components/WebSocketManager/index.tsx index a2c4537..48c00d7 100644 --- a/frontend/src/components/WebSocketManager/index.tsx +++ b/frontend/src/components/WebSocketManager/index.tsx @@ -25,7 +25,7 @@ export function useWebSocketManager() { return { status, lastMessage, - connect: () => wsClient.connect(), + connect: (token?: string) => wsClient.connect(token), disconnect: () => wsClient.disconnect(), send: wsClient.send.bind(wsClient), }; diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 497b996..0b68bf6 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -29,7 +29,7 @@ export interface SessionStats { totalTokens: number; } -export function useVisionSession() { +export function useVisionSession(accessToken?: string | null) { const { t } = useI18n(); const [messages, setMessages] = useState([]); const [currentReply, setCurrentReply] = useState(""); @@ -316,7 +316,7 @@ export function useVisionSession() { const startSession = useCallback(async () => { // 1. 确保 WebSocket 已连接 if (statusRef.current !== "connected") { - connect(); + connect(accessToken || undefined); // 等待连接完成(通过 status 变化触发后续流程,这里直接继续) } @@ -337,7 +337,7 @@ export function useVisionSession() { } else { console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行"); } - }, [startCamera, startMic, connect, startVAD]); + }, [startCamera, startMic, connect, startVAD, accessToken]); /** 结束会话 */ const stopSession = useCallback(async () => { @@ -425,7 +425,7 @@ export function useVisionSession() { { role: "user", content: text.trim(), timestamp: Date.now() }], ); // 自动连接 WebSocket - connect(); + connect(accessToken || undefined); return; } @@ -453,7 +453,7 @@ export function useVisionSession() { setIsProcessing(true); }, - [captureFrame, send, connect], + [captureFrame, send, connect, accessToken], ); return { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ea11424 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,106 @@ +// ============================================================ +// HTTP API 客户端 +// 职责:封装 REST API 请求(auth、conversations 等) +// ============================================================ + +const API_BASE = "/api"; + +interface ApiResponse { + data?: T; + error?: { code: string; message: string }; + status: number; +} + +async function request( + path: string, + options: RequestInit = {} +): Promise> { + const url = `${API_BASE}${path}`; + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + try { + const res = await fetch(url, { ...options, headers }); + const status = res.status; + + if (res.status === 204) { + return { status }; + } + + const body = await res.json(); + + if (!res.ok) { + return { + error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" }, + status, + }; + } + + return { data: body as T, status }; + } catch (err) { + return { + error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" }, + status: 0, + }; + } +} + +function authHeaders(accessToken: string): Record { + return { Authorization: `Bearer ${accessToken}` }; +} + +// ---- Auth API ---- + +export interface AuthUser { + id: string; + username: string; + created_at: string; +} + +export interface AuthResponse { + user: AuthUser; + access_token: string; + refresh_token: string; +} + +export async function register( + username: string, + password: string +): Promise> { + return request("/auth/register", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function login( + username: string, + password: string +): Promise> { + return request("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function refreshToken( + refresh_token: string +): Promise> { + return request("/auth/refresh", { + method: "POST", + body: JSON.stringify({ refresh_token }), + }); +} + +export async function logout( + accessToken: string, + refreshToken: string +): Promise> { + return request<{ message: string }>("/auth/logout", { + method: "POST", + headers: authHeaders(accessToken), + body: JSON.stringify({ refresh_token: refreshToken }), + }); +} diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..b270f6d --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,209 @@ +// ============================================================ +// Auth Context — 认证状态管理 +// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态 +// ============================================================ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import * as api from "./api"; +import type { AuthUser } from "./api"; +import { + clearAuth, + loadAccessToken, + loadRefreshToken, + loadUser, + saveAccessToken, + saveRefreshToken, + saveUser, +} from "./storage"; + +interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +interface AuthContextValue extends AuthState { + login: (username: string, password: string) => Promise<{ error?: string }>; + register: (username: string, password: string) => Promise<{ error?: string }>; + logout: () => Promise; + accessToken: string | null; +} + +const AuthContext = createContext(null); + +/** Access token 提前刷新的缓冲时间(秒) */ +const REFRESH_BUFFER_SEC = 60; + +/** 解析 JWT payload(不做签名验证) */ +function parseJwtPayload(token: string): { exp?: number } | null { + try { + const base64 = token.split(".")[1]; + const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(loadUser); + const [accessToken, setAccessToken] = useState(loadAccessToken); + const [isLoading, setIsLoading] = useState(true); + const refreshTimerRef = useRef | null>(null); + + // 清除定时器 + const clearRefreshTimer = useCallback(() => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, []); + + // 持久化 token + user + const persistAuth = useCallback( + (authUser: AuthUser, access: string, refresh: string) => { + setUser(authUser); + setAccessToken(access); + saveAccessToken(access); + saveRefreshToken(refresh); + saveUser(authUser); + }, + [] + ); + + // 安排自动刷新 + const scheduleRefresh = useCallback( + (access: string) => { + clearRefreshTimer(); + const payload = parseJwtPayload(access); + if (!payload?.exp) return; + + const nowSec = Math.floor(Date.now() / 1000); + const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000); + + refreshTimerRef.current = setTimeout(async () => { + const rt = loadRefreshToken(); + if (!rt) return; + const res = await api.refreshToken(rt); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + } else { + // 刷新失败,清除 auth + clearAuth(); + setUser(null); + setAccessToken(null); + } + }, delayMs); + }, + [clearRefreshTimer, persistAuth] + ); + + // 初始化:检查已有 token 并尝试刷新 + useEffect(() => { + const init = async () => { + const storedAccess = loadAccessToken(); + const storedRefresh = loadRefreshToken(); + const storedUser = loadUser(); + + if (!storedAccess || !storedRefresh || !storedUser) { + setIsLoading(false); + return; + } + + // 检查 access token 是否过期 + const payload = parseJwtPayload(storedAccess); + const nowSec = Math.floor(Date.now() / 1000); + + if (payload?.exp && payload.exp > nowSec) { + // access token 仍然有效 + setUser(storedUser); + setAccessToken(storedAccess); + scheduleRefresh(storedAccess); + } else { + // access token 过期,尝试 refresh + const res = await api.refreshToken(storedRefresh); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + } else { + clearAuth(); + } + } + setIsLoading(false); + }; + + init(); + return () => clearRefreshTimer(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const login = useCallback( + async (username: string, password: string): Promise<{ error?: string }> => { + const res = await api.login(username, password); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + return {}; + } + return { error: res.error?.message || "登录失败" }; + }, + [persistAuth, scheduleRefresh] + ); + + const register = useCallback( + async (username: string, password: string): Promise<{ error?: string }> => { + const res = await api.register(username, password); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + return {}; + } + return { error: res.error?.message || "注册失败" }; + }, + [persistAuth, scheduleRefresh] + ); + + const logout = useCallback(async () => { + const at = accessToken; + const rt = loadRefreshToken(); + if (at && rt) { + await api.logout(at, rt); + } + clearRefreshTimer(); + clearAuth(); + setUser(null); + setAccessToken(null); + }, [accessToken, clearRefreshTimer]); + + const value = useMemo( + () => ({ + user, + isAuthenticated: !!user && !!accessToken, + isLoading, + login, + register, + logout, + accessToken, + }), + [user, accessToken, isLoading, login, register, logout] + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index b24bcb4..835c5fa 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -130,4 +130,23 @@ export const enUS: TranslationMap = { "error.vadInit": "VAD initialization failed", "error.cameraAccess": "Cannot access camera", "error.micAccess": "Cannot access microphone", + + // Auth + "auth.subtitle": "AI Vision Assistant", + "auth.login": "Sign In", + "auth.register": "Sign Up", + "auth.username": "Username", + "auth.username.placeholder": "3-64 characters", + "auth.password": "Password", + "auth.password.placeholder": "8-72 characters", + "auth.submitting": "Please wait...", + "auth.noAccount": "Don't have an account?", + "auth.hasAccount": "Already have an account?", + "auth.error.usernameLength": "Username must be 3-64 characters", + "auth.error.passwordLength": "Password must be 8-72 characters", + "auth.logout": "Sign Out", + + // Account (in settings) + "settings.account": "Account", + "settings.account.user": "Current user", }; diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 37bc1e8..74f4764 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -130,4 +130,23 @@ export const jaJP: TranslationMap = { "error.vadInit": "VAD初期化に失敗しました", "error.cameraAccess": "カメラにアクセスできません", "error.micAccess": "マイクにアクセスできません", + + // Auth + "auth.subtitle": "AI ビジョンアシスタント", + "auth.login": "ログイン", + "auth.register": "新規登録", + "auth.username": "ユーザー名", + "auth.username.placeholder": "3〜64文字", + "auth.password": "パスワード", + "auth.password.placeholder": "8〜72文字", + "auth.submitting": "お待ちください...", + "auth.noAccount": "アカウントをお持ちでないですか?", + "auth.hasAccount": "すでにアカウントをお持ちですか?", + "auth.error.usernameLength": "ユーザー名は3〜64文字で入力してください", + "auth.error.passwordLength": "パスワードは8〜72文字で入力してください", + "auth.logout": "ログアウト", + + // Account (in settings) + "settings.account": "アカウント", + "settings.account.user": "現在のユーザー", }; diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index 1f49ae0..8fc5716 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -130,4 +130,23 @@ export const zhCN: TranslationMap = { "error.vadInit": "VAD 初始化失败", "error.cameraAccess": "无法访问摄像头", "error.micAccess": "无法访问麦克风", + + // Auth + "auth.subtitle": "AI 视觉对话助手", + "auth.login": "登录", + "auth.register": "注册", + "auth.username": "用户名", + "auth.username.placeholder": "3-64 个字符", + "auth.password": "密码", + "auth.password.placeholder": "8-72 个字符", + "auth.submitting": "请稍候...", + "auth.noAccount": "还没有账号?", + "auth.hasAccount": "已有账号?", + "auth.error.usernameLength": "用户名需要 3-64 个字符", + "auth.error.passwordLength": "密码需要 8-72 个字符", + "auth.logout": "退出登录", + + // Account (in settings) + "settings.account": "账号", + "settings.account.user": "当前用户", }; diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index e7cf6c5..13673f1 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -7,6 +7,9 @@ import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types const CONFIG_KEY = "camtalk:config"; const THEME_KEY = "camtalk:theme"; +const ACCESS_TOKEN_KEY = "camtalk:access_token"; +const REFRESH_TOKEN_KEY = "camtalk:refresh_token"; +const USER_KEY = "camtalk:user"; const DEFAULT_CONFIG: SessionConfig = { ttsEnabled: true, @@ -100,3 +103,70 @@ export function deleteSessionMessages(sessionId: string): void { localStorage.removeItem(SESSION_MSG_PREFIX + sessionId); } catch { /* ignore */ } } + +// ---- Auth Token 存储 ---- + +export interface StoredUser { + id: string; + username: string; + created_at: string; +} + +/** 保存 access token */ +export function saveAccessToken(token: string): void { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + } catch { /* ignore */ } +} + +/** 获取 access token */ +export function loadAccessToken(): string | null { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY); + } catch { + return null; + } +} + +/** 保存 refresh token */ +export function saveRefreshToken(token: string): void { + try { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + } catch { /* ignore */ } +} + +/** 获取 refresh token */ +export function loadRefreshToken(): string | null { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } +} + +/** 保存用户信息 */ +export function saveUser(user: StoredUser): void { + try { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } catch { /* ignore */ } +} + +/** 获取用户信息 */ +export function loadUser(): StoredUser | null { + try { + const raw = localStorage.getItem(USER_KEY); + if (!raw) return null; + return JSON.parse(raw) as StoredUser; + } catch { + return null; + } +} + +/** 清除所有 auth 数据 */ +export function clearAuth(): void { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } catch { /* ignore */ } +} diff --git a/frontend/src/lib/websocket.ts b/frontend/src/lib/websocket.ts index 7d8c947..356413a 100644 --- a/frontend/src/lib/websocket.ts +++ b/frontend/src/lib/websocket.ts @@ -24,6 +24,7 @@ export class CamTalkWebSocket { private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; private shouldReconnect = true; + private token: string | undefined; private messageHandlers = new Set(); private statusHandlers = new Set(); @@ -45,14 +46,16 @@ export class CamTalkWebSocket { return () => this.statusHandlers.delete(handler); } - /** 建立连接 */ - connect(): void { + /** 建立连接,可选传入 JWT token 用于认证 */ + connect(token?: string): void { if (this.ws?.readyState === WebSocket.OPEN) return; + this.token = token; this.shouldReconnect = true; this.setStatus("connecting"); - const ws = new WebSocket(WS_URL); + const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL; + const ws = new WebSocket(url); ws.onopen = () => { this.reconnectAttempt = 0; @@ -130,7 +133,7 @@ export class CamTalkWebSocket { this.reconnectTimer = setTimeout(() => { this.reconnectAttempt++; - this.connect(); + this.connect(this.token); }, totalDelay); }