feat(student): 完成 student 模块 v4 剩余修复
- P1-4.2: 新增班级详情页 courses/[classId],展示教师/学校/教室信息与课表 - P2-2.5: 今日课表卡片高亮当前/下一节课(useMemo 实时计算) - P2-3.9: 作业作答进度网格支持点击跳转题目(scrollIntoView) - P2-3.10: 作业复习视图显示正确答案(选择/判断/文本题) - P2-4.4: 课程列表支持按班级名/教师/学校搜索 - P2-5.2: 成绩页新增趋势折线图组件 GradeTrendCard - P2-9.2/9.3: 诊断报告新增历史记录卡片与弱点练习入口 - P2-10.2: 选课列表支持搜索与选课模式筛选 - P2-11.3: 修复教材阅读页全屏溢出 - P3-1.5: 面包屑保留首个角色段作为根上下文 - P3-7.3: 课表项支持点击跳转至班级详情页(ScheduleList href)
This commit is contained in:
25
src/modules/student/components/course-filters.tsx
Normal file
25
src/modules/student/components/course-filters.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function CourseFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
|
||||
const hasFilters = Boolean(search)
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
layout="between"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => setSearch(null)}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search by class name, teacher, school..."
|
||||
/>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool } from "lucide-react"
|
||||
import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool, Mail, School } from "lucide-react"
|
||||
|
||||
import type { StudentEnrolledClass } from "@/modules/classes/types"
|
||||
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
|
||||
@@ -22,7 +22,11 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1 text-lg">{c.name}</CardTitle>
|
||||
<CardTitle className="line-clamp-1 text-lg">
|
||||
<Link href={`/student/learning/courses/${encodeURIComponent(c.id)}`} className="hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
@@ -44,12 +48,26 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
|
||||
|
||||
<CardContent className="flex-1 space-y-4 py-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
{c.schoolName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<School className="h-4 w-4" />
|
||||
<span>{c.schoolName}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.teacherName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{c.teacherName}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.teacherEmail && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a href={`mailto:${c.teacherEmail}`} className="hover:underline hover:text-foreground">
|
||||
{c.teacherEmail}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{c.room && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Building2 className="h-4 w-4" />
|
||||
@@ -60,6 +78,12 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2 border-t bg-muted/10 p-4">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/student/learning/courses/${encodeURIComponent(c.id)}`}>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
@@ -118,21 +142,24 @@ export function StudentCoursesView({
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No courses yet"
|
||||
description="You are not enrolled in any classes. Join a class to get started."
|
||||
description="You are not enrolled in any classes. Join a class below to get started."
|
||||
className="py-12"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<PlusCircle className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Join a Class</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the invitation code provided by your teacher to enroll.
|
||||
</p>
|
||||
{/* 加入班级表单:无课程时置顶,有课程时置底 */}
|
||||
<div className={classes.length === 0 ? "" : "rounded-lg border bg-card p-6 shadow-sm"}>
|
||||
<div className={classes.length === 0 ? "rounded-lg border bg-card p-6 shadow-sm" : "mb-6 flex items-center gap-3"}>
|
||||
<div className={classes.length === 0 ? "mb-6 flex items-center gap-3" : ""}>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<PlusCircle className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Join a Class</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the invitation code provided by your teacher to enroll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { CalendarX } from "lucide-react"
|
||||
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { StudentScheduleItem } from "@/modules/classes/types"
|
||||
|
||||
@@ -15,6 +16,16 @@ const WEEKDAYS: Array<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
|
||||
{ key: 7, label: "Sun" },
|
||||
]
|
||||
|
||||
const getTodayWeekday = (): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
// getDay() returns 0 (Sun) - 6 (Sat); convert to 1 (Mon) - 7 (Sun)
|
||||
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
||||
const day = new Date().getDay()
|
||||
if (day < 0 || day > 6) {
|
||||
throw new Error(`Invalid day from getDay(): ${day}`)
|
||||
}
|
||||
return WEEKDAY_MAP[day]
|
||||
}
|
||||
|
||||
export function StudentScheduleView({ items }: { items: StudentScheduleItem[] }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -27,6 +38,8 @@ export function StudentScheduleView({ items }: { items: StudentScheduleItem[] })
|
||||
)
|
||||
}
|
||||
|
||||
const todayKey = getTodayWeekday()
|
||||
|
||||
const itemsByDay = new Map<number, StudentScheduleItem[]>()
|
||||
for (const item of items) {
|
||||
const list = itemsByDay.get(item.weekday) ?? []
|
||||
@@ -41,17 +54,33 @@ export function StudentScheduleView({ items }: { items: StudentScheduleItem[] })
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{WEEKDAYS.map((d) => {
|
||||
const dayItems = itemsByDay.get(d.key) ?? []
|
||||
const isToday = d.key === todayKey
|
||||
return (
|
||||
<Card key={d.key}>
|
||||
<Card
|
||||
key={d.key}
|
||||
className={cn(
|
||||
isToday && "border-primary ring-1 ring-primary/30 shadow-sm"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">{d.label}</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{d.label}</span>
|
||||
{isToday && (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
|
||||
Today
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dayItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No classes.</div>
|
||||
) : (
|
||||
<ScheduleList
|
||||
items={dayItems}
|
||||
items={dayItems.map((item) => ({
|
||||
...item,
|
||||
href: `/student/learning/courses/${encodeURIComponent(item.classId)}`,
|
||||
}))}
|
||||
variant="card"
|
||||
spacingClassName="space-y-3"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user