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

@@ -0,0 +1,128 @@
import { Metadata } from "next"
import Link from "next/link"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
export const metadata: Metadata = {
title: "隐私政策 - Next_Edu",
description: "Next_Edu 隐私政策与个人信息保护说明",
}
const SECTION_CLASS = "space-y-2"
const HEADING_CLASS = "text-base font-semibold text-foreground"
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
export default function PrivacyPage() {
return (
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
2026 6 16
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Next_Edu K12
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
</p>
<ul className={LIST_CLASS}>
<li></li>
<li>//</li>
<li></li>
<li></li>
<li>访</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}>使</h2>
<p className={TEXT_CLASS}></p>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li>使 bcrypt AI 使 AES </li>
<li>访RBACDataScope</li>
<li>HTTPS 访</li>
<li>访</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
<li>使</li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}>Cookie </h2>
<p className={TEXT_CLASS}>
使 Cookie Cookie
Cookie 广
</p>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li> 14 </li>
<li>14 18 </li>
<li>访</li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
</p>
<ul className={LIST_CLASS}>
<li>privacy@next-edu.example.com</li>
<li>400-000-0000 9:00-18:00</li>
</ul>
</section>
<div className="border-t pt-4 text-center">
<Link
href="/register"
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -11,12 +11,27 @@ export const metadata: Metadata = {
description: "Create an account",
}
const ADULT_AGE = 18
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
return `$2b$${value}`
}
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
}
export default function RegisterPage() {
async function registerAction(formData: FormData): Promise<ActionState> {
"use server"
@@ -33,11 +48,23 @@ export default function RegisterPage() {
const name = String(formData.get("name") ?? "").trim()
const email = String(formData.get("email") ?? "").trim().toLowerCase()
const password = String(formData.get("password") ?? "")
const birthDateRaw = String(formData.get("birthDate") ?? "").trim()
const guardianName = String(formData.get("guardianName") ?? "").trim()
const guardianPhone = String(formData.get("guardianPhone") ?? "").trim()
const guardianRelation = String(formData.get("guardianRelation") ?? "").trim()
if (!email) return { success: false, message: "请输入邮箱" }
if (!password) return { success: false, message: "请输入密码" }
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
const age = calcAge(birthDateRaw)
const isMinor = age !== null && age < ADULT_AGE
if (isMinor) {
if (!guardianName) return { success: false, message: "未成年人须填写监护人姓名" }
if (!guardianPhone) return { success: false, message: "未成年人须填写监护人电话" }
if (!guardianRelation) return { success: false, message: "未成年人须选择监护人关系" }
}
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
columns: { id: true },
@@ -51,6 +78,12 @@ export default function RegisterPage() {
name: name.length ? name : null,
email,
password: hashedPassword,
birthDate: birthDateRaw ? new Date(birthDateRaw) : null,
age: age ?? null,
guardianName: guardianName || null,
guardianPhone: guardianPhone || null,
guardianRelation: guardianRelation || null,
consentAcceptedAt: new Date(),
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),

View File

@@ -0,0 +1,116 @@
import { Metadata } from "next"
import Link from "next/link"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
export const metadata: Metadata = {
title: "用户协议 - Next_Edu",
description: "Next_Edu 用户服务协议",
}
const SECTION_CLASS = "space-y-2"
const HEADING_CLASS = "text-base font-semibold text-foreground"
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
export default function TermsPage() {
return (
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
2026 6 16
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
使 Next_Edu 使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<p className={TEXT_CLASS}>
K12 AI
</p>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li>使</li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li>访使</li>
<li></li>
<li>使</li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li>AI 使</li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section className={SECTION_CLASS}>
<h2 className={HEADING_CLASS}></h2>
<ul className={LIST_CLASS}>
<li></li>
<li></li>
</ul>
</section>
<div className="border-t pt-4 text-center">
<Link
href="/register"
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
>
</Link>
</div>
</CardContent>
</Card>
</div>
)
}