feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角
将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。 业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。 视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。 架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。 测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。 附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
This commit is contained in:
@@ -636,15 +636,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:教材与知识体系管理(教材/章节树形结构、知识点 CRUD、Markdown 内容编辑、知识图谱)。
|
||||
|
||||
**导出函数**:
|
||||
- Actions(11 个,均为写操作;读操作由 RSC 页面直接调用 data-access):`createTextbookAction` / `updateTextbookAction` / `deleteTextbookAction` / `createChapterAction` / `updateChapterContentAction` / `deleteChapterAction` / `reorderChaptersAction` / `createKnowledgePointAction` / `updateKnowledgePointAction` / `deleteKnowledgePointAction` / `getKnowledgePointsByChapterAction`
|
||||
- Data-access:`getTextbooks` / `getTextbookById` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` / `createTextbook` / `updateTextbook` / `deleteTextbook` / `createChapter` / `updateChapterContent` / `deleteChapter` / `createKnowledgePoint` / `updateKnowledgePoint` / `deleteKnowledgePoint` / `reorderChapters` / `getTextbooksDashboardStats` / `getKnowledgePointOptions`(跨模块接口,供 questions 调用)/ `getTextbooksWithScope`(P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤)/ `verifyChapterBelongsToTextbook`(P0-4 新增:资源归属校验)/ `verifyKnowledgePointBelongsToTextbook`(P0-4 新增:资源归属校验)/ `getSubjectLabelKey` / `getGradeLabelKey`(i18n 标签键)
|
||||
- Actions(14 个,均为写操作;读操作由 RSC 页面直接调用 data-access):`createTextbookAction` / `updateTextbookAction` / `deleteTextbookAction` / `createChapterAction` / `updateChapterContentAction` / `deleteChapterAction` / `reorderChaptersAction` / `createKnowledgePointAction` / `updateKnowledgePointAction` / `deleteKnowledgePointAction` / `getKnowledgePointsByChapterAction` / `getKnowledgeGraphDataAction`(✅ Task 7 新增:知识图谱数据查询,支持 structure/student-mastery/class-mastery 三种视图)/ `createPrerequisiteAction`(✅ Task 7 新增:声明前置依赖,含循环检测)/ `deletePrerequisiteAction`(✅ Task 7 新增:删除前置依赖)
|
||||
- Data-access:`getTextbooks` / `getTextbookById` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` / `createTextbook` / `updateTextbook` / `deleteTextbook` / `createChapter` / `updateChapterContent` / `deleteChapter` / `createKnowledgePoint` / `updateKnowledgePoint` / `deleteKnowledgePoint` / `reorderChapters` / `getTextbooksDashboardStats` / `getKnowledgePointOptions`(跨模块接口,供 questions 调用)/ `getTextbooksWithScope`(P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤)/ `verifyChapterBelongsToTextbook`(P0-4 新增:资源归属校验)/ `verifyKnowledgePointBelongsToTextbook`(P0-4 新增:资源归属校验)/ `createPrerequisite`(✅ Task 7 新增:创建前置依赖)/ `deletePrerequisite`(✅ Task 7 新增:删除前置依赖)/ `getPrerequisiteEdgesForTextbook`(✅ Task 7 新增:获取教材下所有前置依赖边,用于循环检测)/ `getSubjectLabelKey` / `getGradeLabelKey`(i18n 标签键)
|
||||
- Data-access-graph(✅ Task 5 新增,图谱只读查询):`getKnowledgePointsWithRelations`(知识点+依赖+题目数聚合查询)/ `getStudentKpMastery`(学生掌握度)/ `getClassKpMastery`(班级平均掌握度)/ `getPrerequisitesForKp` / `getSuccessorsForKp`
|
||||
- Constants(✅ 新增):`SUBJECTS` / `GRADES` / `SUBJECT_COLORS` / `getSubjectColor` / `getSubjectLabelKey` / `getGradeLabelKey`
|
||||
- Utils(✅ 新增,纯函数 + 单测):`sortChapters` / `buildChapterTree` / `buildChapterIndex` / `findChapterParent` / `filterKnowledgePointsByChapter` / `normalizeOptional` / `highlightKnowledgePoints`
|
||||
- ✅ v1 测试修复:`SUBJECTS` 新增 `Chinese` 学科、`GRADES` 新增 `Grade 1`/`Grade 2`,与 seed 数据对齐
|
||||
- ✅ v1 测试修复:`SUBJECT_COLORS` 新增 `Chinese` 主题色(rose)
|
||||
- Utils(✅ 新增,纯函数 + 单测):`sortChapters` / `buildChapterTree` / `buildChapterIndex` / `findChapterParent` / `filterKnowledgePointsByChapter` / `normalizeOptional` / `highlightKnowledgePoints` / `hasCycleAfterAddingEdge`(Task 4 新增:循环依赖检测,DFS 算法)
|
||||
- Graph-layout(✅ 新增,纯函数 + 单测):`computeGraphLayout`
|
||||
- Components(✅ Task 13 新增):`GraphNodeDetailPanel` — 知识图谱节点详情侧边面板,展示知识点名称/描述/掌握度/关联题目/前置后置知识点,支持跳转与前置关系增删
|
||||
- Analytics(✅ 新增):`TextbookAnalytics` / `TextbookAnalyticsProvider` / `useTextbookAnalytics`
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`
|
||||
- 依赖:`shared/*`、`@/auth`、`@xyflow/react`(知识图谱可视化)、`@dagrejs/dagre`(图谱分层布局算法)
|
||||
- 被依赖:`questions`(✅ P1-1 已修复:通过 textbooks data-access)、`exams`(通过类型)、`dashboard`(通过 data-access,P0-4 已修复)
|
||||
- ✅ UI 层跨模块依赖已解耦:`textbooks/components/knowledge-point-dialogs.tsx` 不再直接 import questions 模块,改为通过 render prop 注入创建题目入口
|
||||
|
||||
@@ -661,6 +665,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ P1 Error Boundary 已补齐:新增 `section-error-boundary.tsx`
|
||||
- ✅ P1 知识点列表/弹窗重复实现已清理:移除无调用方的 `knowledge-point-panel.tsx`
|
||||
- ✅ P1 学科/年级选项统一:抽取到 `constants.ts`(`SUBJECTS` / `GRADES` / `SUBJECT_COLORS`)
|
||||
- ✅ v1 测试修复:`textbook-reader.tsx` 移除 `SheetTrigger`(错误置于 `Sheet` 外部导致 `DialogTrigger must be used within Dialog`),改用受控 `Button` + `onClick` 打开移动端抽屉
|
||||
- ✅ v1 测试修复:新增 `teacher-textbook-reader.tsx` 客户端包装组件,解决 Server Component 向 Client Component 传递函数 prop(`renderQuestionCreator`)违反 Next.js 序列化约束的问题
|
||||
- ✅ v1 测试修复:`scripts/seed.ts` 教材 subject/grade 改为英文 value(与 `constants.ts` 对齐),消除 i18n `MISSING_MESSAGE` 错误
|
||||
- ✅ P1 纯逻辑已导出并补单测:新增 `utils.ts` / `graph-layout.ts` 及对应 `.test.ts`
|
||||
- ⚠️ i18n 覆盖率约 95%(`chapter-sidebar-list` 已接入,`actions.ts` 已接入,`section-error-boundary` 默认值已改英文)
|
||||
- ⚠️ 类型断言残留 3 处 `as string`
|
||||
@@ -669,17 +676,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | 377 | 11 个 Server Action(写操作,含 Zod 校验 + 资源归属校验) |
|
||||
| `data-access.ts` | 619 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 |
|
||||
| `types.ts` | 45 | 类型定义 |
|
||||
| `schema.ts` | 64 | Zod 校验 |
|
||||
| `constants.ts` | 91 | 学科/年级常量与颜色映射(✅ 新增) |
|
||||
| `utils.ts` | 181 | 章节树构建/排序/查找等纯函数(✅ 新增,含单测) |
|
||||
| `graph-layout.ts` | 141 | 知识图谱布局计算纯函数(✅ 新增,含单测) |
|
||||
| `actions.ts` | 502 | 14 个 Server Action(写操作,含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理) |
|
||||
| `data-access.ts` | 586 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD |
|
||||
| `data-access-graph.ts` | 184 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only`) |
|
||||
| `types.ts` | 94 | 类型定义(含知识图谱类型:GraphViewMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel) |
|
||||
| `schema.ts` | 62 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchema) |
|
||||
| `constants.ts` | 99 | 学科/年级常量与颜色映射(✅ 新增;v1 测试修复:新增 Chinese/Grade 1/Grade 2) |
|
||||
| `utils.ts` | 203 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) |
|
||||
| `graph-layout.ts` | 105 | 知识图谱布局计算纯函数(✅ Task 8 重写为 dagre 集成,输出 React Flow 格式,含单测) |
|
||||
| `analytics.tsx` | 43 | 教材分析 Context/Provider/Hook(✅ 新增) |
|
||||
| `hooks/use-knowledge-point-actions.ts` | 121 | 知识点操作 Hook |
|
||||
| `hooks/use-text-selection.ts` | 57 | 文本选区捕获 Hook |
|
||||
| `components/*` | 12 文件 | 教材编辑/知识图谱组件(新增 `section-error-boundary.tsx`) |
|
||||
| `hooks/use-graph-data.ts` | 58 | 知识图谱数据加载 Hook(✅ Task 11 新增:派生值模式避免 effect 中 setState) |
|
||||
| `components/teacher-textbook-reader.tsx` | 41 | 教师端 TextbookReader 客户端包装(✅ v1 测试修复:解决 Server→Client 函数 prop 序列化问题) |
|
||||
| `components/knowledge-graph.tsx` | 249 | React Flow 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色) |
|
||||
| `components/graph-kp-node.tsx` | 80 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条) |
|
||||
| `components/graph-prerequisite-edge.tsx` | 40 | React Flow 自定义边(✅ Task 9 新增:虚线+箭头表示前置依赖) |
|
||||
| `components/graph-toolbar.tsx` | 77 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图) |
|
||||
| `components/graph-node-detail-panel.tsx` | 171 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表) |
|
||||
| `components/*` | 17 文件 | 教材编辑/知识图谱组件(新增 `section-error-boundary.tsx`、`teacher-textbook-reader.tsx`、5 个 graph-* 组件) |
|
||||
|
||||
---
|
||||
|
||||
@@ -688,9 +703,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`
|
||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend`
|
||||
- Lib(✅ P1-2 新增):`toNumber` / `normalize` / `buildScopeClassFilter`(从 3 个 data-access 文件抽取的公共工具函数)
|
||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)
|
||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)
|
||||
- Lib(✅ P1-2 新增,✅ P3 更新签名):`toNumber` / `normalize` / `buildScopeClassFilter(scope, currentUserId?)`(P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
|
||||
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
||||
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)
|
||||
|
||||
@@ -712,21 +727,50 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ actions 层无直接 DB 访问(标杆)
|
||||
- ✅ data-access 按职责拆分为 3 个文件(标杆)
|
||||
- ✅ P2 已修复:`export.ts` 中 `scoreMap.get(r.studentId)!` 非空断言清理为安全守卫(`if (!subjMap) continue`)
|
||||
- ✅ P3 修复(2026-06-22):~~`schema.ts` 缺少输入边界约束~~ score 字段添加 `.max(1000)`,records 数组添加 `.max(500)`,查询 schema 补全 studentId/semester/examId 字段
|
||||
- ✅ P3 修复(2026-06-22):~~`buildScopeClassFilter` 的 `class_members` scope 未应用 studentId 过滤~~ 新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤
|
||||
- ✅ P3 修复(2026-06-22):~~`getGradeRecords` 内存分页(全量查询后切片)~~ 改为 DB 层分页(limit/offset),并行查询总数与当前页数据,返回 `PaginatedGradeRecords { records, total }`
|
||||
- ✅ P3 修复(2026-06-22):~~`batchCreateGradeRecords` 非原子操作~~ 包裹 `db.transaction` 确保原子性
|
||||
- ✅ P3 修复(2026-06-22):~~`updateGradeRecord`/`deleteGradeRecord` 未检查记录是否存在~~ 新增存在性检查,不存在时抛 `NotFoundError("成绩记录")`
|
||||
- ✅ P3 修复(2026-06-22):~~`getClassGradeStats`/`getStudentGradeSummary`/`getClassRanking`/`getClassStudentsForEntry`/`getClassGradeStatsWithMeta` 缺少 scope 过滤~~ 所有函数新增 `scope?`/`currentUserId?` 参数,应用 `buildScopeClassFilter`;`getStudentGradeSummary`/`getClassStudentsForEntry` 对 `class_taught` scope 校验学生/班级归属
|
||||
- ✅ P3 修复(2026-06-22):~~`getClassRanking` 未处理并列排名~~ 相同平均分获得相同名次,下一名次跳过占位
|
||||
- ✅ P3 修复(2026-06-22):~~`getClassComparison`/`getRankingTrend` 缺少 scope 过滤~~ 应用 `buildScopeClassFilter`;`getRankingTrend` 对 `class_taught` scope 校验
|
||||
- ✅ P3 修复(2026-06-22):~~actions.ts/actions-analytics.ts catch 块直接返回 `e.message`~~ 改用 `handleActionError(e)` 统一错误处理;`batchCreateGradeRecordsAction` 使用 `safeJsonParse` 解析 JSON
|
||||
- ✅ P3 修复(2026-06-22):~~actions-analytics.ts 缺少 class scope 校验~~ `getGradeTrendAction`/`getSubjectComparisonAction`/`getGradeDistributionAction` 新增 `assertClassInScope` 校验
|
||||
- ✅ P3 修复(2026-06-22):~~组件直接 try/catch 调用 Server Action~~ 改用 `safeActionCall` 包装器(onError/onFinally 回调);`batch-grade-entry.tsx` localStorage 访问包裹 `typeof window !== "undefined"` 检查;区分"未录入"与"录入 0"
|
||||
- ✅ P3 修复(2026-06-22):~~`grade-trend-card.tsx` 排序未处理 NaN 日期、除法未检查 fullScore > 0~~ 新增日期有效性检查与 `fullScore > 0` 守卫
|
||||
- ✅ P3 修复(2026-06-22):~~页面文件缺少 scope 传递~~ teacher/grades/page.tsx 使用 DB 层分页;analytics/page.tsx 添加 EmptyState;entry/page.tsx 与 stats/page.tsx 过滤 class_taught scope 班级;student/grades/page.tsx 与 parent/grades/page.tsx 传递 `ctx.dataScope` 到 data-access
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | 359 | 7 个 Server Action(含 Zod 校验) |
|
||||
| `actions-analytics.ts` | 175 | 5 个分析 Action(含 Zod 校验) |
|
||||
| `data-access.ts` | 361 | 成绩 CRUD + 统计 |
|
||||
| `data-access-analytics.ts` | 266 | 趋势/对比分析 |
|
||||
| `data-access-ranking.ts` | 96 | 排名查询 |
|
||||
| `stats-service.ts` | - | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口) |
|
||||
| `export.ts` | 214 | Excel 导出 |
|
||||
| `schema.ts` | 100 | Zod 校验(含 12 个查询 schema) |
|
||||
| `lib/grade-utils.ts` | 46 | 公共工具函数(toNumber/normalize/buildScopeClassFilter) |
|
||||
| `components/widget-boundary.tsx` | - | Widget 边界组件(P1-5 新增) |
|
||||
| `types.ts` | - | 类型定义 |
|
||||
| `actions.ts` | 396 | 15 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页) |
|
||||
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验) |
|
||||
| `data-access.ts` | 428 | 成绩 CRUD + 统计(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名) |
|
||||
| `data-access-analytics.ts` | 200 | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter) |
|
||||
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
||||
| `stats-service.ts` | 279 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口) |
|
||||
| `export.ts` | 189 | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope) |
|
||||
| `schema.ts` | 113 | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段) |
|
||||
| `lib/grade-utils.ts` | 66 | 公共工具函数(toNumber/normalize/buildScopeClassFilter,v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:buildScopeClassFilter 新增 currentUserId 参数) |
|
||||
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
||||
| `components/grade-trend-card.tsx` | 69 | 趋势卡片(v2-P2-9 修复:a11y;v2-P1-4:i18n;P3 修复:NaN 日期检查 + fullScore > 0 守卫) |
|
||||
| `components/grade-record-list.tsx` | 125 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作) |
|
||||
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图(v2-P1-4:i18n) |
|
||||
| `components/subject-comparison-chart.tsx` | 62 | 科目对比图(v2-P1-4:i18n) |
|
||||
| `components/class-comparison-chart.tsx` | 58 | 班级对比图(v2-P1-4:i18n) |
|
||||
| `components/grade-trend-chart.tsx` | 59 | 趋势图(v2-P1-4:i18n) |
|
||||
| `components/grade-record-form.tsx` | 177 | 录入表单(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall 包装提交) |
|
||||
| `components/batch-grade-entry.tsx` | 435 | 批量录入(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall + localStorage 安全检查 + 区分未录入与录入 0) |
|
||||
| `components/grade-filters.tsx` | 76 | 过滤器(v2-P1-4:i18n) |
|
||||
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要(v2-P1-4:i18n) |
|
||||
| `components/export-button.tsx` | 79 | 导出按钮(v2-P1-4:i18n;P3 修复:safeActionCall 包装导出操作) |
|
||||
| `components/analytics-filters.tsx` | 86 | 分析过滤器(v2-P1-4:i18n) |
|
||||
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器(v2-P1-4:i18n) |
|
||||
| `components/grade-stats-card.tsx` | 74 | 统计卡片(v2-P1-4:i18n) |
|
||||
| `components/class-grade-report.tsx` | 90 | 班级成绩报告(v2-P1-4:i18n) |
|
||||
| `components/grade-query-filters.tsx` | 96 | 查询过滤器(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n) |
|
||||
| `types.ts` | 168 | 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1173,7 +1217,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**组件清单**:
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `components/announcement-list.tsx` | 公告列表(用户端,支持状态筛选;✅ V2-P1-1:纯服务端过滤,Select 切换更新 URL ?status= 触发 RSC 重新渲染) |
|
||||
| `components/announcement-list.tsx` | 公告列表(用户端,支持状态筛选;✅ V2-P1-1:纯服务端过滤,Select 切换更新 URL ?status= 触发 RSC 重新渲染;✅ V3:新增 `detailHrefPrefix` prop 替代 `detailHrefBuilder` 函数 prop,解决 Next.js 16 Server→Client 序列化限制) |
|
||||
| `components/announcement-card.tsx` | 公告卡片(列表项) |
|
||||
| `components/announcement-detail.tsx` | 公告详情(只读) |
|
||||
| `components/announcement-form.tsx` | 公告表单(创建/编辑,✅ P1-6:条件校验由 schema superRefine 保证;✅ V2-P1-4:fieldErrors + aria-invalid 字段级错误展示) |
|
||||
@@ -1417,36 +1461,50 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:知识点掌握度查询 + 诊断报告生成。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`generateStudentDiagnosticReportAction` / `generateClassDiagnosticReportAction` / `publishDiagnosticReportAction` / `deleteDiagnosticReportAction` / `getDiagnosticReportsAction` / `getStudentMasteryAction`
|
||||
- Data-access:`updateMasteryFromSubmission` / `getStudentMastery` / `getClassMasteryOverview`
|
||||
- Data-access-reports:`createDiagnosticReport` / `getDiagnosticReport` / `getDiagnosticReports` / `deleteDiagnosticReport` / `publishDiagnosticReport`(✅ P2 已修复:`getDiagnosticReports` 和 `getDiagnosticReportById` 使用 `React.cache()` 包装实现请求级 memoization)
|
||||
- Schema:`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema` / `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema`
|
||||
- Actions:`generateStudentReportAction` / `generateClassReportAction` / `publishReportAction` / `deleteReportAction`(v2-P2-3 修复:删除死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction`,页面直接调用 data-access 并自行权限校验)
|
||||
- Data-access:`updateMasteryFromSubmission`(v2-P1-8 修复:累积模式;v2-P2-5 修复:db.transaction 包裹)/ `getStudentMastery` / `getStudentMasterySummary` / `getClassMasterySummary`(v2-P2-4 修复:totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`(v2-P1-7 修复:页面先查班级再传参)
|
||||
- Data-access-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据)/ `getDiagnosticReports` / `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization)
|
||||
- Stats-service(✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses` / `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数)
|
||||
- Schema:`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema`(v2-P2-3 修复:删除死代码 `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema`)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId)
|
||||
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId;v2-P1-7 新增 getStudentActiveClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId)
|
||||
- 被依赖:无
|
||||
|
||||
**已知问题**:
|
||||
- ✅ P1-1 已修复:~~`updateMasteryFromSubmission` 跨模块直查 4 张表(与 exams/homework/questions 紧耦合)~~ 改为调用 `exams/data-access.getExamSubmissionWithAnswers` 和 `questions/data-access.getKnowledgePointsForQuestions`
|
||||
- ✅ P1-1 已修复:~~`updateMasteryFromSubmission` 跨模块直查 4 张表~~ 改为调用 `exams/data-access.getExamSubmissionWithAnswers` 和 `questions/data-access.getKnowledgePointsForQuestions`
|
||||
- ✅ P2 已修复:~~`data-access-reports.ts` 有未使用代码(`round2` + `void round2`)~~ 已删除死代码
|
||||
- ✅ P2 已修复:~~`updateMasteryFromSubmission` 循环内串行 await upsert~~ 改为 `Promise.all` 并行执行所有 upsert
|
||||
- ✅ P2 已修复:~~`getClassMasterySummary` 串行查询(className → studentIds → userMap → masteryRows)~~ 改为两组 `Promise.all` 并行(className+studentIds,userMap+masteryRows)
|
||||
- ✅ P2 已修复:~~`getClassMasterySummary` 串行查询~~ 改为两组 `Promise.all` 并行
|
||||
- ✅ P2 已修复:~~`getDiagnosticReports` 中 `conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注
|
||||
- ✅ P0-2 已修复:~~`data-access-reports.ts` 直查 `users` 表获取姓名~~ 改为通过 `users/data-access.getUserNamesByIds` 跨模块接口
|
||||
- ✅ P2-2 已修复:~~`class-diagnostic-view.tsx`/`student-diagnostic-view.tsx`/`mastery-radar-chart.tsx` 中存在 Tailwind 任意值~~ 改用标准 Tailwind 类(w-44/max-w-32/text-xs/h-96/max-w-lg)
|
||||
- ✅ P0-2 已修复:~~`data-access-reports.ts` 直查 `users` 表获取姓名~~ 改为通过 `users/data-access.getUserNamesByIds`
|
||||
- ✅ P2-2 已修复:~~Tailwind 任意值~~ 改用标准 Tailwind 类
|
||||
- ✅ P2-1 已修复:~~图表/表格/列表缺少 a11y ARIA 属性~~ 为 5 个图表添加 `role="img"` + `aria-label`,2 个表格添加 `<caption>`,3 个列表添加 `role="list"`,3 个图标按钮添加 `aria-label`
|
||||
- ✅ P2-3 已修复:~~班级报告将生成者 ID 存入 `studentId` 字段(schema 设计缺陷 workaround)~~ schema `learningDiagnosticReports.studentId` 改为可空,班级报告 `studentId` 置空,读取逻辑适配 null
|
||||
- ✅ 与 grades 模块无职责重叠(grades 管分数,diagnostic 管知识点掌握度)
|
||||
- ✅ P2-3 已修复:~~班级报告将生成者 ID 存入 `studentId` 字段~~ schema 改为可空,班级报告 `studentId` 置空
|
||||
- ✅ v2-P1-6 已修复:~~统计逻辑混在 data-access 层~~ 抽取 `stats-service.ts`(352 行,12 个纯函数 + 2 个常量 + 4 个接口)
|
||||
- ✅ v2-P1-7 已修复:~~`getKnowledgePointStats` 无参调用~~ 页面先查 `getStudentActiveClassId` 再传参
|
||||
- ✅ v2-P1-8 已修复:~~`updateMasteryFromSubmission` 覆盖模式~~ 改为累积计算(读取已有记录后累加)
|
||||
- ✅ v2-P2-3 已修复:~~死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction` 全局零调用~~ 已删除,页面直接调用 data-access
|
||||
- ✅ v2-P2-4 已修复:~~`totalStudents` 语义错误 + 班级平均掌握度计算偏差~~ 改为实际有掌握度记录的学生数;先算学生个人平均再取平均
|
||||
- ✅ v2-P2-5 已修复:~~多 upsert 无事务包裹~~ 使用 `db.transaction()` 保证原子性
|
||||
- ✅ v2-P2-6 已修复:~~生成报告未校验掌握度数据~~ 添加 `totalKnowledgePoints === 0` 和 `studentCount === 0` 校验
|
||||
- ✅ v2-P1-4 已修复:~~4 个组件 i18n 完全未接入~~ 全部接入 `useTranslations("diagnostic")`
|
||||
- ✅ v2-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor` 和 `id`
|
||||
- ✅ 与 grades 模块无职责重叠
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `data-access.ts` | 254 | 知识点掌握度查询 + 更新 |
|
||||
| `data-access-reports.ts` | 202 | 诊断报告 CRUD |
|
||||
| `actions.ts` | 172 | 6 个 Server Action(使用 Zod schema 校验) |
|
||||
| `schema.ts` | 56 | Zod 校验(6 个 schema:生成/发布/删除/查询报告) |
|
||||
| `types.ts` | 97 | 类型定义 |
|
||||
| `components/*` | 4 文件 | 学生/班级诊断视图 + 雷达图 |
|
||||
| `data-access.ts` | 179 | 知识点掌握度查询 + 更新(v2-P1-8 累积模式;v2-P2-5 事务;v2-P2-4 语义修正;v2-P1-6 改用 stats-service 纯函数) |
|
||||
| `data-access-reports.ts` | 160 | 诊断报告 CRUD(v2-P2-6 校验;v2-P1-6 改用 stats-service 纯函数) |
|
||||
| `stats-service.ts` | 352 | 统计计算纯函数(v2-P1-6 新增:12 个纯函数 + 2 个常量 + 4 个接口) |
|
||||
| `actions.ts` | 111 | 4 个 Server Action(v2-P2-3 删除 2 个死代码读 Action) |
|
||||
| `schema.ts` | 23 | Zod 校验(4 个 schema,v2-P2-3 删除 2 个死代码 schema) |
|
||||
| `types.ts` | 87 | 类型定义 |
|
||||
| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图(v2-P1-6 热力图 a11y;v2-P1-4 i18n) |
|
||||
| `components/student-diagnostic-view.tsx` | 225 | 学生诊断视图(v2-P1-4 i18n) |
|
||||
| `components/mastery-radar-chart.tsx` | 72 | 雷达图(v2-P1-4 i18n) |
|
||||
| `components/report-list.tsx` | 265 | 报告列表(v2-P2-7 Label htmlFor;v2-P1-4 i18n) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1496,6 +1554,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ P2-9 已修复:~~无 2FA / 会话管理~~ 新增 `actions-security.ts` + `SecurityCenterCard` 组件(2FA 开关占位 + 最近登录历史来自 login_logs 表)
|
||||
- ✅ P2-10 已修复:~~通知偏好表单无测试通知按钮~~ 新增 `sendTestNotificationAction`,每个已启用渠道旁显示测试按钮
|
||||
- ✅ P2-11 已修复:~~语言切换未集成到设置页~~ `ThemePreferencesCard` 集成 `LocaleSwitcher` 到 Appearance 标签页
|
||||
- ✅ v1.1 已修复:~~`/settings` 页面 ErrorBoundary 触发(Functions cannot be passed directly to Client Components)~~ 新增 `actions-service.ts`("use server" 文件),导出 `updateProfileAction` + `updateNotificationPreferencesAction` 两个 Server Action wrapper;`page.tsx` 直接传递 Server Action 引用;`SettingsService` 接口的 `getProfile`/`getPreferences` 改为可选
|
||||
- ✅ v1.1 已修复:~~i18n 键双重 `settings.` 前缀(MISSING_MESSAGE)~~ `role-settings-config.tsx` 中 `descriptionKey` 去掉 `settings.` 前缀
|
||||
- ✅ v1.1 已修复:~~AI 标签页 FormLabel 在 Form 上下文外使用(getFieldState null)~~ `ai-provider-settings-card.tsx` 中两处 `<FormLabel>` 改为 `<Label>`
|
||||
- ✅ v1.1 已修复:~~非管理员访问 `/settings?tab=ai` 显示空白~~ `settings-view.tsx` 新增 `resolveTab()` 函数,对无权限的 tab 回退到 `general`
|
||||
- ✅ v1.1 已修复:~~数据库 `notification_preferences` 表缺少 `quiet_hours_*` 字段~~ 通过 ALTER TABLE 添加缺失字段
|
||||
- ✅ v1.1 已修复:~~数据库 `system_settings` 表不存在~~ 通过 CREATE TABLE 创建
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
@@ -1506,6 +1570,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `actions-notifications.ts` | 46 | 发送测试通知(P2-10 新增:占位实现待接入真实通知服务) |
|
||||
| `actions-system-settings.ts` | 186 | 管理员系统设置 CRUD(P0-3 新增:4 分类 Zod 校验 + upsert) |
|
||||
| `actions-security.ts` | 165 | 2FA 状态查询/切换 + 最近登录历史(P2-9 新增) |
|
||||
| `actions-service.ts` | 60 | 设置页 Server Action wrapper(v1.1 新增:updateProfileAction + updateNotificationPreferencesAction,作为 Server Action 引用传递给 Client Component,避免 Server→Client 函数传递违规) |
|
||||
| `data-access.ts` | 158 | AI Provider CRUD + 密码修改 DB 操作(P1 新增) |
|
||||
| `data-access-system-settings.ts` | 119 | system_settings 表 CRUD(P0-3 新增:键值对存储模式) |
|
||||
| `types.ts` | 60 | 类型定义(AiProviderSummary + SettingsService/ProfileService/NotificationPreferenceService 接口) |
|
||||
@@ -1708,6 +1773,90 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
---
|
||||
|
||||
## 2.28 error-book(错题本模块)
|
||||
|
||||
**职责**:自动采集考试/作业错题,基于 SM-2 间隔重复算法科学复习,提供知识点薄弱度分析与多角色视图(学生/教师/家长/管理员)。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getErrorBookItemsAction` / `getErrorBookItemDetailAction` / `getErrorBookStatsAction` / `createErrorBookItemAction` / `updateErrorBookNoteAction` / `reviewErrorBookItemAction` / `deleteErrorBookItemAction` / `archiveErrorBookItemAction` / `collectFromSubmissionAction`
|
||||
- Data-access:`getErrorBookItems` / `getErrorBookItemById` / `getErrorBookStats` / `createErrorBookItem` / `updateErrorBookNote` / `recordReview` / `deleteErrorBookItem` / `archiveErrorBookItem` / `collectFromExamSubmission` / `collectFromHomeworkSubmission` / `getStudentErrorBookSummaries` / `getTopWrongQuestionsByStudentIds` / `getKnowledgePointWeakness` / `getSubjectErrorDistribution` / `getStudentNameMap` / `getStudentIdsByClassIdList`
|
||||
- Schema:`CreateErrorBookItemSchema` / `UpdateErrorBookNoteSchema` / `ReviewErrorBookItemSchema` / `CollectFromSubmissionSchema`
|
||||
- Types:`ErrorBookItem` / `ErrorBookItemDetail` / `ErrorBookReviewRecord` / `ErrorBookStats` / `StudentErrorBookSummary` / `KnowledgePointWeakness` / `SubjectErrorDistribution`
|
||||
- Components:`ErrorBookStatsCards` / `ErrorBookFilters` / `ErrorBookItemCard` / `ReviewButtons` / `ErrorBookDetailDialog` / `ErrorBookList` / `AddErrorBookDialog` / `ClassErrorOverview` / `TopWrongQuestions`
|
||||
|
||||
**权限点**:
|
||||
- `ERROR_BOOK_READ`(`error_book:read`):查看错题本(student 自己、parent 子女)
|
||||
- `ERROR_BOOK_MANAGE`(`error_book:manage`):管理错题(添加/复习/删除/归档,student)
|
||||
- `ERROR_BOOK_ANALYTICS_READ`(`error_book:analytics_read`):查看错题分析(teacher/admin/grade_head/teaching_head)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`(db、auth-guard、types、utils)、`modules/classes`(getStudentIdsByClassIds)、`modules/questions`(getQuestionsAction)
|
||||
- 被依赖:`app/(dashboard)/student/error-book`、`app/(dashboard)/teacher/error-book`、`app/(dashboard)/parent/error-book`、`app/(dashboard)/admin/error-book`
|
||||
|
||||
**DataScope 行级权限**:
|
||||
| 角色 | DataScope | 说明 |
|
||||
|------|-----------|------|
|
||||
| student | `owned` | 仅查看/管理自己的错题 |
|
||||
| parent | `children` | 查看子女的错题统计 |
|
||||
| teacher | `class_taught` | 查看所教班级学生的错题分析 |
|
||||
| admin | `all` | 查看全校错题分析 |
|
||||
| grade_head | `grade_managed` | 查看所管年级学生的错题分析 |
|
||||
| teaching_head | `grade_managed` | 查看所管年级学生的错题分析 |
|
||||
|
||||
**核心算法:SM-2 间隔重复(简化版)**:
|
||||
- 4 级评级:`again`(重来)/ `hard`(困难)/ `good`(良好)/ `easy`(简单)
|
||||
- 初始间隔:1/2/4/7 天
|
||||
- 间隔增长:`again` 重置为 1 天;`hard` ×1.2;`good` ×1.5;`easy` ×2
|
||||
- 掌握度:0-5 级,`again` -1,`hard` ±0,`good` +1,`easy` +2
|
||||
- 已掌握判定:掌握度 ≥5 或连续答对 ≥3 次
|
||||
- 复习时间:次日早上 9 点
|
||||
|
||||
**自动采集机制**:
|
||||
- `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入
|
||||
- `collectFromHomeworkSubmission`:从作业提交记录中筛选错题,去重后批量插入
|
||||
- 自动关联知识点(通过 `questionsToKnowledgePoints` 表)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | ~180 | 9 个 Server Actions,全部使用 `requirePermission()` + `ActionState<T>` |
|
||||
| `data-access.ts` | ~960 | 16 个数据访问函数 + SM-2 算法实现 + 自动采集逻辑 |
|
||||
| `schema.ts` | ~60 | 4 个 Zod 验证 schema |
|
||||
| `types.ts` | ~120 | 6 个类型定义 + 状态映射常量 + 错误标签常量 |
|
||||
| `components/error-book-stats-cards.tsx` | ~80 | 5 个统计卡片(总数/待学习/学习中/已掌握/待复习) |
|
||||
| `components/error-book-filters.tsx` | ~100 | 筛选栏(搜索/状态/来源/待复习),使用 nuqs |
|
||||
| `components/error-book-item-card.tsx` | ~150 | 错题卡片(预览/标签/笔记/掌握度/操作) |
|
||||
| `components/review-buttons.tsx` | ~80 | 4 按钮复习面板(again/hard/good/easy) |
|
||||
| `components/error-book-detail-dialog.tsx` | ~250 | 详情对话框(题目/答案/复习/笔记/历史) |
|
||||
| `components/error-book-list.tsx` | ~60 | 网格列表 |
|
||||
| `components/add-error-book-dialog.tsx` | ~180 | 手动添加对话框(题库选择 + 标签) |
|
||||
| `components/class-error-overview.tsx` | ~200 | 班级错题概览(教师/管理员视图) |
|
||||
| `components/top-wrong-questions.tsx` | ~80 | 高频错题列表(Top 10) |
|
||||
|
||||
**路由清单**:
|
||||
| 路由 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/student/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 学生错题本(统计/筛选/列表/手动添加/详情复习) |
|
||||
| `/teacher/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 教师错题分析(班级概览/薄弱知识点/学科分布/高频错题) |
|
||||
| `/parent/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 家长错题本(子女错题统计/薄弱知识点/高频错题) |
|
||||
| `/admin/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 管理员错题分析(全校错题统计/薄弱知识点/学科分布/高频错题) |
|
||||
|
||||
**数据库表**:
|
||||
| 表 | 说明 |
|
||||
|------|------|
|
||||
| `errorBookItems` | 错题条目主表(18 列/4 索引/2 外键):studentId、questionId、sourceType、sourceId、studentAnswer、correctAnswer、subjectId、knowledgePointIds、status、masteryLevel、nextReviewAt、reviewInterval、reviewCount、correctStreak、note、errorTags、createdAt、updatedAt |
|
||||
| `errorBookReviews` | 复习记录表(8 列/2 索引/2 外键):itemId、studentId、result、reviewedAt、newInterval、newMasteryLevel |
|
||||
|
||||
**i18n**:
|
||||
- `src/shared/i18n/messages/zh-CN/error-book.json`
|
||||
- `src/shared/i18n/messages/en/error-book.json`
|
||||
|
||||
**导航配置**:
|
||||
- 6 个角色(admin/teacher/student/parent/grade_head/teaching_head)均添加「错题分析」导航项
|
||||
- 图标:`BookX`(lucide-react)
|
||||
|
||||
---
|
||||
|
||||
# 第三部分:已知架构问题和技术债
|
||||
|
||||
## 3.1 P0 严重问题(必须立即修复)
|
||||
@@ -1788,6 +1937,33 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
- Server Action `recordProctoringEventAction`(`proctoring/actions.ts`)为唯一规范路径
|
||||
- `exam-mode-config.tsx` 暂保留原位(集成到考试表单属于功能新增,不在本次修复范围)
|
||||
|
||||
### P0-7:proxy.ts 路由权限跨角色访问漏洞 ✅ 已修复(webapp-testing 发现)
|
||||
|
||||
**问题**:`proxy.ts` 中 `/teacher` 和 `/parent` 路由前缀都使用 `EXAM_READ` 权限校验,但 `student` 和 `parent` 角色也拥有 `EXAM_READ` 权限,导致:
|
||||
- 学生可访问 `/teacher/*` 所有页面
|
||||
- 教师可访问 `/parent/*` 所有页面
|
||||
|
||||
**修复方案**(已实施):
|
||||
- `/teacher` 改为 `Permissions.EXAM_CREATE`(仅 teacher/admin 拥有)
|
||||
- `/parent` 改为 `Permissions.DASHBOARD_PARENT_READ`(仅 parent 拥有)
|
||||
- 新增 `DASHBOARD_ROUTE_PERMISSIONS` 细粒度仪表盘权限表,覆盖各角色 dashboard 路由
|
||||
- 跨角色访问测试验证:teacher/student/parent 访问其他角色路由均被重定向回各自 dashboard
|
||||
|
||||
### P0-8:list-pagination.tsx 客户端/服务端边界错误 ✅ 已修复(webapp-testing 发现)
|
||||
|
||||
**问题**:`shared/components/ui/list-pagination.tsx` 文件顶部声明 `"use client"`,但导出的 `computePagination()` 和 `paginate()` 纯工具函数被 4 个服务端组件页面直接调用:
|
||||
- `teacher/attendance/page.tsx`
|
||||
- `teacher/homework/assignments/page.tsx`
|
||||
- `teacher/homework/submissions/page.tsx`
|
||||
- `teacher/grades/page.tsx`
|
||||
|
||||
导致 Next.js 16 / React 19 抛出错误:`Attempted to call computePagination() from the server but computePagination is on the client`,页面渲染时触发 ErrorBoundary。
|
||||
|
||||
**修复方案**(已实施):
|
||||
- 移除 `list-pagination.tsx` 的 `"use client"` 指令
|
||||
- `ListPagination` 组件仅使用 `Link`、`Button` 和图标,无需客户端交互,可作为服务端组件渲染
|
||||
- `computePagination` 和 `paginate` 恢复为服务端可调用的纯函数
|
||||
|
||||
---
|
||||
|
||||
## 3.2 P1 较严重问题(短期执行)
|
||||
@@ -1951,34 +2127,35 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
> 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。
|
||||
> ✅ P1-1 已修复:所有跨模块直查已改为通过对方 data-access 接口。
|
||||
|
||||
| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | users | grades | messaging | notifications | lesson-prep | 其他 |
|
||||
|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|-------|--------|-----------|---------------|-------------|------|
|
||||
| **shared** | - | ⟳✅已修复 | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **auth(root)** | ✅ db/lib | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **exams** | ✅ | ✅ | - | - | ✅data-access | - | ✅data-access | ✅data-access | - | - | - | - | - | - | - |
|
||||
| **homework** | ✅ | ✅ | ✅data-access | - | ✅关系 | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - |
|
||||
| **questions** | ✅ | ✅ | - | - | - | ✅data-access | - | - | - | - | - | - | - | - | - |
|
||||
| **textbooks** | ✅ | ✅ | - | - | ✅UI | - | - | - | - | - | - | - | - | - | - |
|
||||
| **classes** | ✅ | ✅ | ✅data-access | ✅data-access | - | - | - | ✅data-access | - | - | - | - | - | - | ✅scheduling(P0-5:data-access-class-schedule 写函数) |
|
||||
| **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - | - |
|
||||
| **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - |
|
||||
| **dashboard** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | ✅data-access | - | - | - | - | - |
|
||||
| **users** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - |
|
||||
| **messaging** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | ✅dispatcher | - | - |
|
||||
| **notifications** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - |
|
||||
| **attendance** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - |
|
||||
| **scheduling** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | ✅data-access | - | - | - | - | - |
|
||||
| **proctoring** | ✅ | ✅ | ✅data-access | - | - | - | - | - | - | ✅data-access | - | - | - | - | - |
|
||||
| **diagnostic** | ✅ | ✅ | ✅data-access | - | ✅data-access | - | ✅data-access | - | - | ✅data-access | - | - | - | - | - |
|
||||
| **parent** | ✅ | ✅ | - | ✅data-access | - | - | ✅data-access | ✅data-access | - | ✅data-access | ✅data-access | - | - | - | - |
|
||||
| **elective** | ✅ | ✅ | - | - | - | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - |
|
||||
| **course-plans** | ✅ | ✅ | - | - | - | - | ✅ | ✅ | - | ✅ | - | - | - | - | - |
|
||||
| **audit** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **announcements** | ✅ | ✅ | - | - | - | - | - | ✅ | - | - | - | - | - | - | - |
|
||||
| **files** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **settings** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | ✅ | - | - | - |
|
||||
| **layout** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | ✅ | - | - | - |
|
||||
| **lesson-preparation** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | - | - | - | - | - | ✅files/ai |
|
||||
| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | users | grades | messaging | notifications | lesson-prep | error-book | 其他 |
|
||||
|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|-------|--------|-----------|---------------|-------------|------------|------|
|
||||
| **shared** | - | ⟳✅已修复 | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **auth(root)** | ✅ db/lib | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **exams** | ✅ | ✅ | - | - | ✅data-access | - | ✅data-access | ✅data-access | - | - | - | - | - | - | - | - |
|
||||
| **homework** | ✅ | ✅ | ✅data-access | - | ✅关系 | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **questions** | ✅ | ✅ | - | - | - | ✅data-access | - | - | - | - | - | - | - | - | - | - |
|
||||
| **textbooks** | ✅ | ✅ | - | - | ✅UI | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **classes** | ✅ | ✅ | ✅data-access | ✅data-access | - | - | - | ✅data-access | - | - | - | - | - | - | - | ✅scheduling(P0-5:data-access-class-schedule 写函数) |
|
||||
| **school** | ✅ | ✅ | - | - | - | - | - | - | - | ⚠️可接受 | - | - | - | - | - | - |
|
||||
| **grades** | ✅ | ✅ | ✅外键 | ✅外键 | - | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **dashboard** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **users** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - | - |
|
||||
| **messaging** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | ✅dispatcher | - | - | - |
|
||||
| **notifications** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - | - |
|
||||
| **attendance** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | - | - | - | - | - | - | - |
|
||||
| **scheduling** | ✅ | ✅ | - | - | - | - | ✅data-access | - | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **proctoring** | ✅ | ✅ | ✅data-access | - | - | - | - | - | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **diagnostic** | ✅ | ✅ | ✅data-access | - | ✅data-access | - | ✅data-access | - | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **parent** | ✅ | ✅ | - | ✅data-access | - | - | ✅data-access | ✅data-access | - | ✅data-access | ✅data-access | - | - | - | - | - |
|
||||
| **elective** | ✅ | ✅ | - | - | - | - | ✅data-access | ✅data-access | - | ✅data-access | - | - | - | - | - | - |
|
||||
| **course-plans** | ✅ | ✅ | - | - | - | - | ✅ | ✅ | - | ✅ | - | - | - | - | - | - |
|
||||
| **audit** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **announcements** | ✅ | ✅ | - | - | - | - | - | ✅ | - | - | - | - | - | - | - | - |
|
||||
| **files** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **settings** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | ✅ | - | - | - | - |
|
||||
| **layout** | ✅ | ✅ | - | - | - | - | - | - | - | - | - | ✅ | - | - | - | - |
|
||||
| **lesson-preparation** | ✅ | ✅ | ✅data-access | ✅data-access | ✅data-access | ✅data-access | ✅data-access | - | - | - | - | - | - | - | - | ✅files/ai |
|
||||
| **error-book** | ✅ | ✅ | - | - | ✅actions | - | ✅data-access | - | - | - | - | - | - | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
@@ -2124,7 +2301,7 @@ createGradeClassAction(prevState: ActionState, formData: FormData): Promise<Acti
|
||||
// + update/delete 各 3 个,共 9 个
|
||||
|
||||
// grades/actions.ts
|
||||
getGradeRecordsAction(params: GetGradeRecordsParams): Promise<ActionState<GradeRecord[]>>
|
||||
getGradeRecordsAction(params: GetGradeRecordsParams): Promise<ActionState<PaginatedGradeRecords>>
|
||||
createGradeRecordAction(prevState: ActionState, formData: FormData): Promise<ActionState>
|
||||
exportGradesAction(params: ExportGradesParams): Promise<ActionState<Buffer>>
|
||||
|
||||
@@ -2156,9 +2333,9 @@ getClassStudents(classId: string): Promise<Student[]>
|
||||
getClassHomeworkInsights(classId: string): Promise<ClassHomeworkInsights> // ✅ P0-7 已修复:通过 homework/data-access-classes 获取数据
|
||||
|
||||
// grades/data-access.ts
|
||||
getGradeRecords(params: GetGradeRecordsParams & { scope: DataScope }): Promise<GradeRecord[]>
|
||||
getStudentGradeSummary(studentId: string): Promise<StudentGradeSummary>
|
||||
getClassRanking(classId: string, examId?: string): Promise<ClassRanking[]>
|
||||
getGradeRecords(params: GetGradeRecordsParams & { scope: DataScope; currentUserId?: string; limit?: number; offset?: number }): Promise<PaginatedGradeRecords>
|
||||
getStudentGradeSummary(studentId: string, scope?: DataScope): Promise<StudentGradeSummary | null>
|
||||
getClassRanking(classId: string, subjectId?: string, examId?: string, scope?: DataScope, currentUserId?: string): Promise<ClassRankingItem[]>
|
||||
|
||||
// scheduling/auto-scheduler.ts(纯函数,标杆)
|
||||
findOptimalSlot(input: FindOptimalSlotInput): ScheduleSlot | null
|
||||
|
||||
File diff suppressed because one or more lines are too long
2266
docs/superpowers/plans/2026-06-22-knowledge-graph.md
Normal file
2266
docs/superpowers/plans/2026-06-22-knowledge-graph.md
Normal file
File diff suppressed because it is too large
Load Diff
338
docs/superpowers/specs/2026-06-22-knowledge-graph-design.md
Normal file
338
docs/superpowers/specs/2026-06-22-knowledge-graph-design.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# 知识图谱重构设计文档
|
||||
|
||||
- **日期**:2026-06-22
|
||||
- **模块**:textbooks
|
||||
- **范围**:知识图谱功能全面重构
|
||||
- **状态**:已批准,待实现
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
教材模块的知识图谱功能([knowledge-graph.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/knowledge-graph.tsx))处于"基本无用"状态:
|
||||
|
||||
- **静态 SVG 树状布局**:仅 `parentId` 父子关系,无前置依赖
|
||||
- **仅显示当前章节**:无法跨章节/全书查看知识体系
|
||||
- **交互单一**:点击节点仅高亮正文,无缩放/平移/拖拽/键盘导航
|
||||
- **信息密度低**:节点只有名称,无关联题目数、无掌握度
|
||||
- **无师生区分**:教师和学生看到相同的图,无学情数据
|
||||
|
||||
### 1.2 同类平台调研
|
||||
|
||||
| 平台 | 核心设计 | 可借鉴点 |
|
||||
|------|---------|---------|
|
||||
| 人教数字教材 | 按学科/章节聚合的层级图,节点关联资源 | 跨章节聚合 + 资源关联 |
|
||||
| Khan Academy | 力导向图,前置依赖边,节点显示掌握度 | 前置依赖 + 掌握度 + 跳转练习 |
|
||||
| 学而思/猿辅导 | 学习路径图,红/黄/绿表示掌握度 | 掌握度色彩 + 路径推荐 |
|
||||
| ClassIn/Seewo | 师生双视角,教师看班级整体 | 师生双视角 |
|
||||
| 洋葱学院 | 章节→知识点→题目三级下钻 | 下钻交互 + 缩放 |
|
||||
|
||||
**共性特征**:① 跨章节/全书视图 ② 前置依赖关系 ③ 掌握度可视化 ④ 关联题目/资源 ⑤ 缩放平移 ⑥ 师生双视角
|
||||
|
||||
### 1.3 已有可复用数据
|
||||
|
||||
- `questionsToKnowledgePoints` 关联表(题目↔知识点多对多)
|
||||
- `knowledgePointMastery` 表(学生掌握度,已被 `diagnostic` 模块填充)
|
||||
- `questions` 模块已支持按 `knowledgePointId` 筛选
|
||||
- `@xyflow/react`(React Flow 12)已在 `lesson-preparation` 模块使用
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **跨章节全书视图**:支持单章节和全书两种范围切换
|
||||
2. **前置依赖关系**:新增数据模型,支持声明任意知识点间的前置依赖
|
||||
3. **掌握度可视化**:学生看个人,教师看班级,红/黄/绿/灰着色
|
||||
4. **关联题目预览**:节点显示关联题目数,详情面板可跳转题目库
|
||||
5. **缩放平移交互**:React Flow 内置画布交互
|
||||
6. **师生双视角**:同一组件,通过 prop 注入不同数据源
|
||||
7. **侧边栏详情面板**:点击节点显示详情,不离开当前页面
|
||||
|
||||
## 3. 数据模型扩展
|
||||
|
||||
### 3.1 新增表:knowledge_point_prerequisites
|
||||
|
||||
```typescript
|
||||
export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequisites", {
|
||||
id: id("id").primaryKey(),
|
||||
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull()
|
||||
.references(() => knowledgePoints.id, { onDelete: "cascade" }),
|
||||
prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull()
|
||||
.references(() => knowledgePoints.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId] }),
|
||||
kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId),
|
||||
prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId),
|
||||
}))
|
||||
```
|
||||
|
||||
**设计说明**:
|
||||
- 多对多自关联表,表达"学习 KP_B 前应先掌握 KP_A"
|
||||
- `knowledgePointId` = 目标知识点,`prerequisiteKpId` = 前置知识点
|
||||
- 联合主键防止重复声明
|
||||
- 级联删除:知识点删除时自动清理关联
|
||||
- 循环依赖检测由 Server Action 层 DFS 校验,拒绝形成环的声明
|
||||
|
||||
### 3.2 不修改的表
|
||||
|
||||
- `knowledgePoints`:已有 `parentId`(树归属)和 `chapterId`(章节归属),不变
|
||||
- `knowledgePointMastery`:已有 `masteryLevel`/`totalQuestions`/`correctQuestions`,不变
|
||||
- `questionsToKnowledgePoints`:不变
|
||||
|
||||
## 4. 架构与模块结构
|
||||
|
||||
### 4.1 新增文件清单
|
||||
|
||||
```
|
||||
src/modules/textbooks/
|
||||
├─ data-access-graph.ts # 新增:图谱专用数据访问
|
||||
├─ components/
|
||||
│ ├─ knowledge-graph.tsx # 重写:React Flow 渲染器
|
||||
│ ├─ graph-node-detail-panel.tsx # 新增:节点详情侧边栏
|
||||
│ ├─ graph-kp-node.tsx # 新增:React Flow 自定义节点
|
||||
│ ├─ graph-prerequisite-edge.tsx # 新增:React Flow 自定义边
|
||||
│ └─ graph-toolbar.tsx # 新增:视图切换/筛选/搜索工具栏
|
||||
└─ hooks/
|
||||
└─ use-graph-data.ts # 新增:图谱数据加载与缓存 Hook
|
||||
```
|
||||
|
||||
### 4.2 修改的文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/shared/db/schema.ts` | 新增 `knowledgePointPrerequisites` 表定义 |
|
||||
| `data-access.ts` | 新增 prerequisite CRUD 函数 |
|
||||
| `actions.ts` | 新增 3 个 Server Action |
|
||||
| `schema.ts` | 新增 prerequisite 声明的 Zod 校验 |
|
||||
| `types.ts` | 新增 `GraphNodeData` / `GraphViewMode` / `KpWithRelations` 等类型 |
|
||||
| `graph-layout.ts` | 重写:调用 dagre,保留纯函数签名 |
|
||||
| `components/textbook-reader.tsx` | 图谱 Tab 接入新组件 |
|
||||
| `i18n/messages/zh-CN/textbooks.json` | 新增 graph.* 翻译键 |
|
||||
| `i18n/messages/en/textbooks.json` | 新增 graph.* 翻译键 |
|
||||
|
||||
### 4.3 数据访问层(data-access-graph.ts)
|
||||
|
||||
```typescript
|
||||
// 全书知识点 + 前置依赖 + 关联题目数,一次查询聚合
|
||||
export async function getKnowledgePointsWithRelations(
|
||||
textbookId: string
|
||||
): Promise<KpWithRelations[]>
|
||||
|
||||
// 学生个人掌握度(按教材范围)
|
||||
export async function getStudentKpMastery(
|
||||
studentId: string,
|
||||
textbookId: string
|
||||
): Promise<Map<string, MasteryInfo>>
|
||||
|
||||
// 班级平均掌握度(教师视角)
|
||||
export async function getClassKpMastery(
|
||||
teacherId: string,
|
||||
textbookId: string
|
||||
): Promise<Map<string, MasteryInfo>>
|
||||
|
||||
// 单个知识点的前置列表
|
||||
export async function getPrerequisitesForKp(
|
||||
kpId: string
|
||||
): Promise<KnowledgePoint[]>
|
||||
```
|
||||
|
||||
**性能考量**:
|
||||
- 全书知识点通常 50-300 个,一次查询无压力
|
||||
- 关联题目数用子查询 `COUNT` 聚合,避免 N+1
|
||||
- 掌握度查询走索引(`mastery_kp_idx` + `mastery_student_idx`)
|
||||
|
||||
### 4.4 Server Actions(actions.ts)
|
||||
|
||||
```typescript
|
||||
// 图谱数据懒加载入口
|
||||
export async function getKnowledgeGraphDataAction(
|
||||
textbookId: string,
|
||||
viewMode: GraphViewMode
|
||||
): Promise<ActionState<KnowledgeGraphData>>
|
||||
|
||||
// 声明前置依赖(含循环检测)
|
||||
export async function createPrerequisiteAction(
|
||||
input: CreatePrerequisiteInput
|
||||
): Promise<ActionState<void>>
|
||||
|
||||
// 删除前置依赖
|
||||
export async function deletePrerequisiteAction(
|
||||
input: DeletePrerequisiteInput
|
||||
): Promise<ActionState<void>>
|
||||
```
|
||||
|
||||
**权限**:
|
||||
- `getKnowledgeGraphDataAction`:`requirePermission(Permissions.TEXTBOOK_READ)`,掌握度数据按当前用户角色过滤
|
||||
- `createPrerequisiteAction` / `deletePrerequisiteAction`:`requirePermission(Permissions.TEXTBOOK_UPDATE)`
|
||||
|
||||
**循环检测**:`createPrerequisiteAction` 中做 DFS,若声明 `A→B` 后从 B 可达 A 则拒绝。
|
||||
|
||||
## 5. 图谱视图与交互
|
||||
|
||||
### 5.1 视图模式
|
||||
|
||||
| 模式 | 数据源 | 节点着色 | 适用角色 |
|
||||
|------|--------|---------|---------|
|
||||
| `structure` | 全书知识点 + parentId + prerequisites | 按章节分色 | 教师/学生 |
|
||||
| `student-mastery` | + 学生个人掌握度 | 红(<60%)/黄(60-85%)/绿(>85%)/灰(未测) | 学生 |
|
||||
| `class-mastery` | + 班级平均掌握度 | 同上但聚合班级数据 | 教师 |
|
||||
|
||||
### 5.2 节点设计(graph-kp-node.tsx)
|
||||
|
||||
- 矩形卡片,宽度 180px,高度自适应
|
||||
- 内容:知识点名称 + 关联题目数徽章 + 掌握度进度条(mastery 模式下)
|
||||
- 双击节点 → 打开右侧详情面板
|
||||
- 单击节点 → 高亮关联节点(前置+后置),其余节点降低透明度
|
||||
- 节点支持拖拽(位置不持久化,切换章节后重新布局)
|
||||
|
||||
### 5.3 边设计
|
||||
|
||||
- `parentId` 关系:实线,无箭头(树归属)
|
||||
- `prerequisite` 关系:虚线 + 箭头(依赖方向)
|
||||
- 选中节点时:关联边高亮,其余边降低透明度
|
||||
|
||||
### 5.4 画布交互
|
||||
|
||||
- React Flow 内置:缩放(滚轮)、平移(拖拽空白)、小地图、键盘导航(Tab/方向键)
|
||||
- 工具栏(`graph-toolbar.tsx`):
|
||||
- 视图模式切换(structure / student-mastery / class-mastery)
|
||||
- 学生角色:仅显示 `structure` + `student-mastery`
|
||||
- 教师角色:显示 `structure` + `class-mastery`(教师看班级整体,不看个人)
|
||||
- 章节筛选(多选下拉,默认全选)
|
||||
- 关键词搜索(高亮匹配节点,非匹配节点降低透明度)
|
||||
- 重置视图按钮
|
||||
|
||||
### 5.5 详情面板(graph-node-detail-panel.tsx)
|
||||
|
||||
- 知识点描述
|
||||
- 掌握度详情(个人/班级,含答题数/正确率)
|
||||
- 关联题目列表(前 5 条 + "查看全部"跳转题目库,带 `?kp=<id>` 查询参数)
|
||||
- 前置知识点列表(可点击跳转)
|
||||
- 后置知识点列表(可点击跳转)
|
||||
- 教师/有权限者:编辑前置依赖入口(添加/删除前置)
|
||||
|
||||
## 6. 数据流与性能
|
||||
|
||||
### 6.1 数据加载策略
|
||||
|
||||
- 图谱数据按教材维度一次性加载(全书知识点 + 依赖 + 题目数聚合)
|
||||
- 掌握度数据按 viewMode 懒加载:切换到 mastery 模式时才请求
|
||||
- 使用 Server Action `getKnowledgeGraphDataAction(textbookId, viewMode)` 统一入口
|
||||
- 客户端缓存:`use-graph-data.ts` 用 `useState` + `useRef` 防重复请求(复用 P2-3 模式)
|
||||
|
||||
### 6.2 布局计算
|
||||
|
||||
- dagre 布局在客户端 `useMemo` 中执行
|
||||
- 知识点量级(50-300)下 dagre 计算时间 <10ms
|
||||
- 布局参数:`rankdir=TB`(从上到下)、`nodesep=40`、`ranksep=90`
|
||||
|
||||
### 6.3 错误处理
|
||||
|
||||
- 图谱数据加载失败 → 复用 `TextbookSectionErrorBoundary`
|
||||
- 掌握度数据缺失 → 节点显示"未测评"灰色状态,不阻断图谱渲染
|
||||
- 前置依赖循环检测 → Server Action 返回结构化错误,前端 toast 提示
|
||||
|
||||
## 7. 权限与 i18n
|
||||
|
||||
### 7.1 权限
|
||||
|
||||
- `TEXTBOOK_READ` — 查看图谱
|
||||
- `TEXTBOOK_UPDATE` — 编辑前置依赖
|
||||
- 掌握度查看:学生只能看自己,教师看班级(由 data-access 层按 `getCurrentStudentUser`/`getCurrentTeacherUser` 过滤)
|
||||
|
||||
### 7.2 i18n 新增键
|
||||
|
||||
```json
|
||||
{
|
||||
"graph": {
|
||||
"viewMode": {
|
||||
"structure": "结构图",
|
||||
"studentMastery": "个人掌握度",
|
||||
"classMastery": "班级掌握度"
|
||||
},
|
||||
"node": {
|
||||
"questions": "题目",
|
||||
"mastery": "掌握度",
|
||||
"prerequisite": "前置",
|
||||
"successor": "后置"
|
||||
},
|
||||
"detail": {
|
||||
"title": "知识点详情",
|
||||
"noDescription": "暂无描述",
|
||||
"viewAllQuestions": "查看全部题目",
|
||||
"editPrerequisite": "编辑前置依赖",
|
||||
"addPrerequisite": "添加前置",
|
||||
"removePrerequisite": "移除",
|
||||
"noPrerequisites": "暂无前置知识点",
|
||||
"noSuccessors": "暂无后置知识点",
|
||||
"masteryNotAssessed": "未测评",
|
||||
"correctRate": "正确率"
|
||||
},
|
||||
"toolbar": {
|
||||
"search": "搜索知识点",
|
||||
"filterByChapter": "按章节筛选",
|
||||
"resetView": "重置视图"
|
||||
},
|
||||
"empty": {
|
||||
"noPrerequisites": "暂无前置依赖关系",
|
||||
"noData": "暂无图谱数据"
|
||||
},
|
||||
"error": {
|
||||
"cyclicDependency": "不能添加循环依赖",
|
||||
"loadFailed": "图谱加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
### 8.1 单元测试
|
||||
|
||||
- `graph-layout.ts`:dagre 集成后布局正确性、空数据、循环容错
|
||||
- `data-access-graph.ts`:聚合查询正确性、权限过滤
|
||||
- 循环依赖检测:DFS 算法单测
|
||||
|
||||
### 8.2 组件测试
|
||||
|
||||
- `graph-kp-node.tsx`:节点渲染、掌握度进度条、徽章
|
||||
- `graph-node-detail-panel.tsx`:详情展示、前置/后置列表、跳转链接
|
||||
- `graph-toolbar.tsx`:视图切换、搜索、筛选
|
||||
|
||||
### 8.3 集成测试
|
||||
|
||||
- 图谱数据加载 → 渲染 → 节点点击 → 详情面板
|
||||
- 视图模式切换 → 掌握度数据懒加载
|
||||
- 前置依赖 CRUD → 图谱边更新
|
||||
|
||||
## 9. 依赖变更
|
||||
|
||||
### 9.1 新增依赖
|
||||
|
||||
- `@dagrejs/dagre` — 分层有向图布局算法(~50KB gzip)
|
||||
|
||||
### 9.2 复用依赖
|
||||
|
||||
- `@xyflow/react`(已在项目中使用)
|
||||
|
||||
## 10. 架构图同步
|
||||
|
||||
实现完成后需同步以下架构文档:
|
||||
|
||||
- `docs/architecture/004_architecture_impact_map.md` — §2.5 教材模块章节
|
||||
- 更新文件清单(新增 6 个文件)
|
||||
- 更新导出函数(新增 4 个 data-access + 3 个 actions)
|
||||
- 更新 knownIssues(移除"P2 图谱方向键导航未实现")
|
||||
- `docs/architecture/005_architecture_data.json` — modules.textbooks 节点
|
||||
- 更新 exports、dbTables(新增 knowledge_point_prerequisites)、dependencyMatrix
|
||||
|
||||
## 11. 非目标(YAGNI)
|
||||
|
||||
以下功能不在本次范围内,后续迭代考虑:
|
||||
|
||||
- 节点位置持久化(拖拽后保存布局)
|
||||
- 学习路径自动推荐
|
||||
- 关联视频/课件资源(当前仅关联题目)
|
||||
- 教材阅读进度跟踪
|
||||
- 学生笔记/标注
|
||||
- 教材导入/导出
|
||||
- 多版本教材对比
|
||||
11
drizzle/0004_calm_sandman.sql
Normal file
11
drizzle/0004_calm_sandman.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `knowledge_point_prerequisites` (
|
||||
`knowledge_point_id` varchar(128) NOT NULL,
|
||||
`prerequisite_kp_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `knowledge_point_prerequisites_knowledge_point_id_prerequisite_kp_id_pk` PRIMARY KEY(`knowledge_point_id`,`prerequisite_kp_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `knowledge_point_prerequisites` ADD CONSTRAINT `kp_prereq_kp_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `knowledge_point_prerequisites` ADD CONSTRAINT `kp_prereq_prereq_fk` FOREIGN KEY (`prerequisite_kp_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `kp_prereq_kp_idx` ON `knowledge_point_prerequisites` (`knowledge_point_id`);--> statement-breakpoint
|
||||
CREATE INDEX `kp_prereq_prereq_idx` ON `knowledge_point_prerequisites` (`prerequisite_kp_id`);
|
||||
48
drizzle/0005_messy_pride.sql
Normal file
48
drizzle/0005_messy_pride.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
CREATE TABLE `error_book_items` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`source_type` enum('exam','homework','manual') NOT NULL DEFAULT 'manual',
|
||||
`source_id` varchar(128),
|
||||
`student_answer` json,
|
||||
`correct_answer` json,
|
||||
`subject_id` varchar(128),
|
||||
`knowledge_point_ids` json,
|
||||
`error_status` enum('new','learning','mastered','archived') NOT NULL DEFAULT 'new',
|
||||
`mastery_level` int NOT NULL DEFAULT 0,
|
||||
`next_review_at` timestamp,
|
||||
`review_interval` int NOT NULL DEFAULT 1,
|
||||
`review_count` int NOT NULL DEFAULT 0,
|
||||
`correct_streak` int NOT NULL DEFAULT 0,
|
||||
`note` text,
|
||||
`error_tags` json,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `error_book_items_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `error_book_reviews` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`item_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`review_result` enum('again','hard','good','easy') NOT NULL,
|
||||
`reviewed_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`new_interval` int,
|
||||
`new_mastery_level` int,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `error_book_reviews_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `error_book_items` ADD CONSTRAINT `error_book_items_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `error_book_items` ADD CONSTRAINT `error_book_items_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `error_book_reviews` ADD CONSTRAINT `error_book_reviews_item_id_error_book_items_id_fk` FOREIGN KEY (`item_id`) REFERENCES `error_book_items`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `error_book_reviews` ADD CONSTRAINT `error_book_reviews_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_student_idx` ON `error_book_items` (`student_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_student_status_idx` ON `error_book_items` (`student_id`,`error_status`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_student_review_idx` ON `error_book_items` (`student_id`,`next_review_at`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_question_idx` ON `error_book_items` (`question_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_subject_idx` ON `error_book_items` (`subject_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_item_source_idx` ON `error_book_items` (`source_type`,`source_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_review_item_idx` ON `error_book_reviews` (`item_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_review_student_idx` ON `error_book_reviews` (`student_id`);--> statement-breakpoint
|
||||
CREATE INDEX `eb_review_student_reviewed_idx` ON `error_book_reviews` (`student_id`,`reviewed_at`);
|
||||
7644
drizzle/meta/0004_snapshot.json
Normal file
7644
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8001
drizzle/meta/0005_snapshot.json
Normal file
8001
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,27 @@
|
||||
"when": 1781789296745,
|
||||
"tag": "0002_tiny_lionheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1782118370256,
|
||||
"tag": "0003_diagnostic_student_nullable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1782136411839,
|
||||
"tag": "0004_calm_sandman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1782141546400,
|
||||
"tag": "0005_messy_pride",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import type { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access";
|
||||
import {
|
||||
createTextbook,
|
||||
createChapter,
|
||||
@@ -17,7 +19,15 @@ import {
|
||||
reorderChapters,
|
||||
verifyChapterBelongsToTextbook,
|
||||
verifyKnowledgePointBelongsToTextbook,
|
||||
getKnowledgePointsByChapterId,
|
||||
createPrerequisite,
|
||||
deletePrerequisite,
|
||||
getPrerequisiteEdgesForTextbook,
|
||||
} from "./data-access";
|
||||
import {
|
||||
getKnowledgePointsWithRelations,
|
||||
getStudentKpMastery,
|
||||
} from "./data-access-graph";
|
||||
import {
|
||||
CreateTextbookSchema,
|
||||
UpdateTextbookSchema,
|
||||
@@ -25,7 +35,12 @@ import {
|
||||
UpdateChapterContentSchema,
|
||||
CreateKnowledgePointSchema,
|
||||
UpdateKnowledgePointSchema,
|
||||
CreatePrerequisiteSchema,
|
||||
DeletePrerequisiteSchema,
|
||||
} from "./schema";
|
||||
import { hasCycleAfterAddingEdge } from "./utils";
|
||||
import type { GraphViewMode, KnowledgeGraphData, KnowledgePoint, MasteryInfo } from "./types";
|
||||
import { handleActionError } from "@/shared/lib/action-utils";
|
||||
|
||||
const getStringValue = (formData: FormData, key: string): string => {
|
||||
const value = formData.get(key)
|
||||
@@ -39,20 +54,18 @@ export async function reorderChaptersAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
// P0-4 资源归属校验:防止越权操作其他教材的章节
|
||||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||||
if (!belongs) {
|
||||
return { success: false, message: "Chapter does not belong to this textbook" };
|
||||
return { success: false, message: t("chapterNotBelong") };
|
||||
}
|
||||
await reorderChapters(chapterId, newIndex, parentId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapters reordered successfully" };
|
||||
return { success: true, message: t("chaptersReordered") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to reorder chapters" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +73,7 @@ export async function createTextbookAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = CreateTextbookSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
@@ -70,7 +84,7 @@ export async function createTextbookAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
message: t("fillRequired"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -81,16 +95,10 @@ export async function createTextbookAction(
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook created successfully.",
|
||||
message: t("createSuccess"),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create textbook.",
|
||||
};
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +107,7 @@ export async function updateTextbookAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = UpdateTextbookSchema.safeParse({
|
||||
id: textbookId,
|
||||
title: getStringValue(formData, "title"),
|
||||
@@ -110,7 +119,7 @@ export async function updateTextbookAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
message: t("fillRequired"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -121,16 +130,10 @@ export async function updateTextbookAction(
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook updated successfully.",
|
||||
message: t("updateSuccess"),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update textbook.",
|
||||
};
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,21 +141,16 @@ export async function deleteTextbookAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
await deleteTextbook(textbookId);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook deleted successfully.",
|
||||
message: t("deleteSuccess"),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete textbook.",
|
||||
};
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +160,7 @@ export async function createChapterAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = CreateChapterSchema.safeParse({
|
||||
textbookId,
|
||||
title: getStringValue(formData, "title"),
|
||||
@@ -172,7 +171,7 @@ export async function createChapterAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Title is required",
|
||||
message: t("titleRequired"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -181,12 +180,9 @@ export async function createChapterAction(
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createChapter(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter created successfully" };
|
||||
return { success: true, message: t("chapterCreateSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to create chapter" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +191,7 @@ export async function updateChapterContentAction(
|
||||
content: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = UpdateChapterContentSchema.safeParse({
|
||||
chapterId,
|
||||
content,
|
||||
@@ -203,7 +200,7 @@ export async function updateChapterContentAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid chapter content data",
|
||||
message: t("invalidContent"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -213,16 +210,13 @@ export async function updateChapterContentAction(
|
||||
// P0-4 资源归属校验
|
||||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||||
if (!belongs) {
|
||||
return { success: false, message: "Chapter does not belong to this textbook" };
|
||||
return { success: false, message: t("chapterNotBelong") };
|
||||
}
|
||||
await updateChapterContent(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Content updated successfully" };
|
||||
return { success: true, message: t("contentUpdateSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to update content" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,20 +225,18 @@ export async function deleteChapterAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
// P0-4 资源归属校验
|
||||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||||
if (!belongs) {
|
||||
return { success: false, message: "Chapter does not belong to this textbook" };
|
||||
return { success: false, message: t("chapterNotBelong") };
|
||||
}
|
||||
await deleteChapter(chapterId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter deleted successfully" };
|
||||
return { success: true, message: t("chapterDeleteSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to delete chapter" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +246,7 @@ export async function createKnowledgePointAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = CreateKnowledgePointSchema.safeParse({
|
||||
name: getStringValue(formData, "name"),
|
||||
description: getStringValue(formData, "description") || undefined,
|
||||
@@ -264,7 +257,7 @@ export async function createKnowledgePointAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
message: t("nameRequired"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -274,16 +267,13 @@ export async function createKnowledgePointAction(
|
||||
// P0-4 资源归属校验:确保 chapter 属于该 textbook,防止跨教材越权创建知识点
|
||||
const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
|
||||
if (!chapterBelongs) {
|
||||
return { success: false, message: "Chapter does not belong to this textbook" };
|
||||
return { success: false, message: t("chapterNotBelong") };
|
||||
}
|
||||
await createKnowledgePoint(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
return { success: true, message: t("kpCreateSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to create knowledge point" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,20 +282,18 @@ export async function deleteKnowledgePointAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
// P0-4 资源归属校验
|
||||
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
|
||||
if (!belongs) {
|
||||
return { success: false, message: "Knowledge point does not belong to this textbook" };
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
await deleteKnowledgePoint(kpId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point deleted successfully" };
|
||||
return { success: true, message: t("kpDeleteSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to delete knowledge point" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +303,7 @@ export async function updateKnowledgePointAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
const parsed = UpdateKnowledgePointSchema.safeParse({
|
||||
id: kpId,
|
||||
name: getStringValue(formData, "name"),
|
||||
@@ -325,7 +314,7 @@ export async function updateKnowledgePointAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
message: t("nameRequired"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -335,15 +324,161 @@ export async function updateKnowledgePointAction(
|
||||
// P0-4 资源归属校验
|
||||
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
|
||||
if (!belongs) {
|
||||
return { success: false, message: "Knowledge point does not belong to this textbook" };
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
await updateKnowledgePoint(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point updated successfully" };
|
||||
return { success: true, message: t("kpUpdateSuccess") };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to update knowledge point" };
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-3 知识点懒加载:按章节 ID 获取知识点。
|
||||
*
|
||||
* 用于详情页切换章节时按需加载,避免一次性拉取整本教材所有知识点。
|
||||
* 需 TEXTBOOK_READ 权限。
|
||||
*/
|
||||
export async function getKnowledgePointsByChapterAction(
|
||||
chapterId: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState<KnowledgePoint[]>> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_READ);
|
||||
// P0-4 资源归属校验:确保 chapter 属于该 textbook
|
||||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
|
||||
if (!belongs) {
|
||||
return { success: false, message: t("chapterNotBelong") };
|
||||
}
|
||||
const knowledgePoints = await getKnowledgePointsByChapterId(chapterId);
|
||||
return { success: true, message: t("ok"), data: knowledgePoints };
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 知识图谱 Actions =====
|
||||
|
||||
/**
|
||||
* 获取知识图谱数据。
|
||||
*
|
||||
* - structure 模式:仅返回知识点+依赖+题目数
|
||||
* - student-mastery 模式:附加当前学生掌握度
|
||||
* - class-mastery 模式:附加班级平均掌握度(仅教师可用)
|
||||
*/
|
||||
export async function getKnowledgeGraphDataAction(
|
||||
textbookId: string,
|
||||
viewMode: GraphViewMode,
|
||||
): Promise<ActionState<KnowledgeGraphData>> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_READ);
|
||||
|
||||
const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId);
|
||||
const masteryMap: Record<string, MasteryInfo> = {};
|
||||
|
||||
if (viewMode === "student-mastery") {
|
||||
const student = await getCurrentStudentUser();
|
||||
if (student) {
|
||||
const mastery = await getStudentKpMastery(student.id, textbookId);
|
||||
for (const [kpId, info] of mastery) {
|
||||
masteryMap[kpId] = info;
|
||||
}
|
||||
}
|
||||
} else if (viewMode === "class-mastery") {
|
||||
// 简化实现:暂不获取班级学生列表,返回空 masteryMap
|
||||
// 后续迭代可通过 classes 模块获取教师所带班级学生 ID,
|
||||
// 再从 data-access-graph 导入 getClassKpMastery 并调用
|
||||
// getClassKpMastery(studentIds, textbookId) 计算班级平均掌握度
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: t("ok"),
|
||||
data: { knowledgePoints: knowledgePointsData, masteryMap, viewMode },
|
||||
};
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明前置依赖(含循环检测)。
|
||||
*/
|
||||
export async function createPrerequisiteAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
|
||||
const knowledgePointId = getStringValue(formData, "knowledgePointId");
|
||||
const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId");
|
||||
const textbookId = getStringValue(formData, "textbookId");
|
||||
|
||||
const parsed = CreatePrerequisiteSchema.safeParse({
|
||||
knowledgePointId,
|
||||
prerequisiteKpId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("invalidInput") };
|
||||
}
|
||||
|
||||
// 归属校验
|
||||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||
parsed.data.knowledgePointId,
|
||||
textbookId,
|
||||
);
|
||||
if (!kpBelongs) {
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
|
||||
// 循环检测
|
||||
const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId);
|
||||
if (hasCycleAfterAddingEdge(
|
||||
existingEdges,
|
||||
parsed.data.knowledgePointId,
|
||||
parsed.data.prerequisiteKpId,
|
||||
)) {
|
||||
return { success: false, message: t("cyclicDependency") };
|
||||
}
|
||||
|
||||
await createPrerequisite(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: t("prerequisiteCreated") };
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前置依赖。
|
||||
*/
|
||||
export async function deletePrerequisiteAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const t = await getTranslations("textbooks.action");
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
|
||||
const knowledgePointId = getStringValue(formData, "knowledgePointId");
|
||||
const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId");
|
||||
const textbookId = getStringValue(formData, "textbookId");
|
||||
|
||||
const parsed = DeletePrerequisiteSchema.safeParse({
|
||||
knowledgePointId,
|
||||
prerequisiteKpId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("invalidInput") };
|
||||
}
|
||||
|
||||
await deletePrerequisite(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: t("prerequisiteDeleted") };
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
91
src/modules/textbooks/components/graph-kp-node.tsx
Normal file
91
src/modules/textbooks/components/graph-kp-node.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { GraphNodeData, MasteryLevel } from "../types"
|
||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||
|
||||
/** 根据掌握度计算色彩等级 */
|
||||
function getMasteryLevel(mastery: number | null): MasteryLevel {
|
||||
if (mastery === null) return "unassessed"
|
||||
if (mastery < 60) return "low"
|
||||
if (mastery < 85) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
const MASTERY_COLORS: Record<MasteryLevel, string> = {
|
||||
low: "border-red-500 bg-red-50 dark:bg-red-950/30",
|
||||
medium: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30",
|
||||
high: "border-green-500 bg-green-50 dark:bg-green-950/30",
|
||||
unassessed: "border-border bg-card",
|
||||
}
|
||||
|
||||
const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
|
||||
low: "bg-red-500",
|
||||
medium: "bg-yellow-500",
|
||||
high: "bg-green-500",
|
||||
unassessed: "bg-muted",
|
||||
}
|
||||
|
||||
function GraphKpNodeComponent({ data, selected }: NodeProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const nodeData = data as unknown as GraphLayoutNodeData
|
||||
const { kp } = nodeData
|
||||
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
|
||||
const mastery = graphData?.mastery ?? null
|
||||
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
||||
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 px-3 py-2 shadow-sm transition-all",
|
||||
MASTERY_COLORS[masteryLevel],
|
||||
selected && "ring-2 ring-primary ring-offset-1",
|
||||
graphData?.isHighlighted && "ring-2 ring-primary",
|
||||
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
|
||||
)}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<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>
|
||||
{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")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMastery && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1">
|
||||
<span>{t("graph.node.mastery")}</span>
|
||||
<span>
|
||||
{mastery ? `${Math.round(mastery.masteryLevel)}%` : t("graph.detail.masteryNotAssessed")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all", MASTERY_BAR_COLORS[masteryLevel])}
|
||||
style={{ width: `${mastery?.masteryLevel ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kp.chapterTitle && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground truncate">
|
||||
{kp.chapterTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="opacity-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphKpNode = memo(GraphKpNodeComponent)
|
||||
182
src/modules/textbooks/components/graph-node-detail-panel.tsx
Normal file
182
src/modules/textbooks/components/graph-node-detail-panel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { X, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import type { KpWithRelations, MasteryInfo } from "../types"
|
||||
|
||||
interface GraphNodeDetailPanelProps {
|
||||
kp: KpWithRelations
|
||||
mastery: MasteryInfo | null
|
||||
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
|
||||
onRemovePrerequisite: (prereqId: string) => void
|
||||
}
|
||||
|
||||
export function GraphNodeDetailPanel({
|
||||
kp,
|
||||
mastery,
|
||||
prerequisites,
|
||||
successors,
|
||||
canEdit,
|
||||
onClose,
|
||||
onJumpToKp,
|
||||
onAddPrerequisite,
|
||||
onRemovePrerequisite,
|
||||
}: GraphNodeDetailPanelProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
const correctRate = mastery && mastery.totalQuestions > 0
|
||||
? Math.round((mastery.correctQuestions / mastery.totalQuestions) * 100)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l bg-background">
|
||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
||||
<h3 className="text-sm font-semibold truncate">{t("graph.detail.title")}</h3>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-3 space-y-4">
|
||||
{/* 知识点名称 */}
|
||||
<div>
|
||||
<h4 className="text-base font-medium">{kp.name}</h4>
|
||||
{kp.chapterTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{kp.chapterTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("graph.detail.title")}
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
{kp.description || t("graph.detail.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 掌握度 */}
|
||||
{mastery && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("graph.node.mastery")}
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("graph.detail.correctRate")}</span>
|
||||
<span>{correctRate !== null ? `${correctRate}%` : t("graph.detail.masteryNotAssessed")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("graph.detail.totalQuestions")}</span>
|
||||
<span>{mastery.totalQuestions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 关联题目 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">
|
||||
{t("graph.node.questions")} ({kp.questionCount})
|
||||
</h5>
|
||||
{kp.questionCount > 0 && (
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<Link href={`/teacher/questions?kp=${kp.id}`}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
{t("graph.detail.viewAllQuestions")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 前置知识点 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">
|
||||
{t("graph.node.prerequisite")} ({prerequisites.length})
|
||||
</h5>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onAddPrerequisite}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t("graph.detail.addPrerequisite")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{prerequisites.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("graph.detail.noPrerequisites")}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{prerequisites.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs justify-start flex-1"
|
||||
onClick={() => onJumpToKp(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemovePrerequisite(p.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 后置知识点 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("graph.node.successor")} ({successors.length})
|
||||
</h5>
|
||||
{successors.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("graph.detail.noSuccessors")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{successors.map((s) => (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-accent text-xs"
|
||||
onClick={() => onJumpToKp(s.id)}
|
||||
>
|
||||
{s.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/modules/textbooks/components/graph-prerequisite-edge.tsx
Normal file
45
src/modules/textbooks/components/graph-prerequisite-edge.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function GraphPrerequisiteEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const isHighlighted = (data as { isHighlighted?: boolean } | undefined)?.isHighlighted ?? false
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isHighlighted ? "opacity-100" : "opacity-30",
|
||||
)}
|
||||
style={{
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "6 4",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphPrerequisiteEdge = memo(GraphPrerequisiteEdgeComponent)
|
||||
86
src/modules/textbooks/components/graph-toolbar.tsx
Normal file
86
src/modules/textbooks/components/graph-toolbar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Search, RotateCcw } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import type { GraphViewMode } from "../types"
|
||||
|
||||
interface GraphToolbarProps {
|
||||
viewMode: GraphViewMode
|
||||
onViewModeChange: (mode: GraphViewMode) => void
|
||||
availableViewModes: GraphViewMode[]
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
onResetView: () => void
|
||||
}
|
||||
|
||||
const ALL_VIEW_MODES: readonly GraphViewMode[] = [
|
||||
"structure",
|
||||
"student-mastery",
|
||||
"class-mastery",
|
||||
]
|
||||
|
||||
function isGraphViewMode(value: string): value is GraphViewMode {
|
||||
return ALL_VIEW_MODES.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function GraphToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
availableViewModes,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onResetView,
|
||||
}: GraphToolbarProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
const handleValueChange = (v: string): void => {
|
||||
if (isGraphViewMode(v)) {
|
||||
onViewModeChange(v)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
|
||||
<Select value={viewMode} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableViewModes.includes("structure") && (
|
||||
<SelectItem value="structure">{t("graph.viewMode.structure")}</SelectItem>
|
||||
)}
|
||||
{availableViewModes.includes("student-mastery") && (
|
||||
<SelectItem value="student-mastery">{t("graph.viewMode.studentMastery")}</SelectItem>
|
||||
)}
|
||||
{availableViewModes.includes("class-mastery") && (
|
||||
<SelectItem value="class-mastery">{t("graph.viewMode.classMastery")}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("graph.toolbar.search")}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 px-2" onClick={onResetView}>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
|
||||
import { Share2 } from "lucide-react"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||
import { computeGraphLayout } from "../graph-layout"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import { GraphKpNode } from "./graph-kp-node"
|
||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||
import { GraphToolbar } from "./graph-toolbar"
|
||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
||||
|
||||
const nodeTypes = { kpNode: GraphKpNode }
|
||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||
|
||||
/** 章节颜色调色板 */
|
||||
const CHAPTER_COLORS = [
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
|
||||
]
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
selectedId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
textbookId: string
|
||||
/** 初始视图模式,默认 structure */
|
||||
initialViewMode?: GraphViewMode
|
||||
}
|
||||
|
||||
export function KnowledgeGraph({
|
||||
knowledgePoints,
|
||||
selectedId,
|
||||
onHighlight,
|
||||
}: KnowledgeGraphProps) {
|
||||
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const { hasPermission } = usePermission()
|
||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||
|
||||
if (knowledgePoints.length === 0) {
|
||||
const { data, isLoading, error } = useGraphData(textbookId, viewMode)
|
||||
|
||||
const availableViewModes: GraphViewMode[] = isTeacher
|
||||
? ["structure", "class-mastery"]
|
||||
: ["structure", "student-mastery"]
|
||||
|
||||
// 章节颜色映射
|
||||
const chapterColorMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
if (!data) return map
|
||||
const chapterIds = [...new Set(
|
||||
data.knowledgePoints
|
||||
.map((kp) => kp.chapterId)
|
||||
.filter((id): id is string => id !== null),
|
||||
)]
|
||||
chapterIds.forEach((id, index) => {
|
||||
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
|
||||
})
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
return computeGraphLayout(data.knowledgePoints)
|
||||
}, [data])
|
||||
|
||||
// 搜索高亮
|
||||
const matchedIds = useMemo(() => {
|
||||
if (!searchText || !data) return new Set<string>()
|
||||
const searchLower = searchText.toLowerCase()
|
||||
return new Set(
|
||||
data.knowledgePoints
|
||||
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
|
||||
.map((kp) => kp.id),
|
||||
)
|
||||
}, [searchText, data])
|
||||
|
||||
// 关联节点高亮(选中节点的前置+后置)
|
||||
const relatedIds = useMemo(() => {
|
||||
if (!selectedKpId || !data) return new Set<string>()
|
||||
const related = new Set<string>([selectedKpId])
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (selectedKp) {
|
||||
for (const id of selectedKp.prerequisiteIds) related.add(id)
|
||||
for (const kp of data.knowledgePoints) {
|
||||
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
|
||||
}
|
||||
}
|
||||
return related
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
|
||||
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (!selectedKp) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 组装 React Flow nodes
|
||||
const rfNodes: Node[] = useMemo(() => {
|
||||
return layout.nodes.map((node) => {
|
||||
const kp = node.data.kp
|
||||
const mastery = data?.masteryMap[kp.id] ?? null
|
||||
const isSelected = selectedKpId === node.id
|
||||
const isHighlighted = !searchText
|
||||
? (selectedKpId === null || relatedIds.has(node.id))
|
||||
: matchedIds.has(node.id)
|
||||
|
||||
const graphData: GraphNodeData = {
|
||||
kp,
|
||||
mastery,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, graphData },
|
||||
selected: isSelected,
|
||||
}
|
||||
})
|
||||
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
|
||||
|
||||
// 组装 React Flow edges
|
||||
const rfEdges: Edge[] = useMemo(() => {
|
||||
return layout.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
|
||||
},
|
||||
}))
|
||||
}, [layout, selectedKpId, relatedIds])
|
||||
|
||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
||||
setSelectedKpId(node.id)
|
||||
}, [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
reactFlow.fitView({ duration: 300 })
|
||||
setSearchText("")
|
||||
setSelectedKpId(null)
|
||||
}, [reactFlow])
|
||||
|
||||
const onJumpToKp = useCallback((kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||
}, [reactFlow])
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.emptyKnowledge")}
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("reader.loadingKnowledge")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-4">
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label={t("reader.tabs.graph")}
|
||||
className="mx-auto"
|
||||
>
|
||||
<title>{t("reader.tabs.graph")}</title>
|
||||
{/* 边 */}
|
||||
{layout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={1.5}
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("graph.error.loadFailed")}
|
||||
description={error}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 节点 */}
|
||||
{layout.nodes.map((node) => {
|
||||
const isSelected = selectedId === node.id
|
||||
return (
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
|
||||
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHighlight(node.id)}
|
||||
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
|
||||
}`}
|
||||
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="line-clamp-2">{node.name}</span>
|
||||
</button>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
}
|
||||
|
||||
if (!data || data.knowledgePoints.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("reader.emptyKnowledge")}
|
||||
description={t("reader.emptyKnowledgeDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
|
||||
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<GraphToolbar
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
availableViewModes={availableViewModes}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="!bg-background !border !rounded-lg" />
|
||||
<MiniMap
|
||||
className="!bg-background !border !rounded-lg"
|
||||
nodeColor={(node) => {
|
||||
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
|
||||
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
|
||||
return graphData?.chapterColor ?? "#6b7280"
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedKp && (
|
||||
<div className="w-[300px] shrink-0">
|
||||
<GraphNodeDetailPanel
|
||||
kp={selectedKp}
|
||||
mastery={selectedMastery}
|
||||
prerequisites={prerequisites}
|
||||
successors={successors}
|
||||
canEdit={canEdit}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setSelectedKpId(null)}
|
||||
onJumpToKp={onJumpToKp}
|
||||
onAddPrerequisite={() => {
|
||||
// 后续迭代:打开添加前置对话框
|
||||
}}
|
||||
onRemovePrerequisite={(_prereqId: string) => {
|
||||
// 后续迭代:调用 deletePrerequisiteAction
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeGraph(props: KnowledgeGraphProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<KnowledgeGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useEffect, type ReactNode } from "react"
|
||||
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Tag, List, Share2 } from "lucide-react"
|
||||
import { Tag, List, Share2, Menu } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { updateChapterContentAction } from "../actions"
|
||||
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,
|
||||
@@ -33,20 +34,30 @@ import {
|
||||
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 } from "../utils"
|
||||
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
||||
|
||||
export interface TextbookReaderProps {
|
||||
chapters: Chapter[]
|
||||
knowledgePoints?: KnowledgePoint[]
|
||||
/**
|
||||
* 教材 ID,用于按章节懒加载知识点(P2-3)。
|
||||
* 必传,否则知识点面板将始终为空。
|
||||
*/
|
||||
textbookId: string
|
||||
/**
|
||||
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
|
||||
* 保留 prop 仅为向后兼容,传入值会被忽略。
|
||||
* @deprecated 改用权限系统自动判断
|
||||
*/
|
||||
canEdit?: boolean
|
||||
textbookId?: string
|
||||
/**
|
||||
* 题目创建器渲染函数(P0-1 解耦)。
|
||||
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
||||
@@ -57,7 +68,6 @@ export interface TextbookReaderProps {
|
||||
|
||||
export function TextbookReader({
|
||||
chapters,
|
||||
knowledgePoints = [],
|
||||
textbookId,
|
||||
renderQuestionCreator,
|
||||
}: TextbookReaderProps) {
|
||||
@@ -71,6 +81,8 @@ export function TextbookReader({
|
||||
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("")
|
||||
@@ -92,10 +104,42 @@ export function TextbookReader({
|
||||
const selected = chapterId ? index.get(chapterId) ?? null : null
|
||||
const selectedId = selected?.id ?? null
|
||||
|
||||
const currentChapterKPs = useMemo(() => {
|
||||
if (!selectedId) return []
|
||||
return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
|
||||
}, [knowledgePoints, selectedId])
|
||||
// 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,
|
||||
@@ -137,8 +181,8 @@ export function TextbookReader({
|
||||
const handleSaveContent = async () => {
|
||||
if (!selectedId || !textbookId) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -147,6 +191,12 @@ export function TextbookReader({
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save chapter content", e)
|
||||
toast.error("Failed to save content")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
@@ -160,40 +210,35 @@ export function TextbookReader({
|
||||
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 ""
|
||||
let content = effectiveContent
|
||||
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
|
||||
|
||||
for (const kp of sortedKPs) {
|
||||
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`(${escapedName})`, "gi")
|
||||
content = content.replace(regex, `[$1](#kp-${kp.id})`)
|
||||
}
|
||||
|
||||
return content
|
||||
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
|
||||
}, [effectiveContent, currentChapterKPs])
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedKpId) {
|
||||
if (!highlightedKpId) return
|
||||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||||
if (el) {
|
||||
if (!el) return
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||||
}, 2000)
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [highlightedKpId])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
||||
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
||||
// 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">
|
||||
@@ -210,7 +255,7 @@ export function TextbookReader({
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||||
<TabsTrigger value="graph" className="gap-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
{t("reader.tabs.graph")}
|
||||
</TabsTrigger>
|
||||
@@ -244,8 +289,15 @@ export function TextbookReader({
|
||||
retryLabel={t("error.retry")}
|
||||
>
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.selectChapterKnowledge")}
|
||||
<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
|
||||
@@ -274,23 +326,49 @@ export function TextbookReader({
|
||||
fallbackDescription={t("error.loadFailedDesc")}
|
||||
retryLabel={t("error.retry")}
|
||||
>
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.selectChapterGraph")}
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgeGraph
|
||||
knowledgePoints={currentChapterKPs}
|
||||
selectedId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
/>
|
||||
)}
|
||||
<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}>
|
||||
<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="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
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">
|
||||
{selected.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -334,7 +412,6 @@ export function TextbookReader({
|
||||
editContent={editContent}
|
||||
setEditContent={setEditContent}
|
||||
canEdit={canEdit}
|
||||
knowledgePoints={currentChapterKPs}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
||||
@@ -342,10 +419,7 @@ export function TextbookReader({
|
||||
onPointerDown={handleContentPointerDown}
|
||||
onContextMenuChange={handleContextMenuChange}
|
||||
selectedText={selectedText}
|
||||
createDialogOpen={createDialogOpen}
|
||||
setCreateDialogOpen={setCreateDialogOpen}
|
||||
isCreating={isCreating}
|
||||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
||||
startEditing={startEditing}
|
||||
cancelEditing={() => setIsEditing(false)}
|
||||
saveContent={handleSaveContent}
|
||||
|
||||
203
src/modules/textbooks/data-access-graph.ts
Normal file
203
src/modules/textbooks/data-access-graph.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
chapters,
|
||||
knowledgePoints,
|
||||
knowledgePointPrerequisites,
|
||||
questionsToKnowledgePoints,
|
||||
knowledgePointMastery,
|
||||
} from "@/shared/db/schema"
|
||||
import type { KpWithRelations, MasteryInfo } from "./types"
|
||||
|
||||
/**
|
||||
* 获取教材下全书知识点(含前置依赖 + 关联题目数 + 章节标题)。
|
||||
*
|
||||
* 一次查询聚合,避免 N+1。
|
||||
*/
|
||||
export const getKnowledgePointsWithRelations = cache(async (
|
||||
textbookId: string,
|
||||
): Promise<KpWithRelations[]> => {
|
||||
// 1. 查询全书知识点 + 章节标题
|
||||
const kpRows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
description: knowledgePoints.description,
|
||||
parentId: knowledgePoints.parentId,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
level: knowledgePoints.level,
|
||||
order: knowledgePoints.order,
|
||||
chapterTitle: chapters.title,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.where(eq(chapters.textbookId, textbookId))
|
||||
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
|
||||
|
||||
if (kpRows.length === 0) return []
|
||||
|
||||
const kpIds = kpRows.map((r) => r.id)
|
||||
|
||||
// 2. 查询关联题目数(批量聚合)
|
||||
const questionCountRows = await db
|
||||
.select({
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
count: count(),
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
.groupBy(questionsToKnowledgePoints.knowledgePointId)
|
||||
|
||||
const questionCountMap = new Map<string, number>()
|
||||
for (const r of questionCountRows) {
|
||||
questionCountMap.set(r.knowledgePointId, Number(r.count))
|
||||
}
|
||||
|
||||
// 3. 查询前置依赖(批量)
|
||||
const prereqRows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
|
||||
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
|
||||
})
|
||||
.from(knowledgePointPrerequisites)
|
||||
.where(inArray(knowledgePointPrerequisites.knowledgePointId, kpIds))
|
||||
|
||||
const prereqMap = new Map<string, string[]>()
|
||||
for (const r of prereqRows) {
|
||||
const arr = prereqMap.get(r.knowledgePointId) ?? []
|
||||
arr.push(r.prerequisiteKpId)
|
||||
prereqMap.set(r.knowledgePointId, arr)
|
||||
}
|
||||
|
||||
// 4. 组装结果
|
||||
return kpRows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
parentId: r.parentId,
|
||||
chapterId: r.chapterId,
|
||||
level: r.level ?? 0,
|
||||
order: r.order ?? 0,
|
||||
chapterTitle: r.chapterTitle,
|
||||
questionCount: questionCountMap.get(r.id) ?? 0,
|
||||
prerequisiteIds: prereqMap.get(r.id) ?? [],
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取学生在某教材下所有知识点的掌握度。
|
||||
*/
|
||||
export const getStudentKpMastery = cache(async (
|
||||
studentId: string,
|
||||
textbookId: string,
|
||||
): Promise<Map<string, MasteryInfo>> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
masteryLevel: knowledgePointMastery.masteryLevel,
|
||||
totalQuestions: knowledgePointMastery.totalQuestions,
|
||||
correctQuestions: knowledgePointMastery.correctQuestions,
|
||||
lastAssessedAt: knowledgePointMastery.lastAssessedAt,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.where(and(
|
||||
eq(knowledgePointMastery.studentId, studentId),
|
||||
eq(chapters.textbookId, textbookId),
|
||||
))
|
||||
|
||||
const map = new Map<string, MasteryInfo>()
|
||||
for (const r of rows) {
|
||||
map.set(r.knowledgePointId, {
|
||||
masteryLevel: Number(r.masteryLevel),
|
||||
totalQuestions: r.totalQuestions,
|
||||
correctQuestions: r.correctQuestions,
|
||||
lastAssessedAt: r.lastAssessedAt,
|
||||
})
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。
|
||||
*
|
||||
* @param studentIds 班级学生 ID 列表
|
||||
* @param textbookId 教材 ID
|
||||
*/
|
||||
export const getClassKpMastery = cache(async (
|
||||
studentIds: string[],
|
||||
textbookId: string,
|
||||
): Promise<Map<string, MasteryInfo>> => {
|
||||
if (studentIds.length === 0) return new Map()
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
avgMastery: sql<number>`AVG(${knowledgePointMastery.masteryLevel})`,
|
||||
totalQuestions: sql<number>`SUM(${knowledgePointMastery.totalQuestions})`,
|
||||
correctQuestions: sql<number>`SUM(${knowledgePointMastery.correctQuestions})`,
|
||||
lastAssessedAt: sql<Date>`MAX(${knowledgePointMastery.lastAssessedAt})`,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.where(and(
|
||||
inArray(knowledgePointMastery.studentId, studentIds),
|
||||
eq(chapters.textbookId, textbookId),
|
||||
))
|
||||
.groupBy(knowledgePointMastery.knowledgePointId)
|
||||
|
||||
const map = new Map<string, MasteryInfo>()
|
||||
for (const r of rows) {
|
||||
map.set(r.knowledgePointId, {
|
||||
masteryLevel: Number(r.avgMastery),
|
||||
totalQuestions: Number(r.totalQuestions),
|
||||
correctQuestions: Number(r.correctQuestions),
|
||||
lastAssessedAt: r.lastAssessedAt,
|
||||
})
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取某个知识点的前置依赖列表(含知识点详情)。
|
||||
*/
|
||||
export const getPrerequisitesForKp = cache(async (
|
||||
kpId: string,
|
||||
): Promise<{ id: string; name: string; description: string | null }[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
description: knowledgePoints.description,
|
||||
})
|
||||
.from(knowledgePointPrerequisites)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.prerequisiteKpId))
|
||||
.where(eq(knowledgePointPrerequisites.knowledgePointId, kpId))
|
||||
|
||||
return rows
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。
|
||||
*/
|
||||
export const getSuccessorsForKp = cache(async (
|
||||
kpId: string,
|
||||
): Promise<{ id: string; name: string; description: string | null }[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
description: knowledgePoints.description,
|
||||
})
|
||||
.from(knowledgePointPrerequisites)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
|
||||
.where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId))
|
||||
|
||||
return rows
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "d
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
|
||||
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils"
|
||||
import type {
|
||||
Chapter,
|
||||
KnowledgePoint,
|
||||
@@ -14,7 +15,9 @@ import type {
|
||||
import type {
|
||||
CreateChapterInput,
|
||||
CreateKnowledgePointInput,
|
||||
CreatePrerequisiteInput,
|
||||
CreateTextbookInput,
|
||||
DeletePrerequisiteInput,
|
||||
UpdateChapterContentInput,
|
||||
UpdateKnowledgePointInput,
|
||||
UpdateTextbookInput,
|
||||
@@ -41,7 +44,7 @@ export const getTextbooks = cache(async (query?: string, subject?: string, grade
|
||||
|
||||
const q = query?.trim()
|
||||
if (q) {
|
||||
const needle = `%${q}%`
|
||||
const needle = `%${escapeLikePattern(q)}%`
|
||||
const nameCond = or(
|
||||
like(textbooks.title, needle),
|
||||
like(textbooks.subject, needle),
|
||||
@@ -288,8 +291,10 @@ export async function deleteChapter(id: string): Promise<void> {
|
||||
if (kids) stack.push(...kids)
|
||||
}
|
||||
|
||||
await db.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
|
||||
await db.delete(chapters).where(inArray(chapters.id, idsToDelete))
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
|
||||
await tx.delete(chapters).where(inArray(chapters.id, idsToDelete))
|
||||
})
|
||||
}
|
||||
|
||||
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => {
|
||||
@@ -505,7 +510,7 @@ export const getTextbooksWithScope = cache(
|
||||
|
||||
const q = query?.trim()
|
||||
if (q) {
|
||||
const needle = `%${q}%`
|
||||
const needle = `%${escapeLikePattern(q)}%`
|
||||
const nameCond = or(
|
||||
like(textbooks.title, needle),
|
||||
like(textbooks.subject, needle),
|
||||
@@ -617,3 +622,41 @@ export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOp
|
||||
grade: row.grade ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
// ===== Prerequisite CRUD =====
|
||||
|
||||
export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> {
|
||||
await db.insert(knowledgePointPrerequisites).values({
|
||||
knowledgePointId: data.knowledgePointId,
|
||||
prerequisiteKpId: data.prerequisiteKpId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
|
||||
await db
|
||||
.delete(knowledgePointPrerequisites)
|
||||
.where(and(
|
||||
eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId),
|
||||
eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId),
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教材下所有知识点的前置依赖边列表。
|
||||
* 用于循环检测。
|
||||
*/
|
||||
export async function getPrerequisiteEdgesForTextbook(
|
||||
textbookId: string,
|
||||
): Promise<Array<[string, string]>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
|
||||
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
|
||||
})
|
||||
.from(knowledgePointPrerequisites)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
|
||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.where(eq(chapters.textbookId, textbookId))
|
||||
|
||||
return rows.map((r) => [r.knowledgePointId, r.prerequisiteKpId] as [string, string])
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { KnowledgePoint } from "./types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
|
||||
import type { KpWithRelations } from "./types"
|
||||
import { computeGraphLayout } from "./graph-layout"
|
||||
|
||||
describe("textbooks/graph-layout", () => {
|
||||
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
|
||||
const makeKp = (
|
||||
id: string,
|
||||
parentId: string | null = null,
|
||||
prerequisiteIds: string[] = [],
|
||||
): KpWithRelations => ({
|
||||
id,
|
||||
name: `KP-${id}`,
|
||||
chapterId: "c1",
|
||||
description: null,
|
||||
parentId,
|
||||
chapterId: "c1",
|
||||
level: 1,
|
||||
order: 0,
|
||||
chapterTitle: "Chapter 1",
|
||||
questionCount: 0,
|
||||
prerequisiteIds,
|
||||
})
|
||||
|
||||
describe("computeGraphLayout", () => {
|
||||
@@ -17,79 +25,40 @@ describe("textbooks/graph-layout", () => {
|
||||
const layout = computeGraphLayout([])
|
||||
expect(layout.nodes).toEqual([])
|
||||
expect(layout.edges).toEqual([])
|
||||
expect(layout.width).toBe(0)
|
||||
expect(layout.height).toBe(0)
|
||||
})
|
||||
|
||||
it("should place single root node", () => {
|
||||
it("should place single node", () => {
|
||||
const layout = computeGraphLayout([makeKp("1")])
|
||||
expect(layout.nodes).toHaveLength(1)
|
||||
expect(layout.nodes[0].x).toBe(GAP_X)
|
||||
expect(layout.nodes[0].y).toBe(GAP_Y)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
expect(layout.nodes[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("should compute parent-child layout with edge", () => {
|
||||
it("should generate parent-child edge", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
expect(layout.edges).toHaveLength(1)
|
||||
expect(layout.edges[0].id).toBe("1-2")
|
||||
const parentEdge = layout.edges.find((e) => e.id === "parent-1-2")
|
||||
expect(parentEdge).toBeDefined()
|
||||
})
|
||||
|
||||
it("should place children at lower level (higher y)", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
|
||||
const root = layout.nodes.find((n) => n.id === "1")
|
||||
const child = layout.nodes.find((n) => n.id === "2")
|
||||
expect(child!.y).toBeGreaterThan(root!.y)
|
||||
})
|
||||
|
||||
it("should handle multiple roots", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2")])
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
const n1 = layout.nodes.find((n) => n.id === "1")
|
||||
const n2 = layout.nodes.find((n) => n.id === "2")
|
||||
expect(n2!.x).toBeGreaterThan(n1!.x)
|
||||
})
|
||||
|
||||
it("should handle circular references gracefully (no infinite loop)", () => {
|
||||
// a → b → a 循环
|
||||
const kps = [makeKp("a", "b"), makeKp("b", "a")]
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle all nodes referencing non-existent parent", () => {
|
||||
const kps = [makeKp("1", "nonexistent"), makeKp("2", "nonexistent")]
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
// 全部作为根节点处理
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should compute width based on max nodes per level", () => {
|
||||
const kps = [
|
||||
it("should generate prerequisite edge", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2"),
|
||||
makeKp("3"),
|
||||
makeKp("4", "1"),
|
||||
]
|
||||
const layout = computeGraphLayout(kps)
|
||||
// 第 0 层有 3 个节点,是最大层
|
||||
const expectedWidth = 3 * (NODE_WIDTH + GAP_X) + GAP_X
|
||||
expect(layout.width).toBe(expectedWidth)
|
||||
makeKp("2", null, ["1"]),
|
||||
])
|
||||
const prereqEdge = layout.edges.find((e) => e.id === "prereq-1-2")
|
||||
expect(prereqEdge).toBeDefined()
|
||||
})
|
||||
|
||||
it("should compute height based on level count", () => {
|
||||
const kps = [
|
||||
it("should assign positions to all nodes", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2", "1"),
|
||||
makeKp("3", "2"),
|
||||
]
|
||||
const layout = computeGraphLayout(kps)
|
||||
// 3 层
|
||||
const expectedHeight = 3 * (NODE_HEIGHT + GAP_Y) + GAP_Y
|
||||
expect(layout.height).toBe(expectedHeight)
|
||||
makeKp("3", "1"),
|
||||
])
|
||||
for (const node of layout.nodes) {
|
||||
expect(node.position.x).toBeGreaterThanOrEqual(0)
|
||||
expect(node.position.y).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,141 +1,121 @@
|
||||
/**
|
||||
* 知识图谱布局纯函数。
|
||||
* 知识图谱布局纯函数(dagre 集成)。
|
||||
*
|
||||
* 从 knowledge-graph.tsx 抽离,便于单元测试。
|
||||
*/
|
||||
|
||||
import type { KnowledgePoint } from "./types"
|
||||
import dagre from "@dagrejs/dagre"
|
||||
import type { EdgeLabel, GraphLabel, NodeLabel } from "@dagrejs/dagre"
|
||||
import type { Edge, Node } from "@xyflow/react"
|
||||
import type { KpWithRelations } from "./types"
|
||||
|
||||
export interface GraphNode extends KnowledgePoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
export interface GraphLayoutNodeData {
|
||||
kp: KpWithRelations
|
||||
label: string
|
||||
// 索引签名:满足 @xyflow/react Node<GraphLayoutNodeData> 的 Record<string, unknown> 约束
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GraphLayout {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
nodes: Node<GraphLayoutNodeData>[]
|
||||
edges: Edge[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 节点尺寸常量 */
|
||||
export const NODE_WIDTH = 160
|
||||
export const NODE_HEIGHT = 52
|
||||
export const GAP_X = 40
|
||||
export const GAP_Y = 90
|
||||
export const NODE_WIDTH = 180
|
||||
export const NODE_HEIGHT = 80
|
||||
export const RANK_SEP = 90
|
||||
export const NODE_SEP = 40
|
||||
|
||||
/**
|
||||
* 计算知识图谱的分层布局。
|
||||
* 使用 dagre 计算分层有向图布局。
|
||||
*
|
||||
* 算法:
|
||||
* 1. 根据 parentId 构建父子关系
|
||||
* 2. BFS 计算每个节点的层级(level)
|
||||
* 3. 同层节点按出现顺序水平排列
|
||||
* 4. 生成节点坐标和边坐标
|
||||
*
|
||||
* @param knowledgePoints 知识点列表
|
||||
* @returns 图布局(节点带坐标、边、总宽高)
|
||||
* @param knowledgePoints 知识点列表(含 parentId 和 prerequisiteIds)
|
||||
* @returns React Flow 格式的 nodes/edges + 画布尺寸
|
||||
*/
|
||||
export function computeGraphLayout(
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
knowledgePoints: KpWithRelations[],
|
||||
): GraphLayout {
|
||||
if (knowledgePoints.length === 0) {
|
||||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const byId = new Map<string, KnowledgePoint>()
|
||||
for (const kp of knowledgePoints) byId.set(kp.id, kp)
|
||||
|
||||
const children = new Map<string, string[]>()
|
||||
const roots: string[] = []
|
||||
const g = new dagre.graphlib.Graph<GraphLabel, NodeLabel, EdgeLabel>()
|
||||
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
|
||||
// 添加节点
|
||||
for (const kp of knowledgePoints) {
|
||||
if (kp.parentId && byId.has(kp.parentId)) {
|
||||
const arr = children.get(kp.parentId) ?? []
|
||||
arr.push(kp.id)
|
||||
children.set(kp.parentId, arr)
|
||||
} else {
|
||||
roots.push(kp.id)
|
||||
}
|
||||
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
}
|
||||
|
||||
const levelMap = new Map<string, number>()
|
||||
const levels: string[][] = []
|
||||
const queue = [...roots].map((id) => ({ id, level: 0 }))
|
||||
|
||||
// 容错:如果没有任何根节点(全部循环引用),把所有节点放第 0 层
|
||||
if (queue.length === 0) {
|
||||
for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 })
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift()
|
||||
if (!item) continue
|
||||
if (levelMap.has(item.id)) continue
|
||||
levelMap.set(item.id, item.level)
|
||||
if (!levels[item.level]) levels[item.level] = []
|
||||
levels[item.level].push(item.id)
|
||||
const kids = children.get(item.id) ?? []
|
||||
for (const kid of kids) {
|
||||
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// 容错:处理孤立节点(未在 BFS 中访问到)
|
||||
// 添加 parentId 边(树归属,实线)
|
||||
for (const kp of knowledgePoints) {
|
||||
if (!levelMap.has(kp.id)) {
|
||||
const level = levels.length
|
||||
levelMap.set(kp.id, level)
|
||||
if (!levels[level]) levels[level] = []
|
||||
levels[level].push(kp.id)
|
||||
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
|
||||
g.setEdge(kp.parentId, kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...levels.map((l) => l.length), 1)
|
||||
const width = maxCount * (NODE_WIDTH + GAP_X) + GAP_X
|
||||
const height = levels.length * (NODE_HEIGHT + GAP_Y) + GAP_Y
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
levels.forEach((ids, level) => {
|
||||
ids.forEach((id, index) => {
|
||||
const x = GAP_X + index * (NODE_WIDTH + GAP_X)
|
||||
const y = GAP_Y + level * (NODE_HEIGHT + GAP_Y)
|
||||
positions.set(id, { x, y })
|
||||
})
|
||||
})
|
||||
|
||||
const nodes = knowledgePoints.map((kp) => {
|
||||
const pos = positions.get(kp.id) ?? { x: GAP_X, y: GAP_Y }
|
||||
return { ...kp, x: pos.x, y: pos.y }
|
||||
})
|
||||
|
||||
const edges = knowledgePoints
|
||||
.filter((kp) => kp.parentId && positions.has(kp.parentId))
|
||||
.map((kp) => {
|
||||
const parentId = kp.parentId as string
|
||||
const parentPos = positions.get(parentId)
|
||||
const childPos = positions.get(kp.id)
|
||||
// 类型守卫:两个位置都必须存在(已在 filter 中保证,但 TS 需要 narrowing)
|
||||
if (!parentPos || !childPos) {
|
||||
return null
|
||||
// 添加 prerequisite 边(依赖,虚线箭头)
|
||||
for (const kp of knowledgePoints) {
|
||||
for (const prereqId of kp.prerequisiteIds) {
|
||||
if (knowledgePoints.some((k) => k.id === prereqId)) {
|
||||
g.setEdge(prereqId, kp.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g)
|
||||
|
||||
const nodes: Node<GraphLayoutNodeData>[] = knowledgePoints.map((kp) => {
|
||||
const dagreNode = g.node(kp.id)
|
||||
return {
|
||||
id: `${parentId}-${kp.id}`,
|
||||
x1: parentPos.x + NODE_WIDTH / 2,
|
||||
y1: parentPos.y + NODE_HEIGHT,
|
||||
x2: childPos.x + NODE_WIDTH / 2,
|
||||
y2: childPos.y,
|
||||
id: kp.id,
|
||||
type: "kpNode",
|
||||
position: {
|
||||
x: (dagreNode.x ?? 0) - NODE_WIDTH / 2,
|
||||
y: (dagreNode.y ?? 0) - NODE_HEIGHT / 2,
|
||||
},
|
||||
data: { kp, label: kp.name },
|
||||
}
|
||||
})
|
||||
.filter((e): e is GraphEdge => e !== null)
|
||||
|
||||
const edges: Edge[] = []
|
||||
|
||||
// parentId 边
|
||||
for (const kp of knowledgePoints) {
|
||||
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
|
||||
edges.push({
|
||||
id: `parent-${kp.parentId}-${kp.id}`,
|
||||
source: kp.parentId,
|
||||
target: kp.id,
|
||||
type: "default",
|
||||
className: "edge-parent",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prerequisite 边
|
||||
for (const kp of knowledgePoints) {
|
||||
for (const prereqId of kp.prerequisiteIds) {
|
||||
if (knowledgePoints.some((k) => k.id === prereqId)) {
|
||||
edges.push({
|
||||
id: `prereq-${prereqId}-${kp.id}`,
|
||||
source: prereqId,
|
||||
target: kp.id,
|
||||
type: "prerequisiteEdge",
|
||||
className: "edge-prerequisite",
|
||||
animated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graph = g.graph()
|
||||
const width = graph.width ?? 0
|
||||
const height = graph.height ?? 0
|
||||
|
||||
return { nodes, edges, width, height }
|
||||
}
|
||||
|
||||
69
src/modules/textbooks/hooks/use-graph-data.ts
Normal file
69
src/modules/textbooks/hooks/use-graph-data.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { getKnowledgeGraphDataAction } from "../actions"
|
||||
import type { GraphViewMode, KnowledgeGraphData } from "../types"
|
||||
|
||||
interface UseGraphDataResult {
|
||||
data: KnowledgeGraphData | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱数据加载 Hook。
|
||||
*
|
||||
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
|
||||
* 使用派生值模式(isLoading 从 data.viewMode 派生),避免 effect 中同步 setState。
|
||||
*/
|
||||
export function useGraphData(
|
||||
textbookId: string,
|
||||
viewMode: GraphViewMode,
|
||||
): UseGraphDataResult {
|
||||
const [data, setData] = useState<KnowledgeGraphData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadTrigger, setReloadTrigger] = useState(0)
|
||||
const lastRequestKey = useRef<string>("")
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadTrigger((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
// 派生 loading 状态:无数据或当前数据不匹配请求的 viewMode
|
||||
const isLoading = data === null || data.viewMode !== viewMode
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) return
|
||||
|
||||
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
|
||||
if (lastRequestKey.current === requestKey) return
|
||||
lastRequestKey.current = requestKey
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getKnowledgeGraphDataAction(textbookId, viewMode)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.data) {
|
||||
setData(result.data)
|
||||
setError(null)
|
||||
} else {
|
||||
setData(null)
|
||||
setError(result.message ?? "Unknown error")
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setData(null)
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [textbookId, viewMode, reloadTrigger])
|
||||
|
||||
return { data, isLoading, error, reload }
|
||||
}
|
||||
@@ -62,3 +62,19 @@ export const ReorderChaptersSchema = z.object({
|
||||
})
|
||||
|
||||
export type ReorderChaptersInput = z.infer<typeof ReorderChaptersSchema>
|
||||
|
||||
export const CreatePrerequisiteSchema = z.object({
|
||||
knowledgePointId: z.string().min(1),
|
||||
prerequisiteKpId: z.string().min(1),
|
||||
}).refine((data) => data.knowledgePointId !== data.prerequisiteKpId, {
|
||||
message: "知识点不能作为自己的前置",
|
||||
})
|
||||
|
||||
export type CreatePrerequisiteInput = z.infer<typeof CreatePrerequisiteSchema>
|
||||
|
||||
export const DeletePrerequisiteSchema = z.object({
|
||||
knowledgePointId: z.string().min(1),
|
||||
prerequisiteKpId: z.string().min(1),
|
||||
})
|
||||
|
||||
export type DeletePrerequisiteInput = z.infer<typeof DeletePrerequisiteSchema>
|
||||
|
||||
@@ -43,3 +43,64 @@ export type KnowledgePoint = {
|
||||
level: number;
|
||||
order: number;
|
||||
};
|
||||
|
||||
// ===== 知识图谱相关类型 =====
|
||||
|
||||
/** 图谱视图模式 */
|
||||
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
|
||||
|
||||
/** 掌握度信息 */
|
||||
export interface MasteryInfo {
|
||||
/** 掌握度等级 0-100 */
|
||||
masteryLevel: number
|
||||
/** 总题数 */
|
||||
totalQuestions: number
|
||||
/** 正确题数 */
|
||||
correctQuestions: number
|
||||
/** 最后测评时间 */
|
||||
lastAssessedAt: Date
|
||||
}
|
||||
|
||||
/** 带关联关系的知识点(图谱数据) */
|
||||
export interface KpWithRelations {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
parentId: string | null
|
||||
chapterId: string | null
|
||||
level: number
|
||||
order: number
|
||||
/** 关联题目数 */
|
||||
questionCount: number
|
||||
/** 前置知识点 ID 列表 */
|
||||
prerequisiteIds: string[]
|
||||
/** 章节标题(用于节点 tooltip) */
|
||||
chapterTitle: string | null
|
||||
}
|
||||
|
||||
/** 图谱节点数据(React Flow Node.data) */
|
||||
export interface GraphNodeData {
|
||||
kp: KpWithRelations
|
||||
mastery: MasteryInfo | null
|
||||
viewMode: GraphViewMode
|
||||
isSelected: boolean
|
||||
isHighlighted: boolean
|
||||
chapterColor: string
|
||||
}
|
||||
|
||||
/** 图谱边数据(React Flow Edge.data) */
|
||||
export interface GraphEdgeData {
|
||||
edgeType: "parent" | "prerequisite"
|
||||
isHighlighted: boolean
|
||||
}
|
||||
|
||||
/** 图谱完整数据(Server Action 返回) */
|
||||
export interface KnowledgeGraphData {
|
||||
knowledgePoints: KpWithRelations[]
|
||||
/** mastery map,key = kpId(仅 mastery 模式下有值) */
|
||||
masteryMap: Record<string, MasteryInfo>
|
||||
viewMode: GraphViewMode
|
||||
}
|
||||
|
||||
/** 掌握度色彩等级 */
|
||||
export type MasteryLevel = "low" | "medium" | "high" | "unassessed"
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
filterKnowledgePointsByChapter,
|
||||
normalizeOptional,
|
||||
sortChapters,
|
||||
highlightKnowledgePoints,
|
||||
hasCycleAfterAddingEdge,
|
||||
} from "./utils"
|
||||
|
||||
// 测试辅助:构造最小合法 Chapter(补齐 createdAt/updatedAt 等必填字段)
|
||||
@@ -178,4 +180,87 @@ describe("textbooks/utils", () => {
|
||||
expect(normalizeOptional(" hello ")).toBe("hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("highlightKnowledgePoints", () => {
|
||||
const makeKp = (id: string, name: string): KnowledgePoint => ({
|
||||
id,
|
||||
name,
|
||||
chapterId: "c1",
|
||||
level: 1,
|
||||
order: 0,
|
||||
})
|
||||
|
||||
it("should return content unchanged when no knowledge points", () => {
|
||||
expect(highlightKnowledgePoints("hello world", [])).toBe("hello world")
|
||||
})
|
||||
|
||||
it("should return content unchanged when content is empty", () => {
|
||||
expect(highlightKnowledgePoints("", [makeKp("1", "test")])).toBe("")
|
||||
})
|
||||
|
||||
it("should wrap single knowledge point name with link", () => {
|
||||
const result = highlightKnowledgePoints("学习物理很有趣", [makeKp("kp1", "物理")])
|
||||
expect(result).toBe("学习[物理](#kp-kp1)很有趣")
|
||||
})
|
||||
|
||||
it("should match case-insensitively", () => {
|
||||
const result = highlightKnowledgePoints("Hello HELLO hello", [makeKp("kp1", "hello")])
|
||||
expect(result).toBe("[Hello](#kp-kp1) [HELLO](#kp-kp1) [hello](#kp-kp1)")
|
||||
})
|
||||
|
||||
it("should match multiple occurrences", () => {
|
||||
const result = highlightKnowledgePoints("物理物理物理", [makeKp("kp1", "物理")])
|
||||
expect(result).toBe("[物理](#kp-kp1)[物理](#kp-kp1)[物理](#kp-kp1)")
|
||||
})
|
||||
|
||||
it("should prioritize longest match (物理学 before 物理)", () => {
|
||||
const kps = [makeKp("kp1", "物理"), makeKp("kp2", "物理学")]
|
||||
const result = highlightKnowledgePoints("物理学是研究物理的学科", kps)
|
||||
// "物理学" 应被 kp2 匹配,独立的 "物理" 应被 kp1 匹配
|
||||
expect(result).toBe("[物理学](#kp-kp2)是研究[物理](#kp-kp1)的学科")
|
||||
})
|
||||
|
||||
it("should handle multiple different knowledge points in one pass", () => {
|
||||
const kps = [makeKp("kp1", "数学"), makeKp("kp2", "物理")]
|
||||
const result = highlightKnowledgePoints("数学和物理都是科学", kps)
|
||||
expect(result).toBe("[数学](#kp-kp1)和[物理](#kp-kp2)都是科学")
|
||||
})
|
||||
|
||||
it("should escape regex metacharacters in knowledge point names", () => {
|
||||
const result = highlightKnowledgePoints("计算 1+2 的结果", [makeKp("kp1", "1+2")])
|
||||
expect(result).toBe("计算 [1+2](#kp-kp1) 的结果")
|
||||
})
|
||||
|
||||
it("should handle knowledge point names with parentheses", () => {
|
||||
const result = highlightKnowledgePoints("函数 f(x) 是映射", [makeKp("kp1", "f(x)")])
|
||||
expect(result).toBe("函数 [f(x)](#kp-kp1) 是映射")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("textbooks/utils - cycle detection", () => {
|
||||
it("should return false when adding edge to empty graph", () => {
|
||||
const edges: Array<[string, string]> = []
|
||||
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect direct cycle (a->b then b->a)", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "b", "a")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect indirect cycle (a->b->c then c->a)", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "c", "a")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect cycle for independent chains", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["c", "d"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "b", "c")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not detect cycle for diamond shape", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,3 +127,99 @@ export function normalizeOptional(
|
||||
if (!trimmed) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则元字符,用于构建字面量匹配的正则。
|
||||
*/
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-2 知识点高亮:单遍替换实现。
|
||||
*
|
||||
* 旧实现 O(n×m):对每个知识点名做一次 `new RegExp(..., "gi")` 全局替换,
|
||||
* 内容被反复扫描 n 次,且长名替换可能破坏短名匹配。
|
||||
*
|
||||
* 新实现 O(m):构建单个 alternation 正则 `(name1|name2|...)`,一次扫描完成所有替换。
|
||||
* 按名称长度降序排列保证最长匹配优先(避免 "物理" 抢占 "物理学")。
|
||||
*
|
||||
* @param content 章节正文(Markdown)
|
||||
* @param kps 当前章节的知识点列表
|
||||
* @returns 替换后的 Markdown,知识点名被包装为 `[name](#kp-{id})` 链接
|
||||
*/
|
||||
export function highlightKnowledgePoints(
|
||||
content: string,
|
||||
kps: KnowledgePoint[]
|
||||
): string {
|
||||
if (!content || kps.length === 0) return content
|
||||
|
||||
// 按名称长度降序,保证最长匹配优先
|
||||
const sorted = [...kps].sort((a, b) => b.name.length - a.name.length)
|
||||
|
||||
// 构建 id 查找表:name(小写)→ kpId(取第一个匹配,因已按长度降序)
|
||||
// 使用小写作为键以支持大小写不敏感匹配
|
||||
const nameToId = new Map<string, string>()
|
||||
for (const kp of sorted) {
|
||||
const key = kp.name.toLowerCase()
|
||||
if (!nameToId.has(key)) {
|
||||
nameToId.set(key, kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建单遍 alternation 正则
|
||||
const pattern = sorted
|
||||
.map((kp) => `(${escapeRegExp(kp.name)})`)
|
||||
.join("|")
|
||||
const regex = new RegExp(pattern, "gi")
|
||||
|
||||
return content.replace(regex, (match: string) => {
|
||||
const id = nameToId.get(match.toLowerCase())
|
||||
if (id) return `[${match}](#kp-${id})`
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测在添加新边 (from -> to) 后是否形成环。
|
||||
*
|
||||
* 算法:DFS 从 to 出发,若能到达 from 则有环。
|
||||
*
|
||||
* @param existingEdges 现有边列表,每项为 [from, to]
|
||||
* @param newFrom 新边的起点
|
||||
* @param newTo 新边的终点
|
||||
* @returns true 表示添加后会形成环
|
||||
*/
|
||||
export function hasCycleAfterAddingEdge(
|
||||
existingEdges: Array<[string, string]>,
|
||||
newFrom: string,
|
||||
newTo: string,
|
||||
): boolean {
|
||||
// 构建邻接表
|
||||
const adj = new Map<string, string[]>()
|
||||
for (const [from, to] of existingEdges) {
|
||||
const arr = adj.get(from) ?? []
|
||||
arr.push(to)
|
||||
adj.set(from, arr)
|
||||
}
|
||||
// 添加新边
|
||||
const arr = adj.get(newFrom) ?? []
|
||||
arr.push(newTo)
|
||||
adj.set(newFrom, arr)
|
||||
|
||||
// DFS 从 newTo 出发,若能到达 newFrom 则有环
|
||||
const visited = new Set<string>()
|
||||
const stack = [newTo]
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()
|
||||
if (!node) continue
|
||||
if (node === newFrom) return true
|
||||
if (visited.has(node)) continue
|
||||
visited.add(node)
|
||||
const neighbors = adj.get(node) ?? []
|
||||
for (const n of neighbors) {
|
||||
if (!visited.has(n)) stack.push(n)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -153,6 +153,27 @@ export const knowledgePoints = mysqlTable("knowledge_points", {
|
||||
chapterIdIdx: index("kp_chapter_id_idx").on(table.chapterId),
|
||||
}));
|
||||
|
||||
// --- 知识点前置依赖(知识图谱) ---
|
||||
export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequisites", {
|
||||
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(),
|
||||
prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId] }),
|
||||
kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId),
|
||||
prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId),
|
||||
kpFk: foreignKey({
|
||||
columns: [table.knowledgePointId],
|
||||
foreignColumns: [knowledgePoints.id],
|
||||
name: "kp_prereq_kp_fk",
|
||||
}).onDelete("cascade"),
|
||||
prereqFk: foreignKey({
|
||||
columns: [table.prerequisiteKpId],
|
||||
foreignColumns: [knowledgePoints.id],
|
||||
name: "kp_prereq_prereq_fk",
|
||||
}).onDelete("cascade"),
|
||||
}));
|
||||
|
||||
// --- 3. Question Bank (Core) ---
|
||||
|
||||
export const questionTypeEnum = mysqlEnum("type", ["single_choice", "multiple_choice", "text", "judgment", "composite"]);
|
||||
@@ -1337,3 +1358,85 @@ export const systemSettings = mysqlTable("system_settings", {
|
||||
categoryKeyIdx: uniqueIndex("ss_category_key_idx").on(table.category, table.key),
|
||||
categoryIdx: index("ss_category_idx").on(table.category),
|
||||
}));
|
||||
|
||||
// --- 26. Error Book (错题本) ---
|
||||
|
||||
export const errorBookSourceTypeEnum = mysqlEnum("source_type", ["exam", "homework", "manual"]);
|
||||
export const errorBookStatusEnum = mysqlEnum("error_status", ["new", "learning", "mastered", "archived"]);
|
||||
export const errorBookReviewResultEnum = mysqlEnum("review_result", ["again", "hard", "good", "easy"]);
|
||||
|
||||
/**
|
||||
* 错题本条目 - 存储学生的错题记录。
|
||||
* 来源可以是考试提交、作业提交,或学生手动添加。
|
||||
* 采用简化版 SM-2 间隔重复算法调度复习。
|
||||
*/
|
||||
export const errorBookItems = mysqlTable("error_book_items", {
|
||||
id: id("id").primaryKey(),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id),
|
||||
|
||||
// 来源
|
||||
sourceType: errorBookSourceTypeEnum.default("manual").notNull(),
|
||||
/** exam_submission_id / homework_submission_id / null(手动添加) */
|
||||
sourceId: varchar("source_id", { length: 128 }),
|
||||
|
||||
// 作答快照(冗余存储,避免源记录被删除后丢失上下文)
|
||||
studentAnswer: json("student_answer"),
|
||||
correctAnswer: json("correct_answer"),
|
||||
|
||||
// 分类元数据(冗余,加速筛选)
|
||||
subjectId: varchar("subject_id", { length: 128 }),
|
||||
knowledgePointIds: json("knowledge_point_ids"),
|
||||
|
||||
// 状态与掌握度
|
||||
status: errorBookStatusEnum.default("new").notNull(),
|
||||
/** 0-5 掌握程度,0=未学,5=已掌握 */
|
||||
masteryLevel: int("mastery_level").default(0).notNull(),
|
||||
|
||||
// SM-2 间隔重复参数
|
||||
nextReviewAt: timestamp("next_review_at", { mode: "date" }),
|
||||
/** 复习间隔(天) */
|
||||
reviewInterval: int("review_interval").default(1).notNull(),
|
||||
reviewCount: int("review_count").default(0).notNull(),
|
||||
/** 连续答对次数(用于判断是否标记为已掌握) */
|
||||
correctStreak: int("correct_streak").default(0).notNull(),
|
||||
|
||||
// 学生笔记与反思
|
||||
note: text("note"),
|
||||
/** 错误原因标签(JSON 数组,如 ["粗心", "概念不清", "计算错误"]) */
|
||||
errorTags: json("error_tags"),
|
||||
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
studentIdx: index("eb_item_student_idx").on(table.studentId),
|
||||
studentStatusIdx: index("eb_item_student_status_idx").on(table.studentId, table.status),
|
||||
studentReviewIdx: index("eb_item_student_review_idx").on(table.studentId, table.nextReviewAt),
|
||||
questionIdx: index("eb_item_question_idx").on(table.questionId),
|
||||
subjectIdx: index("eb_item_subject_idx").on(table.subjectId),
|
||||
sourceIdx: index("eb_item_source_idx").on(table.sourceType, table.sourceId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 错题复习记录 - 每次复习的历史快照。
|
||||
* 用于追踪复习进度和算法调参。
|
||||
*/
|
||||
export const errorBookReviews = mysqlTable("error_book_reviews", {
|
||||
id: id("id").primaryKey(),
|
||||
itemId: varchar("item_id", { length: 128 }).notNull().references(() => errorBookItems.id, { onDelete: "cascade" }),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
/** 复习自评结果:again(重来) / hard(困难) / good(良好) / easy(简单) */
|
||||
result: errorBookReviewResultEnum.notNull(),
|
||||
reviewedAt: timestamp("reviewed_at").defaultNow().notNull(),
|
||||
|
||||
/** 本次复习后的新间隔(天) */
|
||||
newInterval: int("new_interval"),
|
||||
newMasteryLevel: int("new_mastery_level"),
|
||||
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
itemIdx: index("eb_review_item_idx").on(table.itemId),
|
||||
studentIdx: index("eb_review_student_idx").on(table.studentId),
|
||||
studentReviewedIdx: index("eb_review_student_reviewed_idx").on(table.studentId, table.reviewedAt),
|
||||
}));
|
||||
|
||||
@@ -34,10 +34,16 @@
|
||||
"graph": "Graph"
|
||||
},
|
||||
"selectChapter": "Please select a chapter to start reading.",
|
||||
"selectChapterDesc": "Choose a chapter from the sidebar to view its content.",
|
||||
"selectChapterKnowledge": "Please select a chapter to view knowledge points.",
|
||||
"selectChapterKnowledgeDesc": "Choose a chapter from the sidebar to see its knowledge points.",
|
||||
"selectChapterGraph": "Please select a chapter to view the knowledge graph.",
|
||||
"selectChapterGraphDesc": "Choose a chapter from the sidebar to see its knowledge graph.",
|
||||
"emptyKnowledge": "No knowledge points in this chapter yet.",
|
||||
"emptyKnowledgeDesc": "Select text while reading to create a knowledge point.",
|
||||
"emptyContent": "No content yet",
|
||||
"emptyContentDesc": "Click \"Edit Content\" to start writing this chapter.",
|
||||
"loadingKnowledge": "Loading knowledge points...",
|
||||
"editContent": "Edit Content",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
@@ -45,7 +51,9 @@
|
||||
"addKnowledgePoint": "Add Knowledge Point",
|
||||
"clickToViewKp": "Click to view knowledge point details",
|
||||
"noChapters": "No chapters",
|
||||
"noChaptersDesc": "This textbook has no chapters yet."
|
||||
"noChaptersDesc": "This textbook has no chapters yet.",
|
||||
"sidebar": "Chapters & Knowledge",
|
||||
"openSidebar": "Open Sidebar"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
@@ -75,7 +83,11 @@
|
||||
"deleting": "Deleting...",
|
||||
"cannotDeleteWithSubchapters": "Cannot delete chapter with subchapters",
|
||||
"addSubchapter": "Add Subchapter",
|
||||
"titlePlaceholder": "e.g. Chapter 1: Introduction"
|
||||
"titlePlaceholder": "e.g. Chapter 1: Introduction",
|
||||
"toggle": "Toggle",
|
||||
"orderUpdated": "Order updated",
|
||||
"cancel": "Cancel",
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"knowledge": {
|
||||
"createTitle": "Add Knowledge Point",
|
||||
@@ -133,6 +145,7 @@
|
||||
"level": "Lv."
|
||||
},
|
||||
"subject": {
|
||||
"chinese": "Chinese",
|
||||
"mathematics": "Mathematics",
|
||||
"physics": "Physics",
|
||||
"chemistry": "Chemistry",
|
||||
@@ -142,6 +155,8 @@
|
||||
"geography": "Geography"
|
||||
},
|
||||
"grade": {
|
||||
"grade1": "Grade 1",
|
||||
"grade2": "Grade 2",
|
||||
"grade7": "Grade 7",
|
||||
"grade8": "Grade 8",
|
||||
"grade9": "Grade 9",
|
||||
@@ -160,7 +175,7 @@
|
||||
"updateSuccess": "Textbook updated successfully.",
|
||||
"updateFailed": "Failed to update textbook.",
|
||||
"deleteSuccess": "Textbook deleted successfully.",
|
||||
"deleteFailed": "Failed to delete textbook.",
|
||||
"textbookDeleteFailed": "Failed to delete textbook.",
|
||||
"chapterCreateSuccess": "Chapter created successfully",
|
||||
"chapterCreateFailed": "Failed to create chapter",
|
||||
"chapterDeleteSuccess": "Chapter deleted successfully",
|
||||
@@ -181,6 +196,57 @@
|
||||
"invalidContent": "Invalid chapter content data",
|
||||
"errorOccurred": "An error occurred",
|
||||
"deleteFailed": "Deletion failed",
|
||||
"updateFailedGeneric": "Update failed"
|
||||
"updateFailedGeneric": "Update failed",
|
||||
"chapterNotBelong": "Chapter does not belong to this textbook",
|
||||
"kpNotBelong": "Knowledge point does not belong to this textbook",
|
||||
"chaptersReordered": "Chapters reordered successfully",
|
||||
"ok": "OK",
|
||||
"kpLoadFailed": "Failed to load knowledge points",
|
||||
"graphLoadFailed": "Graph failed to load",
|
||||
"invalidInput": "Invalid input",
|
||||
"cyclicDependency": "Cannot add cyclic dependency",
|
||||
"prerequisiteCreated": "Prerequisite added",
|
||||
"prerequisiteCreateFailed": "Failed to add prerequisite",
|
||||
"prerequisiteDeleted": "Prerequisite removed",
|
||||
"prerequisiteDeleteFailed": "Failed to remove prerequisite"
|
||||
},
|
||||
"graph": {
|
||||
"viewMode": {
|
||||
"structure": "Structure",
|
||||
"studentMastery": "My Mastery",
|
||||
"classMastery": "Class Mastery"
|
||||
},
|
||||
"node": {
|
||||
"questions": "Questions",
|
||||
"mastery": "Mastery",
|
||||
"prerequisite": "Prerequisite",
|
||||
"successor": "Successor"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Knowledge Point Details",
|
||||
"noDescription": "No description",
|
||||
"viewAllQuestions": "View all questions",
|
||||
"editPrerequisite": "Edit prerequisites",
|
||||
"addPrerequisite": "Add prerequisite",
|
||||
"removePrerequisite": "Remove",
|
||||
"noPrerequisites": "No prerequisite knowledge points",
|
||||
"noSuccessors": "No successor knowledge points",
|
||||
"masteryNotAssessed": "Not assessed",
|
||||
"correctRate": "Correct rate",
|
||||
"totalQuestions": "Total questions"
|
||||
},
|
||||
"toolbar": {
|
||||
"search": "Search knowledge points",
|
||||
"filterByChapter": "Filter by chapter",
|
||||
"resetView": "Reset view"
|
||||
},
|
||||
"empty": {
|
||||
"noPrerequisites": "No prerequisite relationships",
|
||||
"noData": "No graph data"
|
||||
},
|
||||
"error": {
|
||||
"cyclicDependency": "Cannot add cyclic dependency",
|
||||
"loadFailed": "Graph failed to load"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,16 @@
|
||||
"graph": "图谱"
|
||||
},
|
||||
"selectChapter": "请选择一个章节开始阅读。",
|
||||
"selectChapterDesc": "从左侧目录中选择一个章节以查看内容。",
|
||||
"selectChapterKnowledge": "请选择一个章节查看知识点。",
|
||||
"selectChapterKnowledgeDesc": "从左侧目录中选择一个章节以查看关联的知识点。",
|
||||
"selectChapterGraph": "请选择一个章节查看知识图谱。",
|
||||
"selectChapterGraphDesc": "从左侧目录中选择一个章节以查看知识图谱关系。",
|
||||
"emptyKnowledge": "该章节暂无知识点。",
|
||||
"emptyKnowledgeDesc": "在阅读内容时选中文本即可创建知识点。",
|
||||
"emptyContent": "暂无内容",
|
||||
"emptyContentDesc": "点击「编辑内容」开始编写本章正文。",
|
||||
"loadingKnowledge": "正在加载知识点...",
|
||||
"editContent": "编辑内容",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
@@ -45,7 +51,9 @@
|
||||
"addKnowledgePoint": "添加知识点",
|
||||
"clickToViewKp": "点击查看知识点详情",
|
||||
"noChapters": "暂无章节",
|
||||
"noChaptersDesc": "这本教材还没有章节。"
|
||||
"noChaptersDesc": "这本教材还没有章节。",
|
||||
"sidebar": "目录与知识点",
|
||||
"openSidebar": "打开目录"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
@@ -75,7 +83,11 @@
|
||||
"deleting": "删除中...",
|
||||
"cannotDeleteWithSubchapters": "无法删除含有子章节的章节",
|
||||
"addSubchapter": "添加子章节",
|
||||
"titlePlaceholder": "例如:第一章:入门"
|
||||
"titlePlaceholder": "例如:第一章:入门",
|
||||
"toggle": "展开/折叠",
|
||||
"orderUpdated": "顺序已更新",
|
||||
"cancel": "取消",
|
||||
"dragHandle": "拖拽排序"
|
||||
},
|
||||
"knowledge": {
|
||||
"createTitle": "添加知识点",
|
||||
@@ -133,6 +145,7 @@
|
||||
"level": "等级"
|
||||
},
|
||||
"subject": {
|
||||
"chinese": "语文",
|
||||
"mathematics": "数学",
|
||||
"physics": "物理",
|
||||
"chemistry": "化学",
|
||||
@@ -142,6 +155,8 @@
|
||||
"geography": "地理"
|
||||
},
|
||||
"grade": {
|
||||
"grade1": "一年级",
|
||||
"grade2": "二年级",
|
||||
"grade7": "七年级",
|
||||
"grade8": "八年级",
|
||||
"grade9": "九年级",
|
||||
@@ -160,7 +175,7 @@
|
||||
"updateSuccess": "教材更新成功。",
|
||||
"updateFailed": "更新教材失败。",
|
||||
"deleteSuccess": "教材删除成功。",
|
||||
"deleteFailed": "删除教材失败。",
|
||||
"textbookDeleteFailed": "删除教材失败。",
|
||||
"chapterCreateSuccess": "章节创建成功",
|
||||
"chapterCreateFailed": "创建章节失败",
|
||||
"chapterDeleteSuccess": "章节删除成功",
|
||||
@@ -181,6 +196,57 @@
|
||||
"invalidContent": "章节内容数据无效",
|
||||
"errorOccurred": "发生错误",
|
||||
"deleteFailed": "删除失败",
|
||||
"updateFailedGeneric": "更新失败"
|
||||
"updateFailedGeneric": "更新失败",
|
||||
"chapterNotBelong": "章节不属于该教材",
|
||||
"kpNotBelong": "知识点不属于该教材",
|
||||
"chaptersReordered": "章节排序成功",
|
||||
"ok": "成功",
|
||||
"kpLoadFailed": "加载知识点失败",
|
||||
"graphLoadFailed": "图谱加载失败",
|
||||
"invalidInput": "输入无效",
|
||||
"cyclicDependency": "不能添加循环依赖",
|
||||
"prerequisiteCreated": "前置依赖已添加",
|
||||
"prerequisiteCreateFailed": "添加前置依赖失败",
|
||||
"prerequisiteDeleted": "前置依赖已删除",
|
||||
"prerequisiteDeleteFailed": "删除前置依赖失败"
|
||||
},
|
||||
"graph": {
|
||||
"viewMode": {
|
||||
"structure": "结构图",
|
||||
"studentMastery": "个人掌握度",
|
||||
"classMastery": "班级掌握度"
|
||||
},
|
||||
"node": {
|
||||
"questions": "题目",
|
||||
"mastery": "掌握度",
|
||||
"prerequisite": "前置",
|
||||
"successor": "后置"
|
||||
},
|
||||
"detail": {
|
||||
"title": "知识点详情",
|
||||
"noDescription": "暂无描述",
|
||||
"viewAllQuestions": "查看全部题目",
|
||||
"editPrerequisite": "编辑前置依赖",
|
||||
"addPrerequisite": "添加前置",
|
||||
"removePrerequisite": "移除",
|
||||
"noPrerequisites": "暂无前置知识点",
|
||||
"noSuccessors": "暂无后置知识点",
|
||||
"masteryNotAssessed": "未测评",
|
||||
"correctRate": "正确率",
|
||||
"totalQuestions": "总题数"
|
||||
},
|
||||
"toolbar": {
|
||||
"search": "搜索知识点",
|
||||
"filterByChapter": "按章节筛选",
|
||||
"resetView": "重置视图"
|
||||
},
|
||||
"empty": {
|
||||
"noPrerequisites": "暂无前置依赖关系",
|
||||
"noData": "暂无图谱数据"
|
||||
},
|
||||
"error": {
|
||||
"cyclicDependency": "不能添加循环依赖",
|
||||
"loadFailed": "图谱加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user