P2 修复(来自审计报告): - 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action) - 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面) - 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页) - 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid) - 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页) - 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重) - 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入) P2 建议项: - 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict) - 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit) - 考勤/选课数据导出 Excel(export.ts + API 路由扩展) 新增文件: - src/modules/attendance/components/attendance-page-layout.tsx - src/modules/elective/components/elective-page-layout.tsx - src/modules/elective/resolvers.ts - src/modules/attendance/export.ts - src/modules/elective/export.ts 校验: - npm run lint 通过(exit 0) - npx tsc --noEmit attendance/elective/parent 相关零错误
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
"use client"
|
||
|
||
import { memo } from "react"
|
||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||
import { useTranslations } from "next-intl"
|
||
import { cn } from "@/shared/lib/utils"
|
||
import type { GraphNodeData, MasteryLevel } from "../types"
|
||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||
import { NODE_WIDTH } from "../graph-layout"
|
||
|
||
/** 根据掌握度计算色彩等级 */
|
||
function getMasteryLevel(mastery: number | null): MasteryLevel {
|
||
if (mastery === null) return "unassessed"
|
||
if (mastery < 60) return "low"
|
||
if (mastery < 85) return "medium"
|
||
return "high"
|
||
}
|
||
|
||
const MASTERY_COLORS: Record<MasteryLevel, string> = {
|
||
low: "border-red-500 bg-red-50 dark:bg-red-950/30",
|
||
medium: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30",
|
||
high: "border-green-500 bg-green-50 dark:bg-green-950/30",
|
||
unassessed: "border-border bg-card",
|
||
}
|
||
|
||
const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
|
||
low: "bg-red-500",
|
||
medium: "bg-yellow-500",
|
||
high: "bg-green-500",
|
||
unassessed: "bg-muted",
|
||
}
|
||
|
||
function GraphKpNodeComponent({ data, selected }: NodeProps) {
|
||
const t = useTranslations("textbooks")
|
||
const nodeData = data as unknown as GraphLayoutNodeData
|
||
const { kp } = nodeData
|
||
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
|
||
const mastery = graphData?.mastery ?? null
|
||
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
||
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-lg border-2 px-3 py-2 shadow-sm transition-all",
|
||
MASTERY_COLORS[masteryLevel],
|
||
selected && "ring-2 ring-primary ring-offset-1",
|
||
graphData?.isHighlighted && "ring-2 ring-primary",
|
||
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
|
||
)}
|
||
style={{ width: NODE_WIDTH }}
|
||
>
|
||
<Handle type="target" position={Position.Top} className="opacity-0" />
|
||
|
||
<div className="flex items-start justify-between gap-2 mb-1">
|
||
<span className="text-xs font-medium line-clamp-2 flex-1">{kp.name}</span>
|
||
{/* 任意值 text-[10px]:图谱节点空间受限,text-xs(12px) 过大 */}
|
||
{kp.questionCount > 0 && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0">
|
||
{kp.questionCount} {t("graph.node.questions")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{showMastery && (
|
||
<div className="mt-2">
|
||
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1">
|
||
<span>{t("graph.node.mastery")}</span>
|
||
<span>
|
||
{mastery ? `${Math.round(mastery.masteryLevel)}%` : t("graph.detail.masteryNotAssessed")}
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
className={cn("h-full transition-all", MASTERY_BAR_COLORS[masteryLevel])}
|
||
style={{ width: `${mastery?.masteryLevel ?? 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{kp.chapterTitle && (
|
||
<div className="mt-1 text-[10px] text-muted-foreground truncate">
|
||
{kp.chapterTitle}
|
||
</div>
|
||
)}
|
||
|
||
<Handle type="source" position={Position.Bottom} className="opacity-0" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const GraphKpNode = memo(GraphKpNodeComponent)
|