feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
dashboard: - Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter - Add parent-dashboard components directory - Add config, hooks, and services directories diagnostic: - Add role-config and services directory elective: - Add elective-course-detail, elective-stats-cards, parent-selection-view components - Add data-access-settings and data-access-stats
This commit is contained in:
@@ -30,7 +30,8 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { useDiagnosticService } from "../services/diagnostic-service-context"
|
||||
import type { ClassMasterySummary } from "../types"
|
||||
|
||||
interface ClassDiagnosticViewProps {
|
||||
@@ -60,6 +61,8 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
// v2-P1-4: 通过 Context 注入服务,不直接 import actions
|
||||
const service = useDiagnosticService()
|
||||
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
@@ -71,10 +74,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const handleGenerate = async () => {
|
||||
if (!summary) return
|
||||
setIsGenerating(true)
|
||||
const formData = new FormData()
|
||||
formData.set("classId", summary.classId)
|
||||
formData.set("period", period)
|
||||
const result = await generateClassReportAction(null, formData)
|
||||
const result = await service.generateClassReport(summary.classId, period)
|
||||
setIsGenerating(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -86,7 +86,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
|
||||
/**
|
||||
* v3-P2-5: 按知识点筛选学生。
|
||||
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。
|
||||
* 选择知识点后调用服务获取该知识点上所有学生的掌握度。
|
||||
*/
|
||||
const handleKpFilter = async (kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
@@ -96,10 +96,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
}
|
||||
setIsFiltering(true)
|
||||
try {
|
||||
const result = await getClassStudentsByKnowledgePointAction({
|
||||
classId: summary.classId,
|
||||
knowledgePointId: kpId,
|
||||
})
|
||||
const result = await service.getClassStudentsByKp(
|
||||
summary.classId,
|
||||
kpId,
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
setFilteredStudents(result.data)
|
||||
} else {
|
||||
@@ -127,43 +127,46 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 概览 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.className}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* v2-P1-6: 概览区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("summary.class")} skeletonHeight={140}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.className}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 知识点掌握度热力图 */}
|
||||
{/* v2-P1-6: 知识点掌握度热力图区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.heatmapTitle")} skeletonHeight={300}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -181,7 +184,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="img"
|
||||
role="group"
|
||||
aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
|
||||
>
|
||||
{summary.knowledgePointStats.map((kp) => {
|
||||
@@ -189,9 +192,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
return (
|
||||
<div
|
||||
key={kp.knowledgePointId}
|
||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
||||
tabIndex={0}
|
||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white outline-none transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${masteryColor(kp.averageMastery)}`}
|
||||
role="img"
|
||||
aria-label={`${kp.knowledgePointName}:${kp.averageMastery.toFixed(1)}%,${levelLabel},${kp.masteredCount}/${kp.totalStudents}`}
|
||||
aria-label={t("classDiagnostic.heatmapCellAriaLabel", {
|
||||
name: kp.knowledgePointName,
|
||||
level: kp.averageMastery.toFixed(1),
|
||||
label: levelLabel,
|
||||
mastered: kp.masteredCount,
|
||||
total: kp.totalStudents,
|
||||
})}
|
||||
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`}
|
||||
>
|
||||
<span className="max-w-32 truncate text-xs font-medium">
|
||||
@@ -230,8 +240,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* v3-P2-5: 按知识点筛选学生 */}
|
||||
{/* v2-P1-6: 按知识点筛选学生区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("classDiagnostic.filterByKpTitle")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -316,8 +328,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 知识点排名表 */}
|
||||
{/* v2-P1-6: 知识点排名表区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.rankingTitle")} skeletonHeight={240}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("chart.rankingTitle")}</CardTitle>
|
||||
@@ -360,8 +374,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 需重点关注的学生 */}
|
||||
{/* v2-P1-6: 需重点关注的学生区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("classDiagnostic.studentsNeedingAttentionTitle")} skeletonHeight={240}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -411,9 +427,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 生成班级报告 */}
|
||||
{/* v2-P1-6: 生成班级报告区块独立 Error Boundary */}
|
||||
{canManage ? (
|
||||
<WidgetBoundary title={t("report.generateClass")} skeletonHeight={160}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -442,6 +460,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* v4-P3-7: 诊断报告数据置信度工具。
|
||||
* v2-P1-5: 改进为基于知识点数量的多级置信度计算。
|
||||
*
|
||||
* 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
|
||||
* 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。
|
||||
@@ -9,14 +10,39 @@ import type { DiagnosticReportWithDetails } from "../types"
|
||||
|
||||
export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient"
|
||||
|
||||
/** 置信度阈值(基于知识点数量 = strengths.length + weaknesses.length) */
|
||||
const CONFIDENCE_INSUFFICIENT_MAX = 0
|
||||
const CONFIDENCE_LOW_MAX = 3
|
||||
const CONFIDENCE_MEDIUM_MAX = 8
|
||||
|
||||
/**
|
||||
* 根据报告数据计算置信度。
|
||||
* 简化方案:overallScore === null 表示无数据(insufficient),
|
||||
* 否则视为高置信度(high)。
|
||||
* 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。
|
||||
* v2-P1-5: 根据报告数据计算置信度。
|
||||
*
|
||||
* 置信度基于报告中涉及的知识点数量(strengths + weaknesses 数组长度之和):
|
||||
* - 0 个知识点 → insufficient(数据不足)
|
||||
* - 1-3 个 → low(数据较少,结论仅供参考)
|
||||
* - 4-8 个 → medium(数据量一般,建议结合其他信息参考)
|
||||
* - >8 个 → high(数据充足,报告结论可靠)
|
||||
*
|
||||
* 若 overallScore 为 null 也视为 insufficient。
|
||||
*
|
||||
* @param report 诊断报告(含详情)
|
||||
* @param totalKnowledgePoints 可选:显式传入知识点总数(优先于数组长度推断)
|
||||
*/
|
||||
export function getConfidenceLevel(report: DiagnosticReportWithDetails): ConfidenceLevel {
|
||||
export function getConfidenceLevel(
|
||||
report: DiagnosticReportWithDetails,
|
||||
totalKnowledgePoints?: number,
|
||||
): ConfidenceLevel {
|
||||
if (report.overallScore === null) return "insufficient"
|
||||
|
||||
// 优先使用显式传入的知识点数;否则从 strengths + weaknesses 数组推断
|
||||
const kpCount =
|
||||
totalKnowledgePoints ??
|
||||
(report.strengths?.length ?? 0) + (report.weaknesses?.length ?? 0)
|
||||
|
||||
if (kpCount <= CONFIDENCE_INSUFFICIENT_MAX) return "insufficient"
|
||||
if (kpCount <= CONFIDENCE_LOW_MAX) return "low"
|
||||
if (kpCount <= CONFIDENCE_MEDIUM_MAX) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,17 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
// v2-P2-5: 保留完整 knowledgePoint 作为 angleKey,使 Tooltip 显示完整名称;
|
||||
// 通过 angleTickFormatter 截断轴上显示文本,避免长名称溢出图表区域。
|
||||
const MAX_AXIS_LABEL_LENGTH = 8
|
||||
const truncateAxisLabel = (value: string): string =>
|
||||
value.length > MAX_AXIS_LABEL_LENGTH
|
||||
? `${value.slice(0, MAX_AXIS_LABEL_LENGTH)}...`
|
||||
: value
|
||||
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.map((d) => ({
|
||||
...d,
|
||||
shortName:
|
||||
d.knowledgePoint.length > 8
|
||||
? `${d.knowledgePoint.slice(0, 8)}...`
|
||||
: d.knowledgePoint,
|
||||
}))
|
||||
: data.map((d) => ({ ...d }))
|
||||
|
||||
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
||||
|
||||
@@ -52,7 +54,8 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
angleKey="knowledgePoint"
|
||||
angleTickFormatter={truncateAxisLabel}
|
||||
angleTickFontSize={11}
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react"
|
||||
import { FileText, Trash2, Send, Download } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -34,12 +34,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
|
||||
import { useDiagnosticService } from "../services/diagnostic-service-context"
|
||||
import type { DiagnosticReportWithDetails } from "../types"
|
||||
import {
|
||||
getConfidenceLevel,
|
||||
@@ -63,10 +62,11 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const { hasPermission } = usePermission()
|
||||
const t = useTranslations("diagnostic")
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
// v2-P1-4: 通过 Context 注入服务,不直接 import actions
|
||||
const service = useDiagnosticService()
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [publishId, setPublishId] = useState<string | null>(null)
|
||||
const [shareId, setShareId] = useState<string | null>(null)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
const updateParam = useCallback(
|
||||
@@ -85,9 +85,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handlePublish = async () => {
|
||||
if (!publishId) return
|
||||
setIsBusy(true)
|
||||
const formData = new FormData()
|
||||
formData.set("id", publishId)
|
||||
const result = await publishReportAction(null, formData)
|
||||
const result = await service.publishReport(publishId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -101,9 +99,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
setIsBusy(true)
|
||||
const formData = new FormData()
|
||||
formData.set("id", deleteId)
|
||||
const result = await deleteReportAction(null, formData)
|
||||
const result = await service.deleteReport(deleteId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -121,7 +117,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handleExport = async (reportId: string) => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const result = await exportDiagnosticReportAction(reportId)
|
||||
const result = await service.exportReport(reportId)
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || t("error.exportFailed"))
|
||||
return
|
||||
@@ -151,18 +147,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// v3-P3-8: 复制报告分享链接到剪贴板
|
||||
const handleCopyLink = async (): Promise<void> => {
|
||||
if (!shareId) return
|
||||
const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success(t("reportList.copyLinkSuccess"))
|
||||
} catch {
|
||||
toast.error(t("reportList.copyLinkFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
// v4-P3-7: 置信度标签与提示
|
||||
const confidenceLabel = (level: ConfidenceLevel): string => {
|
||||
if (level === "high") return t("reportList.confidenceHigh")
|
||||
@@ -202,12 +186,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
// v3-P3-8: 当前分享的报告及链接
|
||||
const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null
|
||||
const shareUrl = typeof window !== "undefined" && sharedReport
|
||||
? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 过滤器 */}
|
||||
@@ -314,20 +292,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* v3-P3-8: 分享按钮(仅教师可见) */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShareId(r.id)}
|
||||
disabled={isBusy}
|
||||
title={t("reportList.share")}
|
||||
aria-label={t("reportList.shareAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && r.status === "draft" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -402,44 +366,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v3-P3-8: 分享报告 */}
|
||||
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{sharedReport?.summary ? (
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
<p className="text-sm">{sharedReport.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="share-link"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
aria-label={t("reportList.shareLinkAriaLabel")}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button onClick={handleCopyLink} className="shrink-0">
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{t("reportList.copyLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShareId(null)}>
|
||||
{t("report.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { MasteryRadarChart } from "./mastery-radar-chart"
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
confidenceBadgeVariant,
|
||||
type ConfidenceLevel,
|
||||
} from "./confidence-utils"
|
||||
import { getDiagnosticRoleConfig, type DiagnosticRole } from "../role-config"
|
||||
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
||||
|
||||
interface StudentDiagnosticViewProps {
|
||||
@@ -23,11 +25,13 @@ interface StudentDiagnosticViewProps {
|
||||
reports: DiagnosticReportWithDetails[]
|
||||
classAverageMastery?: MasteryRadarPoint[]
|
||||
/**
|
||||
* v3-P2-6: "练习"按钮的跳转基础路径。
|
||||
* - 学生视角:默认 `/student/learning/assignments`
|
||||
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选)
|
||||
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口)
|
||||
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
|
||||
* v4-P2-2: 角色配置驱动。
|
||||
* 组件内部根据 role 查找 DIAGNOSTIC_ROLE_CONFIG 获取 practiceHrefBase 等角色差异配置。
|
||||
* 新增角色只需在 role-config.ts 中添加配置项,无需修改组件 props。
|
||||
*/
|
||||
role?: DiagnosticRole
|
||||
/**
|
||||
* @deprecated v4-P2-2: 请改用 `role` prop。保留向后兼容,若同时传入则 role 优先。
|
||||
*/
|
||||
practiceHrefBase?: string | null
|
||||
}
|
||||
@@ -36,9 +40,12 @@ export function StudentDiagnosticView({
|
||||
summary,
|
||||
reports,
|
||||
classAverageMastery,
|
||||
practiceHrefBase = "/student/learning/assignments",
|
||||
role = "student",
|
||||
practiceHrefBase,
|
||||
}: StudentDiagnosticViewProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
// v4-P2-2: 角色配置驱动,role prop 优先于 deprecated practiceHrefBase
|
||||
const resolvedPracticeHrefBase = practiceHrefBase ?? getDiagnosticRoleConfig(role).practiceHrefBase
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
@@ -132,11 +139,14 @@ export function StudentDiagnosticView({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 雷达图 */}
|
||||
<MasteryRadarChart data={radarData} />
|
||||
{/* v2-P1-6: 雷达图区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.radarTitle")} skeletonHeight={384}>
|
||||
<MasteryRadarChart data={radarData} />
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 强项 / 弱项 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("strengths.title")} skeletonHeight={300}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -179,9 +189,9 @@ export function StudentDiagnosticView({
|
||||
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
||||
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
{practiceHrefBase ? (
|
||||
{resolvedPracticeHrefBase ? (
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
|
||||
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
|
||||
<Link href={`${resolvedPracticeHrefBase}?kp=${m.knowledgePointId}`}>
|
||||
{t("weaknesses.practice")}
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
@@ -194,9 +204,11 @@ export function StudentDiagnosticView({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 最新报告 / 建议 */}
|
||||
{/* v2-P1-6: 最新报告区块独立 Error Boundary */}
|
||||
{latestReport ? (
|
||||
<WidgetBoundary title={t("studentDiagnostic.diagnosticReportTitle")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -245,10 +257,12 @@ export function StudentDiagnosticView({
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
|
||||
{/* 历史报告列表 */}
|
||||
{/* v2-P1-6: 历史报告区块独立 Error Boundary */}
|
||||
{publishedReports.length > 1 ? (
|
||||
<WidgetBoundary title={t("report.history")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -287,6 +301,7 @@ export function StudentDiagnosticView({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user