feat(users): add comprehensive user management with search, filtering, and admin controls
- Add paginated user list endpoint with search by email, name, and phone
- Implement role-based and status filtering for user queries
- Add total count to user list response for pagination UI
- Create new Account page component for user profile management
- Add PUT /me endpoint for users to update their own profile information
- Add GET /{user_id} endpoint to retrieve specific user details
- Add PUT /{user_id} endpoint for admin user updates with full control
- Add DELETE /{user_id} endpoint for admin user deletion with self-protection
- Add POST /{user_id}/toggle-status endpoint to enable/disable user accounts
- Implement admin-only access control on user creation and management endpoints
- Add phone field support to user schema and creation flow
- Update user list response schema with pagination metadata
- Improve query validation with min/max constraints on pagination parameters
- Add Chinese localization to user-facing error messages and docstrings
- Update frontend routes to include new Account page
- Update Sidebar navigation to support account management links
- Update Login page styling to match new UI design system
This commit is contained in:
parent
7aa4ab89cb
commit
5676211b20
|
|
@ -1,54 +1,96 @@
|
|||
from typing import Any, List
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import User as UserSchema, UserCreate, UserUpdate
|
||||
from app.schemas.user import User as UserSchema, UserCreate, UserUpdate, UserListResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[UserSchema])
|
||||
@router.get("/", response_model=UserListResponse)
|
||||
async def read_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||
role: Optional[str] = Query(None, description="角色筛选"),
|
||||
is_active: Optional[bool] = Query(None, description="状态筛选"),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve users.
|
||||
获取用户列表(支持分页、搜索、筛选)
|
||||
"""
|
||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
||||
query = select(User)
|
||||
count_query = select(func.count(User.id))
|
||||
|
||||
# 搜索条件
|
||||
if search:
|
||||
search_filter = or_(
|
||||
User.email.ilike(f"%{search}%"),
|
||||
User.full_name.ilike(f"%{search}%"),
|
||||
User.phone.ilike(f"%{search}%")
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
# 角色筛选
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
|
||||
# 状态筛选
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_query = count_query.where(User.is_active == is_active)
|
||||
|
||||
# 获取总数
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# 分页查询
|
||||
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return users
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
@router.post("/", response_model=UserSchema)
|
||||
async def create_user(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_in: UserCreate,
|
||||
current_user: User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Create new user.
|
||||
创建新用户(仅管理员)
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.email == user_in.email))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The user with this username already exists in the system.",
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
|
||||
db_user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=security.get_password_hash(user_in.password),
|
||||
full_name=user_in.full_name,
|
||||
is_active=user_in.is_active,
|
||||
is_superuser=user_in.is_superuser,
|
||||
phone=user_in.phone,
|
||||
role=user_in.role,
|
||||
is_active=user_in.is_active if user_in.is_active is not None else True,
|
||||
is_superuser=user_in.is_superuser if user_in.is_superuser is not None else False,
|
||||
)
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
|
|
@ -60,8 +102,125 @@ async def read_user_me(
|
|||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get current user.
|
||||
获取当前用户信息
|
||||
"""
|
||||
return current_user
|
||||
|
||||
@router.put("/me", response_model=UserSchema)
|
||||
async def update_user_me(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_in: UserUpdate,
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
更新当前用户信息
|
||||
"""
|
||||
update_data = user_in.model_dump(exclude_unset=True)
|
||||
|
||||
# 普通用户不能修改自己的角色和超级管理员状态
|
||||
update_data.pop('role', None)
|
||||
update_data.pop('is_superuser', None)
|
||||
update_data.pop('is_active', None)
|
||||
|
||||
# 如果更新密码
|
||||
if 'password' in update_data and update_data['password']:
|
||||
update_data['hashed_password'] = security.get_password_hash(update_data['password'])
|
||||
update_data.pop('password', None)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
@router.get("/{user_id}", response_model=UserSchema)
|
||||
async def read_user(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
获取指定用户信息
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
@router.put("/{user_id}", response_model=UserSchema)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
*,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_in: UserUpdate,
|
||||
current_user: User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
更新用户信息(仅管理员)
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
update_data = user_in.model_dump(exclude_unset=True)
|
||||
|
||||
# 如果更新密码
|
||||
if 'password' in update_data and update_data['password']:
|
||||
update_data['hashed_password'] = security.get_password_hash(update_data['password'])
|
||||
update_data.pop('password', None)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
删除用户(仅管理员)
|
||||
"""
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己的账户")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"message": "用户已删除"}
|
||||
|
||||
@router.post("/{user_id}/toggle-status", response_model=UserSchema)
|
||||
async def toggle_user_status(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
切换用户状态(启用/禁用)
|
||||
"""
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己的账户")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
user.is_active = not user.is_active
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
class UserBase(BaseModel):
|
||||
|
|
@ -33,4 +33,10 @@ class UserInDBBase(UserBase):
|
|||
class User(UserInDBBase):
|
||||
pass
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: List[User]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AuditTasks from "@/pages/AuditTasks";
|
|||
import TaskDetail from "@/pages/TaskDetail";
|
||||
import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import Account from "@/pages/Account";
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteConfig {
|
||||
|
|
@ -71,6 +72,12 @@ const routes: RouteConfig[] = [
|
|||
element: <LogsPage />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "账号管理",
|
||||
path: "/account",
|
||||
element: <Account />,
|
||||
visible: false, // 不在主导航显示,在侧边栏底部单独显示
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
@ -13,7 +13,8 @@ import {
|
|||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Github
|
||||
Github,
|
||||
UserCircle
|
||||
} from "lucide-react";
|
||||
import routes from "@/app/routes";
|
||||
|
||||
|
|
@ -152,8 +153,30 @@ export default function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer with GitHub Link */}
|
||||
<div className="p-4 border-t border-border bg-card z-10">
|
||||
{/* Footer with Account & GitHub Link */}
|
||||
<div className="p-4 border-t border-border bg-card z-10 space-y-2">
|
||||
{/* Account Link */}
|
||||
<Link
|
||||
to="/account"
|
||||
className={`
|
||||
flex items-center space-x-3 px-3 py-2.5 rounded-sm
|
||||
transition-all duration-200 group
|
||||
border
|
||||
${location.pathname === '/account'
|
||||
? "bg-primary border-primary/30 shadow-md text-white"
|
||||
: "bg-transparent border-transparent hover:border-border hover:bg-card hover:shadow-sm text-gray-600 hover:text-foreground"
|
||||
}
|
||||
`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
title={collapsed ? "账号管理" : undefined}
|
||||
>
|
||||
<UserCircle className={`w-5 h-5 flex-shrink-0 ${location.pathname === '/account' ? 'text-white' : ''}`} />
|
||||
{!collapsed && (
|
||||
<span className="font-mono font-bold text-sm uppercase">账号管理</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* GitHub Link */}
|
||||
<a
|
||||
href="https://github.com/lintsinghua/XCodeReviewer"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Shield,
|
||||
Calendar,
|
||||
Save,
|
||||
Loader2,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
UserPlus,
|
||||
GitBranch
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/shared/api/serverClient";
|
||||
import { toast } from "sonner";
|
||||
import type { Profile } from "@/shared/types";
|
||||
|
||||
export default function Account() {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
full_name: "",
|
||||
phone: "",
|
||||
github_username: "",
|
||||
gitlab_username: "",
|
||||
});
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
current_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
});
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiClient.get('/users/me');
|
||||
setProfile(res.data);
|
||||
setForm({
|
||||
full_name: res.data.full_name || "",
|
||||
phone: res.data.phone || "",
|
||||
github_username: res.data.github_username || "",
|
||||
gitlab_username: res.data.gitlab_username || "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error);
|
||||
toast.error("加载账号信息失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await apiClient.put('/users/me', form);
|
||||
setProfile(res.data);
|
||||
toast.success("账号信息已更新");
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
toast.error("更新失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordForm.new_password || !passwordForm.confirm_password) {
|
||||
toast.error("请填写新密码");
|
||||
return;
|
||||
}
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
toast.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
if (passwordForm.new_password.length < 6) {
|
||||
toast.error("密码长度至少6位");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setChangingPassword(true);
|
||||
await apiClient.put('/users/me', { password: passwordForm.new_password });
|
||||
toast.success("密码已更新");
|
||||
setPasswordForm({ current_password: "", new_password: "", confirm_password: "" });
|
||||
} catch (error) {
|
||||
console.error('Failed to change password:', error);
|
||||
toast.error("密码更新失败");
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getInitials = (name?: string, email?: string) => {
|
||||
if (name) return name.charAt(0).toUpperCase();
|
||||
if (email) return email.charAt(0).toUpperCase();
|
||||
return "U";
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
toast.success("已退出登录");
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleSwitchAccount = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 pt-0 pb-4 bg-background min-h-screen font-mono relative overflow-hidden">
|
||||
{/* Decorative Background */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative z-10 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b-4 border-black pb-6 bg-white/50 backdrop-blur-sm p-4 retro-border">
|
||||
<div>
|
||||
<h1 className="text-3xl font-display font-bold text-black uppercase tracking-tighter">
|
||||
账号<span className="text-primary">_管理</span>
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1 font-mono border-l-2 border-primary pl-2">管理您的个人账号信息</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSwitchAccount}
|
||||
className="terminal-btn-primary bg-white text-black hover:bg-gray-100"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
切换账号
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
className="bg-red-500 hover:bg-red-600 text-white border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Profile Card */}
|
||||
<Card className="retro-card border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<Avatar className="w-24 h-24 mx-auto border-4 border-black">
|
||||
<AvatarImage src={profile?.avatar_url} />
|
||||
<AvatarFallback className="bg-primary text-white text-2xl font-bold">
|
||||
{getInitials(profile?.full_name, profile?.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<CardTitle className="mt-4 font-display uppercase">{profile?.full_name || "未设置姓名"}</CardTitle>
|
||||
<CardDescription className="font-mono">{profile?.email}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Separator />
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-600">角色:</span>
|
||||
<span className="font-bold uppercase">{profile?.role === 'admin' ? '管理员' : '成员'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-600">注册时间:</span>
|
||||
<span className="font-bold">{formatDate(profile?.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Form */}
|
||||
<Card className="lg:col-span-2 retro-card border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-display uppercase flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="font-mono font-bold uppercase text-xs flex items-center gap-2">
|
||||
<Mail className="w-3 h-3" /> 邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={profile?.email || ""}
|
||||
disabled
|
||||
className="terminal-input bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">邮箱不可修改</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="full_name" className="font-mono font-bold uppercase text-xs flex items-center gap-2">
|
||||
<User className="w-3 h-3" /> 姓名
|
||||
</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
value={form.full_name}
|
||||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||
placeholder="请输入姓名"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="font-mono font-bold uppercase text-xs flex items-center gap-2">
|
||||
<Phone className="w-3 h-3" /> 手机号
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="请输入手机号"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-display font-bold uppercase text-sm">代码托管账号</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="github" className="font-mono font-bold uppercase text-xs flex items-center gap-2">
|
||||
<GitBranch className="w-3 h-3" /> GitHub 用户名
|
||||
</Label>
|
||||
<Input
|
||||
id="github"
|
||||
value={form.github_username}
|
||||
onChange={(e) => setForm({ ...form, github_username: e.target.value })}
|
||||
placeholder="your-github-username"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gitlab" className="font-mono font-bold uppercase text-xs flex items-center gap-2">
|
||||
<GitBranch className="w-3 h-3" /> GitLab 用户名
|
||||
</Label>
|
||||
<Input
|
||||
id="gitlab"
|
||||
value={form.gitlab_username}
|
||||
onChange={(e) => setForm({ ...form, gitlab_username: e.target.value })}
|
||||
placeholder="your-gitlab-username"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={handleSave} disabled={saving} className="terminal-btn-primary">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card className="lg:col-span-3 retro-card border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-display uppercase flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
修改密码
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password" className="font-mono font-bold uppercase text-xs">新密码</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
value={passwordForm.new_password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
|
||||
placeholder="输入新密码"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password" className="font-mono font-bold uppercase text-xs">确认密码</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
value={passwordForm.confirm_password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirm_password: e.target.value })}
|
||||
placeholder="再次输入新密码"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleChangePassword} disabled={changingPassword} variant="outline" className="terminal-btn-primary bg-white text-black hover:bg-gray-100">
|
||||
{changingPassword ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
|
||||
更新密码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Logout Confirmation Dialog */}
|
||||
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<AlertDialogContent className="retro-card border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="font-display uppercase">确认退出登录?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="font-mono">
|
||||
退出后需要重新登录才能访问系统。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="terminal-btn-primary bg-white text-black hover:bg-gray-100">
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleLogout}
|
||||
className="bg-red-500 hover:bg-red-600 text-white border-2 border-black"
|
||||
>
|
||||
确认退出
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +1,34 @@
|
|||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/shared/context/AuthContext';
|
||||
import { apiClient } from '@/shared/api/serverClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { Lock, Cpu } from 'lucide-react';
|
||||
import { useState, FormEvent, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { apiClient } from "@/shared/api/serverClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { Lock, Mail, Terminal, Shield, Fingerprint } from "lucide-react";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
|
||||
const from = location.state?.from?.pathname || '/';
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否有保存的邮箱
|
||||
const savedEmail = localStorage.getItem("remembered_email");
|
||||
if (savedEmail) {
|
||||
setEmail(savedEmail);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听认证状态,登录成功后自动跳转
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !loading) {
|
||||
navigate(from, { replace: true });
|
||||
|
|
@ -31,79 +40,118 @@ export default function Login() {
|
|||
setLoading(true);
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', email);
|
||||
formData.append('password', password);
|
||||
formData.append("username", email);
|
||||
formData.append("password", password);
|
||||
|
||||
const response = await apiClient.post('/auth/login', formData, {
|
||||
const response = await apiClient.post("/auth/login", formData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// 记住邮箱
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("remembered_email", email);
|
||||
} else {
|
||||
localStorage.removeItem("remembered_email");
|
||||
}
|
||||
|
||||
await login(response.data.access_token);
|
||||
toast.success('访问已授予');
|
||||
// 跳转由 useEffect 监听 isAuthenticated 状态变化自动处理
|
||||
toast.success("登录成功");
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '访问被拒绝');
|
||||
toast.error(error.response?.data?.detail || "登录失败,请检查邮箱和密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 relative overflow-hidden">
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none" />
|
||||
|
||||
<div className="absolute top-10 left-10 font-mono text-xs text-gray-400 hidden md:block">
|
||||
<div>系统ID: 0x84F2</div>
|
||||
<div>状态: 等待输入</div>
|
||||
<div>加密: AES-256</div>
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-8 left-8 font-mono text-[10px] text-gray-400 hidden lg:block space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span>SYS_ID: 0x84F2</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>ENCRYPT: AES-256</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="w-3 h-3" />
|
||||
<span>AUTH: READY</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-10 right-10 font-mono text-xs text-gray-400 hidden md:block text-right">
|
||||
<div>安全连接</div>
|
||||
<div>端口: 443</div>
|
||||
<div className="absolute bottom-8 right-8 font-mono text-[10px] text-gray-400 hidden lg:block text-right space-y-1">
|
||||
<div>SECURE_CONN: TRUE</div>
|
||||
<div>PORT: 443</div>
|
||||
<div>TLS: 1.3</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="w-full max-w-md relative z-10 p-4">
|
||||
<div className="w-full max-w-md relative z-10 px-4">
|
||||
{/* Logo & Title */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center p-4 bg-white border border-border shadow-md mb-4 rounded-sm">
|
||||
<img src="/logo_xcodereviewer.png" alt="XCodeReviewer" className="w-16 h-16 object-contain" />
|
||||
<div className="inline-flex items-center justify-center p-3 bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] mb-6">
|
||||
<img
|
||||
src="/logo_xcodereviewer.png"
|
||||
alt="XCodeReviewer"
|
||||
className="w-14 h-14 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-4xl font-display font-bold tracking-tighter uppercase">
|
||||
<h1 className="text-3xl font-display font-bold tracking-tighter uppercase">
|
||||
XCode<span className="text-primary">Reviewer</span>
|
||||
</h1>
|
||||
<p className="text-sm font-mono text-gray-500 mt-2">输入凭据以继续</p>
|
||||
<p className="text-sm font-mono text-gray-500 mt-2">
|
||||
// 代码审计平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="terminal-card p-8 relative">
|
||||
{/* Decorative Corner Markers - Subtle */}
|
||||
<div className="absolute top-3 left-3 w-1.5 h-1.5 bg-primary/20 rounded-sm" />
|
||||
<div className="absolute top-3 right-3 w-1.5 h-1.5 bg-primary/20 rounded-sm" />
|
||||
<div className="absolute bottom-3 left-3 w-1.5 h-1.5 bg-primary/20 rounded-sm" />
|
||||
<div className="absolute bottom-3 right-3 w-1.5 h-1.5 bg-primary/20 rounded-sm" />
|
||||
{/* Login Form Card */}
|
||||
<div className="bg-white border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] p-8 relative">
|
||||
{/* Card Header */}
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b-2 border-dashed border-gray-200">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
<span className="ml-2 font-mono text-xs text-gray-500 uppercase">
|
||||
身份验证
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 mt-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="font-mono uppercase text-xs font-bold">身份 / 邮箱</Label>
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="font-mono uppercase text-xs font-bold"
|
||||
>
|
||||
邮箱地址
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="USER@DOMAIN.COM"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="terminal-input font-mono pl-10"
|
||||
className="h-12 pl-11 font-mono border-2 border-gray-200 focus:border-black transition-colors"
|
||||
/>
|
||||
<Cpu className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="font-mono uppercase text-xs font-bold">通行码</Label>
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="font-mono uppercase text-xs font-bold"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
|
|
@ -112,31 +160,66 @@ export default function Login() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="terminal-input font-mono pl-10"
|
||||
className="h-12 pl-11 font-mono border-2 border-gray-200 focus:border-black transition-colors"
|
||||
/>
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked as boolean)
|
||||
}
|
||||
className="border-2 border-gray-300 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="remember"
|
||||
className="text-sm font-mono text-gray-600 cursor-pointer"
|
||||
>
|
||||
记住我
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full terminal-btn-primary text-lg h-12 mt-4"
|
||||
className="w-full h-12 text-base font-bold uppercase tracking-wider border-2 border-black shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] hover:shadow-[1px_1px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] transition-all bg-primary hover:bg-primary/90"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin">/</span> 认证中...
|
||||
<span className="animate-spin">◌</span> 验证中...
|
||||
</span>
|
||||
) : '初始化会话'}
|
||||
) : (
|
||||
"登 录"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 terminal-divider text-center">
|
||||
<div className="text-xs font-mono text-gray-500">
|
||||
没有访问令牌? <span className="text-primary font-bold cursor-pointer hover:underline" onClick={() => navigate('/register')}>申请访问</span>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-5 border-t-2 border-dashed border-gray-200 text-center">
|
||||
<p className="text-sm font-mono text-gray-500">
|
||||
还没有账号?{" "}
|
||||
<span
|
||||
className="text-primary font-bold cursor-pointer hover:underline"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
立即注册
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="font-mono text-[10px] text-gray-400 uppercase">
|
||||
Version 1.3.4 · Secure Connection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue