- {/* P2-4 桌面端侧栏:lg 及以上内联显示 */}
-
- {sidebarContent}
+ // 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
+ const sidebarInitialPct = activeTab === "graph" ? 60 : 35
+
+ const contentPanel = (
+
+ {/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
+
+
+ {selected && (
+
+ {selected.title}
+
+ )}
+ {selected && canCreateLessonPlan && (
+
+ )}
- {/* P2-4 移动端侧栏:lg 以下用 Sheet 抽屉式展示 */}
+
+
+
+ {t("dialog.knowledge.deleteTitle")}
+ {t("dialog.knowledge.deleteDesc")}
+
+
+ {t("dialog.knowledge.cancel")}
+
+ {t("dialog.knowledge.delete")}
+
+
+
+
+
+
+
+
+ setIsEditing(false),
+ saveContent: handleSaveContent,
+ }}
+ selection={{
+ contentRef,
+ onPointerDown: handleContentPointerDown,
+ onContextMenuChange: handleContextMenuChange,
+ selectedText,
+ setCreateDialogOpen,
+ }}
+ highlight={{
+ highlightedKpId,
+ onHighlight: setHighlightedKpId,
+ onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
+ }}
+ canEdit={canEdit}
+ />
+
+
+ )
+
+ return (
+
+ {/* 移动端:单列布局 + Sheet 抽屉侧栏 */}
+
{contentPanel}
{/* 任意值 w-[85vw]:移动端抽屉占视口宽度,max-w-sm 防止超宽屏过大 */}
@@ -344,100 +442,16 @@ export function TextbookReader({
-
- {/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
-
-
- {selected && (
-
- {selected.title}
-
- )}
- {selected && canCreateLessonPlan && (
-
- )}
-
-
-
-
-
- {t("dialog.knowledge.deleteTitle")}
- {t("dialog.knowledge.deleteDesc")}
-
-
- {t("dialog.knowledge.cancel")}
-
- {t("dialog.knowledge.delete")}
-
-
-
-
-
-
+ {sidebarContent}}
+ right={
{contentPanel}
}
/>
-
-
- setIsEditing(false),
- saveContent: handleSaveContent,
- }}
- selection={{
- contentRef,
- onPointerDown: handleContentPointerDown,
- onContextMenuChange: handleContextMenuChange,
- selectedText,
- setCreateDialogOpen,
- }}
- highlight={{
- highlightedKpId,
- onHighlight: setHighlightedKpId,
- onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
- }}
- canEdit={canEdit}
- />
-
)
diff --git a/src/modules/textbooks/hooks/use-graph-data.ts b/src/modules/textbooks/hooks/use-graph-data.ts
index 8975fe8..5fbedef 100644
--- a/src/modules/textbooks/hooks/use-graph-data.ts
+++ b/src/modules/textbooks/hooks/use-graph-data.ts
@@ -1,6 +1,6 @@
"use client"
-import { useState, useEffect, useRef, useCallback } from "react"
+import { useState, useEffect, useCallback } from "react"
import { getKnowledgeGraphDataAction } from "../actions"
import type { GraphViewMode, KnowledgeGraphData } from "../types"
@@ -20,6 +20,12 @@ interface UseGraphDataResult {
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
* 区分 isLoading(首次加载)和 isRefreshing(切换模式刷新),
* 切换模式时保留旧数据避免 UI 闪烁。
+ *
+ * 注意:不使用 lastRequestKey 防重复机制。该机制在 React StrictMode
+ * (Next.js 默认启用)下会导致 action 结果被丢弃:
+ * 第一次 mount 发起 action → cleanup 设置 cancelled=true →
+ * 第二次 mount 因 requestKey 相同跳过 → action 结果被忽略 → 永久 loading。
+ * useEffect 依赖数组已足够控制 effect 执行频率,无需额外防重复。
*/
export function useGraphData(
textbookId: string,
@@ -28,7 +34,6 @@ export function useGraphData(
const [data, setData] = useState
(null)
const [error, setError] = useState(null)
const [reloadTrigger, setReloadTrigger] = useState(0)
- const lastRequestKey = useRef("")
const reload = useCallback(() => {
setReloadTrigger((n) => n + 1)
@@ -42,10 +47,6 @@ export function useGraphData(
useEffect(() => {
if (!textbookId) return
- const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
- if (lastRequestKey.current === requestKey) return
- lastRequestKey.current = requestKey
-
let cancelled = false
getKnowledgeGraphDataAction(textbookId, viewMode)
diff --git a/src/modules/textbooks/types.ts b/src/modules/textbooks/types.ts
index e789f4e..7c12759 100644
--- a/src/modules/textbooks/types.ts
+++ b/src/modules/textbooks/types.ts
@@ -46,9 +46,12 @@ export type KnowledgePoint = {
// ===== 知识图谱相关类型 =====
-/** 图谱视图模式 */
+/** 图谱视图模式(数据视角:结构 / 学生掌握度 / 班级掌握度) */
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
+/** 图谱布局模式(渲染方式:分层有向图 / Obsidian 风格力导向图) */
+export type GraphLayoutMode = "hierarchical" | "force"
+
/** 掌握度信息 */
export interface MasteryInfo {
/** 掌握度等级 0-100 */