用户系统

This commit is contained in:
2026-04-15 00:21:43 +08:00
parent 97bbb3f306
commit 47c0863bab
23 changed files with 1640 additions and 56 deletions

View File

@@ -41,5 +41,12 @@ IMAGE_OUTPUT_FORMAT=png
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ─── 用户认证 ─────────────────────────────────────────────
# JWT 签名密钥(必填,建议随机生成 32+ 位字符串)
JWT_SECRET=your-random-secret-key-here
# 首次启动时自动创建的管理员密码(留空则随机生成并打印到控制台)
# ADMIN_DEFAULT_PASSWORD=changeme123
# ─── 服务配置 ─────────────────────────────────────────────
PORT=8000

View File

@@ -70,6 +70,7 @@ async def run_agent_loop(
ref_image_url: Optional[str] = None,
image_model: Optional[str] = None,
session_id: Optional[str] = None,
user_id: str = "default_user",
) -> AsyncGenerator[dict, None]:
"""
运行 Agent Loop以 SSE 事件流形式 yield 结果。
@@ -98,7 +99,7 @@ async def run_agent_loop(
break
if last_user_content:
search_kwargs = {"query": last_user_content, "user_id": "default_user", "limit": 10}
search_kwargs = {"query": last_user_content, "user_id": user_id, "limit": 10}
if session_id:
search_kwargs["run_id"] = session_id
relevant = memory.search(**search_kwargs)
@@ -223,7 +224,7 @@ async def run_agent_loop(
})
continue
# 对话正常结束,异步存储记忆
async for evt in _store_and_done(messages, session_id):
async for evt in _store_and_done(messages, session_id, user_id):
yield evt
return
@@ -297,7 +298,7 @@ async def run_agent_loop(
# 后续工具调用仍需参考图InstantStyle 等模型必须有 style_image
# 迭代次数用尽,存储记忆后结束
async for evt in _store_and_done(messages, session_id):
async for evt in _store_and_done(messages, session_id, user_id):
yield evt
@@ -305,13 +306,13 @@ async def run_agent_loop(
async def _store_and_done(
messages: list[dict], session_id: Optional[str]
messages: list[dict], session_id: Optional[str], user_id: str = "default_user"
) -> AsyncGenerator[dict, None]:
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
try:
memory = get_memory()
recent = messages[-4:] if len(messages) >= 4 else messages
add_kwargs: dict = {"user_id": "default_user"}
add_kwargs: dict = {"user_id": user_id}
if session_id:
add_kwargs["run_id"] = session_id

View File

@@ -0,0 +1,95 @@
"""
管理员路由:创建用户 / 用户列表 / 禁用或删除用户。
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import hash_password, require_admin
from app.db import User, get_session
router = APIRouter(prefix="/admin", tags=["admin"])
class CreateUserRequest(BaseModel):
username: str
password: str
display_name: str = ""
is_admin: bool = False
class UserOut(BaseModel):
id: str
username: str
display_name: str
is_admin: bool
is_active: bool
created_at: str
@router.post("/users", response_model=UserOut)
def create_user(
body: CreateUserRequest,
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
existing = session.exec(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
if len(body.password) < 6:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
user = User(
username=body.username,
hashed_password=hash_password(body.password),
display_name=body.display_name or body.username,
is_admin=body.is_admin,
)
session.add(user)
session.commit()
session.refresh(user)
return UserOut(
id=user.id,
username=user.username,
display_name=user.display_name,
is_admin=user.is_admin,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
@router.get("/users", response_model=list[UserOut])
def list_users(
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
users = session.exec(select(User)).all()
return [
UserOut(
id=u.id,
username=u.username,
display_name=u.display_name,
is_admin=u.is_admin,
is_active=u.is_active,
created_at=u.created_at.isoformat(),
)
for u in users
]
@router.delete("/users/{user_id}")
def disable_user(
user_id: str,
_admin: User = Depends(require_admin),
session: Session = Depends(get_session),
):
user = session.exec(select(User).where(User.id == user_id)).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.id == _admin.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己")
user.is_active = False
session.add(user)
session.commit()
return {"message": f"用户 {user.username} 已禁用"}

View File

@@ -0,0 +1,99 @@
"""
认证路由:登录 / 刷新令牌 / 修改密码 / 当前用户信息。
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import (
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
hash_password,
verify_password,
)
from app.db import User, get_session
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: dict
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
def _user_dict(u: User) -> dict:
return {
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"is_admin": u.is_admin,
}
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
user=_user_dict(user),
)
@router.post("/refresh")
def refresh(body: RefreshRequest, session: Session = Depends(get_session)):
user_id = decode_token(body.refresh_token, expected_type="refresh")
user = session.exec(select(User).where(User.id == user_id)).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
return {
"access_token": create_access_token(user.id),
"token_type": "bearer",
}
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
if not verify_password(body.old_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
if len(body.new_password) < 6:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码至少 6 位")
# 重新获取以确保在同一 session 中
user = session.exec(select(User).where(User.id == current_user.id)).first()
if user:
user.hashed_password = hash_password(body.new_password)
session.add(user)
session.commit()
return {"message": "密码已修改"}
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return _user_dict(current_user)

View File

@@ -3,11 +3,13 @@ import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, File, Form, UploadFile
from fastapi import APIRouter, Depends, File, Form, UploadFile
from sse_starlette.sse import EventSourceResponse
from app.agent.loop import run_agent_loop
from app.auth import get_current_user
from app.config import get_image_models_list, get_default_image_model_id
from app.db import User
router = APIRouter()
@@ -27,6 +29,7 @@ async def _save_upload(file: UploadFile) -> str:
@router.post("/upload-ref-image")
async def upload_ref_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
):
"""
独立的参考图上传端点。
@@ -38,7 +41,7 @@ async def upload_ref_image(
@router.get("/models")
async def list_models():
async def list_models(current_user: User = Depends(get_current_user)):
"""返回可用的图像生成模型列表。"""
return {
"models": get_image_models_list(),
@@ -53,6 +56,7 @@ async def chat(
ref_image_url: Optional[str] = Form(None),
image_model: Optional[str] = Form(None),
session_id: Optional[str] = Form(None),
current_user: User = Depends(get_current_user),
):
"""
主对话端点。
@@ -78,6 +82,7 @@ async def chat(
resolved_ref_url,
image_model=image_model,
session_id=session_id,
user_id=current_user.id,
):
yield {
"event": event["type"],

View File

@@ -0,0 +1,89 @@
"""
认证工具:密码哈希 + JWT 令牌 + FastAPI 依赖注入。
"""
import os
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
from sqlmodel import Session, select
from app.db import User, get_session
# Argon2 优先用于新密码BcryptHasher 兼容旧版已有哈希
pwd_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
def _get_secret() -> str:
secret = os.getenv("JWT_SECRET", "")
if not secret:
raise RuntimeError("JWT_SECRET 环境变量未设置")
return secret
def hash_password(plain: str) -> str:
return pwd_hash.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_hash.verify(plain, hashed)
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": user_id, "exp": expire, "type": "access"},
_get_secret(),
algorithm=ALGORITHM,
)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode(
{"sub": user_id, "exp": expire, "type": "refresh"},
_get_secret(),
algorithm=ALGORITHM,
)
def decode_token(token: str, expected_type: str = "access") -> str:
"""解码 JWT返回 user_id。无效时抛 HTTPException 401。"""
try:
payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
user_id: str = payload.get("sub", "")
token_type: str = payload.get("type", "")
if not user_id or token_type != expected_type:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
return user_id
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌已过期或无效")
def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
"""FastAPI 依赖:从 Bearer token 解析当前用户。"""
user_id = decode_token(token, "access")
user = session.exec(select(User).where(User.id == user_id)).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
"""FastAPI 依赖:要求当前用户是管理员。"""
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user

View File

@@ -0,0 +1,67 @@
"""
数据库初始化 + User 模型。
使用 SQLite单文件存放在 data/epeekit.db。
"""
import secrets
import uuid
from datetime import datetime
from pathlib import Path
from sqlmodel import Field, Session, SQLModel, create_engine, select
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'epeekit.db'}"
engine = create_engine(DATABASE_URL, echo=False)
class User(SQLModel, table=True):
id: str = Field(default_factory=lambda: uuid.uuid4().hex, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
display_name: str = ""
is_admin: bool = False
is_active: bool = True
created_at: datetime = Field(default_factory=datetime.utcnow)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
def ensure_default_admin():
"""如果 users 表为空,创建默认管理员账号。"""
import os
from app.auth import hash_password
with Session(engine) as session:
user = session.exec(select(User).limit(1)).first()
if user is not None:
return
password = os.getenv("ADMIN_DEFAULT_PASSWORD", "")
if not password:
password = secrets.token_urlsafe(12)
print(f"\n{'='*50}")
print(f" 默认管理员账号已创建")
print(f" 用户名: admin")
print(f" 密码: {password}")
print(f" 请登录后尽快修改密码!")
print(f"{'='*50}\n")
admin = User(
username="admin",
hashed_password=hash_password(password),
display_name="管理员",
is_admin=True,
)
session.add(admin)
session.commit()

View File

@@ -1,4 +1,5 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
@@ -9,8 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.chat import router as chat_router
from app.api.auth import router as auth_router
from app.api.admin import router as admin_router
from app.db import create_db_and_tables, ensure_default_admin
app = FastAPI(title="EPEEKit API")
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
ensure_default_admin()
yield
app = FastAPI(title="EPEEKit API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -30,6 +42,8 @@ GENERATED_DIR.mkdir(exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
app.mount("/generated", StaticFiles(directory=str(GENERATED_DIR)), name="generated")
app.include_router(auth_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(chat_router, prefix="/api")

View File

@@ -9,3 +9,6 @@ python-dotenv>=1.0.0
Pillow>=10.4.0
mem0ai
ollama
sqlmodel>=0.0.22
pwdlib[argon2,bcrypt]>=0.3.0
python-jose[cryptography]>=3.3.0

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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}`);
}

View File

@@ -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>;
}

View 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>;
}

View 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}</>;
}

View File

@@ -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) {