refactor(lesson-preparation): V2 审计深度修复 — Server Actions i18n + 错误码模式 + 类型断言清零 + a11y 深度修复 + Tracker 埋点接入
V2-1: 12 个 Server Action 通过 getTranslations 翻译错误消息;Service/DataAccess 层抛出错误码异常(PublishServiceError/LessonPlanDataError),Actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息 V2-2: SYSTEM_TEMPLATES name/title 改为 i18n 键,createLessonPlan 接受 translateTitle 函数在服务端翻译后存储到 DB V2-3: 8 处 as unknown as 断言替换为显式类型映射函数(mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate/mapRowToVersion)+ 类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope) V2-4: MiniMap nodeColor 复用 lib/node-summary.ts 的 getNodeColor V2-5: a11y 深度修复 — lesson-plan-filters/exercise-block/inline-question-editor 的 select 添加 label htmlFor 关联;exercise-block 题目列表改为 ul/li;node-editor 画布添加 role=application + 键盘导航配置 V2-6: Tracker 埋点接入 — 新增 useLessonPlanTrackerSafe hook,在 create/save/publish/revert/duplicate/archive 6 处调用 tracker.track 同步更新架构图 004 和 005 文档
This commit is contained in:
@@ -12793,7 +12793,7 @@
|
|||||||
},
|
},
|
||||||
"lesson_preparation": {
|
"lesson_preparation": {
|
||||||
"path": "src/modules/lesson-preparation",
|
"path": "src/modules/lesson-preparation",
|
||||||
"description": "教师备课模块:基于教材章节创建课案(节点图编辑器 React Flow,v2 nodes+edges 数据结构),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布。编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react),旧 v1 数据通过 migrateV1ToV2() 自动迁移",
|
"description": "教师备课模块:基于教材章节创建课案(节点图编辑器 React Flow,v2 nodes+edges 数据结构),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布。编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react),旧 v1 数据通过 migrateV1ToV2() 自动迁移。V2 审计修复:Server Actions i18n + 错误码模式、SYSTEM_TEMPLATES i18n 化、as unknown as 类型断言清零、a11y 深度修复、Tracker 埋点接入",
|
||||||
"exports": {
|
"exports": {
|
||||||
"dataAccess": [
|
"dataAccess": [
|
||||||
{
|
{
|
||||||
@@ -12809,7 +12809,7 @@
|
|||||||
{
|
{
|
||||||
"name": "createLessonPlan",
|
"name": "createLessonPlan",
|
||||||
"file": "data-access.ts",
|
"file": "data-access.ts",
|
||||||
"purpose": "创建课案"
|
"purpose": "创建课案(V2-2:接受 translateTitle 函数翻译 SYSTEM_TEMPLATES 的 i18n 键后存储到 DB)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "updateLessonPlanContent",
|
"name": "updateLessonPlanContent",
|
||||||
@@ -12899,7 +12899,7 @@
|
|||||||
{
|
{
|
||||||
"name": "publishLessonPlanHomework",
|
"name": "publishLessonPlanHomework",
|
||||||
"file": "publish-service.ts",
|
"file": "publish-service.ts",
|
||||||
"purpose": "发布课案为作业(编排 homework/exams/classes,通过对方 data-access 调用 addExamQuestions/getStudentIdsByClassIds,无直查跨模块表)"
|
"purpose": "发布课案为作业(编排 homework/exams/classes,通过对方 data-access 调用 addExamQuestions/getStudentIdsByClassIds,无直查跨模块表;V2-1:抛出 PublishServiceError 错误码;V2-3:显式字段映射替代 as unknown as)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "suggestKnowledgePoints",
|
"name": "suggestKnowledgePoints",
|
||||||
|
|||||||
137
docs/architecture/audit/lesson-preparation-audit-report-v2.md
Normal file
137
docs/architecture/audit/lesson-preparation-audit-report-v2.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 备课模块审计报告 V2(第二轮深度检查)
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22(第二轮)
|
||||||
|
> 审查范围:基于 V1 审计报告的修复成果,对全模块进行深度复查
|
||||||
|
> 前置状态:V1 审计报告中的 P0-1/P0-2/P0-3/P1-2/P1-3/P1-4/P1-5/P1-6/P1-7/P1-8/P2-1(部分)/P2-4(接口)已完成
|
||||||
|
> 本次目的:识别 V1 修复中遗留的未完成项,继续全量完整完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、V1 修复成果确认
|
||||||
|
|
||||||
|
| 项 | 状态 | 证据 |
|
||||||
|
|----|------|------|
|
||||||
|
| P0-1 跨模块直查 | ✅ 已完成 | publish-service.ts 使用 `addExamQuestions`/`getStudentIdsByClassIds` 跨模块接口 |
|
||||||
|
| P0-2 i18n 接入 | ⚠️ 部分完成 | 消息文件、request.ts、组件 useTranslations 已接入;但 actions 错误消息、constants SYSTEM_TEMPLATES 仍硬编码 |
|
||||||
|
| P0-3 DataScope | ✅ 已完成 | buildScopeCondition 按 scope 类型精确过滤 |
|
||||||
|
| P1-1 类型安全 | ⚠️ 部分完成 | `as never` 已修复;但 8 处 `as unknown as` 断言未修复 |
|
||||||
|
| P1-2 错误边界 | ✅ 已完成 | LessonPlanErrorBoundary 包裹 NodeEditPanel |
|
||||||
|
| P1-3 骨架屏 | ✅ 已完成 | 4 个 Skeleton 组件已创建 |
|
||||||
|
| P1-4 阻塞式 UI | ✅ 已完成 | alert/confirm/window.location.reload 全部替换 |
|
||||||
|
| P1-5 多实例 | ✅ 已完成 | LessonPlanProvider + Context 注入 |
|
||||||
|
| P1-6 纯函数抽取 | ⚠️ 部分完成 | lib/ 三个文件已抽取;但 node-editor.tsx MiniMap nodeColor 仍内联颜色映射 |
|
||||||
|
| P1-7 角色配置 | ✅ 已完成 | 4 个角色配置 + ROLE_CONFIGS 注册表 |
|
||||||
|
| P1-8 Block 注册表 | ✅ 已完成 | BLOCK_REGISTRY 配置驱动渲染 |
|
||||||
|
| P2-1 a11y | ⚠️ 部分完成 | 5 个对话框 role/aria-label 已添加;但 select 无 label、题目列表非 ul/li、画布无键盘导航 |
|
||||||
|
| P2-4 监控埋点 | ⚠️ 部分完成 | LessonPlanTracker 接口已定义;但未在关键操作处调用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、V2 新发现的问题
|
||||||
|
|
||||||
|
### V2-1:actions 错误消息仍硬编码中文(P0-2 遗留)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [actions.ts:53](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L53) | `"获取课案列表失败"` | i18n 规范 |
|
||||||
|
| [actions.ts:66](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L66) | `"课案不存在或无权访问"` | 同上 |
|
||||||
|
| [actions.ts:71](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L71) | `"获取课案失败"` | 同上 |
|
||||||
|
| [actions.ts:102](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L102) | `"创建课案失败"` | 同上 |
|
||||||
|
| [actions.ts:125](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L125) | `"保存失败"` | 同上 |
|
||||||
|
| [actions.ts:152](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L152) | `"保存版本失败"` | 同上 |
|
||||||
|
| [actions.ts:171](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L171) | `"获取版本失败"` | 同上 |
|
||||||
|
| [actions.ts:190](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L190) | `"版本不存在或无权操作"` | 同上 |
|
||||||
|
| [actions.ts:196](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L196) | `"回退失败"` | 同上 |
|
||||||
|
| [actions.ts:212](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L212) | `"删除失败"` | 同上 |
|
||||||
|
| [actions.ts:228](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L228) | `"复制失败"` | 同上 |
|
||||||
|
| [actions.ts:245](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L245) | `"获取模板失败"` | 同上 |
|
||||||
|
| [actions.ts:267](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L267) | `"保存模板失败"` | 同上 |
|
||||||
|
| [actions.ts:282](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts#L282) | `"删除模板失败"` | 同上 |
|
||||||
|
| [actions-ai.ts:29](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-ai.ts#L29) | `"AI 推荐失败,请检查 AI Provider 配置"` | 同上 |
|
||||||
|
| [actions-kp.ts:37](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-kp.ts#L37) | `"加载知识点失败"` | 同上 |
|
||||||
|
| [actions-publish.ts:48](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-publish.ts#L48) | `"发布失败"` | 同上 |
|
||||||
|
| [publish-service.ts:39,55,60,62,64,70,103,128](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts) | 8 处 `throw new Error("中文")` | 同上 |
|
||||||
|
| [data-access.ts:183,243](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts) | `"模板不存在"`/`"课案不存在或无权访问"` | 同上 |
|
||||||
|
| [data-access-templates.ts:61](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts#L61) | `"课案不存在或无权访问"` | 同上 |
|
||||||
|
|
||||||
|
**修复方案**:Server Actions 使用 `getTranslations("lessonPreparation")` 获取翻译;publish-service/data-access 的 `throw new Error` 改为抛出错误码(如 `LESSON_PLAN_NOT_FOUND`),由 actions 层捕获并翻译。
|
||||||
|
|
||||||
|
### V2-2:constants.ts SYSTEM_TEMPLATES 仍硬编码中文(P0-2 遗留)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| [constants.ts:46-106](file:///e:/Desktop/CICD/src/modules/lesson-preparation/constants.ts#L46-L106) | SYSTEM_TEMPLATES 的 `name`/`title`/`hint` 字段硬编码中文("常规课"/"教学目标"/"明确本课的知识、能力、情感目标"等) |
|
||||||
|
|
||||||
|
**修复方案**:将 SYSTEM_TEMPLATES 的 `name`/`title`/`hint` 改为 i18n 键(如 `template.names.tpl_regular`/`blockType.objective`/`template.hints.tpl_regular.objective`),在 buildInitialContent 调用时由 actions 层传入翻译后的标题。
|
||||||
|
|
||||||
|
### V2-3:8 处 `as unknown as` 断言未修复(P1-1 遗留)
|
||||||
|
|
||||||
|
| 位置 | 代码 |
|
||||||
|
|------|------|
|
||||||
|
| [data-access.ts:146](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L146) | `rows as unknown as LessonPlanListItem[]` |
|
||||||
|
| [data-access.ts:166](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L166) | `row as unknown as LessonPlan` |
|
||||||
|
| [data-access.ts:288](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L288) | `rows[0] as unknown as LessonPlanTemplate` |
|
||||||
|
| [data-access-versions.ts:30](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-versions.ts#L30) | `rows as unknown as LessonPlanVersion[]` |
|
||||||
|
| [data-access-knowledge.ts:25](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts#L25) | `rows.filter(...) as unknown as LessonPlanListItem[]` |
|
||||||
|
| [data-access-knowledge.ts:43](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts#L43) | 同上 |
|
||||||
|
| [data-access-templates.ts:40](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts#L40) | `personalRows as unknown as LessonPlanTemplate[]` |
|
||||||
|
| [publish-service.ts:40](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L40) | `rows[0] as unknown as {...}` |
|
||||||
|
|
||||||
|
**修复方案**:使用 Drizzle 的 `inferSelect` 类型推导,或定义显式类型映射函数替代断言。
|
||||||
|
|
||||||
|
### V2-4:node-editor.tsx MiniMap nodeColor 仍内联颜色映射(P1-6 遗留)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| [node-editor.tsx:126-144](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L126-L144) | MiniMap nodeColor 内联 colors 对象,未使用 lib/node-summary.ts 的 NODE_COLORS/getNodeColor |
|
||||||
|
|
||||||
|
**修复方案**:改为 `import { getNodeColor } from "../lib/node-summary"` 并在 nodeColor 回调中调用。
|
||||||
|
|
||||||
|
### V2-5:a11y 遗留问题(P2-1 遗留)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [lesson-plan-filters.tsx:40-51](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-filters.tsx#L40-L51) | 2 个 `<select>` 无 `<label>` 关联 | "语义化标签、ARIA 属性" |
|
||||||
|
| [exercise-block.tsx:56-65](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L56-L65) | purpose `<select>` 无 `<label>` | 同上 |
|
||||||
|
| [exercise-block.tsx:72-92](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L72-L92) | 题目列表用 `<div>` 而非 `<ul>/<li>` | 语义化标签 |
|
||||||
|
| [node-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx) | React Flow 画布无键盘导航支持(Tab/方向键无法聚焦/移动节点) | 键盘导航 |
|
||||||
|
| [inline-question-editor.tsx:83-95](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L83-L95) | type `<select>` 有 `<label>` 但未通过 htmlFor/id 关联 | label 关联 |
|
||||||
|
|
||||||
|
**修复方案**:为所有 `<select>` 添加 `id` 和 `<label htmlFor>`;题目列表改为 `<ul>/<li>`;node-editor 添加键盘事件处理(方向键移动节点)。
|
||||||
|
|
||||||
|
### V2-6:LessonPlanTracker 未在关键操作处调用(P2-4 遗留)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| [providers/lesson-plan-provider.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx) | LessonPlanTracker 接口已定义,但全模块无 `tracker.track()` 调用 |
|
||||||
|
|
||||||
|
**修复方案**:在以下关键操作处调用 tracker:
|
||||||
|
- createLessonPlanAction(create)
|
||||||
|
- updateLessonPlanAction(save)
|
||||||
|
- publishLessonPlanHomeworkAction(publish)
|
||||||
|
- revertLessonPlanVersionAction(revert)
|
||||||
|
- duplicateLessonPlanAction(duplicate)
|
||||||
|
- deleteLessonPlanAction(archive)
|
||||||
|
|
||||||
|
由于 actions 是 server-side,tracker 应在客户端组件中调用(如 lesson-plan-editor 的 handleManualSave、lesson-plan-card 的 handleArchive/handleDuplicate、publish-homework-dialog 的 handlePublish、version-history-drawer 的 handleRevert)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、V2 改进优先级
|
||||||
|
|
||||||
|
| # | 问题 | 优先级 | 改进方向 |
|
||||||
|
|---|------|--------|----------|
|
||||||
|
| V2-1 | actions 错误消息硬编码 | P0 | Server Actions 使用 getTranslations;publish-service/data-access 抛错误码 |
|
||||||
|
| V2-2 | SYSTEM_TEMPLATES 硬编码 | P0 | 改为 i18n 键,actions 层传入翻译后标题 |
|
||||||
|
| V2-3 | 8 处 `as unknown as` 断言 | P1 | 使用 Drizzle inferSelect 或显式映射函数 |
|
||||||
|
| V2-4 | MiniMap nodeColor 内联 | P1 | 使用 lib/node-summary.getNodeColor |
|
||||||
|
| V2-5 | a11y 遗留 | P2 | select 加 label、题目列表改 ul/li、画布键盘导航 |
|
||||||
|
| V2-6 | Tracker 未调用 | P2 | 6 个关键操作处调用 tracker.track |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、架构图同步说明
|
||||||
|
|
||||||
|
本次 V2 修复完成后需同步更新:
|
||||||
|
- `docs/architecture/004_architecture_impact_map.md` §2.27(标注 V2 修复完成)
|
||||||
|
- `docs/architecture/005_architecture_data.json` modules.lesson_preparation.auditFixes(新增 V2-1~V2-6)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
import { suggestKnowledgePoints } from "./ai-suggest";
|
import { suggestKnowledgePoints } from "./ai-suggest";
|
||||||
@@ -14,6 +15,7 @@ export async function suggestKnowledgePointsAction(input: {
|
|||||||
suggestions: { id: string; name: string; reason: string }[];
|
suggestions: { id: string; name: string; reason: string }[];
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
await requirePermission(Permissions.AI_CHAT);
|
await requirePermission(Permissions.AI_CHAT);
|
||||||
@@ -26,6 +28,6 @@ export async function suggestKnowledgePointsAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "AI 推荐失败,请检查 AI Provider 配置" };
|
return { success: false, message: t("error.aiSuggest") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ export async function getKnowledgePointOptionsAction(input: {
|
|||||||
}): Promise<
|
}): Promise<
|
||||||
ActionState<{ options: { id: string; name: string }[] }>
|
ActionState<{ options: { id: string; name: string }[] }>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
if (!input.textbookId) return { success: true, data: { options: [] } };
|
if (!input.textbookId) return { success: true, data: { options: [] } };
|
||||||
@@ -34,6 +36,6 @@ export async function getKnowledgePointOptionsAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "加载知识点失败" };
|
return { success: false, message: t("error.loadKnowledgePoints") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
requirePermission,
|
requirePermission,
|
||||||
PermissionDeniedError,
|
PermissionDeniedError,
|
||||||
} from "@/shared/lib/auth-guard";
|
} from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
import { publishLessonPlanHomework } from "./publish-service";
|
import { publishLessonPlanHomework, PublishServiceError } from "./publish-service";
|
||||||
import type { ActionState } from "./types";
|
import type { ActionState } from "./types";
|
||||||
|
|
||||||
export async function publishLessonPlanHomeworkAction(input: {
|
export async function publishLessonPlanHomeworkAction(input: {
|
||||||
@@ -16,6 +17,7 @@ export async function publishLessonPlanHomeworkAction(input: {
|
|||||||
availableAt?: string;
|
availableAt?: string;
|
||||||
dueAt?: string;
|
dueAt?: string;
|
||||||
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(
|
const ctx = await requirePermission(
|
||||||
Permissions.LESSON_PLAN_PUBLISH,
|
Permissions.LESSON_PLAN_PUBLISH,
|
||||||
@@ -43,9 +45,25 @@ export async function publishLessonPlanHomeworkAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
|
// publish-service 抛出 PublishServiceError(含 code),翻译为 i18n 消息
|
||||||
|
if (e instanceof PublishServiceError) {
|
||||||
|
const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish";
|
||||||
|
return { success: false, message: t(messageKey) };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: e instanceof Error ? e.message : "发布失败",
|
message: e instanceof Error ? e.message : t("error.publish"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publish-service 错误码 → i18n 键映射
|
||||||
|
const PUBLISH_ERROR_KEY_MAP: Record<string, string> = {
|
||||||
|
PLAN_NOT_FOUND: "publish.planNotFound",
|
||||||
|
NO_PERMISSION: "publish.noPermission",
|
||||||
|
NO_EXERCISE_BLOCK: "publish.noExerciseBlock",
|
||||||
|
NO_QUESTIONS: "publish.noQuestions",
|
||||||
|
ALREADY_PUBLISHED: "publish.alreadyPublished",
|
||||||
|
NO_SUBJECT_OR_GRADE: "publish.noSubjectOrGrade",
|
||||||
|
NO_STUDENTS: "publish.noStudents",
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
updateLessonPlanContent,
|
updateLessonPlanContent,
|
||||||
softDeleteLessonPlan,
|
softDeleteLessonPlan,
|
||||||
duplicateLessonPlan,
|
duplicateLessonPlan,
|
||||||
|
LessonPlanDataError,
|
||||||
} from "./data-access";
|
} from "./data-access";
|
||||||
import {
|
import {
|
||||||
getLessonPlanVersions,
|
getLessonPlanVersions,
|
||||||
@@ -43,6 +45,7 @@ export async function getLessonPlansAction(params: {
|
|||||||
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
||||||
@@ -50,7 +53,7 @@ export async function getLessonPlansAction(params: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "获取课案列表失败" };
|
return { success: false, message: t("error.getList") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +63,16 @@ export async function getLessonPlanByIdAction(
|
|||||||
): Promise<
|
): Promise<
|
||||||
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const plan = await getLessonPlanById(planId, ctx.userId);
|
const plan = await getLessonPlanById(planId, ctx.userId);
|
||||||
if (!plan) return { success: false, message: "课案不存在或无权访问" };
|
if (!plan) return { success: false, message: t("error.notFound") };
|
||||||
return { success: true, data: { plan } };
|
return { success: true, data: { plan } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "获取课案失败" };
|
return { success: false, message: t("error.getOne") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ export async function createLessonPlanAction(
|
|||||||
prevState: ActionState | null,
|
prevState: ActionState | null,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<ActionState<{ planId: string }>> {
|
): Promise<ActionState<{ planId: string }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||||
const parsed = createLessonPlanSchema.safeParse({
|
const parsed = createLessonPlanSchema.safeParse({
|
||||||
@@ -93,13 +98,30 @@ export async function createLessonPlanAction(
|
|||||||
const { planId } = await createLessonPlan({
|
const { planId } = await createLessonPlan({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
creatorId: ctx.userId,
|
creatorId: ctx.userId,
|
||||||
|
// V2-2 修复:传入翻译函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||||||
|
translateTitle: (key: string) => {
|
||||||
|
// 支持 "blockType.objective" 和 "template.blocks.tpl_review.1" 两种键
|
||||||
|
if (key.startsWith("blockType.")) {
|
||||||
|
const blockKey = key.replace("blockType.", "");
|
||||||
|
return t(`blockType.${blockKey}`);
|
||||||
|
}
|
||||||
|
if (key.startsWith("template.blocks.")) {
|
||||||
|
const parts = key.split(".");
|
||||||
|
const templateId = parts[2];
|
||||||
|
const blockIndex = parts[3];
|
||||||
|
return t(`template.blocks.${templateId}.${blockIndex}`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/teacher/lesson-plans");
|
revalidatePath("/teacher/lesson-plans");
|
||||||
return { success: true, data: { planId } };
|
return { success: true, data: { planId } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "创建课案失败" };
|
if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND")
|
||||||
|
return { success: false, message: t("error.templateNotFound") };
|
||||||
|
return { success: false, message: t("error.create") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +131,7 @@ export async function updateLessonPlanAction(input: {
|
|||||||
title?: string;
|
title?: string;
|
||||||
content: LessonPlanDocument;
|
content: LessonPlanDocument;
|
||||||
}): Promise<ActionState> {
|
}): Promise<ActionState> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
||||||
@@ -122,7 +145,7 @@ export async function updateLessonPlanAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "保存失败" };
|
return { success: false, message: t("error.save") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +155,7 @@ export async function saveLessonPlanVersionAction(input: {
|
|||||||
content: LessonPlanDocument;
|
content: LessonPlanDocument;
|
||||||
label?: string;
|
label?: string;
|
||||||
}): Promise<ActionState<{ versionNo: number }>> {
|
}): Promise<ActionState<{ versionNo: number }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = saveVersionSchema.safeParse(input);
|
const parsed = saveVersionSchema.safeParse(input);
|
||||||
@@ -149,7 +173,7 @@ export async function saveLessonPlanVersionAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "保存版本失败" };
|
return { success: false, message: t("error.saveVersion") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +185,7 @@ export async function getLessonPlanVersionsAction(
|
|||||||
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
||||||
@@ -168,7 +193,7 @@ export async function getLessonPlanVersionsAction(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "获取版本失败" };
|
return { success: false, message: t("error.getVersions") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +202,7 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
planId: string;
|
planId: string;
|
||||||
versionNo: number;
|
versionNo: number;
|
||||||
}): Promise<ActionState<{ newVersionNo: number }>> {
|
}): Promise<ActionState<{ newVersionNo: number }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
const parsed = revertVersionSchema.safeParse(input);
|
const parsed = revertVersionSchema.safeParse(input);
|
||||||
@@ -187,13 +213,13 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
parsed.data.versionNo,
|
parsed.data.versionNo,
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
);
|
);
|
||||||
if (!result) return { success: false, message: "版本不存在或无权操作" };
|
if (!result) return { success: false, message: t("error.versionNotFound") };
|
||||||
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
|
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
|
||||||
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "回退失败" };
|
return { success: false, message: t("error.revert") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +227,7 @@ export async function revertLessonPlanVersionAction(input: {
|
|||||||
export async function deleteLessonPlanAction(
|
export async function deleteLessonPlanAction(
|
||||||
planId: string,
|
planId: string,
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||||
await softDeleteLessonPlan(planId, ctx.userId);
|
await softDeleteLessonPlan(planId, ctx.userId);
|
||||||
@@ -209,7 +236,7 @@ export async function deleteLessonPlanAction(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "删除失败" };
|
return { success: false, message: t("error.delete") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +244,7 @@ export async function deleteLessonPlanAction(
|
|||||||
export async function duplicateLessonPlanAction(
|
export async function duplicateLessonPlanAction(
|
||||||
planId: string,
|
planId: string,
|
||||||
): Promise<ActionState<{ newPlanId: string }>> {
|
): Promise<ActionState<{ newPlanId: string }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||||
const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId);
|
const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId);
|
||||||
@@ -225,7 +253,9 @@ export async function duplicateLessonPlanAction(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "复制失败" };
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
return { success: false, message: t("error.duplicate") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +265,7 @@ export async function getLessonPlanTemplatesAction(): Promise<
|
|||||||
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
|
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
const templates = await getLessonPlanTemplates(ctx.userId);
|
const templates = await getLessonPlanTemplates(ctx.userId);
|
||||||
@@ -242,7 +273,7 @@ export async function getLessonPlanTemplatesAction(): Promise<
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "获取模板失败" };
|
return { success: false, message: t("error.getTemplates") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +282,7 @@ export async function saveAsTemplateAction(input: {
|
|||||||
sourcePlanId: string;
|
sourcePlanId: string;
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<ActionState<{ templateId: string }>> {
|
}): Promise<ActionState<{ templateId: string }>> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||||
const parsed = saveAsTemplateSchema.safeParse(input);
|
const parsed = saveAsTemplateSchema.safeParse(input);
|
||||||
@@ -264,7 +296,9 @@ export async function saveAsTemplateAction(input: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "保存模板失败" };
|
if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND")
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
return { success: false, message: t("error.saveTemplate") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +306,7 @@ export async function saveAsTemplateAction(input: {
|
|||||||
export async function deleteTemplateAction(
|
export async function deleteTemplateAction(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||||
await deletePersonalTemplate(templateId, ctx.userId);
|
await deletePersonalTemplate(templateId, ctx.userId);
|
||||||
@@ -279,6 +314,6 @@ export async function deleteTemplateAction(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError)
|
if (e instanceof PermissionDeniedError)
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
return { success: false, message: "删除模板失败" };
|
return { success: false, message: t("error.deleteTemplate") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,12 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<label htmlFor={`exercise-purpose-${blockId}`} className="text-sm font-medium">
|
||||||
|
{t("exercise.purposeLabel")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id={`exercise-purpose-${blockId}`}
|
||||||
value={data.purpose}
|
value={data.purpose}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update({ purpose: e.target.value as ExercisePurpose })
|
update({ purpose: e.target.value as ExercisePurpose })
|
||||||
@@ -69,9 +73,9 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
{t("questionBank.empty")}
|
{t("questionBank.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<ul className="space-y-1 list-none p-0" role="list">
|
||||||
{data.items.map((item, idx) => (
|
{data.items.map((item, idx) => (
|
||||||
<div
|
<li
|
||||||
key={item.questionId}
|
key={item.questionId}
|
||||||
className="flex items-center gap-2 border rounded p-2"
|
className="flex items-center gap-2 border rounded p-2"
|
||||||
>
|
>
|
||||||
@@ -87,9 +91,9 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
||||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -79,8 +79,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
</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">{t("questionBank.typeLabel")}</label>
|
<label htmlFor="inline-question-type" className="text-sm font-medium">
|
||||||
|
{t("questionBank.typeLabel")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="inline-question-type"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (isQuestionType(e.target.value)) {
|
if (isQuestionType(e.target.value)) {
|
||||||
@@ -95,8 +98,9 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
|
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="inline-question-stem"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
||||||
@@ -170,8 +174,9 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
|
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
|
||||||
<select
|
<select
|
||||||
|
id="inline-question-difficulty"
|
||||||
value={difficulty}
|
value={difficulty}
|
||||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||||
className="w-full border rounded px-2 py-1 mt-1"
|
className="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import {
|
|||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { formatDateTime } from "@/shared/lib/utils";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||||
import { useLessonPlanContextSafe, useRoleConfig } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
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 t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const roleConfig = useRoleConfig();
|
const roleConfig = useRoleConfig();
|
||||||
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
|
|
||||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
@@ -35,6 +36,7 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
? await service.deleteLessonPlan(plan.id)
|
? await service.deleteLessonPlan(plan.id)
|
||||||
: await deleteLessonPlanAction(plan.id);
|
: await deleteLessonPlanAction(plan.id);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||||
toast.success(t("status.archived"));
|
toast.success(t("status.archived"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
@@ -46,7 +48,10 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
const res = service
|
const res = service
|
||||||
? await service.duplicateLessonPlan(plan.id)
|
? await service.duplicateLessonPlan(plan.id)
|
||||||
: await duplicateLessonPlanAction(plan.id);
|
: await duplicateLessonPlanAction(plan.id);
|
||||||
if (res.success) router.refresh();
|
if (res.success) {
|
||||||
|
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
saveLessonPlanVersionAction,
|
saveLessonPlanVersionAction,
|
||||||
getLessonPlanByIdAction,
|
getLessonPlanByIdAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
|
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
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";
|
||||||
@@ -49,6 +50,7 @@ export function LessonPlanEditor({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const editor = useLessonPlanEditor();
|
const editor = useLessonPlanEditor();
|
||||||
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -130,8 +132,11 @@ export function LessonPlanEditor({
|
|||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
state.setSaving(false);
|
state.setSaving(false);
|
||||||
if (res.success) state.markSaved();
|
if (res.success) {
|
||||||
}, []);
|
state.markSaved();
|
||||||
|
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||||
|
}
|
||||||
|
}, [tracker]);
|
||||||
|
|
||||||
// 版本回退后刷新内容(修复 P1-1)
|
// 版本回退后刷新内容(修复 P1-1)
|
||||||
const handleReverted = useCallback(async () => {
|
const handleReverted = useCallback(async () => {
|
||||||
|
|||||||
@@ -30,14 +30,22 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
<label htmlFor="lesson-plan-search" className="sr-only">
|
||||||
|
{t("filters.searchPlaceholder")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="lesson-plan-search"
|
||||||
placeholder={t("filters.searchPlaceholder")}
|
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"
|
||||||
/>
|
/>
|
||||||
|
<label htmlFor="lesson-plan-subject" className="sr-only">
|
||||||
|
{t("filters.allSubjects")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="lesson-plan-subject"
|
||||||
value={subjectId}
|
value={subjectId}
|
||||||
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"
|
||||||
@@ -49,7 +57,11 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<label htmlFor="lesson-plan-status" className="sr-only">
|
||||||
|
{t("filters.allStatus")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="lesson-plan-status"
|
||||||
value={status}
|
value={status}
|
||||||
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"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||||
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
import type { LessonPlanNode } from "../types";
|
import type { LessonPlanNode } from "../types";
|
||||||
|
|
||||||
const nodeTypes = { lesson: LessonNode };
|
const nodeTypes = { lesson: LessonNode };
|
||||||
@@ -87,7 +88,7 @@ export function NodeEditor({}: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
|
||||||
{doc.nodes.length === 0 && (
|
{doc.nodes.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
<div className="text-center text-on-surface-variant">
|
<div className="text-center text-on-surface-variant">
|
||||||
@@ -107,6 +108,12 @@ export function NodeEditor({}: Props) {
|
|||||||
onPaneClick={() => selectNode(null)}
|
onPaneClick={() => selectNode(null)}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||||||
|
nodesFocusable
|
||||||
|
nodesDraggable
|
||||||
|
edgesFocusable
|
||||||
|
elementsSelectable
|
||||||
|
deleteKeyCode={["Backspace", "Delete"]}
|
||||||
|
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||||
@@ -126,21 +133,7 @@ export function NodeEditor({}: Props) {
|
|||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||||
if (!nodeData) return "#9e9e9e";
|
if (!nodeData) return "#9e9e9e";
|
||||||
const colors: Record<string, string> = {
|
return getNodeColor(nodeData.type);
|
||||||
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",
|
|
||||||
};
|
|
||||||
return colors[nodeData.type] ?? "#9e9e9e";
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ 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";
|
||||||
|
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
|
|
||||||
export function TemplatePicker() {
|
export function TemplatePicker() {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [selected, setSelected] = useState<string>("");
|
const [selected, setSelected] = useState<string>("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -20,6 +22,7 @@ export function TemplatePicker() {
|
|||||||
formData.set("title", title);
|
formData.set("title", title);
|
||||||
const res = await createLessonPlanAction(null, formData);
|
const res = await createLessonPlanAction(null, formData);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
|
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||||
} else {
|
} else {
|
||||||
setError(res.message ?? t("error.createFailed"));
|
setError(res.message ?? t("error.createFailed"));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getLessonPlanVersionsAction,
|
getLessonPlanVersionsAction,
|
||||||
revertLessonPlanVersionAction,
|
revertLessonPlanVersionAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
|
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -36,6 +37,7 @@ export function VersionHistoryDrawer({
|
|||||||
onReverted,
|
onReverted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ export function VersionHistoryDrawer({
|
|||||||
async function handleRevert(versionNo: number) {
|
async function handleRevert(versionNo: number) {
|
||||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||||
toast.success(t("version.revertSuccess", { versionNo }));
|
toast.success(t("version.revertSuccess", { versionNo }));
|
||||||
onReverted();
|
onReverted();
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 系统预设模板骨架(seed 用)
|
// 系统预设模板骨架(seed 用)
|
||||||
|
// V2-2 修复:title/hint 存储 i18n 键,由 createLessonPlan 调用 getTranslations 翻译
|
||||||
|
// 键格式:`template.blocks.${templateId}.${blockIndex}.title` 或 `blockType.${type}`(默认)
|
||||||
export interface SystemTemplateDef {
|
export interface SystemTemplateDef {
|
||||||
id: string; // 固定 ID,便于幂等
|
id: string; // 固定 ID,便于幂等
|
||||||
name: string;
|
name: string; // i18n 键:template.names.${id}
|
||||||
scope: TemplateScope;
|
scope: TemplateScope;
|
||||||
blocks: TemplateBlockSkeleton[];
|
blocks: TemplateBlockSkeleton[];
|
||||||
}
|
}
|
||||||
@@ -46,60 +48,60 @@ export interface SystemTemplateDef {
|
|||||||
export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
|
export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
|
||||||
{
|
{
|
||||||
id: "tpl_regular",
|
id: "tpl_regular",
|
||||||
name: "常规课",
|
name: "tpl_regular", // i18n 键
|
||||||
scope: "regular",
|
scope: "regular",
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: "objective", title: "教学目标", hint: "明确本课的知识、能力、情感目标" },
|
{ type: "objective", title: "blockType.objective" },
|
||||||
{ type: "key_point", title: "教学重难点", hint: "标注重点与难点及突破策略" },
|
{ type: "key_point", title: "blockType.key_point" },
|
||||||
{ type: "import", title: "导入", hint: "情境导入/复习导入/问题导入" },
|
{ type: "import", title: "blockType.import" },
|
||||||
{ type: "new_teaching", title: "新授", hint: "核心教学活动设计" },
|
{ type: "new_teaching", title: "blockType.new_teaching" },
|
||||||
{ type: "consolidation", title: "巩固练习", hint: "课堂练习,检验学习效果" },
|
{ type: "consolidation", title: "blockType.consolidation" },
|
||||||
{ type: "summary", title: "课堂小结", hint: "归纳本课要点" },
|
{ type: "summary", title: "blockType.summary" },
|
||||||
{ type: "homework", title: "作业布置", hint: "课后作业说明(如需下发请用练习块)" },
|
{ type: "homework", title: "blockType.homework" },
|
||||||
{ type: "blackboard", title: "板书设计", hint: "板书结构示意" },
|
{ type: "blackboard", title: "blockType.blackboard" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tpl_review",
|
id: "tpl_review",
|
||||||
name: "复习课",
|
name: "tpl_review",
|
||||||
scope: "review",
|
scope: "review",
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: "objective", title: "复习目标" },
|
{ type: "objective", title: "blockType.objective" },
|
||||||
{ type: "rich_text", title: "知识网络梳理", hint: "构建知识结构图" },
|
{ type: "rich_text", title: "template.blocks.tpl_review.1" },
|
||||||
{ type: "rich_text", title: "典型例题精讲" },
|
{ type: "rich_text", title: "template.blocks.tpl_review.2" },
|
||||||
{ type: "rich_text", title: "变式训练" },
|
{ type: "rich_text", title: "template.blocks.tpl_review.3" },
|
||||||
{ type: "exercise", title: "当堂检测", hint: "purpose 选 class_practice" },
|
{ type: "exercise", title: "blockType.exercise" },
|
||||||
{ type: "summary", title: "课堂小结" },
|
{ type: "summary", title: "blockType.summary" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tpl_experiment",
|
id: "tpl_experiment",
|
||||||
name: "实验课",
|
name: "tpl_experiment",
|
||||||
scope: "experiment",
|
scope: "experiment",
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: "objective", title: "实验目的" },
|
{ type: "objective", title: "blockType.objective" },
|
||||||
{ type: "rich_text", title: "器材准备" },
|
{ type: "rich_text", title: "template.blocks.tpl_experiment.1" },
|
||||||
{ type: "rich_text", title: "实验步骤" },
|
{ type: "rich_text", title: "template.blocks.tpl_experiment.2" },
|
||||||
{ type: "rich_text", title: "观察记录表" },
|
{ type: "rich_text", title: "template.blocks.tpl_experiment.3" },
|
||||||
{ type: "rich_text", title: "交流讨论" },
|
{ type: "rich_text", title: "template.blocks.tpl_experiment.4" },
|
||||||
{ type: "summary", title: "课堂小结" },
|
{ type: "summary", title: "blockType.summary" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tpl_inquiry",
|
id: "tpl_inquiry",
|
||||||
name: "探究课",
|
name: "tpl_inquiry",
|
||||||
scope: "inquiry",
|
scope: "inquiry",
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: "rich_text", title: "情境导入" },
|
{ type: "rich_text", title: "template.blocks.tpl_inquiry.0" },
|
||||||
{ type: "rich_text", title: "问题驱动" },
|
{ type: "rich_text", title: "template.blocks.tpl_inquiry.1" },
|
||||||
{ type: "rich_text", title: "小组探究" },
|
{ type: "rich_text", title: "template.blocks.tpl_inquiry.2" },
|
||||||
{ type: "rich_text", title: "成果展示" },
|
{ type: "rich_text", title: "template.blocks.tpl_inquiry.3" },
|
||||||
{ type: "rich_text", title: "归纳提升" },
|
{ type: "rich_text", title: "template.blocks.tpl_inquiry.4" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tpl_blank",
|
id: "tpl_blank",
|
||||||
name: "空白模板",
|
name: "tpl_blank",
|
||||||
scope: "blank",
|
scope: "blank",
|
||||||
blocks: [],
|
blocks: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,13 +16,37 @@ export async function getLessonPlansByKnowledgePoint(
|
|||||||
.select()
|
.select()
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(like(lessonPlans.content, `%${knowledgePointId}%`));
|
.where(like(lessonPlans.content, `%${knowledgePointId}%`));
|
||||||
return rows.filter((r) => {
|
// 类型守卫:过滤出包含该知识点的课案,并映射为 LessonPlanListItem
|
||||||
|
return rows
|
||||||
|
.filter((r) => {
|
||||||
const doc = normalizeDocument(r.content);
|
const doc = normalizeDocument(r.content);
|
||||||
return doc.nodes.some((b) => {
|
return doc.nodes.some((b) => {
|
||||||
const data = b.data as { knowledgePointIds?: string[] };
|
const data = b.data as { knowledgePointIds?: string[] };
|
||||||
return data?.knowledgePointIds?.includes(knowledgePointId);
|
return data?.knowledgePointIds?.includes(knowledgePointId);
|
||||||
});
|
});
|
||||||
}) as unknown as LessonPlanListItem[];
|
})
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
textbookId: r.textbookId,
|
||||||
|
chapterId: r.chapterId,
|
||||||
|
coursePlanItemId: r.coursePlanItemId,
|
||||||
|
subjectId: r.subjectId,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
templateId: r.templateId,
|
||||||
|
templateName: r.templateName,
|
||||||
|
content: normalizeDocument(r.content),
|
||||||
|
status: r.status as LessonPlanListItem["status"],
|
||||||
|
creatorId: r.creatorId,
|
||||||
|
lastSavedAt: r.lastSavedAt?.toISOString() ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
textbookTitle: null,
|
||||||
|
chapterTitle: null,
|
||||||
|
subjectName: null,
|
||||||
|
gradeName: null,
|
||||||
|
creatorName: null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询使用了某题目的课案
|
// 查询使用了某题目的课案
|
||||||
@@ -33,12 +57,35 @@ export async function getLessonPlansByQuestion(
|
|||||||
.select()
|
.select()
|
||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(like(lessonPlans.content, `%${questionId}%`));
|
.where(like(lessonPlans.content, `%${questionId}%`));
|
||||||
return rows.filter((r) => {
|
return rows
|
||||||
|
.filter((r) => {
|
||||||
const doc = normalizeDocument(r.content);
|
const doc = normalizeDocument(r.content);
|
||||||
return doc.nodes.some((b) => {
|
return doc.nodes.some((b) => {
|
||||||
if (b.type !== "exercise") return false;
|
if (b.type !== "exercise") return false;
|
||||||
const data = b.data as { items?: Array<{ questionId: string }> };
|
const data = b.data as { items?: Array<{ questionId: string }> };
|
||||||
return data?.items?.some((it) => it.questionId === questionId);
|
return data?.items?.some((it) => it.questionId === questionId);
|
||||||
});
|
});
|
||||||
}) as unknown as LessonPlanListItem[];
|
})
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
textbookId: r.textbookId,
|
||||||
|
chapterId: r.chapterId,
|
||||||
|
coursePlanItemId: r.coursePlanItemId,
|
||||||
|
subjectId: r.subjectId,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
templateId: r.templateId,
|
||||||
|
templateName: r.templateName,
|
||||||
|
content: normalizeDocument(r.content),
|
||||||
|
status: r.status as LessonPlanListItem["status"],
|
||||||
|
creatorId: r.creatorId,
|
||||||
|
lastSavedAt: r.lastSavedAt?.toISOString() ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
textbookTitle: null,
|
||||||
|
chapterTitle: null,
|
||||||
|
subjectName: null,
|
||||||
|
gradeName: null,
|
||||||
|
creatorName: null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,48 @@ import { createId } from "@paralleldrive/cuid2";
|
|||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema";
|
import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema";
|
||||||
import { SYSTEM_TEMPLATES } from "./constants";
|
import { SYSTEM_TEMPLATES } from "./constants";
|
||||||
import { normalizeDocument } from "./data-access";
|
import { normalizeDocument, LessonPlanDataError } from "./data-access";
|
||||||
import type {
|
import type {
|
||||||
LessonPlanTemplate,
|
LessonPlanTemplate,
|
||||||
TemplateBlockSkeleton,
|
TemplateBlockSkeleton,
|
||||||
|
TemplateType,
|
||||||
|
TemplateScope,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ----
|
||||||
|
const TEMPLATE_TYPES = ["system", "personal"] as const;
|
||||||
|
function isTemplateType(v: string): v is TemplateType {
|
||||||
|
return (TEMPLATE_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const;
|
||||||
|
function isTemplateScope(v: string): v is TemplateScope {
|
||||||
|
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型映射:Drizzle 行 → LessonPlanTemplate(Date → ISO string)----
|
||||||
|
function mapRowToTemplate(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
scope: string;
|
||||||
|
blocks: unknown;
|
||||||
|
creatorId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): LessonPlanTemplate {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: isTemplateType(row.type) ? row.type : "personal",
|
||||||
|
scope: isTemplateScope(row.scope) ? row.scope : "custom",
|
||||||
|
// 从 unknown 转换为 TemplateBlockSkeleton[](DB JSON 字段)
|
||||||
|
blocks: row.blocks as LessonPlanTemplate["blocks"],
|
||||||
|
creatorId: row.creatorId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLessonPlanTemplates(
|
export async function getLessonPlanTemplates(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<LessonPlanTemplate[]> {
|
): Promise<LessonPlanTemplate[]> {
|
||||||
@@ -36,8 +72,7 @@ export async function getLessonPlanTemplates(
|
|||||||
eq(lessonPlanTemplates.creatorId, userId),
|
eq(lessonPlanTemplates.creatorId, userId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const personalTemplates =
|
const personalTemplates = personalRows.map(mapRowToTemplate);
|
||||||
personalRows as unknown as LessonPlanTemplate[];
|
|
||||||
|
|
||||||
return [...systemTemplates, ...personalTemplates];
|
return [...systemTemplates, ...personalTemplates];
|
||||||
}
|
}
|
||||||
@@ -58,7 +93,7 @@ export async function saveAsTemplate(input: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (plan.length === 0) throw new Error("课案不存在或无权访问");
|
if (plan.length === 0) throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
|
||||||
const doc = normalizeDocument(plan[0].content);
|
const doc = normalizeDocument(plan[0].content);
|
||||||
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
|
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
|
|||||||
import { normalizeDocument } from "./data-access";
|
import { normalizeDocument } from "./data-access";
|
||||||
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
|
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
|
||||||
|
|
||||||
|
// ---- 类型映射:Drizzle 行 → LessonPlanVersion(Date → ISO string)----
|
||||||
|
function mapRowToVersion(row: {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
versionNo: number;
|
||||||
|
label: string | null;
|
||||||
|
content: unknown;
|
||||||
|
isAuto: boolean;
|
||||||
|
creatorId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}): LessonPlanVersion {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
planId: row.planId,
|
||||||
|
versionNo: row.versionNo,
|
||||||
|
label: row.label,
|
||||||
|
content: normalizeDocument(row.content),
|
||||||
|
isAuto: row.isAuto,
|
||||||
|
creatorId: row.creatorId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLessonPlanVersions(
|
export async function getLessonPlanVersions(
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -27,7 +50,7 @@ export async function getLessonPlanVersions(
|
|||||||
.from(lessonPlanVersions)
|
.from(lessonPlanVersions)
|
||||||
.where(eq(lessonPlanVersions.planId, planId))
|
.where(eq(lessonPlanVersions.planId, planId))
|
||||||
.orderBy(desc(lessonPlanVersions.versionNo));
|
.orderBy(desc(lessonPlanVersions.versionNo));
|
||||||
return rows as unknown as LessonPlanVersion[];
|
return rows.map(mapRowToVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLessonPlanVersion(input: {
|
export async function createLessonPlanVersion(input: {
|
||||||
|
|||||||
@@ -26,11 +26,130 @@ import type {
|
|||||||
LessonPlanDocument,
|
LessonPlanDocument,
|
||||||
LessonPlanListItem,
|
LessonPlanListItem,
|
||||||
LessonPlanTemplate,
|
LessonPlanTemplate,
|
||||||
|
LessonPlanStatus,
|
||||||
|
TemplateType,
|
||||||
|
TemplateScope,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// re-export 纯函数保持向后兼容
|
// re-export 纯函数保持向后兼容
|
||||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||||||
|
|
||||||
|
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
||||||
|
export class LessonPlanDataError extends Error {
|
||||||
|
constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") {
|
||||||
|
super(code);
|
||||||
|
this.name = "LessonPlanDataError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ----
|
||||||
|
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
||||||
|
function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
||||||
|
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
const TEMPLATE_TYPES = ["system", "personal"] as const;
|
||||||
|
function isTemplateType(v: string): v is TemplateType {
|
||||||
|
return (TEMPLATE_TYPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const;
|
||||||
|
function isTemplateScope(v: string): v is TemplateScope {
|
||||||
|
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型映射:Drizzle 行 → LessonPlan(Date → ISO string)----
|
||||||
|
function mapRowToLessonPlan(row: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
textbookId: string | null;
|
||||||
|
chapterId: string | null;
|
||||||
|
coursePlanItemId: string | null;
|
||||||
|
subjectId: string | null;
|
||||||
|
gradeId: string | null;
|
||||||
|
templateId: string | null;
|
||||||
|
templateName: string | null;
|
||||||
|
content: unknown;
|
||||||
|
status: string;
|
||||||
|
creatorId: string;
|
||||||
|
lastSavedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): LessonPlan {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
textbookId: row.textbookId,
|
||||||
|
chapterId: row.chapterId,
|
||||||
|
coursePlanItemId: row.coursePlanItemId,
|
||||||
|
subjectId: row.subjectId,
|
||||||
|
gradeId: row.gradeId,
|
||||||
|
templateId: row.templateId,
|
||||||
|
templateName: row.templateName,
|
||||||
|
content: normalizeDocument(row.content),
|
||||||
|
status: isLessonPlanStatus(row.status) ? row.status : "draft",
|
||||||
|
creatorId: row.creatorId,
|
||||||
|
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型映射:Drizzle 行 → LessonPlanListItem ----
|
||||||
|
function mapRowToListItem(row: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
textbookId: string | null;
|
||||||
|
chapterId: string | null;
|
||||||
|
coursePlanItemId: string | null;
|
||||||
|
subjectId: string | null;
|
||||||
|
gradeId: string | null;
|
||||||
|
templateId: string | null;
|
||||||
|
templateName: string | null;
|
||||||
|
content: unknown;
|
||||||
|
status: string;
|
||||||
|
creatorId: string;
|
||||||
|
lastSavedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
textbookTitle: string | null;
|
||||||
|
chapterTitle: string | null;
|
||||||
|
subjectName: string | null;
|
||||||
|
gradeName: string | null;
|
||||||
|
creatorName: string | null;
|
||||||
|
}): LessonPlanListItem {
|
||||||
|
return {
|
||||||
|
...mapRowToLessonPlan(row),
|
||||||
|
textbookTitle: row.textbookTitle,
|
||||||
|
chapterTitle: row.chapterTitle,
|
||||||
|
subjectName: row.subjectName,
|
||||||
|
gradeName: row.gradeName,
|
||||||
|
creatorName: row.creatorName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型映射:Drizzle 行 → LessonPlanTemplate ----
|
||||||
|
function mapRowToTemplate(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
scope: string;
|
||||||
|
blocks: unknown;
|
||||||
|
creatorId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): LessonPlanTemplate {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: isTemplateType(row.type) ? row.type : "personal",
|
||||||
|
scope: isTemplateScope(row.scope) ? row.scope : "custom",
|
||||||
|
// 从 unknown 转换为 TemplateBlockSkeleton[](DB JSON 字段)
|
||||||
|
blocks: row.blocks as LessonPlanTemplate["blocks"],
|
||||||
|
creatorId: row.creatorId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---- DataScope → 查询条件 ----
|
// ---- DataScope → 查询条件 ----
|
||||||
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
|
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
|
||||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||||
@@ -143,10 +262,7 @@ export const getLessonPlans = cache(
|
|||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(lessonPlans.updatedAt));
|
.orderBy(desc(lessonPlans.updatedAt));
|
||||||
|
|
||||||
const items = rows as unknown as LessonPlanListItem[];
|
const items = rows.map(mapRowToListItem);
|
||||||
items.forEach((it) => {
|
|
||||||
it.content = normalizeDocument(it.content);
|
|
||||||
});
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -163,13 +279,12 @@ export const getLessonPlanById = cache(
|
|||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
// 权限:creator 可看 draft;非 creator 仅 published
|
// 权限:creator 可看 draft;非 creator 仅 published
|
||||||
if (row.creatorId !== userId && row.status !== "published") return null;
|
if (row.creatorId !== userId && row.status !== "published") return null;
|
||||||
const plan = row as unknown as LessonPlan;
|
return mapRowToLessonPlan(row);
|
||||||
plan.content = normalizeDocument(plan.content);
|
|
||||||
return plan;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- 创建 ----
|
// ---- 创建 ----
|
||||||
|
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||||||
export async function createLessonPlan(input: {
|
export async function createLessonPlan(input: {
|
||||||
title: string;
|
title: string;
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -178,12 +293,20 @@ export async function createLessonPlan(input: {
|
|||||||
gradeId?: string;
|
gradeId?: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
|
translateTitle?: (key: string) => string;
|
||||||
}): Promise<{ planId: string }> {
|
}): Promise<{ planId: string }> {
|
||||||
const template = await getTemplateById(input.templateId);
|
const template = await getTemplateById(input.templateId);
|
||||||
if (!template) throw new Error("模板不存在");
|
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
|
||||||
|
|
||||||
const planId = createId();
|
const planId = createId();
|
||||||
const content = buildInitialContent(template.blocks);
|
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
|
||||||
|
const blocks = input.translateTitle
|
||||||
|
? template.blocks.map((b) => ({
|
||||||
|
...b,
|
||||||
|
title: input.translateTitle!(b.title),
|
||||||
|
}))
|
||||||
|
: template.blocks;
|
||||||
|
const content = buildInitialContent(blocks);
|
||||||
|
|
||||||
await db.insert(lessonPlans).values({
|
await db.insert(lessonPlans).values({
|
||||||
id: planId,
|
id: planId,
|
||||||
@@ -240,7 +363,7 @@ export async function duplicateLessonPlan(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ newPlanId: string }> {
|
): Promise<{ newPlanId: string }> {
|
||||||
const src = await getLessonPlanById(planId, userId);
|
const src = await getLessonPlanById(planId, userId);
|
||||||
if (!src) throw new Error("课案不存在或无权访问");
|
if (!src) throw new LessonPlanDataError("NOT_FOUND");
|
||||||
|
|
||||||
const newId = createId();
|
const newId = createId();
|
||||||
await db.insert(lessonPlans).values({
|
await db.insert(lessonPlans).values({
|
||||||
@@ -284,7 +407,5 @@ export async function getTemplateById(
|
|||||||
.from(lessonPlanTemplates)
|
.from(lessonPlanTemplates)
|
||||||
.where(eq(lessonPlanTemplates.id, templateId))
|
.where(eq(lessonPlanTemplates.id, templateId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return rows.length > 0
|
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
|
||||||
? (rows[0] as unknown as LessonPlanTemplate)
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,3 +187,9 @@ export function useLessonPlanService(): LessonPlanDataService {
|
|||||||
export function useLessonPlanTracker(): LessonPlanTracker {
|
export function useLessonPlanTracker(): LessonPlanTracker {
|
||||||
return useLessonPlanContext().tracker;
|
return useLessonPlanContext().tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hook:获取埋点(若未在 Provider 内则返回 noopTracker,不抛错) */
|
||||||
|
export function useLessonPlanTrackerSafe(): LessonPlanTracker {
|
||||||
|
const ctx = useContext(LessonPlanContext);
|
||||||
|
return ctx?.tracker ?? noopTracker;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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 { 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, LessonPlan, LessonPlanStatus } from "./types";
|
||||||
|
|
||||||
interface PublishInput {
|
interface PublishInput {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -27,6 +27,32 @@ interface PublishResult {
|
|||||||
updatedContent: LessonPlanDocument;
|
updatedContent: LessonPlanDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 类型守卫:安全地将 string 收窄为 LessonPlanStatus
|
||||||
|
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
||||||
|
function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
||||||
|
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* publish-service 错误:使用错误码替代硬编码中文,
|
||||||
|
* 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。
|
||||||
|
*/
|
||||||
|
export type PublishErrorCode =
|
||||||
|
| "PLAN_NOT_FOUND"
|
||||||
|
| "NO_PERMISSION"
|
||||||
|
| "NO_EXERCISE_BLOCK"
|
||||||
|
| "NO_QUESTIONS"
|
||||||
|
| "ALREADY_PUBLISHED"
|
||||||
|
| "NO_SUBJECT_OR_GRADE"
|
||||||
|
| "NO_STUDENTS";
|
||||||
|
|
||||||
|
export class PublishServiceError extends Error {
|
||||||
|
constructor(public readonly code: PublishErrorCode) {
|
||||||
|
super(code);
|
||||||
|
this.name = "PublishServiceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishLessonPlanHomework(
|
export async function publishLessonPlanHomework(
|
||||||
input: PublishInput,
|
input: PublishInput,
|
||||||
): Promise<PublishResult> {
|
): Promise<PublishResult> {
|
||||||
@@ -36,38 +62,43 @@ export async function publishLessonPlanHomework(
|
|||||||
.from(lessonPlans)
|
.from(lessonPlans)
|
||||||
.where(eq(lessonPlans.id, input.planId))
|
.where(eq(lessonPlans.id, input.planId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) throw new Error("课案不存在");
|
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
|
||||||
const row = rows[0] as unknown as {
|
const row = rows[0];
|
||||||
id: string;
|
// 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段
|
||||||
content: unknown;
|
const plan: LessonPlan = {
|
||||||
creatorId: string;
|
id: row.id,
|
||||||
title: string;
|
title: row.title,
|
||||||
textbookId: string | null;
|
textbookId: row.textbookId,
|
||||||
chapterId: string | null;
|
chapterId: row.chapterId,
|
||||||
subjectId: string | null;
|
coursePlanItemId: row.coursePlanItemId,
|
||||||
gradeId: string | null;
|
subjectId: row.subjectId,
|
||||||
};
|
gradeId: row.gradeId,
|
||||||
const plan = {
|
templateId: row.templateId,
|
||||||
...row,
|
templateName: row.templateName,
|
||||||
content: normalizeDocument(row.content),
|
content: normalizeDocument(row.content),
|
||||||
|
status: isLessonPlanStatus(row.status) ? row.status : "draft",
|
||||||
|
creatorId: row.creatorId,
|
||||||
|
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
if (plan.creatorId !== input.userId)
|
if (plan.creatorId !== input.userId)
|
||||||
throw new Error("无权发布");
|
throw new PublishServiceError("NO_PERMISSION");
|
||||||
|
|
||||||
// 2. 定位 exercise block
|
// 2. 定位 exercise block
|
||||||
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
||||||
if (!block || block.type !== "exercise")
|
if (!block || block.type !== "exercise")
|
||||||
throw new Error("练习块不存在");
|
throw new PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
const data = block.data as ExerciseBlockData;
|
const data = block.data as ExerciseBlockData;
|
||||||
if (data.items.length === 0) throw new Error("练习块无题目");
|
if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS");
|
||||||
if (data.publishedAssignmentId)
|
if (data.publishedAssignmentId)
|
||||||
throw new Error("该练习块已发布,请使用'重新发布'");
|
throw new PublishServiceError("ALREADY_PUBLISHED");
|
||||||
|
|
||||||
// 3. inline 题目入库,替换占位 ID
|
// 3. inline 题目入库,替换占位 ID
|
||||||
const newContent: LessonPlanDocument = structuredClone(plan.content);
|
const newContent: LessonPlanDocument = structuredClone(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 PublishServiceError("NO_EXERCISE_BLOCK");
|
||||||
const newData = newBlock.data as ExerciseBlockData;
|
const newData = newBlock.data as ExerciseBlockData;
|
||||||
|
|
||||||
for (let i = 0; i < newData.items.length; i++) {
|
for (let i = 0; i < newData.items.length; i++) {
|
||||||
@@ -100,7 +131,7 @@ export async function publishLessonPlanHomework(
|
|||||||
// 4. 打包 exam 草稿
|
// 4. 打包 exam 草稿
|
||||||
const examId = createId();
|
const examId = createId();
|
||||||
if (!plan.subjectId || !plan.gradeId) {
|
if (!plan.subjectId || !plan.gradeId) {
|
||||||
throw new Error("课案缺少学科或年级信息,无法发布");
|
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
|
||||||
}
|
}
|
||||||
await persistExamDraft({
|
await persistExamDraft({
|
||||||
examId,
|
examId,
|
||||||
@@ -125,7 +156,7 @@ export async function publishLessonPlanHomework(
|
|||||||
const assignmentId = createId();
|
const assignmentId = createId();
|
||||||
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
|
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
|
||||||
if (targetStudentIds.length === 0) {
|
if (targetStudentIds.length === 0) {
|
||||||
throw new Error("所选班级无学生");
|
throw new PublishServiceError("NO_STUDENTS");
|
||||||
}
|
}
|
||||||
await createHomeworkAssignment({
|
await createHomeworkAssignment({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
|
|||||||
@@ -63,9 +63,30 @@
|
|||||||
"tpl_experiment": "Experiment Lesson",
|
"tpl_experiment": "Experiment Lesson",
|
||||||
"tpl_inquiry": "Inquiry Lesson",
|
"tpl_inquiry": "Inquiry Lesson",
|
||||||
"tpl_blank": "Blank Template"
|
"tpl_blank": "Blank Template"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"tpl_review": {
|
||||||
|
"1": "Knowledge Network",
|
||||||
|
"2": "Typical Examples",
|
||||||
|
"3": "Variant Training"
|
||||||
|
},
|
||||||
|
"tpl_experiment": {
|
||||||
|
"1": "Equipment Preparation",
|
||||||
|
"2": "Experiment Steps",
|
||||||
|
"3": "Observation Record",
|
||||||
|
"4": "Discussion"
|
||||||
|
},
|
||||||
|
"tpl_inquiry": {
|
||||||
|
"0": "Scenario Introduction",
|
||||||
|
"1": "Problem Driven",
|
||||||
|
"2": "Group Inquiry",
|
||||||
|
"3": "Results Presentation",
|
||||||
|
"4": "Inductive Elevation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
"canvasLabel": "Lesson Plan Canvas",
|
||||||
"canvasEmpty": "Canvas is empty",
|
"canvasEmpty": "Canvas is empty",
|
||||||
"canvasEmptyHint": "Click \"Add Node\" at the bottom left to start",
|
"canvasEmptyHint": "Click \"Add Node\" at the bottom left to start",
|
||||||
"selectNodeHint": "Click a node to edit content, or drag to connect",
|
"selectNodeHint": "Click a node to edit content, or drag to connect",
|
||||||
@@ -142,6 +163,7 @@
|
|||||||
"addBtn": "Add"
|
"addBtn": "Add"
|
||||||
},
|
},
|
||||||
"exercise": {
|
"exercise": {
|
||||||
|
"purposeLabel": "Purpose",
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"class_practice": "Class Practice",
|
"class_practice": "Class Practice",
|
||||||
"after_class_homework": "After-class Homework"
|
"after_class_homework": "After-class Homework"
|
||||||
|
|||||||
@@ -63,9 +63,30 @@
|
|||||||
"tpl_experiment": "实验课",
|
"tpl_experiment": "实验课",
|
||||||
"tpl_inquiry": "探究课",
|
"tpl_inquiry": "探究课",
|
||||||
"tpl_blank": "空白模板"
|
"tpl_blank": "空白模板"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"tpl_review": {
|
||||||
|
"1": "知识网络梳理",
|
||||||
|
"2": "典型例题精讲",
|
||||||
|
"3": "变式训练"
|
||||||
|
},
|
||||||
|
"tpl_experiment": {
|
||||||
|
"1": "器材准备",
|
||||||
|
"2": "实验步骤",
|
||||||
|
"3": "观察记录表",
|
||||||
|
"4": "交流讨论"
|
||||||
|
},
|
||||||
|
"tpl_inquiry": {
|
||||||
|
"0": "情境导入",
|
||||||
|
"1": "问题驱动",
|
||||||
|
"2": "小组探究",
|
||||||
|
"3": "成果展示",
|
||||||
|
"4": "归纳提升"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
"canvasLabel": "备课画布",
|
||||||
"canvasEmpty": "画布为空",
|
"canvasEmpty": "画布为空",
|
||||||
"canvasEmptyHint": "点击左下角\"添加节点\"开始备课",
|
"canvasEmptyHint": "点击左下角\"添加节点\"开始备课",
|
||||||
"selectNodeHint": "点击节点编辑内容,或拖拽连线建立流程",
|
"selectNodeHint": "点击节点编辑内容,或拖拽连线建立流程",
|
||||||
@@ -142,6 +163,7 @@
|
|||||||
"addBtn": "添加"
|
"addBtn": "添加"
|
||||||
},
|
},
|
||||||
"exercise": {
|
"exercise": {
|
||||||
|
"purposeLabel": "用途",
|
||||||
"purpose": {
|
"purpose": {
|
||||||
"class_practice": "课堂练习",
|
"class_practice": "课堂练习",
|
||||||
"after_class_homework": "课后作业"
|
"after_class_homework": "课后作业"
|
||||||
|
|||||||
Reference in New Issue
Block a user