sync-docs-and-fixes

This commit is contained in:
SpecialX
2026-03-03 17:32:26 +08:00
parent 538805bad0
commit eb08c0ab68
73 changed files with 2218 additions and 422 deletions

View File

@@ -25,7 +25,7 @@ export default function RegisterPage() {
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
const [{ db }, { users }] = await Promise.all([
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
@@ -45,13 +45,25 @@ export default function RegisterPage() {
if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
const userId = createId()
await db.insert(users).values({
id: createId(),
id: userId,
name: name.length ? name : null,
email,
password: hashedPassword,
role: "student",
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: "student" })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
if (resolvedRole?.id) {
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
}
return { success: true, message: "账户创建成功" }
} catch (error) {

View File

@@ -1,19 +1,18 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = normalizeRole(session.user.role)
const userId = String(session.user.id ?? "").trim()
if (!userId) redirect("/login")
const profile = await getUserProfile(userId)
if (!profile) redirect("/login")
const role = profile.role || "student"
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")

View File

@@ -2,7 +2,7 @@ import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
const role = userProfile.role || "student"
const isStudent = role === "student"
const isTeacher = role === "teacher"
const studentData =
isStudent
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
})()
: null
const teacherData =
isTeacher
? await (async () => {
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
return { subjects, classes }
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
</div>
</div>
) : null}
{teacherData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Teaching Subjects</CardTitle>
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
</CardHeader>
<CardContent>
{teacherData.subjects.length === 0 ? (
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
) : (
<div className="flex flex-wrap gap-2">
{teacherData.subjects.map((subject) => (
<Badge key={subject} variant="secondary">
{subject}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Teaching Classes</CardTitle>
<CardDescription>Classes you are currently managing.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teacherData.classes.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
) : (
teacherData.classes.map((cls) => (
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{cls.name}</div>
<div className="text-xs text-muted-foreground">
{cls.grade}
{cls.homeroom ? `${cls.homeroom}` : ""}
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react"

View File

@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
if (!student) {
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">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
</div> */}
<TextbookFilters />

View File

@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassHomeworkInsights({ classId: id, limit: 20 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
if (!insights) return notFound()
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
@@ -91,10 +86,7 @@ export default async function ClassDetailPage({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassTrendsWidget assignments={assignmentSummaries} />
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}

View File

@@ -1,9 +1,5 @@
import { eq } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -12,11 +8,11 @@ export default function MyClassesPage() {
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
return (
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
</div>
)
}

View File

@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined

View File

@@ -1,37 +1,9 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() {
return (
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground mt-2">
Set up a new exam draft and choose your assembly method.
</p>
</div>
</div>
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm />
</div>
)

View File

@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"

View File

@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const creatorId = await getTeacherIdForMutations()
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0

View File

@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
const q = getParam(params, "q")
const type = getParam(params, "type")
const difficulty = getParam(params, "difficulty")
const knowledgePointId = getParam(params, "kp")
const questionType: QuestionType | undefined =
type === "single_choice" ||
@@ -37,10 +38,16 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
q: q || undefined,
type: questionType,
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
pageSize: 200,
})
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
const hasFilters = Boolean(
q ||
(type && type !== "all") ||
(difficulty && difficulty !== "all") ||
(knowledgePointId && knowledgePointId !== "all")
)
if (questions.length === 0) {
return (

View File

@@ -3,7 +3,7 @@ import { eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
@@ -34,13 +34,14 @@ export async function POST(req: Request) {
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
const current = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
})
const currentRole = String(current?.role ?? "student")
const currentRoleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
if (role === "admin" && currentRole !== "admin") {
if (role === "admin" && !currentMapped.includes("admin")) {
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
}
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
.map((s) => String(s).trim())
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, role),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: role })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
const roleId = resolvedRole?.id
await db
.update(users)
.set({
role,
name,
phone: phone.length ? phone : null,
address: address.length ? address : null,
})
.where(eq(users.id, userId))
if (roleId) {
await db
.insert(usersToRoles)
.values({ userId, roleId })
.onDuplicateKeyUpdate({ set: { roleId } })
}
if (role === "student" && codes.length) {
for (const code of codes) {
await enrollStudentByInvitationCode(userId, code)
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
}
}
// Resolve subject ids when possible (by name exact match)
const subjectsFound = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, teacherSubjects))
const subjectIdByName = new Map<string, string>()
for (const s of subjectsFound) {
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
}
for (const code of codes) {
const classId = byCode.get(code)
if (!classId) continue
for (const subject of teacherSubjects) {
const subjectId = subjectIdByName.get(subject)
if (!subjectId) continue
await db
.insert(classSubjectTeachers)
.values({ classId, subject, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
.values({ classId, subjectId, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
}
}
}
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
return NextResponse.json({ success: true })
}

View File

@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { roles, users, usersToRoles } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -14,12 +14,32 @@ export async function GET() {
return NextResponse.json({ required: false })
}
const row = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true, role: true },
})
const [row, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
const resolvedRole = mappedRoles.find((r) => r === "admin")
?? mappedRoles.find((r) => r === "teacher")
?? mappedRoles.find((r) => r === "parent")
?? mappedRoles.find((r) => r === "student")
?? "student"
const required = !row?.onboardedAt
return NextResponse.json({ required, role: row?.role ?? "student" })
return NextResponse.json({ required, role: resolvedRole })
}