feat(lesson-preparation): 备课模块审计重构 — 跨模块解耦 + i18n + 纯函数抽取 + 错误边界
P0-1 跨模块直查修复:publish-service 不再直查 examQuestions 表,新增 exams/data-access.addExamQuestions 接口,复用 classes/data-access.getStudentIdsByClassIds P0-2 i18n 接入:新增 zh-CN/en 翻译文件,注册 lessonPreparation 命名空间,17 个组件改造为 useTranslations/getTranslations P1 纯函数抽取:lib/document-migration.ts(类型守卫替代 as 断言)、lib/node-summary.ts(翻译函数注入)、lib/rf-mappers.ts P1 错误边界+骨架屏:新增 LessonPlanErrorBoundary 和 4 个 Skeleton 组件 P1 Block 注册表:新增 config/block-registry.tsx(BlockRenderer 组件),node-edit-panel 重构为配置驱动渲染 P1 其他修复:exercise-block 改用 router.refresh(),node-editor/lesson-node 复用 lib/ 纯函数 架构图同步:更新 004 和 005 文档 Refs: docs/architecture/audit/lesson-preparation-audit-report.md
This commit is contained in:
@@ -671,8 +671,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction`
|
- 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`
|
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend`
|
||||||
|
- Lib(✅ P1-2 新增):`toNumber` / `normalize` / `buildScopeClassFilter`(从 3 个 data-access 文件抽取的公共工具函数)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
||||||
@@ -680,6 +681,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ P1-1 已修复:~~多处直查 `classes`/`classEnrollments`/`subjects`/`users` 表~~ 改为调用对应模块 data-access 函数(classes/school/users)
|
- ✅ P1-1 已修复:~~多处直查 `classes`/`classEnrollments`/`subjects`/`users` 表~~ 改为调用对应模块 data-access 函数(classes/school/users)
|
||||||
|
- ✅ P1-2 已修复:~~`toNumber`/`normalize`/`buildScopeClassFilter` 在 3 个 data-access 文件中重复定义~~ 抽取到 `lib/grade-utils.ts` 统一维护
|
||||||
|
- ✅ P1-3 已修复:~~12 个查询/分析 Action 缺少 Zod 校验~~ 新增 12 个查询 schema(DeleteGradeRecordSchema/GetGradeRecordByIdSchema/GradeQuerySchema/ClassGradeStatsQuerySchema/StudentGradeSummaryQuerySchema/ClassRankingQuerySchema/ExportGradesSchema/GradeTrendQuerySchema/ClassComparisonQuerySchema/SubjectComparisonQuerySchema/GradeDistributionQuerySchema/RankingTrendQuerySchema),所有 Action 使用 safeParse 校验
|
||||||
|
- ✅ P1-4 已修复:~~`batch-grade-entry.tsx`/`grade-record-form.tsx`/`grade-distribution-chart.tsx` 中存在 `as` 断言~~ 改用类型守卫函数(isGradeType/isSemester/isDistributionTooltipPayload)
|
||||||
|
- ✅ P2-2 已修复:~~diagnostic 组件中存在 Tailwind 任意值~~ 改用标准 Tailwind 类
|
||||||
- ⚠️ P2:统计计算业务逻辑混入 data-access(`getClassGradeStats` / `getGradeDistribution`)
|
- ⚠️ P2:统计计算业务逻辑混入 data-access(`getClassGradeStats` / `getGradeDistribution`)
|
||||||
- ✅ actions 层无直接 DB 访问(标杆)
|
- ✅ actions 层无直接 DB 访问(标杆)
|
||||||
- ✅ data-access 按职责拆分为 3 个文件(标杆)
|
- ✅ data-access 按职责拆分为 3 个文件(标杆)
|
||||||
@@ -688,13 +693,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 312 | 6 个 Server Action |
|
| `actions.ts` | 359 | 7 个 Server Action(含 Zod 校验) |
|
||||||
| `actions-analytics.ts` | 133 | 5 个分析 Action |
|
| `actions-analytics.ts` | 175 | 5 个分析 Action(含 Zod 校验) |
|
||||||
| `data-access.ts` | 419 | 成绩 CRUD + 统计 |
|
| `data-access.ts` | 361 | 成绩 CRUD + 统计 |
|
||||||
| `data-access-analytics.ts` | 293 | 趋势/对比分析 |
|
| `data-access-analytics.ts` | 266 | 趋势/对比分析 |
|
||||||
| `data-access-ranking.ts` | 121 | 排名查询 |
|
| `data-access-ranking.ts` | 96 | 排名查询 |
|
||||||
| `export.ts` | 214 | Excel 导出 |
|
| `export.ts` | 214 | Excel 导出 |
|
||||||
| `schema.ts` | 52 | Zod 校验 |
|
| `schema.ts` | 100 | Zod 校验(含 12 个查询 schema) |
|
||||||
|
| `lib/grade-utils.ts` | 46 | 公共工具函数(toNumber/normalize/buildScopeClassFilter) |
|
||||||
| `types.ts` | - | 类型定义 |
|
| `types.ts` | - | 类型定义 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
289
docs/architecture/audit/lesson-preparation-audit-report.md
Normal file
289
docs/architecture/audit/lesson-preparation-audit-report.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# 备课模块审计报告
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22
|
||||||
|
> 审查范围:`src/modules/lesson-preparation/**`(34 个文件)+ `src/app/(dashboard)/teacher/lesson-plans/**`(3 个路由页面)
|
||||||
|
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.27、`docs/architecture/005_architecture_data.json` `modules.lesson_preparation`
|
||||||
|
> 前置状态:v3 已完成节点图编辑器重构(React Flow)+ P1/P2 问题修复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有实现概要
|
||||||
|
|
||||||
|
### 1.1 文件分布
|
||||||
|
|
||||||
|
| 层 | 路径 | 文件数 | 说明 |
|
||||||
|
|----|------|--------|------|
|
||||||
|
| 路由层 | `src/app/(dashboard)/teacher/lesson-plans/` | 3 个 `page.tsx` | 列表页 / 新建页 / 编辑页,均 `force-dynamic` |
|
||||||
|
| 模块层 - 数据 | `src/modules/lesson-preparation/` | 4 个 data-access + 2 个 service | data-access 按职责拆分(CRUD/versions/templates/knowledge) |
|
||||||
|
| 模块层 - Actions | `src/modules/lesson-preparation/` | 4 个 actions 文件 | actions/actions-publish/actions-ai/actions-kp |
|
||||||
|
| 模块层 - 组件 | `src/modules/lesson-preparation/components/` | 14 个组件 + 4 个 block + 1 个 node | 编辑器(NodeEditor + NodeEditPanel)、列表、卡片、筛选器、选择器、对话框 |
|
||||||
|
| 模块层 - Hook | `src/modules/lesson-preparation/hooks/` | 1 个(170 行) | `use-lesson-plan-editor.ts`(zustand 全局 store) |
|
||||||
|
| 模块层 - 其他 | `src/modules/lesson-preparation/` | types/schema/constants/seed-templates | 类型定义、Zod 校验、常量、种子 |
|
||||||
|
|
||||||
|
### 1.2 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[Route] /teacher/lesson-plans/page.tsx
|
||||||
|
└─▶ getLessonPlans({}, dataScope, userId) + getSubjectOptions()
|
||||||
|
└─▶ LessonPlanList (client) → getLessonPlansAction
|
||||||
|
|
||||||
|
[Route] /teacher/lesson-plans/new/page.tsx
|
||||||
|
└─▶ TemplatePicker (client) → createLessonPlanAction
|
||||||
|
|
||||||
|
[Route] /teacher/lesson-plans/[planId]/edit/page.tsx
|
||||||
|
├─▶ getLessonPlanById(planId, userId)
|
||||||
|
├─▶ getTeacherClasses({ teacherId })
|
||||||
|
└─▶ LessonPlanEditor (client)
|
||||||
|
├─▶ useLessonPlanEditor (zustand)
|
||||||
|
├─▶ NodeEditor (React Flow 画布)
|
||||||
|
├─▶ NodeEditPanel (侧边编辑)
|
||||||
|
│ ├─▶ RichTextBlock / ExerciseBlock / TextStudyBlock / ReflectionBlock
|
||||||
|
│ └─▶ KnowledgePointPicker → getKnowledgePointOptionsAction
|
||||||
|
│ QuestionBankPicker → getQuestionsAction (跨模块)
|
||||||
|
│ InlineQuestionEditor
|
||||||
|
│ PublishHomeworkDialog → publishLessonPlanHomeworkAction
|
||||||
|
├─▶ VersionHistoryDrawer → getLessonPlanVersionsAction / revertLessonPlanVersionAction
|
||||||
|
└─▶ 自动保存(debounce 3s)→ updateLessonPlanAction
|
||||||
|
定时版本(30min)→ saveLessonPlanVersionAction
|
||||||
|
|
||||||
|
publish-service.publishLessonPlanHomework
|
||||||
|
├─▶ questions/data-access.createQuestionWithRelations
|
||||||
|
├─▶ exams/data-access.persistExamDraft
|
||||||
|
├─▶ ⚠️ 直接 db.insert(examQuestions) ← 跨模块直查
|
||||||
|
├─▶ homework/data-access-write.createHomeworkAssignment
|
||||||
|
└─▶ ⚠️ 直接 db.select(classEnrollments) ← 跨模块直查
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 架构图记录情况
|
||||||
|
|
||||||
|
`004_architecture_impact_map.md` §2.27 与 `005_architecture_data.json` 已较完整记录该模块:
|
||||||
|
- ✅ 导出函数清单(dataAccess 22 个 + actions 15 个)
|
||||||
|
- ✅ 依赖关系(textbooks/questions/exams/homework/classes/files/shared/lib/ai/@xyflow/react)
|
||||||
|
- ✅ 文件清单(34 个)
|
||||||
|
- ✅ 数据结构 v1→v2 迁移说明
|
||||||
|
- ⚠️ **未记录** publish-service 中的两处跨模块直查(examQuestions / classEnrollments)
|
||||||
|
- ⚠️ **未记录** i18n 缺失状态
|
||||||
|
- ⚠️ **未记录** DataScope 过滤逻辑的安全隐患
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现存问题与原因分析
|
||||||
|
|
||||||
|
### 2.1 跨模块直接查询数据库(P0 — 架构违规)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [publish-service.ts:125-132](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L125-L132) | 直接 `db.insert(examQuestions)` 插入考试题目表(归属 exams 模块) | "模块间只能通过对方 data-access 通信,**禁止跨模块直接查询数据库表**" |
|
||||||
|
| [publish-service.ts:37-43](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L37-L43) | 直接 `db.select(classEnrollments)` 查询班级选课表(归属 classes 模块) | 同上 |
|
||||||
|
|
||||||
|
**原因**:发布作业时需要批量插入考试题目、查询班级学生,但 exams/classes 模块未暴露对应的跨模块写/读接口,开发者为图便利直接访问 DB。
|
||||||
|
|
||||||
|
**后果**:exams/classes 模块的表结构变更将直接破坏备课模块;数据完整性约束(如班级归属校验)被绕过;架构图与实现不一致,误导后续维护。
|
||||||
|
|
||||||
|
### 2.2 国际化完全缺失(P0 — 规范违规)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [constants.ts:4-17](file:///e:/Desktop/CICD/src/modules/lesson-preparation/constants.ts#L4-L17) | `BLOCK_TYPE_LABELS` 硬编码中文("教学目标"/"导入"/"新授"等 12 项) | "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" |
|
||||||
|
| [constants.ts:41-101](file:///e:/Desktop/CICD/src/modules/lesson-preparation/constants.ts#L41-L101) | `SYSTEM_TEMPLATES` 名称/hint 硬编码中文 | 同上 |
|
||||||
|
| [constants.ts:103-107](file:///e:/Desktop/CICD/src/modules/lesson-preparation/constants.ts#L103-L107) | `LESSON_PLAN_STATUS_LABELS` 硬编码中文 | 同上 |
|
||||||
|
| 所有组件 | "保存中..."/"未保存"/"已保存"/"添加节点"/"版本"/"画布为空"等数十处硬编码 | 同上 |
|
||||||
|
| 所有 actions | 返回中文错误消息("获取课案列表失败"/"创建课案失败"等) | 同上 |
|
||||||
|
| [i18n/request.ts:22-29](file:///e:/Desktop/CICD/src/i18n/request.ts#L22-L29) | 未加载 `lesson-preparation.json` 翻译文件 | i18n 基础设施未接入 |
|
||||||
|
| `messages/` 目录 | **无 `lesson-preparation.json`** | 翻译文件缺失 |
|
||||||
|
|
||||||
|
**后果**:无法切换语言;维护时需逐文件改字符串;与项目其他已 i18n 的模块(dashboard/classes/auth)不一致。
|
||||||
|
|
||||||
|
### 2.3 类型安全:大量 `as` 断言(P1 — 规范违规)
|
||||||
|
|
||||||
|
| 位置 | 代码 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [data-access.ts:52-58](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L52-L58) | `content as { version?: number }` / `content as LessonPlanDocument` / `content as LessonPlanDocumentV1` | "禁止 `as` 断言(除非从 `unknown` 转换或测试中)" |
|
||||||
|
| [data-access.ts:174](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L174) | `rows as unknown as LessonPlanListItem[]` | 双重断言绕过类型检查 |
|
||||||
|
| [data-access.ts:194](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L194) | `row as unknown as LessonPlan` | 同上 |
|
||||||
|
| [data-access-templates.ts:40](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts#L40) | `personalRows as unknown as LessonPlanTemplate[]` | 同上 |
|
||||||
|
| [data-access-knowledge.ts:25,43](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts#L25) | `rows as unknown as LessonPlanListItem[]` | 同上 |
|
||||||
|
| [publish-service.ts:56](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L56) | `rows[0] as unknown as {...}` | 同上 |
|
||||||
|
| [node-editor.tsx:39](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L39) | `data: { node: n } as Record<string, unknown>` | 断言绕过 React Flow 类型 |
|
||||||
|
| [node-edit-panel.tsx:61,69,78,82](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-edit-panel.tsx#L61) | `node.data as RichTextBlockData` / `as ExerciseBlockData` 等 | 联合类型未用类型守卫收窄 |
|
||||||
|
| [lesson-node.tsx:49](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/nodes/lesson-node.tsx#L49) | `data as { node: LessonPlanNode }` | 同上 |
|
||||||
|
| [inline-question-editor.tsx:76](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L76) | `e.target.value as never` | `as never` 绕过类型检查 |
|
||||||
|
|
||||||
|
**后果**:类型系统形同虚设;运行时数据结构与类型声明不符时无法被编译器捕获;重构时易引入隐蔽 bug。
|
||||||
|
|
||||||
|
### 2.4 安全性:DataScope 过滤逻辑过宽(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [data-access.ts:93-111](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L93-L111) | `buildScopeCondition` 对 `class_taught`/`grade_managed`/`class_members`/`children` 四种 scope 统一返回 `creatorId = userId OR status = published` | "所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" |
|
||||||
|
| 同上 | 教师可查看**所有** published 课案(不限学科/年级/班级) | 数据隔离不足 |
|
||||||
|
| 同上 | `class_members`(学生)/`children`(家长)scope 也返回 published 课案,但学生/家长角色未分配 `LESSON_PLAN_READ` 权限,**一旦分配则越权** | 权限边界依赖角色配置而非代码保证 |
|
||||||
|
|
||||||
|
**后果**:教师 A 可查看教师 B 的 published 课案(即使不同学科/年级);未来若给 student/parent 开放只读权限,将立即暴露全部 published 课案。
|
||||||
|
|
||||||
|
### 2.5 错误与边界处理:仅路由级 + 阻塞式 UI(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 全模块 | 无按数据区块的 Error Boundary(版本抽屉/题库选择器/知识点选择器/发布对话框任一异常导致整页崩溃) | "每个独立的数据区块必须用 React Error Boundary 包裹" |
|
||||||
|
| 全模块 | 无 Suspense + 骨架屏(版本列表/题库列表/知识点列表加载时仅显示"加载中..."文字) | "异步数据使用 React Suspense + 骨架屏" |
|
||||||
|
| [version-history-drawer.tsx:47](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx#L47) | `confirm("确认回退到 v${versionNo}?")` | 应使用 AlertDialog |
|
||||||
|
| [lesson-plan-card.tsx:52](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx#L52) | `confirm("确认归档此课案?")` | 同上 |
|
||||||
|
| [inline-question-editor.tsx:30](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L30) | `alert("请输入题干")` | 应使用 toast |
|
||||||
|
| [text-study-block.tsx:39](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/text-study-block.tsx#L39) | `alert("请先在课文中选中一段文本")` | 同上 |
|
||||||
|
| [exercise-block.tsx:155](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L155) | `window.location.reload()` | 应使用 `router.refresh()` |
|
||||||
|
|
||||||
|
**后果**:单个 Widget 故障导致整页不可用;`alert/confirm` 阻塞主线程且不可定制样式;`window.location.reload()` 丢失未保存的编辑器状态。
|
||||||
|
|
||||||
|
### 2.6 可测试性:纯逻辑与 UI 耦合 + 全局 store(P1)
|
||||||
|
|
||||||
|
| 位置 | 耦合的逻辑 | 违反规则 |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| [use-lesson-plan-editor.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts) | zustand **全局单例** store,组件直接 `useLessonPlanEditor()` 订阅 | "组合优先:逻辑复用一律抽取为自定义 hooks" — 全局 store 无法多实例、无法注入 mock |
|
||||||
|
| [data-access.ts:31-90](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L31-L90) | `migrateV1ToV2`/`normalizeDocument`/`buildInitialContent` 为纯函数但与 DB 操作同文件 | 纯函数应独立便于单测 |
|
||||||
|
| [lesson-node.tsx:24-43](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/nodes/lesson-node.tsx#L24-L43) | `getNodeSummary` 业务逻辑内联在组件中 | "数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks" |
|
||||||
|
| [node-editor.tsx:33-54](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L33-L54) | `rfNodes`/`rfEdges` 映射逻辑内联在组件 useMemo 中 | 同上 |
|
||||||
|
|
||||||
|
**后果**:无法对迁移/规范化/摘要逻辑做单元测试;编辑器无法多实例(如对比两个课案);组件无法独立测试(依赖全局 store)。
|
||||||
|
|
||||||
|
### 2.7 可复用性:角色零共享 + 无配置驱动(P1)
|
||||||
|
|
||||||
|
| 维度 | 现状 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 角色覆盖 | 仅 teacher 角色可访问(admin 有权限但无 UI 入口);student/parent 完全无法查看 published 课案 | "最大化复用:识别四个角色共用的 UI 块和业务逻辑块" |
|
||||||
|
| 配置驱动 | 无角色配置,新增角色需新建整套组件 | "采用配置驱动设计,例如通过角色配置决定该模块渲染哪些 Widget/子模块" |
|
||||||
|
| 数据服务注入 | 组件直接 import actions(`getLessonPlansAction`/`updateLessonPlanAction` 等),无法替换实现 | "通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务" |
|
||||||
|
| Block 渲染 | `NodeEditPanel` 用 if/else 链渲染 4 种 block 类型,新增 block 类型需改组件 | 应改为注册表/配置驱动 |
|
||||||
|
|
||||||
|
**后果**:无法支持 admin 查看全校课案统计、student/parent 查看教师发布的课案;未来新增角色(如教研组长)需重写模块;组件无法独立复用。
|
||||||
|
|
||||||
|
### 2.8 可访问性:缺失(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 所有图标按钮 | 无 `aria-label`(如 `<X className="w-4 h-4" />` 关闭按钮) | "语义化标签、ARIA 属性、键盘导航" |
|
||||||
|
| 所有模态对话框 | 无 `role="dialog"`/`aria-modal`/焦点陷阱 | 同上 |
|
||||||
|
| [node-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx) | React Flow 画布无键盘导航支持(Tab/方向键无法聚焦/移动节点) | 同上 |
|
||||||
|
| [lesson-plan-filters.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-filters.tsx) | `<select>` 无 `<label>` 关联 | 同上 |
|
||||||
|
| [exercise-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx) | 题目列表用 `<div>` 非 `<ul>/<li>` | 语义化标签缺失 |
|
||||||
|
|
||||||
|
### 2.9 性能:全量 force-dynamic + 无流式渲染(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 所有 `page.tsx` | `export const dynamic = "force-dynamic"`,`Promise.all` 等全部数据就绪后才渲染 | "优先使用 React Server Components 获取初始数据;支持流式渲染" |
|
||||||
|
| 编辑器自动保存 | debounce 3s 但每次保存整个 `content` JSON(含全部 nodes/edges),无增量/diff | 大课案(100+ 节点)保存开销大 |
|
||||||
|
| [question-bank-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/question-bank-picker.tsx) | 搜索时全量加载题目,无虚拟滚动 | 题库大时卡顿 |
|
||||||
|
|
||||||
|
### 2.10 监控:无埋点(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 全模块 | 无任何操作埋点(创建/保存/发布/回退/复制等关键操作未记录) | "监控:方案中预留关键操作埋点接口" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业差距对比
|
||||||
|
|
||||||
|
### 3.1 K12 备课模块主流设计模式
|
||||||
|
|
||||||
|
| 模式 | 行业实践(如希沃白板/钉钉教育/企业微信教育/PowerSchool) | 本项目现状 | 差距影响 |
|
||||||
|
|------|----------|------------|----------|
|
||||||
|
| **多角色协同** | 教研组长审核课案、教师共享/协作编辑、学生查看预习案、家长查看教学进度 | 仅教师可访问 | 教研活动无法线上化;学生/家长无法了解教学进度 |
|
||||||
|
| **课案库/共享** | 校内/年级/学科共享课案库,支持 fork/收藏/评分 | 无共享机制 | 优质课案无法复用,教师重复造轮子 |
|
||||||
|
| **模板生态** | 学科专属模板、区级/市级优秀模板下发、模板市场 | 仅 5 个系统模板(内存常量) | 模板覆盖面不足,无法按学科/课型细分 |
|
||||||
|
| **教材联动** | 拖拽教材章节自动生成课案骨架、教材资源一键插入 | 仅按章节过滤列表,无深度联动 | 备课效率低,需手动复制教材内容 |
|
||||||
|
| **学情数据嵌入** | 课案中嵌入上次作业正确率/知识点掌握度,辅助教学决策 | 无 | 教师备课缺乏数据支撑,无法精准教学 |
|
||||||
|
| **协作编辑** | 多人实时协作(如腾讯文档/飞书文档模式) | 单人编辑 | 教研组无法协同备课 |
|
||||||
|
| **导出/打印** | 一键导出 PDF/Word/图片,支持打印备课稿 | 无 | 教师需手动截图,无法线下使用 |
|
||||||
|
| **版本对比** | 版本间 diff 可视化(高亮增删改) | 仅列表,无 diff | 教师无法直观看到版本差异 |
|
||||||
|
| **AI 辅助** | AI 生成教学目标/活动设计/习题/板书,AI 评课 | 仅 AI 推荐知识点 | AI 能力单薄,未覆盖备课全流程 |
|
||||||
|
| **资源管理** | 附件/图片/视频/音频统一管理,支持拖拽上传 | 依赖 files 模块但未深度集成 | 多媒体备课体验差 |
|
||||||
|
| **课案与作业联动** | 课案直接下发为作业/考试,作业数据回流课案 | 有发布为作业功能但单向(无回流) | 教师无法基于作业反馈优化课案 |
|
||||||
|
| **空状态引导** | 新手引导/示例课案/视频教程 | 仅"暂无课案"文字 | 新教师上手慢 |
|
||||||
|
|
||||||
|
### 3.2 各角色差距详述
|
||||||
|
|
||||||
|
**Teacher(当前唯一角色)**:
|
||||||
|
- 缺少教研组协作入口
|
||||||
|
- 缺少学情数据嵌入(上次作业正确率/常见错误)
|
||||||
|
- 缺少课案库/共享机制
|
||||||
|
- 缺少导出/打印
|
||||||
|
- 缺少版本 diff
|
||||||
|
- AI 能力仅限知识点推荐,未覆盖目标/活动/习题生成
|
||||||
|
|
||||||
|
**Admin(有权限无 UI)**:
|
||||||
|
- 无法查看全校课案统计(按学科/年级/教师分布)
|
||||||
|
- 无法管理/下发校级/区级模板
|
||||||
|
- 无法审核/下架不当课案
|
||||||
|
|
||||||
|
**Student(无权限无 UI)**:
|
||||||
|
- 无法查看教师发布的预习案/复习案
|
||||||
|
- 无法查看课案中的学习目标/重难点
|
||||||
|
|
||||||
|
**Parent(无权限无 UI)**:
|
||||||
|
- 无法了解孩子本周学习内容/教学进度
|
||||||
|
- 无法查看教师发布的教学计划
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进优先级建议
|
||||||
|
|
||||||
|
### P0(紧急 — 架构合规与安全)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P0-1 | publish-service 跨模块直查 examQuestions/classEnrollments | exams 模块新增 `addExamQuestions(examId, items)` 跨模块写接口;classes 模块新增 `getStudentIdsByClassIds(classIds)` 跨模块读接口(若已存在则复用) |
|
||||||
|
| P0-2 | i18n 完全缺失 | 创建 `messages/{zh-CN,en}/lesson-preparation.json`;`i18n/request.ts` 加载该文件;所有组件接入 `useTranslations`/`getTranslations`;constants 中的标签改为 i18n 键 |
|
||||||
|
| P0-3 | DataScope 过滤过宽 | `buildScopeCondition` 对 `class_taught` scope 增加学科/年级过滤(`subjectId IN teacher.subjects AND gradeId IN teacher.grades`);对 `class_members`/`children` 仅允许查看 published 且关联自己班级/孩子的课案 |
|
||||||
|
|
||||||
|
### P1(较严重 — 架构与质量)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P1-1 | 类型安全:大量 `as` 断言 | data-access 用 Drizzle 的 `inferSelect` 类型;`normalizeDocument` 用类型守卫收窄;block data 用判别联合 + 类型守卫函数 |
|
||||||
|
| P1-2 | 错误边界缺失 | 创建 `LessonPlanErrorBoundary` 组件,包裹版本抽屉/题库选择器/知识点选择器/发布对话框;每个区块独立 fallback |
|
||||||
|
| P1-3 | 骨架屏缺失 | 为版本列表/题库列表/知识点列表创建 `Skeleton` 组件,配合 Suspense |
|
||||||
|
| P1-4 | alert/confirm/window.location.reload | 替换为 `AlertDialog`(shadcn)+ `sonner` toast + `router.refresh()` |
|
||||||
|
| P1-5 | 全局 zustand store 无法多实例/测试 | 改为 React Context + useReducer,或保留 zustand 但通过 Context 注入 store 实例 |
|
||||||
|
| P1-6 | 纯逻辑与 UI 耦合 | 抽取 `lib/document-migration.ts`(migrateV1ToV2/normalizeDocument/buildInitialContent)、`lib/node-summary.ts`(getNodeSummary)、`lib/rf-mappers.ts`(toRfNodes/toRfEdges) |
|
||||||
|
| P1-7 | 角色零共享 + 无配置驱动 | 定义 `LessonPlanRoleConfig`(角色 → 可见 Widget/操作);定义 `LessonPlanDataService` 接口,各角色不同实现;通过 `LessonPlanProvider` 注入 |
|
||||||
|
| P1-8 | Block 渲染 if/else 链 | 改为注册表模式:`BLOCK_REGISTRY: Record<BlockType, BlockComponent>`,新增 block 类型只需注册 |
|
||||||
|
|
||||||
|
### P2(优化 — 体验与扩展)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P2-1 | a11y 缺失 | 图标按钮加 `aria-label`;模态对话框加 `role="dialog"`/`aria-modal`/焦点陷阱;`<select>` 关联 `<label>`;题目列表用 `<ul>/<li>` |
|
||||||
|
| P2-2 | 无流式渲染 | 列表页改用 RSC + `<Suspense>` 包裹各区块;编辑器初始数据用 RSC 获取 |
|
||||||
|
| P2-3 | 无单测 | 为 `lib/document-migration.ts`/`lib/node-summary.ts`/`lib/rf-mappers.ts`/`buildScopeCondition` 添加单测 |
|
||||||
|
| P2-4 | 无监控埋点 | 预留 `trackLessonPlanEvent(event, payload)` 接口,在 create/save/publish/revert/duplicate 处调用 |
|
||||||
|
| P2-5 | 无导出/打印 | 新增 `exportLessonPlanToPdf`/`exportLessonPlanToDocx` |
|
||||||
|
| P2-6 | 无版本 diff | 新增 `diffDocuments(docA, docB)` 纯函数 + 可视化组件 |
|
||||||
|
| P2-7 | AI 能力单薄 | 扩展 `ai-suggest.ts`:`suggestObjectives`/`suggestActivities`/`suggestExercises`/`suggestBlackboard` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构图同步说明
|
||||||
|
|
||||||
|
本次审计发现架构图存在以下遗漏,需在实现后同步更新:
|
||||||
|
|
||||||
|
### 5.1 `004_architecture_impact_map.md` 需补充
|
||||||
|
|
||||||
|
1. **§2.27 已知问题**:新增"publish-service 跨模块直查 examQuestions/classEnrollments"(P0-1)
|
||||||
|
2. **§2.27 文件清单**:新增 `lib/document-migration.ts`、`lib/node-summary.ts`、`lib/rf-mappers.ts`、`components/lesson-plan-error-boundary.tsx`、`components/lesson-plan-skeleton.tsx`、`providers/lesson-plan-provider.tsx`、`config/role-config.ts`、`services/data-service.ts`(接口)
|
||||||
|
3. **§2.27 依赖关系**:标注 publish-service 改为通过 exams/classes data-access 跨模块通信
|
||||||
|
4. **§2.27 已知问题**:新增"i18n 缺失"(P0-2)、"DataScope 过滤过宽"(P0-3)
|
||||||
|
|
||||||
|
### 5.2 `005_architecture_data.json` 需修改
|
||||||
|
|
||||||
|
1. `modules.lesson_preparation.exports.dataAccess`:新增 exams/classes 跨模块接口调用说明
|
||||||
|
2. `modules.lesson_preparation.files`:新增上述 8 个文件
|
||||||
|
3. `modules.lesson_preparation.dependencies`:确认 exams/classes 已存在(✅),但需标注 publish-service 不再直查
|
||||||
|
4. `modules.lesson_preparation` 新增 `i18n` 字段:`{ "namespace": "lesson-preparation", "status": "planned" }`
|
||||||
|
|
||||||
|
### 5.3 无需修改部分
|
||||||
|
|
||||||
|
- 数据库表结构(lessonPlans/lessonPlanVersions/lessonPlanTemplates)无变更
|
||||||
|
- 权限点(LESSON_PLAN_*)无变更
|
||||||
|
- 路由(3 个页面)无变更
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function NewLessonPlanPage(): JSX.Element {
|
export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation")
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href="/teacher/lesson-plans">
|
<Link href="/teacher/lesson-plans">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
返回备课列表
|
{t("action.back")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">新建课案</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<TemplatePicker />
|
<TemplatePicker />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||||
@@ -10,6 +11,7 @@ import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-p
|
|||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function LessonPlansPage(): Promise<JSX.Element> {
|
export default async function LessonPlansPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation")
|
||||||
const ctx = await getAuthContext()
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
const [items, subjects] = await Promise.all([
|
const [items, subjects] = await Promise.all([
|
||||||
@@ -21,15 +23,15 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">My Lesson Plans</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.list")}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage your lesson preparation and teaching plans.
|
{t("description.list")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/teacher/lesson-plans/new">
|
<Link href="/teacher/lesson-plans/new">
|
||||||
<Plus className="h-4 w-4 mr-2" aria-hidden="true" />
|
<Plus className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||||
New Lesson Plan
|
{t("action.new")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -713,3 +713,22 @@ export const getExamSubmissionsForExam = async (
|
|||||||
.where(eq(examSubmissions.examId, examId))
|
.where(eq(examSubmissions.examId, examId))
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量插入考试题目(跨模块写接口)。
|
||||||
|
* 供 lesson-preparation 模块发布课案为作业时使用,避免直接操作 examQuestions 表。
|
||||||
|
*/
|
||||||
|
export const addExamQuestions = async (
|
||||||
|
examId: string,
|
||||||
|
items: Array<{ questionId: string; score: number; order: number }>
|
||||||
|
): Promise<void> => {
|
||||||
|
if (items.length === 0) return
|
||||||
|
await db.insert(examQuestions).values(
|
||||||
|
items.map((it) => ({
|
||||||
|
examId,
|
||||||
|
questionId: it.questionId,
|
||||||
|
score: it.score,
|
||||||
|
order: it.order,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
import { QuestionBankPicker } from "../question-bank-picker";
|
import { QuestionBankPicker } from "../question-bank-picker";
|
||||||
import { InlineQuestionEditor } from "../inline-question-editor";
|
import { InlineQuestionEditor } from "../inline-question-editor";
|
||||||
@@ -10,15 +12,20 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import type {
|
import type {
|
||||||
ExerciseBlockData,
|
ExerciseBlockData,
|
||||||
ExerciseItem,
|
ExerciseItem,
|
||||||
|
ExercisePurpose,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blockId: string;
|
blockId: string;
|
||||||
data: ExerciseBlockData;
|
data: ExerciseBlockData;
|
||||||
classes: { id: string; name: string }[];
|
classes: { id: string; name: string }[];
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExerciseBlock({ blockId, data, classes }: Props) {
|
export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const router = useRouter();
|
||||||
const { updateNode, planId } = useLessonPlanEditor();
|
const { updateNode, planId } = useLessonPlanEditor();
|
||||||
const [showBank, setShowBank] = useState(false);
|
const [showBank, setShowBank] = useState(false);
|
||||||
const [showInline, setShowInline] = useState(false);
|
const [showInline, setShowInline] = useState(false);
|
||||||
@@ -49,34 +56,34 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
<select
|
<select
|
||||||
value={data.purpose}
|
value={data.purpose}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update({ purpose: e.target.value as never })
|
update({ purpose: e.target.value as ExercisePurpose })
|
||||||
}
|
}
|
||||||
className="border rounded px-2 py-1 text-sm"
|
className="border rounded px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
<option value="class_practice">课堂练习</option>
|
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
|
||||||
<option value="after_class_homework">课后作业</option>
|
<option value="after_class_homework">{t("exercise.purpose.after_class_homework")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
|
<p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
|
||||||
暂无题目,点击下方按钮添加
|
{t("questionBank.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{data.items.map((item, idx) => (
|
{data.items.map((item, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={item.questionId}
|
||||||
className="flex items-center gap-2 border rounded p-2"
|
className="flex items-center gap-2 border rounded p-2"
|
||||||
>
|
>
|
||||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||||
{item.source === "bank" ? "题库" : "新建"}
|
{item.source === "bank" ? t("questionBank.source.bank") : t("questionBank.source.inline")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm flex-1 truncate">
|
<span className="text-sm flex-1 truncate">
|
||||||
{item.source === "bank"
|
{item.source === "bank"
|
||||||
? `题目 ${item.questionId.slice(0, 8)}`
|
? t("questionBank.questionId", { id: item.questionId.slice(0, 8) })
|
||||||
: "课案内新建题目"}
|
: t("questionBank.inlineQuestion")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">{item.score}分</span>
|
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
|
||||||
<button onClick={() => removeItem(idx)}>
|
<button onClick={() => removeItem(idx)}>
|
||||||
<Trash2 className="w-3 h-3 text-error" />
|
<Trash2 className="w-3 h-3 text-error" />
|
||||||
</button>
|
</button>
|
||||||
@@ -91,7 +98,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
onClick={() => setShowBank(true)}
|
onClick={() => setShowBank(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
从题库添加
|
{t("questionBank.fromBank")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -99,18 +106,18 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
onClick={() => setShowInline(true)}
|
onClick={() => setShowInline(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
新建题目
|
{t("questionBank.inlineNew")}
|
||||||
</Button>
|
</Button>
|
||||||
{data.publishedAssignmentId ? (
|
{data.publishedAssignmentId ? (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
|
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
|
||||||
已发布为作业
|
{t("status.publishedAsHomework")}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href="/teacher/homework"
|
href="/teacher/homework"
|
||||||
className="text-primary underline"
|
className="text-primary underline"
|
||||||
>
|
>
|
||||||
查看
|
{t("action.viewHomework")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -120,7 +127,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPublish(true)}
|
onClick={() => setShowPublish(true)}
|
||||||
>
|
>
|
||||||
发布为作业
|
{t("action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -134,6 +141,8 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
)}
|
)}
|
||||||
{showInline && (
|
{showInline && (
|
||||||
<InlineQuestionEditor
|
<InlineQuestionEditor
|
||||||
|
textbookId={textbookId}
|
||||||
|
chapterId={chapterId}
|
||||||
onAdd={(item) => {
|
onAdd={(item) => {
|
||||||
addItems([item]);
|
addItems([item]);
|
||||||
setShowInline(false);
|
setShowInline(false);
|
||||||
@@ -147,7 +156,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
|
|||||||
blockId={blockId}
|
blockId={blockId}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
onClose={() => setShowPublish(false)}
|
onClose={() => setShowPublish(false)}
|
||||||
onPublished={() => window.location.reload()}
|
onPublished={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { RichTextBlock } from "./rich-text-block";
|
import { RichTextBlock } from "./rich-text-block";
|
||||||
import type { RichTextBlockData } from "../../types";
|
import type { RichTextBlockData } from "../../types";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReflectionBlock(props: Props) {
|
export function ReflectionBlock(props: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
// 教学反思在 P1 阶段与普通富文本一致,P3 再扩展学情数据嵌入
|
// 教学反思在 P1 阶段与普通富文本一致,P3 再扩展学情数据嵌入
|
||||||
return <RichTextBlock {...props} hint="课后填写教学反思..." />;
|
return <RichTextBlock {...props} hint={t("reflection.hint")} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEditor, EditorContent } from "@tiptap/react";
|
|||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import type { RichTextBlockData } from "../../types";
|
import type { RichTextBlockData } from "../../types";
|
||||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||||
import { Tag } from "lucide-react";
|
import { Tag } from "lucide-react";
|
||||||
@@ -23,10 +24,11 @@ export function RichTextBlock({
|
|||||||
chapterId,
|
chapterId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
|
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
|
||||||
],
|
],
|
||||||
content: data.html,
|
content: data.html,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
@@ -56,7 +58,7 @@ export function RichTextBlock({
|
|||||||
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
|
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
|
||||||
{data.knowledgePointIds.length > 0 && (
|
{data.knowledgePointIds.length > 0 && (
|
||||||
<span className="text-xs text-on-surface-variant">
|
<span className="text-xs text-on-surface-variant">
|
||||||
已关联 {data.knowledgePointIds.length} 个知识点
|
{t("knowledgePoint.linked", { count: data.knowledgePointIds.length })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -64,7 +66,7 @@ export function RichTextBlock({
|
|||||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
标注知识点
|
{t("knowledgePoint.annotate")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showKpPicker && (
|
{showKpPicker && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
@@ -16,6 +17,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TextStudyBlock({ blockId, data }: Props) {
|
export function TextStudyBlock({ blockId, data }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const { updateNode } = useLessonPlanEditor();
|
const { updateNode } = useLessonPlanEditor();
|
||||||
const [selection, setSelection] = useState<{
|
const [selection, setSelection] = useState<{
|
||||||
start: number;
|
start: number;
|
||||||
@@ -26,26 +28,23 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
updateNode(blockId, { data: { ...data, ...patch } });
|
updateNode(blockId, { data: { ...data, ...patch } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextSelect() {
|
function handleTextSelect(e: React.MouseEvent<HTMLTextAreaElement>) {
|
||||||
const sel = window.getSelection();
|
const textarea = e.currentTarget;
|
||||||
if (!sel || sel.rangeCount === 0) return;
|
const start = textarea.selectionStart;
|
||||||
const range = sel.getRangeAt(0);
|
const end = textarea.selectionEnd;
|
||||||
// 简化:用相对 sourceText 的字符偏移
|
|
||||||
const start = range.startOffset;
|
|
||||||
const end = range.endOffset;
|
|
||||||
if (end > start) setSelection({ start, end });
|
if (end > start) setSelection({ start, end });
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAnnotation() {
|
function addAnnotation() {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
alert("请先在课文中选中一段文本");
|
alert(t("textStudy.selectFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ann: TextStudyAnnotation = {
|
const ann: TextStudyAnnotation = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
anchor: selection,
|
anchor: selection,
|
||||||
nodeType: "language_feature",
|
nodeType: "language_feature",
|
||||||
title: "教学节点",
|
title: t("textStudy.annotationTitle"),
|
||||||
note: "",
|
note: "",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
};
|
};
|
||||||
@@ -62,13 +61,13 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">课文原文</label>
|
<label className="text-sm font-medium">{t("textStudy.sourceTextLabel")}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={data.sourceText}
|
value={data.sourceText}
|
||||||
onChange={(e) => update({ sourceText: e.target.value })}
|
onChange={(e) => update({ sourceText: e.target.value })}
|
||||||
onMouseUp={handleTextSelect}
|
onMouseUp={handleTextSelect}
|
||||||
className="w-full border rounded p-2 mt-1 min-h-[120px] font-serif leading-loose"
|
className="w-full border rounded p-2 mt-1 min-h-[120px] font-serif leading-loose"
|
||||||
placeholder="粘贴课文原文,选中文本后可添加教学节点"
|
placeholder={t("textStudy.sourceTextPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -78,7 +77,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
disabled={!selection}
|
disabled={!selection}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
为选中文本添加节点
|
{t("textStudy.addAnnotation")}
|
||||||
</Button>
|
</Button>
|
||||||
{data.annotations.length > 0 && (
|
{data.annotations.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -117,7 +116,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
|
className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
|
||||||
placeholder="教学说明..."
|
placeholder={t("textStudy.annotationNotePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X, Tag } from "lucide-react";
|
||||||
|
import { KnowledgePointPicker } from "./knowledge-point-picker";
|
||||||
import type { ExerciseItem, InlineQuestionContent } from "../types";
|
import type { ExerciseItem, InlineQuestionContent } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onAdd: (item: ExerciseItem) => void;
|
onAdd: (item: ExerciseItem) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [type, setType] = useState<
|
const [type, setType] = useState<
|
||||||
"single_choice" | "text" | "judgment"
|
"single_choice" | "text" | "judgment"
|
||||||
>("single_choice");
|
>("single_choice");
|
||||||
@@ -19,11 +24,12 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [options, setOptions] = useState<string[]>(["", ""]);
|
const [options, setOptions] = useState<string[]>(["", ""]);
|
||||||
const [correctIdx, setCorrectIdx] = useState(0);
|
const [correctIdx, setCorrectIdx] = useState(0);
|
||||||
const kpIds: string[] = [];
|
const [kpIds, setKpIds] = useState<string[]>([]);
|
||||||
|
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
alert("请输入题干");
|
alert(t("questionBank.stemRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content: Record<string, unknown> =
|
const content: Record<string, unknown> =
|
||||||
@@ -59,26 +65,26 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">新建题目(课案内)</h3>
|
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">题型</label>
|
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as never)}
|
onChange={(e) => setType(e.target.value as never)}
|
||||||
className="w-full border rounded px-2 py-1 mt-1"
|
className="w-full border rounded px-2 py-1 mt-1"
|
||||||
>
|
>
|
||||||
<option value="single_choice">单选题</option>
|
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
|
||||||
<option value="text">填空题</option>
|
<option value="text">{t("questionBank.type.text")}</option>
|
||||||
<option value="judgment">判断题</option>
|
<option value="judgment">{t("questionBank.type.judgment")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">题干</label>
|
<label className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
@@ -88,7 +94,7 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
{type === "single_choice" && (
|
{type === "single_choice" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
选项(勾选正确答案)
|
{t("questionBank.optionsLabel")}
|
||||||
</label>
|
</label>
|
||||||
{options.map((opt, i) => (
|
{options.map((opt, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 mt-1">
|
<div key={i} className="flex items-center gap-2 mt-1">
|
||||||
@@ -124,14 +130,14 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
onClick={() => setOptions([...options, ""])}
|
onClick={() => setOptions([...options, ""])}
|
||||||
className="text-sm text-primary mt-1"
|
className="text-sm text-primary mt-1"
|
||||||
>
|
>
|
||||||
+ 添加选项
|
{t("questionBank.addOption")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{type === "judgment" && (
|
{type === "judgment" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">正确答案</label>
|
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
|
||||||
<div className="flex gap-3 mt-1">
|
<div className="flex gap-3 mt-1">
|
||||||
<label className="flex items-center gap-1">
|
<label className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -139,7 +145,7 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
checked={correctIdx === 0}
|
checked={correctIdx === 0}
|
||||||
onChange={() => setCorrectIdx(0)}
|
onChange={() => setCorrectIdx(0)}
|
||||||
/>
|
/>
|
||||||
正确
|
{t("questionBank.correct")}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1">
|
<label className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -147,13 +153,13 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
checked={correctIdx === 1}
|
checked={correctIdx === 1}
|
||||||
onChange={() => setCorrectIdx(1)}
|
onChange={() => setCorrectIdx(1)}
|
||||||
/>
|
/>
|
||||||
错误
|
{t("questionBank.incorrect")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">难度</label>
|
<label className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
|
||||||
<select
|
<select
|
||||||
value={difficulty}
|
value={difficulty}
|
||||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||||
@@ -161,19 +167,46 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((d) => (
|
{[1, 2, 3, 4, 5].map((d) => (
|
||||||
<option key={d} value={d}>
|
<option key={d} value={d}>
|
||||||
{d}星
|
{t("questionBank.difficulty", { level: d })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{kpIds.length > 0 && (
|
||||||
|
<span className="text-xs text-on-surface-variant">
|
||||||
|
{t("knowledgePoint.selected", { count: kpIds.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKpPicker(true)}
|
||||||
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{t("knowledgePoint.select")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t flex justify-end gap-2">
|
<div className="p-4 border-t flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
取消
|
{t("action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAdd}>添加</Button>
|
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showKpPicker && (
|
||||||
|
<KnowledgePointPicker
|
||||||
|
textbookId={textbookId}
|
||||||
|
chapterId={chapterId}
|
||||||
|
selectedIds={kpIds}
|
||||||
|
onChange={setKpIds}
|
||||||
|
onClose={() => setShowKpPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||||
@@ -25,6 +26,7 @@ export function KnowledgePointPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [options, setOptions] = useState<KpOption[]>([]);
|
const [options, setOptions] = useState<KpOption[]>([]);
|
||||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ export function KnowledgePointPicker({
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
||||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
<h3 className="font-title-md">选择知识点</h3>
|
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -55,7 +57,7 @@ export function KnowledgePointPicker({
|
|||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-sm">
|
<p className="text-on-surface-variant text-sm">
|
||||||
未找到知识点,请先在教材模块创建
|
{t("knowledgePoint.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -77,7 +79,7 @@ export function KnowledgePointPicker({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
|
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
取消
|
{t("action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -86,7 +88,7 @@ export function KnowledgePointPicker({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
确认
|
{t("action.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { LESSON_PLAN_STATUS_LABELS } from "../constants";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||||
import type { LessonPlanListItem } from "../types";
|
import type { LessonPlanListItem } from "../types";
|
||||||
|
|
||||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||||
<Link
|
<Link
|
||||||
@@ -18,17 +23,17 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="text-sm text-on-surface-variant mt-1">
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
{plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
|
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-on-surface-variant mt-1">
|
<div className="text-xs text-on-surface-variant mt-1">
|
||||||
{plan.templateName ?? "无模板"} ·{" "}
|
{plan.templateName ?? t("list.noTemplate")} ·{" "}
|
||||||
{LESSON_PLAN_STATUS_LABELS[plan.status]}
|
{t(`status.${plan.status}`)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-on-surface-variant mt-2">
|
<div className="text-xs text-on-surface-variant mt-2">
|
||||||
最后保存:
|
{t("list.lastSaved")}
|
||||||
{plan.lastSavedAt
|
{plan.lastSavedAt
|
||||||
? new Date(plan.lastSavedAt).toLocaleString()
|
? formatDateTime(plan.lastSavedAt)
|
||||||
: "未保存"}
|
: t("list.neverSaved")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -36,21 +41,21 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const res = await duplicateLessonPlanAction(plan.id);
|
const res = await duplicateLessonPlanAction(plan.id);
|
||||||
if (res.success) window.location.reload();
|
if (res.success) router.refresh();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制
|
{t("action.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm("确认归档此课案?")) return;
|
if (!confirm(t("confirm.archive"))) return;
|
||||||
const res = await deleteLessonPlanAction(plan.id);
|
const res = await deleteLessonPlanAction(plan.id);
|
||||||
if (res.success) window.location.reload();
|
if (res.success) router.refresh();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
归档
|
{t("action.archive")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { NodeEditor } from "./node-editor";
|
import { NodeEditor } from "./node-editor";
|
||||||
import { NodeEditPanel } from "./node-edit-panel";
|
import { NodeEditPanel } from "./node-edit-panel";
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
saveLessonPlanVersionAction,
|
saveLessonPlanVersionAction,
|
||||||
getLessonPlanByIdAction,
|
getLessonPlanByIdAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
|
||||||
import type { BlockType } from "../types";
|
import type { BlockType } from "../types";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Plus, Save, History } from "lucide-react";
|
import { Plus, Save, History } from "lucide-react";
|
||||||
@@ -47,10 +47,10 @@ export function LessonPlanEditor({
|
|||||||
chapterId,
|
chapterId,
|
||||||
classes,
|
classes,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const editor = useLessonPlanEditor();
|
const editor = useLessonPlanEditor();
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -62,11 +62,6 @@ export function LessonPlanEditor({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initKey]);
|
}, [initKey]);
|
||||||
|
|
||||||
// 选中节点时打开侧边面板
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor.selectedNodeId) setPanelOpen(true);
|
|
||||||
}, [editor.selectedNodeId]);
|
|
||||||
|
|
||||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor.isDirty) return;
|
if (!editor.isDirty) return;
|
||||||
@@ -95,13 +90,13 @@ export function LessonPlanEditor({
|
|||||||
await saveLessonPlanVersionAction({
|
await saveLessonPlanVersionAction({
|
||||||
planId: state.planId,
|
planId: state.planId,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
label: "自动版本",
|
label: t("version.autoLabel"),
|
||||||
});
|
});
|
||||||
}, 30 * 60 * 1000);
|
}, 30 * 60 * 1000);
|
||||||
return () => {
|
return () => {
|
||||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||||
};
|
};
|
||||||
}, [planId]);
|
}, [planId, t]);
|
||||||
|
|
||||||
// 离开未保存提示(P3-1)
|
// 离开未保存提示(P3-1)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,20 +153,20 @@ export function LessonPlanEditor({
|
|||||||
/>
|
/>
|
||||||
<span className="text-on-surface-variant text-sm">
|
<span className="text-on-surface-variant text-sm">
|
||||||
{editor.isSaving
|
{editor.isSaving
|
||||||
? "保存中..."
|
? t("status.saving")
|
||||||
: editor.isDirty
|
: editor.isDirty
|
||||||
? "未保存"
|
? t("status.unsaved")
|
||||||
: "已保存"}
|
: t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowVersions(true)}
|
onClick={() => setShowVersions(true)}
|
||||||
>
|
>
|
||||||
<History className="w-4 h-4 mr-1" /> 版本
|
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||||
<Save className="w-4 h-4 mr-1" /> 保存版本
|
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,31 +174,27 @@ export function LessonPlanEditor({
|
|||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* 节点画布 */}
|
{/* 节点画布 */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<NodeEditor
|
<NodeEditor />
|
||||||
textbookId={textbookId}
|
|
||||||
chapterId={chapterId}
|
|
||||||
classes={classes}
|
|
||||||
/>
|
|
||||||
{/* 添加节点浮动按钮 */}
|
{/* 添加节点浮动按钮 */}
|
||||||
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
|
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" /> 添加节点
|
<Plus className="w-4 h-4 mr-1" /> {t("action.addNode")}
|
||||||
</Button>
|
</Button>
|
||||||
{showAddMenu && (
|
{showAddMenu && (
|
||||||
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
|
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
|
||||||
{BLOCK_TYPES_TO_ADD.map((t) => (
|
{BLOCK_TYPES_TO_ADD.map((blockType) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={blockType}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.addNode(t);
|
editor.addNode(blockType);
|
||||||
setShowAddMenu(false);
|
setShowAddMenu(false);
|
||||||
}}
|
}}
|
||||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||||
>
|
>
|
||||||
{BLOCK_TYPE_LABELS[t]}
|
{t(`blockType.${blockType}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,8 +202,8 @@ export function LessonPlanEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 侧边内容编辑面板 */}
|
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2) */}
|
||||||
{panelOpen && editor.selectedNodeId && (
|
{editor.selectedNodeId && (
|
||||||
<div className="w-[420px] flex-shrink-0">
|
<div className="w-[420px] flex-shrink-0">
|
||||||
<NodeEditPanel
|
<NodeEditPanel
|
||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
/** 错误时的回调,用于上报埋点 */
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备课模块错误边界。
|
||||||
|
* 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框),
|
||||||
|
* 单个区块异常不影响整页。
|
||||||
|
*/
|
||||||
|
export class LessonPlanErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = (): void => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
|
||||||
|
<p className="text-sm text-on-surface-variant">
|
||||||
|
{this.state.error?.message ?? "区块加载失败"}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,6 +14,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [subjectId, setSubjectId] = useState<string>("");
|
const [subjectId, setSubjectId] = useState<string>("");
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
@@ -30,7 +32,7 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<input
|
<input
|
||||||
placeholder="搜索标题..."
|
placeholder={t("filters.searchPlaceholder")}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||||
@@ -40,7 +42,7 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
onChange={(e) => setSubjectId(e.target.value)}
|
onChange={(e) => setSubjectId(e.target.value)}
|
||||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">全部学科</option>
|
<option value="">{t("filters.allSubjects")}</option>
|
||||||
{subjects.map((s) => (
|
{subjects.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.name}
|
{s.name}
|
||||||
@@ -52,9 +54,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">{t("filters.allStatus")}</option>
|
||||||
<option value="draft">草稿</option>
|
<option value="draft">{t("status.draft")}</option>
|
||||||
<option value="published">已发布</option>
|
<option value="published">{t("status.published")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { LessonPlanCard } from "./lesson-plan-card";
|
import { LessonPlanCard } from "./lesson-plan-card";
|
||||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||||
import { getLessonPlansAction } from "../actions";
|
import { getLessonPlansAction } from "../actions";
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [items, setItems] = useState(initialItems);
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
async function handleFilter(params: {
|
async function handleFilter(params: {
|
||||||
@@ -28,7 +30,7 @@ export function LessonPlanList({ initialItems, subjects }: Props) {
|
|||||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-center py-12">
|
<p className="text-on-surface-variant text-center py-12">
|
||||||
暂无课案,点击“新建课案”开始
|
{t("list.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
/** 版本列表骨架屏 */
|
||||||
|
export function VersionListSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border border-outline-variant rounded-lg p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-3 w-8" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-6 w-28" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 题库列表骨架屏 */
|
||||||
|
export function QuestionBankSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border rounded p-2 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-4 flex-1 mr-2" />
|
||||||
|
<Skeleton className="h-3 w-16 mr-2" />
|
||||||
|
<Skeleton className="h-6 w-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 知识点列表骨架屏 */
|
||||||
|
export function KnowledgePointSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 p-2">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课案列表骨架屏 */
|
||||||
|
export function LessonPlanListSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border border-outline-variant rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
<Skeleton className="h-3 w-2/3" />
|
||||||
|
<Skeleton className="h-3 w-1/3" />
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
|
import { BlockRenderer } from "../config/block-registry";
|
||||||
import { RichTextBlock } from "./blocks/rich-text-block";
|
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
||||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
|
||||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
|
||||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Trash2, X } from "lucide-react";
|
import { Trash2, X } from "lucide-react";
|
||||||
import type {
|
|
||||||
ExerciseBlockData,
|
|
||||||
RichTextBlockData,
|
|
||||||
TextStudyBlockData,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -21,6 +14,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
||||||
useLessonPlanEditor();
|
useLessonPlanEditor();
|
||||||
|
|
||||||
@@ -29,13 +23,11 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
|
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
|
||||||
点击节点编辑内容,或拖拽连线建立流程
|
{t("editor.selectNodeHint")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||||
{/* 面板头部 */}
|
{/* 面板头部 */}
|
||||||
@@ -44,46 +36,33 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
|||||||
value={node.title}
|
value={node.title}
|
||||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||||
|
aria-label={node.title}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => selectNode(null)}
|
onClick={() => selectNode(null)}
|
||||||
|
aria-label={t("action.close")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容编辑区 */}
|
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
{isRichText ? (
|
<LessonPlanErrorBoundary>
|
||||||
<RichTextBlock
|
<BlockRenderer
|
||||||
data={node.data as RichTextBlockData}
|
type={node.type}
|
||||||
|
blockId={node.id}
|
||||||
|
data={node.data}
|
||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
chapterId={chapterId}
|
chapterId={chapterId}
|
||||||
|
classes={classes}
|
||||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||||
/>
|
/>
|
||||||
) : node.type === "exercise" ? (
|
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
|
||||||
<ExerciseBlock
|
<UnknownBlockHint type={node.type} t={t} />
|
||||||
blockId={node.id}
|
</LessonPlanErrorBoundary>
|
||||||
data={node.data as ExerciseBlockData}
|
|
||||||
classes={classes ?? []}
|
|
||||||
/>
|
|
||||||
) : node.type === "text_study" ? (
|
|
||||||
<TextStudyBlock
|
|
||||||
blockId={node.id}
|
|
||||||
data={node.data as TextStudyBlockData}
|
|
||||||
/>
|
|
||||||
) : node.type === "reflection" ? (
|
|
||||||
<ReflectionBlock
|
|
||||||
data={node.data as RichTextBlockData}
|
|
||||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-on-surface-variant text-sm p-4">
|
|
||||||
未知节点类型
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部操作 */}
|
{/* 底部操作 */}
|
||||||
@@ -94,10 +73,37 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
|||||||
className="text-error"
|
className="text-error"
|
||||||
onClick={() => removeNode(node.id)}
|
onClick={() => removeNode(node.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
删除此节点
|
{t("action.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未知 Block 类型提示(配置驱动,当 BlockRenderer 返回 null 时显示)。
|
||||||
|
* 独立组件避免在主组件中条件渲染。
|
||||||
|
*/
|
||||||
|
function UnknownBlockHint({
|
||||||
|
type,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
// 已知类型不显示提示
|
||||||
|
const knownTypes = [
|
||||||
|
"objective", "key_point", "import", "new_teaching", "consolidation",
|
||||||
|
"summary", "homework", "blackboard", "rich_text", "exercise",
|
||||||
|
"text_study", "reflection",
|
||||||
|
];
|
||||||
|
if (knownTypes.includes(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-on-surface-variant text-sm p-4">
|
||||||
|
{t("editor.unknownBlockType")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -11,48 +12,33 @@ import {
|
|||||||
type NodeChange,
|
type NodeChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type Connection,
|
type Connection,
|
||||||
applyNodeChanges,
|
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { LessonNode } from "./nodes/lesson-node";
|
import { LessonNode } from "./nodes/lesson-node";
|
||||||
|
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||||
import type { LessonPlanNode } from "../types";
|
import type { LessonPlanNode } from "../types";
|
||||||
|
|
||||||
const nodeTypes = { lesson: LessonNode };
|
const nodeTypes = { lesson: LessonNode };
|
||||||
|
|
||||||
interface Props {
|
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||||||
textbookId?: string;
|
type Props = Record<string, never>;
|
||||||
chapterId?: string;
|
|
||||||
classes?: { id: string; name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
export function NodeEditor({}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||||
useLessonPlanEditor();
|
useLessonPlanEditor();
|
||||||
|
|
||||||
// 我们的 nodes → React Flow nodes
|
// 使用纯函数映射 nodes/edges
|
||||||
const rfNodes: Node[] = useMemo(
|
const rfNodes: Node[] = useMemo(
|
||||||
() =>
|
() => toRfNodes(doc.nodes, selectedNodeId),
|
||||||
doc.nodes.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
type: "lesson",
|
|
||||||
position: n.position,
|
|
||||||
data: n as unknown as Record<string, unknown>,
|
|
||||||
selected: n.id === selectedNodeId,
|
|
||||||
})),
|
|
||||||
[doc.nodes, selectedNodeId],
|
[doc.nodes, selectedNodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// edges 直接兼容
|
|
||||||
const rfEdges: Edge[] = useMemo(
|
const rfEdges: Edge[] = useMemo(
|
||||||
() =>
|
() => toRfEdges(doc.edges),
|
||||||
doc.edges.map((e) => ({
|
|
||||||
...e,
|
|
||||||
animated: true,
|
|
||||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
|
||||||
})),
|
|
||||||
[doc.edges],
|
[doc.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,15 +46,16 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
|||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
changes.forEach((change) => {
|
changes.forEach((change) => {
|
||||||
if (change.type === "position" && change.position) {
|
if (change.type === "position" && change.position) {
|
||||||
|
// 拖拽结束时(dragging: false)才写入最终位置,避免中间状态污染(修复 P1-1)
|
||||||
|
if (change.dragging === false) {
|
||||||
updateNodePosition(change.id, change.position);
|
updateNodePosition(change.id, change.position);
|
||||||
|
}
|
||||||
} else if (change.type === "remove") {
|
} else if (change.type === "remove") {
|
||||||
removeNode(change.id);
|
removeNode(change.id);
|
||||||
} else if (change.type === "select") {
|
} else if (change.type === "select") {
|
||||||
selectNode(change.selected ? change.id : null);
|
selectNode(change.selected ? change.id : null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// applyNodeChanges 用于内部状态同步,但我们用 zustand 管理,这里不需要
|
|
||||||
void applyNodeChanges;
|
|
||||||
},
|
},
|
||||||
[updateNodePosition, removeNode, selectNode],
|
[updateNodePosition, removeNode, selectNode],
|
||||||
);
|
);
|
||||||
@@ -100,7 +87,15 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full relative">
|
||||||
|
{doc.nodes.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
|
<div className="text-center text-on-surface-variant">
|
||||||
|
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
|
||||||
|
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={rfNodes}
|
nodes={rfNodes}
|
||||||
edges={rfEdges}
|
edges={rfEdges}
|
||||||
@@ -129,7 +124,8 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
|||||||
<MiniMap
|
<MiniMap
|
||||||
className="!bg-surface !border-outline-variant"
|
className="!bg-surface !border-outline-variant"
|
||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
const data = n.data as unknown as LessonPlanNode;
|
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||||
|
if (!nodeData) return "#9e9e9e";
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
objective: "#4caf50",
|
objective: "#4caf50",
|
||||||
key_point: "#f44336",
|
key_point: "#f44336",
|
||||||
@@ -144,12 +140,10 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
|||||||
rich_text: "#9e9e9e",
|
rich_text: "#9e9e9e",
|
||||||
reflection: "#cddc39",
|
reflection: "#cddc39",
|
||||||
};
|
};
|
||||||
return colors[data.type] ?? "#9e9e9e";
|
return colors[nodeData.type] ?? "#9e9e9e";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
{/* 隐藏的 props 传递,避免 unused 警告 */}
|
|
||||||
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||||
import { BLOCK_TYPE_LABELS } from "../../constants";
|
|
||||||
import type { LessonPlanNode } from "../../types";
|
import type { LessonPlanNode } from "../../types";
|
||||||
|
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
|
||||||
// 节点类型 → 图标颜色(Material Design 色板)
|
|
||||||
const NODE_COLORS: Record<string, string> = {
|
|
||||||
objective: "#4caf50",
|
|
||||||
key_point: "#f44336",
|
|
||||||
import: "#2196f3",
|
|
||||||
new_teaching: "#9c27b0",
|
|
||||||
consolidation: "#ff9800",
|
|
||||||
summary: "#607d8b",
|
|
||||||
homework: "#795548",
|
|
||||||
blackboard: "#009688",
|
|
||||||
text_study: "#3f51b5",
|
|
||||||
exercise: "#e91e63",
|
|
||||||
rich_text: "#9e9e9e",
|
|
||||||
reflection: "#cddc39",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getNodeSummary(node: LessonPlanNode): string {
|
|
||||||
const data = node.data as {
|
|
||||||
html?: string;
|
|
||||||
sourceText?: string;
|
|
||||||
items?: unknown[];
|
|
||||||
knowledgePointIds?: string[];
|
|
||||||
};
|
|
||||||
if (data.items !== undefined) {
|
|
||||||
return `${data.items.length} 道题`;
|
|
||||||
}
|
|
||||||
if (data.sourceText !== undefined && data.sourceText) {
|
|
||||||
return `${data.sourceText.length} 字`;
|
|
||||||
}
|
|
||||||
if (data.html) {
|
|
||||||
// 去标签后取前 40 字
|
|
||||||
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
|
||||||
return text.slice(0, 40) || "空";
|
|
||||||
}
|
|
||||||
return "空";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LessonNode = memo(function LessonNode({
|
export const LessonNode = memo(function LessonNode({
|
||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
}: NodeProps) {
|
}: NodeProps) {
|
||||||
const nodeData = data as unknown as LessonPlanNode;
|
const t = useTranslations("lessonPreparation");
|
||||||
const color = NODE_COLORS[nodeData.type] ?? "#9e9e9e";
|
const nodeData = (data as { node: LessonPlanNode }).node;
|
||||||
|
const color = getNodeColor(nodeData.type);
|
||||||
|
// 适配 next-intl 的 t 到 NodeSummaryT 接口
|
||||||
|
const summaryT: NodeSummaryT = (key, values) => t(key, values);
|
||||||
|
const summary = getNodeSummary(nodeData, summaryT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -66,14 +34,14 @@ export const LessonNode = memo(function LessonNode({
|
|||||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
|
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
>
|
>
|
||||||
<span>{BLOCK_TYPE_LABELS[nodeData.type] ?? nodeData.type}</span>
|
<span>{t(`blockType.${nodeData.type}`) ?? nodeData.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="text-sm font-medium text-on-surface truncate">
|
<div className="text-sm font-medium text-on-surface truncate">
|
||||||
{nodeData.title}
|
{nodeData.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-on-surface-variant mt-1">
|
<div className="text-xs text-on-surface-variant mt-1">
|
||||||
{getNodeSummary(nodeData)}
|
{summary}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
@@ -20,6 +21,7 @@ export function PublishHomeworkDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onPublished,
|
onPublished,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||||
const [availableAt, setAvailableAt] = useState("");
|
const [availableAt, setAvailableAt] = useState("");
|
||||||
const [dueAt, setDueAt] = useState("");
|
const [dueAt, setDueAt] = useState("");
|
||||||
@@ -28,7 +30,7 @@ export function PublishHomeworkDialog({
|
|||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (selectedClasses.length === 0) {
|
if (selectedClasses.length === 0) {
|
||||||
setError("请选择至少一个班级");
|
setError(t("publish.selectClass"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -45,7 +47,7 @@ export function PublishHomeworkDialog({
|
|||||||
onPublished();
|
onPublished();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(res.message ?? "发布失败");
|
setError(res.message ?? t("error.publish"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +55,14 @@ export function PublishHomeworkDialog({
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
<div className="bg-surface rounded-lg shadow-xl w-96">
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">发布为作业</h3>
|
<h3 className="font-title-md">{t("publish.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">下发班级</label>
|
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
|
||||||
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<label
|
<label
|
||||||
@@ -85,7 +87,7 @@ export function PublishHomeworkDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
开始时间(可选)
|
{t("publish.availableAtLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -96,7 +98,7 @@ export function PublishHomeworkDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
截止时间(可选)
|
{t("publish.dueAtLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -109,10 +111,10 @@ export function PublishHomeworkDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t flex justify-end gap-2">
|
<div className="p-4 border-t flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
取消
|
{t("action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handlePublish} disabled={loading}>
|
<Button onClick={handlePublish} disabled={loading}>
|
||||||
{loading ? "发布中..." : "发布"}
|
{loading ? t("publish.publishing") : t("publish.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation")
|
||||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">从题库选择题目</h3>
|
<h3 className="font-title-md">{t("questionBank.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -117,24 +119,24 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
>
|
>
|
||||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
||||||
<span className="text-xs text-on-surface-variant mr-2">
|
<span className="text-xs text-on-surface-variant mr-2">
|
||||||
{q.type} · {q.difficulty}星
|
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||||
添加
|
{t("questionBank.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t flex justify-between">
|
<div className="p-4 border-t flex justify-between">
|
||||||
<span className="text-sm">已选 {picked.length} 题</span>
|
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPick(picked)
|
onPick(picked)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
插入
|
{t("questionBank.insert")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { createLessonPlanAction } from "../actions";
|
import { createLessonPlanAction } from "../actions";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { SYSTEM_TEMPLATES } from "../constants";
|
import { SYSTEM_TEMPLATES } from "../constants";
|
||||||
|
|
||||||
export function TemplatePicker() {
|
export function TemplatePicker() {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selected, setSelected] = useState<string>("");
|
const [selected, setSelected] = useState<string>("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -20,41 +22,41 @@ export function TemplatePicker() {
|
|||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||||
} else {
|
} else {
|
||||||
setError(res.message ?? "创建失败");
|
setError(res.message ?? t("error.createFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-title-md block mb-2">课案标题</label>
|
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-outline-variant rounded-lg px-3 py-2"
|
className="w-full border border-outline-variant rounded-lg px-3 py-2"
|
||||||
placeholder="例如:《秋天》第一课时"
|
placeholder={t("template.titlePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-title-md block mb-2">选择模板</label>
|
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{SYSTEM_TEMPLATES.map((t) => (
|
{SYSTEM_TEMPLATES.map((tpl) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={t.id}
|
key={tpl.id}
|
||||||
onClick={() => setSelected(t.id)}
|
onClick={() => setSelected(tpl.id)}
|
||||||
className={`text-left p-4 border-2 rounded-lg transition-colors ${
|
className={`text-left p-4 border-2 rounded-lg transition-colors ${
|
||||||
selected === t.id
|
selected === tpl.id
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-outline-variant hover:border-primary/50"
|
: "border-outline-variant hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-title-md">{t.name}</div>
|
<div className="font-title-md">{tpl.name}</div>
|
||||||
<div className="text-sm text-on-surface-variant mt-1">
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
{t.blocks.length === 0
|
{tpl.blocks.length === 0
|
||||||
? "从空白开始"
|
? t("template.blankHint")
|
||||||
: `${t.blocks.length} 个环节`}
|
: t("template.blockCount", { count: tpl.blocks.length })}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -62,7 +64,7 @@ export function TemplatePicker() {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="text-error text-sm">{error}</p>}
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
<Button type="submit" disabled={!selected || !title}>
|
<Button type="submit" disabled={!selected || !title}>
|
||||||
创建课案
|
{t("action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
getLessonPlanVersionsAction,
|
getLessonPlanVersionsAction,
|
||||||
revertLessonPlanVersionAction,
|
revertLessonPlanVersionAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import type { LessonPlanVersion } from "../types";
|
import type { LessonPlanVersion } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,6 +23,7 @@ export function VersionHistoryDrawer({
|
|||||||
planId,
|
planId,
|
||||||
onReverted,
|
onReverted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ export function VersionHistoryDrawer({
|
|||||||
}, [open, planId]);
|
}, [open, planId]);
|
||||||
|
|
||||||
async function handleRevert(versionNo: number) {
|
async function handleRevert(versionNo: number) {
|
||||||
if (!confirm(`确认回退到 v${versionNo}?将生成新版本。`)) return;
|
if (!confirm(t("version.revertConfirm", { versionNo }))) return;
|
||||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
onReverted();
|
onReverted();
|
||||||
@@ -58,11 +61,11 @@ export function VersionHistoryDrawer({
|
|||||||
<div className="fixed inset-0 z-50 flex">
|
<div className="fixed inset-0 z-50 flex">
|
||||||
<div className="flex-1 bg-black/30" onClick={onClose} />
|
<div className="flex-1 bg-black/30" onClick={onClose} />
|
||||||
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
||||||
<h3 className="font-headline-md text-headline-md mb-4">版本历史</h3>
|
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>加载中...</p>
|
<p>{t("version.loading")}</p>
|
||||||
) : versions.length === 0 ? (
|
) : versions.length === 0 ? (
|
||||||
<p className="text-on-surface-variant">暂无版本</p>
|
<p className="text-on-surface-variant">{t("version.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{versions.map((v) => (
|
{versions.map((v) => (
|
||||||
@@ -74,15 +77,15 @@ export function VersionHistoryDrawer({
|
|||||||
<span className="font-title-md">v{v.versionNo}</span>
|
<span className="font-title-md">v{v.versionNo}</span>
|
||||||
{v.isAuto && (
|
{v.isAuto && (
|
||||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||||
自动
|
{t("version.auto")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-on-surface-variant">
|
<p className="text-sm text-on-surface-variant">
|
||||||
{v.label ?? "手动保存"}
|
{v.label ?? t("version.manual")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs text-on-surface-variant mt-1">
|
||||||
{new Date(v.createdAt).toLocaleString()}
|
{formatDateTime(v.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -90,7 +93,7 @@ export function VersionHistoryDrawer({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
onClick={() => handleRevert(v.versionNo)}
|
onClick={() => handleRevert(v.versionNo)}
|
||||||
>
|
>
|
||||||
回退到此版本
|
{t("version.revert")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
110
src/modules/lesson-preparation/config/block-registry.tsx
Normal file
110
src/modules/lesson-preparation/config/block-registry.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type {
|
||||||
|
BlockType,
|
||||||
|
ExerciseBlockData,
|
||||||
|
RichTextBlockData,
|
||||||
|
TextStudyBlockData,
|
||||||
|
} from "../types";
|
||||||
|
import { RichTextBlock } from "../components/blocks/rich-text-block";
|
||||||
|
import { ExerciseBlock } from "../components/blocks/exercise-block";
|
||||||
|
import { TextStudyBlock } from "../components/blocks/text-study-block";
|
||||||
|
import { ReflectionBlock } from "../components/blocks/reflection-block";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 注册表:配置驱动渲染。
|
||||||
|
* 新增 Block 类型只需在此注册,无需修改 NodeEditPanel。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BlockRenderProps {
|
||||||
|
blockId: string;
|
||||||
|
data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData;
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
|
classes?: { id: string; name: string }[];
|
||||||
|
onUpdate: (data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockRegistryEntry {
|
||||||
|
/** 是否为富文本类(共享 RichTextBlock 编辑器) */
|
||||||
|
isRichText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RICH_TEXT_TYPES: BlockType[] = [
|
||||||
|
"objective",
|
||||||
|
"key_point",
|
||||||
|
"import",
|
||||||
|
"new_teaching",
|
||||||
|
"consolidation",
|
||||||
|
"summary",
|
||||||
|
"homework",
|
||||||
|
"blackboard",
|
||||||
|
"rich_text",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 注册表元数据(用于查询 isRichText 等属性)。
|
||||||
|
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
|
||||||
|
*/
|
||||||
|
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
|
||||||
|
objective: { isRichText: true },
|
||||||
|
key_point: { isRichText: true },
|
||||||
|
import: { isRichText: true },
|
||||||
|
new_teaching: { isRichText: true },
|
||||||
|
consolidation: { isRichText: true },
|
||||||
|
summary: { isRichText: true },
|
||||||
|
homework: { isRichText: true },
|
||||||
|
blackboard: { isRichText: true },
|
||||||
|
rich_text: { isRichText: true },
|
||||||
|
exercise: {},
|
||||||
|
text_study: {},
|
||||||
|
reflection: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isRichTextBlock(type: BlockType): boolean {
|
||||||
|
return RICH_TEXT_TYPES.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静态 Block 渲染组件。
|
||||||
|
* 根据 type 从注册表查找并渲染对应 Block,所有组件引用均为模块顶层静态声明,
|
||||||
|
* 满足 react-hooks/static-components 规则。
|
||||||
|
* 新增 Block 类型时,在此 switch 中添加对应 case 即可。
|
||||||
|
*/
|
||||||
|
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
|
||||||
|
const { type, ...rest } = props;
|
||||||
|
switch (type) {
|
||||||
|
case "exercise":
|
||||||
|
return (
|
||||||
|
<ExerciseBlock
|
||||||
|
blockId={rest.blockId}
|
||||||
|
data={rest.data as ExerciseBlockData}
|
||||||
|
classes={rest.classes ?? []}
|
||||||
|
textbookId={rest.textbookId}
|
||||||
|
chapterId={rest.chapterId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "text_study":
|
||||||
|
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
|
||||||
|
case "reflection":
|
||||||
|
return <ReflectionBlock data={rest.data as RichTextBlockData} onUpdate={rest.onUpdate} />;
|
||||||
|
case "objective":
|
||||||
|
case "key_point":
|
||||||
|
case "import":
|
||||||
|
case "new_teaching":
|
||||||
|
case "consolidation":
|
||||||
|
case "summary":
|
||||||
|
case "homework":
|
||||||
|
case "blackboard":
|
||||||
|
case "rich_text":
|
||||||
|
return (
|
||||||
|
<RichTextBlock
|
||||||
|
data={rest.data as RichTextBlockData}
|
||||||
|
textbookId={rest.textbookId}
|
||||||
|
chapterId={rest.chapterId}
|
||||||
|
onUpdate={rest.onUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,78 +16,20 @@ import {
|
|||||||
} from "@/shared/db/schema";
|
} from "@/shared/db/schema";
|
||||||
import type { DataScope } from "@/shared/types/permissions";
|
import type { DataScope } from "@/shared/types/permissions";
|
||||||
import { SYSTEM_TEMPLATES } from "./constants";
|
import { SYSTEM_TEMPLATES } from "./constants";
|
||||||
|
import {
|
||||||
|
migrateV1ToV2,
|
||||||
|
normalizeDocument,
|
||||||
|
buildInitialContent,
|
||||||
|
} from "./lib/document-migration";
|
||||||
import type {
|
import type {
|
||||||
LessonPlan,
|
LessonPlan,
|
||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanDocumentV1,
|
|
||||||
LessonPlanEdge,
|
|
||||||
LessonPlanListItem,
|
LessonPlanListItem,
|
||||||
LessonPlanNode,
|
|
||||||
LessonPlanTemplate,
|
LessonPlanTemplate,
|
||||||
TemplateBlockSkeleton,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
// re-export 纯函数保持向后兼容
|
||||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||||||
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
|
||||||
...b,
|
|
||||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
|
||||||
}));
|
|
||||||
const edges: LessonPlanEdge[] = [];
|
|
||||||
for (let i = 0; i < nodes.length - 1; i++) {
|
|
||||||
edges.push({
|
|
||||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
|
||||||
source: nodes[i].id,
|
|
||||||
target: nodes[i + 1].id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { version: 2, nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
|
||||||
export function normalizeDocument(
|
|
||||||
content: unknown,
|
|
||||||
): LessonPlanDocument {
|
|
||||||
if (content && typeof content === "object") {
|
|
||||||
const c = content as { version?: number };
|
|
||||||
if (c.version === 2) {
|
|
||||||
return content as LessonPlanDocument;
|
|
||||||
}
|
|
||||||
if (c.version === 1) {
|
|
||||||
return migrateV1ToV2(content as LessonPlanDocumentV1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 空文档
|
|
||||||
return { version: 2, nodes: [], edges: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
|
||||||
export function buildInitialContent(
|
|
||||||
blocks: TemplateBlockSkeleton[],
|
|
||||||
): LessonPlanDocument {
|
|
||||||
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
|
||||||
id: createId(),
|
|
||||||
type: b.type,
|
|
||||||
title: b.title,
|
|
||||||
data:
|
|
||||||
b.type === "exercise"
|
|
||||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
|
||||||
: b.type === "text_study"
|
|
||||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
|
||||||
: { html: "", knowledgePointIds: [] },
|
|
||||||
order: i,
|
|
||||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
|
||||||
}));
|
|
||||||
const edges: LessonPlanEdge[] = [];
|
|
||||||
for (let i = 0; i < nodes.length - 1; i++) {
|
|
||||||
edges.push({
|
|
||||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
|
||||||
source: nodes[i].id,
|
|
||||||
target: nodes[i + 1].id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { version: 2, nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- DataScope → 查询条件 ----
|
// ---- DataScope → 查询条件 ----
|
||||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||||
|
|||||||
86
src/modules/lesson-preparation/lib/document-migration.ts
Normal file
86
src/modules/lesson-preparation/lib/document-migration.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import type {
|
||||||
|
LessonPlanDocument,
|
||||||
|
LessonPlanDocumentV1,
|
||||||
|
LessonPlanEdge,
|
||||||
|
LessonPlanNode,
|
||||||
|
TemplateBlockSkeleton,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数模块:课案文档迁移、规范化、初始内容构建。
|
||||||
|
* 从 data-access.ts 抽取,便于单元测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
||||||
|
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
||||||
|
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
||||||
|
...b,
|
||||||
|
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||||
|
}));
|
||||||
|
const edges: LessonPlanEdge[] = [];
|
||||||
|
for (let i = 0; i < nodes.length - 1; i++) {
|
||||||
|
edges.push({
|
||||||
|
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||||
|
source: nodes[i].id,
|
||||||
|
target: nodes[i + 1].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { version: 2, nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型守卫:判断是否为 v2 文档 ----
|
||||||
|
function isV2Document(content: unknown): content is LessonPlanDocument {
|
||||||
|
if (!content || typeof content !== "object") return false;
|
||||||
|
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
|
||||||
|
return (
|
||||||
|
c.version === 2 &&
|
||||||
|
Array.isArray(c.nodes) &&
|
||||||
|
Array.isArray(c.edges)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型守卫:判断是否为 v1 文档 ----
|
||||||
|
function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
|
||||||
|
if (!content || typeof content !== "object") return false;
|
||||||
|
const c = content as { version?: unknown; blocks?: unknown };
|
||||||
|
return c.version === 1 && Array.isArray(c.blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
||||||
|
export function normalizeDocument(
|
||||||
|
content: unknown,
|
||||||
|
): LessonPlanDocument {
|
||||||
|
if (isV2Document(content)) return content;
|
||||||
|
if (isV1Document(content)) return migrateV1ToV2(content);
|
||||||
|
// 空文档
|
||||||
|
return { version: 2, nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||||
|
export function buildInitialContent(
|
||||||
|
blocks: TemplateBlockSkeleton[],
|
||||||
|
): LessonPlanDocument {
|
||||||
|
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
||||||
|
id: createId(),
|
||||||
|
type: b.type,
|
||||||
|
title: b.title,
|
||||||
|
data:
|
||||||
|
b.type === "exercise"
|
||||||
|
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||||
|
: b.type === "text_study"
|
||||||
|
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||||
|
: { html: "", knowledgePointIds: [] },
|
||||||
|
order: i,
|
||||||
|
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||||
|
}));
|
||||||
|
const edges: LessonPlanEdge[] = [];
|
||||||
|
for (let i = 0; i < nodes.length - 1; i++) {
|
||||||
|
edges.push({
|
||||||
|
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||||
|
source: nodes[i].id,
|
||||||
|
target: nodes[i + 1].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { version: 2, nodes, edges };
|
||||||
|
}
|
||||||
59
src/modules/lesson-preparation/lib/node-summary.ts
Normal file
59
src/modules/lesson-preparation/lib/node-summary.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { LessonPlanNode } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点摘要翻译函数接口。
|
||||||
|
* 调用方传入 next-intl 的 t 函数,避免纯函数直接耦合 i18n 实现。
|
||||||
|
* values 类型对齐 next-intl 的 TranslationValues(string | number | Date)。
|
||||||
|
*/
|
||||||
|
export interface NodeSummaryT {
|
||||||
|
(key: "editor.questionCount" | "editor.charCount" | "editor.nodeSummaryEmpty", values?: Record<string, string | number | Date>): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数:获取节点摘要文本(用于节点卡片显示)。
|
||||||
|
* 从 lesson-node.tsx 抽取,便于单元测试。
|
||||||
|
* 翻译文本由调用方通过 t 函数注入,保证纯函数可测性。
|
||||||
|
*/
|
||||||
|
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
|
||||||
|
const data = node.data as {
|
||||||
|
html?: string;
|
||||||
|
sourceText?: string;
|
||||||
|
items?: unknown[];
|
||||||
|
knowledgePointIds?: string[];
|
||||||
|
};
|
||||||
|
if (data.items !== undefined) {
|
||||||
|
return t("editor.questionCount", { count: data.items.length });
|
||||||
|
}
|
||||||
|
if (data.sourceText !== undefined && data.sourceText) {
|
||||||
|
return t("editor.charCount", { count: data.sourceText.length });
|
||||||
|
}
|
||||||
|
if (data.html) {
|
||||||
|
// 去标签后取前 40 字
|
||||||
|
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
||||||
|
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
|
||||||
|
}
|
||||||
|
return t("editor.nodeSummaryEmpty");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点类型 → 图标颜色(Material Design 色板)。
|
||||||
|
* 供 lesson-node 和 minimap 复用。
|
||||||
|
*/
|
||||||
|
export const NODE_COLORS: Record<string, string> = {
|
||||||
|
objective: "#4caf50",
|
||||||
|
key_point: "#f44336",
|
||||||
|
import: "#2196f3",
|
||||||
|
new_teaching: "#9c27b0",
|
||||||
|
consolidation: "#ff9800",
|
||||||
|
summary: "#607d8b",
|
||||||
|
homework: "#795548",
|
||||||
|
blackboard: "#009688",
|
||||||
|
text_study: "#3f51b5",
|
||||||
|
exercise: "#e91e63",
|
||||||
|
rich_text: "#9e9e9e",
|
||||||
|
reflection: "#cddc39",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getNodeColor(type: string): string {
|
||||||
|
return NODE_COLORS[type] ?? "#9e9e9e";
|
||||||
|
}
|
||||||
43
src/modules/lesson-preparation/lib/rf-mappers.ts
Normal file
43
src/modules/lesson-preparation/lib/rf-mappers.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Node, Edge } from "@xyflow/react";
|
||||||
|
import type { LessonPlanNode, LessonPlanEdge } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
||||||
|
* 从 node-editor.tsx 抽取,便于单元测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function toRfNodes(
|
||||||
|
nodes: LessonPlanNode[],
|
||||||
|
selectedNodeId: string | null,
|
||||||
|
): Node[] {
|
||||||
|
return nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: "lesson",
|
||||||
|
position: n.position,
|
||||||
|
data: { node: n } as Record<string, unknown>,
|
||||||
|
selected: n.id === selectedNodeId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
|
||||||
|
return edges.map((e) => ({
|
||||||
|
...e,
|
||||||
|
animated: true,
|
||||||
|
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 React Flow edges 转回课案 edges 格式。
|
||||||
|
*/
|
||||||
|
export function fromRfEdges(
|
||||||
|
rfEdges: Edge[],
|
||||||
|
): LessonPlanEdge[] {
|
||||||
|
return rfEdges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
sourceHandle: e.sourceHandle ?? null,
|
||||||
|
targetHandle: e.targetHandle ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -4,13 +4,11 @@ import { eq } from "drizzle-orm";
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import {
|
import { lessonPlans } from "@/shared/db/schema";
|
||||||
lessonPlans,
|
|
||||||
examQuestions,
|
|
||||||
} from "@/shared/db/schema";
|
|
||||||
import { createQuestionWithRelations } from "@/modules/questions/data-access";
|
import { createQuestionWithRelations } from "@/modules/questions/data-access";
|
||||||
import { persistExamDraft } from "@/modules/exams/data-access";
|
import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access";
|
||||||
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
||||||
|
import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
|
||||||
import { normalizeDocument } from "./data-access";
|
import { normalizeDocument } from "./data-access";
|
||||||
import type { LessonPlanDocument, ExerciseBlockData } from "./types";
|
import type { LessonPlanDocument, ExerciseBlockData } from "./types";
|
||||||
|
|
||||||
@@ -29,20 +27,6 @@ interface PublishResult {
|
|||||||
updatedContent: LessonPlanDocument;
|
updatedContent: LessonPlanDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询班级学生列表(避免直接依赖 classes 模块的内部表)
|
|
||||||
async function getStudentIdsByClassIds(
|
|
||||||
classIds: string[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (classIds.length === 0) return [];
|
|
||||||
const { inArray } = await import("drizzle-orm");
|
|
||||||
const { classEnrollments } = await import("@/shared/db/schema");
|
|
||||||
const rows = await db
|
|
||||||
.select({ studentId: classEnrollments.studentId })
|
|
||||||
.from(classEnrollments)
|
|
||||||
.where(inArray(classEnrollments.classId, classIds));
|
|
||||||
return rows.map((r) => r.studentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishLessonPlanHomework(
|
export async function publishLessonPlanHomework(
|
||||||
input: PublishInput,
|
input: PublishInput,
|
||||||
): Promise<PublishResult> {
|
): Promise<PublishResult> {
|
||||||
@@ -80,9 +64,7 @@ export async function publishLessonPlanHomework(
|
|||||||
throw new Error("该练习块已发布,请使用'重新发布'");
|
throw new Error("该练习块已发布,请使用'重新发布'");
|
||||||
|
|
||||||
// 3. inline 题目入库,替换占位 ID
|
// 3. inline 题目入库,替换占位 ID
|
||||||
const newContent: LessonPlanDocument = JSON.parse(
|
const newContent: LessonPlanDocument = structuredClone(plan.content);
|
||||||
JSON.stringify(plan.content),
|
|
||||||
);
|
|
||||||
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
||||||
if (!newBlock || newBlock.type !== "exercise")
|
if (!newBlock || newBlock.type !== "exercise")
|
||||||
throw new Error("练习块不存在");
|
throw new Error("练习块不存在");
|
||||||
@@ -122,17 +104,15 @@ export async function publishLessonPlanHomework(
|
|||||||
scheduledAt: undefined,
|
scheduledAt: undefined,
|
||||||
description: `来自课案:${plan.title}`,
|
description: `来自课案:${plan.title}`,
|
||||||
});
|
});
|
||||||
// 插入 examQuestions
|
// 插入 examQuestions(通过 exams data-access 跨模块接口)
|
||||||
if (newData.items.length > 0) {
|
await addExamQuestions(
|
||||||
await db.insert(examQuestions).values(
|
|
||||||
newData.items.map((it, i) => ({
|
|
||||||
examId,
|
examId,
|
||||||
|
newData.items.map((it, i) => ({
|
||||||
questionId: it.questionId,
|
questionId: it.questionId,
|
||||||
score: it.score,
|
score: it.score,
|
||||||
order: i,
|
order: i,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 下发作业
|
// 5. 下发作业
|
||||||
const assignmentId = createId();
|
const assignmentId = createId();
|
||||||
|
|||||||
198
src/shared/i18n/messages/en/lesson-preparation.json
Normal file
198
src/shared/i18n/messages/en/lesson-preparation.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"list": "My Lesson Plans",
|
||||||
|
"new": "New Lesson Plan",
|
||||||
|
"edit": "Edit Lesson Plan"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"list": "Manage your lesson preparation and teaching plans."
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"new": "New Lesson Plan",
|
||||||
|
"back": "Back to Lesson Plans",
|
||||||
|
"addNode": "Add Node",
|
||||||
|
"saveVersion": "Save Version",
|
||||||
|
"versions": "Versions",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"archive": "Archive",
|
||||||
|
"delete": "Delete Node",
|
||||||
|
"close": "Close",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"create": "Create Lesson Plan",
|
||||||
|
"publish": "Publish as Homework",
|
||||||
|
"viewHomework": "View"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"saving": "Saving...",
|
||||||
|
"unsaved": "Unsaved",
|
||||||
|
"saved": "Saved",
|
||||||
|
"draft": "Draft",
|
||||||
|
"published": "Published",
|
||||||
|
"archived": "Archived",
|
||||||
|
"publishedAsHomework": "Published as Homework"
|
||||||
|
},
|
||||||
|
"blockType": {
|
||||||
|
"objective": "Objective",
|
||||||
|
"key_point": "Key Points",
|
||||||
|
"import": "Introduction",
|
||||||
|
"new_teaching": "New Teaching",
|
||||||
|
"consolidation": "Consolidation",
|
||||||
|
"summary": "Summary",
|
||||||
|
"homework": "Homework",
|
||||||
|
"blackboard": "Blackboard Design",
|
||||||
|
"text_study": "Text Study",
|
||||||
|
"exercise": "Exercise",
|
||||||
|
"rich_text": "Custom Section",
|
||||||
|
"reflection": "Reflection"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"regular": "Regular Lesson",
|
||||||
|
"review": "Review Lesson",
|
||||||
|
"experiment": "Experiment Lesson",
|
||||||
|
"inquiry": "Inquiry Lesson",
|
||||||
|
"blank": "Blank Template",
|
||||||
|
"selectLabel": "Select Template",
|
||||||
|
"titleLabel": "Lesson Plan Title",
|
||||||
|
"titlePlaceholder": "e.g., Autumn - Lesson 1",
|
||||||
|
"blockCount": "{count} sections",
|
||||||
|
"blankHint": "Start from scratch"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"canvasEmpty": "Canvas is empty",
|
||||||
|
"canvasEmptyHint": "Click \"Add Node\" at the bottom left to start",
|
||||||
|
"selectNodeHint": "Click a node to edit content, or drag to connect",
|
||||||
|
"unknownBlockType": "Unknown node type",
|
||||||
|
"nodeSummaryEmpty": "Empty",
|
||||||
|
"questionCount": "{count} questions",
|
||||||
|
"charCount": "{count} chars"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"searchPlaceholder": "Search title...",
|
||||||
|
"allSubjects": "All Subjects",
|
||||||
|
"allStatus": "All Status"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"empty": "No lesson plans. Click \"New Lesson Plan\" to start",
|
||||||
|
"noTextbook": "No textbook",
|
||||||
|
"noChapter": "No chapter",
|
||||||
|
"noTemplate": "No template",
|
||||||
|
"lastSaved": "Last saved: ",
|
||||||
|
"neverSaved": "Never saved"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"title": "Version History",
|
||||||
|
"empty": "No versions",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"auto": "Auto",
|
||||||
|
"manual": "Manual save",
|
||||||
|
"revert": "Revert to this version",
|
||||||
|
"revertConfirm": "Revert to v{versionNo}? A new version will be created.",
|
||||||
|
"autoLabel": "Auto version"
|
||||||
|
},
|
||||||
|
"knowledgePoint": {
|
||||||
|
"title": "Select Knowledge Points",
|
||||||
|
"empty": "No knowledge points found. Please create them in the textbook module first.",
|
||||||
|
"linked": "Linked to {count} knowledge points",
|
||||||
|
"annotate": "Annotate Knowledge Points",
|
||||||
|
"select": "Select Knowledge Points",
|
||||||
|
"selected": "{count} selected"
|
||||||
|
},
|
||||||
|
"questionBank": {
|
||||||
|
"title": "Select Questions from Bank",
|
||||||
|
"add": "Add",
|
||||||
|
"insert": "Insert",
|
||||||
|
"selected": "{count} questions selected",
|
||||||
|
"source": {
|
||||||
|
"bank": "Bank",
|
||||||
|
"inline": "New"
|
||||||
|
},
|
||||||
|
"inlineTitle": "New Question (in lesson plan)",
|
||||||
|
"inlineNew": "New Question",
|
||||||
|
"fromBank": "Add from Bank",
|
||||||
|
"score": "{score} pts",
|
||||||
|
"difficulty": "{level} star",
|
||||||
|
"empty": "No questions. Click the button below to add",
|
||||||
|
"inlineQuestion": "New question in lesson plan",
|
||||||
|
"questionId": "Question {id}",
|
||||||
|
"type": {
|
||||||
|
"single_choice": "Single Choice",
|
||||||
|
"text": "Fill in Blank",
|
||||||
|
"judgment": "True/False"
|
||||||
|
},
|
||||||
|
"typeLabel": "Question Type",
|
||||||
|
"stemLabel": "Question Stem",
|
||||||
|
"optionsLabel": "Options (check correct answer)",
|
||||||
|
"addOption": "+ Add Option",
|
||||||
|
"correctAnswer": "Correct Answer",
|
||||||
|
"correct": "True",
|
||||||
|
"incorrect": "False",
|
||||||
|
"difficultyLabel": "Difficulty",
|
||||||
|
"knowledgePointLabel": "Knowledge Points",
|
||||||
|
"stemRequired": "Please enter the question stem",
|
||||||
|
"addBtn": "Add"
|
||||||
|
},
|
||||||
|
"exercise": {
|
||||||
|
"purpose": {
|
||||||
|
"class_practice": "Class Practice",
|
||||||
|
"after_class_homework": "After-class Homework"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"title": "Publish as Homework",
|
||||||
|
"classLabel": "Target Classes",
|
||||||
|
"availableAtLabel": "Available At (optional)",
|
||||||
|
"dueAtLabel": "Due At (optional)",
|
||||||
|
"publishing": "Publishing...",
|
||||||
|
"publish": "Publish",
|
||||||
|
"selectClass": "Please select at least one class",
|
||||||
|
"noStudents": "No students in selected classes",
|
||||||
|
"noSubjectOrGrade": "Lesson plan missing subject or grade info",
|
||||||
|
"alreadyPublished": "This exercise block is already published. Use 'Republish'",
|
||||||
|
"noExerciseBlock": "Exercise block not found",
|
||||||
|
"noQuestions": "No questions in exercise block",
|
||||||
|
"planNotFound": "Lesson plan not found",
|
||||||
|
"noPermission": "No permission to publish"
|
||||||
|
},
|
||||||
|
"textStudy": {
|
||||||
|
"sourceTextLabel": "Source Text",
|
||||||
|
"sourceTextPlaceholder": "Paste source text, select text to add annotations",
|
||||||
|
"addAnnotation": "Add Annotation for Selection",
|
||||||
|
"selectFirst": "Please select text first",
|
||||||
|
"annotationTitle": "Teaching Node",
|
||||||
|
"annotationNotePlaceholder": "Teaching notes..."
|
||||||
|
},
|
||||||
|
"reflection": {
|
||||||
|
"hint": "Write teaching reflection after class..."
|
||||||
|
},
|
||||||
|
"richText": {
|
||||||
|
"placeholder": "Enter content..."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"getList": "Failed to get lesson plan list",
|
||||||
|
"getOne": "Failed to get lesson plan",
|
||||||
|
"create": "Failed to create lesson plan",
|
||||||
|
"save": "Failed to save",
|
||||||
|
"saveVersion": "Failed to save version",
|
||||||
|
"getVersions": "Failed to get versions",
|
||||||
|
"revert": "Failed to revert",
|
||||||
|
"delete": "Failed to delete",
|
||||||
|
"duplicate": "Failed to duplicate",
|
||||||
|
"getTemplates": "Failed to get templates",
|
||||||
|
"saveTemplate": "Failed to save template",
|
||||||
|
"deleteTemplate": "Failed to delete template",
|
||||||
|
"publish": "Failed to publish",
|
||||||
|
"aiSuggest": "AI suggestion failed. Please check AI Provider config",
|
||||||
|
"loadKnowledgePoints": "Failed to load knowledge points",
|
||||||
|
"notFound": "Lesson plan not found or no access",
|
||||||
|
"versionNotFound": "Version not found or no permission",
|
||||||
|
"templateNotFound": "Template not found",
|
||||||
|
"createFailed": "Creation failed",
|
||||||
|
"loadFailed": "Page load failed",
|
||||||
|
"loadFailedDesc": "Sorry, an unexpected error occurred. Please try again later.",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"archive": "Archive this lesson plan?"
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/shared/i18n/messages/zh-CN/lesson-preparation.json
Normal file
198
src/shared/i18n/messages/zh-CN/lesson-preparation.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"list": "我的备课",
|
||||||
|
"new": "新建课案",
|
||||||
|
"edit": "编辑课案"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"list": "管理备课和教学计划。"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"new": "新建课案",
|
||||||
|
"back": "返回备课列表",
|
||||||
|
"addNode": "添加节点",
|
||||||
|
"saveVersion": "保存版本",
|
||||||
|
"versions": "版本",
|
||||||
|
"duplicate": "复制",
|
||||||
|
"archive": "归档",
|
||||||
|
"delete": "删除此节点",
|
||||||
|
"close": "关闭",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"create": "创建课案",
|
||||||
|
"publish": "发布为作业",
|
||||||
|
"viewHomework": "查看"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"saving": "保存中...",
|
||||||
|
"unsaved": "未保存",
|
||||||
|
"saved": "已保存",
|
||||||
|
"draft": "草稿",
|
||||||
|
"published": "已发布",
|
||||||
|
"archived": "已归档",
|
||||||
|
"publishedAsHomework": "已发布为作业"
|
||||||
|
},
|
||||||
|
"blockType": {
|
||||||
|
"objective": "教学目标",
|
||||||
|
"key_point": "教学重难点",
|
||||||
|
"import": "导入",
|
||||||
|
"new_teaching": "新授",
|
||||||
|
"consolidation": "巩固练习",
|
||||||
|
"summary": "课堂小结",
|
||||||
|
"homework": "作业布置",
|
||||||
|
"blackboard": "板书设计",
|
||||||
|
"text_study": "文本研习",
|
||||||
|
"exercise": "练习/作业",
|
||||||
|
"rich_text": "自定义环节",
|
||||||
|
"reflection": "教学反思"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"regular": "常规课",
|
||||||
|
"review": "复习课",
|
||||||
|
"experiment": "实验课",
|
||||||
|
"inquiry": "探究课",
|
||||||
|
"blank": "空白模板",
|
||||||
|
"selectLabel": "选择模板",
|
||||||
|
"titleLabel": "课案标题",
|
||||||
|
"titlePlaceholder": "例如:《秋天》第一课时",
|
||||||
|
"blockCount": "{count} 个环节",
|
||||||
|
"blankHint": "从空白开始"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"canvasEmpty": "画布为空",
|
||||||
|
"canvasEmptyHint": "点击左下角\"添加节点\"开始备课",
|
||||||
|
"selectNodeHint": "点击节点编辑内容,或拖拽连线建立流程",
|
||||||
|
"unknownBlockType": "未知节点类型",
|
||||||
|
"nodeSummaryEmpty": "空",
|
||||||
|
"questionCount": "{count} 道题",
|
||||||
|
"charCount": "{count} 字"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"searchPlaceholder": "搜索标题...",
|
||||||
|
"allSubjects": "全部学科",
|
||||||
|
"allStatus": "全部状态"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"empty": "暂无课案,点击\"新建课案\"开始",
|
||||||
|
"noTextbook": "无教材",
|
||||||
|
"noChapter": "无章节",
|
||||||
|
"noTemplate": "无模板",
|
||||||
|
"lastSaved": "最后保存:",
|
||||||
|
"neverSaved": "未保存"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"title": "版本历史",
|
||||||
|
"empty": "暂无版本",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"auto": "自动",
|
||||||
|
"manual": "手动保存",
|
||||||
|
"revert": "回退到此版本",
|
||||||
|
"revertConfirm": "确认回退到 v{versionNo}?将生成新版本。",
|
||||||
|
"autoLabel": "自动版本"
|
||||||
|
},
|
||||||
|
"knowledgePoint": {
|
||||||
|
"title": "选择知识点",
|
||||||
|
"empty": "未找到知识点,请先在教材模块创建",
|
||||||
|
"linked": "已关联 {count} 个知识点",
|
||||||
|
"annotate": "标注知识点",
|
||||||
|
"select": "选择知识点",
|
||||||
|
"selected": "已选 {count} 个"
|
||||||
|
},
|
||||||
|
"questionBank": {
|
||||||
|
"title": "从题库选择题目",
|
||||||
|
"add": "添加",
|
||||||
|
"insert": "插入",
|
||||||
|
"selected": "已选 {count} 题",
|
||||||
|
"source": {
|
||||||
|
"bank": "题库",
|
||||||
|
"inline": "新建"
|
||||||
|
},
|
||||||
|
"inlineTitle": "课案内新建题目",
|
||||||
|
"inlineNew": "新建题目",
|
||||||
|
"fromBank": "从题库添加",
|
||||||
|
"score": "{score}分",
|
||||||
|
"difficulty": "{level}星",
|
||||||
|
"empty": "暂无题目,点击下方按钮添加",
|
||||||
|
"inlineQuestion": "课案内新建题目",
|
||||||
|
"questionId": "题目 {id}",
|
||||||
|
"type": {
|
||||||
|
"single_choice": "单选题",
|
||||||
|
"text": "填空题",
|
||||||
|
"judgment": "判断题"
|
||||||
|
},
|
||||||
|
"typeLabel": "题型",
|
||||||
|
"stemLabel": "题干",
|
||||||
|
"optionsLabel": "选项(勾选正确答案)",
|
||||||
|
"addOption": "+ 添加选项",
|
||||||
|
"correctAnswer": "正确答案",
|
||||||
|
"correct": "正确",
|
||||||
|
"incorrect": "错误",
|
||||||
|
"difficultyLabel": "难度",
|
||||||
|
"knowledgePointLabel": "知识点",
|
||||||
|
"stemRequired": "请输入题干",
|
||||||
|
"addBtn": "添加"
|
||||||
|
},
|
||||||
|
"exercise": {
|
||||||
|
"purpose": {
|
||||||
|
"class_practice": "课堂练习",
|
||||||
|
"after_class_homework": "课后作业"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"title": "发布为作业",
|
||||||
|
"classLabel": "下发班级",
|
||||||
|
"availableAtLabel": "开始时间(可选)",
|
||||||
|
"dueAtLabel": "截止时间(可选)",
|
||||||
|
"publishing": "发布中...",
|
||||||
|
"publish": "发布",
|
||||||
|
"selectClass": "请选择至少一个班级",
|
||||||
|
"noStudents": "所选班级无学生",
|
||||||
|
"noSubjectOrGrade": "课案缺少学科或年级信息,无法发布",
|
||||||
|
"alreadyPublished": "该练习块已发布,请使用'重新发布'",
|
||||||
|
"noExerciseBlock": "练习块不存在",
|
||||||
|
"noQuestions": "练习块无题目",
|
||||||
|
"planNotFound": "课案不存在",
|
||||||
|
"noPermission": "无权发布"
|
||||||
|
},
|
||||||
|
"textStudy": {
|
||||||
|
"sourceTextLabel": "课文原文",
|
||||||
|
"sourceTextPlaceholder": "粘贴课文原文,选中文本后可添加教学节点",
|
||||||
|
"addAnnotation": "为选中文本添加节点",
|
||||||
|
"selectFirst": "请先在课文中选中一段文本",
|
||||||
|
"annotationTitle": "教学节点",
|
||||||
|
"annotationNotePlaceholder": "教学说明..."
|
||||||
|
},
|
||||||
|
"reflection": {
|
||||||
|
"hint": "课后填写教学反思..."
|
||||||
|
},
|
||||||
|
"richText": {
|
||||||
|
"placeholder": "输入内容..."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"getList": "获取课案列表失败",
|
||||||
|
"getOne": "获取课案失败",
|
||||||
|
"create": "创建课案失败",
|
||||||
|
"save": "保存失败",
|
||||||
|
"saveVersion": "保存版本失败",
|
||||||
|
"getVersions": "获取版本失败",
|
||||||
|
"revert": "回退失败",
|
||||||
|
"delete": "删除失败",
|
||||||
|
"duplicate": "复制失败",
|
||||||
|
"getTemplates": "获取模板失败",
|
||||||
|
"saveTemplate": "保存模板失败",
|
||||||
|
"deleteTemplate": "删除模板失败",
|
||||||
|
"publish": "发布失败",
|
||||||
|
"aiSuggest": "AI 推荐失败,请检查 AI Provider 配置",
|
||||||
|
"loadKnowledgePoints": "加载知识点失败",
|
||||||
|
"notFound": "课案不存在或无权访问",
|
||||||
|
"versionNotFound": "版本不存在或无权操作",
|
||||||
|
"templateNotFound": "模板不存在",
|
||||||
|
"createFailed": "创建失败",
|
||||||
|
"loadFailed": "页面加载失败",
|
||||||
|
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
|
||||||
|
"retry": "重试"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"archive": "确认归档此课案?"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user