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 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
This commit is contained in:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
+269
View File
@@ -0,0 +1,269 @@
"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>
);
}