feat(textbooks): add force-graph component and update graph components

- Add force-graph.tsx for new graph visualization

- Update graph-toolbar, knowledge-graph, knowledge-point-list, textbook-reader

- Update use-graph-data hook and types

- Update textbooks error boundary pages
This commit is contained in:
SpecialX
2026-07-04 10:22:24 +08:00
parent 25dca843be
commit 6ea8ba763b
11 changed files with 667 additions and 153 deletions

View File

@@ -42,6 +42,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
@@ -326,14 +327,111 @@ export function TextbookReader({
</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}
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
const contentPanel = (
<div className="flex flex-col h-full min-h-0 relative">
{/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
<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>
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<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
chapter={{ selected, processedContent }}
editing={{
isEditing,
editContent,
setEditContent,
isSaving,
startEditing,
cancelEditing: () => setIsEditing(false),
saveContent: handleSaveContent,
}}
selection={{
contentRef,
onPointerDown: handleContentPointerDown,
onContextMenuChange: handleContextMenuChange,
selectedText,
setCreateDialogOpen,
}}
highlight={{
highlightedKpId,
onHighlight: setHighlightedKpId,
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
}}
canEdit={canEdit}
/>
</TextbookSectionErrorBoundary>
</div>
)
return (
<div className="flex flex-col h-full lg:h-full">
{/* 移动端:单列布局 + Sheet 抽屉侧栏 */}
<div className="lg:hidden flex-1 min-h-0">{contentPanel}</div>
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
@@ -344,100 +442,16 @@ export function TextbookReader({
</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}
{/* 桌面端:可拖拽分栏,左侧侧栏 + 右侧正文,切换 tab 时重置比例 */}
<div className="hidden lg:flex h-full min-h-0">
<ResizablePanel
key={activeTab}
initialLeft={sidebarInitialPct}
minLeft={20}
minRight={25}
left={<div className="h-full pr-3 border-r">{sidebarContent}</div>}
right={<div className="h-full pl-3">{contentPanel}</div>}
/>
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<TextbookContentPanel
chapter={{ selected, processedContent }}
editing={{
isEditing,
editContent,
setEditContent,
isSaving,
startEditing,
cancelEditing: () => setIsEditing(false),
saveContent: handleSaveContent,
}}
selection={{
contentRef,
onPointerDown: handleContentPointerDown,
onContextMenuChange: handleContextMenuChange,
selectedText,
setCreateDialogOpen,
}}
highlight={{
highlightedKpId,
onHighlight: setHighlightedKpId,
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
}}
canEdit={canEdit}
/>
</TextbookSectionErrorBoundary>
</div>
</div>
)