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 相关零错误
This commit is contained in:
SpecialX
2026-06-23 09:02:41 +08:00
parent c766951374
commit e2e0487a3b
50 changed files with 1514 additions and 411 deletions

View File

@@ -40,7 +40,7 @@ interface SortableChapterItemProps {
canEdit?: boolean
}
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: SortableChapterItemProps) {
const t = useTranslations("textbooks")
const [isOpen, setIsOpen] = useState(level === 0)
const hasChildren = chapter.children && chapter.children.length > 0
@@ -158,7 +158,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
)
}
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: {
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: {
items: Chapter[],
level: number,
selectedId?: string,
@@ -195,7 +195,7 @@ interface ChapterSidebarListProps {
canEdit?: boolean
}
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = false }: ChapterSidebarListProps) {
const t = useTranslations("textbooks")
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
@@ -239,11 +239,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
if (result.success) {
toast.success(t("dialog.chapter.orderUpdated"))
} else {
toast.error(result.message || t("dialog.chapter.orderUpdated"))
toast.error(result.message || t("dialog.chapter.orderUpdateFailed"))
}
} catch (e) {
console.error("Failed to reorder chapters", e)
toast.error(t("dialog.chapter.orderUpdated"))
toast.error(t("dialog.chapter.orderUpdateFailed"))
}
}
}

View File

@@ -81,6 +81,7 @@ export function CreateChapterDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.chapter.createTitle")}</DialogTitle>

View File

@@ -6,6 +6,7 @@ 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 {
@@ -47,12 +48,13 @@ function GraphKpNodeComponent({ data, selected }: NodeProps) {
graphData?.isHighlighted && "ring-2 ring-primary",
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
)}
style={{ width: 180 }}
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")}

View File

