Compare commits
12 Commits
284c7939b8
...
872d5fb085
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
872d5fb085 | ||
|
|
cbc6e259fa | ||
|
|
4b6cb5f11e | ||
|
|
d2c250a1b3 | ||
|
|
3569d83b8e | ||
|
|
98429e87eb | ||
|
|
b63d116b6c | ||
|
|
eee0145274 | ||
|
|
6ea8ba763b | ||
|
|
25dca843be | ||
|
|
41fe8d8903 | ||
|
|
e7a01eadef |
@@ -121,3 +121,35 @@ src/modules/[module]/
|
|||||||
- 使用 Conventional Commits 格式:`feat(scope): description`
|
- 使用 Conventional Commits 格式:`feat(scope): description`
|
||||||
- 类型:`feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `perf`, `ci`
|
- 类型:`feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `perf`, `ci`
|
||||||
- 提交前必须运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误
|
- 提交前必须运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误
|
||||||
|
|
||||||
|
## 问题记录规则
|
||||||
|
|
||||||
|
**所有工作完成后,必须将遇到的问题记录到 `docs/troubleshooting/known-issues.md`(速查手册格式)。**
|
||||||
|
|
||||||
|
### 必须记录的场景
|
||||||
|
|
||||||
|
| 场景 | 记录要求 |
|
||||||
|
|------|---------|
|
||||||
|
| 构建报错(dev/build/lint/tsc) | 记录错误现象 + 正确写法 |
|
||||||
|
| 运行时异常(白屏/API 报错/数据加载失败) | 记录错误现象 + 正确写法 |
|
||||||
|
| 框架/库版本兼容问题 | 记录错误现象 + 正确写法 |
|
||||||
|
| 依赖配置问题(serverExternalPackages/webpackIgnore 等) | 记录错误现象 + 正确写法 |
|
||||||
|
| 架构约束违规 | 记录错误现象 + 正确写法 |
|
||||||
|
|
||||||
|
### 记录格式
|
||||||
|
|
||||||
|
以**规则表**形式记录,指明正确做法,无需详细解释原因:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 问题分类标题
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 简述规则 | 代码示例 | 代码示例 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 记录要求
|
||||||
|
|
||||||
|
- **速查手册风格**:只指明方向,不做新手指导
|
||||||
|
- **去重**:同类问题在原条目补充,不重复创建
|
||||||
|
- **可操作**:正确写法需具体到代码示例
|
||||||
|
|||||||
@@ -894,7 +894,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `actions.ts` | 502 | 14 个 Server Action(写操作,含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理 + class-mastery 视图) |
|
| `actions.ts` | 502 | 14 个 Server Action(写操作,含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理 + class-mastery 视图) |
|
||||||
| `data-access.ts` | 593 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD(v3 P2:硬编码异常改为 NotFoundError) |
|
| `data-access.ts` | 593 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD(v3 P2:硬编码异常改为 NotFoundError) |
|
||||||
| `data-access-graph.ts` | 188 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only`;v2 修复:前置依赖双向 IN 过滤防跨教材污染) |
|
| `data-access-graph.ts` | 188 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only`;v2 修复:前置依赖双向 IN 过滤防跨教材污染) |
|
||||||
| `types.ts` | 94 | 类型定义(含知识图谱类型:GraphViewMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel) |
|
| `types.ts` | 94 | 类型定义(含知识图谱类型:GraphViewMode/GraphLayoutMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel) |
|
||||||
| `schema.ts` | 56 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchema;v2 修复:refine 消息改为英文;v3 修复:移除死代码 ReorderChaptersSchema) |
|
| `schema.ts` | 56 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchema;v2 修复:refine 消息改为英文;v3 修复:移除死代码 ReorderChaptersSchema) |
|
||||||
| `constants.ts` | 91 | 学科/年级常量与颜色映射(✅ 新增;v1 测试修复:新增 Chinese/Grade 1/Grade 2) |
|
| `constants.ts` | 91 | 学科/年级常量与颜色映射(✅ 新增;v1 测试修复:新增 Chinese/Grade 1/Grade 2) |
|
||||||
| `utils.ts` | 226 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) |
|
| `utils.ts` | 226 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) |
|
||||||
@@ -908,10 +908,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `hooks/use-kp-update.ts` | 36 | 知识点更新 Hook(v3 P2 新增:从 use-kp-crud 拆分) |
|
| `hooks/use-kp-update.ts` | 36 | 知识点更新 Hook(v3 P2 新增:从 use-kp-crud 拆分) |
|
||||||
| `hooks/use-text-selection.ts` | 49 | 文本选区捕获 Hook |
|
| `hooks/use-text-selection.ts` | 49 | 文本选区捕获 Hook |
|
||||||
| `hooks/use-graph-data.ts` | 65 | 知识图谱数据加载 Hook(✅ Task 11 新增:派生值模式避免 effect 中 setState;v2 修复:区分 isLoading/isRefreshing 避免 UI 闪烁) |
|
| `hooks/use-graph-data.ts` | 65 | 知识图谱数据加载 Hook(✅ Task 11 新增:派生值模式避免 effect 中 setState;v2 修复:区分 isLoading/isRefreshing 避免 UI 闪烁) |
|
||||||
| `components/knowledge-graph.tsx` | 356 | React Flow 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色;v2 新增:添加/删除前置依赖 Dialog) |
|
| `components/knowledge-graph.tsx` | 408 | 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色;v2 新增:添加/删除前置依赖 Dialog;v4 新增:hierarchical/force 布局模式切换,根据布局模式渲染分层 ReactFlow 或 Obsidian 风格力导向图,状态/详情面板/依赖增删对话框共享) |
|
||||||
| `components/graph-kp-node.tsx` | 92 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条;v2 修复:节点宽度常量化 NODE_WIDTH;v3 修复:类型守卫替代 as 断言) |
|
| `components/graph-kp-node.tsx` | 92 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条;v2 修复:节点宽度常量化 NODE_WIDTH;v3 修复:类型守卫替代 as 断言) |
|
||||||
| `components/graph-prerequisite-edge.tsx` | 43 | React Flow 自定义边(✅ Task 9 新增:虚线+箭头表示前置依赖;v2 修复:GraphEdgeData 类型安全转换) |
|
| `components/graph-prerequisite-edge.tsx` | 43 | React Flow 自定义边(✅ Task 9 新增:虚线+箭头表示前置依赖;v2 修复:GraphEdgeData 类型安全转换) |
|
||||||
| `components/graph-toolbar.tsx` | 84 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图;v2 新增:isRefreshing 轻量指示器) |
|
| `components/graph-toolbar.tsx` | 121 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图;v2 新增:isRefreshing 轻量指示器;v4 新增:hierarchical/force 布局模式切换 Select) |
|
||||||
|
| `components/force-graph.tsx` | 451 | Obsidian 风格力导向图谱组件(v4 新增:基于 react-force-graph-2d + d3-force;v5 视觉对齐 Obsidian Graph View:小圆点节点+极细半透明边线+标签右侧带背景+hover/选中邻居高亮+紫色外圈焦点环;通过 next/dynamic ssr:false + import type 加载避免 SSR canvas/window 报错) |
|
||||||
| `components/graph-node-detail-panel.tsx` | 171 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表;v2 修复:移除未使用 textbookId prop;v3 修复:aria-label) |
|
| `components/graph-node-detail-panel.tsx` | 171 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表;v2 修复:移除未使用 textbookId prop;v3 修复:aria-label) |
|
||||||
| `components/chapter-sidebar-list.tsx` | 325 | 章节侧边栏列表(v2 修复:canEdit 默认 false + orderUpdateFailed i18n key;v3 修复:键盘导航) |
|
| `components/chapter-sidebar-list.tsx` | 325 | 章节侧边栏列表(v2 修复:canEdit 默认 false + orderUpdateFailed i18n key;v3 修复:键盘导航) |
|
||||||
| `components/section-error-boundary.tsx` | 53 | 章节内容错误边界(v2 修复:默认值改为空字符串 + 条件渲染;v3 修复:componentDidCatch + a11y) |
|
| `components/section-error-boundary.tsx` | 53 | 章节内容错误边界(v2 修复:默认值改为空字符串 + 条件渲染;v3 修复:componentDidCatch + a11y) |
|
||||||
@@ -919,7 +920,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `components/textbook-content-panel.tsx` | 199 | 章节内容面板(v3 P2:17 props 按 chapter/editing/selection/highlight 四组重构) |
|
| `components/textbook-content-panel.tsx` | 199 | 章节内容面板(v3 P2:17 props 按 chapter/editing/selection/highlight 四组重构) |
|
||||||
| `components/textbook-form-dialog.tsx` | 68 | 教材创建对话框(v3 P2:复用 TextbookFormFields) |
|
| `components/textbook-form-dialog.tsx` | 68 | 教材创建对话框(v3 P2:复用 TextbookFormFields) |
|
||||||
| `components/textbook-settings-dialog.tsx` | 136 | 教材设置对话框(v3 P2:复用 TextbookFormFields) |
|
| `components/textbook-settings-dialog.tsx` | 136 | 教材设置对话框(v3 P2:复用 TextbookFormFields) |
|
||||||
| `components/textbook-reader.tsx` | 412 | 教材阅读器主组件(v3 P2:移除命令式 classList,高亮改为状态驱动) |
|
| `components/textbook-reader.tsx` | 458 | 教材阅读器主组件(v3 P2:移除命令式 classList,高亮改为状态驱动;v5 桌面端改用 ResizablePanel 可拖拽分栏,图谱 tab 时图谱初始占 60%,切换 tab 用 key 重置比例) |
|
||||||
| `components/*` | 17 文件 | 教材编辑/知识图谱组件(新增 `textbook-form-fields.tsx`、5 个 graph-* 组件、3 个 use-kp-* 子 Hook;`teacher-textbook-reader.tsx` 已移至 app 层) |
|
| `components/*` | 17 文件 | 教材编辑/知识图谱组件(新增 `textbook-form-fields.tsx`、5 个 graph-* 组件、3 个 use-kp-* 子 Hook;`teacher-textbook-reader.tsx` 已移至 app 层) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2758,6 +2759,34 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
> - **权限扩展**:新增 STANDARD_READ/MANAGE/LINK + LESSON_PLAN_SUBMIT/REVIEW/ANALYTICS_READ/SUBSTITUTE_MANAGE
|
> - **权限扩展**:新增 STANDARD_READ/MANAGE/LINK + LESSON_PLAN_SUBMIT/REVIEW/ANALYTICS_READ/SUBSTITUTE_MANAGE
|
||||||
> - **i18n 扩展**:zh-CN 新增 9 个命名空间;status 扩展为 6 状态
|
> - **i18n 扩展**:zh-CN 新增 9 个命名空间;status 扩展为 6 状态
|
||||||
> - **数据库表**:新增 6 张表(lessonPlanComments/lessonPlanReviewRecords/lessonPlanFormativeItems/lessonPlanAttachments/lessonPlanAiEvaluations/lessonPlanSubstituteAssignments)
|
> - **数据库表**:新增 6 张表(lessonPlanComments/lessonPlanReviewRecords/lessonPlanFormativeItems/lessonPlanAttachments/lessonPlanAiEvaluations/lessonPlanSubstituteAssignments)
|
||||||
|
>
|
||||||
|
> 架构变更(2026-07-03,V5 场景缺口修复第一阶段):
|
||||||
|
> - **V5-1 自动保存失败 UI 兜底**:version-slice.ts 新增 saveError/isOnline 状态;lesson-plan-editor.tsx 监听 online/offline 事件 + toast 提示 + 重试按钮
|
||||||
|
> - **V5-2 撤销/重做**:新增 hooks/history-slice.ts(past/future 栈,50 步上限);editor-slice.ts 所有 mutation 包装 pushHistory;编辑器顶部 Undo/Redo 按钮 + Ctrl/Cmd+Z/Shift+Z 快捷键
|
||||||
|
> - **V5-3 发布前作业单预览**:publish-homework-dialog.tsx 改为 3 步流程(选班级 → 预览题目+总分+学生数 → 确认发布);exercise-block.tsx 传入 items
|
||||||
|
> - **V5-4 导出/打印**:新增 lib/export.ts(文档扁平化,详细版/简洁版)+ components/print-view.tsx(打印视图);编辑器顶部新增"导出"按钮
|
||||||
|
> - **V5-5 多媒体嵌入 + 素材库**:types.ts RichTextBlockData 新增 attachments 字段;rich-text-block.tsx 集成 @tiptap/extension-image;新增 components/attachment-picker.tsx(复用 use-file-upload + M6 附件表 data-access);LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法;新增依赖 @tiptap/extension-image;FileTargetType 新增 "lesson_plan"
|
||||||
|
> - **i18n 扩展**:zh-CN/en 同步新增 action.undo/redo、status.retrySave/saveFailed/recovered/offline、publish.step1-3/preview*/confirm*、questionBank.type 多题型、export.*、attachment.*、editor.textbookContent
|
||||||
|
> - **审计报告**:新增 docs/architecture/audit/lesson-preparation-audit-report-v5.md(场景缺口维度,对照 v4 架构合规维度)
|
||||||
|
>
|
||||||
|
> 架构变更(2026-07-03,V5 场景缺口修复第二阶段 V5-6~V5-21):
|
||||||
|
> - **V5-6 W1 题库组卷预览**:exercise-block.tsx 增加组卷预览模式(题目列表 + 总分统计)
|
||||||
|
> - **V5-7 W3/W4 课案绑定课时**:新增 data-access-schedules.ts + actions-schedules.ts + components/schedule-dialog.tsx;编辑器新增「安排课时」按钮;数据库新增 lesson_plan_schedules 表
|
||||||
|
> - **V5-8 U2 画布自动布局**:新增 lib/auto-layout.ts(@dagrejs/dagre DAG 自动布局,项目已安装依赖无需新增);editor-slice.ts 新增 autoLayout action
|
||||||
|
> - **V5-9 F5 教材模糊搜索 + 最近使用**:template-picker.tsx 增加客户端模糊搜索(标题/学科/年级/出版社)+ localStorage 最近使用 5 个教材
|
||||||
|
> - **V5-10 P1 画布性能优化**:node-editor.tsx ReactFlow 新增 onlyRenderVisibleElements 虚拟化 + minZoom={0.2}/maxZoom={2.5}/elevateNodesOnSelect={false}
|
||||||
|
> - **V5-12 R4 校内课案库**:新增 teacher/lesson-plans/library 路由 + loading.tsx + error.tsx;actions.ts 新增 duplicateLessonPlanFormAction(FormData wrapper 适配 form action)
|
||||||
|
> - **V5-13 P3 移动端只读视图**:新增 components/lesson-plan-mobile-view.tsx(按教学阶段分组线性卡片视图);lesson-plan-readonly-view.tsx 集成 useMediaQuery("(max-width: 768px)") 小屏切换
|
||||||
|
> - **V5-14 F4 板书可视化工具**:blackboard-block.tsx 增加轻量级可视化预览(不引入新库)——编辑/预览模式切换、structure 层级树、mindmap 中心+分支、text 等宽展示;新增 BlackboardPreview 子组件 + parseContent 纯函数(按缩进识别层级)
|
||||||
|
> - **V5-15 T1 教学阶段分组**:types.ts 新增 TeachingStage 类型 + TEACHING_STAGE_KEYS 常量;Block 接口新增 stage? 字段;node-edit-panel.tsx 添加教学阶段选择器 UI
|
||||||
|
> - **V5-16 T2 反思闭环(版本对比)**:新增 lib/version-diff.ts(纯函数 diffDocuments 对比两个 LessonPlanDocument 节点级差异,added/removed/modified/unchanged + changedFields);新增 components/version-diff-view.tsx;version-history-drawer.tsx 集成版本对比(currentDoc prop + 对比按钮)
|
||||||
|
> - **V5-17 A1/A2 AI 反馈闭环**:新增 lib/ai-feedback.ts(generateLessonPlanFeedback 纯服务端函数,4 维度 strengths/improvements/alignment/differentiation,含解释性 reason 字段);actions-ai.ts 新增 generateLessonPlanFeedbackAction;新增 components/ai-feedback-dialog.tsx;编辑器工具栏新增「AI 反馈」按钮
|
||||||
|
> - **V5-18 W6 差异化教学标记**:types.ts 新增 DifferentiationLevel 类型 + DIFFERENTIATION_LEVEL_KEYS 常量;Block 接口新增 differentiation? 字段;node-edit-panel.tsx 添加差异化标记选择器 UI
|
||||||
|
> - **V5-19 T3 目标-评价一致性校验**:新增 lib/consistency-check.ts(纯函数 checkConsistency 输出 ConsistencyResult,校验规则:缺少 objective/exercise 警告、exercise 无知识点警告、objective 未被评价覆盖警告);新增 components/consistency-check-dialog.tsx;编辑器工具栏新增「一致性校验」按钮
|
||||||
|
> - **V5-20 T4 课标覆盖热力图**:新增 lib/curriculum-coverage.ts(纯函数 computeCurriculumCoverage 按 chapterId 分组统计覆盖率,识别 isBlindSpot 教学盲点;getHeatLevel 0-4 热力等级);新增 components/curriculum-heatmap.tsx;新增 teacher/lesson-plans/heatmap 路由 + loading.tsx + error.tsx
|
||||||
|
> - **V5-21 A3/A4/A5 AI 差异化模块**:新增 lib/ai-differentiation.ts(3 个纯服务端函数:generateDifferentiationSuggestions/checkCurriculumAlignment/generateExplainableAssessment,复用 ai-feedback.ts 的 createAiChatCompletion + Zod + JSON 提取模式);actions-ai.ts 新增 4 个 server actions(generateDifferentiationSuggestionsAction/checkCurriculumAlignmentAction/generateExplainableAssessmentAction/getKnowledgePointsForAlignmentAction);新增 components/ai-differentiation-dialog.tsx(3 Tab 视图:差异化建议/课标核对/可解释评估,A4 按教材 ID 按需加载知识点);编辑器工具栏新增「AI 差异化」按钮
|
||||||
|
> - **i18n 扩展**:zh-CN/en 同步新增 editor.stage*/editor.differentiation*/editor.consistency*/consistency.*/diff.*/library.*/feedback.*/heatmap.*/aiDifferentiation.*/blackboard.editMode/previewMode/editHint/previewEmpty/untitled
|
||||||
|
> - **V5-11 R3 协同编辑**:需引入新依赖(Yjs/Liveblocks)或采用轻量协作方案(评论+@提醒+版本对比),暂标记为待规划,待用户决策
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3893,7 +3922,7 @@ rateLimit(params: RateLimitParams): Promise<RateLimitResult> // audit-P1-6:
|
|||||||
resetRateLimit(key: string): Promise<void> // audit-P1-6:改为 Promise 返回
|
resetRateLimit(key: string): Promise<void> // audit-P1-6:改为 Promise 返回
|
||||||
rateLimitKey(prefix: string, identifier: string): string // 同步纯函数
|
rateLimitKey(prefix: string, identifier: string): string // 同步纯函数
|
||||||
rateLimitHeaders(result: RateLimitResult): Record<string, string> // 同步纯函数
|
rateLimitHeaders(result: RateLimitResult): Record<string, string> // 同步纯函数
|
||||||
getRateLimiter(): RateLimiter // audit-P1-6 新增:返回当前进程的限流器实例
|
getRateLimiter(): Promise<RateLimiter> // audit-P1-6 新增:返回当前进程的限流器实例;redis 驱动下动态 import redis-limiter 故为异步
|
||||||
RATE_LIMIT_RULES // 同步常量(含 LOGIN/API/UPLOAD/AI_CHAT/PASSWORD_CHANGE/ONBOARDING_BIND;audit-P1-8 新增 ONBOARDING_BIND: 5次/小时)
|
RATE_LIMIT_RULES // 同步常量(含 LOGIN/API/UPLOAD/AI_CHAT/PASSWORD_CHANGE/ONBOARDING_BIND;audit-P1-8 新增 ONBOARDING_BIND: 5次/小时)
|
||||||
// RateLimiter interface (audit-P1-6 新增)
|
// RateLimiter interface (audit-P1-6 新增)
|
||||||
// limit(params: RateLimitParams): Promise<RateLimitResult>
|
// limit(params: RateLimitParams): Promise<RateLimitResult>
|
||||||
|
|||||||
@@ -6443,11 +6443,21 @@
|
|||||||
"name": "GraphViewMode",
|
"name": "GraphViewMode",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
"definition": "\"structure\" | \"student-mastery\" | \"class-mastery\"",
|
"definition": "\"structure\" | \"student-mastery\" | \"class-mastery\"",
|
||||||
"purpose": "Task 7 新增:图谱视图模式",
|
"purpose": "Task 7 新增:图谱数据视角(结构 / 学生掌握度 / 班级掌握度)",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"getKnowledgeGraphDataAction"
|
"getKnowledgeGraphDataAction"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "GraphLayoutMode",
|
||||||
|
"type": "type",
|
||||||
|
"definition": "\"hierarchical\" | \"force\"",
|
||||||
|
"purpose": "v4 新增:图谱渲染布局(hierarchical=分层有向图 ReactFlow+dagre;force=Obsidian 风格力导向图 react-force-graph-2d)",
|
||||||
|
"usedBy": [
|
||||||
|
"components/knowledge-graph.tsx",
|
||||||
|
"components/graph-toolbar.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "MasteryInfo",
|
"name": "MasteryInfo",
|
||||||
"type": "interface",
|
"type": "interface",
|
||||||
@@ -6648,7 +6658,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KnowledgeGraph",
|
"name": "KnowledgeGraph",
|
||||||
"purpose": "知识图谱可视化"
|
"purpose": "知识图谱可视化(v4 新增:支持 hierarchical/force 两种布局模式切换,hierarchical=ReactFlow+dagre 分层有向图,force=Obsidian 风格力导向图;外层共享状态/详情面板/前置依赖增删对话框)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ForceKnowledgeGraph",
|
||||||
|
"file": "components/force-graph.tsx",
|
||||||
|
"purpose": "v4 新增:Obsidian 风格力导向图谱组件,基于 react-force-graph-2d + d3-force;圆形节点+曲线连接+物理力导向+邻居高亮+拖拽联动;通过 next/dynamic ssr:false 加载避免 SSR canvas/window 报错"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GraphToolbar",
|
||||||
|
"file": "components/graph-toolbar.tsx",
|
||||||
|
"purpose": "图谱工具栏(v4 新增 hierarchical/force 布局模式切换 Select;保留视图模式切换/搜索/重置视图)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GraphNodeDetailPanel",
|
"name": "GraphNodeDetailPanel",
|
||||||
@@ -6693,7 +6713,10 @@
|
|||||||
],
|
],
|
||||||
"uiDeps": [],
|
"uiDeps": [],
|
||||||
"uiDepsNote": "已通过 render prop 解耦,TeacherTextbookReader 已移至 app 层(src/app/(dashboard)/teacher/textbooks/[id]/_components/),textbooks 模块不再直接 import questions 模块组件",
|
"uiDepsNote": "已通过 render prop 解耦,TeacherTextbookReader 已移至 app 层(src/app/(dashboard)/teacher/textbooks/[id]/_components/),textbooks 模块不再直接 import questions 模块组件",
|
||||||
|
"npmDeps": ["@xyflow/react", "@dagrejs/dagre", "react-force-graph-2d"],
|
||||||
|
"npmDepsNote": "v4 新增 react-force-graph-2d(基于 d3-force + Canvas)实现 Obsidian 风格力导向图谱;通过 next/dynamic ssr:false 加载避免 SSR canvas/window 报错;@xyflow/react + @dagrejs/dagre 用于分层有向图布局",
|
||||||
"knownIssues": [
|
"knownIssues": [
|
||||||
|
"v4 新增(2026-07-03):知识点图谱新增 Obsidian 风格力导向布局(force 模式),与原分层有向图(hierarchical 模式)共存,工具栏可切换;新增组件 force-graph.tsx,新增类型 GraphLayoutMode;保留全部现有功能(掌握度视图/搜索高亮/邻居高亮/前置依赖增删)",
|
||||||
"i18n 覆盖率约 99%(v3 新增 notFound/noClassMasteryPermission/close/prerequisiteAddFailed/prerequisiteRemoveFailed 等键,data-access 层硬编码英文异常文案已改为 NotFoundError 错误码化)",
|
"i18n 覆盖率约 99%(v3 新增 notFound/noClassMasteryPermission/close/prerequisiteAddFailed/prerequisiteRemoveFailed 等键,data-access 层硬编码英文异常文案已改为 NotFoundError 错误码化)",
|
||||||
"v3 审计修复(2026-06-24):学生端两个页面添加 requirePermission(Permissions.TEXTBOOK_READ) 显式权限校验",
|
"v3 审计修复(2026-06-24):学生端两个页面添加 requirePermission(Permissions.TEXTBOOK_READ) 显式权限校验",
|
||||||
"v3 审计修复(2026-06-24):getKnowledgeGraphDataAction 添加学生/家长年级 scope 过滤,class-mastery 视图模式限制为 teacher/admin",
|
"v3 审计修复(2026-06-24):getKnowledgeGraphDataAction 添加学生/家长年级 scope 过滤,class-mastery 视图模式限制为 teacher/admin",
|
||||||
@@ -6749,8 +6772,9 @@
|
|||||||
"components/graph-kp-node.tsx": 92,
|
"components/graph-kp-node.tsx": 92,
|
||||||
"components/graph-node-detail-panel.tsx": 171,
|
"components/graph-node-detail-panel.tsx": 171,
|
||||||
"components/graph-prerequisite-edge.tsx": 43,
|
"components/graph-prerequisite-edge.tsx": 43,
|
||||||
"components/graph-toolbar.tsx": 84,
|
"components/graph-toolbar.tsx": 121,
|
||||||
"components/knowledge-graph.tsx": 356,
|
"components/force-graph.tsx": 454,
|
||||||
|
"components/knowledge-graph.tsx": 408,
|
||||||
"components/knowledge-point-dialogs.tsx": 167,
|
"components/knowledge-point-dialogs.tsx": 167,
|
||||||
"components/knowledge-point-list.tsx": 117,
|
"components/knowledge-point-list.tsx": 117,
|
||||||
"components/section-error-boundary.tsx": 53,
|
"components/section-error-boundary.tsx": 53,
|
||||||
@@ -20513,6 +20537,76 @@
|
|||||||
"name": "suggestKnowledgePoints",
|
"name": "suggestKnowledgePoints",
|
||||||
"file": "ai-suggest.ts",
|
"file": "ai-suggest.ts",
|
||||||
"purpose": "AI 建议知识点(基于课案内容)"
|
"purpose": "AI 建议知识点(基于课案内容)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "autoLayout",
|
||||||
|
"file": "lib/auto-layout.ts",
|
||||||
|
"purpose": "V5-8 U2 新增:基于 @dagrejs/dagre 的 DAG 自动布局算法,按 edges 拓扑排序计算 nodes 位置"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "diffDocuments",
|
||||||
|
"file": "lib/version-diff.ts",
|
||||||
|
"purpose": "V5-16 T2 新增:纯函数对比两个 LessonPlanDocument 节点级差异,返回 added/removed/modified/unchanged + changedFields"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hasChanges",
|
||||||
|
"file": "lib/version-diff.ts",
|
||||||
|
"purpose": "V5-16 T2 新增:判断 VersionDiffResult 是否存在变更"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "checkConsistency",
|
||||||
|
"file": "lib/consistency-check.ts",
|
||||||
|
"purpose": "V5-19 T3 新增:纯函数目标-评价一致性校验,输出 ConsistencyResult(缺少 objective/exercise 警告、exercise 无知识点警告、objective 未被评价覆盖警告)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hasConsistencyWarnings",
|
||||||
|
"file": "lib/consistency-check.ts",
|
||||||
|
"purpose": "V5-19 T3 新增:判断 ConsistencyResult 是否存在警告"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeCurriculumCoverage",
|
||||||
|
"file": "lib/curriculum-coverage.ts",
|
||||||
|
"purpose": "V5-20 T4 新增:纯函数按 chapterId 分组统计教师所有课案对教材知识点的覆盖率,识别 isBlindSpot 教学盲点"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getHeatLevel",
|
||||||
|
"file": "lib/curriculum-coverage.ts",
|
||||||
|
"purpose": "V5-20 T4 新增:根据覆盖率返回热力图颜色等级(0-4)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateLessonPlanFeedback",
|
||||||
|
"file": "lib/ai-feedback.ts",
|
||||||
|
"purpose": "V5-17 A1/A2 新增:AI 反馈闭环,纯服务端函数,4 维度 strengths/improvements/alignment/differentiation,含解释性 reason 字段;AI 不可用时返回空结果"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateDifferentiationSuggestions",
|
||||||
|
"file": "lib/ai-differentiation.ts",
|
||||||
|
"purpose": "V5-21 A3 新增:AI 差异化教学建议生成,按 basic/intermediate/advanced 三层生成建议"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "checkCurriculumAlignment",
|
||||||
|
"file": "lib/ai-differentiation.ts",
|
||||||
|
"purpose": "V5-21 A4 新增:AI 课标实时核对,检查课案是否覆盖教材知识点"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateExplainableAssessment",
|
||||||
|
"file": "lib/ai-differentiation.ts",
|
||||||
|
"purpose": "V5-21 A5 新增:AI 可解释评估,对 exercise 节点给出 conclusion/rationale/suggestion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getLessonPlanSchedules",
|
||||||
|
"file": "data-access-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:查询课案绑定的课时列表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createLessonPlanSchedule",
|
||||||
|
"file": "data-access-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:课案绑定课时"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteLessonPlanSchedule",
|
||||||
|
"file": "data-access-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:删除课时绑定"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
@@ -20629,6 +20723,60 @@
|
|||||||
"permission": "LESSON_PLAN_READ",
|
"permission": "LESSON_PLAN_READ",
|
||||||
"file": "actions.ts",
|
"file": "actions.ts",
|
||||||
"purpose": "按 textbookId 获取章节树(供 template-picker 选择章节)"
|
"purpose": "按 textbookId 获取章节树(供 template-picker 选择章节)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duplicateLessonPlanFormAction",
|
||||||
|
"permission": "LESSON_PLAN_CREATE",
|
||||||
|
"file": "actions.ts",
|
||||||
|
"purpose": "V5-12 R4 新增:FormData wrapper 适配 <form action={...}>,内部调用 duplicateLessonPlanAction 后 redirect 到新课案编辑页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getLessonPlanSchedulesAction",
|
||||||
|
"permission": "LESSON_PLAN_READ",
|
||||||
|
"file": "actions-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:查询课案绑定的课时列表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createLessonPlanScheduleAction",
|
||||||
|
"permission": "LESSON_PLAN_UPDATE",
|
||||||
|
"file": "actions-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:课案绑定课时"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteLessonPlanScheduleAction",
|
||||||
|
"permission": "LESSON_PLAN_UPDATE",
|
||||||
|
"file": "actions-schedules.ts",
|
||||||
|
"purpose": "V5-7 W3/W4 新增:删除课时绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateLessonPlanFeedbackAction",
|
||||||
|
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||||
|
"file": "actions-ai.ts",
|
||||||
|
"purpose": "V5-17 A1/A2 新增:AI 反馈闭环,调用 generateLessonPlanFeedback 生成结构化反馈"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateDifferentiationSuggestionsAction",
|
||||||
|
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||||
|
"file": "actions-ai.ts",
|
||||||
|
"purpose": "V5-21 A3 新增:AI 差异化教学建议生成"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "checkCurriculumAlignmentAction",
|
||||||
|
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||||
|
"file": "actions-ai.ts",
|
||||||
|
"purpose": "V5-21 A4 新增:AI 课标实时核对"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generateExplainableAssessmentAction",
|
||||||
|
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||||
|
"file": "actions-ai.ts",
|
||||||
|
"purpose": "V5-21 A5 新增:AI 可解释评估"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getKnowledgePointsForAlignmentAction",
|
||||||
|
"permission": "LESSON_PLAN_READ",
|
||||||
|
"file": "actions-ai.ts",
|
||||||
|
"purpose": "V5-21 A4 辅助新增:按 textbookId 获取知识点列表(仅 id+name),供课标核对 Tab 按需加载"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemas": [
|
"schemas": [
|
||||||
@@ -20867,7 +21015,12 @@
|
|||||||
"V4-11": "P2-4 废弃文件删除:components/block-renderer.tsx(@deprecated,无源码引用)删除",
|
"V4-11": "P2-4 废弃文件删除:components/block-renderer.tsx(@deprecated,无源码引用)删除",
|
||||||
"V4-12": "P2-5 anchor-injector regex 一致性:markdownToPlainText 的 /^>\\s+/gm 改为 /^\\s*>\\s+/gm;skipPatterns 5 个 regex 统一添加 g 标志,与 markdownToPlainText 保持一致",
|
"V4-12": "P2-5 anchor-injector regex 一致性:markdownToPlainText 的 /^>\\s+/gm 改为 /^\\s*>\\s+/gm;skipPatterns 5 个 regex 统一添加 g 标志,与 markdownToPlainText 保持一致",
|
||||||
"V4-13": "P2-6 Zustand store 拆 slice:hooks/use-lesson-plan-editor.ts 从 303 行单体 store 拆分为 3 个独立 slice——hooks/editor-slice.ts(文档结构+文档操作)/hooks/selection-slice.ts(选中状态)/hooks/version-slice.ts(版本与保存状态);主文件重写为 25 行薄层,组合 3 个 slice 并导出 EditorState 类型",
|
"V4-13": "P2-6 Zustand store 拆 slice:hooks/use-lesson-plan-editor.ts 从 303 行单体 store 拆分为 3 个独立 slice——hooks/editor-slice.ts(文档结构+文档操作)/hooks/selection-slice.ts(选中状态)/hooks/version-slice.ts(版本与保存状态);主文件重写为 25 行薄层,组合 3 个 slice 并导出 EditorState 类型",
|
||||||
"V4-14": "type-guards 导出补全:lib/type-guards.ts 的 VALID_QUESTION_TYPES 常量从模块私有改为 export(供 inline-question-editor.tsx 复用,消除重复定义)"
|
"V4-14": "type-guards 导出补全:lib/type-guards.ts 的 VALID_QUESTION_TYPES 常量从模块私有改为 export(供 inline-question-editor.tsx 复用,消除重复定义)",
|
||||||
|
"V5-1": "自动保存失败 UI 兜底:version-slice.ts 新增 saveError/isOnline 状态 + setSaveError/setOnline 方法;lesson-plan-editor.tsx 自动保存失败时 toast 提示 + 设置 saveError,断网时不触发保存请求,监听 window online/offline 事件 toast 提示,saveError 时显示重试按钮,恢复时提示已恢复",
|
||||||
|
"V5-2": "撤销/重做:新增 hooks/history-slice.ts(past/future 栈,50 步上限,canUndo/canRedo/pushHistory/undo/redo/clearHistory 方法);editor-slice.ts 所有 mutation 方法(setTitle/addNode/updateNode/removeNode/updateTextbookContent/addAnchor/removeAnchor/updateAnchor/connect/disconnect/setEdges)包装 pushHistory,updateNodePosition 除外(拖拽过程中由调用方在 onDragStart 时推入);version-slice hydrate/replaceDoc 调用 clearHistory;lesson-plan-editor.tsx 顶部新增 Undo/Redo 按钮 + Ctrl/Cmd+Z 撤销 + Ctrl/Cmd+Shift+Z 或 Ctrl/Cmd+Y 重做快捷键",
|
||||||
|
"V5-3": "发布前作业单预览:publish-homework-dialog.tsx 改为 3 步流程(step=select 选班级+时间 → step=preview 预览题目+总分+班级数 → step=confirm 确认发布),第二步显示题目列表(题型/来源/题干预览/分值)+汇总信息(班级数/题目数/总分),第三步显示最终确认信息+警告文案;exercise-block.tsx 调用 PublishHomeworkDialog 时传入 items={data.items}",
|
||||||
|
"V5-4": "导出/打印:新增 lib/export.ts(flattenLessonPlanForPrint 函数,将画布式 LessonPlanDocument 扁平化为 PrintableLessonPlan 线性结构,支持 detailed/concise 两种 variant,concise 仅含 objective/new_teaching/exercise/homework)+ components/print-view.tsx(打印视图组件,详细版/简洁版切换 + 调用 window.print() 通过浏览器原生能力保存为 PDF + print: CSS 媒体查询隐藏工具栏 + 页眉含教材/教师/班级/教学时长/最后保存时间 + break-inside-avoid 防止段落跨页);lesson-plan-editor.tsx 新增 printablePlan useMemo 构造 + 导出按钮 + PrintView 渲染",
|
||||||
|
"V5-5": "多媒体嵌入 + 我的素材库:types.ts RichTextBlockData 新增 attachments?: RichTextAttachment[] 字段 + RichTextAttachment 类型(attachmentId/fileId/displayName/kind: image|audio|video|file/url/mimeType?);rich-text-block.tsx 集成 @tiptap/extension-image(Image.configure inline:false allowBase64:false)+ 新增附件入口按钮 + 已嵌入附件列表渲染(非图片附件列表 + 图片附件缩略图,含删除按钮)+ 图片附件通过 editor.commands.setImage 直接插入富文本;新增 components/attachment-picker.tsx(素材库 picker,复用 use-file-upload hook 调用 /api/upload + 调用 service.createLessonPlanAttachment 落库到 M6 lessonPlanAttachments 表 + 列出 service.getLessonPlanAttachments + 删除 service.deleteLessonPlanAttachment + inferAttachmentKind 工具函数);LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法 + LessonPlanAttachmentOption 导出类型;default-data-service.ts 实现三个方法(DB 层 Date 转 ISO string 以匹配接口类型);BlockRenderProps 新增 planId? 字段;NodeEditPanel 新增 planId prop 透传;lesson-plan-editor.tsx NodeEditPanel 调用传入 planId;新增依赖 @tiptap/extension-image;files/types.ts FileTargetType 新增 'lesson_plan' 类型"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error-book": {
|
"error-book": {
|
||||||
@@ -22029,6 +22182,13 @@
|
|||||||
"columns": 6,
|
"columns": 6,
|
||||||
"indexes": 2,
|
"indexes": 2,
|
||||||
"foreignKeys": 2
|
"foreignKeys": 2
|
||||||
|
},
|
||||||
|
"lessonPlanSchedules": {
|
||||||
|
"owner": "lesson-preparation",
|
||||||
|
"description": "V5-7 W3/W4 课案绑定课时表(planId+classId+lessonDate 唯一约束,2026-07-03 新增)",
|
||||||
|
"columns": 6,
|
||||||
|
"indexes": 3,
|
||||||
|
"foreignKeys": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -25438,11 +25598,45 @@
|
|||||||
"duplicateLessonPlanAction",
|
"duplicateLessonPlanAction",
|
||||||
"saveAsTemplateAction",
|
"saveAsTemplateAction",
|
||||||
"suggestKnowledgePointsAction",
|
"suggestKnowledgePointsAction",
|
||||||
"publishLessonPlanHomeworkAction"
|
"publishLessonPlanHomeworkAction",
|
||||||
|
"getLessonPlanSchedulesAction",
|
||||||
|
"createLessonPlanScheduleAction",
|
||||||
|
"deleteLessonPlanScheduleAction",
|
||||||
|
"generateLessonPlanFeedbackAction",
|
||||||
|
"generateDifferentiationSuggestionsAction",
|
||||||
|
"checkCurriculumAlignmentAction",
|
||||||
|
"generateExplainableAssessmentAction",
|
||||||
|
"getKnowledgePointsForAlignmentAction"
|
||||||
],
|
],
|
||||||
"permission": "lesson_plan:update",
|
"permission": "lesson_plan:update",
|
||||||
"permissionRead": "lesson_plan:read",
|
"permissionRead": "lesson_plan:read",
|
||||||
"description": "编辑课案页面(权限:requirePermission(LESSON_PLAN_UPDATE);只读访问需 LESSON_PLAN_READ)"
|
"description": "编辑课案页面(权限:requirePermission(LESSON_PLAN_UPDATE);只读访问需 LESSON_PLAN_READ;V5-7 新增课时绑定 actions;V5-17/V5-21 新增 AI 反馈/差异化 actions)"
|
||||||
|
},
|
||||||
|
"/teacher/lesson-plans/library": {
|
||||||
|
"component": "LibraryPage",
|
||||||
|
"type": "server",
|
||||||
|
"module": "lesson-preparation",
|
||||||
|
"method": "GET",
|
||||||
|
"dataAccess": [
|
||||||
|
"lesson-preparation/data-access.getLessonPlans"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
"duplicateLessonPlanFormAction"
|
||||||
|
],
|
||||||
|
"permission": "lesson_plan:read",
|
||||||
|
"description": "V5-12 R4 新增:校内课案库页面,展示其他教师发布的课案,支持一键复制(权限:requirePermission(LESSON_PLAN_READ))"
|
||||||
|
},
|
||||||
|
"/teacher/lesson-plans/heatmap": {
|
||||||
|
"component": "HeatmapPage",
|
||||||
|
"type": "server",
|
||||||
|
"module": "lesson-preparation",
|
||||||
|
"method": "GET",
|
||||||
|
"dataAccess": [
|
||||||
|
"lesson-preparation/data-access.getLessonPlans",
|
||||||
|
"textbooks/data-access.getKnowledgePointsByTextbookId"
|
||||||
|
],
|
||||||
|
"permission": "lesson_plan:read",
|
||||||
|
"description": "V5-20 T4 新增:课标覆盖热力图页面,统计教师所有课案对教材知识点的覆盖情况(权限:requirePermission(LESSON_PLAN_READ))"
|
||||||
},
|
},
|
||||||
"/teacher/error-book": {
|
"/teacher/error-book": {
|
||||||
"component": "TeacherErrorBookPage + SubjectTabs + ClassFilter + AnalyticsStatsCards + ClassErrorBarChart + KnowledgePointWeaknessChart + ChapterWeaknessChart + GroupedStudentErrorTable + TopWrongQuestions",
|
"component": "TeacherErrorBookPage + SubjectTabs + ClassFilter + AnalyticsStatsCards + ClassErrorBarChart + KnowledgePointWeaknessChart + ChapterWeaknessChart + GroupedStudentErrorTable + TopWrongQuestions",
|
||||||
|
|||||||
289
docs/architecture/audit/lesson-preparation-audit-report-v5.md
Normal file
289
docs/architecture/audit/lesson-preparation-audit-report-v5.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# 备课模块审计报告 v5
|
||||||
|
|
||||||
|
> 审计日期:2026-07-03
|
||||||
|
> 审计范围:`src/modules/lesson-preparation/` 场景覆盖度维度(区别于 v4 的架构合规维度)
|
||||||
|
> 审计依据:用户实际备课工作流反馈 + v4 审计报告 + K12 行业产品对标(Planboard / Nearpod / Common Curriculum)
|
||||||
|
> 前序报告:`lesson-preparation-audit-report-v4.md`(2026-06-25,架构合规审计)
|
||||||
|
> 审计方法:场景驱动缺口分析 + 实际代码核对 + 高频备课工作流走查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、审计背景
|
||||||
|
|
||||||
|
v4 审计聚焦**架构合规性**(权限/类型/解耦/a11y),30 项 P0/P1/P2 已全部修复。M1-M12 中长期计划已**部分实施**(M1 课标 / M2 评论 / M3 审核 / M4 课程地图 / M5 形成性 / M6 附件表 / M8 AI 评估 / M9 日历 / M10 分析 / M11 diff / M12 代课)。
|
||||||
|
|
||||||
|
但用户反馈显示:**M1-M12 虽建了数据层,UI 集成度不够**(M6 附件表无 UI、M9 日历不绑课表、M2 评论无协同编辑),且**完全遗漏了 4 项核心能力**(导出/打印、撤销/重做、多媒体嵌入、发布前预览)。
|
||||||
|
|
||||||
|
v5 审计聚焦"场景覆盖度"维度,对照教师日常备课工作流识别缺口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、教师备课工作流与模块覆盖度
|
||||||
|
|
||||||
|
### 2.1 标准备课工作流
|
||||||
|
|
||||||
|
| 阶段 | 教师动作 | 模块覆盖 | 缺口 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 课前 | 选教材/课文 → 选模板 → 创建课案 | ✅ 完整 | - |
|
||||||
|
| 课前 | 设计教学目标 / 重难点 / 导入 / 新授 / 总结 | ✅ 11 种 Block 完整 | - |
|
||||||
|
| 课前 | 关联知识点 / 课标 | ✅ 知识点 / M1 课标 | - |
|
||||||
|
| 课前 | 插入朗读音频 / 实验视频 / 图片素材 | ❌ rich_text 仅富文本 | F2 多媒体嵌入缺失 |
|
||||||
|
| 课前 | 重用历史素材 | ❌ 无素材库 | F3 我的素材库缺失 |
|
||||||
|
| 课前 | 设计板书(图文混排、分区) | ⚠️ blackboard 仅文本 | F4 板书可视化弱 |
|
||||||
|
| 课前 | 撤销/重做画布操作 | ❌ 无 history 栈 | U1 撤销/重做缺失 |
|
||||||
|
| 课前 | 自动保存失败提示 | ❌ 仅 console.error | S1 UI 兜底缺失 |
|
||||||
|
| 课前 | 题库选题 → 一键发布作业 | ⚠️ 无预览步骤 | W2 发布前预览缺失 |
|
||||||
|
| 课前 | 导出 PDF / 打印教案 | ❌ 完全缺失 | F1 导出/打印缺失 |
|
||||||
|
| 课中 | 学生查看发布课案 | ✅ 只读视图 | - |
|
||||||
|
| 课后 | 教学反思 + 二次备课 | ⚠️ reflection 节点存在,无锁定/diff | T2 反思闭环不完整 |
|
||||||
|
|
||||||
|
### 2.2 工作流卡点分析
|
||||||
|
|
||||||
|
教师日常备课高频路径中存在 **5 个"卡点"**——遇到即停止使用,回退到 Word/PPT:
|
||||||
|
|
||||||
|
| 卡点 | 频次 | 影响 |
|
||||||
|
|------|------|------|
|
||||||
|
| 画布误删节点无法撤销 | 极高 | 教师不敢操作画布 |
|
||||||
|
| 自动保存失败无提示,关闭页面数据丢失 | 高 | 信任崩塌 |
|
||||||
|
| 备课成果无法导出/打印交付教研组 | 高 | 系统外循环 |
|
||||||
|
| 无法插入多媒体素材 | 高 | 课"备不活" |
|
||||||
|
| 一键发布作业无预览 | 中 | 误发布风险 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、场景缺口详细分析
|
||||||
|
|
||||||
|
### 3.1 S1 - 自动保存失败 UI 兜底(🔴 极紧急)
|
||||||
|
|
||||||
|
- **位置**:[lesson-plan-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx) L97-116
|
||||||
|
- **现状**:自动保存失败仅 `console.error("[LessonPlanEditor] auto-save failed", e)`,UI 无任何提示
|
||||||
|
- **后果**:
|
||||||
|
- 教师关闭页面时数据丢失(beforeunload 仅检查 isDirty,不区分"保存失败"与"未触发保存")
|
||||||
|
- 断网场景下教师继续编辑,所有改动静默丢失
|
||||||
|
- **违反需求**:用户 §五"怕丢数据:必须要有强自动保存,以及断网恢复提示"
|
||||||
|
|
||||||
|
### 3.2 U1 - 撤销/重做(🔴 极紧急)
|
||||||
|
|
||||||
|
- **位置**:[editor-slice.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/editor-slice.ts) 全文
|
||||||
|
- **现状**:Zustand store 无 history 栈,所有 mutation 直接 set
|
||||||
|
- **后果**:
|
||||||
|
- 画布上误删一个节点,只能去版本抽屉回滚到 30 分钟前的状态
|
||||||
|
- 教师不敢操作画布,回退到列表式备课
|
||||||
|
- **违反需求**:用户 §三.5"撤销/重做:画布操作没有撤销,简直没法用。必须支持至少 50 步撤销"
|
||||||
|
|
||||||
|
### 3.3 W2 - 发布前作业单预览(🔴 极紧急)
|
||||||
|
|
||||||
|
- **位置**:[publish-homework-dialog.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/publish-homework-dialog.tsx) 全文
|
||||||
|
- **现状**:对话框仅选班级 + 时间,点击"发布"直接调用 service.publishLessonPlanHomework
|
||||||
|
- **缺失**:
|
||||||
|
- 无题目列表预览(题干/选项/答案/分值)
|
||||||
|
- 无总分预览
|
||||||
|
- 无班级学生数提示
|
||||||
|
- 无"返回修改"步骤
|
||||||
|
- **后果**:一键发布到学生/家长端,误发布无法收回
|
||||||
|
- **违反需求**:用户 §五"怕误发布:发布前必须有预览"
|
||||||
|
|
||||||
|
### 3.4 F1 - 导出/打印(🔴 极紧急)
|
||||||
|
|
||||||
|
- **位置**:全模块无导出/打印相关代码
|
||||||
|
- **现状**:画布做得再好,备课成果无法走出系统
|
||||||
|
- **缺失**:
|
||||||
|
- 无 PDF 导出
|
||||||
|
- 无打印视图
|
||||||
|
- 无"详细版/简洁版"切换
|
||||||
|
- **后果**:教研组检查无纸质交付,教师回退到 Word
|
||||||
|
- **违反需求**:用户 §二.1"没有导出,备课成果无法走出系统"
|
||||||
|
|
||||||
|
### 3.5 F2/F3 - 多媒体嵌入 + 我的素材库(🔴 极紧急)
|
||||||
|
|
||||||
|
- **位置**:
|
||||||
|
- [rich-text-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx) L29-45 仅用 Tiptap StarterKit + Placeholder
|
||||||
|
- [data-access-attachments.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-attachments.ts) M6 数据层已建,**无 UI 集成**
|
||||||
|
- [actions-attachments.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-attachments.ts) Server Actions 已建,**无组件调用**
|
||||||
|
- **现状**:rich_text 节点不支持图片/音视频;M6 附件表与 UI 完全断链
|
||||||
|
- **后果**:
|
||||||
|
- 教师无法插入朗读音频 / 实验视频 / PPT 截图
|
||||||
|
- 每次都得从本地重新上传,无沉淀
|
||||||
|
- **违反需求**:用户 §二.2"这决定了我能不能把课'备活'"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进方案与实施
|
||||||
|
|
||||||
|
### 4.1 第一阶段实施(v5 P0,本次完成)
|
||||||
|
|
||||||
|
| ID | 缺口 | 实施方案 | 验收标准 |
|
||||||
|
|----|------|---------|---------|
|
||||||
|
| V5-1 | S1 自动保存失败 UI 兜底 | editor 添加 saveError 状态 + toast 提示 + 在线状态监听 + 重试按钮 | 断网时 toast 显示"保存失败",恢复时 toast 显示"已恢复在线" |
|
||||||
|
| V5-2 | U1 撤销/重做 | 新增 `hooks/history-slice.ts`(past/future 栈,50 步上限)+ 编辑器顶部 Undo/Redo 按钮 + Cmd/Ctrl+Z / Cmd+Shift+Z 快捷键 | 误删节点后 Cmd+Z 可恢复 |
|
||||||
|
| V5-3 | W2 发布前作业单预览 | publish-homework-dialog 改为 3 步流程(选班级 → 预览题目+总分+学生数 → 确认发布) | 发布前可见所有题干、选项、答案、分值、班级学生数 |
|
||||||
|
| V5-4 | F1 导出/打印 | 新增 `lib/export.ts`(文档扁平化)+ `components/print-view.tsx`(打印视图)+ 编辑器"导出/打印"按钮 + 详细版/简洁版切换 | 浏览器打印对话框可保存为 PDF,含教材/教师/班级信息 |
|
||||||
|
| V5-5 | F2/F3 多媒体嵌入 + 素材库 | rich-text-block 集成 Tiptap Image 扩展 + 新增 `components/attachment-picker.tsx` 附件库 picker + 集成 M6 附件表 | 可在 rich_text 中插入图片,从素材库选择已有附件 |
|
||||||
|
|
||||||
|
### 4.2 第二阶段计划(v5 P1,本次仅记录)
|
||||||
|
|
||||||
|
| ID | 缺口 | 优先级 |
|
||||||
|
|----|------|--------|
|
||||||
|
| V5-6 | W1 题库组卷预览(题干展开) | 🟠 |
|
||||||
|
| V5-7 | W3/W4 课案绑定课时 | 🟠 |
|
||||||
|
| V5-8 | U2 画布自动布局 | 🟠 |
|
||||||
|
| V5-9 | F5 教材模糊搜索 + 最近使用 | 🟠 |
|
||||||
|
| V5-10 | P1 大课案画布性能压测 | 🟠 |
|
||||||
|
|
||||||
|
### 4.3 第三阶段计划(v5 P2,本次仅记录)
|
||||||
|
|
||||||
|
| ID | 缺口 | 优先级 |
|
||||||
|
|----|------|--------|
|
||||||
|
| V5-11 | R3 协同编辑(Yjs/Liveblocks 评估) | 🟡 |
|
||||||
|
| V5-12 | R4 校内课案库 | 🟡 |
|
||||||
|
| V5-13 | P3 移动端只读视图 | 🟡 |
|
||||||
|
| V5-14 | F4 板书可视化工具 | 🟡 |
|
||||||
|
| V5-15 | T1 教学阶段分组 | 🟡 |
|
||||||
|
| V5-16 | T2 反思闭环 | 🟡 |
|
||||||
|
| V5-17 | A1/A2 AI 反馈闭环 + 解释性 | 🟡 |
|
||||||
|
|
||||||
|
### 4.4 第四阶段计划(v5 P3,本次仅记录)
|
||||||
|
|
||||||
|
| ID | 缺口 | 优先级 |
|
||||||
|
|----|------|--------|
|
||||||
|
| V5-18 | W6 差异化教学标记 | 🟢 |
|
||||||
|
| V5-19 | T3 目标-评价一致性 | 🟢 |
|
||||||
|
| V5-20 | T4 教师端课标热力图 | 🟢 |
|
||||||
|
| V5-21 | A3/A4/A5 AI 差异化生成 + 课标实时核对 + 评估可解释 | 🟢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构影响
|
||||||
|
|
||||||
|
### 5.1 新增文件
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `hooks/history-slice.ts` | Zustand history slice(past/future 栈,50 步上限) |
|
||||||
|
| `lib/export.ts` | 文档扁平化导出工具(详细版/简洁版) |
|
||||||
|
| `components/print-view.tsx` | 打印视图组件(含教材/教师/班级信息) |
|
||||||
|
| `components/attachment-picker.tsx` | 附件库 picker(集成 M6 附件表) |
|
||||||
|
|
||||||
|
### 5.2 修改文件
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|---------|
|
||||||
|
| `hooks/use-lesson-plan-editor.ts` | 组合 history slice |
|
||||||
|
| `hooks/editor-slice.ts` | mutation 方法包装 history 推送 |
|
||||||
|
| `components/lesson-plan-editor.tsx` | 自动保存失败 UI 兜底 + Undo/Redo 按钮 + 快捷键 + 导出按钮 |
|
||||||
|
| `components/publish-homework-dialog.tsx` | 3 步发布流程(选班级 → 预览 → 确认) |
|
||||||
|
| `components/blocks/rich-text-block.tsx` | 集成 Tiptap Image 扩展 + 附件库入口 |
|
||||||
|
| `shared/i18n/messages/zh-CN/lesson-preparation.json` | 新增 undo/redo/export/print/saveError 等键 |
|
||||||
|
| `shared/i18n/messages/en/lesson-preparation.json` | 同步英文翻译 |
|
||||||
|
|
||||||
|
### 5.3 架构图同步
|
||||||
|
|
||||||
|
修改 `docs/architecture/004_architecture_impact_map.md` §2.27 章节末尾追加"V5 场景缺口修复"小节。
|
||||||
|
修改 `docs/architecture/005_architecture_data.json` modules.lesson_preparation.auditFixes 新增 V5-1 至 V5-5 条目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、验收标准
|
||||||
|
|
||||||
|
- ✅ `npm run lint` 零错误
|
||||||
|
- ✅ `npx tsc --noEmit` 零错误
|
||||||
|
- ✅ 单文件行数:组件 ≤ 500,hooks ≤ 80,工具函数 ≤ 40
|
||||||
|
- ✅ 无 `any`、无 `as` 断言(除 unknown 收窄)
|
||||||
|
- ✅ 全量 i18n(zh-CN + en)
|
||||||
|
- ✅ 架构文档 004/005 已同步
|
||||||
|
- ✅ `docs/troubleshooting/known-issues.md` 已记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**v5 审计完成。本次实施第一阶段 V5-1 至 V5-5 共 5 项场景缺口修复。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、V5 第二阶段实施结果(2026-07-03 续)
|
||||||
|
|
||||||
|
### 7.1 已完成清单
|
||||||
|
|
||||||
|
| ID | 缺口 | 状态 | 实施摘要 |
|
||||||
|
|----|------|------|---------|
|
||||||
|
| V5-6 | W1 题库组卷预览 | ✅ | exercise-block.tsx 增加组卷预览模式 |
|
||||||
|
| V5-7 | W3/W4 课案绑定课时 | ✅ | 新增 data-access-schedules.ts + actions-schedules.ts + schedule-dialog.tsx + lesson_plan_schedules 表 |
|
||||||
|
| V5-8 | U2 画布自动布局 | ✅ | 新增 lib/auto-layout.ts(@dagrejs/dagre,复用已安装依赖) |
|
||||||
|
| V5-9 | F5 教材模糊搜索 + 最近使用 | ✅ | template-picker.tsx 增加客户端模糊搜索 + localStorage 最近使用 |
|
||||||
|
| V5-10 | P1 大画布性能优化 | ✅ | node-editor.tsx ReactFlow onlyRenderVisibleElements + zoom 限制 |
|
||||||
|
| V5-12 | R4 校内课案库 | ✅ | 新增 teacher/lesson-plans/library 路由 + duplicateLessonPlanFormAction |
|
||||||
|
| V5-13 | P3 移动端只读视图 | ✅ | 新增 lesson-plan-mobile-view.tsx + useMediaQuery 切换 |
|
||||||
|
| V5-14 | F4 板书可视化工具 | ✅ | blackboard-block.tsx 增加轻量级可视化预览(不引入新库,纯 CSS + 文本解析) |
|
||||||
|
| V5-15 | T1 教学阶段分组 | ✅ | types.ts TeachingStage 类型 + node-edit-panel 选择器 |
|
||||||
|
| V5-16 | T2 反思闭环 | ✅ | 新增 lib/version-diff.ts 纯函数 + version-diff-view 组件 + 抽屉集成 |
|
||||||
|
| V5-17 | A1/A2 AI 反馈闭环 | ✅ | 新增 lib/ai-feedback.ts 纯服务端函数 + ai-feedback-dialog + 工具栏集成 |
|
||||||
|
| V5-18 | W6 差异化教学标记 | ✅ | types.ts DifferentiationLevel 类型 + node-edit-panel 选择器 |
|
||||||
|
| V5-19 | T3 一致性校验 | ✅ | 新增 lib/consistency-check.ts 纯函数 + consistency-check-dialog + 工具栏集成 |
|
||||||
|
| V5-20 | T4 课标热力图 | ✅ | 新增 lib/curriculum-coverage.ts 纯函数 + curriculum-heatmap + teacher/lesson-plans/heatmap 路由 |
|
||||||
|
| V5-21 | A3/A4/A5 AI 差异化 | ✅ | 新增 lib/ai-differentiation.ts 3 个纯服务端函数 + ai-differentiation-dialog(3 Tab) + 4 个 server actions + 工具栏集成 |
|
||||||
|
|
||||||
|
### 7.2 待规划
|
||||||
|
|
||||||
|
| ID | 缺口 | 状态 | 备注 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| V5-11 | R3 协同编辑 | 🟡 长远计划 | 详见 §7.5 Yjs 方案评估(2026-07-03 评估,因架构改造复杂度高暂不实施) |
|
||||||
|
|
||||||
|
### 7.3 i18n 修复
|
||||||
|
|
||||||
|
- 修复 zh-CN/en lesson-preparation.json 重复键问题(JSON 后者覆盖前者)
|
||||||
|
- 新增命名空间:`editor.stage*`/`editor.differentiation*`/`editor.consistency*`/`consistency.*`/`diff.*`/`library.*`/`feedback.*`/`heatmap.*`/`aiDifferentiation.*`/`blackboard.editMode/previewMode/editHint/previewEmpty/untitled`
|
||||||
|
|
||||||
|
### 7.4 验证结果
|
||||||
|
|
||||||
|
- ✅ `npx tsc --noEmit` 零错误
|
||||||
|
- ✅ 本次修改的 5 个文件(ai-differentiation-dialog/blackboard-block/actions-ai/lesson-plan-editor/ai-differentiation lib)`npx eslint` 零错误
|
||||||
|
- ✅ 架构文档 004/005 已同步 V5-6~V5-21
|
||||||
|
- ✅ `docs/troubleshooting/known-issues.md` 新增"V5 第二阶段 V5-6~V5-21 通用规则"小节
|
||||||
|
|
||||||
|
**V5 第二阶段完成 15 项(V5-6~V5-21 除 V5-11),V5-11 作为长远计划记录于 §7.5。**
|
||||||
|
|
||||||
|
### 7.5 V5-11 R3 协同编辑长远规划(Yjs + y-websocket 方案)
|
||||||
|
|
||||||
|
**决策**:2026-07-03 评估,因架构改造复杂度高,暂不实施,作为长远计划。
|
||||||
|
|
||||||
|
#### 7.5.1 Yjs 工作原理(澄清)
|
||||||
|
|
||||||
|
Yjs 是 **CRDT(无冲突复制数据类型)** 库,**不是邮箱/消息系统**,而是**实时状态同步**机制。当 A 老师在编辑器输入"导入",B 老师的屏幕上**立刻**出现"导入"——这是实时同步,不是发消息。
|
||||||
|
|
||||||
|
- **Yjs**:CRDT 数据结构库,保证多端最终一致性
|
||||||
|
- **y-websocket**:传输层,需要独立的 WebSocket 服务器作为中转
|
||||||
|
- **y-prosemirror**:Yjs 与 ProseMirror(Tiptap 底层)的绑定,让富文本编辑器支持多人光标
|
||||||
|
|
||||||
|
#### 7.5.2 与当前项目的关键冲突
|
||||||
|
|
||||||
|
| 冲突点 | 当前项目 | Yjs 要求 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| 服务器类型 | Next.js HTTP | 需独立 WebSocket 服务器进程 |
|
||||||
|
| 持久化 | Drizzle ORM + MySQL | Yjs document 状态需独立持久化(LevelDB 或自定义 adapter) |
|
||||||
|
| 权限校验 | Server Actions + requirePermission | WS 连接时需校验 JWT/session |
|
||||||
|
| 编辑器状态 | Zustand store(editor-slice) | Yjs document 作为真相源,Zustand 降级为视图层 |
|
||||||
|
| 数据结构 | LessonPlanDocument JSON | Yjs.XmlFragment(Tiptap 文档)或 Yjs.Map |
|
||||||
|
|
||||||
|
#### 7.5.3 推荐架构(未来实施时)
|
||||||
|
|
||||||
|
- **WS 服务器部署**:项目内独立进程(`server/ws-server.ts`,`npm run ws` 启动,生产环境用 PM2/Docker 管理)
|
||||||
|
- **协同范围**:仅 RichTextBlock(Tiptap)节点实现多人实时协同,其他节点保持单人编辑
|
||||||
|
- **持久化策略**:双写——Yjs document 作为实时层,每隔 N 秒(或用户离开时)将状态序列化为 JSON 写入 `lessonPlans.content`,兼容现有数据结构
|
||||||
|
- **权限校验**:WS 连接握手时校验 JWT,按 planId 校验 `LESSON_PLAN_UPDATE` 权限
|
||||||
|
|
||||||
|
#### 7.5.4 需要安装的依赖
|
||||||
|
|
||||||
|
| 依赖 | 用途 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| `yjs` | CRDT 核心库 | 必装 |
|
||||||
|
| `y-websocket` | WebSocket 传输层 | 必装 |
|
||||||
|
| `y-prosemirror` | Yjs ↔ ProseMirror 绑定 | 必装(Tiptap 集成) |
|
||||||
|
| `@hocuspocus/server` | WS 服务器框架(可选) | 提供权限/持久化/扩展插件,开箱即用 |
|
||||||
|
| `y-leveldb` | Yjs 持久化(可选) | LevelDB 存储,或自定义 MySQL adapter |
|
||||||
|
|
||||||
|
#### 7.5.5 替代方案
|
||||||
|
|
||||||
|
若未来评估认为 Yjs 复杂度过高,可考虑**轻量协作方案**作为替代:
|
||||||
|
- 不引入实时协同库
|
||||||
|
- 复用现有 messaging/notifications 模块 + V5-16 版本对比
|
||||||
|
- 实现「Block 级评论 + @提醒 + 版本对比」
|
||||||
|
- 无需新依赖,开发快,与项目现有架构契合
|
||||||
652
docs/troubleshooting/known-issues.md
Normal file
652
docs/troubleshooting/known-issues.md
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
# 项目规则速查手册
|
||||||
|
|
||||||
|
> 一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、i18n 翻译文件规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| key 禁止包含 `.` | `"title": { "parent": "..." }` | `"title.parent": "..."` |
|
||||||
|
| 同字段多角色变体用嵌套对象 | `"title": { "default": "...", "parent": "...", "teacher": "..." }` | `"title": "..."`, `"title.parent": "..."` |
|
||||||
|
| 不同字段用驼峰命名 | `"createDesc": "..."` | `"create.desc": "..."` |
|
||||||
|
| 调用方 `t("title.parent")` 自动解析嵌套 | 无需改调用方代码 | — |
|
||||||
|
| 动态拼 key 需包含完整路径 | `t(\`type.${type}\`)`(type 在 type 对象下) | `t(type)`(漏掉 `type.` 前缀) |
|
||||||
|
| 嵌套 key 调用需带父级路径 | `t(\`difficulty.${level}\`)` | `t(level)` |
|
||||||
|
|
||||||
|
### i18n 动态 key 调用规则
|
||||||
|
|
||||||
|
当 `t()` 参数为动态变量时,**必须包含完整嵌套路径**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// JSON 结构: { "type": { "single_choice": "单选题", "multiple_choice": "多选题" } }
|
||||||
|
|
||||||
|
// 正确:带父级路径
|
||||||
|
const label = t(`type.${question.type}`) // t("type.single_choice")
|
||||||
|
|
||||||
|
// 错误:漏掉父级路径,触发 MISSING_MESSAGE
|
||||||
|
const label = t(question.type) // t("single_choice") → 找不到
|
||||||
|
```
|
||||||
|
| ICU 参数用单花括号 | `"level": "难度 {level}"` | `"level": "难度 {{level}}"` |
|
||||||
|
| ICU 多参数直接并列 | `"summary": "{count} 题 · {subject}"` | `"summary": "{{count}} 题 · {{subject}}"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、"use server" 文件规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 只能导出 `async function` | `export async function foo() {}` | `export const foo = ...` |
|
||||||
|
| 常量/对象放 `types.ts` 或 `constants.ts` | `types.ts: export const CONFIG = {...}` | `actions.ts: export const CONFIG = {...}` |
|
||||||
|
| 同步函数放 `lib/` 目录 | `lib/track-event.ts: export function track() {}` | `actions.ts: export function track() {}` |
|
||||||
|
| 禁止值 re-export | 调用方直接从源文件 import | `export { foo } from "./bar"` |
|
||||||
|
| 类型 re-export 允许 | `export type { Foo } from "./bar"` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Client Component 依赖隔离规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 常量定义放 `types.ts` 或 `constants.ts` | `types.ts: export const MAX = 30` | `retention.ts: export const MAX = 30`(含 `import { db }`) |
|
||||||
|
| Client Component 不得 import server-only 文件 | `import { MAX } from "../types"` | `import { MAX } from "../retention"`(拉入 mysql2) |
|
||||||
|
| `import { db }` 或 `import "server-only"` 的文件不得被客户端 import | — | — |
|
||||||
|
| 纯类型文件不得 import db/mysql2 | `types.ts` 无 server-only import | `types.ts: import { db } from "@/shared/db"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、可选依赖处理规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 可选依赖动态 import 加 `webpackIgnore` | `await import(/* webpackIgnore: true */ "@upstash/redis")` | `await import("@upstash/redis")` |
|
||||||
|
| 环境变量控制加载的模块用动态 import | `if (env.DRIVER === "redis") { const { Redis } = await import(...) }` | 顶层静态 import |
|
||||||
|
| 仅 redis 驱动加载的类用动态 import | `const { RedisRateLimiter } = await import("./redis-limiter")` | `import { RedisRateLimiter } from "./redis-limiter"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Next.js 配置规则
|
||||||
|
|
||||||
|
| 规则 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` |
|
||||||
|
| 输出模式 | `output: "standalone"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、架构分层规则
|
||||||
|
|
||||||
|
| 层级 | 允许依赖 | 禁止依赖 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `app/` | `modules/` 的 actions + data-access | 直接访问 DB |
|
||||||
|
| `modules/` | 其他 `modules/` 的 data-access + `shared/` | 直接查询对方 DB 表 |
|
||||||
|
| `shared/` | 无 | `@/auth`、`@/proxy`、`modules/*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、模块文件职责规则
|
||||||
|
|
||||||
|
| 文件 | 职责 | 禁止 |
|
||||||
|
|------|------|------|
|
||||||
|
| `actions.ts` | Server Actions(async function + 权限校验) | 导出常量、同步函数、值 re-export |
|
||||||
|
| `data-access.ts` | 数据访问(可拆分 `data-access-*.ts`) | 业务逻辑、权限校验 |
|
||||||
|
| `types.ts` | 类型定义 + 常量 | `import { db }`、`import "server-only"` |
|
||||||
|
| `schema.ts` | Zod 验证 | — |
|
||||||
|
| `lib/` | 同步工具函数、业务逻辑 | "use server" 标记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、验证命令速查
|
||||||
|
|
||||||
|
| 场景 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| 类型检查 | `npx tsc --noEmit` |
|
||||||
|
| Lint | `npm run lint` |
|
||||||
|
| 开发服务器 | `npm run dev` |
|
||||||
|
| 生产构建 | `npm run build` |
|
||||||
|
| 检查 i18n key 是否含 `.` | Grep pattern: `^\s*"[a-zA-Z_]+\.[a-zA-Z_\.]+"\s*:`,glob: `**/messages/**/*.json` |
|
||||||
|
| 同步 schema 到数据库 | `npm run db:push` |
|
||||||
|
| 执行迁移文件 | `npm run db:migrate` |
|
||||||
|
| 检查表结构 | `SHOW COLUMNS FROM table_name` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、数据库 Schema 同步规则
|
||||||
|
|
||||||
|
| 规则 | 正确做法 | 错误做法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 修改 schema.ts 后必须同步数据库 | `npm run db:push` 或 `npm run db:migrate` | 仅改 schema.ts 不同步数据库 |
|
||||||
|
| 报错 `Unknown column` 时先查表结构 | `SHOW COLUMNS FROM xxx` | 仅看代码 schema 假设数据库已同步 |
|
||||||
|
| 迁移文件按编号顺序执行 | `drizzle/00XX_*.sql` 按序号递增 | 跳过编号或乱序执行 |
|
||||||
|
| `Failed query` 错误先排查数据库结构 | 用 mysql 客户端执行相同 SQL 确认 | 仅看代码假设查询逻辑错误 |
|
||||||
|
| 新增字段必须生成迁移文件 | `npm run db:generate` 后 commit `.sql` + `_journal.json` | 仅 ALTER 数据库不留迁移文件 |
|
||||||
|
| 修改 schema 后验证一致性 | `node scripts/verify-modules-schema.mjs` 或 `npm run db:push` | 仅看 dev server 不报错即认为成功 |
|
||||||
|
|
||||||
|
### 数据库同步问题排查
|
||||||
|
|
||||||
|
当出现 `Unknown column 'xxx' in 'field list'` 或 `Failed query` 错误时:
|
||||||
|
|
||||||
|
1. 用 `SHOW COLUMNS FROM 表名` 对比数据库实际结构与 `schema.ts`
|
||||||
|
2. 若字段缺失,执行 `npm run db:push` 同步,或手动执行对应迁移 SQL
|
||||||
|
3. 验证:用相同 SQL 在数据库客户端执行,确认不再报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、Drizzle 迁移记录管理规则
|
||||||
|
|
||||||
|
| 规则 | 正确做法 | 错误做法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 迁移文件必须录入 `_journal.json` | `npm run db:generate` 自动生成 entry | 手动创建 `.sql` 不更新 journal |
|
||||||
|
| `__drizzle_migrations` 表必须记录已应用迁移 | `npm run db:migrate` 自动写入 hash | 手动 ALTER 后不补 hash 记录 |
|
||||||
|
| 迁移文件编号唯一不可复用 | `0016_invitation_codes.sql` 与 `0016_message_reports.sql` 应改 `0022+` | 同前缀冲突 |
|
||||||
|
| 手动补 DDL 后必须同步 journal + migrations 表 | 见下方"手动补迁移"流程 | 仅 ALTER 数据库,留 journal 缺失 |
|
||||||
|
| 检查迁移状态 | `SELECT * FROM __drizzle_migrations` | 仅看 `_journal.json` 文件 |
|
||||||
|
|
||||||
|
### 手动补迁移流程(数据库已 ALTER 但 journal/migrations 表缺失时)
|
||||||
|
|
||||||
|
1. 在 `drizzle/` 创建 `00XX_*.sql` 文件(即使 DDL 已应用,仍需归档)
|
||||||
|
2. 在 `drizzle/meta/_journal.json` 添加对应 entry(`tag` = 文件名不含扩展名)
|
||||||
|
3. 计算文件 sha256 hash,插入 `__drizzle_migrations` 表:
|
||||||
|
```sql
|
||||||
|
INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('<sha256>', <unix_ms>);
|
||||||
|
```
|
||||||
|
4. 验证:`npm run db:migrate` 应输出 "No new migrations to apply"
|
||||||
|
|
||||||
|
### Drizzle 字段映射规则
|
||||||
|
|
||||||
|
| 场景 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| TS 属性名与 DB 列名不同 | `status: mysqlEnum("report_status", [...])` | 假设 DB 列名是 `status` |
|
||||||
|
| 验证 SQL 用 DB 列名 | `SELECT report_status FROM learning_diagnostic_reports` | `SELECT status FROM learning_diagnostic_reports` |
|
||||||
|
| ORM 查询用 TS 属性名 | `db.select({ s: table.status })` | `db.select({ s: table.report_status })` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、V5 备课模块场景缺口修复规则
|
||||||
|
|
||||||
|
### Zustand store 拆 slice + history slice 模式
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| mutation 方法包装 history | `setTitle: (title) => { get().pushHistory(); set({ title, isDirty: true }); }` | `setTitle: (title) => set({ title, isDirty: true })` |
|
||||||
|
| 拖拽位置更新除外 | `updateNodePosition` 不推 history(调用方 onDragStart 时推) | 拖拽过程每次 move 都 pushHistory(栈瞬间爆满) |
|
||||||
|
| hydrate/replaceDoc 清空历史 | `hydrate: (...) => set({ ..., past: [], future: [] })` | 切换课案后旧 history 残留 |
|
||||||
|
| history 栈上限 | `past: [...s.past, snapshot].slice(-MAX_HISTORY)` | 无上限(内存爆炸) |
|
||||||
|
|
||||||
|
### 撤销/重做快捷键
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Cmd/Ctrl 区分 | `const isMod = e.metaKey \|\| e.ctrlKey` | 仅 `e.ctrlKey`(Mac 不工作) |
|
||||||
|
| Shift+Z 或 Y 重做 | `(key === "z" && e.shiftKey) \|\| key === "y"` | 仅 `key === "y"` |
|
||||||
|
| preventDefault | `e.preventDefault()` | 不阻止默认(浏览器原生撤销/重做冲突) |
|
||||||
|
|
||||||
|
### 自动保存失败 UI 兜底
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 保存失败显示 toast | `toast.error(t("status.saveFailed"), { description: ... })` | `console.error(e)`(用户无感知) |
|
||||||
|
| 断网时不触发保存 | `if (!editor.isOnline) return;` 在 debounce 内 | 网络请求堆积 |
|
||||||
|
| 监听 online/offline | `window.addEventListener("online", handleOnline)` | 不监听(永远显示离线) |
|
||||||
|
| saveError 显示重试按钮 | `{editor.saveError && <Button onClick={handleRetrySave}>...}` | 失败后无重试入口 |
|
||||||
|
|
||||||
|
### 发布前预览 3 步流程
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 步骤状态机 | `type Step = "select" \| "preview" \| "confirm"` | 单步直接发布 |
|
||||||
|
| 预览显示题目列表 | `<ol>{items.map(...)}` 含题干/选项/分值 | 仅显示题目数量 |
|
||||||
|
| 总分 useMemo | `const totalScore = useMemo(() => items.reduce(...), [items])` | 每次渲染 reduce |
|
||||||
|
| 确认页警告文案 | `{t("publish.confirmWarning")}` | 无确认步骤直接发布 |
|
||||||
|
|
||||||
|
### Tiptap Image 扩展集成
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 安装依赖 | `npm install @tiptap/extension-image` | 自建图片上传组件 |
|
||||||
|
| 配置 inline:false | `Image.configure({ inline: false, allowBase64: false })` | `allowBase64: true`(XSS 风险) |
|
||||||
|
| 插入图片命令 | `editor.commands.setImage({ src, alt })` | 手动拼接 HTML `<img>` |
|
||||||
|
| HTMLAttributes 类名 | `class: "rich-text-image max-w-full h-auto rounded"` | 内联 style |
|
||||||
|
|
||||||
|
### 附件表 Date → ISO string 转换
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| DB 层 Date 转接口 string | `createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt` | 直接 `createdAt: a.createdAt`(类型不匹配) |
|
||||||
|
| data-access 与 service 接口分离 | data-access 返回 `LessonPlanAttachment`(Date),service 转换为 `LessonPlanAttachmentOption`(string) | data-access 直接返回 string(破坏类型一致性) |
|
||||||
|
|
||||||
|
### 打印视图 print: 媒体查询
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 隐藏工具栏 | `<div className="... print:hidden">` | 工具栏也打印出来 |
|
||||||
|
| 内容区全屏 | `print:static print:w-full print:max-h-none print:rounded-none` | 保持 modal 样式 |
|
||||||
|
| 防止跨页 | `break-inside-avoid` | 段落被切断 |
|
||||||
|
| 页脚固定 | `print:fixed print:bottom-2` | 页脚丢失 |
|
||||||
|
|
||||||
|
### 素材库 picker 复用现有基础设施
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 复用 use-file-upload hook | `const { inputRef, handleFiles, tasks } = useFileUpload(...)` | 自建 FormData + XMLHttpRequest |
|
||||||
|
| 通过 service 调用 | `service.createLessonPlanAttachment(...)` | 组件直接 import createLessonPlanAttachmentAction |
|
||||||
|
| LessonPlanDataService 接口扩展 | 在接口声明 + default-data-service 实现 | 组件直接 import actions(破坏解耦) |
|
||||||
|
| FileTargetType 扩展 | `files/types.ts` 新增 `'lesson_plan'` 类型 | 字符串硬编码 `"lesson_plan"` |
|
||||||
|
|
||||||
|
### V5 第二阶段 V5-6~V5-21 通用规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Server Action form action 适配 | `duplicateLessonPlanFormAction(formData: FormData)` 内部调用 `duplicateLessonPlanAction(planId)` + `redirect()` | 直接把 `duplicateLessonPlanAction(planId: string)` 传给 `<form action>`(签名不匹配) |
|
||||||
|
| 按需加载知识点避免首屏负担 | 对话框 Tab 内 `useEffect` 调用 `getKnowledgePointsForAlignmentAction(textbookId)` | 编辑页直接 `Promise.all` 拉取整教材知识点(即使不打开 Tab) |
|
||||||
|
| 复用已安装依赖 | V5-8 直接用 `@dagrejs/dagre`(已在 package.json) | 重新装 dagre 或自实现 DAG 布局 |
|
||||||
|
| 轻量级可视化不引入新库 | V5-14 板书预览纯 CSS + 文本解析(缩进识别层级) | 引入 mermaid/markmap 库做板书预览 |
|
||||||
|
| AI 模块纯服务端函数 | `import "server-only"` + `createAiChatCompletion` + Zod 验证 + JSON 提取 | AI 函数混在 actions 里,没有 server-only 标记 |
|
||||||
|
| AI 不可用降级返回空结果 | `if (!text.trim()) return [];` `try { ... } catch { return []; }` | AI 失败抛错导致整个对话框崩溃 |
|
||||||
|
| 版本对比纯函数 | `diffDocuments(oldDoc, newDoc)` 输出 added/removed/modified/unchanged | 在组件内直接对比(无法复用 + 难测试) |
|
||||||
|
| 一致性校验纯函数 | `checkConsistency(doc)` 输出 ConsistencyResult | 在对话框 useEffect 内嵌套 if/else 校验逻辑 |
|
||||||
|
| 课标覆盖热力图纯函数 | `computeCurriculumCoverage(allKps, planLinks)` | 在组件内嵌套计算逻辑 |
|
||||||
|
| useMediaQuery 移动端切换 | `const isMobile = useMediaQuery("(max-width: 768px)")` 在 readonly-view | 服务端 `useState` 默认值与客户端不一致(hydration mismatch) |
|
||||||
|
| 编辑器工具栏按钮分组 | 一致性校验/AI 反馈/AI 差异化 三个并列 Button + 各自 state + ErrorBoundary 包裹 | 三个对话框共用一个 state(无法独立打开) |
|
||||||
|
| FocusTrap + ESC 关闭对话框 | `useEffect` 监听 `Escape` 键 + FocusTrap 包裹 | 仅点击 X 关闭(键盘用户无法关闭) |
|
||||||
|
| 类型扩展不破坏旧数据 | `Block` 接口新增 `stage?` / `differentiation?` 可选字段 | 必填字段(旧数据迁移失败) |
|
||||||
|
| KnowledgePoint 类型导入路径 | `import type { KnowledgePoint } from "@/modules/textbooks/types"` | `from "@/modules/textbooks/data-access"`(仅 types 文件导出) |
|
||||||
|
| chapterId null 处理 | `chapterId: kp.chapterId ?? null` | 直接 `kp.chapterId`(`string \| undefined` 不能赋给 `string \| null`) |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、问题记录规则
|
||||||
|
|
||||||
|
| 规则 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 构建报错修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||||
|
| 运行时异常修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||||
|
| 框架/库版本兼容问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||||
|
| 依赖配置问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||||
|
| 架构约束违规 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||||
|
| 记录格式 | 标题 → 错误现象 → 根因 → 解决方案 → 验证方法 → 受影响文件 |
|
||||||
|
| 去重 | 同类问题在原条目补充,不重复创建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、Drizzle 子查询字段 alias 规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 子查询中 raw SQL 字段必须声明 alias | `count().as("errorCount")` | `count()` |
|
||||||
|
| `sql\`...\`` 字段也需 alias | `sql<number>\`sum(...)\`.as("masteredCount")` | `sql<number>\`sum(...)\`` |
|
||||||
|
| 普通列引用可不加 alias | `questionId: errorBookItems.questionId` | — |
|
||||||
|
| 外层引用子查询字段用 `.field` | `aggregatedSubquery.errorCount` | — |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
You tried to reference "errorCount" field from a subquery, which is a raw SQL field,
|
||||||
|
but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 正确示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const aggregatedSubquery = db
|
||||||
|
.select({
|
||||||
|
questionId: errorBookItems.questionId,
|
||||||
|
errorCount: count().as("errorCount"),
|
||||||
|
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
|
||||||
|
})
|
||||||
|
.from(errorBookItems)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(errorBookItems.questionId)
|
||||||
|
.as("aggregated")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、Server/Client Component 函数传递规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Server Component 不传含函数的对象给 Client Component | `<Provider>`(不传 monitor prop) | `<Provider monitor={noopMonitor}>`(含 track 函数) |
|
||||||
|
| Client Component prop 含函数时改为可选 | `monitor?: DiagnosticMonitor` | `monitor: DiagnosticMonitor`(必填) |
|
||||||
|
| 函数对象在 Client 端组装 | `useMemo(() => createService(monitor), [monitor])` | Server 端 `createService()` 后传给 Client |
|
||||||
|
| Server Component 仅传纯数据 | `reports={reports.reports}`(纯数据数组) | `service={createMonitoredService()}`(含函数) |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||||||
|
Attempted to call createMonitoredDiagnosticService() from the server but createMonitoredDiagnosticService is on the client.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Provider (client component) - prop 改为可选,未传时用默认值
|
||||||
|
interface ProviderProps {
|
||||||
|
monitor?: DiagnosticMonitor // 可选,Server Component 不传
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
export function Provider({ monitor, children }: ProviderProps) {
|
||||||
|
const value = monitor ?? noopDiagnosticMonitor
|
||||||
|
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Component - 不传含函数的 prop
|
||||||
|
<DiagnosticMonitorProvider>
|
||||||
|
<DiagnosticServiceProvider>
|
||||||
|
<ReportList reports={reports.reports} />
|
||||||
|
</DiagnosticServiceProvider>
|
||||||
|
</DiagnosticMonitorProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、Playwright 测试检测规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 不检查 content 中的 i18n JSON 文本 | `"发生未知错误" in content`(会匹配翻译 JSON) | — |
|
||||||
|
| 检测错误用 HTTP 状态码 | `response.status != 200` | 仅检查页面文本 |
|
||||||
|
| 检测可见错误用 locator + is_visible | `page.locator('text="错误").is_visible()` | `"错误" in content`(匹配隐藏 JSON) |
|
||||||
|
| pageerror 回调不能累积注册 | 循环外注册一次,循环内 `errors.clear()` | 每次循环 `page.on("pageerror", ...)` |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
i18n 翻译 JSON 内联在页面 HTML 中(如 `"errors":{"unexpected":"发生未知错误"}`),`"发生未知错误" in content` 会误判为错误边界触发。
|
||||||
|
|
||||||
|
### 正确检测方式
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. HTTP 状态码
|
||||||
|
response = page.goto(url, ...)
|
||||||
|
http_status = response.status # 属性,不是方法
|
||||||
|
|
||||||
|
# 2. pageerror(循环外注册一次)
|
||||||
|
all_errors = []
|
||||||
|
page.on("pageerror", lambda err: all_errors.append(str(err)))
|
||||||
|
for module in modules:
|
||||||
|
all_errors.clear()
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# 3. 可见错误元素(不检查 HTML content)
|
||||||
|
loc = page.locator('text="发生未知错误"').first
|
||||||
|
if loc.count() > 0 and loc.is_visible():
|
||||||
|
# 真正的错误边界
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、next-auth useSession SSR Hydration Mismatch 规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Root Layout SSR 期间预取 session | `const session = await auth()` 后传给 `<AuthSessionProvider session={session}>` | 依赖 client 端 `useSession()` 异步获取 |
|
||||||
|
| SessionProvider 接收 SSR session prop | `<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>` | 不传 session,client 端首次渲染为 `status:"loading"` |
|
||||||
|
| Hydration mismatch 修复不用 `mounted` workaround | root layout 提供 SSR session 后直接用 `useSession()` | `const [mounted] = useState(false); useEffect(()=>setMounted(true),[]); if(!mounted) return null` |
|
||||||
|
| 关闭 SessionProvider 自动 refetch | `refetchOnWindowFocus={false} refetchInterval={0}` | 默认 refetch 触发 SSR/CSR 不一致 |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||||
|
+ id="radix-_R_19ebn6lb_"
|
||||||
|
- id="radix-_R_55qbn6lb_"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`useSession()` 在 SSR 期间返回 `status:"loading"`,CSR hydration 后变成 `"authenticated"`,导致 `SiteHeader` 中 `displayName`/`avatarFallback` 渲染结果不同,触发 Radix UI 组件重新生成 `id` 属性。
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/layout.tsx (Server Component)
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await auth() // SSR 预取
|
||||||
|
return (
|
||||||
|
<AuthSessionProvider session={session}>
|
||||||
|
{children}
|
||||||
|
</AuthSessionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/shared/components/auth-session-provider.tsx ("use client")
|
||||||
|
export function AuthSessionProvider({ children, session }: { children: React.ReactNode; session?: Session | null }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十七、useState 初始值 SSR/CSR 一致性规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 初始值禁止 `typeof window` 分支 | `useState("unsupported")` 固定值 | `useState(typeof window !== "undefined" ? Notification.permission : "unsupported")` |
|
||||||
|
| 浏览器 API 检测放 `useEffect` | `useEffect(() => { if ("Notification" in window) setPermission(Notification.permission) }, [])` | `useState` 初始值中读取 `window.Notification` |
|
||||||
|
| 初始值禁止 `Math.random`/`Date.now()` | `useState(0)`,副作用在 `useEffect` 中更新 | `useState(Date.now())` |
|
||||||
|
| 初始值禁止读取 localStorage | `useState(null)`,`useEffect` 中读 localStorage | `useState(localStorage.getItem("key"))` |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||||
|
Hydration failed because the initial UI does not match what was rendered on the server.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 错误:SSR 返回 "unsupported",CSR 返回 "default"/"granted"
|
||||||
|
const [permission, setPermission] = useState(
|
||||||
|
typeof window !== "undefined" && "Notification" in window
|
||||||
|
? Notification.permission
|
||||||
|
: "unsupported"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 正确:初始固定值 + useEffect 检测
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported")
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !("Notification" in window)) return
|
||||||
|
setPermission(Notification.permission)
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十八、SectionErrorBoundary i18n Keys 完整性规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 使用 `SectionErrorBoundary namespace="X"` 时必须补全 3 个 key | `{namespace}.json` 中 `error.boundaryTitle` / `error.boundaryDescription` / `error.retry` 全部存在 | 仅添加 `error.retry` |
|
||||||
|
| 新增 namespace 用法时同步检查 i18n 文件 | `<SectionErrorBoundary namespace="notifications">` → 检查 `notifications.json` 是否有 `error.boundaryTitle/Description` | 仅检查 `error.retry` |
|
||||||
|
| 所有语言文件同步补全 | `zh-CN/X.json` 和 `en/X.json` 都要加 | 只加中文 |
|
||||||
|
| 缺 key 会触发 hydration 警告 | `MISSING_MESSAGE` 错误在 SSR 期间抛出,client 渲染回退,触发 hydration mismatch | — |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryTitle` in messages for locale `zh-CN`.
|
||||||
|
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryDescription` in messages for locale `zh-CN`.
|
||||||
|
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`SectionErrorBoundary` 内部调用 `t("error.boundaryTitle")` 和 `t("error.boundaryDescription")`,若 namespace JSON 缺少这些 key,next-intl 在 SSR 期间抛 `IntlError`,导致 Server Component 渲染失败,React 回退到 client 渲染,触发 hydration mismatch。
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```json
|
||||||
|
// src/shared/i18n/messages/zh-CN/notifications.json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "通知加载失败",
|
||||||
|
"loadFailedDesc": "...",
|
||||||
|
"boundaryTitle": "通知区块加载失败",
|
||||||
|
"boundaryDescription": "加载通知数据时发生错误,请重试。",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十九、Server Component 传递纯数据替代函数规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 分页组件接收 `basePath` + `statusFilter` 纯数据 | `<Pagination basePath="/announcements" statusFilter="published" />` | `<Pagination buildPageHref={(p) => \`/announcements?page=${p}\`} />` |
|
||||||
|
| Client Component 内部构建 URL | `const buildHref = (p) => { const params = new URLSearchParams(); if (statusFilter) params.set("status", statusFilter); ... }` | Server 端定义函数传给 client |
|
||||||
|
| Server Component 仅传可序列化数据 | `pagination={{ page, pageSize, total, basePath, statusFilter }}` | `pagination={{ page, pageSize, total, buildPageHref: fn }}` |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||||||
|
{page: 1, pageSize: 12, total: 2, buildPageHref: function buildPageHref}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server Component (announcements/page.tsx)
|
||||||
|
const statusFilter = sp.status === "published" ? sp.status : "all"
|
||||||
|
<AnnouncementList
|
||||||
|
pagination={{
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
basePath: "/announcements", // 纯数据
|
||||||
|
statusFilter, // 纯数据
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Client Component (announcement-pagination.tsx)
|
||||||
|
export function AnnouncementPagination({ page, pageSize, total, basePath, statusFilter }: Props) {
|
||||||
|
const buildHref = (targetPage: number): string => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
|
||||||
|
if (targetPage > 1) params.set("page", String(targetPage))
|
||||||
|
const qs = params.toString()
|
||||||
|
return qs ? `${basePath}?${qs}` : basePath
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二十、i18n Namespace 与文件名一致性规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `useTranslations` namespace 必须与 `i18n/request.ts` 配置一致 | `useTranslations("errorBook")`(驼峰,与 `request.ts` 的 `errorBook: errorBook.default` 一致) | `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆) |
|
||||||
|
| namespace 跟 `request.ts` 的 messages key,不跟文件名 | 文件名 `error-book.json` → messages key `errorBook` → `useTranslations("errorBook")` | 文件名 `error-book.json` → `useTranslations("error-book")` |
|
||||||
|
| 批量检查 namespace 一致性 | `grep -r "useTranslations(\"error-book\")"` 应返回 0 结果 | — |
|
||||||
|
| 新增 namespace 时同步更新 `request.ts` | `request.ts` 新增 `import` + `messages` key | 只创建 JSON 文件不注册 |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
IntlError: MISSING_MESSAGE: Could not resolve `error-book` in messages for locale `zh-CN`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`i18n/request.ts` 中配置的 messages key 是 `errorBook`(驼峰),但 17 个组件错误使用了 `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆)。next-intl 找不到 `error-book` namespace,所有 `t()` 调用抛出 `MISSING_MESSAGE` 错误。
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// i18n/request.ts 配置
|
||||||
|
errorBook: errorBook.default, // key 是 "errorBook"
|
||||||
|
|
||||||
|
// 组件中正确使用
|
||||||
|
const t = useTranslations("errorBook") // ✅ 驼峰,与配置一致
|
||||||
|
|
||||||
|
// 组件中错误使用
|
||||||
|
const t = useTranslations("error-book") // ❌ 连字符,与文件名混淆
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二十一、useSearchParams() 导致 Hydration Mismatch 规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Client Component 中避免 `useSearchParams()` | `window.location.search` 在 `handleSelect` 点击时读取 | `useSearchParams()` 在组件顶层读取 |
|
||||||
|
| `useSearchParams()` 需要 `<Suspense>` 包裹且会导致 SSR fallback | 仅在需要 SSR 流式渲染的场景使用 | 在普通交互组件中直接使用 |
|
||||||
|
| URL 参数在点击时构建 | `const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); params.set("subject", id); router.push(\`?${params}\`)` | `const searchParams = useSearchParams(); const params = new URLSearchParams(searchParams.toString())` |
|
||||||
|
| `useSearchParams()` 导致组件树 SSR/CSR 不同 | SSR 显示 Suspense fallback,CSR 显示真实内容,`useId()` 生成不同 id | — |
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
|
||||||
|
```
|
||||||
|
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||||
|
+ aria-controls="radix-_R_3jabn6lb_"
|
||||||
|
- aria-controls="radix-_R_edabn6lb_"
|
||||||
|
|
||||||
|
+ data-chart="chart-_R_4matpesndubn6lb_"
|
||||||
|
- data-chart="chart-_R_ipbn5ritnqbn6lb_"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`useSearchParams()` 在 SSR 期间会导致包裹它的 `<Suspense>` 显示 fallback(Skeleton),而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React `useId()` 生成的 id 不匹配,触发全局 hydration mismatch(影响页面所有 Radix UI 和 ChartContainer 组件的 id)。
|
||||||
|
|
||||||
|
### 修复模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 错误:useSearchParams() 导致 SSR/CSR 组件树不同
|
||||||
|
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams() // ❌ SSR 显示 fallback
|
||||||
|
|
||||||
|
const handleSelect = (subjectId: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正确:在点击时用 window.location.search 读取当前 URL 参数
|
||||||
|
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleSelect = (subjectId: string | null) => {
|
||||||
|
// 仅在 client 端点击时执行,不影响 SSR
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
typeof window !== "undefined" ? window.location.search : ""
|
||||||
|
)
|
||||||
|
if (subjectId === null) {
|
||||||
|
params.delete("subject")
|
||||||
|
} else {
|
||||||
|
params.set("subject", subjectId)
|
||||||
|
}
|
||||||
|
router.push(`?${params.toString()}`, { scroll: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二十二、React StrictMode 与 Server Action Hook 规则
|
||||||
|
|
||||||
|
| 规则 | 正确写法 | 错误写法 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| useEffect 中调用 Server Action 时禁止用 ref 防重复 | 仅用 useEffect 依赖数组控制执行频率 | `const lastKey = useRef(""); if (lastKey.current === key) return; lastKey.current = key;` |
|
||||||
|
| StrictMode 下 cleanup 会将第一次 action 结果标记为 cancelled | `let cancelled = false; action().then(r => { if (cancelled) return; ... }); return () => { cancelled = true }` 接受 StrictMode 下 action 执行两次 | 用 ref 阻止第二次 effect 执行 action,导致第一次结果被 cancelled 丢弃,永久 loading |
|
||||||
|
| 客户端 dynamic import 第三方 Canvas/Window 依赖必须用 `import type` | `import type ForceGraph2DType from "react-force-graph-2d"` + `dynamic(...)` | `import ForceGraph2D from "react-force-graph-2d"` 值导入会触发顶层副作用在 SSR 执行 |
|
||||||
|
|
||||||
|
**问题现象**:知识点图谱页面一直显示"正在加载知识点",但 Server Action 实际已返回 `success: true` 和数据。
|
||||||
|
|
||||||
|
**根因 1**:Next.js 默认启用 `reactStrictMode: true`,dev 模式下 useEffect 执行两次(mount → unmount → mount)。若 Hook 内用 `lastRequestKey` ref 防重复,第一次 mount 发起 action 后 cleanup 设 `cancelled=true`,第二次 mount 因 key 相同跳过不发起 action,第一次 action 结果被 cancelled 忽略,state 永不更新。
|
||||||
|
|
||||||
|
**根因 2**:`next/dynamic` 加载的第三方库若在顶层用值导入(`import X from "lib"`),即使 `dynamic({ ssr: false })`,模块顶层代码仍会在 SSR 求值时执行,导致 canvas/window 访问报错,dynamic loading 永久卡住。必须用 `import type` 引入类型。
|
||||||
|
|
||||||
1560
drizzle/0000_aberrant_deathstrike.sql
Normal file
1560
drizzle/0000_aberrant_deathstrike.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,816 +0,0 @@
|
|||||||
CREATE TABLE `academic_years` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(100) NOT NULL,
|
|
||||||
`start_date` timestamp NOT NULL,
|
|
||||||
`end_date` timestamp NOT NULL,
|
|
||||||
`is_active` boolean NOT NULL DEFAULT false,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `academic_years_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `academic_years_name_unique` UNIQUE(`name`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `accounts` (
|
|
||||||
`userId` varchar(128) NOT NULL,
|
|
||||||
`type` varchar(255) NOT NULL,
|
|
||||||
`provider` varchar(255) NOT NULL,
|
|
||||||
`providerAccountId` varchar(255) NOT NULL,
|
|
||||||
`refresh_token` text,
|
|
||||||
`access_token` text,
|
|
||||||
`expires_at` int,
|
|
||||||
`token_type` varchar(255),
|
|
||||||
`scope` varchar(255),
|
|
||||||
`id_token` text,
|
|
||||||
`session_state` varchar(255),
|
|
||||||
CONSTRAINT `accounts_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `ai_providers` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`provider` enum('zhipu','openai','gemini','custom') NOT NULL,
|
|
||||||
`base_url` varchar(512),
|
|
||||||
`model` varchar(128) NOT NULL,
|
|
||||||
`api_key_encrypted` text NOT NULL,
|
|
||||||
`api_key_last4` varchar(4),
|
|
||||||
`is_default` boolean NOT NULL DEFAULT false,
|
|
||||||
`created_by` varchar(128),
|
|
||||||
`updated_by` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `ai_providers_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `announcements` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`content` text NOT NULL,
|
|
||||||
`type` enum('school','grade','class') NOT NULL DEFAULT 'school',
|
|
||||||
`status` enum('draft','published','archived') NOT NULL DEFAULT 'draft',
|
|
||||||
`target_grade_id` varchar(128),
|
|
||||||
`target_class_id` varchar(128),
|
|
||||||
`author_id` varchar(128) NOT NULL,
|
|
||||||
`published_at` datetime,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `announcements_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `attendance_records` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`schedule_id` varchar(128),
|
|
||||||
`date` date NOT NULL,
|
|
||||||
`status` enum('present','absent','late','early_leave','excused') NOT NULL,
|
|
||||||
`remark` text,
|
|
||||||
`recorded_by` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `attendance_records_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `attendance_rules` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128),
|
|
||||||
`late_threshold_minutes` int DEFAULT 15,
|
|
||||||
`early_leave_threshold_minutes` int DEFAULT 15,
|
|
||||||
`enable_auto_mark` boolean DEFAULT false,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `attendance_rules_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `audit_logs` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`user_name` varchar(255) NOT NULL,
|
|
||||||
`action` varchar(255) NOT NULL,
|
|
||||||
`module` varchar(128) NOT NULL,
|
|
||||||
`target_id` varchar(128),
|
|
||||||
`target_type` varchar(128),
|
|
||||||
`detail` text,
|
|
||||||
`ip_address` varchar(45),
|
|
||||||
`user_agent` varchar(512),
|
|
||||||
`status` enum('success','failure') NOT NULL DEFAULT 'success',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `audit_logs_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `chapters` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`textbook_id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`order` int DEFAULT 0,
|
|
||||||
`parent_id` varchar(128),
|
|
||||||
`content` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `chapters_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `class_enrollments` (
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`class_enrollment_status` enum('active','inactive') NOT NULL DEFAULT 'active',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `class_enrollments_class_id_student_id_pk` PRIMARY KEY(`class_id`,`student_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `class_schedule` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`weekday` int NOT NULL,
|
|
||||||
`start_time` varchar(5) NOT NULL,
|
|
||||||
`end_time` varchar(5) NOT NULL,
|
|
||||||
`course` varchar(255) NOT NULL,
|
|
||||||
`location` varchar(100),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `class_schedule_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `class_subject_teachers` (
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`subject_id` varchar(128) NOT NULL,
|
|
||||||
`teacher_id` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `class_subject_teachers_class_id_subject_id_pk` PRIMARY KEY(`class_id`,`subject_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `classes` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`school_name` varchar(255),
|
|
||||||
`school_id` varchar(128),
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`grade` varchar(50) NOT NULL,
|
|
||||||
`grade_id` varchar(128),
|
|
||||||
`homeroom` varchar(50),
|
|
||||||
`room` varchar(50),
|
|
||||||
`invitation_code` varchar(6),
|
|
||||||
`teacher_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `classes_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `classes_invitation_code_unique` UNIQUE(`invitation_code`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `classrooms` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`building` varchar(100),
|
|
||||||
`floor` int,
|
|
||||||
`capacity` int,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `classrooms_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `classrooms_name_unique` UNIQUE(`name`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `course_plan_items` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`plan_id` varchar(128) NOT NULL,
|
|
||||||
`week` int NOT NULL,
|
|
||||||
`topic` varchar(255) NOT NULL,
|
|
||||||
`content` text,
|
|
||||||
`hours` int NOT NULL DEFAULT 2,
|
|
||||||
`textbook_chapter` varchar(255),
|
|
||||||
`notes` text,
|
|
||||||
`is_completed` boolean NOT NULL DEFAULT false,
|
|
||||||
`completed_at` date,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `course_plan_items_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `course_plans` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`subject_id` varchar(128) NOT NULL,
|
|
||||||
`teacher_id` varchar(128) NOT NULL,
|
|
||||||
`academic_year_id` varchar(128),
|
|
||||||
`semester` enum('1','2') NOT NULL DEFAULT '1',
|
|
||||||
`total_hours` int NOT NULL DEFAULT 0,
|
|
||||||
`completed_hours` int NOT NULL DEFAULT 0,
|
|
||||||
`weekly_hours` int NOT NULL DEFAULT 0,
|
|
||||||
`start_date` date,
|
|
||||||
`end_date` date,
|
|
||||||
`syllabus` text,
|
|
||||||
`objectives` text,
|
|
||||||
`status` enum('planning','active','completed','paused') NOT NULL DEFAULT 'planning',
|
|
||||||
`created_by` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `course_plans_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `data_change_logs` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`table_name` varchar(128) NOT NULL,
|
|
||||||
`record_id` varchar(128) NOT NULL,
|
|
||||||
`action` enum('create','update','delete') NOT NULL,
|
|
||||||
`old_value` text,
|
|
||||||
`new_value` text,
|
|
||||||
`changed_by` varchar(128) NOT NULL,
|
|
||||||
`changed_by_name` varchar(255) NOT NULL,
|
|
||||||
`ip_address` varchar(45),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `data_change_logs_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `departments` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `departments_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `departments_name_unique` UNIQUE(`name`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `exam_questions` (
|
|
||||||
`exam_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`score` int DEFAULT 0,
|
|
||||||
`order` int DEFAULT 0,
|
|
||||||
CONSTRAINT `exam_questions_exam_id_question_id_pk` PRIMARY KEY(`exam_id`,`question_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `exam_submissions` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`exam_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`score` int,
|
|
||||||
`status` varchar(50) DEFAULT 'started',
|
|
||||||
`submitted_at` timestamp,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `exam_submissions_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `exams` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`structure` json,
|
|
||||||
`creator_id` varchar(128) NOT NULL,
|
|
||||||
`subject_id` varchar(128),
|
|
||||||
`grade_id` varchar(128),
|
|
||||||
`start_time` timestamp,
|
|
||||||
`end_time` timestamp,
|
|
||||||
`status` varchar(50) DEFAULT 'draft',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `exams_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `file_attachments` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`filename` varchar(255) NOT NULL,
|
|
||||||
`original_name` varchar(255) NOT NULL,
|
|
||||||
`mime_type` varchar(128) NOT NULL,
|
|
||||||
`size` bigint NOT NULL,
|
|
||||||
`storage_path` varchar(512) NOT NULL,
|
|
||||||
`url` varchar(512),
|
|
||||||
`uploader_id` varchar(128) NOT NULL,
|
|
||||||
`target_type` varchar(128),
|
|
||||||
`target_id` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `file_attachments_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `grade_records` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`subject_id` varchar(128) NOT NULL,
|
|
||||||
`exam_id` varchar(128),
|
|
||||||
`academic_year_id` varchar(128),
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`score` decimal(6,2) NOT NULL,
|
|
||||||
`full_score` decimal(6,2) NOT NULL DEFAULT '100',
|
|
||||||
`type` enum('exam','quiz','homework','other') NOT NULL DEFAULT 'exam',
|
|
||||||
`semester` enum('1','2') NOT NULL DEFAULT '1',
|
|
||||||
`recorded_by` varchar(128) NOT NULL,
|
|
||||||
`remark` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `grade_records_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `grades` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`school_id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(100) NOT NULL,
|
|
||||||
`order` int NOT NULL DEFAULT 0,
|
|
||||||
`grade_head_id` varchar(128),
|
|
||||||
`teaching_head_id` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `grades_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `homework_answers` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`submission_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`answer_content` json,
|
|
||||||
`score` int,
|
|
||||||
`feedback` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `homework_answers_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `homework_assignment_questions` (
|
|
||||||
`assignment_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`score` int DEFAULT 0,
|
|
||||||
`order` int DEFAULT 0,
|
|
||||||
CONSTRAINT `homework_assignment_questions_assignment_id_question_id_pk` PRIMARY KEY(`assignment_id`,`question_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `homework_assignment_targets` (
|
|
||||||
`assignment_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `homework_assignment_targets_assignment_id_student_id_pk` PRIMARY KEY(`assignment_id`,`student_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `homework_assignments` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`source_exam_id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`structure` json,
|
|
||||||
`status` varchar(50) DEFAULT 'draft',
|
|
||||||
`creator_id` varchar(128) NOT NULL,
|
|
||||||
`available_at` timestamp,
|
|
||||||
`due_at` timestamp,
|
|
||||||
`allow_late` boolean NOT NULL DEFAULT false,
|
|
||||||
`late_due_at` timestamp,
|
|
||||||
`max_attempts` int NOT NULL DEFAULT 1,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `homework_assignments_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `homework_submissions` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`assignment_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`attempt_no` int NOT NULL DEFAULT 1,
|
|
||||||
`score` int,
|
|
||||||
`status` varchar(50) DEFAULT 'started',
|
|
||||||
`started_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`submitted_at` timestamp,
|
|
||||||
`is_late` boolean NOT NULL DEFAULT false,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `homework_submissions_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `knowledge_points` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`anchor_text` varchar(255),
|
|
||||||
`parent_id` varchar(128),
|
|
||||||
`chapter_id` varchar(128),
|
|
||||||
`level` int DEFAULT 0,
|
|
||||||
`order` int DEFAULT 0,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `knowledge_points_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `login_logs` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128),
|
|
||||||
`user_email` varchar(255) NOT NULL,
|
|
||||||
`action` enum('signin','signout','signup') NOT NULL,
|
|
||||||
`status` enum('success','failure') NOT NULL DEFAULT 'success',
|
|
||||||
`ip_address` varchar(45),
|
|
||||||
`user_agent` varchar(512),
|
|
||||||
`error_message` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `login_logs_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `message_notifications` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`type` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`content` text,
|
|
||||||
`link` varchar(512),
|
|
||||||
`is_read` boolean NOT NULL DEFAULT false,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `message_notifications_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `messages` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`sender_id` varchar(128) NOT NULL,
|
|
||||||
`receiver_id` varchar(128) NOT NULL,
|
|
||||||
`subject` varchar(255),
|
|
||||||
`content` text NOT NULL,
|
|
||||||
`is_read` boolean NOT NULL DEFAULT false,
|
|
||||||
`read_at` timestamp,
|
|
||||||
`parent_message_id` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `messages_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `notification_preferences` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`email_enabled` boolean NOT NULL DEFAULT false,
|
|
||||||
`sms_enabled` boolean NOT NULL DEFAULT false,
|
|
||||||
`push_enabled` boolean NOT NULL DEFAULT true,
|
|
||||||
`homework_notifications` boolean NOT NULL DEFAULT true,
|
|
||||||
`grade_notifications` boolean NOT NULL DEFAULT true,
|
|
||||||
`announcement_notifications` boolean NOT NULL DEFAULT true,
|
|
||||||
`message_notifications` boolean NOT NULL DEFAULT true,
|
|
||||||
`attendance_notifications` boolean NOT NULL DEFAULT true,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `notification_preferences_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `notification_preferences_user_id_unique` UNIQUE(`user_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `parent_student_relations` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`parent_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`relation` varchar(50),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `parent_student_relations_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `password_security` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`failed_login_attempts` int NOT NULL DEFAULT 0,
|
|
||||||
`locked_until` timestamp,
|
|
||||||
`password_changed_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`must_change_password` boolean NOT NULL DEFAULT false,
|
|
||||||
`last_password_change` timestamp,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `password_security_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `password_security_user_id_unique` UNIQUE(`user_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `questions` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`content` json NOT NULL,
|
|
||||||
`type` enum('single_choice','multiple_choice','text','judgment','composite') NOT NULL,
|
|
||||||
`difficulty` int DEFAULT 1,
|
|
||||||
`parent_id` varchar(128),
|
|
||||||
`author_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `questions_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `questions_to_knowledge_points` (
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`knowledge_point_id` varchar(128) NOT NULL,
|
|
||||||
CONSTRAINT `questions_to_knowledge_points_question_id_knowledge_point_id_pk` PRIMARY KEY(`question_id`,`knowledge_point_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `role_permissions` (
|
|
||||||
`role_id` varchar(128) NOT NULL,
|
|
||||||
`permission` varchar(100) NOT NULL,
|
|
||||||
CONSTRAINT `role_permissions_role_id_permission_pk` PRIMARY KEY(`role_id`,`permission`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `roles` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(50) NOT NULL,
|
|
||||||
`description` varchar(255),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `roles_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `roles_name_unique` UNIQUE(`name`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `schedule_changes` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`original_schedule_id` varchar(128),
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`original_teacher_id` varchar(128),
|
|
||||||
`substitute_teacher_id` varchar(128),
|
|
||||||
`original_date` date,
|
|
||||||
`new_date` date,
|
|
||||||
`new_start_time` varchar(10),
|
|
||||||
`new_end_time` varchar(10),
|
|
||||||
`reason` text,
|
|
||||||
`status` enum('pending','approved','rejected','completed') NOT NULL DEFAULT 'pending',
|
|
||||||
`requested_by` varchar(128) NOT NULL,
|
|
||||||
`approved_by` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `schedule_changes_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `scheduling_rules` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128),
|
|
||||||
`max_daily_hours` int DEFAULT 8,
|
|
||||||
`max_continuous_hours` int DEFAULT 2,
|
|
||||||
`lunch_break_start` varchar(10) DEFAULT '12:00',
|
|
||||||
`lunch_break_end` varchar(10) DEFAULT '13:00',
|
|
||||||
`morning_start` varchar(10) DEFAULT '08:00',
|
|
||||||
`afternoon_end` varchar(10) DEFAULT '17:00',
|
|
||||||
`avoid_back_to_back` boolean DEFAULT false,
|
|
||||||
`balanced_subjects` boolean DEFAULT true,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `scheduling_rules_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `schools` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`code` varchar(50),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `schools_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `schools_name_unique` UNIQUE(`name`),
|
|
||||||
CONSTRAINT `schools_code_unique` UNIQUE(`code`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `sessions` (
|
|
||||||
`sessionToken` varchar(255) NOT NULL,
|
|
||||||
`userId` varchar(128) NOT NULL,
|
|
||||||
`expires` timestamp NOT NULL,
|
|
||||||
CONSTRAINT `sessions_sessionToken` PRIMARY KEY(`sessionToken`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `subjects` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(100) NOT NULL,
|
|
||||||
`code` varchar(50),
|
|
||||||
`order` int DEFAULT 0,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `subjects_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `subjects_name_unique` UNIQUE(`name`),
|
|
||||||
CONSTRAINT `subjects_code_unique` UNIQUE(`code`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `submission_answers` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`submission_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`answer_content` json,
|
|
||||||
`score` int,
|
|
||||||
`feedback` text,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `submission_answers_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `textbooks` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`subject` varchar(100) NOT NULL,
|
|
||||||
`grade` varchar(50),
|
|
||||||
`publisher` varchar(100),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `textbooks_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `users` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255),
|
|
||||||
`email` varchar(255) NOT NULL,
|
|
||||||
`emailVerified` timestamp,
|
|
||||||
`image` varchar(255),
|
|
||||||
`password` varchar(255),
|
|
||||||
`phone` varchar(30),
|
|
||||||
`address` varchar(255),
|
|
||||||
`gender` varchar(20),
|
|
||||||
`age` int,
|
|
||||||
`grade_id` varchar(128),
|
|
||||||
`department_id` varchar(128),
|
|
||||||
`onboarded_at` timestamp,
|
|
||||||
`birth_date` date,
|
|
||||||
`guardian_name` varchar(255),
|
|
||||||
`guardian_phone` varchar(20),
|
|
||||||
`guardian_relation` varchar(50),
|
|
||||||
`consent_accepted_at` datetime,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `users_email_unique` UNIQUE(`email`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `users_to_roles` (
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`role_id` varchar(128) NOT NULL,
|
|
||||||
CONSTRAINT `users_to_roles_user_id_role_id_pk` PRIMARY KEY(`user_id`,`role_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `verificationTokens` (
|
|
||||||
`identifier` varchar(255) NOT NULL,
|
|
||||||
`token` varchar(255) NOT NULL,
|
|
||||||
`expires` timestamp NOT NULL,
|
|
||||||
CONSTRAINT `verificationTokens_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `announcements` ADD CONSTRAINT `announcements_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `attendance_records_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `attendance_records_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `attendance_records_recorded_by_users_id_fk` FOREIGN KEY (`recorded_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `ar_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `ar_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_records` ADD CONSTRAINT `ar_rb_fk` FOREIGN KEY (`recorded_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_rules` ADD CONSTRAINT `attendance_rules_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `attendance_rules` ADD CONSTRAINT `atr_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `chapters` ADD CONSTRAINT `chapters_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_schedule` ADD CONSTRAINT `cs_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `class_subject_teachers_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `classes` ADD CONSTRAINT `classes_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `classes` ADD CONSTRAINT `c_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `classes` ADD CONSTRAINT `c_g_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plan_items` ADD CONSTRAINT `course_plan_items_plan_id_course_plans_id_fk` FOREIGN KEY (`plan_id`) REFERENCES `course_plans`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plan_items` ADD CONSTRAINT `cpi_p_fk` FOREIGN KEY (`plan_id`) REFERENCES `course_plans`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `course_plans_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `course_plans_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `course_plans_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `course_plans_created_by_users_id_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `cp_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `cp_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `cp_t_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_plans` ADD CONSTRAINT `cp_cb_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD CONSTRAINT `exams_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `file_attachments` ADD CONSTRAINT `file_attachments_uploader_id_users_id_fk` FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `grade_records_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `grade_records_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `grade_records_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `grade_records_recorded_by_users_id_fk` FOREIGN KEY (`recorded_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `gr_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `gr_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `gr_sub_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grade_records` ADD CONSTRAINT `gr_rb_fk` FOREIGN KEY (`recorded_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grades` ADD CONSTRAINT `g_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grades` ADD CONSTRAINT `g_gh_fk` FOREIGN KEY (`grade_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `grades` ADD CONSTRAINT `g_th_fk` FOREIGN KEY (`teaching_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_sub_fk` FOREIGN KEY (`submission_id`) REFERENCES `homework_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_exam_fk` FOREIGN KEY (`source_exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_creator_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_student_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `message_notifications` ADD CONSTRAINT `message_notifications_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `messages` ADD CONSTRAINT `messages_sender_id_users_id_fk` FOREIGN KEY (`sender_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `messages` ADD CONSTRAINT `messages_receiver_id_users_id_fk` FOREIGN KEY (`receiver_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `notification_preferences` ADD CONSTRAINT `notification_preferences_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `notification_preferences` ADD CONSTRAINT `np_u_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `parent_student_relations` ADD CONSTRAINT `parent_student_relations_parent_id_users_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `parent_student_relations` ADD CONSTRAINT `parent_student_relations_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `parent_student_relations` ADD CONSTRAINT `psr_p_fk` FOREIGN KEY (`parent_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `parent_student_relations` ADD CONSTRAINT `psr_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `password_security` ADD CONSTRAINT `password_security_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `password_security` ADD CONSTRAINT `ps_u_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `questions` ADD CONSTRAINT `questions_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `academic_years_name_idx` ON `academic_years` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `academic_years_active_idx` ON `academic_years` (`is_active`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `account_userId_idx` ON `accounts` (`userId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `ai_provider_idx` ON `ai_providers` (`provider`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `ai_provider_default_idx` ON `ai_providers` (`is_default`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `announcements_author_idx` ON `announcements` (`author_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `announcements_status_idx` ON `announcements` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `announcements_type_idx` ON `announcements` (`type`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `announcements_target_grade_idx` ON `announcements` (`target_grade_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `announcements_target_class_idx` ON `announcements` (`target_class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_student_idx` ON `attendance_records` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_class_idx` ON `attendance_records` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_date_idx` ON `attendance_records` (`date`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_class_date_idx` ON `attendance_records` (`class_id`,`date`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_student_date_idx` ON `attendance_records` (`student_id`,`date`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_schedule_idx` ON `attendance_records` (`schedule_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_records_recorded_by_idx` ON `attendance_records` (`recorded_by`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `attendance_rules_class_idx` ON `attendance_rules` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `audit_logs_user_id_idx` ON `audit_logs` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `audit_logs_module_idx` ON `audit_logs` (`module`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `audit_logs_action_idx` ON `audit_logs` (`action`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `audit_logs_status_idx` ON `audit_logs` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `audit_logs_created_at_idx` ON `audit_logs` (`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `textbook_idx` ON `chapters` (`textbook_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `parent_id_idx` ON `chapters` (`parent_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_enrollments_class_idx` ON `class_enrollments` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_enrollments_student_idx` ON `class_enrollments` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_schedule_class_idx` ON `class_schedule` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_schedule_class_day_idx` ON `class_schedule` (`class_id`,`weekday`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_subject_teachers_class_idx` ON `class_subject_teachers` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_subject_teachers_teacher_idx` ON `class_subject_teachers` (`teacher_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_subject_teachers_subject_id_idx` ON `class_subject_teachers` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `classes_teacher_idx` ON `classes` (`teacher_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `classes_grade_idx` ON `classes` (`grade`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `classes_school_idx` ON `classes` (`school_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `classes_grade_id_idx` ON `classes` (`grade_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `classrooms_name_idx` ON `classrooms` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plan_items_plan_idx` ON `course_plan_items` (`plan_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plan_items_plan_week_idx` ON `course_plan_items` (`plan_id`,`week`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plans_class_idx` ON `course_plans` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plans_teacher_idx` ON `course_plans` (`teacher_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plans_subject_idx` ON `course_plans` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plans_status_idx` ON `course_plans` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_plans_class_subject_idx` ON `course_plans` (`class_id`,`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `data_change_logs_table_name_idx` ON `data_change_logs` (`table_name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `data_change_logs_record_id_idx` ON `data_change_logs` (`record_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `data_change_logs_action_idx` ON `data_change_logs` (`action`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `data_change_logs_changed_by_idx` ON `data_change_logs` (`changed_by`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `data_change_logs_created_at_idx` ON `data_change_logs` (`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `departments_name_idx` ON `departments` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `exam_student_idx` ON `exam_submissions` (`exam_id`,`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `file_attachments_uploader_idx` ON `file_attachments` (`uploader_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `file_attachments_target_idx` ON `file_attachments` (`target_type`,`target_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `file_attachments_created_at_idx` ON `file_attachments` (`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_student_idx` ON `grade_records` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_class_idx` ON `grade_records` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_subject_idx` ON `grade_records` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_exam_idx` ON `grade_records` (`exam_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_class_subject_idx` ON `grade_records` (`class_id`,`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grade_records_recorded_by_idx` ON `grade_records` (`recorded_by`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grades_school_idx` ON `grades` (`school_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grades_school_name_uniq` ON `grades` (`school_id`,`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grades_grade_head_idx` ON `grades` (`grade_head_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `grades_teaching_head_idx` ON `grades` (`teaching_head_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_answer_submission_idx` ON `homework_answers` (`submission_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_answer_submission_question_idx` ON `homework_answers` (`submission_id`,`question_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_questions_assignment_idx` ON `homework_assignment_questions` (`assignment_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_targets_assignment_idx` ON `homework_assignment_targets` (`assignment_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_targets_student_idx` ON `homework_assignment_targets` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_creator_idx` ON `homework_assignments` (`creator_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_source_exam_idx` ON `homework_assignments` (`source_exam_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_status_idx` ON `homework_assignments` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `hw_assignment_student_idx` ON `homework_submissions` (`assignment_id`,`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `parent_id_idx` ON `knowledge_points` (`parent_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `kp_chapter_id_idx` ON `knowledge_points` (`chapter_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `login_logs_user_id_idx` ON `login_logs` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `login_logs_user_email_idx` ON `login_logs` (`user_email`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `login_logs_action_idx` ON `login_logs` (`action`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `login_logs_status_idx` ON `login_logs` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `login_logs_created_at_idx` ON `login_logs` (`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `message_notifications_user_idx` ON `message_notifications` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `message_notifications_is_read_idx` ON `message_notifications` (`is_read`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `message_notifications_user_read_idx` ON `message_notifications` (`user_id`,`is_read`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `message_notifications_created_at_idx` ON `message_notifications` (`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `messages_sender_idx` ON `messages` (`sender_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `messages_receiver_idx` ON `messages` (`receiver_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `messages_is_read_idx` ON `messages` (`is_read`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `messages_parent_idx` ON `messages` (`parent_message_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `messages_receiver_read_idx` ON `messages` (`receiver_id`,`is_read`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `notification_preferences_user_idx` ON `notification_preferences` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `parent_student_relations_parent_idx` ON `parent_student_relations` (`parent_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `parent_student_relations_student_idx` ON `parent_student_relations` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `password_security_user_idx` ON `password_security` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `parent_id_idx` ON `questions` (`parent_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `author_id_idx` ON `questions` (`author_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `kp_idx` ON `questions_to_knowledge_points` (`knowledge_point_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `role_permissions_role_idx` ON `role_permissions` (`role_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schedule_changes_class_idx` ON `schedule_changes` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schedule_changes_status_idx` ON `schedule_changes` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schedule_changes_requested_by_idx` ON `schedule_changes` (`requested_by`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schedule_changes_original_schedule_idx` ON `schedule_changes` (`original_schedule_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `scheduling_rules_class_idx` ON `scheduling_rules` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schools_name_idx` ON `schools` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `schools_code_idx` ON `schools` (`code`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `session_userId_idx` ON `sessions` (`userId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `subjects_name_idx` ON `subjects` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `submission_idx` ON `submission_answers` (`submission_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `users_grade_id_idx` ON `users` (`grade_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `users_department_id_idx` ON `users` (`department_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `user_id_idx` ON `users_to_roles` (`user_id`);
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
CREATE TABLE `course_selections` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`course_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`selection_status` enum('selected','enrolled','waitlist','dropped','rejected') NOT NULL DEFAULT 'selected',
|
|
||||||
`priority` int DEFAULT 1,
|
|
||||||
`selected_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`enrolled_at` timestamp,
|
|
||||||
`dropped_at` timestamp,
|
|
||||||
`lottery_rank` int,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `course_selections_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `elective_courses` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`subject_id` varchar(128),
|
|
||||||
`teacher_id` varchar(128) NOT NULL,
|
|
||||||
`grade_id` varchar(128),
|
|
||||||
`description` text,
|
|
||||||
`capacity` int NOT NULL DEFAULT 30,
|
|
||||||
`enrolled_count` int NOT NULL DEFAULT 0,
|
|
||||||
`classroom` varchar(100),
|
|
||||||
`schedule` varchar(255),
|
|
||||||
`start_date` date,
|
|
||||||
`end_date` date,
|
|
||||||
`selection_start_at` datetime,
|
|
||||||
`selection_end_at` datetime,
|
|
||||||
`status` enum('draft','open','closed','cancelled') NOT NULL DEFAULT 'draft',
|
|
||||||
`selection_mode` enum('fcfs','lottery') NOT NULL DEFAULT 'fcfs',
|
|
||||||
`credit` decimal(3,1) DEFAULT '1.0',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `elective_courses_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `exam_proctoring_events` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`submission_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`exam_id` varchar(128) NOT NULL,
|
|
||||||
`event_type` enum('tab_switch','window_blur','copy_attempt','paste_attempt','right_click','devtools_open','fullscreen_exit','idle_timeout') NOT NULL,
|
|
||||||
`event_detail` text,
|
|
||||||
`occurred_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `exam_proctoring_events_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `knowledge_point_mastery` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`knowledge_point_id` varchar(128) NOT NULL,
|
|
||||||
`mastery_level` decimal(5,2) NOT NULL DEFAULT '0',
|
|
||||||
`total_questions` int NOT NULL DEFAULT 0,
|
|
||||||
`correct_questions` int NOT NULL DEFAULT 0,
|
|
||||||
`last_assessed_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `knowledge_point_mastery_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `learning_diagnostic_reports` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`generated_by` varchar(128),
|
|
||||||
`report_type` enum('individual','class','grade') NOT NULL DEFAULT 'individual',
|
|
||||||
`period` varchar(50),
|
|
||||||
`summary` text,
|
|
||||||
`strengths` json,
|
|
||||||
`weaknesses` json,
|
|
||||||
`recommendations` json,
|
|
||||||
`overall_score` decimal(5,2),
|
|
||||||
`report_status` enum('draft','published','archived') NOT NULL DEFAULT 'draft',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `learning_diagnostic_reports_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `exam_mode` enum('homework','timed','proctored') DEFAULT 'homework';--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `duration_minutes` int;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `shuffle_questions` boolean DEFAULT false;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `allow_late_start` boolean DEFAULT false;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `late_start_grace_minutes` int DEFAULT 0;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exams` ADD `anti_cheat_enabled` boolean DEFAULT false;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_course_id_elective_courses_id_fk` FOREIGN KEY (`course_id`) REFERENCES `elective_courses`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_generated_by_users_id_fk` FOREIGN KEY (`generated_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_selections_course_idx` ON `course_selections` (`course_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_selections_student_idx` ON `course_selections` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `course_selections_status_idx` ON `course_selections` (`selection_status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `elective_courses_teacher_idx` ON `elective_courses` (`teacher_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `elective_courses_subject_idx` ON `elective_courses` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `elective_courses_grade_idx` ON `elective_courses` (`grade_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `elective_courses_status_idx` ON `elective_courses` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `proctoring_submission_idx` ON `exam_proctoring_events` (`submission_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `proctoring_student_idx` ON `exam_proctoring_events` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `proctoring_exam_idx` ON `exam_proctoring_events` (`exam_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `proctoring_event_type_idx` ON `exam_proctoring_events` (`event_type`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `mastery_student_idx` ON `knowledge_point_mastery` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `mastery_kp_idx` ON `knowledge_point_mastery` (`knowledge_point_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `diagnostic_student_idx` ON `learning_diagnostic_reports` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `diagnostic_generated_by_idx` ON `learning_diagnostic_reports` (`generated_by`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `diagnostic_status_idx` ON `learning_diagnostic_reports` (`report_status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `diagnostic_report_type_idx` ON `learning_diagnostic_reports` (`report_type`);
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
CREATE TABLE `lesson_plan_templates` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`name` varchar(100) NOT NULL,
|
|
||||||
`type` varchar(50) NOT NULL,
|
|
||||||
`scope` varchar(50) NOT NULL,
|
|
||||||
`blocks` json NOT NULL,
|
|
||||||
`creator_id` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `lesson_plan_templates_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `lesson_plan_versions` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`plan_id` varchar(128) NOT NULL,
|
|
||||||
`version_no` int NOT NULL,
|
|
||||||
`label` varchar(100),
|
|
||||||
`content` json NOT NULL,
|
|
||||||
`is_auto` boolean NOT NULL DEFAULT false,
|
|
||||||
`creator_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `lesson_plan_versions_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `lesson_plans` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`textbook_id` varchar(128),
|
|
||||||
`chapter_id` varchar(128),
|
|
||||||
`course_plan_item_id` varchar(128),
|
|
||||||
`subject_id` varchar(128),
|
|
||||||
`grade_id` varchar(128),
|
|
||||||
`template_id` varchar(128),
|
|
||||||
`template_name` varchar(100),
|
|
||||||
`content` json NOT NULL,
|
|
||||||
`status` varchar(50) NOT NULL DEFAULT 'draft',
|
|
||||||
`creator_id` varchar(128) NOT NULL,
|
|
||||||
`last_saved_at` timestamp,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `lesson_plans_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plan_templates` ADD CONSTRAINT `lesson_plan_templates_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plan_versions` ADD CONSTRAINT `lesson_plan_versions_plan_id_lesson_plans_id_fk` FOREIGN KEY (`plan_id`) REFERENCES `lesson_plans`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plan_versions` ADD CONSTRAINT `lesson_plan_versions_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plans` ADD CONSTRAINT `lesson_plans_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plans` ADD CONSTRAINT `lesson_plans_chapter_id_chapters_id_fk` FOREIGN KEY (`chapter_id`) REFERENCES `chapters`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plans` ADD CONSTRAINT `lesson_plans_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plans` ADD CONSTRAINT `lesson_plans_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `lesson_plans` ADD CONSTRAINT `lesson_plans_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `lpt_type_creator_idx` ON `lesson_plan_templates` (`type`,`creator_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lpv_plan_version_idx` ON `lesson_plan_versions` (`plan_id`,`version_no`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lpv_plan_created_idx` ON `lesson_plan_versions` (`plan_id`,`created_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lp_creator_idx` ON `lesson_plans` (`creator_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lp_status_idx` ON `lesson_plans` (`status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lp_textbook_chapter_idx` ON `lesson_plans` (`textbook_id`,`chapter_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `lp_subject_grade_idx` ON `lesson_plans` (`subject_id`,`grade_id`);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
CREATE TABLE `class_invitation_codes` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`code` varchar(8) NOT NULL,
|
|
||||||
`class_invitation_code_status` enum('active','disabled','expired','exhausted') NOT NULL DEFAULT 'active',
|
|
||||||
`max_uses` int,
|
|
||||||
`used_count` int NOT NULL DEFAULT 0,
|
|
||||||
`expires_at` timestamp,
|
|
||||||
`created_by` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
`revoked_at` timestamp,
|
|
||||||
`revoked_by` varchar(128),
|
|
||||||
`note` varchar(255),
|
|
||||||
CONSTRAINT `class_invitation_codes_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `class_invitation_codes_code_unique` UNIQUE(`code`),
|
|
||||||
CONSTRAINT `class_invitation_codes_code_idx` UNIQUE(`code`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `system_settings` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`category` varchar(50) NOT NULL,
|
|
||||||
`key` varchar(100) NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`value_type` varchar(20) NOT NULL DEFAULT 'string',
|
|
||||||
`updated_by` varchar(128),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `system_settings_id` PRIMARY KEY(`id`),
|
|
||||||
CONSTRAINT `ss_category_key_idx` UNIQUE(`category`,`key`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `homework_assignments` MODIFY COLUMN `source_exam_id` varchar(128);--> statement-breakpoint
|
|
||||||
ALTER TABLE `learning_diagnostic_reports` MODIFY COLUMN `student_id` varchar(128);--> statement-breakpoint
|
|
||||||
ALTER TABLE `messages` ADD `sender_deleted_at` timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE `messages` ADD `receiver_deleted_at` timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE `notification_preferences` ADD `quiet_hours_enabled` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE `notification_preferences` ADD `quiet_hours_start` varchar(5);--> statement-breakpoint
|
|
||||||
ALTER TABLE `notification_preferences` ADD `quiet_hours_end` varchar(5);--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `class_invitation_codes_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `class_invitation_codes_created_by_users_id_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `cic_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_invitation_codes_class_idx` ON `class_invitation_codes` (`class_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `class_invitation_codes_status_expires_idx` ON `class_invitation_codes` (`class_invitation_code_status`,`expires_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `ss_category_idx` ON `system_settings` (`category`);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE TABLE `knowledge_point_prerequisites` (
|
|
||||||
`knowledge_point_id` varchar(128) NOT NULL,
|
|
||||||
`prerequisite_kp_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `knowledge_point_prerequisites_knowledge_point_id_prerequisite_kp_id_pk` PRIMARY KEY(`knowledge_point_id`,`prerequisite_kp_id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `knowledge_point_prerequisites` ADD CONSTRAINT `kp_prereq_kp_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `knowledge_point_prerequisites` ADD CONSTRAINT `kp_prereq_prereq_fk` FOREIGN KEY (`prerequisite_kp_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `kp_prereq_kp_idx` ON `knowledge_point_prerequisites` (`knowledge_point_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `kp_prereq_prereq_idx` ON `knowledge_point_prerequisites` (`prerequisite_kp_id`);
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
CREATE TABLE `error_book_items` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`source_type` enum('exam','homework','manual') NOT NULL DEFAULT 'manual',
|
|
||||||
`source_id` varchar(128),
|
|
||||||
`student_answer` json,
|
|
||||||
`correct_answer` json,
|
|
||||||
`subject_id` varchar(128),
|
|
||||||
`knowledge_point_ids` json,
|
|
||||||
`error_status` enum('new','learning','mastered','archived') NOT NULL DEFAULT 'new',
|
|
||||||
`mastery_level` int NOT NULL DEFAULT 0,
|
|
||||||
`next_review_at` timestamp,
|
|
||||||
`review_interval` int NOT NULL DEFAULT 1,
|
|
||||||
`review_count` int NOT NULL DEFAULT 0,
|
|
||||||
`correct_streak` int NOT NULL DEFAULT 0,
|
|
||||||
`note` text,
|
|
||||||
`error_tags` json,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT `error_book_items_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `error_book_reviews` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`item_id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`review_result` enum('again','hard','good','easy') NOT NULL,
|
|
||||||
`reviewed_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
`new_interval` int,
|
|
||||||
`new_mastery_level` int,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
|
||||||
CONSTRAINT `error_book_reviews_id` PRIMARY KEY(`id`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `error_book_items` ADD CONSTRAINT `error_book_items_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `error_book_items` ADD CONSTRAINT `error_book_items_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `error_book_reviews` ADD CONSTRAINT `error_book_reviews_item_id_error_book_items_id_fk` FOREIGN KEY (`item_id`) REFERENCES `error_book_items`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE `error_book_reviews` ADD CONSTRAINT `error_book_reviews_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_student_idx` ON `error_book_items` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_student_status_idx` ON `error_book_items` (`student_id`,`error_status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_student_review_idx` ON `error_book_items` (`student_id`,`next_review_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_question_idx` ON `error_book_items` (`question_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_subject_idx` ON `error_book_items` (`subject_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_item_source_idx` ON `error_book_items` (`source_type`,`source_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_review_item_idx` ON `error_book_reviews` (`item_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_review_student_idx` ON `error_book_reviews` (`student_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `eb_review_student_reviewed_idx` ON `error_book_reviews` (`student_id`,`reviewed_at`);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE TABLE `notification_logs` (
|
|
||||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`channel` varchar(32) NOT NULL,
|
|
||||||
`status` varchar(16) NOT NULL,
|
|
||||||
`message_id` varchar(255),
|
|
||||||
`error` text,
|
|
||||||
`sent_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
CONSTRAINT `notification_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
|
||||||
);
|
|
||||||
CREATE INDEX `notification_logs_user_idx` ON `notification_logs`(`user_id`);
|
|
||||||
CREATE INDEX `notification_logs_channel_idx` ON `notification_logs`(`channel`);
|
|
||||||
CREATE INDEX `notification_logs_status_idx` ON `notification_logs`(`status`);
|
|
||||||
CREATE INDEX `notification_logs_sent_at_idx` ON `notification_logs`(`sent_at`);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) DEFAULT 'normal' NOT NULL;
|
|
||||||
ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean DEFAULT false NOT NULL;
|
|
||||||
CREATE INDEX `message_notifications_priority_idx` ON `message_notifications`(`priority`);
|
|
||||||
CREATE INDEX `message_notifications_user_archived_idx` ON `message_notifications`(`user_id`, `is_archived`);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
ALTER TABLE `messages` ADD COLUMN `is_starred` boolean DEFAULT false NOT NULL;
|
|
||||||
CREATE INDEX `messages_receiver_starred_idx` ON `messages`(`receiver_id`, `is_starred`);
|
|
||||||
|
|
||||||
CREATE TABLE `message_drafts` (
|
|
||||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`receiver_id` varchar(128),
|
|
||||||
`subject` varchar(255),
|
|
||||||
`content` text,
|
|
||||||
`parent_message_id` varchar(128),
|
|
||||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE now() NOT NULL,
|
|
||||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
CONSTRAINT `message_drafts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action,
|
|
||||||
CONSTRAINT `message_drafts_receiver_id_users_id_fk` FOREIGN KEY (`receiver_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
|
||||||
);
|
|
||||||
CREATE INDEX `message_drafts_user_idx` ON `message_drafts`(`user_id`);
|
|
||||||
CREATE INDEX `message_drafts_user_updated_idx` ON `message_drafts`(`user_id`, `updated_at`);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean DEFAULT false NOT NULL;
|
|
||||||
CREATE INDEX `announcements_status_pinned_idx` ON `announcements`(`status`, `is_pinned`);
|
|
||||||
|
|
||||||
CREATE TABLE `announcement_reads` (
|
|
||||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
|
||||||
`announcement_id` varchar(128) NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`read_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
CONSTRAINT `announcement_reads_announcement_id_announcements_id_fk` FOREIGN KEY (`announcement_id`) REFERENCES `announcements`(`id`) ON DELETE cascade ON UPDATE no action,
|
|
||||||
CONSTRAINT `announcement_reads_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
|
||||||
);
|
|
||||||
CREATE INDEX `announcement_reads_announcement_idx` ON `announcement_reads`(`announcement_id`);
|
|
||||||
CREATE INDEX `announcement_reads_user_idx` ON `announcement_reads`(`user_id`);
|
|
||||||
CREATE UNIQUE INDEX `announcement_reads_unique_idx` ON `announcement_reads`(`announcement_id`, `user_id`);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE `grade_record_answers` (
|
|
||||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
|
||||||
`grade_record_id` varchar(128) NOT NULL,
|
|
||||||
`question_id` varchar(128) NOT NULL,
|
|
||||||
`score` decimal(6, 2) NOT NULL,
|
|
||||||
`full_score` decimal(6, 2) NOT NULL,
|
|
||||||
`feedback` text,
|
|
||||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
CONSTRAINT `gra_gr_fk` FOREIGN KEY (`grade_record_id`) REFERENCES `grade_records`(`id`) ON DELETE cascade ON UPDATE no action,
|
|
||||||
CONSTRAINT `gra_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action
|
|
||||||
);
|
|
||||||
CREATE INDEX `grade_record_answers_record_idx` ON `grade_record_answers`(`grade_record_id`);
|
|
||||||
CREATE INDEX `grade_record_answers_question_idx` ON `grade_record_answers`(`question_id`);
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
-- 0011_rbac_role_flags_and_seed.sql
|
|
||||||
-- RBAC: add is_system/is_enabled columns to roles table and seed role_permissions.
|
|
||||||
|
|
||||||
-- 1. Add is_system and is_enabled columns to roles table.
|
|
||||||
ALTER TABLE `roles`
|
|
||||||
ADD COLUMN `is_system` boolean DEFAULT false NOT NULL,
|
|
||||||
ADD COLUMN `is_enabled` boolean DEFAULT true NOT NULL;
|
|
||||||
|
|
||||||
-- 2. Mark the 6 builtin roles as system roles.
|
|
||||||
UPDATE `roles` SET `is_system` = true
|
|
||||||
WHERE `name` IN ('admin', 'teacher', 'student', 'parent', 'grade_head', 'teaching_head');
|
|
||||||
|
|
||||||
-- 3. Seed role_permissions for builtin roles.
|
|
||||||
-- Uses INSERT IGNORE so re-running is safe (role_permissions has a composite PK).
|
|
||||||
-- Permissions are resolved from ROLE_PERMISSIONS_SEED in src/shared/lib/permissions.ts.
|
|
||||||
|
|
||||||
-- admin
|
|
||||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission`)
|
|
||||||
SELECT id, 'exam:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:duplicate' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:publish' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:ai_generate' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'homework:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'homework:grade' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'question:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'question:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'question:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'question:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'textbook:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'textbook:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'textbook:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'textbook:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:enroll' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'class:schedule' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'school:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'grade:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'user:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'user:profile_update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'ai:chat' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'ai:configure' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'settings:admin' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'audit_log:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'announcement:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'announcement:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'grade_record:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'grade_record:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'course_plan:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'course_plan:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'attendance:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'attendance:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'message:send' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'message:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'message:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'schedule:auto' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'schedule:adjust' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'elective:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'elective:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:proctor' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'exam:proctor:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'diagnostic:manage' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'diagnostic:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'lesson_plan:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'lesson_plan:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'lesson_plan:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'lesson_plan:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'lesson_plan:publish' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'file:upload' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'file:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'file:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'dashboard:admin_read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'error_book:analytics_read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'adaptive_practice:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'role:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'role:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'role:update' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'role:delete' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'role:assign' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'permission:read' FROM `roles` WHERE `name` = 'admin';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- v4-P2-3: Add gradeId column to learning_diagnostic_reports for grade-level diagnostic reports
|
|
||||||
ALTER TABLE `learning_diagnostic_reports` ADD COLUMN `grade_id` varchar(128);--> statement-breakpoint
|
|
||||||
ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX `diagnostic_grade_idx` ON `learning_diagnostic_reports` (`grade_id`);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- P2-4: 消息撤回功能(2 分钟窗口)
|
|
||||||
ALTER TABLE `messages` ADD COLUMN `recalled_at` timestamp;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- P2-6: 快捷回复模板
|
|
||||||
CREATE TABLE `message_templates` (
|
|
||||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
|
||||||
`user_id` varchar(128) NOT NULL,
|
|
||||||
`title` varchar(100) NOT NULL,
|
|
||||||
`content` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE now() NOT NULL,
|
|
||||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
|
||||||
CONSTRAINT `message_templates_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
|
||||||
);
|
|
||||||
CREATE INDEX `message_templates_user_idx` ON `message_templates`(`user_id`);
|
|
||||||
CREATE INDEX `message_templates_user_sort_idx` ON `message_templates`(`user_id`, `sort_order`);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- P2-2: 群组消息(教师→全班家长,fan-out on write)
|
|
||||||
ALTER TABLE `messages` ADD COLUMN `group_message_id` varchar(128);
|
|
||||||
CREATE INDEX `messages_group_message_idx` ON `messages`(`group_message_id`);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- audit-P2-3: 注册邀请码表(admin 批量生成,注册时校验 + 自动分配角色/班级)
|
|
||||||
CREATE TABLE IF NOT EXISTS `invitation_codes` (
|
|
||||||
`id` serial PRIMARY KEY,
|
|
||||||
`code` varchar(64) NOT NULL,
|
|
||||||
`email` varchar(255),
|
|
||||||
`role` varchar(50) NOT NULL,
|
|
||||||
`class_id` varchar(128),
|
|
||||||
`notes` varchar(500),
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`created_by` varchar(128),
|
|
||||||
`expires_at` timestamp NULL,
|
|
||||||
`used_by` varchar(128),
|
|
||||||
`used_at` timestamp NULL,
|
|
||||||
UNIQUE KEY `invitation_codes_code_unique` (`code`)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX `invitation_codes_code_idx` ON `invitation_codes`(`code`);
|
|
||||||
CREATE INDEX `invitation_codes_email_idx` ON `invitation_codes`(`email`);
|
|
||||||
CREATE INDEX `invitation_codes_used_by_idx` ON `invitation_codes`(`used_by`);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
-- P2-5: 消息举报 + 用户屏蔽
|
|
||||||
-- 1. message_reports: 举报记录(reporterId → messageId / reportedUserId)
|
|
||||||
-- 2. user_blocks: 用户屏蔽关系(blockerId → blockedId,唯一约束防重复)
|
|
||||||
|
|
||||||
CREATE TABLE `message_reports` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`reporter_id` varchar(128) NOT NULL,
|
|
||||||
`message_id` varchar(128) NOT NULL,
|
|
||||||
`reported_user_id` varchar(128) NOT NULL,
|
|
||||||
`reason` varchar(50) NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`status` varchar(20) NOT NULL DEFAULT 'pending',
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
INDEX `message_reports_reporter_idx` (`reporter_id`),
|
|
||||||
INDEX `message_reports_message_idx` (`message_id`),
|
|
||||||
INDEX `message_reports_reported_user_idx` (`reported_user_id`),
|
|
||||||
INDEX `message_reports_status_idx` (`status`),
|
|
||||||
CONSTRAINT `message_reports_reporter_id_users_id_fk` FOREIGN KEY (`reporter_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `message_reports_message_id_messages_id_fk` FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `message_reports_reported_user_id_users_id_fk` FOREIGN KEY (`reported_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE `user_blocks` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`blocker_id` varchar(128) NOT NULL,
|
|
||||||
`blocked_id` varchar(128) NOT NULL,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE INDEX `user_blocks_blocker_blocked_uniq` (`blocker_id`, `blocked_id`),
|
|
||||||
INDEX `user_blocks_blocker_idx` (`blocker_id`),
|
|
||||||
CONSTRAINT `user_blocks_blocker_id_users_id_fk` FOREIGN KEY (`blocker_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `user_blocks_blocked_id_users_id_fk` FOREIGN KEY (`blocked_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-- P2-10: 跨设备草稿同步
|
|
||||||
-- 1. 新增 device_id 列(标识来源设备)
|
|
||||||
-- 2. 新增 version 列(乐观锁,冲突检测)
|
|
||||||
-- 3. 新增 (user_id, parent_message_id) 索引(按会话查询最新版本草稿)
|
|
||||||
|
|
||||||
ALTER TABLE `message_drafts` ADD COLUMN `device_id` varchar(128);
|
|
||||||
ALTER TABLE `message_drafts` ADD COLUMN `version` int NOT NULL DEFAULT 1;
|
|
||||||
CREATE INDEX `message_drafts_user_parent_idx` ON `message_drafts`(`user_id`, `parent_message_id`);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- L-1: 考勤状态细分 + 原因字段
|
|
||||||
-- 1. 扩展 attendance_records.status 枚举:新增 'school_activity'(校内活动请假,如运动会、研学、社团活动)
|
|
||||||
-- 2. 新增 reason 字段:记录缺勤/迟到/早退/请假的具体原因(最多 255 字符),与 remark 区分
|
|
||||||
-- - remark:教师备注(自由文本,可记录点名时观察到的情况)
|
|
||||||
-- - reason:标准化原因(用于统计与家长通知,如"病假-感冒"、"家中有事"等)
|
|
||||||
|
|
||||||
ALTER TABLE `attendance_records`
|
|
||||||
MODIFY COLUMN `status`
|
|
||||||
ENUM('present','absent','late','early_leave','excused','school_activity') NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE `attendance_records` ADD COLUMN `reason` varchar(255);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- L-3:出勤率阈值预警
|
|
||||||
-- 新增 attendance_rate_threshold(出勤率阈值,默认 90%)
|
|
||||||
-- 新增 consecutive_absence_threshold(连续缺勤阈值,默认 3 次)
|
|
||||||
ALTER TABLE `attendance_rules`
|
|
||||||
ADD COLUMN `attendance_rate_threshold` int DEFAULT 90,
|
|
||||||
ADD COLUMN `consecutive_absence_threshold` int DEFAULT 3;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
-- 0020_leave_requests.sql
|
|
||||||
-- L-5 在线请假流程:创建 leave_requests 表。
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `leave_requests` (
|
|
||||||
`id` varchar(128) NOT NULL,
|
|
||||||
`student_id` varchar(128) NOT NULL,
|
|
||||||
`requester_id` varchar(128) NOT NULL,
|
|
||||||
`class_id` varchar(128) NOT NULL,
|
|
||||||
`leave_type` enum('sick','personal','family','other') NOT NULL,
|
|
||||||
`start_date` date NOT NULL,
|
|
||||||
`end_date` date NOT NULL,
|
|
||||||
`reason` text NOT NULL,
|
|
||||||
`status` enum('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending',
|
|
||||||
`reviewer_id` varchar(128) DEFAULT NULL,
|
|
||||||
`review_comment` text,
|
|
||||||
`reviewed_at` timestamp NULL DEFAULT NULL,
|
|
||||||
`attendance_synced` boolean NOT NULL DEFAULT false,
|
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `leave_requests_student_idx` (`student_id`),
|
|
||||||
KEY `leave_requests_requester_idx` (`requester_id`),
|
|
||||||
KEY `leave_requests_class_idx` (`class_id`),
|
|
||||||
KEY `leave_requests_status_idx` (`status`),
|
|
||||||
KEY `leave_requests_reviewer_idx` (`reviewer_id`),
|
|
||||||
KEY `leave_requests_class_status_idx` (`class_id`, `status`),
|
|
||||||
KEY `leave_requests_student_status_idx` (`student_id`, `status`),
|
|
||||||
CONSTRAINT `lr_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes` (`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `lr_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `lr_rq_fk` FOREIGN KEY (`requester_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT `lr_rv_fk` FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- 为 builtin 角色注入新权限(INSERT IGNORE 幂等)
|
|
||||||
-- admin:三个权限全有
|
|
||||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission`)
|
|
||||||
SELECT id, 'leave_request:create' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
UNION ALL SELECT id, 'leave_request:review' FROM `roles` WHERE `name` = 'admin'
|
|
||||||
-- teacher:read + review(班主任审批本班请假)
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'teacher'
|
|
||||||
UNION ALL SELECT id, 'leave_request:review' FROM `roles` WHERE `name` = 'teacher'
|
|
||||||
-- parent:create + read(代子女提交、查看子女请假记录)
|
|
||||||
UNION ALL SELECT id, 'leave_request:create' FROM `roles` WHERE `name` = 'parent'
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'parent'
|
|
||||||
-- student:create + read(本人提交、查看本人请假记录)
|
|
||||||
UNION ALL SELECT id, 'leave_request:create' FROM `roles` WHERE `name` = 'student'
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'student'
|
|
||||||
-- grade_head / teaching_head:read + review(年级主任/教务主任可审批)
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'grade_head'
|
|
||||||
UNION ALL SELECT id, 'leave_request:review' FROM `roles` WHERE `name` = 'grade_head'
|
|
||||||
UNION ALL SELECT id, 'leave_request:read' FROM `roles` WHERE `name` = 'teaching_head'
|
|
||||||
UNION ALL SELECT id, 'leave_request:review' FROM `roles` WHERE `name` = 'teaching_head';
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- 0021_attendance_period.sql
|
|
||||||
-- L-6 节次考勤:为 attendance_records 表添加 period 字段。
|
|
||||||
--
|
|
||||||
-- 节次枚举:
|
|
||||||
-- morning_reading 早读
|
|
||||||
-- morning 上午
|
|
||||||
-- afternoon 下午
|
|
||||||
-- evening 晚自习
|
|
||||||
-- full_day 全日(默认值,向后兼容:null 也表示全日)
|
|
||||||
--
|
|
||||||
-- 向后兼容策略:
|
|
||||||
-- - 新增 period 列为 NULLABLE,已有数据保持 NULL(视为 full_day)
|
|
||||||
-- - 新记录由应用层显式写入 period;未指定时写入 'full_day'
|
|
||||||
-- - 查询层 period IS NULL OR period = 'full_day' 均视为全日记录
|
|
||||||
|
|
||||||
ALTER TABLE `attendance_records`
|
|
||||||
ADD COLUMN `period` ENUM('morning_reading','morning','afternoon','evening','full_day') NULL DEFAULT NULL AFTER `date`;
|
|
||||||
|
|
||||||
-- 复合索引:班级 + 日期 + 节次,支持按节次查询班级当日点名
|
|
||||||
CREATE INDEX `attendance_records_class_date_period_idx` ON `attendance_records` (`class_id`, `date`, `period`);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,92 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1781676504560,
|
"when": 1783064688766,
|
||||||
"tag": "0000_perfect_pestilence",
|
"tag": "0000_aberrant_deathstrike",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1781679978738,
|
|
||||||
"tag": "0001_heavy_sage",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 2,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1781789296745,
|
|
||||||
"tag": "0002_tiny_lionheart",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 3,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782118370256,
|
|
||||||
"tag": "0003_diagnostic_student_nullable",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 4,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782136411839,
|
|
||||||
"tag": "0004_calm_sandman",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 5,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782141546400,
|
|
||||||
"tag": "0005_messy_pride",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 6,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782374400000,
|
|
||||||
"tag": "0013_message_recall",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 7,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782374500000,
|
|
||||||
"tag": "0014_message_templates",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782374600000,
|
|
||||||
"tag": "0015_group_messages",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 9,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782445200000,
|
|
||||||
"tag": "0018_attendance_status_reason",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782448800000,
|
|
||||||
"tag": "0019_attendance_warning_thresholds",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 11,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782452400000,
|
|
||||||
"tag": "0020_leave_requests",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 12,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1782456000000,
|
|
||||||
"tag": "0021_attendance_period",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// mysql2 是 Node.js 服务端驱动,依赖 net/tls 等 Node 内置模块,
|
||||||
|
// 不应被 Turbopack 打包到 server bundle 中,标记为外部包在运行时 require。
|
||||||
|
serverExternalPackages: ["mysql2"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
329
package-lock.json
generated
329
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-image": "^3.27.1",
|
||||||
"@tiptap/extension-placeholder": "^3.15.3",
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
"@tiptap/pm": "^3.15.3",
|
"@tiptap/pm": "^3.15.3",
|
||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
@@ -5664,12 +5666,6 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remirror/core-constants": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.9",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||||
@@ -6683,16 +6679,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.15.3",
|
"version": "3.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
|
||||||
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
|
"integrity": "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tiptap/pm": "^3.15.3"
|
"@tiptap/pm": "3.27.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-blockquote": {
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
@@ -6874,6 +6870,19 @@
|
|||||||
"@tiptap/pm": "^3.15.3"
|
"@tiptap/pm": "^3.15.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "3.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.27.1.tgz",
|
||||||
|
"integrity": "sha512-+JTahgQT+NxiGjduaB3qJVyhU/wh4m3pVkht1Earioku2bm/apj5Lb8rSowa/NJYP3B+oQgV/V4YLw5dtDgBoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.27.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-italic": {
|
"node_modules/@tiptap/extension-italic": {
|
||||||
"version": "3.15.3",
|
"version": "3.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
|
||||||
@@ -7037,29 +7046,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "3.15.3",
|
"version": "3.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.1.tgz",
|
||||||
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
|
"integrity": "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
|
||||||
"prosemirror-commands": "^1.6.2",
|
"prosemirror-commands": "^1.6.2",
|
||||||
"prosemirror-dropcursor": "^1.8.1",
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
"prosemirror-gapcursor": "^1.3.2",
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
"prosemirror-inputrules": "^1.4.0",
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
"prosemirror-keymap": "^1.2.2",
|
"prosemirror-keymap": "^1.2.3",
|
||||||
"prosemirror-markdown": "^1.13.1",
|
"prosemirror-model": "^1.25.7",
|
||||||
"prosemirror-menu": "^1.2.4",
|
|
||||||
"prosemirror-model": "^1.24.1",
|
|
||||||
"prosemirror-schema-basic": "^1.2.3",
|
|
||||||
"prosemirror-schema-list": "^1.5.0",
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
"prosemirror-state": "^1.4.3",
|
"prosemirror-state": "^1.4.4",
|
||||||
"prosemirror-tables": "^1.6.4",
|
"prosemirror-tables": "^1.8.0",
|
||||||
"prosemirror-trailing-node": "^3.0.0",
|
"prosemirror-transform": "^1.12.0",
|
||||||
"prosemirror-transform": "^1.10.2",
|
"prosemirror-view": "^1.41.8"
|
||||||
"prosemirror-view": "^1.38.1"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -7129,6 +7133,12 @@
|
|||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "25.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||||
|
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -8159,6 +8169,15 @@
|
|||||||
"d3-zoom": "^3.0.0"
|
"d3-zoom": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/accessor-fn": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -8653,6 +8672,16 @@
|
|||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bezier-js": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bidi-js": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@@ -8913,6 +8942,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvas-color-tracker": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinycolor2": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ccount": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||||
@@ -9145,12 +9186,6 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crelt": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -9218,6 +9253,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-binarytree": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
@@ -9258,6 +9299,22 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-force-3d": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-binarytree": "1",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-octree": "1",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-format": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
@@ -9279,6 +9336,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-octree": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/d3-path": {
|
"node_modules/d3-path": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
@@ -9288,6 +9351,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-scale": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@@ -9304,6 +9376,19 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-selection": {
|
"node_modules/d3-selection": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
@@ -10177,6 +10262,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -10830,6 +10916,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/float-tooltip": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"preact": "10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -10846,6 +10946,32 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/force-graph": {
|
||||||
|
"version": "1.51.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
|
||||||
|
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tweenjs/tween.js": "18 - 25",
|
||||||
|
"accessor-fn": "1",
|
||||||
|
"bezier-js": "3 - 6",
|
||||||
|
"canvas-color-tracker": "^1.3",
|
||||||
|
"d3-array": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-force-3d": "2 - 3",
|
||||||
|
"d3-scale": "1 - 4",
|
||||||
|
"d3-scale-chromatic": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-zoom": "2 - 3",
|
||||||
|
"float-tooltip": "^1.7",
|
||||||
|
"index-array-by": "1",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"lodash-es": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
||||||
@@ -11484,6 +11610,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/index-array-by": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -12121,6 +12256,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jerrypick": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -12144,7 +12288,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -12331,6 +12474,18 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kapsule": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash-es": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -12754,6 +12909,12 @@
|
|||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
@@ -12860,7 +13021,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -14403,7 +14563,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -15088,7 +15247,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -15115,15 +15273,6 @@
|
|||||||
"prosemirror-transform": "^1.0.0"
|
"prosemirror-transform": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-collab": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"prosemirror-state": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prosemirror-commands": {
|
"node_modules/prosemirror-commands": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
@@ -15201,36 +15350,15 @@
|
|||||||
"prosemirror-model": "^1.25.0"
|
"prosemirror-model": "^1.25.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-menu": {
|
|
||||||
"version": "1.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
|
|
||||||
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"crelt": "^1.0.0",
|
|
||||||
"prosemirror-commands": "^1.0.0",
|
|
||||||
"prosemirror-history": "^1.0.0",
|
|
||||||
"prosemirror-state": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prosemirror-model": {
|
"node_modules/prosemirror-model": {
|
||||||
"version": "1.25.4",
|
"version": "1.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-schema-basic": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"prosemirror-model": "^1.25.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prosemirror-schema-list": {
|
"node_modules/prosemirror-schema-list": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
@@ -15266,37 +15394,22 @@
|
|||||||
"prosemirror-view": "^1.41.4"
|
"prosemirror-view": "^1.41.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-trailing-node": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@remirror/core-constants": "3.0.0",
|
|
||||||
"escape-string-regexp": "^4.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"prosemirror-model": "^1.22.1",
|
|
||||||
"prosemirror-state": "^1.4.2",
|
|
||||||
"prosemirror-view": "^1.33.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prosemirror-transform": {
|
"node_modules/prosemirror-transform": {
|
||||||
"version": "1.10.5",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
"integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.21.0"
|
"prosemirror-model": "^1.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-view": {
|
"node_modules/prosemirror-view": {
|
||||||
"version": "1.41.4",
|
"version": "1.42.0",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz",
|
||||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
"integrity": "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.25.8",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
"prosemirror-transform": "^1.1.0"
|
"prosemirror-transform": "^1.1.0"
|
||||||
}
|
}
|
||||||
@@ -15379,6 +15492,23 @@
|
|||||||
"react": "^19.2.1"
|
"react": "^19.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-force-graph-2d": {
|
||||||
|
"version": "1.29.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
|
||||||
|
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"force-graph": "^1.51",
|
||||||
|
"prop-types": "15",
|
||||||
|
"react-kapsule": "^2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.69.0",
|
"version": "7.69.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz",
|
||||||
@@ -15401,6 +15531,21 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-kapsule": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jerrypick": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -16766,6 +16911,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinycolor2": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-image": "^3.27.1",
|
||||||
"@tiptap/extension-placeholder": "^3.15.3",
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
"@tiptap/pm": "^3.15.3",
|
"@tiptap/pm": "^3.15.3",
|
||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
|||||||
37
scripts/check-attendance-rules.mjs
Normal file
37
scripts/check-attendance-rules.mjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 检查 attendance_rules 表的实际列结构。
|
||||||
|
*/
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = await mysql.createConnection(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
console.log("=== attendance_rules 列结构 ===");
|
||||||
|
const [cols] = await conn.query(`SHOW COLUMNS FROM \`attendance_rules\``);
|
||||||
|
console.table(
|
||||||
|
cols.map((c) => ({
|
||||||
|
Field: c.Field,
|
||||||
|
Type: c.Type,
|
||||||
|
Null: c.Null,
|
||||||
|
Default: c.Default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n=== leave_requests 表是否存在 ===");
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`SHOW TABLES LIKE 'leave_requests'`);
|
||||||
|
console.log(rows.length > 0 ? "存在" : "不存在");
|
||||||
|
} catch (err) {
|
||||||
|
console.log("查询失败:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("FATAL:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
35
scripts/check-db-state.mjs
Normal file
35
scripts/check-db-state.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 检查重置后数据库的实际状态。
|
||||||
|
*/
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = await mysql.createConnection(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
console.log("=== 数据库中的所有表 ===");
|
||||||
|
const [tables] = await conn.query(`SHOW TABLES`);
|
||||||
|
if (tables.length === 0) {
|
||||||
|
console.log("(空数据库)");
|
||||||
|
} else {
|
||||||
|
console.table(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 class_enrollments 存在,查看其结构
|
||||||
|
try {
|
||||||
|
console.log("\n=== class_enrollments 结构 ===");
|
||||||
|
const [cols] = await conn.query(`SHOW COLUMNS FROM \`class_enrollments\``);
|
||||||
|
console.table(cols);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("class_enrollments 表不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("FATAL:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
const f = "src/shared/i18n/messages/zh-CN/attendance.json";
|
|
||||||
let c = fs.readFileSync(f, "utf8");
|
|
||||||
const oldStr = `"noDataDescription": "当前班级在所选时间范围内暂无考勤记录。"
|
|
||||||
},
|
|
||||||
"errors": {`;
|
|
||||||
const newStr = `"noDataDescription": "当前班级在所选时间范围内暂无考勤记录。"
|
|
||||||
},
|
|
||||||
"correlation": {
|
|
||||||
"title": "考勤与成绩关联分析",
|
|
||||||
"description": "{className} · {start} 至 {end} · 出勤率与平均成绩的相关性",
|
|
||||||
"noData": "暂无关联分析数据",
|
|
||||||
"noDataDescription": "所选班级在时间范围内无考勤或成绩记录,无法进行关联分析。",
|
|
||||||
"noStudents": "暂无学生数据",
|
|
||||||
"noStudentsDescription": "该班级在时间范围内无考勤与成绩匹配的学生。",
|
|
||||||
"correlationCoefficient": "相关系数 r",
|
|
||||||
"notAvailable": "数据不足",
|
|
||||||
"scatterTitle": "出勤率-成绩散点图",
|
|
||||||
"scatterSeries": "学生",
|
|
||||||
"studentDetails": "学生明细",
|
|
||||||
"riskLevelLabel": "风险等级",
|
|
||||||
"attendanceRate": "出勤率",
|
|
||||||
"averageScore": "平均成绩",
|
|
||||||
"absentCount": "缺勤次数",
|
|
||||||
"gradeRecordCount": "成绩记录数",
|
|
||||||
"riskCounts": {
|
|
||||||
"high": "高风险学生数",
|
|
||||||
"medium": "中风险学生数",
|
|
||||||
"low": "低风险学生数"
|
|
||||||
},
|
|
||||||
"riskLevel": {
|
|
||||||
"high": "高风险",
|
|
||||||
"medium": "中风险",
|
|
||||||
"low": "低风险"
|
|
||||||
},
|
|
||||||
"interpretation": {
|
|
||||||
"strong_negative": "强负相关:出勤率越低,成绩越低",
|
|
||||||
"weak_negative": "弱负相关:出勤率与成绩有一定负相关",
|
|
||||||
"negligible": "无明显线性相关",
|
|
||||||
"weak_positive": "弱正相关:出勤率与成绩有一定正相关",
|
|
||||||
"strong_positive": "强正相关:出勤率越高,成绩越高",
|
|
||||||
"insufficient_data": "数据不足,无法计算相关性"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"errors": {`;
|
|
||||||
if (!c.includes(oldStr)) {
|
|
||||||
console.log("OLD NOT FOUND");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
c = c.replace(oldStr, newStr);
|
|
||||||
fs.writeFileSync(f, c);
|
|
||||||
console.log("Done");
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""
|
|
||||||
检查学情诊断、错题分析、公告、消息 4 个模块的实际渲染状态
|
|
||||||
截取截图 + 捕获控制台错误 + 检查页面内容
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:3000"
|
|
||||||
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
|
||||||
TEACHER_PASSWORD = "123456"
|
|
||||||
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots")
|
|
||||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
ROUTES = [
|
|
||||||
("/teacher/diagnostic", "学情诊断"),
|
|
||||||
("/teacher/error-book", "错题分析"),
|
|
||||||
("/announcements", "公告"),
|
|
||||||
("/messages", "消息"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
# Login
|
|
||||||
print(">>> 登录...")
|
|
||||||
page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded")
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
page.locator('input[name="email"]').fill(TEACHER_EMAIL)
|
|
||||||
page.locator('input[type="password"]').first.fill(TEACHER_PASSWORD)
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
if (form) {
|
|
||||||
const event = new Event('submit', { cancelable: true, bubbles: true });
|
|
||||||
form.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}""")
|
|
||||||
page.wait_for_timeout(5000)
|
|
||||||
print(f"登录后 URL: {page.url}")
|
|
||||||
|
|
||||||
for route, name in ROUTES:
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f">>> 测试: {name} ({route})")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
console_errors = []
|
|
||||||
console_warnings = []
|
|
||||||
|
|
||||||
def on_console(msg):
|
|
||||||
if msg.type == "error":
|
|
||||||
console_errors.append(msg.text)
|
|
||||||
elif msg.type == "warning":
|
|
||||||
console_warnings.append(msg.text)
|
|
||||||
|
|
||||||
def on_page_error(err):
|
|
||||||
console_errors.append(f"PageError: {str(err)[:300]}")
|
|
||||||
|
|
||||||
page.on("console", on_console)
|
|
||||||
page.on("pageerror", on_page_error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded")
|
|
||||||
page.wait_for_timeout(3000) # Wait for client-side JS to execute
|
|
||||||
|
|
||||||
print(f" HTTP Status: {response.status if response else 'N/A'}")
|
|
||||||
print(f" Final URL: {page.url}")
|
|
||||||
|
|
||||||
# Check for error indicators on page
|
|
||||||
body_text = page.locator("body").text_content() or ""
|
|
||||||
print(f" Body text length: {len(body_text)}")
|
|
||||||
|
|
||||||
# Check for common error patterns
|
|
||||||
error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600')
|
|
||||||
error_count = error_elements.count()
|
|
||||||
if error_count > 0:
|
|
||||||
print(f" Error elements on page: {error_count}")
|
|
||||||
for i in range(min(error_count, 5)):
|
|
||||||
try:
|
|
||||||
text = error_elements.nth(i).text_content()
|
|
||||||
if text and text.strip():
|
|
||||||
print(f" [{i}] {text.strip()[:200]}")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check for loading spinners still visible
|
|
||||||
spinners = page.locator('.animate-spin, [role="status"], .loading')
|
|
||||||
spinner_count = spinners.count()
|
|
||||||
if spinner_count > 0:
|
|
||||||
print(f" Loading spinners still visible: {spinner_count}")
|
|
||||||
|
|
||||||
# Check for empty state
|
|
||||||
if "暂无" in body_text or "No data" in body_text or "没有" in body_text:
|
|
||||||
# Find the context
|
|
||||||
for keyword in ["暂无", "No data", "没有"]:
|
|
||||||
idx = body_text.find(keyword)
|
|
||||||
if idx >= 0:
|
|
||||||
context_text = body_text[max(0, idx-30):idx+80]
|
|
||||||
print(f" Empty state: ...{context_text}...")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check for "加载失败" or "error" text
|
|
||||||
for keyword in ["加载失败", "load", "error", "错误", "失败", "Error"]:
|
|
||||||
if keyword.lower() in body_text.lower():
|
|
||||||
idx = body_text.lower().find(keyword.lower())
|
|
||||||
context_text = body_text[max(0, idx-30):idx+100]
|
|
||||||
print(f" Found '{keyword}': ...{context_text}...")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Take screenshot
|
|
||||||
screenshot_name = route.replace("/", "_").strip("_") + ".png"
|
|
||||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
|
||||||
page.screenshot(path=screenshot_path, full_page=True)
|
|
||||||
print(f" Screenshot: {screenshot_path}")
|
|
||||||
|
|
||||||
# Print console errors
|
|
||||||
if console_errors:
|
|
||||||
print(f"\n Console Errors ({len(console_errors)}):")
|
|
||||||
for err in console_errors[:10]:
|
|
||||||
print(f" - {err[:300]}")
|
|
||||||
else:
|
|
||||||
print(f" Console Errors: 0")
|
|
||||||
|
|
||||||
if console_warnings:
|
|
||||||
print(f"\n Console Warnings ({len(console_warnings)}):")
|
|
||||||
for w in console_warnings[:5]:
|
|
||||||
print(f" - {w[:200]}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" EXCEPTION: {type(e).__name__}: {str(e)[:300]}")
|
|
||||||
screenshot_name = route.replace("/", "_").strip("_") + "_error.png"
|
|
||||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
|
||||||
try:
|
|
||||||
page.screenshot(path=screenshot_path, full_page=True)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
page.remove_listener("console", on_console)
|
|
||||||
page.remove_listener("pageerror", on_page_error)
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("测试完成!")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
111
scripts/test-modules.py
Normal file
111
scripts/test-modules.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
验证 6 个模块的页面是否能正常加载。
|
||||||
|
检测方式:
|
||||||
|
1. HTTP 状态码(非 200 即失败)
|
||||||
|
2. JavaScript pageerror
|
||||||
|
3. 可见错误边界元素([data-error-boundary], [role="alert"] 中的错误文本)
|
||||||
|
注意:不检查 content 中的 i18n 文本(翻译 JSON 会被误判)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://localhost:3000"
|
||||||
|
EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
||||||
|
PASSWORD = "123456"
|
||||||
|
|
||||||
|
MODULES = [
|
||||||
|
("考勤统计", "/teacher/attendance/stats"),
|
||||||
|
("学情诊断", "/teacher/diagnostic"),
|
||||||
|
("错题分析", "/teacher/error-book"),
|
||||||
|
("选修课", "/teacher/elective"),
|
||||||
|
("公告", "/announcements"),
|
||||||
|
("消息", "/messages"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def login(page):
|
||||||
|
page.goto(f"{BASE}/login", wait_until="domcontentloaded", timeout=15000)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
email_input = page.locator('input[type="email"]')
|
||||||
|
if email_input.count() == 0:
|
||||||
|
email_input = page.locator('input[name="email"]')
|
||||||
|
email_input.fill(EMAIL)
|
||||||
|
|
||||||
|
pwd_input = page.locator('input[type="password"]')
|
||||||
|
pwd_input.fill(PASSWORD)
|
||||||
|
|
||||||
|
page.get_by_role("button", name="登录").click()
|
||||||
|
page.wait_for_url("**/dashboard**", timeout=15000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
results = []
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
page = browser.new_page()
|
||||||
|
all_errors = []
|
||||||
|
page.on("pageerror", lambda err: all_errors.append(str(err)[:300]))
|
||||||
|
|
||||||
|
print("登录中...")
|
||||||
|
login(page)
|
||||||
|
print(f"登录成功,URL: {page.url}")
|
||||||
|
|
||||||
|
for name, path in MODULES:
|
||||||
|
url = f"{BASE}{path}"
|
||||||
|
all_errors.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(4000)
|
||||||
|
|
||||||
|
http_status = response.status if response else 0
|
||||||
|
|
||||||
|
# 检查可见错误边界(排除 i18n JSON 文本)
|
||||||
|
# 只查找实际渲染的错误提示元素
|
||||||
|
visible_error = ""
|
||||||
|
error_locators = [
|
||||||
|
'text="发生未知错误"',
|
||||||
|
'text="Something went wrong"',
|
||||||
|
'[data-error-boundary]',
|
||||||
|
]
|
||||||
|
for sel in error_locators:
|
||||||
|
loc = page.locator(sel).first
|
||||||
|
if loc.count() > 0 and loc.is_visible():
|
||||||
|
visible_error = sel
|
||||||
|
break
|
||||||
|
|
||||||
|
status = "FAIL"
|
||||||
|
error_info = ""
|
||||||
|
if http_status != 200:
|
||||||
|
status = "FAIL"
|
||||||
|
error_info = f"HTTP {http_status}"
|
||||||
|
elif len(all_errors) > 0:
|
||||||
|
status = "FAIL"
|
||||||
|
error_info = "PageError: " + " | ".join(all_errors[:2])
|
||||||
|
elif visible_error:
|
||||||
|
status = "FAIL"
|
||||||
|
error_info = f"可见错误: {visible_error}"
|
||||||
|
else:
|
||||||
|
status = "PASS"
|
||||||
|
|
||||||
|
results.append((name, status, error_info))
|
||||||
|
print(f"[{status}] {name}: {error_info or 'OK'} (HTTP {http_status})")
|
||||||
|
except Exception as e:
|
||||||
|
results.append((name, "FAIL", str(e)[:100]))
|
||||||
|
print(f"[FAIL] {name}: {str(e)[:100]}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print("\n=== 汇总 ===")
|
||||||
|
pass_count = sum(1 for _, s, _ in results if s == "PASS")
|
||||||
|
fail_count = sum(1 for _, s, _ in results if s == "FAIL")
|
||||||
|
print(f"总计: {pass_count} PASS, {fail_count} FAIL")
|
||||||
|
|
||||||
|
sys.exit(0 if fail_count == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""
|
|
||||||
教师端全功能 Web 测试 (Post-Audit)
|
|
||||||
测试范围: 所有教师端页面路由 + 详情页发现 + 控制台错误捕获
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:3000"
|
|
||||||
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
|
||||||
TEACHER_PASSWORD = "123456"
|
|
||||||
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots")
|
|
||||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# 教师端所有路由
|
|
||||||
TEACHER_ROUTES = [
|
|
||||||
{"category": "Dashboard", "routes": ["/teacher/dashboard"]},
|
|
||||||
{"category": "Textbooks", "routes": ["/teacher/textbooks"]},
|
|
||||||
{"category": "Questions", "routes": ["/teacher/questions"]},
|
|
||||||
{"category": "Exams", "routes": ["/teacher/exams", "/teacher/exams/all", "/teacher/exams/create"]},
|
|
||||||
{"category": "Homework", "routes": ["/teacher/homework", "/teacher/homework/assignments", "/teacher/homework/assignments/create", "/teacher/homework/submissions"]},
|
|
||||||
{"category": "Grades", "routes": ["/teacher/grades", "/teacher/grades/entry", "/teacher/grades/stats", "/teacher/grades/analytics"]},
|
|
||||||
{"category": "Classes", "routes": ["/teacher/classes", "/teacher/classes/my", "/teacher/classes/students", "/teacher/classes/schedule"]},
|
|
||||||
{"category": "Course Plans", "routes": ["/teacher/course-plans"]},
|
|
||||||
{"category": "Lesson Plans", "routes": ["/teacher/lesson-plans", "/teacher/lesson-plans/new"]},
|
|
||||||
{"category": "Attendance", "routes": ["/teacher/attendance", "/teacher/attendance/sheet", "/teacher/attendance/stats"]},
|
|
||||||
{"category": "Schedule Changes", "routes": ["/teacher/schedule-changes"]},
|
|
||||||
{"category": "Diagnostic", "routes": ["/teacher/diagnostic"]},
|
|
||||||
{"category": "Elective", "routes": ["/teacher/elective"]},
|
|
||||||
{"category": "Error Book", "routes": ["/teacher/error-book"]},
|
|
||||||
]
|
|
||||||
|
|
||||||
# 详情页发现模式
|
|
||||||
DETAIL_PATTERNS = [
|
|
||||||
{"category": "Textbooks Detail", "listRoute": "/teacher/textbooks", "linkPattern": "/teacher/textbooks/"},
|
|
||||||
{"category": "Classes Detail", "listRoute": "/teacher/classes/my", "linkPattern": "/teacher/classes/my/"},
|
|
||||||
{"category": "Course Plans Detail", "listRoute": "/teacher/course-plans", "linkPattern": "/teacher/course-plans/"},
|
|
||||||
{"category": "Lesson Plans Detail", "listRoute": "/teacher/lesson-plans", "linkPattern": "/teacher/lesson-plans/"},
|
|
||||||
{"category": "Homework Detail", "listRoute": "/teacher/homework/assignments", "linkPattern": "/teacher/homework/assignments/"},
|
|
||||||
{"category": "Exams Detail", "listRoute": "/teacher/exams/all", "linkPattern": "/teacher/exams/"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestResult:
|
|
||||||
def __init__(self, url, category):
|
|
||||||
self.url = url
|
|
||||||
self.category = category
|
|
||||||
self.status = "unknown"
|
|
||||||
self.http_status = None
|
|
||||||
self.final_url = url
|
|
||||||
self.errors = []
|
|
||||||
self.warnings = []
|
|
||||||
self.screenshot = None
|
|
||||||
|
|
||||||
|
|
||||||
all_results = []
|
|
||||||
|
|
||||||
|
|
||||||
def add_result(result):
|
|
||||||
all_results.append(result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_page(page, route, category):
|
|
||||||
result = TestResult(route, category)
|
|
||||||
console_errors = []
|
|
||||||
|
|
||||||
def on_console(msg):
|
|
||||||
if msg.type == "error":
|
|
||||||
console_errors.append(msg.text)
|
|
||||||
|
|
||||||
page.on("console", on_console)
|
|
||||||
|
|
||||||
# Also capture page errors (uncaught exceptions)
|
|
||||||
def on_page_error(err):
|
|
||||||
console_errors.append(f"PageError: {str(err)[:200]}")
|
|
||||||
|
|
||||||
page.on("pageerror", on_page_error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded")
|
|
||||||
page.wait_for_timeout(2000) # Wait for JS to execute and render
|
|
||||||
|
|
||||||
result.http_status = response.status if response else None
|
|
||||||
result.final_url = page.url
|
|
||||||
|
|
||||||
http_status = result.http_status
|
|
||||||
final_url = result.final_url
|
|
||||||
|
|
||||||
if http_status and http_status >= 500:
|
|
||||||
result.status = "failed"
|
|
||||||
result.errors.append(f"HTTP {http_status} error")
|
|
||||||
elif http_status and http_status >= 400:
|
|
||||||
result.status = "warning"
|
|
||||||
result.warnings.append(f"HTTP {http_status} error")
|
|
||||||
elif "/login" in final_url:
|
|
||||||
result.status = "failed"
|
|
||||||
result.errors.append("Redirected to login - auth issue")
|
|
||||||
elif final_url.rstrip("/").endswith("/error") or "/500" in final_url:
|
|
||||||
result.status = "failed"
|
|
||||||
result.errors.append("Redirected to error page")
|
|
||||||
else:
|
|
||||||
result.status = "passed"
|
|
||||||
|
|
||||||
# Check for error elements on page
|
|
||||||
error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500')
|
|
||||||
error_count = error_elements.count()
|
|
||||||
if error_count > 0:
|
|
||||||
for i in range(min(error_count, 3)):
|
|
||||||
try:
|
|
||||||
text = error_elements.nth(i).text_content()
|
|
||||||
if text and text.strip():
|
|
||||||
result.warnings.append(f"Error text: {text.strip()[:100]}")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if page is empty
|
|
||||||
body_text = page.locator("body").text_content() or ""
|
|
||||||
if len(body_text.strip()) < 50:
|
|
||||||
result.warnings.append("Page appears empty")
|
|
||||||
|
|
||||||
if console_errors:
|
|
||||||
result.errors.extend(console_errors[:5])
|
|
||||||
|
|
||||||
# Take screenshot for failed/warning pages
|
|
||||||
if result.status in ("failed", "warning"):
|
|
||||||
screenshot_name = route.replace("/", "_").strip("_") + ".png"
|
|
||||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
|
||||||
try:
|
|
||||||
page.screenshot(path=screenshot_path, full_page=True)
|
|
||||||
result.screenshot = screenshot_path
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
icon = "PASS" if result.status == "passed" else ("WARN" if result.status == "warning" else "FAIL")
|
|
||||||
print(f" [{icon}] {result.status} (HTTP {result.http_status}) - {route}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
result.status = "failed"
|
|
||||||
err_msg = str(e)[:200]
|
|
||||||
result.errors.append(f"Exception: {err_msg}")
|
|
||||||
print(f" [FAIL] ERROR: {err_msg[:100]} - {route}")
|
|
||||||
|
|
||||||
# Try screenshot even on failure
|
|
||||||
try:
|
|
||||||
screenshot_name = route.replace("/", "_").strip("_") + "_error.png"
|
|
||||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
|
||||||
page.screenshot(path=screenshot_path, full_page=True)
|
|
||||||
result.screenshot = screenshot_path
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
page.remove_listener("console", on_console)
|
|
||||||
page.remove_listener("pageerror", on_page_error)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def discover_detail_links(page, list_route, link_pattern):
|
|
||||||
urls = []
|
|
||||||
try:
|
|
||||||
page.goto(f"{BASE_URL}{list_route}", timeout=25000, wait_until="domcontentloaded")
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
|
|
||||||
links = page.locator(f'a[href*="{link_pattern}"]')
|
|
||||||
count = links.count()
|
|
||||||
seen = set()
|
|
||||||
for i in range(min(count, 10)):
|
|
||||||
href = links.nth(i).get_attribute("href")
|
|
||||||
if href and link_pattern in href and href not in seen:
|
|
||||||
# Avoid the list page itself
|
|
||||||
if href != list_route:
|
|
||||||
seen.add(href)
|
|
||||||
urls.append(href)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: Failed to discover links on {list_route}: {e}")
|
|
||||||
|
|
||||||
return urls[:3] # Max 3 detail pages per category
|
|
||||||
|
|
||||||
|
|
||||||
def generate_report():
|
|
||||||
lines = []
|
|
||||||
passed = sum(1 for r in all_results if r.status == "passed")
|
|
||||||
failed = sum(1 for r in all_results if r.status == "failed")
|
|
||||||
warnings = sum(1 for r in all_results if r.status == "warning")
|
|
||||||
total = len(all_results)
|
|
||||||
|
|
||||||
lines.append("# 教师端 Web 功能测试报告 (Post-Audit)")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"> 测试日期: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
lines.append("> 测试范围: 所有教师端页面功能 (审计修复后)")
|
|
||||||
lines.append("> 测试工具: Playwright + Chromium (Python)")
|
|
||||||
lines.append(f"> 测试账号: {TEACHER_EMAIL}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## 一、测试概览")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("| 指标 | 数值 |")
|
|
||||||
lines.append("|------|------|")
|
|
||||||
lines.append(f"| 总测试页面数 | {total} |")
|
|
||||||
lines.append(f"| PASS | {passed} |")
|
|
||||||
lines.append(f"| FAIL | {failed} |")
|
|
||||||
lines.append(f"| WARN | {warnings} |")
|
|
||||||
pass_rate = f"{(passed / total * 100):.1f}%" if total > 0 else "N/A"
|
|
||||||
lines.append(f"| 通过率 | {pass_rate} |")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## 二、页面测试详情")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Group by category
|
|
||||||
by_category = {}
|
|
||||||
for r in all_results:
|
|
||||||
if r.category not in by_category:
|
|
||||||
by_category[r.category] = []
|
|
||||||
by_category[r.category].append(r)
|
|
||||||
|
|
||||||
for category, results in by_category.items():
|
|
||||||
lines.append(f"### {category}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("| 页面 | HTTP状态 | 结果 | 备注 |")
|
|
||||||
lines.append("|------|----------|------|------|")
|
|
||||||
|
|
||||||
for r in results:
|
|
||||||
icon = "PASS" if r.status == "passed" else ("WARN" if r.status == "warning" else "FAIL")
|
|
||||||
notes = []
|
|
||||||
if r.final_url != f"{BASE_URL}{r.url}" and r.final_url != r.url:
|
|
||||||
notes.append(f"重定向: {r.final_url}")
|
|
||||||
if r.errors:
|
|
||||||
notes.append(f"错误: {'; '.join(r.errors[:2])}")
|
|
||||||
if r.warnings:
|
|
||||||
notes.append(f"警告: {'; '.join(r.warnings[:2])}")
|
|
||||||
note_str = "<br>".join(notes) if notes else "-"
|
|
||||||
|
|
||||||
lines.append(f"| {icon} `{r.url}` | {r.http_status or '-'} | {r.status} | {note_str} |")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Failed details
|
|
||||||
failed_results = [r for r in all_results if r.status == "failed"]
|
|
||||||
if failed_results:
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## 三、失败页面详情")
|
|
||||||
lines.append("")
|
|
||||||
for r in failed_results:
|
|
||||||
lines.append(f"### FAIL `{r.url}`")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"- **分类**: {r.category}")
|
|
||||||
lines.append(f"- **HTTP状态**: {r.http_status or '-'}")
|
|
||||||
if r.final_url != r.url:
|
|
||||||
lines.append(f"- **重定向**: {r.final_url}")
|
|
||||||
if r.errors:
|
|
||||||
lines.append("- **错误信息**:")
|
|
||||||
for e in r.errors:
|
|
||||||
lines.append(f" - {e}")
|
|
||||||
if r.screenshot:
|
|
||||||
lines.append(f"- **截图**: {r.screenshot}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Warning details
|
|
||||||
warning_results = [r for r in all_results if r.status == "warning"]
|
|
||||||
if warning_results:
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## 四、警告页面")
|
|
||||||
lines.append("")
|
|
||||||
for r in warning_results:
|
|
||||||
lines.append(f"### WARN `{r.url}`")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"- **分类**: {r.category}")
|
|
||||||
if r.warnings:
|
|
||||||
for w in r.warnings:
|
|
||||||
lines.append(f" - {w}")
|
|
||||||
if r.screenshot:
|
|
||||||
lines.append(f"- **截图**: {r.screenshot}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"*报告自动生成于 {time.strftime('%Y-%m-%d %H:%M:%S')}*")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
# ====== Step 1: Login ======
|
|
||||||
print("\n>>> 登录教师账号...")
|
|
||||||
page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded")
|
|
||||||
page.wait_for_timeout(3000) # Wait for form to fully render
|
|
||||||
|
|
||||||
# Fill login form
|
|
||||||
email_input = page.locator('input[name="email"]')
|
|
||||||
if email_input.count() == 0:
|
|
||||||
email_input = page.locator('input[type="email"]').first
|
|
||||||
|
|
||||||
email_input.fill(TEACHER_EMAIL)
|
|
||||||
page.locator('input[type="password"], input[name="password"]').first.fill(TEACHER_PASSWORD)
|
|
||||||
|
|
||||||
# Submit form via JavaScript (avoids Next.js dev overlay intercepting clicks)
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
if (form) {
|
|
||||||
const event = new Event('submit', { cancelable: true, bubbles: true });
|
|
||||||
form.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}""")
|
|
||||||
|
|
||||||
page.wait_for_timeout(5000) # Wait for redirect after login
|
|
||||||
|
|
||||||
current_url = page.url
|
|
||||||
print(f"登录后 URL: {current_url}")
|
|
||||||
|
|
||||||
if "/login" in current_url:
|
|
||||||
print("FAIL: 登录失败,仍在登录页!")
|
|
||||||
# Take screenshot of login failure
|
|
||||||
page.screenshot(path=os.path.join(SCREENSHOT_DIR, "login_failure.png"), full_page=True)
|
|
||||||
browser.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
print("PASS: 登录成功!")
|
|
||||||
|
|
||||||
# ====== Step 2: Test all routes ======
|
|
||||||
print("\n>>> 测试所有教师端路由...")
|
|
||||||
for group in TEACHER_ROUTES:
|
|
||||||
category = group["category"]
|
|
||||||
routes = group["routes"]
|
|
||||||
print(f"\n === {category} ===")
|
|
||||||
for route in routes:
|
|
||||||
print(f" 测试: {route}")
|
|
||||||
result = test_single_page(page, route, category)
|
|
||||||
add_result(result)
|
|
||||||
|
|
||||||
# ====== Step 3: Discover and test detail pages ======
|
|
||||||
print("\n\n>>> 发现详情页链接...")
|
|
||||||
for pattern in DETAIL_PATTERNS:
|
|
||||||
category = pattern["category"]
|
|
||||||
list_route = pattern["listRoute"]
|
|
||||||
link_pattern = pattern["linkPattern"]
|
|
||||||
print(f"\n 发现: {category} (from {list_route})")
|
|
||||||
detail_urls = discover_detail_links(page, list_route, link_pattern)
|
|
||||||
if detail_urls:
|
|
||||||
print(f" 找到 {len(detail_urls)} 个详情页")
|
|
||||||
for detail_url in detail_urls:
|
|
||||||
result = test_single_page(page, detail_url, category)
|
|
||||||
add_result(result)
|
|
||||||
else:
|
|
||||||
print(f" 未发现详情页链接")
|
|
||||||
|
|
||||||
# ====== Step 4: Generate report ======
|
|
||||||
report = generate_report()
|
|
||||||
bugs_dir = os.path.join(os.path.dirname(__file__), "..", "bugs")
|
|
||||||
os.makedirs(bugs_dir, exist_ok=True)
|
|
||||||
output_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.md")
|
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(report)
|
|
||||||
|
|
||||||
# Also save JSON results
|
|
||||||
json_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.json")
|
|
||||||
json_data = []
|
|
||||||
for r in all_results:
|
|
||||||
json_data.append({
|
|
||||||
"url": r.url,
|
|
||||||
"category": r.category,
|
|
||||||
"status": r.status,
|
|
||||||
"http_status": r.http_status,
|
|
||||||
"final_url": r.final_url,
|
|
||||||
"errors": r.errors,
|
|
||||||
"warnings": r.warnings,
|
|
||||||
})
|
|
||||||
with open(json_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
passed = sum(1 for r in all_results if r.status == "passed")
|
|
||||||
failed = sum(1 for r in all_results if r.status == "failed")
|
|
||||||
warnings = sum(1 for r in all_results if r.status == "warning")
|
|
||||||
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"测试完成: 总计 {len(all_results)}, PASS {passed}, FAIL {failed}, WARN {warnings}")
|
|
||||||
print(f"报告已写入: {output_path}")
|
|
||||||
print(f"JSON 结果: {json_path}")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -35,14 +35,11 @@ export default async function AnnouncementsPage({
|
|||||||
PAGE_SIZE
|
PAGE_SIZE
|
||||||
)
|
)
|
||||||
|
|
||||||
// 构建分页 URL:保留 ?status= 过滤参数(与 AnnouncementList 的过滤机制一致)
|
// v3: 不再将 buildPageHref 函数传给 Client Component(Server Component 不能传函数)。
|
||||||
const buildPageHref = (targetPage: number): string => {
|
// 改为传 basePath + statusFilter,由 AnnouncementPagination 在客户端自行构建 URL。
|
||||||
const params = new URLSearchParams()
|
const statusFilter = sp.status === "published" || sp.status === "draft" || sp.status === "archived"
|
||||||
if (sp.status) params.set("status", sp.status)
|
? sp.status
|
||||||
if (targetPage > 1) params.set("page", String(targetPage))
|
: "all"
|
||||||
const qs = params.toString()
|
|
||||||
return qs ? `/announcements?${qs}` : "/announcements"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnnouncementsServiceProvider>
|
<AnnouncementsServiceProvider>
|
||||||
@@ -56,12 +53,13 @@ export default async function AnnouncementsPage({
|
|||||||
<AnnouncementList
|
<AnnouncementList
|
||||||
announcements={items}
|
announcements={items}
|
||||||
detailHrefPrefix="/announcements"
|
detailHrefPrefix="/announcements"
|
||||||
initialStatus={sp.status === "published" || sp.status === "draft" || sp.status === "archived" ? sp.status : "all"}
|
initialStatus={statusFilter}
|
||||||
pagination={{
|
pagination={{
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
buildPageHref,
|
basePath: "/announcements",
|
||||||
|
statusFilter,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function StudentErrorBookListClient({
|
|||||||
studentId,
|
studentId,
|
||||||
}: StudentErrorBookListClientProps): JSX.Element {
|
}: StudentErrorBookListClientProps): JSX.Element {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const tPractice = useTranslations("practice")
|
const tPractice = useTranslations("practice")
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function StudentStudyPathError({
|
export default function StudentStudyPathError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="student" />
|
return <RouteErrorBoundary reset={reset} namespace="student" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function StudentTextbookDetailError({
|
export default function StudentTextbookDetailError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function StudentTextbooksError({
|
export default function StudentTextbooksError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ import { ReportList } from "@/modules/diagnostic/components/report-list"
|
|||||||
import {
|
import {
|
||||||
DiagnosticServiceProvider,
|
DiagnosticServiceProvider,
|
||||||
} from "@/modules/diagnostic/services/diagnostic-service-context"
|
} from "@/modules/diagnostic/services/diagnostic-service-context"
|
||||||
import { defaultDiagnosticService } from "@/modules/diagnostic/services/default-diagnostic-service"
|
|
||||||
import { createMonitoredDiagnosticService } from "@/modules/diagnostic/services/monitored-diagnostic-service"
|
|
||||||
import {
|
import {
|
||||||
DiagnosticMonitorProvider,
|
DiagnosticMonitorProvider,
|
||||||
} from "@/modules/diagnostic/services/diagnostic-monitor-context"
|
} from "@/modules/diagnostic/services/diagnostic-monitor-context"
|
||||||
import { noopDiagnosticMonitor } from "@/modules/diagnostic/services/diagnostic-monitor"
|
|
||||||
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
|
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -64,11 +61,8 @@ export default async function TeacherDiagnosticPage({
|
|||||||
// v2-P2-2: 移除客户端 filter,DataScope 过滤已在 data-access 层完成
|
// v2-P2-2: 移除客户端 filter,DataScope 过滤已在 data-access 层完成
|
||||||
// class_members scope 的学生已通过 filters.studentId 在 data-access 层过滤
|
// class_members scope 的学生已通过 filters.studentId 在 data-access 层过滤
|
||||||
|
|
||||||
// v2-P2-7: 包装默认服务以添加监控埋点(默认 no-op,生产环境可注入真实实现)
|
// v2-P2-7: service 组装移至 DiagnosticServiceProvider(客户端),
|
||||||
const monitoredService = createMonitoredDiagnosticService(
|
// 避免 server component 创建含函数的对象传给 client component。
|
||||||
defaultDiagnosticService,
|
|
||||||
noopDiagnosticMonitor,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
@@ -78,8 +72,8 @@ export default async function TeacherDiagnosticPage({
|
|||||||
{t("title.teacherReportListDesc")}
|
{t("title.teacherReportListDesc")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DiagnosticMonitorProvider monitor={noopDiagnosticMonitor}>
|
<DiagnosticMonitorProvider>
|
||||||
<DiagnosticServiceProvider service={monitoredService}>
|
<DiagnosticServiceProvider>
|
||||||
<ReportList reports={reports.reports} />
|
<ReportList reports={reports.reports} />
|
||||||
</DiagnosticServiceProvider>
|
</DiagnosticServiceProvider>
|
||||||
</DiagnosticMonitorProvider>
|
</DiagnosticMonitorProvider>
|
||||||
|
|||||||
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export default function HeatmapError() {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.title")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal file
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function HeatmapLoading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[320px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[80px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[200px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal file
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { getKnowledgePointsByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
|
||||||
|
import { CurriculumHeatmap } from "@/modules/lesson-preparation/components/curriculum-heatmap";
|
||||||
|
import type { PlanKpLink } from "@/modules/lesson-preparation/lib/curriculum-coverage";
|
||||||
|
import type { LessonPlanDocument } from "@/modules/lesson-preparation/types";
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-20 T4:教师端课标热力图页面。
|
||||||
|
*
|
||||||
|
* 展示教师所有课案对所选教材知识点的覆盖情况。
|
||||||
|
* 从教师所有课案中提取 knowledgePointIds,统计覆盖率。
|
||||||
|
*/
|
||||||
|
export default async function HeatmapPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
|
||||||
|
// 教师所有课案(含草稿和已发布)
|
||||||
|
const [plans, textbooks] = await Promise.all([
|
||||||
|
getLessonPlans({}, ctx.dataScope, ctx.userId),
|
||||||
|
getTextbooks(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 仅保留有教材的课案
|
||||||
|
const plansWithTextbook = plans.filter((p) => p.textbookId);
|
||||||
|
|
||||||
|
// 默认展示教师第一本教材(按使用频率)
|
||||||
|
const textbookCounts = new Map<string, number>();
|
||||||
|
for (const p of plansWithTextbook) {
|
||||||
|
if (p.textbookId) {
|
||||||
|
textbookCounts.set(p.textbookId, (textbookCounts.get(p.textbookId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const defaultTextbookId =
|
||||||
|
[...textbookCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? textbooks[0]?.id ?? "";
|
||||||
|
|
||||||
|
if (!defaultTextbookId || textbooks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.title")}
|
||||||
|
description={t("heatmap.selectTextbook")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该教材的所有知识点
|
||||||
|
const allKps = await getKnowledgePointsByTextbookId(defaultTextbookId);
|
||||||
|
|
||||||
|
// 提取该教材下所有课案的知识点关联
|
||||||
|
const planLinks: PlanKpLink[] = [];
|
||||||
|
const chapterNames: Record<string, string> = {};
|
||||||
|
for (const plan of plansWithTextbook.filter((p) => p.textbookId === defaultTextbookId)) {
|
||||||
|
try {
|
||||||
|
const fullPlan = await getLessonPlanById(plan.id, ctx.userId);
|
||||||
|
if (!fullPlan) continue;
|
||||||
|
const doc = fullPlan.content as LessonPlanDocument;
|
||||||
|
const kpSet = new Set<string>();
|
||||||
|
for (const node of doc.nodes) {
|
||||||
|
if (!isRecord(node.data)) continue;
|
||||||
|
const dataKps = (node.data as { knowledgePointIds?: unknown }).knowledgePointIds;
|
||||||
|
if (Array.isArray(dataKps)) {
|
||||||
|
for (const kp of dataKps) {
|
||||||
|
if (typeof kp === "string") kpSet.add(kp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kpSet.size > 0) {
|
||||||
|
planLinks.push({ planId: plan.id, knowledgePointIds: [...kpSet] });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 单个课案加载失败不阻塞整体
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节名映射(从 textbooks 模块获取)
|
||||||
|
// 此处简化:使用知识点自身的 chapterId,章节名从 textbooks data-access 获取
|
||||||
|
// 为避免 N+1 查询,此处仅用 chapterId 作为 key,章节名由前端未知章节兜底
|
||||||
|
// 完整实现可在 textbooks data-access 添加批量查询函数
|
||||||
|
for (const kp of allKps) {
|
||||||
|
if (kp.chapterId) {
|
||||||
|
chapterNames[kp.chapterId] = kp.chapterId; // 占位,实际应查询章节名
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6" aria-hidden="true" />
|
||||||
|
{t("heatmap.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("heatmap.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allKps.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.noData")}
|
||||||
|
description={t("heatmap.description")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CurriculumHeatmap
|
||||||
|
allKps={allKps}
|
||||||
|
planLinks={planLinks}
|
||||||
|
chapterNames={chapterNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export default function LibraryError() {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("library.title")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal file
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function LibraryLoading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[320px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[160px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal file
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, Copy } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { duplicateLessonPlanFormAction } from "@/modules/lesson-preparation/actions";
|
||||||
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-12 R4:校内课案库。
|
||||||
|
*
|
||||||
|
* 展示本校其他教师分享的已发布课案,支持一键复制为自己的课案。
|
||||||
|
* 复用现有 getLessonPlans 数据访问(scope 已限定可见范围),
|
||||||
|
* 在页面层过滤掉当前用户自己创建的课案。
|
||||||
|
*/
|
||||||
|
export default async function LibraryPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
|
||||||
|
const allItems = await getLessonPlans(
|
||||||
|
{ status: "published" },
|
||||||
|
ctx.dataScope,
|
||||||
|
ctx.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 过滤掉当前用户自己创建的课案,仅展示他人分享的
|
||||||
|
const libraryItems = allItems.filter((item) => item.creatorId !== ctx.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6" aria-hidden="true" />
|
||||||
|
{t("library.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("library.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{libraryItems.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("library.empty")}
|
||||||
|
description={t("library.description")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{libraryItems.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="border border-outline-variant rounded-lg p-4 bg-surface flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-base line-clamp-2">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/lesson-plans/${item.id}/edit`}
|
||||||
|
className="hover:text-primary"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1 text-xs text-on-surface-variant">
|
||||||
|
{item.subjectName && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
|
||||||
|
{item.subjectName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.gradeName && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
|
||||||
|
{item.gradeName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.textbookTitle && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest truncate max-w-[120px]">
|
||||||
|
{item.textbookTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-2">
|
||||||
|
{t("library.byCreator", {
|
||||||
|
creator: item.creatorName ?? t("library.noCreator"),
|
||||||
|
})}
|
||||||
|
{" · "}
|
||||||
|
{formatDateTime(item.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={duplicateLessonPlanFormAction}>
|
||||||
|
<input type="hidden" name="planId" value={item.id} />
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="w-full">
|
||||||
|
<Copy className="h-3.5 w-3.5 mr-1" aria-hidden="true" />
|
||||||
|
{t("library.fork")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function TeacherTextbookDetailError({
|
export default function TeacherTextbookDetailError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteError } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
|
||||||
export default function TeacherTextbooksError({
|
export default function TeacherTextbooksError({
|
||||||
error,
|
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ThemeProvider } from "@/shared/components/theme-provider";
|
|||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
import { AuthSessionProvider } from "@/shared/components/auth-session-provider";
|
import { AuthSessionProvider } from "@/shared/components/auth-session-provider";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -46,6 +47,10 @@ export default async function RootLayout({
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
// v3: SSR 期间获取 session,传给 AuthSessionProvider,
|
||||||
|
// 避免 next-auth useSession 在 SSR/CSR 之间返回不同 status 导致 hydration mismatch。
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
@@ -59,7 +64,7 @@ export default async function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthSessionProvider>
|
<AuthSessionProvider session={session}>
|
||||||
<NuqsAdapter>{children}</NuqsAdapter>
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
</AuthSessionProvider>
|
</AuthSessionProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ type Filter = "all" | AnnouncementStatus
|
|||||||
* P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`(Server Component 安全)。
|
* P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`(Server Component 安全)。
|
||||||
* P1-2: 用户端(canManage=false)时调用 `getReadStatus` 批量获取已读状态,
|
* P1-2: 用户端(canManage=false)时调用 `getReadStatus` 批量获取已读状态,
|
||||||
* 传入卡片做已读/未读视觉区分。
|
* 传入卡片做已读/未读视觉区分。
|
||||||
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`,
|
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`。
|
||||||
* 通过 `buildPageHref` 回调生成页码 URL(与 `?status=` 过滤查询参数合并)。
|
* v3: pagination 不再接收 `buildPageHref` 函数(Server Component 不能传函数),
|
||||||
|
* 改为传 `basePath` + `statusFilter`,由 AnnouncementPagination 自行构建 URL。
|
||||||
*/
|
*/
|
||||||
export function AnnouncementList({
|
export function AnnouncementList({
|
||||||
announcements,
|
announcements,
|
||||||
@@ -54,7 +55,8 @@ export function AnnouncementList({
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
total: number
|
total: number
|
||||||
buildPageHref: (page: number) => string
|
basePath: string
|
||||||
|
statusFilter?: string
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("announcements")
|
const t = useTranslations("announcements")
|
||||||
@@ -152,7 +154,8 @@ export function AnnouncementList({
|
|||||||
page={pagination.page}
|
page={pagination.page}
|
||||||
pageSize={pagination.pageSize}
|
pageSize={pagination.pageSize}
|
||||||
total={pagination.total}
|
total={pagination.total}
|
||||||
buildHref={pagination.buildPageHref}
|
basePath={pagination.basePath}
|
||||||
|
statusFilter={pagination.statusFilter}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,18 +19,23 @@ import { cn } from "@/shared/lib/utils"
|
|||||||
* - a11y:上一页/下一页按钮使用 aria-label,当前页用 aria-current="page"
|
* - a11y:上一页/下一页按钮使用 aria-label,当前页用 aria-current="page"
|
||||||
* - 边界处理:total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
|
* - 边界处理:total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
|
||||||
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
|
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
|
||||||
|
*
|
||||||
|
* v3: 不再接收 `buildHref` 函数 prop(Server Component 不能传函数给 Client Component)。
|
||||||
|
* 改为接收 `basePath` + `statusFilter`,在客户端自行构建分页 URL。
|
||||||
*/
|
*/
|
||||||
export function AnnouncementPagination({
|
export function AnnouncementPagination({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
buildHref,
|
basePath,
|
||||||
|
statusFilter,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
total: number
|
total: number
|
||||||
buildHref: (page: number) => string
|
basePath: string
|
||||||
|
statusFilter?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("announcements")
|
const t = useTranslations("announcements")
|
||||||
@@ -38,6 +43,15 @@ export function AnnouncementPagination({
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const currentPage = Math.min(Math.max(1, page), totalPages)
|
const currentPage = Math.min(Math.max(1, page), totalPages)
|
||||||
|
|
||||||
|
// 客户端构建分页 URL:保留 ?status= 过滤参数
|
||||||
|
const buildHref = (targetPage: number): string => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
|
||||||
|
if (targetPage > 1) params.set("page", String(targetPage))
|
||||||
|
const qs = params.toString()
|
||||||
|
return qs ? `${basePath}?${qs}` : basePath
|
||||||
|
}
|
||||||
|
|
||||||
const pages = useMemo(() => {
|
const pages = useMemo(() => {
|
||||||
if (totalPages <= 7) {
|
if (totalPages <= 7) {
|
||||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useAuditService, useAuditAnalytics } from "@/modules/audit/services/aud
|
|||||||
import {
|
import {
|
||||||
MIN_RETENTION_DAYS,
|
MIN_RETENTION_DAYS,
|
||||||
MAX_RETENTION_DAYS,
|
MAX_RETENTION_DAYS,
|
||||||
} from "@/modules/audit/retention"
|
} from "@/modules/audit/types"
|
||||||
import type { AuditRetentionConfig } from "@/modules/audit/types"
|
import type { AuditRetentionConfig } from "@/modules/audit/types"
|
||||||
|
|
||||||
export function AuditRetentionSettings(): ReactNode {
|
export function AuditRetentionSettings(): ReactNode {
|
||||||
|
|||||||
@@ -8,19 +8,21 @@ import {
|
|||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
upsertSystemSetting,
|
upsertSystemSetting,
|
||||||
} from "@/modules/settings/data-access-system-settings"
|
} from "@/modules/settings/data-access-system-settings"
|
||||||
|
import {
|
||||||
|
DEFAULT_RETENTION_DAYS,
|
||||||
|
DEFAULT_LOGIN_LOG_RETENTION_DAYS,
|
||||||
|
MIN_RETENTION_DAYS,
|
||||||
|
MAX_RETENTION_DAYS,
|
||||||
|
} from "./types"
|
||||||
import type { AuditRetentionConfig, PurgeResult } from "./types"
|
import type { AuditRetentionConfig, PurgeResult } from "./types"
|
||||||
|
|
||||||
/** 默认保留天数 */
|
// 保留天数常量从 types.ts 重新导出,保持现有 import 路径兼容
|
||||||
export const DEFAULT_RETENTION_DAYS = 180
|
export {
|
||||||
|
DEFAULT_RETENTION_DAYS,
|
||||||
/** audit-P2-5: 登录日志默认保留天数(1 年,比审计日志更长以支持安全取证) */
|
DEFAULT_LOGIN_LOG_RETENTION_DAYS,
|
||||||
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365
|
MIN_RETENTION_DAYS,
|
||||||
|
MAX_RETENTION_DAYS,
|
||||||
/** 最小保留天数(防止误设为 0 导致全量删除) */
|
}
|
||||||
export const MIN_RETENTION_DAYS = 30
|
|
||||||
|
|
||||||
/** 最大保留天数 */
|
|
||||||
export const MAX_RETENTION_DAYS = 3650
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算保留阈值日期(早于此日期的日志将被清理)
|
* 计算保留阈值日期(早于此日期的日志将被清理)
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ export const DATA_CHANGE_ACTION_CLASS_NAME: StatusClassNameMap<DataChangeAction>
|
|||||||
delete: "bg-red-600 hover:bg-red-700 border-transparent",
|
delete: "bg-red-600 hover:bg-red-700 border-transparent",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志保留天数常量。
|
||||||
|
*
|
||||||
|
* 定义在 types.ts(而非 retention.ts)以便客户端组件直接导入,
|
||||||
|
* 避免 "use client" 组件经 retention.ts 间接拉入 mysql2 / server-only 依赖。
|
||||||
|
*/
|
||||||
|
/** 默认保留天数 */
|
||||||
|
export const DEFAULT_RETENTION_DAYS = 180
|
||||||
|
|
||||||
|
/** audit-P2-5: 登录日志默认保留天数(1 年,比审计日志更长以支持安全取证) */
|
||||||
|
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365
|
||||||
|
|
||||||
|
/** 最小保留天数(防止误设为 0 导致全量删除) */
|
||||||
|
export const MIN_RETENTION_DAYS = 30
|
||||||
|
|
||||||
|
/** 最大保留天数 */
|
||||||
|
export const MAX_RETENTION_DAYS = 3650
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
import { RegisterSchema } from "./schema"
|
import { RegisterSchema } from "./schema"
|
||||||
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
|
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
|
||||||
import { preflightTwoFactorByEmail } from "./services/two-factor-service"
|
import { preflightTwoFactorByEmail } from "./services/two-factor-service"
|
||||||
import type { RegisterResult } from "./types"
|
import {
|
||||||
|
REGISTER_ERROR_CODES,
|
||||||
|
type RegisterErrorCode,
|
||||||
|
type RegisterResult,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册速率限制规则。
|
* 注册速率限制规则。
|
||||||
@@ -30,22 +34,6 @@ const REGISTER_RATE_LIMIT = {
|
|||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册错误码枚举(前端通过 t(`register.errors.${errorCode}`) 查 i18n 翻译)。
|
|
||||||
*/
|
|
||||||
export const REGISTER_ERROR_CODES = {
|
|
||||||
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
|
|
||||||
VALIDATION_FAILED: "VALIDATION_FAILED",
|
|
||||||
EMAIL_TAKEN: "EMAIL_TAKEN",
|
|
||||||
BREACHED_PASSWORD: "BREACHED_PASSWORD",
|
|
||||||
INVITATION_CODE_INVALID: "INVITATION_CODE_INVALID",
|
|
||||||
INVITATION_CODE_USED: "INVITATION_CODE_USED",
|
|
||||||
DEFAULT_ROLE_MISSING: "DEFAULT_ROLE_MISSING",
|
|
||||||
UNKNOWN: "UNKNOWN",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type RegisterErrorCode = typeof REGISTER_ERROR_CODES[keyof typeof REGISTER_ERROR_CODES]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将原始数据库/系统错误归一化为 RegisterErrorCode。
|
* 将原始数据库/系统错误归一化为 RegisterErrorCode。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -43,3 +43,22 @@ export interface RegisterInput {
|
|||||||
export interface EmailAvailabilityResult {
|
export interface EmailAvailabilityResult {
|
||||||
available: boolean
|
available: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册错误码枚举(前端通过 t(`register.errors.${errorCode}`) 查 i18n 翻译)。
|
||||||
|
*
|
||||||
|
* 定义在 types.ts(而非 actions.ts)以便客户端组件直接导入,
|
||||||
|
* 避免 "use server" 文件导出非 async 值(Next.js 16 规则)。
|
||||||
|
*/
|
||||||
|
export const REGISTER_ERROR_CODES = {
|
||||||
|
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
|
||||||
|
VALIDATION_FAILED: "VALIDATION_FAILED",
|
||||||
|
EMAIL_TAKEN: "EMAIL_TAKEN",
|
||||||
|
BREACHED_PASSWORD: "BREACHED_PASSWORD",
|
||||||
|
INVITATION_CODE_INVALID: "INVITATION_CODE_INVALID",
|
||||||
|
INVITATION_CODE_USED: "INVITATION_CODE_USED",
|
||||||
|
DEFAULT_ROLE_MISSING: "DEFAULT_ROLE_MISSING",
|
||||||
|
UNKNOWN: "UNKNOWN",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type RegisterErrorCode = (typeof REGISTER_ERROR_CODES)[keyof typeof REGISTER_ERROR_CODES]
|
||||||
|
|||||||
@@ -483,14 +483,5 @@ export async function getGradeCoursePlanProgressAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 监控埋点接口(P2-8)────────────────────────────────────
|
// ── 监控埋点接口(P2-8)────────────────────────────────────
|
||||||
// 预留埋点接口,供前端调用以记录关键操作。
|
// trackCoursePlanEvent 已移至 lib/track-event.ts,因其为同步函数,
|
||||||
// 当前为空实现,后续接入监控 SDK 时只需修改此函数。
|
// 不允许出现在 "use server" 文件中(Next.js 16 规则)。
|
||||||
export function trackCoursePlanEvent(
|
|
||||||
event: string,
|
|
||||||
properties?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
// 预留:接入监控 SDK 后实现
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.debug(`[course-plans] ${event}`, properties)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ import {
|
|||||||
deleteCoursePlanAction,
|
deleteCoursePlanAction,
|
||||||
bulkToggleItemsAction,
|
bulkToggleItemsAction,
|
||||||
reorderCoursePlanItemsAction,
|
reorderCoursePlanItemsAction,
|
||||||
trackCoursePlanEvent,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
|
import { trackCoursePlanEvent } from "../lib/track-event"
|
||||||
import { exportCoursePlanReport } from "../lib/export-utils"
|
import { exportCoursePlanReport } from "../lib/export-utils"
|
||||||
import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types"
|
import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types"
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
getTemplateCandidatesAction,
|
getTemplateCandidatesAction,
|
||||||
copyCoursePlanAction,
|
copyCoursePlanAction,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { trackCoursePlanEvent } from "../actions"
|
import { trackCoursePlanEvent } from "../lib/track-event"
|
||||||
import type { CoursePlanListItem } from "../types"
|
import type { CoursePlanListItem } from "../types"
|
||||||
|
|
||||||
interface TemplatePickerDialogProps {
|
interface TemplatePickerDialogProps {
|
||||||
|
|||||||
19
src/modules/course-plans/lib/track-event.ts
Normal file
19
src/modules/course-plans/lib/track-event.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 课程计划监控埋点接口(P2-8)
|
||||||
|
*
|
||||||
|
* 供客户端组件直接调用以记录关键操作。
|
||||||
|
* 当前为空实现,后续接入监控 SDK 时只需修改此函数。
|
||||||
|
*
|
||||||
|
* 注意:此文件不得标记 "use server",因为 trackCoursePlanEvent 是同步函数,
|
||||||
|
* 不涉及服务端操作。Next.js 16 要求 "use server" 文件中所有导出必须是 async 函数。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function trackCoursePlanEvent(
|
||||||
|
event: string,
|
||||||
|
properties?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
// 预留:接入监控 SDK 后实现
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.debug(`[course-plans] ${event}`, properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -10,13 +10,22 @@
|
|||||||
* 默认值为 noopDiagnosticMonitor(不发送任何事件),
|
* 默认值为 noopDiagnosticMonitor(不发送任何事件),
|
||||||
* 确保未注入 Provider 时业务流程不受影响。
|
* 确保未注入 Provider 时业务流程不受影响。
|
||||||
*
|
*
|
||||||
|
* 注意:monitor prop 为可选,Server Component 不应传递含函数的对象。
|
||||||
|
* 若需注入真实监控实现,请在 Client Component 内部使用。
|
||||||
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <DiagnosticMonitorProvider monitor={postHogMonitor}>
|
* // Server Component(不传 monitor)
|
||||||
* <DiagnosticServiceProvider service={defaultDiagnosticService}>
|
* <DiagnosticMonitorProvider>
|
||||||
|
* <DiagnosticServiceProvider>
|
||||||
* <ReportList />
|
* <ReportList />
|
||||||
* </DiagnosticServiceProvider>
|
* </DiagnosticServiceProvider>
|
||||||
* </DiagnosticMonitorProvider>
|
* </DiagnosticMonitorProvider>
|
||||||
|
*
|
||||||
|
* // Client Component(可注入真实实现)
|
||||||
|
* <DiagnosticMonitorProvider monitor={postHogMonitor}>
|
||||||
|
* ...
|
||||||
|
* </DiagnosticMonitorProvider>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -32,8 +41,12 @@ const DiagnosticMonitorContext = createContext<DiagnosticMonitor>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface DiagnosticMonitorProviderProps {
|
interface DiagnosticMonitorProviderProps {
|
||||||
/** 监控实现(默认使用 noop,生产环境注入真实实现) */
|
/**
|
||||||
monitor: DiagnosticMonitor
|
* 监控实现(可选)。
|
||||||
|
* 未传时使用 noopDiagnosticMonitor。
|
||||||
|
* Server Component 不要传此 prop(含函数无法跨边界传递)。
|
||||||
|
*/
|
||||||
|
monitor?: DiagnosticMonitor
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +54,9 @@ export function DiagnosticMonitorProvider({
|
|||||||
monitor,
|
monitor,
|
||||||
children,
|
children,
|
||||||
}: DiagnosticMonitorProviderProps): ReactNode {
|
}: DiagnosticMonitorProviderProps): ReactNode {
|
||||||
|
const value = monitor ?? noopDiagnosticMonitor
|
||||||
return (
|
return (
|
||||||
<DiagnosticMonitorContext.Provider value={monitor}>
|
<DiagnosticMonitorContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</DiagnosticMonitorContext.Provider>
|
</DiagnosticMonitorContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { createContext, useContext, type ReactNode } from "react"
|
import { createContext, useContext, useMemo, type ReactNode } from "react"
|
||||||
|
|
||||||
import type { DiagnosticService } from "./diagnostic-service"
|
import type { DiagnosticService } from "./diagnostic-service"
|
||||||
|
import { defaultDiagnosticService } from "./default-diagnostic-service"
|
||||||
|
import { createMonitoredDiagnosticService } from "./monitored-diagnostic-service"
|
||||||
|
import { noopDiagnosticMonitor } from "./diagnostic-monitor"
|
||||||
|
import { useDiagnosticMonitor } from "./diagnostic-monitor-context"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2-P1-4: 诊断模块服务 Context。
|
* v2-P1-4: 诊断模块服务 Context。
|
||||||
@@ -10,13 +14,14 @@ import type { DiagnosticService } from "./diagnostic-service"
|
|||||||
* 组件通过 useDiagnosticService() 获取服务实现,
|
* 组件通过 useDiagnosticService() 获取服务实现,
|
||||||
* 而非直接 import actions,实现依赖反转。
|
* 而非直接 import actions,实现依赖反转。
|
||||||
*
|
*
|
||||||
* 默认由 DefaultDiagnosticServiceProvider 注入真实实现(调用 Server Actions);
|
* 默认由 DiagnosticServiceProvider 在客户端组装默认实现(调用 Server Actions);
|
||||||
* 测试时可注入 mock 实现以隔离组件测试。
|
* 测试时可注入 mock 实现以隔离组件测试。
|
||||||
*/
|
*/
|
||||||
const DiagnosticServiceContext = createContext<DiagnosticService | null>(null)
|
const DiagnosticServiceContext = createContext<DiagnosticService | null>(null)
|
||||||
|
|
||||||
interface DiagnosticServiceProviderProps {
|
interface DiagnosticServiceProviderProps {
|
||||||
service: DiagnosticService
|
/** 可选自定义服务实现,未传则使用 defaultDiagnosticService + 监控包装 */
|
||||||
|
service?: DiagnosticService
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,8 +29,18 @@ export function DiagnosticServiceProvider({
|
|||||||
service,
|
service,
|
||||||
children,
|
children,
|
||||||
}: DiagnosticServiceProviderProps): ReactNode {
|
}: DiagnosticServiceProviderProps): ReactNode {
|
||||||
|
const monitor = useDiagnosticMonitor()
|
||||||
|
const value = useMemo<DiagnosticService>(() => {
|
||||||
|
if (service) return service
|
||||||
|
// 客户端组装:默认 service + 监控埋点包装
|
||||||
|
return createMonitoredDiagnosticService(
|
||||||
|
defaultDiagnosticService,
|
||||||
|
monitor ?? noopDiagnosticMonitor,
|
||||||
|
)
|
||||||
|
}, [service, monitor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiagnosticServiceContext.Provider value={service}>
|
<DiagnosticServiceContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</DiagnosticServiceContext.Provider>
|
</DiagnosticServiceContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2-P2-7: 带监控埋点的诊断服务工厂。
|
* v2-P2-7: 带监控埋点的诊断服务工厂。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function AddErrorBookDialog({
|
|||||||
questionOptions: questionOptionsProp,
|
questionOptions: questionOptionsProp,
|
||||||
onLoadQuestions,
|
onLoadQuestions,
|
||||||
}: AddErrorBookDialogProps) {
|
}: AddErrorBookDialogProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [questionId, setQuestionId] = useState("")
|
const [questionId, setQuestionId] = useState("")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function AnalyticsStatsCards({
|
|||||||
knowledgePointCount,
|
knowledgePointCount,
|
||||||
className,
|
className,
|
||||||
}: AnalyticsStatsCardsProps) {
|
}: AnalyticsStatsCardsProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
|
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
|
|||||||
* 每个柱子可展开显示该章节下错得最多的知识点
|
* 每个柱子可展开显示该章节下错得最多的知识点
|
||||||
*/
|
*/
|
||||||
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
|
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (data.length === 0) return null
|
if (data.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const CHART_COLORS = [
|
|||||||
* 颜色按班级区分,tooltip 显示学生数/人均/掌握率
|
* 颜色按班级区分,tooltip 显示学生数/人均/掌握率
|
||||||
*/
|
*/
|
||||||
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
|
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (data.length === 0) return null
|
if (data.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -18,6 +18,10 @@ interface ClassFilterProps {
|
|||||||
/**
|
/**
|
||||||
* 班级筛选器(教师视图)
|
* 班级筛选器(教师视图)
|
||||||
* 显示教师所教的所有班级,点击切换查看单个班级
|
* 显示教师所教的所有班级,点击切换查看单个班级
|
||||||
|
*
|
||||||
|
* v3: 移除 useSearchParams(),改用 window.location.search 在点击时读取当前 URL 参数。
|
||||||
|
* useSearchParams() 在 SSR 期间会导致 Suspense 显示 fallback,CSR 期间显示真实内容,
|
||||||
|
* 组件树结构不同导致 useId() 生成的 id 不匹配,触发 hydration mismatch。
|
||||||
*/
|
*/
|
||||||
export function ClassFilter({
|
export function ClassFilter({
|
||||||
classes,
|
classes,
|
||||||
@@ -25,11 +29,13 @@ export function ClassFilter({
|
|||||||
paramName = "classId",
|
paramName = "classId",
|
||||||
}: ClassFilterProps) {
|
}: ClassFilterProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const t = useTranslations("errorBook")
|
||||||
const t = useTranslations("error-book")
|
|
||||||
|
|
||||||
const handleSelect = (classId: string) => {
|
const handleSelect = (classId: string) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
// 在 client 端点击时读取当前 URL 参数,避免 useSearchParams() 的 SSR/CSR 不一致
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
typeof window !== "undefined" ? window.location.search : ""
|
||||||
|
)
|
||||||
if (classId === "all") {
|
if (classId === "all") {
|
||||||
params.delete(paramName)
|
params.delete(paramName)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ErrorBookDetailDialog({
|
|||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [note, setNote] = useState(item.note ?? "")
|
const [note, setNote] = useState(item.note ?? "")
|
||||||
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
function handleSaveNote() {
|
function handleSaveNote() {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
|
|
||||||
export function ErrorBookFilters() {
|
export function ErrorBookFilters() {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||||
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
|
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface ErrorBookItemCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
|
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const preview = item.question
|
const preview = item.question
|
||||||
? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
|
? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
|
||||||
: t("itemCard.questionDeleted")
|
: t("itemCard.questionDeleted")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ErrorBookList({
|
|||||||
aiAnalysisSlot,
|
aiAnalysisSlot,
|
||||||
onStartVariantPractice,
|
onStartVariantPractice,
|
||||||
}: ErrorBookListProps): JSX.Element {
|
}: ErrorBookListProps): JSX.Element {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface ErrorBookStatsCardsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
|
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const masteredPercent = stats.totalCount > 0
|
const masteredPercent = stats.totalCount > 0
|
||||||
? Math.round(stats.masteredRate * 100)
|
? Math.round(stats.masteredRate * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function GroupedStudentErrorTable({
|
|||||||
studentNames,
|
studentNames,
|
||||||
basePath,
|
basePath,
|
||||||
}: GroupedStudentErrorTableProps) {
|
}: GroupedStudentErrorTableProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
|
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// 按班级分组
|
// 按班级分组
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function KnowledgePointWeaknessChart({
|
|||||||
data,
|
data,
|
||||||
className,
|
className,
|
||||||
}: KnowledgePointWeaknessChartProps) {
|
}: KnowledgePointWeaknessChartProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (data.length === 0) return null
|
if (data.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type ReviewOption = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
|
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function SubjectDistributionChart({
|
|||||||
data,
|
data,
|
||||||
className,
|
className,
|
||||||
}: SubjectDistributionChartProps) {
|
}: SubjectDistributionChartProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (data.length === 0) return null
|
if (data.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -18,6 +18,10 @@ interface SubjectTabsProps {
|
|||||||
/**
|
/**
|
||||||
* 学科切换 Tab(教师/管理员视图)
|
* 学科切换 Tab(教师/管理员视图)
|
||||||
* 显示每个学科的错题数概览,点击切换
|
* 显示每个学科的错题数概览,点击切换
|
||||||
|
*
|
||||||
|
* v3: 移除 useSearchParams(),改用 window.location.search 在点击时读取当前 URL 参数。
|
||||||
|
* useSearchParams() 在 SSR 期间会导致 Suspense 显示 fallback,CSR 期间显示真实内容,
|
||||||
|
* 组件树结构不同导致 useId() 生成的 id 不匹配,触发 hydration mismatch。
|
||||||
*/
|
*/
|
||||||
export function SubjectTabs({
|
export function SubjectTabs({
|
||||||
subjects,
|
subjects,
|
||||||
@@ -25,11 +29,13 @@ export function SubjectTabs({
|
|||||||
paramName = "subject",
|
paramName = "subject",
|
||||||
}: SubjectTabsProps) {
|
}: SubjectTabsProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const t = useTranslations("errorBook")
|
||||||
const t = useTranslations("error-book")
|
|
||||||
|
|
||||||
const handleSelect = (subjectId: string | null) => {
|
const handleSelect = (subjectId: string | null) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
// 在 client 端点击时读取当前 URL 参数,避免 useSearchParams() 的 SSR/CSR 不一致
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
typeof window !== "undefined" ? window.location.search : ""
|
||||||
|
)
|
||||||
if (subjectId === null) {
|
if (subjectId === null) {
|
||||||
params.delete(paramName)
|
params.delete(paramName)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function isKnownQuestionType(v: string): v is KnownQuestionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
||||||
const t = useTranslations("error-book")
|
const t = useTranslations("errorBook")
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -109,11 +109,13 @@ export async function getTopWrongQuestionsByStudentIds(
|
|||||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||||
|
|
||||||
// 使用子查询聚合,避免 GROUP BY JSON 列的问题
|
// 使用子查询聚合,避免 GROUP BY JSON 列的问题
|
||||||
|
// 注意:子查询中的 raw SQL 字段(count()、sql``)必须声明 alias,
|
||||||
|
// 否则外层引用时会报 "doesn't have an alias declared" 错误
|
||||||
const aggregatedSubquery = db
|
const aggregatedSubquery = db
|
||||||
.select({
|
.select({
|
||||||
questionId: errorBookItems.questionId,
|
questionId: errorBookItems.questionId,
|
||||||
errorCount: count(),
|
errorCount: count().as("errorCount"),
|
||||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
|
||||||
})
|
})
|
||||||
.from(errorBookItems)
|
.from(errorBookItems)
|
||||||
.where(whereClause)
|
.where(whereClause)
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ import {
|
|||||||
successState,
|
successState,
|
||||||
} from "./actions-helpers"
|
} from "./actions-helpers"
|
||||||
|
|
||||||
// Re-export 从拆分文件迁移的导出,保持外部 import 路径不变
|
// Re-export 从拆分文件迁移的类型导出,保持外部 import 路径不变
|
||||||
|
// 注意:Next.js 16 禁止在 "use server" 文件中 re-export 值(仅允许 async 函数导出),
|
||||||
|
// autoMarkExamAction / createExamFromRichEditorAction / updateExamFromRichEditorAction
|
||||||
|
// 的值 re-export 已移除,调用方直接从源文件 import。
|
||||||
export type { AutoMarkResult } from "./ai-pipeline/auto-mark"
|
export type { AutoMarkResult } from "./ai-pipeline/auto-mark"
|
||||||
export { autoMarkExamAction } from "./ai-pipeline/auto-mark"
|
|
||||||
export { createExamFromRichEditorAction, updateExamFromRichEditorAction } from "./actions-rich-editor"
|
|
||||||
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
|
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
|
||||||
|
|
||||||
const ExamCreateSchema = z.object({
|
const ExamCreateSchema = z.object({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// 文件关联的目标资源类型(多态关联)
|
// 文件关联的目标资源类型(多态关联)
|
||||||
// P1-5 新增 "user_avatar":用于用户头像上传场景的 targetType 字段对齐
|
// P1-5 新增 "user_avatar":用于用户头像上传场景的 targetType 字段对齐
|
||||||
// P2-3 新增 "message":用于私信附件上传场景的 targetType 字段对齐
|
// P2-3 新增 "message":用于私信附件上传场景的 targetType 字段对齐
|
||||||
|
// V5-5 新增 "lesson_plan":用于备课模块附件上传场景的 targetType 字段对齐
|
||||||
export type FileTargetType =
|
export type FileTargetType =
|
||||||
| "exam"
|
| "exam"
|
||||||
| "textbook"
|
| "textbook"
|
||||||
@@ -9,6 +10,7 @@ export type FileTargetType =
|
|||||||
| "homework"
|
| "homework"
|
||||||
| "user_avatar"
|
| "user_avatar"
|
||||||
| "message"
|
| "message"
|
||||||
|
| "lesson_plan"
|
||||||
|
|
||||||
// 文件附件记录(DB 行的 TypeScript 表示)
|
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||||
export interface FileAttachment {
|
export interface FileAttachment {
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export function SiteHeader() {
|
|||||||
const tNav = useTranslations("nav")
|
const tNav = useTranslations("nav")
|
||||||
const tCommon = useTranslations("common")
|
const tCommon = useTranslations("common")
|
||||||
|
|
||||||
|
// v3: root layout 已通过 auth() 获取 server-side session 传给 SessionProvider,
|
||||||
|
// SSR 和 CSR 期间 useSession 返回相同数据,不再需要 mounted workaround。
|
||||||
|
|
||||||
const name = session?.user?.name ?? ""
|
const name = session?.user?.name ?? ""
|
||||||
const email = session?.user?.email ?? ""
|
const email = session?.user?.email ?? ""
|
||||||
const displayName = name || email || (status === "loading" ? tCommon("user.loading") : tCommon("user.notLoggedIn"))
|
const displayName = name || email || (status === "loading" ? tCommon("user.loading") : tCommon("user.notLoggedIn"))
|
||||||
|
|||||||
@@ -3,9 +3,20 @@
|
|||||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
import { handleActionError } from "@/shared/lib/action-utils";
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
||||||
import { suggestKnowledgePoints } from "./ai-suggest";
|
import { suggestKnowledgePoints } from "./ai-suggest";
|
||||||
import { suggestKnowledgePointsSchema } from "./schema";
|
import { suggestKnowledgePointsSchema } from "./schema";
|
||||||
import { translateFieldErrors } from "./lib/i18n-errors";
|
import { translateFieldErrors } from "./lib/i18n-errors";
|
||||||
|
import { generateLessonPlanFeedback, type AiFeedbackResult } from "./lib/ai-feedback";
|
||||||
|
import {
|
||||||
|
generateDifferentiationSuggestions,
|
||||||
|
checkCurriculumAlignment,
|
||||||
|
generateExplainableAssessment,
|
||||||
|
type DifferentiationSuggestion,
|
||||||
|
type CurriculumCheckItem,
|
||||||
|
type ExplainableAssessment,
|
||||||
|
} from "./lib/ai-differentiation";
|
||||||
|
import type { LessonPlanDocument } from "./types";
|
||||||
import type { ActionState } from "@/shared/types/action-state";
|
import type { ActionState } from "@/shared/types/action-state";
|
||||||
|
|
||||||
export async function suggestKnowledgePointsAction(input: {
|
export async function suggestKnowledgePointsAction(input: {
|
||||||
@@ -45,3 +56,99 @@ export async function suggestKnowledgePointsAction(input: {
|
|||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-17 A1/A2:AI 反馈闭环。
|
||||||
|
* 调用 AI 对课案文档生成结构化反馈(含解释性展示)。
|
||||||
|
*/
|
||||||
|
export async function generateLessonPlanFeedbackAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<AiFeedbackResult>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const result = await generateLessonPlanFeedback(doc);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A3:AI 差异化教学建议生成。
|
||||||
|
* 根据课案内容生成基础/提高/拓展三个层次的教学建议。
|
||||||
|
*/
|
||||||
|
export async function generateDifferentiationSuggestionsAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<{ items: DifferentiationSuggestion[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await generateDifferentiationSuggestions(doc);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A4:AI 课标实时核对。
|
||||||
|
* 检查课案是否覆盖教材课标要求。
|
||||||
|
*/
|
||||||
|
export async function checkCurriculumAlignmentAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
knowledgePoints: { id: string; name: string }[],
|
||||||
|
): Promise<ActionState<{ items: CurriculumCheckItem[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await checkCurriculumAlignment(doc, knowledgePoints);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A5:AI 可解释评估。
|
||||||
|
* 对课案中的练习节点给出可解释的评分依据。
|
||||||
|
*/
|
||||||
|
export async function generateExplainableAssessmentAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<{ items: ExplainableAssessment[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await generateExplainableAssessment(doc);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A4 辅助:按教材 ID 获取知识点列表(仅 id + name),
|
||||||
|
* 供课标核对 Tab 按需加载,避免编辑页首屏负担。
|
||||||
|
*/
|
||||||
|
export async function getKnowledgePointsForAlignmentAction(
|
||||||
|
textbookId: string,
|
||||||
|
): Promise<ActionState<{ items: { id: string; name: string }[] }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const kps = await getKnowledgePointsByTextbookId(textbookId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: kps.map((kp) => ({ id: kp.id, name: kp.name })) },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
src/modules/lesson-preparation/actions-schedules.ts
Normal file
87
src/modules/lesson-preparation/actions-schedules.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* V5-7:课案-课时绑定 Server Actions
|
||||||
|
*/
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
getSchedulesByPlanId,
|
||||||
|
createSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
} from "./data-access-schedules";
|
||||||
|
import type { ActionState } from "@/shared/types/action-state";
|
||||||
|
import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
|
||||||
|
const createScheduleSchema = z.object({
|
||||||
|
planId: z.string().min(1, "error.planIdRequired"),
|
||||||
|
classId: z.string().min(1, "error.classIdRequired"),
|
||||||
|
scheduledDate: z.string().min(1, "error.dateRequired"),
|
||||||
|
period: z.number().int().min(1).max(12),
|
||||||
|
classScheduleId: z.string().optional(),
|
||||||
|
durationMin: z.number().int().min(5).max(180).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getLessonPlanSchedulesAction(
|
||||||
|
planId: string,
|
||||||
|
): Promise<ActionState<{ items: Awaited<ReturnType<typeof getSchedulesByPlanId>> }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const items = await getSchedulesByPlanId(planId);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLessonPlanScheduleAction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ schedule: Awaited<ReturnType<typeof createSchedule>> }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const parseResult = createScheduleSchema.safeParse(input);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: t("error.invalidInput"),
|
||||||
|
errors: Object.fromEntries(
|
||||||
|
Object.entries(parseResult.error.flatten().fieldErrors),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await getAuthContext();
|
||||||
|
if (!auth.userId) {
|
||||||
|
return { success: false, message: t("error.unauthorized") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await createSchedule({
|
||||||
|
...parseResult.data,
|
||||||
|
createdBy: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath("/teacher/lesson-plans/calendar");
|
||||||
|
return { success: true, data: { schedule } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLessonPlanScheduleAction(
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
|
await deleteSchedule(scheduleId);
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath("/teacher/lesson-plans/calendar");
|
||||||
|
return { success: true, data: null };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
@@ -315,6 +316,19 @@ export async function duplicateLessonPlanAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V5-12 R4:表单兼容版本(从 formData 提取 planId),用于校内课案库一键复制
|
||||||
|
export async function duplicateLessonPlanFormAction(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<void> {
|
||||||
|
const planId = String(formData.get("planId") ?? "");
|
||||||
|
if (!planId) return;
|
||||||
|
const result = await duplicateLessonPlanAction(planId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
redirect(`/teacher/lesson-plans/${result.data.newPlanId}/edit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 模板列表 ----
|
// ---- 模板列表 ----
|
||||||
export async function getLessonPlanTemplatesAction(): Promise<
|
export async function getLessonPlanTemplatesAction(): Promise<
|
||||||
ActionState<{
|
ActionState<{
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
BookCheck,
|
||||||
|
ClipboardList,
|
||||||
|
GraduationCap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
generateDifferentiationSuggestionsAction,
|
||||||
|
checkCurriculumAlignmentAction,
|
||||||
|
generateExplainableAssessmentAction,
|
||||||
|
getKnowledgePointsForAlignmentAction,
|
||||||
|
} from "../actions-ai";
|
||||||
|
import type { LessonPlanDocument, DifferentiationLevel } from "../types";
|
||||||
|
import type {
|
||||||
|
DifferentiationSuggestion,
|
||||||
|
CurriculumCheckItem,
|
||||||
|
ExplainableAssessment,
|
||||||
|
} from "../lib/ai-differentiation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
/** 课案所属教材 ID(用于 A4 课标核对按需加载知识点) */
|
||||||
|
textbookId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = "differentiation" | "curriculum" | "assessment";
|
||||||
|
|
||||||
|
const TABS: TabKey[] = ["differentiation", "curriculum", "assessment"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A3/A4/A5:AI 差异化对话框。
|
||||||
|
*
|
||||||
|
* 三个 Tab 分别承载:
|
||||||
|
* - A3 差异化教学建议(基础/提高/拓展)
|
||||||
|
* - A4 课标实时核对(按 textbookId 按需加载知识点)
|
||||||
|
* - A5 可解释评估(练习节点的评分依据)
|
||||||
|
*/
|
||||||
|
export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("differentiation");
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("aiDifferentiation.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[680px] max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<div className="flex border-b border-outline-variant" role="tablist">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-on-surface-variant hover:text-on-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`aiDifferentiation.tabs.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
|
{activeTab === "differentiation" && (
|
||||||
|
<DifferentiationTab doc={doc} t={t} />
|
||||||
|
)}
|
||||||
|
{activeTab === "curriculum" && (
|
||||||
|
<CurriculumTab doc={doc} textbookId={textbookId} t={t} />
|
||||||
|
)}
|
||||||
|
{activeTab === "assessment" && <AssessmentTab doc={doc} t={t} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-outline-variant flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("editor.consistencyClose")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A3:差异化教学建议 Tab */
|
||||||
|
function DifferentiationTab({
|
||||||
|
doc,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<DifferentiationSuggestion[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateDifferentiationSuggestionsAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
// 按层次排序:basic → intermediate → advanced
|
||||||
|
const order: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => order.indexOf(a.level) - order.indexOf(b.level),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sorted.map((item) => (
|
||||||
|
<section
|
||||||
|
key={item.level}
|
||||||
|
className="rounded border border-outline-variant p-3 bg-surface"
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
|
||||||
|
<Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
{t(`aiDifferentiation.level.${item.level}`)}
|
||||||
|
<span className="text-xs text-on-surface-variant">— {item.targetStudents}</span>
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5 text-sm text-on-surface-variant">
|
||||||
|
{item.suggestions.map((s, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-1.5">
|
||||||
|
<span className="text-primary mt-1">·</span>
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4:课标实时核对 Tab(按 textbookId 按需加载知识点) */
|
||||||
|
function CurriculumTab({
|
||||||
|
doc,
|
||||||
|
textbookId,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
textbookId?: string;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<CurriculumCheckItem[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
// 无教材 ID 时无法获取知识点
|
||||||
|
if (!textbookId) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(t("aiDifferentiation.noKnowledgePoints"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 先按需加载知识点列表
|
||||||
|
const kpRes = await getKnowledgePointsForAlignmentAction(textbookId);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!kpRes.success || !kpRes.data || kpRes.data.items.length === 0) {
|
||||||
|
setError(t("aiDifferentiation.noKnowledgePoints"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 再调用课标核对
|
||||||
|
const res = await checkCurriculumAlignmentAction(doc, kpRes.data.items);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, textbookId, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
const covered = items.filter((i) => i.covered).length;
|
||||||
|
const missed = items.length - covered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-1 text-primary">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.covered", { count: covered })}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-tertiary">
|
||||||
|
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.missed", { count: missed })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
{item.covered ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{item.requirement}</span>
|
||||||
|
</div>
|
||||||
|
{item.explanation && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5">{item.explanation}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A5:可解释评估 Tab */
|
||||||
|
function AssessmentTab({
|
||||||
|
doc,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<ExplainableAssessment[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateExplainableAssessmentAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium flex items-center gap-1.5">
|
||||||
|
<BookCheck className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
<span>{item.conclusion}</span>
|
||||||
|
</div>
|
||||||
|
{item.rationale && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
|
||||||
|
<ClipboardList className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.rationale}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.suggestion && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
|
||||||
|
<GraduationCap className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.suggestion}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingBlock({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-on-surface-variant">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBlock({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyBlock({ label }: { label: string }) {
|
||||||
|
return <div className="text-sm text-on-surface-variant text-center py-6">{label}</div>;
|
||||||
|
}
|
||||||
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal file
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X, Sparkles, Loader2, AlertCircle, Lightbulb, CheckCircle2, Target, Users } from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { generateLessonPlanFeedbackAction } from "../actions-ai";
|
||||||
|
import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-17 A1/A2:AI 反馈闭环对话框(含解释性展示) */
|
||||||
|
export function AiFeedbackDialog({ doc, onClose }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [result, setResult] = useState<AiFeedbackResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateLessonPlanFeedbackAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setResult(res.data);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("feedback.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("feedback.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("feedback.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("feedback.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-on-surface-variant">{t("feedback.loading")}</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<FeedbackContent result={result} t={t} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-outline-variant flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("editor.consistencyClose")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackContent({
|
||||||
|
result,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
result: AiFeedbackResult;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
// 按维度分组
|
||||||
|
const grouped: Record<AiFeedbackItem["category"], AiFeedbackItem[]> = {
|
||||||
|
strengths: [],
|
||||||
|
improvements: [],
|
||||||
|
alignment: [],
|
||||||
|
differentiation: [],
|
||||||
|
};
|
||||||
|
for (const item of result.items) {
|
||||||
|
grouped[item.category].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 摘要 + 评分 */}
|
||||||
|
<div className="flex items-center justify-between rounded border border-outline-variant p-3 bg-surface-container-low">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("feedback.summary")}</p>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3">
|
||||||
|
<div className="text-2xl font-bold text-primary">{result.overallScore}</div>
|
||||||
|
<div className="text-xs text-on-surface-variant">{t("feedback.score")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 各维度反馈 */}
|
||||||
|
{(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => {
|
||||||
|
const items = grouped[cat];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={cat}>
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
|
||||||
|
<CategoryIcon category={cat} />
|
||||||
|
{t(`feedback.category.${cat}`)}
|
||||||
|
<span className="text-xs text-on-surface-variant">({items.length})</span>
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{item.title}</div>
|
||||||
|
{/* A2:解释性展示 — reason 字段说明 AI 判断依据 */}
|
||||||
|
{item.reason && (
|
||||||
|
<div className="text-xs text-on-surface-variant mt-1 flex items-start gap-1">
|
||||||
|
<Lightbulb className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.reason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<div className="text-sm text-on-surface-variant text-center py-6">
|
||||||
|
{t("feedback.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryIcon({ category }: { category: AiFeedbackItem["category"] }) {
|
||||||
|
const icon = {
|
||||||
|
strengths: <CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
|
||||||
|
improvements: <AlertCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />,
|
||||||
|
alignment: <Target className="w-3.5 h-3.5 text-secondary" aria-hidden="true" />,
|
||||||
|
differentiation: <Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
|
||||||
|
}[category];
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user