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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user