@@ -15,7 +15,6 @@ interface GraphNodeDetailPanelProps {
prerequisites: { id: string; name: string; description: string | null }[]
successors: { id: string; name: string; description: string | null }[]
canEdit: boolean
textbookId: string
onClose: () => void
onJumpToKp: (kpId: string) => void
onAddPrerequisite: () => void

View File

@@ -1,7 +1,7 @@
"use client"
import { useTranslations } from "next-intl"
import { Search, RotateCcw } from "lucide-react"
import { Search, RotateCcw, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
@@ -20,6 +20,8 @@ interface GraphToolbarProps {
searchText: string
onSearchChange: (text: string) => void
onResetView: () => void
/** 切换模式刷新中(显示轻量指示器,保留旧数据) */
isRefreshing?: boolean
}
const ALL_VIEW_MODES: readonly GraphViewMode[] = [
@@ -39,6 +41,7 @@ export function GraphToolbar({
searchText,
onSearchChange,
onResetView,
isRefreshing,
}: GraphToolbarProps) {
const t = useTranslations("textbooks")
@@ -67,6 +70,7 @@ export function GraphToolbar({
</SelectContent>
</Select>
{/* 任意值 min-w-[120px]:搜索框最小宽度,防止压缩过窄无法输入 */}
<div className="relative flex-1 min-w-[120px]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
@@ -81,6 +85,10 @@ export function GraphToolbar({
<RotateCcw className="h-3 w-3 mr-1" />
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
</Button>
{isRefreshing && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" aria-label={t("graph.toolbar.refreshing")} />
)}
</div>
)
}

View File

@@ -66,7 +66,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
const t = useTranslations("textbooks")
const { hasPermission } = usePermission()
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
const reactFlow = useReactFlow()
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
@@ -77,9 +76,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
const [newPrereqId, setNewPrereqId] = useState<string>("")
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
const { data, isLoading, error, reload } = useGraphData(textbookId, viewMode)
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
const availableViewModes: GraphViewMode[] = isTeacher
// 教师可查看班级掌握度,学生可查看个人掌握度
const availableViewModes: GraphViewMode[] = canEdit
? ["structure", "class-mastery"]
: ["structure", "student-mastery"]
@@ -286,6 +286,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
searchText={searchText}
onSearchChange={setSearchText}
onResetView={resetView}
isRefreshing={isRefreshing}
/>
<div className="flex-1 min-h-0 relative">
<ReactFlow
@@ -315,6 +316,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
</div>
{selectedKp && (
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
<div className="w-[300px] shrink-0">
<GraphNodeDetailPanel
kp={selectedKp}
@@ -322,7 +324,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
prerequisites={prerequisites}
successors={successors}
canEdit={canEdit}
textbookId={textbookId}
onClose={() => setSelectedKpId(null)}
onJumpToKp={onJumpToKp}
onAddPrerequisite={() => setAddPrereqOpen(true)}

View File

@@ -144,6 +144,7 @@ export function KnowledgePointDialogs({
className="text-sm font-mono"
required
/>
{/* 任意值 text-[10px]辅助提示文案text-xs(12px) 过大 */}
<p className="text-[10px] text-muted-foreground mt-1">{t("anchorTextHint")}</p>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client"
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
import { PlusCircle, Pencil, Trash2, Tag } from "lucide-react"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
@@ -8,6 +8,7 @@ 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[]
@@ -35,9 +36,12 @@ export function KnowledgePointList({
if (knowledgePoints.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.emptyKnowledge")}
</div>
<EmptyState
icon={Tag}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
@@ -57,6 +61,7 @@ export function KnowledgePointList({
<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}

View File

@@ -44,20 +44,25 @@ export class TextbookSectionErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
// 默认值为空字符串,强制调用方传入 i18n 文案
const title = this.props.fallbackTitle ?? ""
const description = this.props.fallbackDescription ?? ""
const retryLabel = this.props.retryLabel ?? ""
return (
// 任意值 min-h-[200px]:错误降级 UI 最小高度,保证视觉占位
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.fallbackTitle ?? "区块加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
<Button size="sm" variant="outline" onClick={this.handleReset}>
{this.props.retryLabel ?? "重试"}
</Button>
{title && (
<p className="text-sm font-medium text-foreground">{title}</p>
)}
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{retryLabel && (
<Button size="sm" variant="outline" onClick={this.handleReset}>
{retryLabel}
</Button>
)}
</div>
)
}

View File

@@ -1,50 +0,0 @@
"use client"
import type { ReactNode } from "react"
import { useTranslations } from "next-intl"
import { TextbookReader, type TextbookReaderProps } from "./textbook-reader"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
import type { KnowledgePoint } from "../types"
/**
* 教师端 TextbookReader 包装组件。
*
* 教师详情页是 Server Component不能直接向 Client ComponentTextbookReader
* 传递函数 proprenderQuestionCreator。此包装组件在客户端层组装
* renderQuestionCreator避免违反 Next.js App Router 的 Server→Client 序列化约束。
*/
export function TeacherTextbookReader({
chapters,
textbookId,
}: {
chapters: TextbookReaderProps["chapters"]
textbookId: string
}) {
const t = useTranslations("textbooks")
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
open,
onOpenChange,
targetKp,
}: {
open: boolean
onOpenChange: (open: boolean) => void
targetKp: KnowledgePoint | null
}): ReactNode => (
<CreateQuestionDialog
open={open}
onOpenChange={onOpenChange}
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
defaultType="text"
/>
)
return (
<TextbookReader
key={textbookId}
chapters={chapters}
textbookId={textbookId}
renderQuestionCreator={renderQuestionCreator}
/>
)
}

View File

@@ -103,6 +103,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</div>
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5" />
{/* 任意值 max-w-[120px]:出版社名称截断宽度,防止卡片布局错乱 */}
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
{textbook.publisher || t("card.publisherNA")}
</span>

View File

@@ -4,13 +4,14 @@ import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import rehypeSanitize from "rehype-sanitize"
import { Edit2, Save, Plus } from "lucide-react"
import { Edit2, Save, Plus, BookOpen } from "lucide-react"
import { useTranslations } from "next-intl"
import type { Chapter, KnowledgePoint } from "../types"
import type { Chapter } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
ContextMenu,
ContextMenuContent,
@@ -25,7 +26,6 @@ interface TextbookContentPanelProps {
editContent: string
setEditContent: (content: string) => void
canEdit: boolean
knowledgePoints: KnowledgePoint[]
highlightedKpId: string | null
onHighlight: (id: string) => void
onSwitchToKnowledgeTab: () => void
@@ -33,10 +33,7 @@ interface TextbookContentPanelProps {
onPointerDown: (e: React.PointerEvent) => void
onContextMenuChange: (open: boolean) => void
selectedText: string
createDialogOpen: boolean
setCreateDialogOpen: (open: boolean) => void
isCreating: boolean
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
startEditing: () => void
cancelEditing: () => void
saveContent: () => void
@@ -68,9 +65,12 @@ export function TextbookContentPanel({
if (!selected) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
{t("reader.selectChapter")}
</div>
<EmptyState
icon={BookOpen}
title={t("reader.selectChapter")}
description={t("reader.selectChapterDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
@@ -105,6 +105,7 @@ export function TextbookContentPanel({
<RichTextEditor
value={editContent}
onChange={setEditContent}
// 任意值 min-h-[500px]:编辑器最小高度,保证可编辑区域可用空间
className="min-h-[500px] border-none shadow-none"
/>
</div>
@@ -129,6 +130,10 @@ export function TextbookContentPanel({
return (
<span
data-kp-id={id}
role="button"
tabIndex={0}
aria-label={t("reader.clickToViewKp")}
aria-pressed={isHighlighted}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
@@ -138,6 +143,13 @@ export function TextbookContentPanel({
onHighlight(id)
onSwitchToKnowledgeTab()
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onHighlight(id)
onSwitchToKnowledgeTab()
}
}}
title={t("reader.clickToViewKp")}
>
{children}
@@ -152,9 +164,12 @@ export function TextbookContentPanel({
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center">
{t("reader.emptyContent")}
</div>
<EmptyState
icon={BookOpen}
title={t("reader.emptyContent")}
description={t("reader.emptyContentDesc")}
className="h-64 border-none shadow-none bg-transparent"
/>
)}
</div>
</ContextMenuTrigger>

View File

@@ -39,6 +39,7 @@ export function TextbookFilters() {
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
{/* 任意值 w-[140px]:学科选择器固定宽度,保证触发器不随内容变化 */}
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("field.subject")} />
</SelectTrigger>
@@ -53,6 +54,7 @@ export function TextbookFilters() {
</Select>
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
{/* 任意值 w-[130px]:年级选择器固定宽度,保证触发器不随内容变化 */}
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("field.grade")} />
</SelectTrigger>

View File

@@ -64,6 +64,7 @@ export function TextbookFormDialog() {
{t("list.add")}
</Button>
</DialogTrigger>
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.create.title")}</DialogTitle>

View File

@@ -2,9 +2,10 @@
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Share2, Menu } from "lucide-react"
import { Tag, List, Share2, Menu, GraduationCap } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import Link from "next/link"
import type { Chapter, KnowledgePoint } from "../types"
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
@@ -77,6 +78,7 @@ export function TextbookReader({
// P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE)
const canCreateLessonPlan = hasPermission(Permissions.LESSON_PLAN_CREATE)
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
@@ -249,6 +251,7 @@ export function TextbookReader({
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{/* 任意值 text-[10px]紧凑徽章text-xs(12px) 在标签栏中过大 */}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
@@ -341,6 +344,7 @@ export function TextbookReader({
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
<SheetHeader className="px-4 py-3 border-b shrink-0">
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
@@ -350,12 +354,13 @@ export function TextbookReader({
</Sheet>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
{/* P2-4 移动端侧栏触发按钮 */}
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
{/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setMobileSidebarOpen(true)}
className="lg:hidden"
aria-expanded={mobileSidebarOpen}
aria-controls="mobile-sidebar-sheet"
>
@@ -363,10 +368,20 @@ export function TextbookReader({
{t("reader.openSidebar")}
</Button>
{selected && (
<span className="text-sm text-muted-foreground truncate">
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
{selected.title}
</span>
)}
{selected && canCreateLessonPlan && (
<Button asChild variant="default" size="sm" className="ml-auto">
<Link
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
>
<GraduationCap className="mr-2 h-4 w-4" />
{t("reader.prepareLesson")}
</Link>
</Button>
)}
</div>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>

View File

@@ -99,6 +99,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
</Button>
)}
</DialogTrigger>
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.settings.title")}</DialogTitle>