用户系统
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { AuthGuard } from "@/lib/auth-guard";
|
||||
import { AppProvider } from "@/lib/app-context";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -22,7 +24,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="antialiased">
|
||||
<AppProvider>{children}</AppProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
96
art-agent/frontend/src/app/login/page.tsx
Normal file
96
art-agent/frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (isAuthenticated) {
|
||||
router.replace("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) return;
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||
<div className="w-full max-w-sm mx-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl mb-3">🎨</div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">EPEEKit</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">AI 美术资源生成工具</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="w-full px-4 py-3 rounded-lg
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]
|
||||
transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full px-4 py-3 rounded-lg
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]
|
||||
transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-400/10 rounded-lg px-4 py-2.5">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
className="w-full py-3 rounded-lg font-medium
|
||||
bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
@@ -23,21 +23,76 @@ export default function Home() {
|
||||
setSidebarCollapsed,
|
||||
} = useApp();
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPositions = useRef<Map<string, number>>(new Map());
|
||||
const prevSessionId = useRef<string | null>(null);
|
||||
const isSwitching = useRef(false);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||
if (isSwitching.current && !instant) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (instant) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !activeSessionId) return;
|
||||
const handler = () => {
|
||||
if (!isSwitching.current) {
|
||||
scrollPositions.current.set(activeSessionId, el.scrollTop);
|
||||
}
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setShowScrollBtn(distFromBottom > 200);
|
||||
};
|
||||
el.addEventListener("scroll", handler, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, [activeSessionId]);
|
||||
|
||||
// 会话切换:标记 switching,等 DOM 更新后恢复位置
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
|
||||
isSwitching.current = true;
|
||||
}
|
||||
prevSessionId.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !isSwitching.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const saved = scrollPositions.current.get(activeSessionId);
|
||||
if (saved !== undefined) {
|
||||
el.scrollTop = saved;
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setShowScrollBtn(distFromBottom > 200);
|
||||
isSwitching.current = false;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSessionId, messages.length]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => {
|
||||
@@ -198,15 +253,13 @@ export default function Home() {
|
||||
setStatusText("");
|
||||
scrollToBottom();
|
||||
},
|
||||
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation]
|
||||
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom]
|
||||
);
|
||||
|
||||
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
|
||||
setPendingAnnotation(data);
|
||||
}, []);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
@@ -298,6 +351,24 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<button
|
||||
onClick={() => scrollToBottom()}
|
||||
className="absolute bottom-16 right-4 z-10
|
||||
w-8 h-8 rounded-full flex items-center justify-center
|
||||
bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)] shadow-lg
|
||||
transition-all duration-200 cursor-pointer
|
||||
animate-[fadeIn_150ms_ease-out]"
|
||||
title="回到底部"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={isLoading} />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ImageModelInfo } from "@/lib/types";
|
||||
import { fetchModels } from "@/lib/api";
|
||||
|
||||
const STORAGE_KEY = "epeekit-selected-image-model";
|
||||
import { getStoreUserId } from "@/lib/store";
|
||||
|
||||
function getModelStorageKey() {
|
||||
const uid = getStoreUserId() || "_anonymous";
|
||||
return `epeekit-${uid}-selected-image-model`;
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
value: string;
|
||||
@@ -20,7 +25,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
fetchModels()
|
||||
.then(({ models: list, default: defaultId }) => {
|
||||
setModels(list);
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
const saved = localStorage.getItem(getModelStorageKey());
|
||||
const validIds = new Set(list.map((m) => m.id));
|
||||
if (saved && validIds.has(saved)) {
|
||||
onChange(saved);
|
||||
@@ -90,7 +95,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(STORAGE_KEY, m.id);
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "对话" },
|
||||
@@ -12,6 +14,20 @@ const NAV_ITEMS = [
|
||||
export function TopNav() {
|
||||
const pathname = usePathname();
|
||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||
const { user, logout } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6">
|
||||
@@ -64,21 +80,43 @@ export function TopNav() {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 全局搜索入口 — 移动端只显示图标 */}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
||||
text-[var(--text-secondary)] border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] hover:border-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
title="搜索"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
<kbd className="hidden md:inline text-xs text-[var(--text-secondary)] opacity-50 ml-2">⌘K</kbd>
|
||||
</button>
|
||||
{/* 用户菜单 */}
|
||||
{user && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline max-w-[100px] truncate">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
shadow-xl py-1 z-50">
|
||||
<div className="px-3 py-2 text-xs text-[var(--text-secondary)] border-b border-[var(--border)]">
|
||||
{user.username}
|
||||
{user.is_admin && <span className="ml-1 text-[var(--accent)]">(管理员)</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); logout(); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
*/
|
||||
|
||||
import type { ApiMessage, ImageModelInfo } from "./types";
|
||||
import { getStoredToken } from "./auth-context";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
/** 0-100 */
|
||||
percent: number;
|
||||
@@ -54,6 +60,9 @@ export function uploadRefImage(
|
||||
} catch {
|
||||
reject(new Error("解析上传响应失败"));
|
||||
}
|
||||
} else if (xhr.status === 401) {
|
||||
window.location.href = "/login";
|
||||
reject(new Error("登录已过期"));
|
||||
} else {
|
||||
reject(new Error(`上传失败: ${xhr.status}`));
|
||||
}
|
||||
@@ -65,6 +74,11 @@ export function uploadRefImage(
|
||||
xhr.open("POST", `${API_URL}/api/upload-ref-image`);
|
||||
xhr.timeout = 120_000;
|
||||
|
||||
const token = getStoredToken();
|
||||
if (token) {
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
xhr.send(formData);
|
||||
@@ -83,7 +97,13 @@ export async function fetchModels(): Promise<{
|
||||
models: ImageModelInfo[];
|
||||
default: string;
|
||||
}> {
|
||||
const resp = await fetch(`${API_URL}/api/models`);
|
||||
const resp = await fetch(`${API_URL}/api/models`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
window.location.href = "/login";
|
||||
throw new Error("登录已过期");
|
||||
}
|
||||
if (!resp.ok) throw new Error(`获取模型列表失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
@@ -121,8 +141,14 @@ export async function* sendChat(
|
||||
const response = await fetch(`${API_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/login";
|
||||
throw new Error("登录已过期");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
deleteAsset as storeDeleteAsset,
|
||||
toggleFavorite as storeToggleFavorite,
|
||||
generateId,
|
||||
setStoreUserId,
|
||||
} from "./store";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
interface AppContextValue {
|
||||
// 会话
|
||||
@@ -73,6 +75,8 @@ export function useApp(): AppContextValue {
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
@@ -85,13 +89,20 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 初始化:从 localStorage 加载
|
||||
// 当用户变化时,切换 store 的 userId 并重新加载数据
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !user) {
|
||||
setInitialized(false);
|
||||
return;
|
||||
}
|
||||
setStoreUserId(user.id);
|
||||
setSessions(loadSessions());
|
||||
setTags(loadTags());
|
||||
setAssets(loadAssets());
|
||||
setActiveSessionId(null);
|
||||
setDetailImage(null);
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 初始化后,如果没有会话则自动创建一个
|
||||
useEffect(() => {
|
||||
@@ -181,7 +192,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
messages: [...s.messages, message],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
// 用首条用户消息作为自动标题
|
||||
if (message.role === "user" && s.messages.length === 0) {
|
||||
updated.title = message.content.slice(0, 30) + (message.content.length > 30 ? "…" : "");
|
||||
}
|
||||
@@ -283,7 +293,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
]
|
||||
);
|
||||
|
||||
if (!initialized) return null;
|
||||
// 未登录时(如 /login 页面)直接渲染 children,不注入 AppContext
|
||||
if (!initialized) return <>{children}</>;
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
161
art-agent/frontend/src/lib/auth-context.tsx
Normal file
161
art-agent/frontend/src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "epeekit-auth-token";
|
||||
const REFRESH_KEY = "epeekit-refresh-token";
|
||||
const USER_KEY = "epeekit-auth-user";
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const saveAuth = useCallback((accessToken: string, refreshToken: string, userData: AuthUser) => {
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
setToken(accessToken);
|
||||
setUser(userData);
|
||||
}, []);
|
||||
|
||||
const clearAuth = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// 尝试用 refresh token 获取新 access token
|
||||
const tryRefresh = useCallback(async (): Promise<boolean> => {
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
const data = await resp.json();
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
if (savedUser && data.access_token) {
|
||||
const userData = JSON.parse(savedUser) as AuthUser;
|
||||
localStorage.setItem(TOKEN_KEY, data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser(userData);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 启动时验证 token
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (!savedToken) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const userData = await resp.json();
|
||||
setToken(savedToken);
|
||||
setUser(userData);
|
||||
} else if (resp.status === 401) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (!refreshed) clearAuth();
|
||||
} else {
|
||||
clearAuth();
|
||||
}
|
||||
} catch {
|
||||
// 网络错误时保留本地缓存的用户信息,允许离线使用
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
if (savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
init();
|
||||
}, [clearAuth, tryRefresh]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const resp = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: "登录失败" }));
|
||||
throw new Error(err.detail || "登录失败");
|
||||
}
|
||||
const data = await resp.json();
|
||||
saveAuth(data.access_token, data.refresh_token, data.user);
|
||||
}, [saveAuth]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
}, [clearAuth]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!token && !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
}),
|
||||
[user, token, isLoading, login, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
36
art-agent/frontend/src/lib/auth-guard.tsx
Normal file
36
art-agent/frontend/src/lib/auth-guard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated && !isPublic) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, isPublic, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||
<div className="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !isPublic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* 基于 localStorage 的客户端持久化存储。
|
||||
* 提供会话、标签、图片资源的 CRUD 操作。
|
||||
* 所有 key 按 user_id 隔离,确保多用户数据不混淆。
|
||||
*/
|
||||
|
||||
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
sessions: "epeekit-sessions",
|
||||
tags: "epeekit-tags",
|
||||
assets: "epeekit-assets",
|
||||
} as const;
|
||||
// 当前登录用户的 ID,由 AppProvider 在初始化时设置
|
||||
let _userId = "";
|
||||
|
||||
export function setStoreUserId(id: string) {
|
||||
_userId = id;
|
||||
}
|
||||
|
||||
export function getStoreUserId(): string {
|
||||
return _userId;
|
||||
}
|
||||
|
||||
function storageKey(base: string): string {
|
||||
const prefix = _userId || "_anonymous";
|
||||
return `epeekit-${prefix}-${base}`;
|
||||
}
|
||||
|
||||
// --------------- 内置标签 ---------------
|
||||
|
||||
@@ -52,7 +63,7 @@ export function generateId(prefix = ""): string {
|
||||
// --------------- 标签 ---------------
|
||||
|
||||
export function loadTags(): Tag[] {
|
||||
const custom = readJSON<Tag[]>(STORAGE_KEYS.tags, []);
|
||||
const custom = readJSON<Tag[]>(storageKey("tags"), []);
|
||||
const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id));
|
||||
const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))];
|
||||
return merged;
|
||||
@@ -60,7 +71,7 @@ export function loadTags(): Tag[] {
|
||||
|
||||
export function saveTags(tags: Tag[]) {
|
||||
const custom = tags.filter((t) => !t.builtin);
|
||||
writeJSON(STORAGE_KEYS.tags, custom);
|
||||
writeJSON(storageKey("tags"), custom);
|
||||
}
|
||||
|
||||
export function addTag(name: string, color: string): Tag {
|
||||
@@ -78,11 +89,11 @@ export function deleteTag(tagId: string) {
|
||||
// --------------- 会话 ---------------
|
||||
|
||||
export function loadSessions(): Session[] {
|
||||
return readJSON<Session[]>(STORAGE_KEYS.sessions, []);
|
||||
return readJSON<Session[]>(storageKey("sessions"), []);
|
||||
}
|
||||
|
||||
export function saveSessions(sessions: Session[]) {
|
||||
writeJSON(STORAGE_KEYS.sessions, sessions);
|
||||
writeJSON(storageKey("sessions"), sessions);
|
||||
}
|
||||
|
||||
export function createSession(): Session {
|
||||
@@ -114,7 +125,6 @@ export function updateSession(session: Session) {
|
||||
export function deleteSession(sessionId: string) {
|
||||
const sessions = loadSessions().filter((s) => s.id !== sessionId);
|
||||
saveSessions(sessions);
|
||||
// 同时删除关联的图片资源
|
||||
const assets = loadAssets().filter((a) => a.sessionId !== sessionId);
|
||||
saveAssets(assets);
|
||||
}
|
||||
@@ -122,11 +132,11 @@ export function deleteSession(sessionId: string) {
|
||||
// --------------- 图片资源 ---------------
|
||||
|
||||
export function loadAssets(): ImageAsset[] {
|
||||
return readJSON<ImageAsset[]>(STORAGE_KEYS.assets, []);
|
||||
return readJSON<ImageAsset[]>(storageKey("assets"), []);
|
||||
}
|
||||
|
||||
export function saveAssets(assets: ImageAsset[]) {
|
||||
writeJSON(STORAGE_KEYS.assets, assets);
|
||||
writeJSON(storageKey("assets"), assets);
|
||||
}
|
||||
|
||||
export function addAsset(asset: ImageAsset) {
|
||||
|
||||
Reference in New Issue
Block a user