33b4c734aa
后端: - 新增认证(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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
} |