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