Files
s2f/frontend/app/(auth)/login/page.tsx
T
freedakgmail b93929eb1a feat: UI优化和功能完善
- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
2026-07-07 12:57:15 +08:00

281 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { motion } from "motion/react";
import { Eye, EyeOff, Lock, Mail, AlertCircle, UserCircle } from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const loginSchema = z.object({
email: z.string().email("请输入有效的邮箱地址"),
password: z.string().min(6, "密码至少6个字符"),
});
type LoginFormData = z.infer<typeof loginSchema>;
interface LoginResponse {
access_token: string;
user: {
id: number;
email: string;
full_name: string;
role: string;
permissions: string[];
company_id: number;
};
}
// 测试用户数据
const TEST_USERS = [
{ name: "管理员", email: "admin@xingchen.com", password: "admin123", role: "管理员" },
{ name: "财务主管", email: "finance@xingchen.com", password: "finance123", role: "财务主管" },
{ name: "会计", email: "accountant@xingchen.com", password: "account123", role: "会计" },
];
export default function LoginPage() {
const router = useRouter();
const { login } = useAuthStore();
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// onSubmit 必须在 handleSubmit 之前定义
const onSubmit = async (data: LoginFormData) => {
setLoading(true);
setError("");
try {
const response = await api.post<LoginResponse>(ENDPOINTS.AUTH.LOGIN, data);
const { access_token, user } = response.data;
// 保存到 localStorage
login(user, access_token);
// 设置 cookie
document.cookie = `auth_token=${access_token}; path=/; max-age=86400; SameSite=Lax`;
document.cookie = `company_id=${user.company_id}; path=/; max-age=86400; SameSite=Lax`;
// 直接跳转
router.push("/dashboard");
} catch (err: unknown) {
const error = err as { message?: string };
setError(error.message || "登录失败,请检查邮箱和密码");
} finally {
setLoading(false);
}
};
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
// 快速填充测试用户
const fillTestUser = (email: string, password: string) => {
setValue("email", email);
setValue("password", password);
setError("");
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/30">
{/* Background decoration */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-1/4 w-1/2 h-1/2 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 -right-1/4 w-1/2 h-1/2 bg-primary/3 rounded-full blur-3xl" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
className="w-full max-w-md relative z-10"
>
{/* Logo & Title */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1, ease: [0.16, 1, 0.3, 1] as const }}
className="text-center mb-8"
>
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary/10 mb-4">
<Lock className="w-7 h-7 text-primary" />
</div>
<h1 className="text-heading-1 text-foreground mb-2">AI助手</h1>
<p className="text-muted-foreground">AI助手</p>
</motion.div>
<Card className="shadow-lg border-muted">
<CardContent className="p-8">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<h2 className="text-heading-2 text-foreground mb-6 text-center">
</h2>
</motion.div>
{/* 测试用户快捷登录 */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.25 }}
className="mb-6 p-4 rounded-lg bg-muted/30 border border-border"
>
<div className="flex items-center gap-2 mb-3">
<UserCircle className="w-4 h-4 text-muted-foreground" />
<span className="text-body-small text-muted-foreground"></span>
</div>
<div className="grid grid-cols-3 gap-2">
{TEST_USERS.map((user) => (
<button
key={user.email}
type="button"
onClick={() => fillTestUser(user.email, user.password)}
className="flex flex-col items-center p-2 rounded-lg bg-background hover:bg-primary/5 border border-border hover:border-primary/30 transition-all text-left"
>
<span className="text-body-small font-medium text-foreground">{user.name}</span>
<span className="text-caption text-muted-foreground truncate w-full text-center">{user.email}</span>
</button>
))}
</div>
</motion.div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Error Alert */}
{error && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-start gap-3 p-4 rounded-lg bg-destructive/10 border border-destructive/20"
>
<AlertCircle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive">{error}</p>
</motion.div>
)}
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="email" className="text-body-small font-medium text-foreground">
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
{...register("email")}
type="email"
id="email"
placeholder="your@email.com"
className={cn(
"pl-10 h-11 transition-all",
errors.email && "border-destructive focus:ring-destructive/20"
)}
/>
</div>
{errors.email && (
<p className="text-caption text-destructive">{errors.email.message}</p>
)}
</div>
{/* Password Field */}
<div className="space-y-2">
<label htmlFor="password" className="text-body-small font-medium text-foreground">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
{...register("password")}
type={showPassword ? "text" : "password"}
id="password"
placeholder="输入密码"
className={cn(
"pl-10 pr-10 h-11 transition-all",
errors.password && "border-destructive focus:ring-destructive/20"
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-caption text-destructive">{errors.password.message}</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loading}
className="w-full h-11 text-body font-medium btn-press"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
...
</span>
) : (
"登录"
)}
</Button>
</form>
{/* Footer */}
<p className="text-caption text-muted-foreground text-center mt-6">
</p>
</CardContent>
</Card>
{/* Help Text */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.4 }}
className="text-caption text-muted-foreground text-center mt-6"
>
</motion.p>
</motion.div>
</div>
);
}