feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -7,36 +7,79 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github } from "lucide-react"
import { Loader2 } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
const ADULT_AGE = 18
function calcAge(birth: string): number | null {
if (!birth) return null
const birthDate = new Date(birth)
if (Number.isNaN(birthDate.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birthDate.getFullYear()
const monthDiff = now.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age -= 1
}
return age >= 0 ? age : null
}
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
registerAction: (formData: FormData) => Promise<ActionState>
}
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [birthDate, setBirthDate] = React.useState<string>("")
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
const [agreedGuardian, setAgreedGuardian] = React.useState<boolean>(false)
const [guardianRelation, setGuardianRelation] = React.useState<string>("")
const router = useRouter()
const age = React.useMemo(() => calcAge(birthDate), [birthDate])
const isMinor = age !== null && age < ADULT_AGE
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
if (!agreedTerms) {
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
return
}
if (isMinor && !agreedGuardian) {
toast.error("未成年人注册须确认已获得监护人同意")
return
}
if (isMinor && !guardianRelation) {
toast.error("请选择监护人与您的关系")
return
}
setIsLoading(true)
try {
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const res = await registerAction(formData)
if (res.success) {
toast.success(res.message || "Account created")
toast.success(res.message || "账户创建成功")
router.push("/login")
router.refresh()
} else {
toast.error(res.message || "Failed to create account")
toast.error(res.message || "注册失败")
}
} catch {
toast.error("Failed to create account")
toast.error("注册失败")
} finally {
setIsLoading(false)
}
@@ -45,21 +88,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
return (
<div className={cn("grid gap-6", className)} {...props}>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<form onSubmit={onSubmit}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Full Name</Label>
<Label htmlFor="name"></Label>
<Input
id="name"
name="name"
placeholder="John Doe"
placeholder="请输入姓名"
type="text"
autoCapitalize="words"
autoComplete="name"
@@ -68,7 +109,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email"></Label>
<Input
id="email"
name="email"
@@ -81,7 +122,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"></Label>
<Input
id="password"
name="password"
@@ -90,39 +131,127 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
disabled={isLoading}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="birthDate"></Label>
<Input
id="birthDate"
name="birthDate"
type="date"
disabled={isLoading}
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
/>
{age !== null && (
<p className="text-xs text-muted-foreground">{age} </p>
)}
</div>
{isMinor && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
</p>
<div className="grid gap-2">
<Label htmlFor="guardianName"></Label>
<Input
id="guardianName"
name="guardianName"
placeholder="请输入监护人姓名"
type="text"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianPhone"></Label>
<Input
id="guardianPhone"
name="guardianPhone"
placeholder="请输入监护人手机号"
type="tel"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianRelation"></Label>
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
<SelectTrigger id="guardianRelation">
<SelectValue placeholder="请选择关系" />
</SelectTrigger>
<SelectContent>
<SelectItem value="父亲"></SelectItem>
<SelectItem value="母亲"></SelectItem>
<SelectItem value="其他法定监护人"></SelectItem>
</SelectContent>
</Select>
<input
type="hidden"
name="guardianRelation"
value={guardianRelation}
/>
</div>
</div>
)}
<div className="flex items-start gap-2">
<Checkbox
id="agreeTerms"
checked={agreedTerms}
onCheckedChange={(v) => setAgreedTerms(v === true)}
disabled={isLoading}
/>
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</Label>
</div>
{isMinor && (
<div className="flex items-start gap-2">
<Checkbox
id="agreeGuardian"
checked={agreedGuardian}
onCheckedChange={(v) => setAgreedGuardian(v === true)}
disabled={isLoading}
/>
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
使
</Label>
</div>
)}
<Button disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Account
</Button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" type="button" disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Github className="mr-2 h-4 w-4" />
)}{" "}
GitHub
</Button>
<p className="px-8 text-center text-sm text-muted-foreground">
Already have an account?{" "}
{" "}
<Link
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
Sign in
</Link>
</p>
</div>