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:
SpecialX
2026-06-22 14:08:34 +08:00
parent c90748124d
commit 30f4983d49
18 changed files with 912 additions and 137 deletions

View File

@@ -15,7 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
type Option = { id: string; text: string; isCorrect?: boolean }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
@@ -32,11 +32,29 @@ const getOptions = (content: unknown): Option[] => {
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
const isCorrect = item.isCorrect === true
out.push({ id, text, isCorrect })
}
return out
}
const getChoiceCorrectIds = (content: unknown): string[] => {
return getOptions(content).filter((o) => o.isCorrect).map((o) => o.id)
}
const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
if (!isRecord(content)) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const getTextCorrectAnswers = (content: unknown): string[] => {
if (!isRecord(content)) return []
const raw = content.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
}
const toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
@@ -144,6 +162,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
</div>
{isGraded && (() => {
const correctTexts = getTextCorrectAnswers(q.questionContent)
if (correctTexts.length === 0) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<div className="font-medium text-emerald-700 mb-1">Correct Answer</div>
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
</div>
)
})()}
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
@@ -161,6 +189,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
</div>
</RadioGroup>
{isGraded && (() => {
const correct = getJudgmentCorrectAnswer(q.questionContent)
if (correct === null) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<span className="font-medium text-emerald-700">Correct Answer: </span>
<span className="text-emerald-900">{correct ? "True" : "False"}</span>
</div>
)
})()}
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
@@ -169,14 +207,26 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
disabled
className="flex flex-col gap-2"
>
{options.map((o) => (
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
{o.text}
</Label>
</div>
))}
{options.map((o) => {
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return (
<div
key={o.id}
className={`flex items-center space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
@@ -184,8 +234,15 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return (
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20">
<div
key={o.id}
className={`flex items-start space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
@@ -193,6 +250,9 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
/>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label>
</div>
)