feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAnnouncementById } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑公告 - Next_Edu",
|
||||
description: "更新公告详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditAnnouncementPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [announcement, grades] = await Promise.all([
|
||||
@@ -23,8 +30,8 @@ export default async function EditAnnouncementPage({
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑公告</h2>
|
||||
<p className="text-muted-foreground">更新公告详情。</p>
|
||||
</div>
|
||||
<AnnouncementForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
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
|
||||
export const metadata: Metadata = {
|
||||
title: "公告管理 - Next_Edu",
|
||||
description: "管理系统公告,支持草稿、发布与归档",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is AnnouncementStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
@@ -19,9 +21,9 @@ export default async function AdminAnnouncementsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const [announcements, grades] = await Promise.all([
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
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 { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
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"
|
||||
import type { AttendanceStatus } from "@/modules/attendance/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "考勤总览 - Next_Edu",
|
||||
description: "查看全校所有班级的考勤记录",
|
||||
}
|
||||
|
||||
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 isValidAttendanceStatus = (v?: string): v is AttendanceStatus =>
|
||||
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
|
||||
|
||||
export default async function AdminAttendancePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const status = getParam(sp, "status")
|
||||
const date = getParam(sp, "date")
|
||||
const classId = getSearchParam(sp, "classId")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status =
|
||||
statusParam && statusParam !== "all" && isValidAttendanceStatus(statusParam) ? statusParam : undefined
|
||||
const date = getSearchParam(sp, "date")
|
||||
|
||||
const classes = await getAdminClasses()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
@@ -36,7 +46,7 @@ export default async function AdminAttendancePage({
|
||||
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,
|
||||
status,
|
||||
date: date && date.length > 0 ? date : undefined,
|
||||
})
|
||||
|
||||
@@ -44,13 +54,13 @@ export default async function AdminAttendancePage({
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">考勤总览</h2>
|
||||
<p className="text-muted-foreground">查看全校所有班级的考勤记录。</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/attendance/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Statistics
|
||||
统计分析
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -59,8 +69,8 @@ export default async function AdminAttendancePage({
|
||||
|
||||
{result.items.length === 0 && !classId && !status && !date ? (
|
||||
<EmptyState
|
||||
title="No attendance records"
|
||||
description="There are no attendance records yet."
|
||||
title="暂无考勤记录"
|
||||
description="系统中尚未产生任何考勤记录。"
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import {
|
||||
getDataChangeLogs,
|
||||
getDataChangeStats,
|
||||
@@ -9,28 +13,30 @@ import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-t
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { DataChangeAction } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数据变更日志 - Next_Edu",
|
||||
description: "追踪系统所有数据变更(增删改),保障合规",
|
||||
}
|
||||
|
||||
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 isValidDataChangeAction = (v?: string): v is DataChangeAction =>
|
||||
v === "create" || v === "update" || v === "delete"
|
||||
|
||||
export default async function DataChangeLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
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 page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const tableName = getSearchParam(params, "tableName") ?? undefined
|
||||
const actionParam = getSearchParam(params, "action")
|
||||
const action = isValidDataChangeAction(actionParam) ? actionParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, tableOptions, stats] = await Promise.all([
|
||||
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
|
||||
@@ -48,9 +54,9 @@ export default async function DataChangeLogsPage({
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">数据变更日志</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} />
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
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 metadata: Metadata = {
|
||||
title: "登录日志 - Next_Edu",
|
||||
description: "监控所有认证事件,包括登录、登出与注册",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
const isValidLoginLogAction = (v?: string): v is LoginLogAction =>
|
||||
v === "signin" || v === "signout" || v === "signup"
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
const isValidLoginLogStatus = (v?: string): v is LoginLogStatus =>
|
||||
v === "success" || v === "failure"
|
||||
|
||||
export default async function LoginLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
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 page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const actionParam = getSearchParam(params, "action")
|
||||
const action = isValidLoginLogAction(actionParam) ? actionParam : undefined
|
||||
const statusParam = getSearchParam(params, "status")
|
||||
const status = isValidLoginLogStatus(statusParam) ? statusParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const result = await getLoginLogs({ page, action, status, startDate, endDate })
|
||||
|
||||
@@ -40,9 +50,9 @@ export default async function LoginLogsPage({
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">登录日志</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} />
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
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 metadata: Metadata = {
|
||||
title: "审计日志 - Next_Edu",
|
||||
description: "追踪系统内所有用户操作,保障安全与合规",
|
||||
}
|
||||
|
||||
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 isValidAuditLogStatus = (v?: string): v is AuditLogStatus =>
|
||||
v === "success" || v === "failure"
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
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 page = Number(getSearchParam(params, "page") ?? "1") || 1
|
||||
const moduleFilter = getSearchParam(params, "module") ?? undefined
|
||||
const action = getSearchParam(params, "action") ?? undefined
|
||||
const statusParam = getSearchParam(params, "status")
|
||||
const status = isValidAuditLogStatus(statusParam) ? statusParam : undefined
|
||||
const startDate = getSearchParam(params, "startDate") ?? undefined
|
||||
const endDate = getSearchParam(params, "endDate") ?? undefined
|
||||
|
||||
const [result, moduleOptions] = await Promise.all([
|
||||
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
|
||||
@@ -45,9 +51,9 @@ export default async function AuditLogsPage({
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">审计日志</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Track all user operations across the system for security and compliance.
|
||||
追踪系统内所有用户操作,保障安全与合规。
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { getCoursePlanById, 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 metadata: Metadata = {
|
||||
title: "编辑课程计划 - Next_Edu",
|
||||
description: "更新课程计划详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditCoursePlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
@@ -28,8 +34,8 @@ export default async function EditCoursePlanPage({
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑课程计划</h2>
|
||||
<p className="text-muted-foreground">更新课程计划详情。</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "课程计划详情 - Next_Edu",
|
||||
description: "查看课程计划详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
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 metadata: Metadata = {
|
||||
title: "新建课程计划 - Next_Edu",
|
||||
description: "创建新的课程教学计划",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateCoursePlanPage() {
|
||||
export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
|
||||
const [classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
getAdminClasses(),
|
||||
getSubjectOptions(),
|
||||
@@ -16,8 +24,8 @@ export default async function CreateCoursePlanPage() {
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">新建课程计划</h2>
|
||||
<p className="text-muted-foreground">创建新的课程教学计划。</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="create"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
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
|
||||
export const metadata: Metadata = {
|
||||
title: "课程计划 - Next_Edu",
|
||||
description: "管理课程教学计划与周课时安排",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||
|
||||
@@ -18,9 +20,9 @@ export default async function AdminCoursePlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const plans = await getCoursePlans({ status })
|
||||
@@ -28,16 +30,16 @@ export default async function AdminCoursePlansPage({
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">课程计划</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}`}
|
||||
detailBaseHref="/admin/course-plans"
|
||||
initialStatus={status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
|
||||
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "管理控制台 - Next_Edu",
|
||||
description: "系统管理总览",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
||||
const data = await getAdminDashboardData()
|
||||
return <AdminDashboardView data={data} />
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getElectiveCourseById } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑选修课程 - Next_Edu",
|
||||
description: "更新选修课程详情",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditElectiveCoursePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const [course, subjects, grades, teachers] = await Promise.all([
|
||||
@@ -25,8 +32,8 @@ export default async function EditElectiveCoursePage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Elective Course</h2>
|
||||
<p className="text-muted-foreground">Update the elective course details below.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">编辑选修课程</h2>
|
||||
<p className="text-muted-foreground">更新选修课程详情。</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建选修课程 - Next_Edu",
|
||||
description: "创建新的选修课程",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateElectiveCoursePage() {
|
||||
export default async function CreateElectiveCoursePage(): Promise<JSX.Element> {
|
||||
const [subjects, grades, teachers] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
@@ -14,8 +21,8 @@ export default async function CreateElectiveCoursePage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">New Elective Course</h2>
|
||||
<p className="text-muted-foreground">Create a new elective course.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">新建选修课程</h2>
|
||||
<p className="text-muted-foreground">创建新的选修课程。</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="create"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import type { ElectiveCourseStatus } from "@/modules/elective/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 const metadata: Metadata = {
|
||||
title: "选修课程 - Next_Edu",
|
||||
description: "管理选修课程、开放/关闭选课与抽签",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||
|
||||
@@ -18,9 +20,9 @@ export default async function AdminElectivePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const courses = await getElectiveCourses({ status })
|
||||
@@ -28,16 +30,16 @@ export default async function AdminElectivePage({
|
||||
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">Elective Courses</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">选修课程</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage elective courses, open/close selection, and run lottery.
|
||||
管理选修课程、开放/关闭选课与抽签。
|
||||
</p>
|
||||
</div>
|
||||
<ElectiveCourseList
|
||||
courses={courses}
|
||||
canManage
|
||||
createHref="/admin/elective/create"
|
||||
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||
editBaseHref="/admin/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
22
src/app/(dashboard)/admin/error.tsx
Normal file
22
src/app/(dashboard)/admin/error.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
@@ -6,9 +9,14 @@ import {
|
||||
} from "@/modules/files/data-access"
|
||||
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "文件管理 - Next_Edu",
|
||||
description: "查看与管理系统中所有上传文件",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminFilesPage() {
|
||||
export default async function AdminFilesPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const [files, stats] = await Promise.all([
|
||||
getFileAttachmentsWithFilters({ limit: 200 }),
|
||||
|
||||
38
src/app/(dashboard)/admin/loading.tsx
Normal file
38
src/app/(dashboard)/admin/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access"
|
||||
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "自动排课 - Next_Edu",
|
||||
description: "基于规则与学科分配自动生成周课表",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingAutoPage() {
|
||||
export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
const classes = await getAdminClassesForScheduling()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
|
||||
@@ -16,16 +23,15 @@ export default async function AdminSchedulingAutoPage() {
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">自动排课</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>
|
||||
@@ -33,8 +39,8 @@ export default async function AdminSchedulingAutoPage() {
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No classes available"
|
||||
description="Please create classes before running auto scheduling."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行自动排课。"
|
||||
/>
|
||||
) : (
|
||||
<AutoSchedulePanel classes={classOptions} />
|
||||
@@ -42,9 +48,7 @@ export default async function AdminSchedulingAutoPage() {
|
||||
|
||||
<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>
|
||||
<span>应用新课表将替换所选班级的现有课表。</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getScheduleChanges,
|
||||
@@ -11,15 +14,13 @@ import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-cha
|
||||
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
|
||||
export const metadata: Metadata = {
|
||||
title: "课表变更申请 - Next_Edu",
|
||||
description: "审核、批准或拒绝课表变更与代课申请",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const isValidStatus = (v?: string): v is ScheduleChangeStatus =>
|
||||
v === "pending" || v === "approved" || v === "rejected" || v === "completed"
|
||||
|
||||
@@ -27,11 +28,11 @@ export default async function AdminSchedulingChangesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
const classIdParam = getParam(sp, "classId")
|
||||
const classIdParam = getSearchParam(sp, "classId")
|
||||
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
|
||||
|
||||
const [classes, items] = await Promise.all([
|
||||
@@ -44,15 +45,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">课表变更申请</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>
|
||||
@@ -60,10 +61,10 @@ export default async function AdminSchedulingChangesPage({
|
||||
{items.length === 0 && !status && !classId ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="No schedule change requests"
|
||||
description="There are no schedule change requests yet."
|
||||
title="暂无课表变更申请"
|
||||
description="系统中尚未产生任何课表变更申请。"
|
||||
action={{
|
||||
label: "New Request",
|
||||
label: "新建申请",
|
||||
href: "/teacher/schedule-changes",
|
||||
}}
|
||||
/>
|
||||
@@ -72,15 +73,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Conflict Detection</h3>
|
||||
<h3 className="text-lg font-semibold">冲突检测</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."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行冲突检测。"
|
||||
/>
|
||||
) : (
|
||||
<ScheduleConflictsView classes={classOptions} />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
@@ -7,9 +9,14 @@ import {
|
||||
} from "@/modules/scheduling/data-access"
|
||||
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "排课规则 - Next_Edu",
|
||||
description: "配置每日课时上限、课间窗口与均衡偏好",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingRulesPage() {
|
||||
export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
|
||||
const [classes, existingRules] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
getSchedulingRules(),
|
||||
@@ -20,17 +27,17 @@ export default async function AdminSchedulingRulesPage() {
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">排课规则</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."
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再配置排课规则。"
|
||||
/>
|
||||
) : (
|
||||
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
|
||||
@@ -38,9 +45,7 @@ export default async function AdminSchedulingRulesPage() {
|
||||
|
||||
<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>
|
||||
<span>提示:未选择具体班级时保存的规则将作为全局默认。</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
|
||||
import { getAcademicYears } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "学年管理 - Next_Edu",
|
||||
description: "管理学年区间与当前激活学年",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminAcademicYearPage() {
|
||||
export default async function AdminAcademicYearPage(): Promise<JSX.Element> {
|
||||
const years = await getAcademicYears()
|
||||
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">Academic Year</h2>
|
||||
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">学年管理</h2>
|
||||
<p className="text-muted-foreground">管理学年区间与当前激活学年。</p>
|
||||
</div>
|
||||
<AcademicYearClient years={years} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
|
||||
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "班级管理 - Next_Edu",
|
||||
description: "管理班级并分配教师",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchoolClassesPage() {
|
||||
export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
|
||||
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
|
||||
|
||||
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">Classes</h2>
|
||||
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">班级管理</h2>
|
||||
<p className="text-muted-foreground">管理班级并分配教师。</p>
|
||||
</div>
|
||||
<AdminClassesClient classes={classes} teachers={teachers} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { DepartmentsClient } from "@/modules/school/components/departments-view"
|
||||
import { getDepartments } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "部门管理 - Next_Edu",
|
||||
description: "管理学校部门",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminDepartmentsPage() {
|
||||
export default async function AdminDepartmentsPage(): Promise<JSX.Element> {
|
||||
const departments = await getDepartments()
|
||||
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">Departments</h2>
|
||||
<p className="text-muted-foreground">Manage school departments.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">部门管理</h2>
|
||||
<p className="text-muted-foreground">管理学校部门。</p>
|
||||
</div>
|
||||
<DepartmentsClient departments={departments} />
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "年级作业洞察 - Next_Edu",
|
||||
description: "按年级聚合的作业统计与班级排名",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
if (typeof v === "string") return v
|
||||
if (Array.isArray(v)) return v[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
export default async function AdminGradeInsightsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
const grades = await getGrades()
|
||||
const gradeId = getSearchParam(params, "gradeId")
|
||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||
|
||||
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
|
||||
// grades 与 insights 无数据依赖,并行查询
|
||||
const [grades, insights] = await Promise.all([
|
||||
getGrades(),
|
||||
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
|
||||
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">年级作业洞察</h2>
|
||||
<p className="text-muted-foreground">按年级聚合的作业统计与班级排名。</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/school/grades">Manage grades</Link>
|
||||
<Link href="/admin/school/grades">管理年级</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<CardTitle className="text-base">筛选</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label className="text-sm font-medium">Grade</label>
|
||||
<form
|
||||
action="/admin/school/grades/insights"
|
||||
method="get"
|
||||
className="flex flex-col gap-3 md:flex-row md:items-center"
|
||||
>
|
||||
<label htmlFor="grade-filter" className="text-sm font-medium">
|
||||
年级
|
||||
</label>
|
||||
<select
|
||||
id="grade-filter"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-80"
|
||||
>
|
||||
<option value="all">Select a grade</option>
|
||||
<option value="all">请选择年级</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.school.name} / {g.name}
|
||||
@@ -67,7 +77,7 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" className="md:ml-2">
|
||||
Apply
|
||||
应用
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -76,72 +86,56 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Select a grade to view insights"
|
||||
description="Pick a grade to see latest homework and historical score statistics."
|
||||
className="h-[360px] bg-card"
|
||||
title="请选择年级以查看洞察"
|
||||
description="选择一个年级,查看最新作业与历史成绩统计。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : !insights ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Grade not found"
|
||||
description="This grade may not exist or has no accessible data."
|
||||
className="h-[360px] bg-card"
|
||||
title="年级未找到"
|
||||
description="该年级可能不存在或无可访问数据。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : insights.assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No homework data for this grade"
|
||||
description="No homework assignments were targeted to students in this grade yet."
|
||||
className="h-[360px] bg-card"
|
||||
title="该年级暂无作业数据"
|
||||
description="尚未向该年级学生布置任何作业。"
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Classes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{insights.grade.school.name} / {insights.grade.name}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active {insights.studentCounts.active} • Inactive {insights.studentCounts.inactive}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
|
||||
<div className="text-xs text-muted-foreground">Across graded homework</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
|
||||
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
title="班级数"
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="学生数"
|
||||
value={insights.studentCounts.total}
|
||||
description={`在读 ${insights.studentCounts.active} • 停用 ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="总体均分"
|
||||
value={formatNumber(insights.overallScores.avg)}
|
||||
description="基于已批改作业"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="最新均分"
|
||||
value={formatNumber(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest?.title ?? "-"}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Latest homework</CardTitle>
|
||||
<CardTitle className="text-base">最新作业</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.assignments.length}
|
||||
</Badge>
|
||||
@@ -151,14 +145,14 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Targeted</TableHead>
|
||||
<TableHead className="text-right">Submitted</TableHead>
|
||||
<TableHead className="text-right">Graded</TableHead>
|
||||
<TableHead className="text-right">Avg</TableHead>
|
||||
<TableHead className="text-right">Median</TableHead>
|
||||
<TableHead>作业</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">目标数</TableHead>
|
||||
<TableHead className="text-right">提交数</TableHead>
|
||||
<TableHead className="text-right">已批改</TableHead>
|
||||
<TableHead className="text-right">均分</TableHead>
|
||||
<TableHead className="text-right">中位数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -174,8 +168,8 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -186,7 +180,7 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Class ranking</CardTitle>
|
||||
<CardTitle className="text-base">班级排名</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.classes.length}
|
||||
</Badge>
|
||||
@@ -196,12 +190,12 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead className="text-right">Latest Avg</TableHead>
|
||||
<TableHead className="text-right">Prev Avg</TableHead>
|
||||
<TableHead>班级</TableHead>
|
||||
<TableHead className="text-right">学生数</TableHead>
|
||||
<TableHead className="text-right">最新均分</TableHead>
|
||||
<TableHead className="text-right">上次均分</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
<TableHead className="text-right">Overall Avg</TableHead>
|
||||
<TableHead className="text-right">总体均分</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -209,13 +203,15 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? <span className="text-muted-foreground"> • {c.class.homeroom}</span> : null}
|
||||
{c.class.homeroom ? (
|
||||
<span className="text-muted-foreground"> • {c.class.homeroom}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -228,4 +224,3 @@ export default async function AdminGradeInsightsPage({ searchParams }: { searchP
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { GradesClient } from "@/modules/school/components/grades-view"
|
||||
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "年级管理 - Next_Edu",
|
||||
description: "管理年级并分配年级组长",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminGradesPage() {
|
||||
export default async function AdminGradesPage(): Promise<JSX.Element> {
|
||||
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
|
||||
|
||||
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">Grades</h2>
|
||||
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">年级管理</h2>
|
||||
<p className="text-muted-foreground">管理年级并分配年级组长。</p>
|
||||
</div>
|
||||
<GradesClient grades={grades} schools={schools} staff={staff} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function AdminSchoolPage() {
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function AdminSchoolPage(): never {
|
||||
redirect("/admin/school/classes")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { SchoolsClient } from "@/modules/school/components/schools-view"
|
||||
import { getSchools } from "@/modules/school/data-access"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "学校管理 - Next_Edu",
|
||||
description: "多校区场景下的学校管理",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchoolsPage() {
|
||||
export default async function AdminSchoolsPage(): Promise<JSX.Element> {
|
||||
const schools = await getSchools()
|
||||
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">Schools</h2>
|
||||
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">学校管理</h2>
|
||||
<p className="text-muted-foreground">多校区场景下的学校管理。</p>
|
||||
</div>
|
||||
<SchoolsClient schools={schools} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Metadata } from "next"
|
||||
import { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -11,7 +20,9 @@ export const metadata: Metadata = {
|
||||
description: "通过 Excel 批量导入用户",
|
||||
}
|
||||
|
||||
export default function UserImportPage() {
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function UserImportPage(): JSX.Element {
|
||||
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">
|
||||
@@ -64,7 +75,7 @@ export default function UserImportPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-amber-500" />
|
||||
<Info className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">注意事项</CardTitle>
|
||||
</div>
|
||||
<CardDescription>导入前请仔细阅读</CardDescription>
|
||||
@@ -90,42 +101,42 @@ export default function UserImportPage() {
|
||||
</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>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>列名</TableHead>
|
||||
<TableHead>是否必填</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">姓名</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">用户姓名</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">邮箱</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">登录账号,需符合邮箱格式且唯一</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">角色</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">手机</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">联系电话</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">班级邀请码</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">仅 student 角色有效,6 位邀请码</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user