feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取

P0 修复:
- 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions,
  改为 renderQuestionCreator render prop 由页面注入
- 接入 usePermission Hook 替换 canEdit 硬编码
- 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案
- Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/
  verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验

P1 改进:
- 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹
- 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过)
- 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog
- 修复类型断言:chapter.children! → 守卫式访问
- 图谱 a11y:添加 role/aria-label/aria-pressed
- 统一删除确认:confirm() → AlertDialog
- 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤

P2 预留:
- TextbookAnalytics 埋点接口 + Provider + Hook

同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
This commit is contained in:
SpecialX
2026-06-22 16:25:59 +08:00
parent 45ee1ae43c
commit 22d3f07fcf
35 changed files with 2043 additions and 792 deletions

View File

@@ -14,7 +14,9 @@ import {
updateKnowledgePoint,
updateTextbook,
deleteTextbook,
reorderChapters
reorderChapters,
verifyChapterBelongsToTextbook,
verifyKnowledgePointBelongsToTextbook,
} from "./data-access";
import {
CreateTextbookSchema,
@@ -38,6 +40,11 @@ export async function reorderChaptersAction(
): Promise<ActionState> {
try {
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" };
}
await reorderChapters(chapterId, newIndex, parentId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapters reordered successfully" };
@@ -203,6 +210,11 @@ export async function updateChapterContentAction(
try {
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" };
}
await updateChapterContent(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
@@ -220,6 +232,11 @@ export async function deleteChapterAction(
): Promise<ActionState> {
try {
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" };
}
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
@@ -254,6 +271,11 @@ export async function createKnowledgePointAction(
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
// P0-4 资源归属校验:确保 chapter 属于该 textbook防止跨教材越权创建知识点
const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
if (!chapterBelongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
}
await createKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
@@ -271,6 +293,11 @@ export async function deleteKnowledgePointAction(
): Promise<ActionState> {
try {
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" };
}
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
@@ -305,6 +332,11 @@ export async function updateKnowledgePointAction(
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
// P0-4 资源归属校验
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
if (!belongs) {
return { success: false, message: "Knowledge point does not belong to this textbook" };
}
await updateKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };