fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理
P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)
P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签
P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式
同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
361
src/modules/homework/components/question-renderer.tsx
Normal file
361
src/modules/homework/components/question-renderer.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
extractAnswerValue,
|
||||
getOptions,
|
||||
getQuestionText,
|
||||
isRecord,
|
||||
type QuestionOption,
|
||||
type QuestionType,
|
||||
} from "../lib/question-content-utils"
|
||||
|
||||
/**
|
||||
* 题目渲染模式
|
||||
* - `take`: 学生作答交互
|
||||
* - `review`: 学生查看批改结果(只读 + 正确答案高亮)
|
||||
* - `grade`: 教师批改(只读学生答案 + 正确答案 + 评分面板 slot)
|
||||
*/
|
||||
export type QuestionRenderMode = "take" | "review" | "grade"
|
||||
|
||||
export interface QuestionRendererProps {
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
index: number
|
||||
mode: QuestionRenderMode
|
||||
/** 学生答案(take 模式下为当前值,review/grade 模式下为已提交值) */
|
||||
value?: unknown
|
||||
/** take 模式下的禁用状态 */
|
||||
disabled?: boolean
|
||||
/** take 模式下答案变更回调 */
|
||||
onChange?: (answer: unknown) => void
|
||||
/** review/grade 模式下是否显示正确答案 */
|
||||
showCorrectAnswer?: boolean
|
||||
/** review/grade 模式下是否显示批改反馈 */
|
||||
feedback?: string | null
|
||||
/** 题目头部右侧额外内容(如分数 Badge) */
|
||||
headerExtra?: ReactNode
|
||||
/** 题目底部额外内容(如批改面板) */
|
||||
footerExtra?: ReactNode
|
||||
/** 题目卡片额外 className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目渲染器(只读展示 + 答案输入组合)
|
||||
*
|
||||
* 通过 `mode` 切换交互行为:
|
||||
* - `take`: 渲染可编辑的作答输入控件
|
||||
* - `review`: 渲染只读答案 + 正确答案高亮
|
||||
* - `grade`: 渲染只读学生答案 + 正确答案(由父组件通过 `footerExtra` 注入批改面板)
|
||||
*/
|
||||
export function QuestionRenderer({
|
||||
questionId,
|
||||
questionType,
|
||||
questionContent,
|
||||
maxScore,
|
||||
index,
|
||||
mode,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
showCorrectAnswer,
|
||||
feedback,
|
||||
headerExtra,
|
||||
footerExtra,
|
||||
className,
|
||||
}: QuestionRendererProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
const text = getQuestionText(questionContent)
|
||||
const options = getOptions(questionContent)
|
||||
const isReadOnly = mode !== "take"
|
||||
const showFeedback = isReadOnly && Boolean(feedback)
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`question-${questionId}`}
|
||||
className={className}
|
||||
aria-labelledby={`question-${questionId}-title`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3
|
||||
id={`question-${questionId}-title`}
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{t("homework.take.question", { index: index + 1 })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} • {maxScore} {t("homework.take.points")}
|
||||
</p>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</header>
|
||||
|
||||
<div className="mt-4 text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<QuestionAnswerInput
|
||||
questionId={questionId}
|
||||
questionType={questionType}
|
||||
options={options}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
readOnly={isReadOnly}
|
||||
onChange={onChange}
|
||||
showCorrectAnswer={showCorrectAnswer === true}
|
||||
questionContent={questionContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFeedback && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{feedback}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerExtra}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
interface QuestionAnswerInputProps {
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
options: QuestionOption[]
|
||||
value: unknown
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
onChange?: (answer: unknown) => void
|
||||
showCorrectAnswer?: boolean
|
||||
questionContent: unknown
|
||||
}
|
||||
|
||||
function QuestionAnswerInput({
|
||||
questionId,
|
||||
questionType,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
readOnly,
|
||||
onChange,
|
||||
showCorrectAnswer,
|
||||
questionContent,
|
||||
}: QuestionAnswerInputProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
if (questionType === "text") {
|
||||
if (readOnly) {
|
||||
const textValue = typeof value === "string" ? value : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||
{textValue || (
|
||||
<span className="text-muted-foreground italic">{t("homework.review.noAnswer")}</span>
|
||||
)}
|
||||
</div>
|
||||
{showCorrectAnswer && (
|
||||
<CorrectAnswerDisplay questionType="text" questionContent={questionContent} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("homework.take.answerPlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="min-h-[120px] resize-y"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const boolValue = typeof value === "boolean" ? (value ? "true" : "false") : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={boolValue}
|
||||
onValueChange={(v) => onChange?.(v === "true")}
|
||||
disabled={disabled || readOnly}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="true" id={`${questionId}-true`} />
|
||||
<Label htmlFor={`${questionId}-true`} className="flex-1 cursor-pointer font-normal">
|
||||
{t("homework.take.true")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="false" id={`${questionId}-false`} />
|
||||
<Label htmlFor={`${questionId}-false`} className="flex-1 cursor-pointer font-normal">
|
||||
{t("homework.take.false")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{showCorrectAnswer && readOnly && (
|
||||
<CorrectAnswerDisplay questionType="judgment" questionContent={questionContent} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
const strValue = typeof value === "string" ? value : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={strValue}
|
||||
onValueChange={(v) => onChange?.(v)}
|
||||
disabled={disabled || readOnly}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{options.map((o) => {
|
||||
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-center space-x-2 rounded-md border p-3 ${
|
||||
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
|
||||
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
|
||||
>
|
||||
<RadioGroupItem value={o.id} id={`${questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">
|
||||
{t("homework.review.correctMarker")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "multiple_choice") {
|
||||
const selectedIds = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = selectedIds.includes(o.id)
|
||||
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-start space-x-2 rounded-md border p-3 ${
|
||||
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
|
||||
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (readOnly) return
|
||||
const isChecked = checked === true
|
||||
const next = isChecked
|
||||
? Array.from(new Set([...selectedIds, o.id]))
|
||||
: selectedIds.filter((x) => x !== o.id)
|
||||
onChange?.(next)
|
||||
}}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">
|
||||
{t("homework.review.correctMarker")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
{t("homework.take.unsupportedType")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正确答案展示(review/grade 模式)
|
||||
*/
|
||||
function CorrectAnswerDisplay({
|
||||
questionType,
|
||||
questionContent,
|
||||
}: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
}) {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
if (questionType === "text") {
|
||||
const correctTexts = (() => {
|
||||
if (!isRecord(questionContent)) return []
|
||||
const raw = questionContent.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
})()
|
||||
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">{t("homework.review.correctAnswer")}</div>
|
||||
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correct = (() => {
|
||||
if (!isRecord(questionContent)) return null
|
||||
return typeof questionContent.correctAnswer === "boolean"
|
||||
? questionContent.correctAnswer
|
||||
: null
|
||||
})()
|
||||
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">{t("homework.review.correctAnswer")}: </span>
|
||||
<span className="text-emerald-900">{correct ? t("homework.take.true") : t("homework.take.false")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取学生答案值(便于父组件格式化展示)
|
||||
*/
|
||||
export { extractAnswerValue }
|
||||
Reference in New Issue
Block a user