settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
105 lines
3.6 KiB
TypeScript
105 lines
3.6 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, KpWithRelations } from "../types"
|
||
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",
|
||
}
|
||
|
||
/**
|
||
* 类型守卫:从 React Flow node.data(Record<string, unknown>)安全提取图谱节点数据。
|
||
* 替代 as unknown as 双重断言,提供运行时安全。
|
||
*/
|
||
function extractNodeData(data: Record<string, unknown>): {
|
||
kp: KpWithRelations
|
||
graphData?: GraphNodeData
|
||
} {
|
||
const kp = data.kp as KpWithRelations
|
||
const graphData = data.graphData as GraphNodeData | undefined
|
||
return { kp, graphData }
|
||
}
|
||
|
||
function GraphKpNodeComponent(props: NodeProps) {
|
||
const t = useTranslations("textbooks")
|
||
const { data, selected } = props
|
||
const { kp, graphData } = extractNodeData(data)
|
||
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)
|