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 相关零错误
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
"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>
|
||
)
|
||
}
|