Files
NextEdu/src/modules/textbooks/components/graph-kp-node.tsx
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
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 相关零错误
2026-06-23 09:02:41 +08:00

94 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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)