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:
128
src/app/(auth)/privacy/page.tsx
Normal file
128
src/app/(auth)/privacy/page.tsx
Normal 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>采用基于角色的访问控制(RBAC)与数据范围(DataScope)行级过滤。</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>
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
116
src/app/(auth)/terms/page.tsx
Normal file
116
src/app/(auth)/terms/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/app/(dashboard)/admin/announcements/[id]/page.tsx
Normal file
36
src/app/(dashboard)/admin/announcements/[id]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getAnnouncementById } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditAnnouncementPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
|
||||
const [announcement, grades] = await Promise.all([
|
||||
getAnnouncementById(id),
|
||||
getGrades(),
|
||||
])
|
||||
|
||||
if (!announcement) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Announcement</h2>
|
||||
<p className="text-muted-foreground">Update the announcement details below.</p>
|
||||
</div>
|
||||
<AnnouncementForm
|
||||
mode="edit"
|
||||
announcement={announcement}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/app/(dashboard)/admin/announcements/page.tsx
Normal file
39
src/app/(dashboard)/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
||||
import type { AnnouncementStatus } from "@/modules/announcements/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is AnnouncementStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
export default async function AdminAnnouncementsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const [announcements, grades] = await Promise.all([
|
||||
getAnnouncements({ status }),
|
||||
getGrades(),
|
||||
])
|
||||
|
||||
return (
|
||||
<AdminAnnouncementsView
|
||||
announcements={announcements}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
initialStatus={status}
|
||||
/>
|
||||
)
|
||||
}
|
||||
71
src/app/(dashboard)/admin/attendance/page.tsx
Normal file
71
src/app/(dashboard)/admin/attendance/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import Link from "next/link"
|
||||
import { BarChart3, ClipboardList } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAttendanceRecords } from "@/modules/attendance/data-access"
|
||||
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
|
||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function AdminAttendancePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const status = getParam(sp, "status")
|
||||
const date = getParam(sp, "date")
|
||||
|
||||
const classes = await getAdminClasses()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
const result = await getAttendanceRecords({
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
classId: classId && classId !== "all" ? classId : undefined,
|
||||
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
|
||||
date: date && date.length > 0 ? date : undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Attendance Overview</h2>
|
||||
<p className="text-muted-foreground">View all attendance records across the school.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/attendance/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Statistics
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AttendanceFilters classes={classOptions} />
|
||||
|
||||
{result.items.length === 0 && !classId && !status && !date ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="There are no attendance records yet."
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
) : (
|
||||
<AttendanceRecordList records={result.items} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx
Normal file
69
src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
getDataChangeLogs,
|
||||
getDataChangeStats,
|
||||
getDataChangeTableOptions,
|
||||
} from "@/modules/audit/data-access"
|
||||
import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-table"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { DataChangeAction } from "@/modules/audit/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function DataChangeLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const tableName = getParam(params, "tableName") ?? undefined
|
||||
const action = (getParam(params, "action") as DataChangeAction | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, tableOptions, stats] = await Promise.all([
|
||||
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
|
||||
getDataChangeTableOptions(),
|
||||
getDataChangeStats(),
|
||||
])
|
||||
|
||||
const exportParams: Record<string, string> = {}
|
||||
if (tableName) exportParams.tableName = tableName
|
||||
if (action) exportParams.action = action
|
||||
if (startDate) exportParams.startDate = startDate
|
||||
if (endDate) exportParams.endDate = endDate
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Change Logs</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Track all data mutations (create/update/delete) across system tables for compliance.
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="dataChange" params={exportParams} />
|
||||
</div>
|
||||
<DataChangeLogTable
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
totalPages={result.totalPages}
|
||||
tableOptions={tableOptions}
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx
Normal file
59
src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getLoginLogs } from "@/modules/audit/data-access"
|
||||
import { LoginLogView } from "@/modules/audit/components/login-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function LoginLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const action = (getParam(params, "action") as LoginLogAction | undefined) ?? undefined
|
||||
const status = (getParam(params, "status") as LoginLogStatus | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
|
||||
const result = await getLoginLogs({ page, action, status, startDate, endDate })
|
||||
|
||||
const exportParams: Record<string, string> = {}
|
||||
if (action) exportParams.action = action
|
||||
if (status) exportParams.status = status
|
||||
if (startDate) exportParams.startDate = startDate
|
||||
if (endDate) exportParams.endDate = endDate
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Login Logs</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor all authentication events including sign in, sign out, and sign up.
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="login" params={exportParams} />
|
||||
</div>
|
||||
<LoginLogView
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
totalPages={result.totalPages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/app/(dashboard)/admin/audit-logs/page.tsx
Normal file
65
src/app/(dashboard)/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
|
||||
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { AuditLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||
const moduleFilter = getParam(params, "module") ?? undefined
|
||||
const action = getParam(params, "action") ?? undefined
|
||||
const status = (getParam(params, "status") as AuditLogStatus | undefined) ?? undefined
|
||||
const startDate = getParam(params, "startDate") ?? undefined
|
||||
const endDate = getParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, moduleOptions] = await Promise.all([
|
||||
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
|
||||
getAuditModuleOptions(),
|
||||
])
|
||||
|
||||
const exportParams: Record<string, string> = {}
|
||||
if (moduleFilter) exportParams.module = moduleFilter
|
||||
if (action) exportParams.action = action
|
||||
if (status) exportParams.status = status
|
||||
if (startDate) exportParams.startDate = startDate
|
||||
if (endDate) exportParams.endDate = endDate
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Audit Logs</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Track all user operations across the system for security and compliance.
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||
</div>
|
||||
<AuditLogView
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
totalPages={result.totalPages}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx
Normal file
45
src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditCoursePlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
|
||||
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
getCoursePlanById(id),
|
||||
getAdminClasses(),
|
||||
getSubjectOptions(),
|
||||
getStaffOptions(),
|
||||
getAcademicYears(),
|
||||
])
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Course Plan</h2>
|
||||
<p className="text-muted-foreground">Update the course plan details below.</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="edit"
|
||||
plan={plan}
|
||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||
subjects={subjects}
|
||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
|
||||
backHref={`/admin/course-plans/${plan.id}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/admin/course-plans/[id]/page.tsx
Normal file
27
src/app/(dashboard)/admin/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<CoursePlanDetail
|
||||
plan={plan}
|
||||
editHref={`/admin/course-plans/${plan.id}/edit`}
|
||||
backHref="/admin/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/admin/course-plans/create/page.tsx
Normal file
32
src/app/(dashboard)/admin/course-plans/create/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateCoursePlanPage() {
|
||||
const [classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
getAdminClasses(),
|
||||
getSubjectOptions(),
|
||||
getStaffOptions(),
|
||||
getAcademicYears(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">New Course Plan</h2>
|
||||
<p className="text-muted-foreground">Create a new course teaching plan.</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="create"
|
||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||
subjects={subjects}
|
||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
|
||||
backHref="/admin/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/admin/course-plans/page.tsx
Normal file
45
src/app/(dashboard)/admin/course-plans/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||
|
||||
export default async function AdminCoursePlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const plans = await getCoursePlans({ status })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Course Plans</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage course teaching plans and weekly schedules.
|
||||
</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
canManage
|
||||
createHref="/admin/course-plans/create"
|
||||
detailHrefBuilder={(id) => `/admin/course-plans/${id}`}
|
||||
initialStatus={status}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/app/(dashboard)/admin/files/page.tsx
Normal file
19
src/app/(dashboard)/admin/files/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
getFileAttachmentsWithFilters,
|
||||
getFileStats,
|
||||
} from "@/modules/files/data-access"
|
||||
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminFilesPage() {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const [files, stats] = await Promise.all([
|
||||
getFileAttachmentsWithFilters({ limit: 200 }),
|
||||
getFileStats(),
|
||||
])
|
||||
|
||||
return <AdminFilesView files={files} stats={stats} />
|
||||
}
|
||||
51
src/app/(dashboard)/admin/scheduling/auto/page.tsx
Normal file
51
src/app/(dashboard)/admin/scheduling/auto/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link"
|
||||
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAdminClassesForScheduling } from "@/modules/scheduling/actions"
|
||||
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingAutoPage() {
|
||||
const classes = await getAdminClassesForScheduling()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Auto Schedule</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Generate a weekly schedule automatically based on configured rules and subject
|
||||
assignments.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/scheduling/rules">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
Configure Rules
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before running auto scheduling."
|
||||
/>
|
||||
) : (
|
||||
<AutoSchedulePanel classes={classOptions} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<span>
|
||||
Applying a new schedule will replace the existing schedule for the selected class.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/app/(dashboard)/admin/scheduling/changes/page.tsx
Normal file
91
src/app/(dashboard)/admin/scheduling/changes/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, ClipboardList } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getScheduleChanges,
|
||||
} from "@/modules/scheduling/actions"
|
||||
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
|
||||
import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view"
|
||||
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is ScheduleChangeStatus =>
|
||||
v === "pending" || v === "approved" || v === "rejected" || v === "completed"
|
||||
|
||||
export default async function AdminSchedulingChangesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
const classIdParam = getParam(sp, "classId")
|
||||
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
|
||||
|
||||
const [classes, items] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
getScheduleChanges({ status, classId }),
|
||||
])
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Review, approve, or reject schedule change and substitute teacher requests.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/schedule-changes">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
New Request
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !status && !classId ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No schedule change requests"
|
||||
description="There are no schedule change requests yet."
|
||||
action={{
|
||||
label: "New Request",
|
||||
href: "/teacher/schedule-changes",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleChangeList items={items} canApprove />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Conflict Detection</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Detect time overlaps in an existing class schedule.
|
||||
</p>
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before checking conflicts."
|
||||
/>
|
||||
) : (
|
||||
<ScheduleConflictsView classes={classOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/app/(dashboard)/admin/scheduling/rules/page.tsx
Normal file
47
src/app/(dashboard)/admin/scheduling/rules/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getSchedulingRules,
|
||||
} from "@/modules/scheduling/actions"
|
||||
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingRulesPage() {
|
||||
const [classes, existingRules] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
getSchedulingRules(),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Scheduling Rules</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure daily hour limits, break windows, and balancing preferences for each class.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before configuring scheduling rules."
|
||||
/>
|
||||
) : (
|
||||
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarCog className="h-4 w-4" />
|
||||
<span>
|
||||
Tip: rules saved without selecting a specific class become the global default.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
src/app/(dashboard)/admin/users/import/page.tsx
Normal file
134
src/app/(dashboard)/admin/users/import/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "批量导入用户 - Next_Edu",
|
||||
description: "通过 Excel 批量导入用户",
|
||||
}
|
||||
|
||||
export default function UserImportPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/dashboard">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold tracking-tight">批量导入用户</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
通过 Excel 文件批量创建用户账号,支持学生自动加入班级。
|
||||
</p>
|
||||
</div>
|
||||
<UserImportDialog />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">导入说明</CardTitle>
|
||||
</div>
|
||||
<CardDescription>使用 Excel 批量导入用户的步骤</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">1</span>
|
||||
<p>点击「批量导入用户」按钮,下载导入模板。</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">2</span>
|
||||
<p>按模板格式填写用户信息(姓名、邮箱、角色、手机、班级邀请码)。</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">3</span>
|
||||
<p>上传填写好的 Excel 文件,系统将解析并预览数据。</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">4</span>
|
||||
<p>确认预览数据无误后,点击「确认导入」完成批量创建。</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-amber-500" />
|
||||
<CardTitle className="text-base">注意事项</CardTitle>
|
||||
</div>
|
||||
<CardDescription>导入前请仔细阅读</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>• 默认密码为 <code className="rounded bg-muted px-1 py-0.5 text-xs">123456</code>,请提示用户首次登录后修改。</p>
|
||||
<p>• 邮箱必须唯一,重复邮箱将被跳过并记录在错误报告中。</p>
|
||||
<p>• 角色可选:admin / teacher / student / parent / grade_head / teaching_head。</p>
|
||||
<p>• 班级邀请码仅对 student 角色有效,填写后学生将自动加入对应班级。</p>
|
||||
<p>• 单次最多导入 10MB 的文件,建议单次不超过 500 条记录。</p>
|
||||
<p>• 导入完成后将显示成功数、失败数及详细错误信息。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">模板字段说明</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Excel 模板各列含义与要求</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 pr-4 text-left font-medium">列名</th>
|
||||
<th className="py-2 pr-4 text-left font-medium">是否必填</th>
|
||||
<th className="py-2 pr-4 text-left font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">姓名</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">用户姓名</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">邮箱</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">登录账号,需符合邮箱格式且唯一</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">角色</td>
|
||||
<td className="py-2 pr-4">必填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">手机</td>
|
||||
<td className="py-2 pr-4">选填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">联系电话</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-medium">班级邀请码</td>
|
||||
<td className="py-2 pr-4">选填</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">仅 student 角色有效,6 位邀请码</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/announcements/page.tsx
Normal file
20
src/app/(dashboard)/announcements/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AnnouncementsPage() {
|
||||
const announcements = await getAnnouncements({ status: "published" })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Stay up to date with the latest school announcements.
|
||||
</p>
|
||||
</div>
|
||||
<AnnouncementList announcements={announcements} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/app/(dashboard)/messages/[id]/page.tsx
Normal file
30
src/app/(dashboard)/messages/[id]/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access"
|
||||
import { MessageDetail } from "@/modules/messaging/components/message-detail"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function MessageDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const { id } = await params
|
||||
|
||||
const message = await getMessageById(id, ctx.userId)
|
||||
if (!message) notFound()
|
||||
|
||||
// Auto-mark as read when viewed by the receiver
|
||||
if (!message.isRead && message.receiverId === ctx.userId) {
|
||||
await markMessageAsRead(id, ctx.userId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-8">
|
||||
<MessageDetail message={message} currentUserId={ctx.userId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/app/(dashboard)/messages/compose/page.tsx
Normal file
34
src/app/(dashboard)/messages/compose/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getRecipients } from "@/modules/messaging/data-access"
|
||||
import { MessageCompose } from "@/modules/messaging/components/message-compose"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ComposeMessagePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ parentId?: string; receiverId?: string; subject?: string }>
|
||||
}) {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
const sp = await searchParams
|
||||
|
||||
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-8">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Compose Message</h2>
|
||||
<p className="text-muted-foreground">Send a message to another user.</p>
|
||||
</div>
|
||||
<MessageCompose
|
||||
recipients={recipients}
|
||||
parentMessageId={sp.parentId}
|
||||
defaultReceiverId={sp.receiverId}
|
||||
defaultSubject={sp.subject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/messages/page.tsx
Normal file
31
src/app/(dashboard)/messages/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getMessages, getNotifications } from "@/modules/messaging/data-access"
|
||||
import { MessageList } from "@/modules/messaging/components/message-list"
|
||||
import { NotificationList } from "@/modules/messaging/components/notification-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function MessagesPage() {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const [messagesResult, notificationsResult] = await Promise.all([
|
||||
getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }),
|
||||
getNotifications(ctx.userId, { page: 1, pageSize: 20 }),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your inbox and stay updated with notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MessageList messages={messagesResult.items} currentUserId={ctx.userId} />
|
||||
|
||||
<NotificationList notifications={notificationsResult.items} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/app/(dashboard)/parent/attendance/page.tsx
Normal file
61
src/app/(dashboard)/parent/attendance/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentAttendancePage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
|
||||
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No children linked"
|
||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id))
|
||||
)
|
||||
|
||||
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
|
||||
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||
</div>
|
||||
|
||||
{validSummaries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="Your children don't have any attendance records yet."
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{validSummaries.map((summary) => (
|
||||
<div key={summary.studentId} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentAttendanceView summary={summary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/app/(dashboard)/parent/children/[studentId]/page.tsx
Normal file
71
src/app/(dashboard)/parent/children/[studentId]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { db } from "@/shared/db"
|
||||
import { parentStudentRelations } from "@/shared/db/schema"
|
||||
import { getChildDashboardData } from "@/modules/parent/data-access"
|
||||
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
||||
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ChildDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ studentId: string }>
|
||||
}) {
|
||||
const { studentId } = await params
|
||||
const ctx = await requireAuth()
|
||||
|
||||
// Verify the student is linked to the current parent
|
||||
const [relation] = await db
|
||||
.select({
|
||||
id: parentStudentRelations.id,
|
||||
relation: parentStudentRelations.relation,
|
||||
})
|
||||
.from(parentStudentRelations)
|
||||
.where(eq(parentStudentRelations.studentId, studentId))
|
||||
.limit(1)
|
||||
|
||||
if (!relation) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Access denied"
|
||||
description="This student is not linked to your account. Please contact the school administrator if you believe this is an error."
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Double-check the parent owns this relation
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Access denied"
|
||||
description="You do not have permission to view this student's data."
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const child = await getChildDashboardData(studentId, relation.relation)
|
||||
if (!child) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<ChildDetailHeader child={child} />
|
||||
<ChildDetailPanel child={child} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
export default function ParentDashboardPage() {
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentDashboardPage() {
|
||||
const ctx = await requireAuth()
|
||||
const data = await getParentDashboardData(ctx.userId)
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Parent Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome, Parent!</p>
|
||||
<div className="p-6 md:p-8">
|
||||
<ParentDashboard data={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
61
src/app/(dashboard)/parent/grades/page.tsx
Normal file
61
src/app/(dashboard)/parent/grades/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentGradesPage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
|
||||
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No children linked"
|
||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id))
|
||||
)
|
||||
|
||||
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
|
||||
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||
</div>
|
||||
|
||||
{validSummaries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No grade records"
|
||||
description="Your children don't have any grade records yet."
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{validSummaries.map((summary) => (
|
||||
<div key={summary.studentId} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<StudentGradeSummary summary={summary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -19,8 +20,13 @@ export default async function SettingsPage() {
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
const permissions = session.user.permissions ?? []
|
||||
const notificationPrefs = await getNotificationPreferences(userId)
|
||||
|
||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) return <AdminSettingsView user={userProfile} />
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return <StudentSettingsView user={userProfile} />
|
||||
return <TeacherSettingsView user={userProfile} />
|
||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) {
|
||||
return <AdminSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
}
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
|
||||
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
}
|
||||
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||
}
|
||||
|
||||
50
src/app/(dashboard)/settings/security/page.tsx
Normal file
50
src/app/(dashboard)/settings/security/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { Lock } from "lucide-react"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = {
|
||||
title: "Security Settings",
|
||||
}
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-7 w-7 text-muted-foreground" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">Security</h1>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Manage your password and account security settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PasswordChangeForm />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Tips</CardTitle>
|
||||
<CardDescription>Best practices to keep your account safe.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>Use a unique password that you don't reuse across other sites.</li>
|
||||
<li>Avoid common words, names, or sequential patterns.</li>
|
||||
<li>Change your password periodically.</li>
|
||||
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/student/attendance/page.tsx
Normal file
40
src/app/(dashboard)/student/attendance/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentAttendancePage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const summary = await getStudentAttendanceSummary(ctx.userId)
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
||||
<p className="text-muted-foreground">View your attendance records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Unable to load your student profile."
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
||||
<p className="text-muted-foreground">View your attendance records and statistics.</p>
|
||||
</div>
|
||||
<StudentAttendanceView summary={summary} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/student/grades/page.tsx
Normal file
40
src/app/(dashboard)/student/grades/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentGradesPage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const summary = await getStudentGradeSummary(ctx.userId)
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
||||
<p className="text-muted-foreground">View your grade records.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Unable to load your student profile."
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
||||
<p className="text-muted-foreground">View your grade records.</p>
|
||||
</div>
|
||||
<StudentGradeSummary summary={summary} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/app/(dashboard)/teacher/attendance/page.tsx
Normal file
83
src/app/(dashboard)/teacher/attendance/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getAttendanceRecords } from "@/modules/attendance/data-access"
|
||||
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
|
||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function TeacherAttendancePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const status = getParam(sp, "status")
|
||||
const date = getParam(sp, "date")
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
const result = await getAttendanceRecords({
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
classId: classId && classId !== "all" ? classId : undefined,
|
||||
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
|
||||
date: date && date.length > 0 ? date : undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Attendance</h2>
|
||||
<p className="text-muted-foreground">Manage student attendance records.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/attendance/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Statistics
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/attendance/sheet">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Take Attendance
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttendanceFilters classes={classOptions} />
|
||||
|
||||
{result.items.length === 0 && !classId && !status && !date ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="Start by taking attendance for your classes."
|
||||
icon={ClipboardList}
|
||||
action={{
|
||||
label: "Take Attendance",
|
||||
href: "/teacher/attendance/sheet",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AttendanceRecordList records={result.items} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/app/(dashboard)/teacher/attendance/sheet/page.tsx
Normal file
49
src/app/(dashboard)/teacher/attendance/sheet/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
|
||||
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function AttendanceSheetPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
|
||||
const defaultClassId = getParam(sp, "classId")
|
||||
const defaultDate = getParam(sp, "date")
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
let students: Array<{ id: string; name: string; email: string }> = []
|
||||
if (defaultClassId) {
|
||||
students = await getClassStudentsForAttendance(defaultClassId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Take Attendance</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Select a class and date, then mark attendance for each student.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AttendanceSheet
|
||||
classes={classOptions}
|
||||
students={students}
|
||||
defaultClassId={defaultClassId}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/app/(dashboard)/teacher/attendance/stats/page.tsx
Normal file
120
src/app/(dashboard)/teacher/attendance/stats/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
||||
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
|
||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function AttendanceStatsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const startDate = getParam(sp, "startDate")
|
||||
const endDate = getParam(sp, "endDate")
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
|
||||
if (classes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
|
||||
<p className="text-muted-foreground">View class attendance statistics.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description="You don't have any classes yet."
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
|
||||
const summary = await getClassAttendanceStats(
|
||||
targetClassId,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
|
||||
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
|
||||
</div>
|
||||
|
||||
<StatsClassSelector
|
||||
classes={classOptions}
|
||||
currentClassId={targetClassId}
|
||||
startDate={startDate ?? ""}
|
||||
endDate={endDate ?? ""}
|
||||
/>
|
||||
|
||||
{summary ? (
|
||||
<>
|
||||
<AttendanceStatsCard stats={summary.stats} />
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Student Records</h3>
|
||||
<AttendanceRecordList records={summary.studentRecords} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No data"
|
||||
description="No attendance data available for this class."
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsClassSelector({
|
||||
classes,
|
||||
currentClassId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
}) {
|
||||
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classes.map((c) => (
|
||||
<a
|
||||
key={c.id}
|
||||
href={`/teacher/attendance/stats?classId=${c.id}${dateParams}`}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
c.id === currentClassId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/app/(dashboard)/teacher/course-plans/[id]/page.tsx
Normal file
26
src/app/(dashboard)/teacher/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherCoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<CoursePlanDetail
|
||||
plan={plan}
|
||||
backHref="/teacher/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/app/(dashboard)/teacher/course-plans/page.tsx
Normal file
49
src/app/(dashboard)/teacher/course-plans/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { auth } from "@/auth"
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||
|
||||
export default async function TeacherCoursePlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const session = await auth()
|
||||
const teacherId = String(session?.user?.id ?? "")
|
||||
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const plans = teacherId
|
||||
? await getCoursePlans({ teacherId, status })
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Course Plans</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View your course teaching plans and weekly schedules.
|
||||
</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
detailHrefBuilder={(id) => `/teacher/course-plans/${id}`}
|
||||
initialStatus={status}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
src/app/(dashboard)/teacher/grades/analytics/page.tsx
Normal file
259
src/app/(dashboard)/teacher/grades/analytics/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import Link from "next/link"
|
||||
import { BarChart3, ArrowLeft } from "lucide-react"
|
||||
import { asc } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { subjects } from "@/shared/db/schema"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
getGradeDistribution,
|
||||
getGradeTrend,
|
||||
getSubjectComparison,
|
||||
} from "@/modules/grades/data-access-analytics"
|
||||
import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
|
||||
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
|
||||
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
||||
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function GradeAnalyticsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
const gradeId = getParam(sp, "gradeId")
|
||||
|
||||
const [classes, allGrades, allSubjects] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
getGrades(),
|
||||
db.query.subjects.findMany({
|
||||
orderBy: [asc(subjects.order), asc(subjects.name)],
|
||||
}),
|
||||
])
|
||||
|
||||
if (classes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Trend analysis, class comparisons, and score distributions.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description="You don't have any classes yet."
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
const targetSubjectId =
|
||||
subjectId && subjectId !== "all" ? subjectId : undefined
|
||||
const targetGradeId = gradeId ?? allGrades[0]?.id
|
||||
|
||||
// Run analytics queries in parallel
|
||||
const [trend, distribution, subjectComparison, classComparison] =
|
||||
await Promise.all([
|
||||
getGradeTrend({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getGradeDistribution({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getSubjectComparison({
|
||||
classId: targetClassId,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
targetGradeId
|
||||
? getClassComparison({
|
||||
gradeId: targetGradeId,
|
||||
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Trend analysis, class comparisons, and score distributions.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Grades
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnalyticsFilters
|
||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
||||
currentClassId={targetClassId}
|
||||
currentSubjectId={subjectId ?? "all"}
|
||||
currentGradeId={targetGradeId ?? ""}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<GradeTrendChart data={trend} />
|
||||
<GradeDistributionChart data={distribution} />
|
||||
<SubjectComparisonChart data={subjectComparison} />
|
||||
<ClassComparisonChart data={classComparison} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AnalyticsFiltersProps {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
grades: Array<{ id: string; name: string }>
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
currentSubjectId: string
|
||||
currentGradeId: string
|
||||
}
|
||||
|
||||
function AnalyticsFilters({
|
||||
classes,
|
||||
grades,
|
||||
subjects,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
currentGradeId,
|
||||
}: AnalyticsFiltersProps) {
|
||||
const buildHref = (overrides: {
|
||||
classId?: string
|
||||
subjectId?: string
|
||||
gradeId?: string
|
||||
}) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set(
|
||||
"classId",
|
||||
overrides.classId !== undefined ? overrides.classId : currentClassId
|
||||
)
|
||||
params.set(
|
||||
"subjectId",
|
||||
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
|
||||
)
|
||||
if (
|
||||
overrides.gradeId !== undefined
|
||||
? overrides.gradeId
|
||||
: currentGradeId
|
||||
) {
|
||||
params.set(
|
||||
"gradeId",
|
||||
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
|
||||
)
|
||||
}
|
||||
return `/teacher/grades/analytics?${params.toString()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Class</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{classes.map((c) => (
|
||||
<a
|
||||
key={c.id}
|
||||
href={buildHref({ classId: c.id })}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
c.id === currentClassId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Subject</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<a
|
||||
href={buildHref({ subjectId: "all" })}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
currentSubjectId === "all"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</a>
|
||||
{subjects.map((s) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={buildHref({ subjectId: s.id })}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
s.id === currentSubjectId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Grade (for class comparison)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{grades.map((g) => (
|
||||
<a
|
||||
key={g.id}
|
||||
href={buildHref({ gradeId: g.id })}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
g.id === currentGradeId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{g.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/app/(dashboard)/teacher/grades/entry/page.tsx
Normal file
52
src/app/(dashboard)/teacher/grades/entry/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { subjects } from "@/shared/db/schema"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
||||
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function BatchEntryPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const sp = await searchParams
|
||||
|
||||
const defaultClassId = getParam(sp, "classId")
|
||||
const defaultSubjectId = getParam(sp, "subjectId")
|
||||
|
||||
const [classes, allSubjects] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
let students: Array<{ id: string; name: string; email: string }> = []
|
||||
if (defaultClassId) {
|
||||
students = await getClassStudentsForEntry(defaultClassId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h2>
|
||||
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||
</div>
|
||||
|
||||
<BatchGradeEntry
|
||||
classes={classOptions}
|
||||
subjects={subjectOptions}
|
||||
students={students}
|
||||
defaultClassId={defaultClassId}
|
||||
defaultSubjectId={defaultSubjectId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
src/app/(dashboard)/teacher/grades/page.tsx
Normal file
101
src/app/(dashboard)/teacher/grades/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { db } from "@/shared/db"
|
||||
import { subjects } from "@/shared/db/schema"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getGradeRecords } from "@/modules/grades/data-access"
|
||||
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
|
||||
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
|
||||
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
const type = getParam(sp, "type")
|
||||
const semester = getParam(sp, "semester")
|
||||
|
||||
const [classes, allSubjects] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||
])
|
||||
|
||||
const records = await getGradeRecords({
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
classId: classId && classId !== "all" ? classId : undefined,
|
||||
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
||||
type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined,
|
||||
semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined,
|
||||
})
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
|
||||
<p className="text-muted-foreground">Manage student grade records.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/grades/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Statistics
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/grades/entry">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
Batch Entry
|
||||
</Link>
|
||||
</Button>
|
||||
<ExportButton
|
||||
classId={classId && classId !== "all" ? classId : ""}
|
||||
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
|
||||
variant="outline"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/grades/entry">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Record Grades
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
||||
|
||||
{records.length === 0 && !classId && !subjectId ? (
|
||||
<EmptyState
|
||||
title="No grade records"
|
||||
description="Start by recording grades for your classes."
|
||||
icon={ClipboardList}
|
||||
action={{
|
||||
label: "Record Grades",
|
||||
href: "/teacher/grades/entry",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GradeRecordList records={records} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
src/app/(dashboard)/teacher/grades/stats/page.tsx
Normal file
139
src/app/(dashboard)/teacher/grades/stats/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { subjects } from "@/shared/db/schema"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access"
|
||||
import { ClassGradeReport } from "@/modules/grades/components/class-grade-report"
|
||||
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function StatsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const sp = await searchParams
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
|
||||
const [classes, allSubjects] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||
])
|
||||
|
||||
if (classes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description="You don't have any classes yet."
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
|
||||
|
||||
const [stats, ranking] = await Promise.all([
|
||||
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
|
||||
getClassRanking(targetClassId, targetSubjectId),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
</div>
|
||||
<ExportButton
|
||||
classId={targetClassId}
|
||||
subjectId={targetSubjectId}
|
||||
variant="outline"
|
||||
label="导出成绩"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatsClassSelector
|
||||
classes={classOptions}
|
||||
subjects={subjectOptions}
|
||||
currentClassId={targetClassId}
|
||||
currentSubjectId={subjectId ?? "all"}
|
||||
/>
|
||||
|
||||
<ClassGradeReport stats={stats} ranking={ranking} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsClassSelector({
|
||||
classes,
|
||||
subjects,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
}: {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
currentSubjectId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classes.map((c) => (
|
||||
<a
|
||||
key={c.id}
|
||||
href={`/teacher/grades/stats?classId=${c.id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
c.id === currentClassId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
))}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`/teacher/grades/stats?classId=${currentClassId}`}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
currentSubjectId === "all"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
All Subjects
|
||||
</a>
|
||||
{subjects.map((s) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`/teacher/grades/stats?classId=${currentClassId}&subjectId=${s.id}`}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
s.id === currentSubjectId
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/app/(dashboard)/teacher/schedule-changes/page.tsx
Normal file
69
src/app/(dashboard)/teacher/schedule-changes/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ClipboardList } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getScheduleChanges,
|
||||
getTeachersForScheduling,
|
||||
} from "@/modules/scheduling/actions"
|
||||
import { ScheduleChangeForm } from "@/modules/scheduling/components/schedule-change-form"
|
||||
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherScheduleChangesPage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// Teachers see only their own requests; admins landing here see all.
|
||||
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
|
||||
|
||||
const [classes, teachers, items] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
getTeachersForScheduling(),
|
||||
getScheduleChanges({ requesterId }),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
const teacherOptions = teachers.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name ?? "Unknown",
|
||||
email: t.email ?? "",
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Submit a schedule change or substitute teacher request, and track its status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="There are no classes available to request schedule changes for."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<ScheduleChangeForm classes={classOptions} teachers={teacherOptions} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">My Requests</h3>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No requests yet"
|
||||
description="Your submitted schedule change requests will appear here."
|
||||
/>
|
||||
) : (
|
||||
<ScheduleChangeList items={items} canApprove={false} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
|
||||
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -16,11 +17,24 @@ export async function POST(req: Request) {
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Rate limit AI chat per user
|
||||
const limitKey = rateLimitKey("ai-chat", userId)
|
||||
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
|
||||
if (!limit.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Rate limit exceeded. Please slow down." },
|
||||
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json().catch(() => null)
|
||||
const input = parseAiChatPayload(body)
|
||||
const result = await createAiChatCompletion(input)
|
||||
return NextResponse.json({ success: true, content: result.content, usage: result.usage })
|
||||
return NextResponse.json(
|
||||
{ success: true, content: result.content, usage: result.usage },
|
||||
{ headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
} catch (e) {
|
||||
const message = getAiErrorMessage(e)
|
||||
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
|
||||
|
||||
135
src/app/api/export/route.ts
Normal file
135
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { exportUsersToExcel } from "@/modules/users/import-export"
|
||||
import { exportGradeRecordsToExcel } from "@/modules/grades/export"
|
||||
import {
|
||||
exportAuditLogsAction,
|
||||
exportDataChangeLogsAction,
|
||||
exportLoginLogsAction,
|
||||
} from "@/modules/audit/actions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type ExportType = "grades" | "users" | "attendance" | "audit" | "login" | "dataChange"
|
||||
|
||||
const isExportType = (v: string): v is ExportType =>
|
||||
v === "grades" || v === "users" || v === "attendance" || v === "audit" || v === "login" || v === "dataChange"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
|
||||
const body = (await req.json()) as {
|
||||
type?: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const type = body.type ?? ""
|
||||
if (!isExportType(type)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid export type" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const params = body.params ?? {}
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
|
||||
if (type === "grades") {
|
||||
buffer = await exportGradeRecordsToExcel({
|
||||
classId: String(params.classId ?? ""),
|
||||
subjectId: params.subjectId ? String(params.subjectId) : undefined,
|
||||
examId: params.examId ? String(params.examId) : undefined,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
filename = `grades_export_${formatDateForFile()}.xlsx`
|
||||
} else if (type === "users") {
|
||||
buffer = await exportUsersToExcel({
|
||||
scope: ctx.dataScope,
|
||||
role: params.role ? String(params.role) : undefined,
|
||||
})
|
||||
filename = `users_export_${formatDateForFile()}.xlsx`
|
||||
} else if (type === "audit" || type === "login" || type === "dataChange") {
|
||||
// Audit-related exports require AUDIT_LOG_READ permission
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: e.message },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const stringParams = Object.fromEntries(
|
||||
Object.entries(params).filter(([, v]) => typeof v === "string")
|
||||
) as Record<string, string>
|
||||
|
||||
if (type === "audit") {
|
||||
const result = await exportAuditLogsAction(stringParams)
|
||||
if (!result.success || !result.data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: result.message ?? "Export failed" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
buffer = result.data.buffer
|
||||
filename = result.data.filename
|
||||
} else if (type === "login") {
|
||||
const result = await exportLoginLogsAction(stringParams)
|
||||
if (!result.success || !result.data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: result.message ?? "Export failed" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
buffer = result.data.buffer
|
||||
filename = result.data.filename
|
||||
} else {
|
||||
const result = await exportDataChangeLogsAction(stringParams)
|
||||
if (!result.success || !result.data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: result.message ?? "Export failed" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
buffer = result.data.buffer
|
||||
filename = result.data.filename
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Attendance export not implemented" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Export failed"
|
||||
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||
? 401
|
||||
: 500
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateForFile(): string {
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(now.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
87
src/app/api/files/[id]/route.ts
Normal file
87
src/app/api/files/[id]/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { unlink } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
deleteFileAttachment,
|
||||
getFileAttachment,
|
||||
} from "@/modules/files/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/files/[id]
|
||||
* 获取文件信息(需要登录)
|
||||
*/
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
await requireAuth()
|
||||
const { id } = await ctx.params
|
||||
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, file })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch file"
|
||||
const status = message.includes("auth_required") ? 401 : 500
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/files/[id]
|
||||
* 删除文件(需要 FILE_DELETE 权限)
|
||||
*/
|
||||
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_DELETE)
|
||||
const { id } = await ctx.params
|
||||
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 删除磁盘文件(静默失败,记录仍会被删除)
|
||||
const absolutePath = path.join(process.cwd(), "public", file.storagePath)
|
||||
try {
|
||||
await unlink(absolutePath)
|
||||
} catch {
|
||||
// 文件可能已不存在,忽略错误
|
||||
}
|
||||
|
||||
const ok = await deleteFileAttachment(id)
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Failed to delete file record" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: "File deleted" })
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: e.message },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "Failed to delete file"
|
||||
return NextResponse.json({ success: false, message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
58
src/app/api/files/batch-delete/route.ts
Normal file
58
src/app/api/files/batch-delete/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { storageProvider } from "@/shared/lib/storage-provider"
|
||||
import {
|
||||
deleteFileAttachments,
|
||||
getFileAttachmentsByIds,
|
||||
} from "@/modules/files/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* POST /api/files/batch-delete
|
||||
* 批量删除文件(需要 FILE_DELETE 权限)
|
||||
* Body: { ids: string[] }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_DELETE)
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { ids?: unknown } | null
|
||||
const ids = Array.isArray(body?.ids) ? body!.ids.filter((x): x is string => typeof x === "string") : []
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "No file ids provided" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 先查出文件记录,以便删除磁盘文件
|
||||
const files = await getFileAttachmentsByIds(ids)
|
||||
|
||||
// 删除磁盘文件(静默失败)
|
||||
await Promise.all(
|
||||
files.map((f) => storageProvider.delete(f.storagePath).catch(() => undefined))
|
||||
)
|
||||
|
||||
const result = await deleteFileAttachments(ids)
|
||||
|
||||
return NextResponse.json({
|
||||
success: result.success,
|
||||
message: `Deleted ${result.deletedCount} file(s)`,
|
||||
deletedCount: result.deletedCount,
|
||||
failedIds: result.failedIds,
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: e.message },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "Failed to batch delete files"
|
||||
return NextResponse.json({ success: false, message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
62
src/app/api/import/route.ts
Normal file
62
src/app/api/import/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { parseExcel } from "@/shared/lib/excel"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
await requirePermission(Permissions.USER_MANAGE)
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get("file")
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "No file provided" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File is empty" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File size exceeds 10MB limit" },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const lowerName = file.name.toLowerCase()
|
||||
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Only .xlsx and .xls files are supported" },
|
||||
{ status: 415 }
|
||||
)
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const sheets = await parseExcel(bytes)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sheets,
|
||||
fileName: file.name,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Import failed"
|
||||
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||
? 401
|
||||
: 500
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
48
src/app/api/rate-limit-test/route.ts
Normal file
48
src/app/api/rate-limit-test/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* Test endpoint for the in-memory rate limiter.
|
||||
* Applies a strict 5-requests-per-minute limit per user so you can
|
||||
* observe 429 responses quickly during manual testing.
|
||||
*
|
||||
* Usage:
|
||||
* curl -X GET http://localhost:3000/api/rate-limit-test \
|
||||
* -H "Cookie: next-auth.session-token=<token>"
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const limitKey = rateLimitKey("test", userId)
|
||||
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
|
||||
|
||||
if (!limit.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Rate limit exceeded",
|
||||
remaining: limit.remaining,
|
||||
retryAfterMs: limit.retryAfterMs,
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Request allowed",
|
||||
remaining: limit.remaining,
|
||||
resetTime: new Date(limit.resetTime).toISOString(),
|
||||
},
|
||||
{ headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
275
src/app/api/search/route.ts
Normal file
275
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
||||
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
announcements,
|
||||
exams,
|
||||
questions,
|
||||
textbooks,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchType = "all" | "question" | "textbook" | "exam" | "announcement"
|
||||
|
||||
const isSearchType = (v: string): v is SearchType =>
|
||||
v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement"
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10
|
||||
|
||||
interface SearchResultItem {
|
||||
id: string
|
||||
title: string
|
||||
snippet: string
|
||||
type: "question" | "textbook" | "exam" | "announcement"
|
||||
href: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
success: boolean
|
||||
query: string
|
||||
type: SearchType
|
||||
results: SearchResultItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search?q=keyword&type=all&page=1
|
||||
* 全文检索:questions / textbooks / exams / announcements
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await requireAuth()
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = (searchParams.get("q") ?? "").trim()
|
||||
const typeRaw = (searchParams.get("type") ?? "all").trim()
|
||||
const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all"
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1)
|
||||
const pageSize = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE)
|
||||
)
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json<SearchResponse>({
|
||||
success: true,
|
||||
query: q,
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
const kw = `%${q}%`
|
||||
const offset = (page - 1) * pageSize
|
||||
const results: SearchResultItem[] = []
|
||||
|
||||
// 并行查询各类型
|
||||
const tasks: Promise<SearchResultItem[]>[] = []
|
||||
|
||||
if (type === "all" || type === "question") {
|
||||
tasks.push(searchQuestions(kw, pageSize))
|
||||
}
|
||||
if (type === "all" || type === "textbook") {
|
||||
tasks.push(searchTextbooks(kw, pageSize))
|
||||
}
|
||||
if (type === "all" || type === "exam") {
|
||||
tasks.push(searchExams(kw, pageSize))
|
||||
}
|
||||
if (type === "all" || type === "announcement") {
|
||||
tasks.push(searchAnnouncements(kw, pageSize))
|
||||
}
|
||||
|
||||
const grouped = await Promise.all(tasks)
|
||||
for (const group of grouped) {
|
||||
results.push(...group)
|
||||
}
|
||||
|
||||
// 按 createdAt 降序排序后分页
|
||||
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
const total = results.length
|
||||
const paged = results.slice(offset, offset + pageSize)
|
||||
|
||||
return NextResponse.json<SearchResponse>({
|
||||
success: true,
|
||||
query: q,
|
||||
type,
|
||||
results: paged,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Search failed"
|
||||
const status = message.includes("auth_required") ? 401 : 500
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
|
||||
async function searchQuestions(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: questions.id,
|
||||
content: questions.content,
|
||||
type: questions.type,
|
||||
createdAt: questions.createdAt,
|
||||
})
|
||||
.from(questions)
|
||||
.where(
|
||||
or(
|
||||
// JSON 内容字段转换为文本进行模糊匹配
|
||||
sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`,
|
||||
like(sql`CAST(${questions.content} AS CHAR)`, kw)
|
||||
)!
|
||||
)
|
||||
.orderBy(desc(questions.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => {
|
||||
const text = extractTextFromJson(r.content)
|
||||
return {
|
||||
id: r.id,
|
||||
title: truncate(text, 80) || `Question (${r.type})`,
|
||||
snippet: truncate(text, 200),
|
||||
type: "question" as const,
|
||||
href: `/admin/questions?id=${r.id}`,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTextbooks(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: textbooks.id,
|
||||
title: textbooks.title,
|
||||
subject: textbooks.subject,
|
||||
grade: textbooks.grade,
|
||||
publisher: textbooks.publisher,
|
||||
createdAt: textbooks.createdAt,
|
||||
})
|
||||
.from(textbooks)
|
||||
.where(
|
||||
or(
|
||||
like(textbooks.title, kw),
|
||||
like(textbooks.subject, kw),
|
||||
like(textbooks.publisher, kw)
|
||||
)!
|
||||
)
|
||||
.orderBy(desc(textbooks.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "),
|
||||
type: "textbook" as const,
|
||||
href: `/admin/textbooks?id=${r.id}`,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function searchExams(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: exams.id,
|
||||
title: exams.title,
|
||||
description: exams.description,
|
||||
status: exams.status,
|
||||
createdAt: exams.createdAt,
|
||||
})
|
||||
.from(exams)
|
||||
.where(
|
||||
or(
|
||||
like(exams.title, kw),
|
||||
like(exams.description, kw)
|
||||
)!
|
||||
)
|
||||
.orderBy(desc(exams.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
snippet: r.description ?? `Status: ${r.status ?? "draft"}`,
|
||||
type: "exam" as const,
|
||||
href: `/admin/exams?id=${r.id}`,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAnnouncements(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
createdAt: announcements.createdAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.where(
|
||||
and(
|
||||
eq(announcements.status, "published"),
|
||||
or(
|
||||
like(announcements.title, kw),
|
||||
like(announcements.content, kw)
|
||||
)!
|
||||
)
|
||||
)
|
||||
.orderBy(desc(announcements.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
snippet: truncate(stripHtml(r.content), 200),
|
||||
type: "announcement" as const,
|
||||
href: `/announcements/${r.id}`,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextFromJson(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
const t = s.trim()
|
||||
if (t.length <= max) return t
|
||||
return t.slice(0, max) + "..."
|
||||
}
|
||||
128
src/app/api/upload/route.ts
Normal file
128
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { mkdir, writeFile } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import {
|
||||
generateStoragePath,
|
||||
isAllowedMimeType,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||
import { createFileAttachment } from "@/modules/files/data-access"
|
||||
import type { FileTargetType } from "@/modules/files/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const VALID_TARGET_TYPES: FileTargetType[] = [
|
||||
"exam",
|
||||
"textbook",
|
||||
"question",
|
||||
"announcement",
|
||||
]
|
||||
|
||||
const isTargetType = (v: string | null): v is FileTargetType =>
|
||||
v !== null && (VALID_TARGET_TYPES as readonly string[]).includes(v)
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const uploaderId = ctx.userId
|
||||
|
||||
// Rate limit uploads per user
|
||||
const limitKey = rateLimitKey("upload", uploaderId)
|
||||
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.UPLOAD })
|
||||
if (!limit.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Upload rate limit exceeded. Please try again later." },
|
||||
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get("file")
|
||||
const targetType = formData.get("targetType")
|
||||
const targetId = formData.get("targetId")
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "No file provided" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File is empty" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "File size exceeds 10MB limit" },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const mimeType = file.type || "application/octet-stream"
|
||||
if (!isAllowedMimeType(mimeType)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: `File type ${mimeType} is not allowed` },
|
||||
{ status: 415 }
|
||||
)
|
||||
}
|
||||
|
||||
const originalName = file.name || "unnamed"
|
||||
const storagePath = generateStoragePath(originalName)
|
||||
const absoluteDir = path.join(process.cwd(), "public", path.dirname(storagePath))
|
||||
await mkdir(absoluteDir, { recursive: true })
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const absolutePath = path.join(process.cwd(), "public", storagePath)
|
||||
await writeFile(absolutePath, bytes)
|
||||
|
||||
const url = `/${storagePath}`
|
||||
const id = createId()
|
||||
const filename = path.basename(storagePath)
|
||||
|
||||
const created = await createFileAttachment({
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storagePath,
|
||||
url,
|
||||
uploaderId,
|
||||
targetType: isTargetType(typeof targetType === "string" ? targetType : null)
|
||||
? (targetType as string)
|
||||
: null,
|
||||
targetId: typeof targetId === "string" && targetId.length > 0 ? targetId : null,
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Failed to persist file record" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: created.id,
|
||||
url: created.url,
|
||||
filename: created.filename,
|
||||
originalName: created.originalName,
|
||||
size: created.size,
|
||||
mimeType: created.mimeType,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Upload failed"
|
||||
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||
? 401
|
||||
: 500
|
||||
return NextResponse.json({ success: false, message }, { status })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user