Files
s2f/frontend/app/(auth)/login/page.tsx
T
freedakgmail 33b4c734aa feat: 完善前后端核心功能模块
后端:
- 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API
- 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers
- 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等
- 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等
- 添加数据库迁移脚本

前端:
- 新增登录页面和仪表盘页面
- 新增任务列表、任务详情、字段映射页面
- 新增异常处理页面和规则设置页面
- 新增 API 代理路由 /api/[...path]
- 新增 UI 组件库 (button, card, dialog, input, table 等)
- 新增 auth 组件 (ProtectedRoute, PermissionGate)
- 新增 layout 组件 (Header, Sidebar)
- 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel)
- 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等)
- 新增状态管理 (auth-store, company-store, ui-store)
- 集成 Tailwind CSS 和 shadcn/ui 组件库

其他:
- 添加 Alembic 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

269 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>;
// 测试用户数据
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(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>
);
}