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 相关零错误
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
|
||
import { useQueryState, parseAsString } from "nuqs"
|
||
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"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
import { usePermission } from "@/shared/hooks/use-permission"
|
||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||
import { Badge } from "@/shared/components/ui/badge"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/shared/components/ui/alert-dialog"
|
||
|
||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||
import { KnowledgeGraph } from "./knowledge-graph"
|
||
import { KnowledgePointList } from "./knowledge-point-list"
|
||
import { TextbookContentPanel } from "./textbook-content-panel"
|
||
import {
|
||
KnowledgePointDialogs,
|
||
type QuestionCreatorRenderProps,
|
||
} from "./knowledge-point-dialogs"
|
||
import { TextbookSectionErrorBoundary } from "./section-error-boundary"
|
||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/shared/components/ui/sheet"
|
||
import { useTextSelection } from "../hooks/use-text-selection"
|
||
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
||
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
||
|
||
export interface TextbookReaderProps {
|
||
chapters: Chapter[]
|
||
/**
|
||
* 教材 ID,用于按章节懒加载知识点(P2-3)。
|
||
* 必传,否则知识点面板将始终为空。
|
||
*/
|
||
textbookId: string
|
||
/**
|
||
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
|
||
* 保留 prop 仅为向后兼容,传入值会被忽略。
|
||
* @deprecated 改用权限系统自动判断
|
||
*/
|
||
canEdit?: boolean
|
||
/**
|
||
* 题目创建器渲染函数(P0-1 解耦)。
|
||
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
||
* 不传则不渲染题目创建入口。
|
||
*/
|
||
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
|
||
}
|
||
|
||
export function TextbookReader({
|
||
chapters,
|
||
textbookId,
|
||
renderQuestionCreator,
|
||
}: TextbookReaderProps) {
|
||
const t = useTranslations("textbooks")
|
||
const { hasPermission } = usePermission()
|
||
|
||
// 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")
|
||
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
|
||
// P2-4 移动端抽屉式侧栏
|
||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||
|
||
const [isEditing, setIsEditing] = useState(false)
|
||
const [editContent, setEditContent] = useState("")
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
|
||
const {
|
||
selectedText,
|
||
setSelectedText,
|
||
contentRef,
|
||
createDialogOpen,
|
||
setCreateDialogOpen,
|
||
isCreating,
|
||
setIsCreating,
|
||
handleContentPointerDown,
|
||
handleContextMenuChange,
|
||
} = useTextSelection()
|
||
|
||
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
|
||
const selected = chapterId ? index.get(chapterId) ?? null : null
|
||
const selectedId = selected?.id ?? null
|
||
|
||
// P2-3 知识点懒加载:按章节通过 Server Action 按需加载,避免一次性拉取全部知识点
|
||
// 使用缓存 + 派生值模式,避免在 effect 主体中同步 setState
|
||
// v2-P2: textbookId 变化时通过页面层 key={textbookId} 重置整个 reader,无需手动清理缓存
|
||
const [kpsByChapter, setKpsByChapter] = useState<Record<string, KnowledgePoint[]>>({})
|
||
const requestedChaptersRef = useRef<Set<string>>(new Set())
|
||
// 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算
|
||
const currentChapterKPs = useMemo<KnowledgePoint[]>(
|
||
() => (selectedId ? kpsByChapter[selectedId] ?? [] : []),
|
||
[selectedId, kpsByChapter]
|
||
)
|
||
// 加载状态派生:选中了章节但缓存中尚无数据时视为加载中
|
||
const isLoadingKPs = selectedId !== null && kpsByChapter[selectedId] === undefined
|
||
|
||
useEffect(() => {
|
||
if (!selectedId || !textbookId) {
|
||
return
|
||
}
|
||
// 已请求过的章节不重复请求(缓存命中)
|
||
if (requestedChaptersRef.current.has(selectedId)) {
|
||
return
|
||
}
|
||
requestedChaptersRef.current.add(selectedId)
|
||
let cancelled = false
|
||
getKnowledgePointsByChapterAction(selectedId, textbookId)
|
||
.then((result) => {
|
||
if (cancelled) return
|
||
const data = result.success ? result.data : undefined
|
||
setKpsByChapter((prev) => ({ ...prev, [selectedId]: data ?? [] }))
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setKpsByChapter((prev) => ({ ...prev, [selectedId]: [] }))
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [selectedId, textbookId])
|
||
|
||
const {
|
||
editingKp,
|
||
setEditingKp,
|
||
editKpDialogOpen,
|
||
setEditKpDialogOpen,
|
||
isUpdatingKp,
|
||
questionDialogOpen,
|
||
setQuestionDialogOpen,
|
||
targetKpForQuestion,
|
||
setTargetKpForQuestion,
|
||
deleteConfirmOpen,
|
||
setDeleteConfirmOpen,
|
||
handleCreateKnowledgePoint,
|
||
requestDeleteKnowledgePoint,
|
||
confirmDeleteKnowledgePoint,
|
||
handleUpdateKnowledgePoint,
|
||
} = useKnowledgePointActions(
|
||
textbookId,
|
||
selectedId,
|
||
selected?.textbookId,
|
||
highlightedKpId,
|
||
setHighlightedKpId,
|
||
() => {
|
||
setCreateDialogOpen(false)
|
||
setActiveTab("knowledge")
|
||
setSelectedText("")
|
||
},
|
||
)
|
||
|
||
const [localContent, setLocalContent] = useState<string | null>(null)
|
||
|
||
const onCreateKnowledgePoint = async (formData: FormData) => {
|
||
setIsCreating(true)
|
||
await handleCreateKnowledgePoint(formData)
|
||
setIsCreating(false)
|
||
}
|
||
|
||
const handleSaveContent = async () => {
|
||
if (!selectedId || !textbookId) return
|
||
setIsSaving(true)
|
||
try {
|
||
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
|
||
|
||
if (result.success) {
|
||
toast.success(result.message)
|
||
setIsEditing(false)
|
||
setLocalContent(editContent)
|
||
} else {
|
||
toast.error(result.message)
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to save chapter content", e)
|
||
toast.error(t("reader.saveFailed"))
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const startEditing = () => {
|
||
if (selected) {
|
||
setEditContent(selected.content || "")
|
||
setIsEditing(true)
|
||
}
|
||
}
|
||
|
||
const handleSelect = (chapter: Chapter) => {
|
||
setChapterId(chapter.id)
|
||
setIsEditing(false)
|
||
setLocalContent(null)
|
||
// P2-4 移动端选择章节后关闭抽屉
|
||
setMobileSidebarOpen(false)
|
||
}
|
||
|
||
const effectiveContent = localContent ?? selected?.content
|
||
|
||
// P2-2 性能优化:单遍 alternation 正则替换,避免 O(n×m) 多遍扫描
|
||
const processedContent = useMemo(() => {
|
||
if (!effectiveContent) return ""
|
||
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
|
||
}, [effectiveContent, currentChapterKPs])
|
||
|
||
useEffect(() => {
|
||
if (!highlightedKpId) return
|
||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||
if (!el) return
|
||
|
||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||
const timer = setTimeout(() => {
|
||
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||
}, 2000)
|
||
return () => {
|
||
clearTimeout(timer)
|
||
}
|
||
}, [highlightedKpId])
|
||
|
||
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
||
const sidebarContent = (
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="chapters" className="gap-2">
|
||
<List className="h-4 w-4" />
|
||
{t("reader.tabs.chapters")}
|
||
</TabsTrigger>
|
||
<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}
|
||
</Badge>
|
||
)}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="graph" className="gap-2">
|
||
<Share2 className="h-4 w-4" />
|
||
{t("reader.tabs.graph")}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||
<TextbookSectionErrorBoundary
|
||
fallbackTitle={t("error.loadFailed")}
|
||
fallbackDescription={t("error.loadFailedDesc")}
|
||
retryLabel={t("error.retry")}
|
||
>
|
||
<ScrollArea className="flex-1 h-full px-2">
|
||
<div className="space-y-1 pb-4">
|
||
<ChapterSidebarList
|
||
chapters={chapters}
|
||
selectedChapterId={selectedId || undefined}
|
||
onSelectChapter={handleSelect}
|
||
textbookId={textbookId || ""}
|
||
canEdit={canEdit}
|
||
/>
|
||
</div>
|
||
</ScrollArea>
|
||
</TextbookSectionErrorBoundary>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||
<TextbookSectionErrorBoundary
|
||
fallbackTitle={t("error.loadFailed")}
|
||
fallbackDescription={t("error.loadFailedDesc")}
|
||
retryLabel={t("error.retry")}
|
||
>
|
||
{!selectedId ? (
|
||
<EmptyState
|
||
icon={Tag}
|
||
title={t("reader.selectChapterKnowledge")}
|
||
description={t("reader.selectChapterKnowledgeDesc")}
|
||
className="h-full border-none shadow-none bg-transparent"
|
||
/>
|
||
) : isLoadingKPs ? (
|
||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||
{t("reader.loadingKnowledge")}
|
||
</div>
|
||
) : (
|
||
<KnowledgePointList
|
||
knowledgePoints={currentChapterKPs}
|
||
canEdit={canEdit}
|
||
canCreateQuestion={canCreateQuestion}
|
||
highlightedKpId={highlightedKpId}
|
||
onHighlight={setHighlightedKpId}
|
||
onEdit={(kp) => {
|
||
setEditingKp(kp)
|
||
setEditKpDialogOpen(true)
|
||
}}
|
||
onDelete={requestDeleteKnowledgePoint}
|
||
onCreateQuestion={(kp) => {
|
||
setTargetKpForQuestion(kp)
|
||
setQuestionDialogOpen(true)
|
||
}}
|
||
/>
|
||
)}
|
||
</TextbookSectionErrorBoundary>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||
<TextbookSectionErrorBoundary
|
||
fallbackTitle={t("error.loadFailed")}
|
||
fallbackDescription={t("error.loadFailedDesc")}
|
||
retryLabel={t("error.retry")}
|
||
>
|
||
<KnowledgeGraph textbookId={textbookId} />
|
||
</TextbookSectionErrorBoundary>
|
||
</TabsContent>
|
||
</Tabs>
|
||
)
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
||
{/* P2-4 桌面端侧栏:lg 及以上内联显示 */}
|
||
<div className="hidden lg:flex lg:col-span-4 lg:border-r lg:pr-6 flex-col min-h-0">
|
||
{sidebarContent}
|
||
</div>
|
||
|
||
{/* 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>
|
||
</SheetHeader>
|
||
<div className="flex-1 min-h-0 p-2">{sidebarContent}</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||
{/* 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"
|
||
>
|
||
<Menu className="mr-2 h-4 w-4" />
|
||
{t("reader.openSidebar")}
|
||
</Button>
|
||
{selected && (
|
||
<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}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
|
||
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
||
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>
|
||
{t("dialog.knowledge.delete")}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<KnowledgePointDialogs
|
||
createDialogOpen={createDialogOpen}
|
||
setCreateDialogOpen={setCreateDialogOpen}
|
||
selectedText={selectedText}
|
||
isCreating={isCreating}
|
||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
||
editKpDialogOpen={editKpDialogOpen}
|
||
setEditKpDialogOpen={setEditKpDialogOpen}
|
||
editingKp={editingKp}
|
||
isUpdatingKp={isUpdatingKp}
|
||
onUpdateKnowledgePoint={handleUpdateKnowledgePoint}
|
||
questionDialogOpen={questionDialogOpen}
|
||
setQuestionDialogOpen={setQuestionDialogOpen}
|
||
targetKpForQuestion={targetKpForQuestion}
|
||
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
|
||
/>
|
||
|
||
<TextbookSectionErrorBoundary
|
||
fallbackTitle={t("error.loadFailed")}
|
||
fallbackDescription={t("error.loadFailedDesc")}
|
||
retryLabel={t("error.retry")}
|
||
>
|
||
<TextbookContentPanel
|
||
selected={selected}
|
||
isEditing={isEditing}
|
||
editContent={editContent}
|
||
setEditContent={setEditContent}
|
||
canEdit={canEdit}
|
||
highlightedKpId={highlightedKpId}
|
||
onHighlight={setHighlightedKpId}
|
||
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
||
contentRef={contentRef}
|
||
onPointerDown={handleContentPointerDown}
|
||
onContextMenuChange={handleContextMenuChange}
|
||
selectedText={selectedText}
|
||
setCreateDialogOpen={setCreateDialogOpen}
|
||
startEditing={startEditing}
|
||
cancelEditing={() => setIsEditing(false)}
|
||
saveContent={handleSaveContent}
|
||
isSaving={isSaving}
|
||
processedContent={processedContent}
|
||
/>
|
||
</TextbookSectionErrorBoundary>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|