Files
NextEdu/src/modules/textbooks/components/knowledge-point-list.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

124 lines
4.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 { PlusCircle, Pencil, Trash2, Tag } from "lucide-react"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
interface KnowledgePointListProps {
knowledgePoints: KnowledgePoint[]
canEdit: boolean
/** 是否有创建题目权限(控制"创建相关题目"按钮可见性) */
canCreateQuestion?: boolean
highlightedKpId: string | null
onHighlight: (id: string) => void
onEdit: (kp: KnowledgePoint) => void
onDelete: (kpId: string, e: React.MouseEvent) => void
onCreateQuestion: (kp: KnowledgePoint) => void
}
export function KnowledgePointList({
knowledgePoints,
canEdit,
canCreateQuestion = false,
highlightedKpId,
onHighlight,
onEdit,
onDelete,
onCreateQuestion,
}: KnowledgePointListProps) {
const t = useTranslations("textbooks")
if (knowledgePoints.length === 0) {
return (
<EmptyState
icon={Tag}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
return (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{knowledgePoints.map((kp) => (
<button
key={kp.id}
type="button"
className={cn(
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => onHighlight(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
{/* 任意值 text-[10px]紧凑徽章text-xs(12px) 在列表项中过大 */}
<Badge variant="outline" className="text-[10px] h-5 px-1">
{t("panel.level")}
{kp.level}
</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
{canCreateQuestion && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onCreateQuestion(kp)
}}
title={t("dialog.knowledge.createQuestion")}
aria-label={t("dialog.knowledge.createQuestion")}
>
<PlusCircle className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onEdit(kp)
}}
title={t("dialog.knowledge.editKp")}
aria-label={t("dialog.knowledge.editKp")}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => onDelete(kp.id, e)}
title={t("dialog.knowledge.deleteKp")}
aria-label={t("dialog.knowledge.deleteKp")}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</button>
))}
</div>
</ScrollArea>
)
}