Merge pull request 'feat: 添加 PostgreSQL 服务并挂载数据库迁移脚本' #101
@@ -1534,3 +1534,210 @@ body {
|
|||||||
.header__menu-btn:active {
|
.header__menu-btn:active {
|
||||||
transform: scale(0.92);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- User badge in header ---- */
|
||||||
|
|
||||||
|
.header__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__logout-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__logout-btn:hover {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { ChatPanel } from "./components/ChatPanel";
|
|||||||
import { ConfigPanel } from "./components/ConfigPanel";
|
import { ConfigPanel } from "./components/ConfigPanel";
|
||||||
import { SessionSidebar } from "./components/SessionSidebar";
|
import { SessionSidebar } from "./components/SessionSidebar";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
|
import { AuthPage } from "./components/AuthPage";
|
||||||
|
import { AuthProvider, useAuth } from "./lib/auth";
|
||||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||||
import type { Locale } from "./lib/i18n";
|
import type { Locale } from "./lib/i18n";
|
||||||
@@ -22,6 +24,7 @@ type VisionMode = "realtime" | "ondemand" | "chat";
|
|||||||
|
|
||||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
@@ -82,7 +85,7 @@ function AppContent() {
|
|||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
} = useVisionSession();
|
} = useVisionSession(accessToken);
|
||||||
|
|
||||||
const isConnected = connectionStatus === "connected";
|
const isConnected = connectionStatus === "connected";
|
||||||
|
|
||||||
@@ -194,6 +197,21 @@ function AppContent() {
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [sidebarOpen]);
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
// Auth guard:未登录时显示登录页(放在所有 hooks 之后以遵守 Rules of Hooks)
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card" style={{ textAlign: "center", border: "none", background: "transparent", boxShadow: "none" }}>
|
||||||
|
<p style={{ color: "var(--color-text-muted)", fontSize: "0.88rem" }}>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <AuthPage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* ---- 顶部导航栏 ---- */}
|
{/* ---- 顶部导航栏 ---- */}
|
||||||
@@ -216,6 +234,12 @@ function AppContent() {
|
|||||||
<span className="header__subtitle">{tr("app.title")}</span>
|
<span className="header__subtitle">{tr("app.title")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header__right">
|
<div className="header__right">
|
||||||
|
<div className="header__user">
|
||||||
|
<span className="header__username">{user?.username}</span>
|
||||||
|
<button className="header__logout-btn" onClick={logout}>
|
||||||
|
{tr("auth.logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span className={`badge badge--${connectionStatus}`}>
|
<span className={`badge badge--${connectionStatus}`}>
|
||||||
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
|
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
|
||||||
</span>
|
</span>
|
||||||
@@ -489,7 +513,9 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nContext.Provider value={i18nValue}>
|
<I18nContext.Provider value={i18nValue}>
|
||||||
|
<AuthProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
130
frontend/src/components/AuthPage/index.tsx
Normal file
130
frontend/src/components/AuthPage/index.tsx
Normal file
@@ -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<AuthMode>("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 (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-card__header">
|
||||||
|
<h1 className="auth-card__logo">CamTalk</h1>
|
||||||
|
<p className="auth-card__subtitle">{t("auth.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="auth-form__tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`auth-tab ${mode === "login" ? "auth-tab--active" : ""}`}
|
||||||
|
onClick={() => { setMode("login"); setError(""); }}
|
||||||
|
>
|
||||||
|
{t("auth.login")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`auth-tab ${mode === "register" ? "auth-tab--active" : ""}`}
|
||||||
|
onClick={() => { setMode("register"); setError(""); }}
|
||||||
|
>
|
||||||
|
{t("auth.register")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-form__fields">
|
||||||
|
<label className="auth-field">
|
||||||
|
<span className="auth-field__label">{t("auth.username")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="auth-field__input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("auth.username.placeholder")}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="auth-field">
|
||||||
|
<span className="auth-field__label">{t("auth.password")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="auth-field__input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("auth.password.placeholder")}
|
||||||
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-form__error">{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="auth-form__submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t("auth.submitting")
|
||||||
|
: mode === "login"
|
||||||
|
? t("auth.login")
|
||||||
|
: t("auth.register")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card__footer">
|
||||||
|
{mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")}
|
||||||
|
<button className="auth-card__link" onClick={toggleMode}>
|
||||||
|
{mode === "login" ? t("auth.register") : t("auth.login")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function useWebSocketManager() {
|
|||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
connect: () => wsClient.connect(),
|
connect: (token?: string) => wsClient.connect(token),
|
||||||
disconnect: () => wsClient.disconnect(),
|
disconnect: () => wsClient.disconnect(),
|
||||||
send: wsClient.send.bind(wsClient),
|
send: wsClient.send.bind(wsClient),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface SessionStats {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVisionSession() {
|
export function useVisionSession(accessToken?: string | null) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [currentReply, setCurrentReply] = useState<string>("");
|
const [currentReply, setCurrentReply] = useState<string>("");
|
||||||
@@ -316,7 +316,7 @@ export function useVisionSession() {
|
|||||||
const startSession = useCallback(async () => {
|
const startSession = useCallback(async () => {
|
||||||
// 1. 确保 WebSocket 已连接
|
// 1. 确保 WebSocket 已连接
|
||||||
if (statusRef.current !== "connected") {
|
if (statusRef.current !== "connected") {
|
||||||
connect();
|
connect(accessToken || undefined);
|
||||||
// 等待连接完成(通过 status 变化触发后续流程,这里直接继续)
|
// 等待连接完成(通过 status 变化触发后续流程,这里直接继续)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ export function useVisionSession() {
|
|||||||
} else {
|
} else {
|
||||||
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
||||||
}
|
}
|
||||||
}, [startCamera, startMic, connect, startVAD]);
|
}, [startCamera, startMic, connect, startVAD, accessToken]);
|
||||||
|
|
||||||
/** 结束会话 */
|
/** 结束会话 */
|
||||||
const stopSession = useCallback(async () => {
|
const stopSession = useCallback(async () => {
|
||||||
@@ -425,7 +425,7 @@ export function useVisionSession() {
|
|||||||
{ role: "user", content: text.trim(), timestamp: Date.now() }],
|
{ role: "user", content: text.trim(), timestamp: Date.now() }],
|
||||||
);
|
);
|
||||||
// 自动连接 WebSocket
|
// 自动连接 WebSocket
|
||||||
connect();
|
connect(accessToken || undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +453,7 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
},
|
},
|
||||||
[captureFrame, send, connect],
|
[captureFrame, send, connect, accessToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
106
frontend/src/lib/api.ts
Normal file
106
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// ============================================================
|
||||||
|
// HTTP API 客户端
|
||||||
|
// 职责:封装 REST API 请求(auth、conversations 等)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const url = `${API_BASE}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string> {
|
||||||
|
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<ApiResponse<AuthResponse>> {
|
||||||
|
return request<AuthResponse>("/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<ApiResponse<AuthResponse>> {
|
||||||
|
return request<AuthResponse>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(
|
||||||
|
refresh_token: string
|
||||||
|
): Promise<ApiResponse<AuthResponse>> {
|
||||||
|
return request<AuthResponse>("/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ refresh_token }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return request<{ message: string }>("/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(accessToken),
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
}
|
||||||
209
frontend/src/lib/auth.tsx
Normal file
209
frontend/src/lib/auth.tsx
Normal file
@@ -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<void>;
|
||||||
|
accessToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(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<AuthUser | null>(loadUser);
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user && !!accessToken,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
accessToken,
|
||||||
|
}),
|
||||||
|
[user, accessToken, isLoading, login, register, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -130,4 +130,19 @@ export const enUS: TranslationMap = {
|
|||||||
"error.vadInit": "VAD initialization failed",
|
"error.vadInit": "VAD initialization failed",
|
||||||
"error.cameraAccess": "Cannot access camera",
|
"error.cameraAccess": "Cannot access camera",
|
||||||
"error.micAccess": "Cannot access microphone",
|
"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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,4 +130,19 @@ export const jaJP: TranslationMap = {
|
|||||||
"error.vadInit": "VAD初期化に失敗しました",
|
"error.vadInit": "VAD初期化に失敗しました",
|
||||||
"error.cameraAccess": "カメラにアクセスできません",
|
"error.cameraAccess": "カメラにアクセスできません",
|
||||||
"error.micAccess": "マイクにアクセスできません",
|
"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": "ログアウト",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,4 +130,19 @@ export const zhCN: TranslationMap = {
|
|||||||
"error.vadInit": "VAD 初始化失败",
|
"error.vadInit": "VAD 初始化失败",
|
||||||
"error.cameraAccess": "无法访问摄像头",
|
"error.cameraAccess": "无法访问摄像头",
|
||||||
"error.micAccess": "无法访问麦克风",
|
"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": "退出登录",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types
|
|||||||
|
|
||||||
const CONFIG_KEY = "camtalk:config";
|
const CONFIG_KEY = "camtalk:config";
|
||||||
const THEME_KEY = "camtalk:theme";
|
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 = {
|
const DEFAULT_CONFIG: SessionConfig = {
|
||||||
ttsEnabled: true,
|
ttsEnabled: true,
|
||||||
@@ -100,3 +103,70 @@ export function deleteSessionMessages(sessionId: string): void {
|
|||||||
localStorage.removeItem(SESSION_MSG_PREFIX + sessionId);
|
localStorage.removeItem(SESSION_MSG_PREFIX + sessionId);
|
||||||
} catch { /* ignore */ }
|
} 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 */ }
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class CamTalkWebSocket {
|
|||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
|
private token: string | undefined;
|
||||||
|
|
||||||
private messageHandlers = new Set<MessageHandler>();
|
private messageHandlers = new Set<MessageHandler>();
|
||||||
private statusHandlers = new Set<StatusHandler>();
|
private statusHandlers = new Set<StatusHandler>();
|
||||||
@@ -45,14 +46,16 @@ export class CamTalkWebSocket {
|
|||||||
return () => this.statusHandlers.delete(handler);
|
return () => this.statusHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 建立连接 */
|
/** 建立连接,可选传入 JWT token 用于认证 */
|
||||||
connect(): void {
|
connect(token?: string): void {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
this.token = token;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.setStatus("connecting");
|
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 = () => {
|
ws.onopen = () => {
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
@@ -130,7 +133,7 @@ export class CamTalkWebSocket {
|
|||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectAttempt++;
|
this.reconnectAttempt++;
|
||||||
this.connect();
|
this.connect(this.token);
|
||||||
}, totalDelay);
|
}, totalDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user