feat(ai): 统一 AI 配置入口到 /admin/ai-settings
## 新增 - 创建 /admin/ai-settings 统一配置页(AiProviderSettingsCard + AiUsageDashboard) - admin 侧边栏新增"AI 配置"菜单项(权限 AI_CONFIGURE,图标 Sparkles) - 新增 deleteAiProvider 数据访问层(事务删除 + 自动转移默认) - 新增 deleteAiProviderAction Server Action(Zod 校验 + 权限校验) - AiProviderSettingsCard 新增删除按钮(AlertDialog 确认 + destructive 变体) - 新增 i18n 翻译键(delete/deleteConfirm/deleteSuccess 等,zh-CN + en) ## 移除 - 从 /settings 移除 AI 标签页(原 VALID_TABS 含 "ai",现仅 4 标签页) - 从考试页面移除 AI 配置弹窗(Dialog + AiProviderSettingsCard 内嵌) - 从 ai-provider-selector.tsx 移除配置弹窗(managePanel/manageOpen props) - 移除 settings-view.tsx 中 canConfigureAi 逻辑和未使用 import ## 变更 - 考试页面"管理"按钮改为 Link 跳转到 /admin/ai-settings - ai-provider-selector.tsx"管理"按钮改为 Link 跳转到 /admin/ai-settings - exam-form.tsx 移除 providerDialogOpen/providerDialogKey 状态 - 修正架构文档 004 中 Action 命名(getAiProvidersAction → getAiProviderSummaries 等) ## 架构文档同步 - 004 更新 settings 模块章节(V3 标记/修正 Action 名称/新增 deleteAiProvider) - 005 新增 deleteAiProviderAction 节点 + /admin/ai-settings 路由
This commit is contained in:
@@ -540,8 +540,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:考试全生命周期管理(创建/编辑/预览/发布/删除/复制)+ AI 辅助出题。
|
**职责**:考试全生命周期管理(创建/编辑/预览/发布/删除/复制)+ AI 辅助出题。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access)
|
- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction` / `getExamsByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度3,按 gradeId 查询年级下所有考试 + 提交统计,EXAM_READ 权限)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access)
|
||||||
- Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades`(后 7 个为 P1-2 新增)
|
- Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades` / `getExamsByGradeId`(✅ v4-P2-7 新增:年级仪表盘维度3,exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分,支持 scope 行级过滤)(后 8 个为 P1-2 新增)
|
||||||
- AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction`
|
- AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction`
|
||||||
- Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取)
|
- Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取)
|
||||||
- Stats-service(V3-8 新增):`getExamAnalytics`(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型
|
- Stats-service(V3-8 新增):`getExamAnalytics`(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型
|
||||||
@@ -725,10 +725,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction`(v4-P1-6 增强:成绩录入后通知学生和家长,调用 `notifyGradeEntered`)/ `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction`(v4-P1-12 增强:新增可选 `studentId` 参数,支持按学生导出,家长视角调用 `exportStudentGradeRecordsToExcel`,校验 studentId 属于家长子女)/ `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`(v4-P1-6 增强:批量成绩录入后通知学生和家长)/ `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions,分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)
|
- Actions:`getGradeRecordsAction` / `createGradeRecordAction`(v4-P1-6 增强:成绩录入后通知学生和家长,调用 `notifyGradeEntered`)/ `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction`(v4-P1-12 增强:新增可选 `studentId` 参数,支持按学生导出,家长视角调用 `exportStudentGradeRecordsToExcel`,校验 studentId 属于家长子女)/ `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getGradeDistributionByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度1,按 gradeId 查询年级整体 + 按班级拆分的成绩分布,GRADE_RECORD_READ 权限)/ `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`(v4-P1-6 增强:批量成绩录入后通知学生和家长)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions,分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)。注:`assertClassInScope` 原位于 actions.ts(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用),✅ v4-P2-6 修复:因 "use server" 文件要求所有 export 为 async,而 `assertClassInScope` 是同步函数,已迁移至独立文件 `lib/scope-check.ts`,actions.ts 与 actions-analytics.ts 均从 `./lib/scope-check` 导入
|
||||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUD,upsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
|
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getGradeDistributionByGradeId`(✅ v4-P2-7 新增:年级仪表盘维度1,通过 getClassesByGradeId 获取年级下所有班级,inArray 查询成绩记录,复用 computeGradeDistribution/computeGradeStats 纯函数,返回整体分布 + 按班级拆分)/ `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUD,upsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
|
||||||
- Types(✅ v3-P2 新增):`SchoolWideGradeSummaryItem`(全校汇总按年级聚合项:gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount)/ `SchoolWideGradeSummary`(全校汇总:grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts)
|
- Types(✅ v3-P2 新增,✅ v4-P2-7 新增年级分布类型):`SchoolWideGradeSummaryItem` / `SchoolWideGradeSummary` / `GradeDraftData`(草稿数据接口)/ `GradeDistributionByGradeResult`(✅ v4-P2-7 新增:年级维度成绩分布结果,含 overall 整体分布 + stats 统计 + byClass 按班级拆分数组)/ `GradeDistributionByGradeClassItem`(✅ v4-P2-7 新增:按班级拆分的分布项:classId/className/distribution/stats)
|
||||||
- Lib(✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分):`toNumber` / `normalize`(位于 `lib/grade-utils.ts`);`buildScopeClassFilter(scope, currentUserId?)`(P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`,P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
|
- Lib(✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分,✅ v4-P2-6 新增 scope-check):`toNumber` / `normalize`(位于 `lib/grade-utils.ts`);`buildScopeClassFilter(scope, currentUserId?)`(P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`,P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数);`assertClassInScope(scope: DataScope, classId: string): string | null`(✅ v4-P2-6 从 actions.ts 迁移至 `lib/scope-check.ts`:校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用。迁移原因:actions.ts 是 "use server" 文件要求所有 export 为 async,而 assertClassInScope 是同步函数)
|
||||||
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
||||||
- Export(✅ v4-P1-12 新增):`exportGradeRecordsToExcel` / `exportClassGradeReportToExcel` / `exportStudentGradeRecordsToExcel`(v4-P1-12 新增:导出单个学生成绩单家长视角,仅含成绩明细 + 个人统计,不含班级数据,scope 为 children 自动按 studentId 过滤)/ `formatDateForFile`(已迁移至 shared/lib/utils)
|
- Export(✅ v4-P1-12 新增):`exportGradeRecordsToExcel` / `exportClassGradeReportToExcel` / `exportStudentGradeRecordsToExcel`(v4-P1-12 新增:导出单个学生成绩单家长视角,仅含成绩明细 + 个人统计,不含班级数据,scope 为 children 自动按 studentId 过滤)/ `formatDateForFile`(已迁移至 shared/lib/utils)
|
||||||
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增:管理员全校成绩汇总卡片,4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接,fullScore<=0 时不着色)
|
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增:管理员全校成绩汇总卡片,4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接,fullScore<=0 时不着色)
|
||||||
@@ -783,12 +783,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ v3-P3-1 改进(2026-06-23):`batch-grade-entry.tsx` 新增"下载模板"按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8),教师可下载填好后粘贴到录入表格
|
- ✅ v3-P3-1 改进(2026-06-23):`batch-grade-entry.tsx` 新增"下载模板"按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8),教师可下载填好后粘贴到录入表格
|
||||||
- ✅ v3-P3-2 改进(2026-06-23):`grade-record-list.tsx` 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;新增 `bulkDeleteGradeRecords` data-access 函数(使用 inArray 一次性删除避免 N+1)+ `bulkDeleteGradeRecordsAction` Server Action(限制单次最多 500 条)
|
- ✅ v3-P3-2 改进(2026-06-23):`grade-record-list.tsx` 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;新增 `bulkDeleteGradeRecords` data-access 函数(使用 inArray 一次性删除避免 N+1)+ `bulkDeleteGradeRecordsAction` Server Action(限制单次最多 500 条)
|
||||||
- ✅ v4-P3-2 改进(2026-06-23):`batch-grade-entry.tsx` 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态避免重复显示
|
- ✅ v4-P3-2 改进(2026-06-23):`batch-grade-entry.tsx` 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态避免重复显示
|
||||||
|
- ✅ v4-P2-6 修复(2026-06-23):~~`assertClassInScope` 是同步函数但位于 "use server" 文件 actions.ts 中~~ Next.js 要求 "use server" 文件中所有 export 必须为 async,同步 export 会导致构建错误。修复:将 `assertClassInScope` 迁移至独立文件 `lib/scope-check.ts`(含 `import "server-only"`),actions.ts 与 actions-analytics.ts 均从 `./lib/scope-check` 导入
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 670+ | 19 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页;v3-P2 新增:saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction;v4-P1-6:createGradeRecordAction/batchCreateGradeRecordsAction 新增通知;v4-P1-12:exportGradesAction 新增 studentId 参数;v3-P3-2 新增:bulkDeleteGradeRecordsAction 批量删除) |
|
| `actions.ts` | 670+ | 19 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页;v3-P2 新增:saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction;v4-P1-6:createGradeRecordAction/batchCreateGradeRecordsAction 新增通知;v4-P1-12:exportGradesAction 新增 studentId 参数;v3-P3-2 新增:bulkDeleteGradeRecordsAction 批量删除;v4-P2-6:assertClassInScope 迁移至 lib/scope-check.ts) |
|
||||||
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验) |
|
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验;v4-P2-6:assertClassInScope 改从 ./lib/scope-check 导入) |
|
||||||
| `data-access.ts` | 450+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P2 新增:saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口;v3-P3-2 新增:bulkDeleteGradeRecords 使用 inArray 批量删除) |
|
| `data-access.ts` | 450+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P2 新增:saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口;v3-P3-2 新增:bulkDeleteGradeRecords 使用 inArray 批量删除) |
|
||||||
| `data-access-analytics.ts` | 200+ | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
|
| `data-access-analytics.ts` | 200+ | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
|
||||||
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
||||||
@@ -797,6 +798,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行) |
|
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行) |
|
||||||
| `lib/grade-utils.ts` | 20 | 公共工具函数(toNumber/normalize;P3-26:buildScopeClassFilter 迁移至 scope-filter.ts) |
|
| `lib/grade-utils.ts` | 20 | 公共工具函数(toNumber/normalize;P3-26:buildScopeClassFilter 迁移至 scope-filter.ts) |
|
||||||
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤(buildScopeClassFilter;P3-26 从 grade-utils.ts 迁移;v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:新增 currentUserId 参数) |
|
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤(buildScopeClassFilter;P3-26 从 grade-utils.ts 迁移;v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:新增 currentUserId 参数) |
|
||||||
|
| `lib/scope-check.ts` | 34 | v4-P2-6 新增:班级 scope 校验工具(assertClassInScope 同步函数,从 actions.ts 迁移至此独立文件以避开 "use server" 文件要求 export 必须为 async 的限制;含 `import "server-only"`) |
|
||||||
| `types.ts` | 168+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary) |
|
| `types.ts` | 168+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary) |
|
||||||
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
||||||
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增:管理员全校成绩汇总卡片(4 个统计卡片 + 各年级对比表格) |
|
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增:管理员全校成绩汇总卡片(4 个统计卡片 + 各年级对比表格) |
|
||||||
@@ -870,7 +872,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit);`promoteGradesAction` 年级升级,审计日志 `grade.promote`)
|
- Actions:`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit);`promoteGradesAction` 年级升级,审计日志 `grade.promote`)
|
||||||
- Data-access:只读查询(`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`)
|
- Data-access:只读查询(`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用)/ `getGradeOverviewStats`(✅ v4-P2-6 新增:年级概览统计,返回 `GradeOverviewStats[]`,每个年级的 classCount/studentCount/teacherCount,供年级管理卡片视图使用,动态导入 classes/data-access 避免循环依赖))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`)
|
||||||
|
- Types(✅ v4-P2-6 新增):`GradeOverviewStats`(年级概览统计接口:{ gradeId, classCount, studentCount, teacherCount },位于 data-access.ts)
|
||||||
|
- Components:`SchoolsClient` / `SchoolFormDialog` / `SchoolDeleteDialog` / `SchoolListToolbar` / `SchoolErrorBoundary` / `SchoolListSkeleton` / `SchoolCardSkeleton` / `OrgTreeNav` / `GradesClient`(✅ v4-P2-6 更新:新增 `gradeStats: GradeOverviewStats[]` prop,渲染年级概览卡片视图——每个年级卡片展示班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口)/ `GradeInsightsFilters`(✅ v4-P2-6 新增:年级洞察筛选器,使用 ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新)/ `GradeDistributionPanel` / `GradeHomeworkPanel` / `GradeExamsPanel` / `GradeProgressPanel`(✅ v4-P2-7 新增:年级仪表盘 4 个维度面板组件,位于 `components/grade-dashboard/` 子目录,服务端组件直接渲染数据,无客户端交互)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`users`(⚠️ `getStaffOptions` 直查 users/roles,可接受)
|
- 依赖:`shared/*`、`@/auth`、`users`(⚠️ `getStaffOptions` 直查 users/roles,可接受)
|
||||||
@@ -892,6 +896,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P2-3 修复(2026-06-23):新增 `promoteGradesAction` + `promoteGrades(schoolId)` + `promoteGradeName(name)` 辅助函数(中文数字 一→二…十二、阿拉伯数字 1→2…12 识别),按 order 降序逐条 +1 避免唯一约束冲突;含 `after(() => logAudit({ action: "grade.promote" }))` 审计日志
|
- ✅ P2-3 修复(2026-06-23):新增 `promoteGradesAction` + `promoteGrades(schoolId)` + `promoteGradeName(name)` 辅助函数(中文数字 一→二…十二、阿拉伯数字 1→2…12 识别),按 order 降序逐条 +1 避免唯一约束冲突;含 `after(() => logAudit({ action: "grade.promote" }))` 审计日志
|
||||||
- ✅ P2-4 修复(2026-06-23):新增 `bulkEnrollStudentsAction`(CSV 批量导入学生,复用 enrollStudentByEmail)+ `bulkAssignSubjectTeachersAction`(CSV 批量分配教师,简化实现含 TODO 待完善查找逻辑);classes/actions.ts barrel 导出
|
- ✅ P2-4 修复(2026-06-23):新增 `bulkEnrollStudentsAction`(CSV 批量导入学生,复用 enrollStudentByEmail)+ `bulkAssignSubjectTeachersAction`(CSV 批量分配教师,简化实现含 TODO 待完善查找逻辑);classes/actions.ts barrel 导出
|
||||||
- ✅ P2-5 修复(2026-06-23):为 department/academicYear/grade 的 9 个 CRUD Action 补充 `after(() => logAudit(...))` 审计日志(action: department.create/update/delete、academicYear.create/update/delete、grade.create/update/delete),与 school 实体审计日志策略一致
|
- ✅ P2-5 修复(2026-06-23):为 department/academicYear/grade 的 9 个 CRUD Action 补充 `after(() => logAudit(...))` 审计日志(action: department.create/update/delete、academicYear.create/update/delete、grade.create/update/delete),与 school 实体审计日志策略一致
|
||||||
|
- ✅ v4-P2-6 改进(2026-06-23):年级管理体验增强——新增 `getGradeOverviewStats()` data-access 函数(返回 `GradeOverviewStats[]`:每个年级的 classCount/studentCount/teacherCount,动态导入 classes/data-access 避免循环依赖);`GradesClient` 新增 `gradeStats` prop,渲染年级概览卡片视图(班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口);admin/school/grades/page.tsx 新增 `getGradeOverviewStats()` 查询并传入 GradesClient
|
||||||
|
- ✅ v4-P2-6 改进(2026-06-23):新增 `GradeInsightsFilters` 组件(使用 ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新);admin/school/grades/insights/page.tsx 与 management/grade/insights/page.tsx 重写,使用 `GradeInsightsFilters` 替代原生 form get,添加 i18n,表格添加 `overflow-x-auto` 水平滚动
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
@@ -907,6 +913,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| components/school-error-boundary.tsx | 72 | 共享 Error Boundary(P1-3 修复) |
|
| components/school-error-boundary.tsx | 72 | 共享 Error Boundary(P1-3 修复) |
|
||||||
| components/school-skeleton.tsx | 69 | 共享骨架屏(P1-3 修复) |
|
| components/school-skeleton.tsx | 69 | 共享骨架屏(P1-3 修复) |
|
||||||
| components/org-tree-nav.tsx | 134 | 学校→年级→班级三级树形导航(P2-2 修复:搜索过滤 + 选中高亮 + 展开折叠 + 节点类型图标) |
|
| components/org-tree-nav.tsx | 134 | 学校→年级→班级三级树形导航(P2-2 修复:搜索过滤 + 选中高亮 + 展开折叠 + 节点类型图标) |
|
||||||
|
| components/grades-view.tsx | 920+ | 年级管理客户端(v4-P2-6 更新:新增 gradeStats prop + 年级概览卡片视图,展示班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口) |
|
||||||
|
| components/grade-insights-filters.tsx | 48 | v4-P2-6 新增:年级洞察筛选器(ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新) |
|
||||||
| hooks/use-school-data.ts | 40 | 学校数据管理 hook(P2-1 修复) |
|
| hooks/use-school-data.ts | 40 | 学校数据管理 hook(P2-1 修复) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1327,8 +1335,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:课程计划 CRUD + 周计划项 CRUD + 排序。
|
**职责**:课程计划 CRUD + 周计划项 CRUD + 排序。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getCoursePlansAction` / `getCoursePlanByIdAction` / `createCoursePlanAction` / `updateCoursePlanAction` / `deleteCoursePlanAction` / `createCoursePlanItemAction` / `updateCoursePlanItemAction` / `deleteCoursePlanItemAction` / `toggleCoursePlanItemCompletedAction`
|
- Actions:`getCoursePlansAction` / `getCoursePlanByIdAction` / `createCoursePlanAction` / `updateCoursePlanAction` / `deleteCoursePlanAction` / `createCoursePlanItemAction` / `updateCoursePlanItemAction` / `deleteCoursePlanItemAction` / `toggleCoursePlanItemCompletedAction` / `getGradeCoursePlanProgressAction`(✅ v4-P2-7 新增:年级仪表盘维度4,按 gradeId 查询年级下所有班级的教学计划进度,COURSE_PLAN_READ 权限)
|
||||||
- Data-access:与 actions 对应
|
- Data-access:与 actions 对应 + `getGradeCoursePlanProgress`(✅ v4-P2-7 新增:通过 getClassesByGradeId 获取年级下所有班级,inArray 查询 course_plans + course_plan_items,返回整体进度汇总 + 按班级/科目拆分的进度矩阵)
|
||||||
|
- Types(✅ v4-P2-7 新增):`GradeCoursePlanProgressItem`(年级进度项:planId/classId/className/subjectId/subjectName/teacherName/semester/totalHours/completedHours/progressRate/status/itemCount/completedItemCount)/ `GradeCoursePlanProgressResult`(年级进度结果:gradeId + overall 汇总 + items 数组)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(合理,getAdminClasses/getStaffOptions)、`school`(合理,getAcademicYears)
|
- 依赖:`shared/*`、`@/auth`、`classes`(合理,getAdminClasses/getStaffOptions)、`school`(合理,getAcademicYears)
|
||||||
@@ -1621,20 +1630,20 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2.23 settings(设置模块)
|
## 2.23 settings(设置模块)— V3 AI 配置统一入口
|
||||||
|
|
||||||
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)。
|
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)。V3 将 AI Provider 配置统一到 `/admin/ai-settings` 独立页面,移除 `/settings?tab=ai` 标签页和考试页面内嵌弹窗。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
- Actions:`getAiProviderSummaries` / `upsertAiProviderAction` / `testAiProviderAction` / `deleteAiProviderAction`(V3 新增删除能力)
|
||||||
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
||||||
- Actions-avatar:`updateUserAvatarAction` / `removeUserAvatarAction`(✅ P2-8 新增:头像上传/删除,复用 `/api/upload` 路由)
|
- Actions-avatar:`updateUserAvatarAction` / `removeUserAvatarAction`(✅ P2-8 新增:头像上传/删除,复用 `/api/upload` 路由)
|
||||||
- Actions-notifications:`sendTestNotificationAction`(✅ P2-10 新增:发送测试通知,占位实现待接入真实通知服务)
|
- Actions-notifications:`sendTestNotificationAction`(✅ P2-10 新增:发送测试通知,占位实现待接入真实通知服务)
|
||||||
- Actions-system-settings:`getAdminSystemSettingsAction` / `saveAdminSystemSettingsAction`(✅ P0-3 新增:管理员系统设置 CRUD,4 分类 Zod 校验)
|
- Actions-system-settings:`getAdminSystemSettingsAction` / `saveAdminSystemSettingsAction`(✅ P0-3 新增:管理员系统设置 CRUD,4 分类 Zod 校验)
|
||||||
- Actions-security:`getSecurityCenterAction` / `toggleTwoFactorAction`(✅ P2-9 新增:2FA 状态查询/切换 + 最近登录历史)
|
- Actions-security:`getSecurityCenterAction` / `toggleTwoFactorAction`(✅ P2-9 新增:2FA 状态查询/切换 + 最近登录历史)
|
||||||
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `deleteAiProvider`(V3 新增:事务删除 + 自动转移默认)/ `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
||||||
- Data-access-system-settings:`getSystemSettingsByCategory` / `getAllSystemSettings` / `getSystemSetting` / `upsertSystemSetting` / `upsertSystemSettings`(✅ P0-3 新增:system_settings 表 CRUD,键值对存储模式)
|
- Data-access-system-settings:`getSystemSettingsByCategory` / `getAllSystemSettings` / `getSystemSetting` / `upsertSystemSetting` / `upsertSystemSettings`(✅ P0-3 新增:system_settings 表 CRUD,键值对存储模式)
|
||||||
- Components:`SettingsView`(统一设置页布局,5 标签页 General/Notifications/Appearance/Security/AI;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏;AI 标签页条件渲染需 `AI_CONFIGURE` 权限)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(✅ P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表)、`AvatarUpload`(✅ P2-8 新增:头像上传/预览/删除客户端组件,文件验证 + i18n)、`SecurityCenterCard`(✅ P2-9 新增:2FA 开关 + 最近登录历史卡片)、`ThemePreferencesCard`(✅ P2-11 已增强:集成 `LocaleSwitcher` 语言切换)
|
- Components:`SettingsView`(统一设置页布局,V3 移除 AI 标签页后为 4 标签页 General/Notifications/Appearance/Security;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(✅ P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表)、`AvatarUpload`(✅ P2-8 新增:头像上传/预览/删除客户端组件,文件验证 + i18n)、`SecurityCenterCard`(✅ P2-9 新增:2FA 开关 + 最近登录历史卡片)、`ThemePreferencesCard`(✅ P2-11 已增强:集成 `LocaleSwitcher` 语言切换)、`AiProviderSettingsCard`(V3 增强:新增删除按钮 + AlertDialog 确认,统一在 `/admin/ai-settings` 页面渲染)
|
||||||
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
||||||
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
||||||
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
||||||
@@ -1812,17 +1821,17 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- `FlowEdge`:流程连线(教学节点 → 教学节点)
|
- `FlowEdge`:流程连线(教学节点 → 教学节点)
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById`
|
- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById` / `publishLessonPlan`(V3 新增,设置 status=published)/ `unpublishLessonPlan`(V3 新增,设置 status=draft,仅 published 课案)
|
||||||
- Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges`
|
- Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges`
|
||||||
- Lib(`lib/anchor-injector.ts`):`markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor`
|
- Lib(`lib/anchor-injector.ts`):`markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor`
|
||||||
- Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS`
|
- Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS`
|
||||||
- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点)/ `toRfEdges`(区分 anchor/flow 边透明度)/ `fromRfEdges`
|
- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点;V3:ctx 新增 `anchorableNodes` 和 `onCreateNewNode` 字段)/ `toRfEdges`(区分 anchor/flow 边透明度;V3:锚点边颜色使用 `getNodeColor(anchor.nodeId)` 替代硬编码,anchorId 存入 edge.data)/ `fromRfEdges`(V3:从 `e.data.anchorId` 读取 anchorId,回退到 className 判断)
|
||||||
- Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions`
|
- Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions`
|
||||||
- Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate`
|
- Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate`
|
||||||
- Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion`
|
- Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion`
|
||||||
- Publish-service(`publish-service.ts`):`publishLessonPlanHomework`
|
- Publish-service(`publish-service.ts`):`publishLessonPlanHomework`
|
||||||
- AI-suggest(`ai-suggest.ts`):`suggestKnowledgePoints`
|
- AI-suggest(`ai-suggest.ts`):`suggestKnowledgePoints`
|
||||||
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction`
|
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction` / `publishLessonPlanAction`(V3 新增,requirePermission(LESSON_PLAN_PUBLISH))/ `unpublishLessonPlanAction`(V3 新增,requirePermission(LESSON_PLAN_PUBLISH))
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`shared/lib/ai`、`@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
|
- 依赖:`shared/*`、`@/auth`、`shared/lib/ai`、`@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
|
||||||
@@ -1855,6 +1864,18 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
> - **统一错误处理**:所有 Server Action catch 块改用 `handleActionError`;`JSON.parse` 改用 `safeJsonParse`
|
> - **统一错误处理**:所有 Server Action catch 块改用 `handleActionError`;`JSON.parse` 改用 `safeJsonParse`
|
||||||
> - **block-renderer 拖拽 BUG 修复**:修复拖拽时节点位置计算错误
|
> - **block-renderer 拖拽 BUG 修复**:修复拖拽时节点位置计算错误
|
||||||
|
|
||||||
|
> 架构变更(2026-06-23,V3 多角色课案查看):
|
||||||
|
> - **V3-1 课案发布/撤回**:新增 `publishLessonPlan(planId, userId)` / `unpublishLessonPlan(planId, userId)` data-access 函数 + `publishLessonPlanAction(planId)` / `unpublishLessonPlanAction(planId)` Server Actions(均 requirePermission(LESSON_PLAN_PUBLISH)),支持课案 status=published 供学生/家长/教研组长只读查看
|
||||||
|
> - **V3-2 只读画布组件**:新增 `LessonPlanReadonlyView`,复用 React Flow(nodesDraggable=false, nodesConnectable=false),供学生/家长/管理员/教研组长查看已发布课案
|
||||||
|
> - **V3-3 多角色视图**:`LessonPlanCard` 新增 viewMode prop(teacher/student/parent/admin/gradeHead),动态跳转链接 + 发布/撤回按钮;`LessonPlanEditor` 新增 initialStatus prop + 发布/撤回按钮(AlertDialog 确认);`LessonPlanList` 新增 viewMode prop
|
||||||
|
> - **V3-4 锚点选择器重写**:`TextbookContentNode` 新增 props: `anchorableNodes`, `onCreateNewNode`;AnchorNodeSelector 重写为节点列表+创建新节点选项;`NodeEditPanel` 选中 textbook_content 时显示操作提示 + 锚点列表(含删除功能)
|
||||||
|
> - **V3-5 模板分区显示**:`TemplatePicker` 加载并显示个人模板(调用 getLessonPlanTemplatesAction),分区显示系统/个人模板
|
||||||
|
> - **V3-6 rf-mappers 增强**:`toRfNodes` ctx 新增 `anchorableNodes` 和 `onCreateNewNode` 字段;`toRfEdges` 锚点边颜色使用 `getNodeColor(anchor.nodeId)` 替代硬编码,anchorId 存入 edge.data;`fromRfEdges` 从 `e.data.anchorId` 读取 anchorId,回退到 className 判断
|
||||||
|
> - **V3-7 权限扩展**:student/parent/grade_head/teaching_head 角色新增 `LESSON_PLAN_READ` 权限,可查看已发布课案
|
||||||
|
> - **V3-8 DataScope 扩展**:`class_members` 和 `children` 新增可选 `gradeIds?: string[]` 字段;auth-guard `resolveDataScope` 中 student 通过 `classEnrollments.innerJoin(classes)` 预解析 gradeIds,parent 通过孩子的 classEnrollments.innerJoin(classes) 预解析 gradeIds
|
||||||
|
> - **V3-9 新增路由**:`/student/lesson-plans`、`/student/lesson-plans/[planId]/view`、`/parent/lesson-plans`、`/parent/lesson-plans/[planId]/view`、`/admin/lesson-plans`、`/admin/lesson-plans/[planId]/view`
|
||||||
|
> - **V3-10 导航变更**:admin 导航新增「课案管理」(/admin/lesson-plans);student 导航新增「我的课案」(/student/lesson-plans);parent 导航新增「孩子课案」(/parent/lesson-plans)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -1864,34 +1885,35 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) |
|
| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) |
|
||||||
| `lib/anchor-injector.ts` | **纯函数**:锚点注入算法(markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor) |
|
| `lib/anchor-injector.ts` | **纯函数**:锚点注入算法(markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor) |
|
||||||
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColor |
|
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColor |
|
||||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(支持 textbook_content 节点 + 锚点回调)/ toRfEdges(区分 anchor/flow 边透明度)/ fromRfEdges |
|
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(支持 textbook_content 节点 + 锚点回调;V3:ctx 新增 anchorableNodes/onCreateNewNode 字段)/ toRfEdges(区分 anchor/flow 边透明度;V3:锚点边颜色使用 getNodeColor(anchor.nodeId) 替代硬编码,anchorId 存入 edge.data)/ fromRfEdges(V3:从 e.data.anchorId 读取 anchorId,回退到 className 判断) |
|
||||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(switch 渲染 11 种定制节点 + textbook_content) |
|
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(switch 渲染 11 种定制节点 + textbook_content) |
|
||||||
| `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4/V2-6)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe(V2-6 新增,返回 noopTracker 不抛错) |
|
| `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4/V2-6)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe(V2-6 新增,返回 noopTracker 不抛错) |
|
||||||
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock |
|
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock |
|
||||||
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3;V2-1:抛出 `LessonPlanDataError` 错误码;V2-3:mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫) |
|
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3;V2-1:抛出 `LessonPlanDataError` 错误码;V2-3:mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫;V3:新增 publishLessonPlan/unpublishLessonPlan 函数) |
|
||||||
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理;V2-3:mapRowToVersion 显式映射) |
|
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理;V2-3:mapRowToVersion 显式映射) |
|
||||||
| `data-access-templates.ts` | 个人模板 CRUD(V2-3:mapRowToTemplate 显式映射 + 类型守卫) |
|
| `data-access-templates.ts` | 个人模板 CRUD(V2-3:mapRowToTemplate 显式映射 + 类型守卫) |
|
||||||
| `data-access-knowledge.ts` | 按知识点/题目反查课案(V2-3:显式字段映射替代 `as unknown as`) |
|
| `data-access-knowledge.ts` | 按知识点/题目反查课案(V2-3:显式字段映射替代 `as unknown as`) |
|
||||||
| `actions.ts` | 课案 CRUD/版本/模板 Server Actions(V2-1:getTranslations i18n + 错误码捕获;V2-2:createLessonPlanAction 传入 translateTitle 翻译 SYSTEM_TEMPLATES) |
|
| `actions.ts` | 课案 CRUD/版本/模板 Server Actions(V2-1:getTranslations i18n + 错误码捕获;V2-2:createLessonPlanAction 传入 translateTitle 翻译 SYSTEM_TEMPLATES) |
|
||||||
| `actions-publish.ts` | 发布作业 Server Action(V2-1:getTranslations i18n + PUBLISH_ERROR_KEY_MAP 错误码映射) |
|
| `actions-publish.ts` | 发布作业 Server Action(V2-1:getTranslations i18n + PUBLISH_ERROR_KEY_MAP 错误码映射;V3:新增 publishLessonPlanAction/unpublishLessonPlanAction,requirePermission(LESSON_PLAN_PUBLISH)) |
|
||||||
| `actions-ai.ts` | AI 知识点建议 Server Action(V2-1:i18n + 错误码) |
|
| `actions-ai.ts` | AI 知识点建议 Server Action(V2-1:i18n + 错误码) |
|
||||||
| `actions-kp.ts` | 知识点选项 Server Action(V2-1:i18n + 错误码) |
|
| `actions-kp.ts` | 知识点选项 Server Action(V2-1:i18n + 错误码) |
|
||||||
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表;V2-1:抛出 `PublishServiceError` 错误码;V2-3:显式字段映射替代 `as unknown as`) |
|
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表;V2-1:抛出 `PublishServiceError` 错误码;V2-3:显式字段映射替代 `as unknown as`) |
|
||||||
| `ai-suggest.ts` | AI 知识点建议服务 |
|
| `ai-suggest.ts` | AI 知识点建议服务 |
|
||||||
| `seed-templates.ts` | 模板种子数据 |
|
| `seed-templates.ts` | 模板种子数据 |
|
||||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges/anchors 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode + 锚点操作 addAnchor/removeAnchor/updateAnchor + 正文节点操作 updateTextbookContent/getTextbookContentNode;实时拖动) |
|
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges/anchors 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode + 锚点操作 addAnchor/removeAnchor/updateAnchor + 正文节点操作 updateTextbookContent/getTextbookContentNode;实时拖动) |
|
||||||
| `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入) |
|
| `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入;V3:新增 viewMode prop,支持 teacher/student/parent/admin/gradeHead 多角色视图) |
|
||||||
| `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入;V2-6:duplicate/archive 调用 tracker.track) |
|
| `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入;V2-6:duplicate/archive 调用 tracker.track;V3:新增 viewMode prop(teacher/student/parent/admin/gradeHead),动态跳转链接 + 发布/撤回按钮) |
|
||||||
| `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入;V2-5:3 个表单元素 label htmlFor 关联) |
|
| `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入;V2-5:3 个表单元素 label htmlFor 关联) |
|
||||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track;V3:顶部工具栏显示教材/章节标题指示器) |
|
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track;V3:顶部工具栏显示教材/章节标题指示器;V3:新增 initialStatus prop + 发布/撤回按钮(AlertDialog 确认)) |
|
||||||
|
| `components/lesson-plan-readonly-view.tsx` | **只读画布组件**(V3 新增):复用 React Flow(nodesDraggable=false, nodesConnectable=false),供学生/家长/管理员/教研组长查看已发布课案 |
|
||||||
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置;V3:注册 textbook_content 节点类型 + 锚点回调 + 实时拖动) |
|
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置;V3:注册 textbook_content 节点类型 + 锚点回调 + 实时拖动) |
|
||||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 BlockRenderer + LessonPlanErrorBoundary 包裹,i18n 已接入;V3:处理 textbook_content 节点,教学节点类型收窄) |
|
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 BlockRenderer + LessonPlanErrorBoundary 包裹,i18n 已接入;V3:处理 textbook_content 节点,教学节点类型收窄;V3:选中 textbook_content 时显示操作提示 + 锚点列表(含删除功能)) |
|
||||||
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
||||||
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 新增):ReactMarkdown 渲染正文 + 锚点注入 + 文本选择(range 锚定)+ 点击位置(point 锚定)+ 缩放控制 + 锚点浮动菜单 |
|
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 新增):ReactMarkdown 渲染正文 + 锚点注入 + 文本选择(range 锚定)+ 点击位置(point 锚定)+ 缩放控制 + 锚点浮动菜单;V3:新增 props `anchorableNodes`, `onCreateNewNode`;AnchorNodeSelector 重写为节点列表+创建新节点选项 |
|
||||||
| `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 |
|
| `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 |
|
||||||
| `components/lesson-plan-skeleton.tsx` | **骨架屏**:VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton |
|
| `components/lesson-plan-skeleton.tsx` | **骨架屏**:VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton |
|
||||||
| `components/block-renderer.tsx` | ⚠️ @deprecated Block 渲染器(已被 NodeEditor 替代,保留向后兼容) |
|
| `components/block-renderer.tsx` | ⚠️ @deprecated Block 渲染器(已被 NodeEditor 替代,保留向后兼容) |
|
||||||
| `components/template-picker.tsx` | 模板选择器(i18n 已接入;V2-6:create 调用 tracker.track) |
|
| `components/template-picker.tsx` | 模板选择器(i18n 已接入;V2-6:create 调用 tracker.track;V3:加载并显示个人模板(调用 getLessonPlanTemplatesAction),分区显示系统/个人模板) |
|
||||||
| `components/version-history-drawer.tsx` | 版本历史抽屉(i18n 已接入;V2-6:revert 调用 tracker.track) |
|
| `components/version-history-drawer.tsx` | 版本历史抽屉(i18n 已接入;V2-6:revert 调用 tracker.track) |
|
||||||
| `components/knowledge-point-picker.tsx` | 知识点选择器(i18n 已接入) |
|
| `components/knowledge-point-picker.tsx` | 知识点选择器(i18n 已接入) |
|
||||||
| `components/question-bank-picker.tsx` | 题库选择器(i18n 已接入) |
|
| `components/question-bank-picker.tsx` | 题库选择器(i18n 已接入) |
|
||||||
@@ -1949,6 +1971,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入
|
- `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入
|
||||||
- `collectFromHomeworkSubmission`:从作业提交记录中筛选错题,去重后批量插入
|
- `collectFromHomeworkSubmission`:从作业提交记录中筛选错题,去重后批量插入
|
||||||
- 自动关联知识点(通过 `questionsToKnowledgePoints` 表)
|
- 自动关联知识点(通过 `questionsToKnowledgePoints` 表)
|
||||||
|
- subjectId 来源:`questions` 表无 subjectId 字段,考试错题从 `exams.subjectId` 获取;作业错题从 `homeworkAssignments.sourceExamId` 关联到源试卷的 `exams.subjectId` 获取(独立作业无学科归属则为 null)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
@@ -2429,7 +2452,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
|||||||
- `buildScopeClassFilter(scope: DataScope, currentUserId?: string): SQL | null`(新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤;P3-26:从 `lib/grade-utils.ts` 迁移至 `lib/scope-filter.ts`)
|
- `buildScopeClassFilter(scope: DataScope, currentUserId?: string): SQL | null`(新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤;P3-26:从 `lib/grade-utils.ts` 迁移至 `lib/scope-filter.ts`)
|
||||||
|
|
||||||
**新增导出**:
|
**新增导出**:
|
||||||
- `assertClassInScope(scope: DataScope, classId: string): string | null`(`actions.ts`,校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用)
|
- `assertClassInScope(scope: DataScope, classId: string): string | null`(✅ v4-P2-6 从 `actions.ts` 迁移至 `lib/scope-check.ts`:校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用。迁移原因:actions.ts 是 "use server" 文件要求所有 export 为 async,而 assertClassInScope 是同步函数)
|
||||||
- `PaginatedGradeRecords` 接口(`data-access.ts`,`{ records: GradeRecordListItem[]; total: number }`,配合 DB 层分页)
|
- `PaginatedGradeRecords` 接口(`data-access.ts`,`{ records: GradeRecordListItem[]; total: number }`,配合 DB 层分页)
|
||||||
|
|
||||||
### 3.6.3 homework 模块签名变更
|
### 3.6.3 homework 模块签名变更
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
43
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
43
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||||
|
import { AiUsageDashboard } from "@/modules/ai/components/ai-usage-dashboard"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "AI 配置 - Next_Edu",
|
||||||
|
description: "统一管理 AI 服务商、API 密钥与使用统计",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 统一配置页
|
||||||
|
*
|
||||||
|
* 作为 AI 模块的独立配置入口,取代:
|
||||||
|
* - /settings?tab=ai(已移除 AI 标签页)
|
||||||
|
* - 考试页面内嵌的 AI 配置弹窗(已改为跳转链接)
|
||||||
|
*
|
||||||
|
* 权限:AI_CONFIGURE(当前仅 admin 角色拥有)
|
||||||
|
*/
|
||||||
|
export default async function AiSettingsPage(): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.AI_CONFIGURE)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">AI 配置</h1>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
统一管理 AI 服务商、API 密钥与使用统计
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AiProviderSettingsCard />
|
||||||
|
<AiUsageDashboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Settings } from "lucide-react"
|
import { Settings } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
@@ -17,14 +18,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import type { Control } from "react-hook-form"
|
import type { Control } from "react-hook-form"
|
||||||
|
|
||||||
@@ -47,11 +40,6 @@ type AiProviderSelectorProps = {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
/** Provider 标签映射 */
|
/** Provider 标签映射 */
|
||||||
providerLabels?: Record<string, string>
|
providerLabels?: Record<string, string>
|
||||||
/** 管理面板触发器 */
|
|
||||||
managePanel?: React.ReactNode
|
|
||||||
/** 管理面板打开状态 */
|
|
||||||
manageOpen?: boolean
|
|
||||||
onManageOpenChange?: (open: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +47,8 @@ type AiProviderSelectorProps = {
|
|||||||
*
|
*
|
||||||
* 可复用的表单字段组件,用于选择 AI Provider。
|
* 可复用的表单字段组件,用于选择 AI Provider。
|
||||||
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
|
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
|
||||||
|
*
|
||||||
|
* V3:移除内嵌配置弹窗,"管理"按钮改为跳转到 /admin/ai-settings 统一配置页。
|
||||||
*/
|
*/
|
||||||
export function AiProviderSelector({
|
export function AiProviderSelector({
|
||||||
control,
|
control,
|
||||||
@@ -66,9 +56,6 @@ export function AiProviderSelector({
|
|||||||
providers,
|
providers,
|
||||||
loading = false,
|
loading = false,
|
||||||
providerLabels,
|
providerLabels,
|
||||||
managePanel,
|
|
||||||
manageOpen,
|
|
||||||
onManageOpenChange,
|
|
||||||
}: AiProviderSelectorProps): React.ReactNode {
|
}: AiProviderSelectorProps): React.ReactNode {
|
||||||
const t = useTranslations("ai")
|
const t = useTranslations("ai")
|
||||||
|
|
||||||
@@ -80,28 +67,12 @@ export function AiProviderSelector({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<FormLabel>{t("provider.label")}</FormLabel>
|
<FormLabel>{t("provider.label")}</FormLabel>
|
||||||
{managePanel ? (
|
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||||
<Dialog open={manageOpen} onOpenChange={onManageOpenChange}>
|
<Link href="/admin/ai-settings">
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||||
{t("provider.manage")}
|
{t("provider.manage")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[960px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("provider.manageDescription")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{managePanel}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Control, UseFormReturn } from "react-hook-form"
|
import type { Control, UseFormReturn } from "react-hook-form"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import Link from "next/link"
|
||||||
import { Settings } from "lucide-react"
|
import { Settings } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
FormField,
|
FormField,
|
||||||
@@ -27,15 +28,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card"
|
} from "@/shared/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
|
||||||
import type { AiProviderSummary } from "@/modules/settings/actions"
|
import type { AiProviderSummary } from "@/modules/settings/actions"
|
||||||
import { formatDateTime } from "@/shared/lib/utils"
|
import { formatDateTime } from "@/shared/lib/utils"
|
||||||
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
|
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
|
||||||
@@ -47,10 +39,6 @@ type ExamAiGeneratorProps = {
|
|||||||
aiProviders: AiProviderSummary[]
|
aiProviders: AiProviderSummary[]
|
||||||
setAiProviders: (providers: AiProviderSummary[]) => void
|
setAiProviders: (providers: AiProviderSummary[]) => void
|
||||||
loadingAiProviders: boolean
|
loadingAiProviders: boolean
|
||||||
providerDialogOpen: boolean
|
|
||||||
setProviderDialogOpen: (open: boolean) => void
|
|
||||||
providerDialogKey: number
|
|
||||||
setProviderDialogKey: (key: number | ((prev: number) => number)) => void
|
|
||||||
handlePreview: () => void
|
handlePreview: () => void
|
||||||
handleBackgroundPreview: () => void
|
handleBackgroundPreview: () => void
|
||||||
previewLoading: boolean
|
previewLoading: boolean
|
||||||
@@ -62,15 +50,11 @@ type ExamAiGeneratorProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExamAiGenerator({
|
export function ExamAiGenerator({
|
||||||
form,
|
form: _form,
|
||||||
control,
|
control,
|
||||||
aiProviders,
|
aiProviders,
|
||||||
setAiProviders,
|
setAiProviders: _setAiProviders,
|
||||||
loadingAiProviders,
|
loadingAiProviders,
|
||||||
providerDialogOpen,
|
|
||||||
setProviderDialogOpen,
|
|
||||||
providerDialogKey,
|
|
||||||
setProviderDialogKey,
|
|
||||||
handlePreview,
|
handlePreview,
|
||||||
handleBackgroundPreview,
|
handleBackgroundPreview,
|
||||||
previewLoading,
|
previewLoading,
|
||||||
@@ -104,41 +88,12 @@ export function ExamAiGenerator({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<FormLabel>{t("provider.label")}</FormLabel>
|
<FormLabel>{t("provider.label")}</FormLabel>
|
||||||
<Dialog
|
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||||
open={providerDialogOpen}
|
<Link href="/admin/ai-settings">
|
||||||
onOpenChange={(open) => {
|
|
||||||
setProviderDialogOpen(open)
|
|
||||||
if (open) {
|
|
||||||
setProviderDialogKey((value) => value + 1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
|
||||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||||
{t("provider.manage")}
|
{t("provider.manage")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[960px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("provider.manageDescription")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<AiProviderSettingsCard
|
|
||||||
key={providerDialogKey}
|
|
||||||
initialMode="new"
|
|
||||||
onProvidersChanged={(rows) => {
|
|
||||||
setAiProviders(rows)
|
|
||||||
const preferred = rows.find((item) => item.isDefault) ?? rows[0]
|
|
||||||
if (preferred) {
|
|
||||||
form.setValue("aiProviderId", preferred.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ export type { ExamFormValues } from "./exam-form-types"
|
|||||||
export function ExamForm() {
|
export function ExamForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [providerDialogOpen, setProviderDialogOpen] = useState(false)
|
|
||||||
const [providerDialogKey, setProviderDialogKey] = useState(0)
|
|
||||||
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||||
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||||
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
||||||
@@ -219,10 +217,6 @@ export function ExamForm() {
|
|||||||
aiProviders={aiProviders}
|
aiProviders={aiProviders}
|
||||||
setAiProviders={setAiProviders}
|
setAiProviders={setAiProviders}
|
||||||
loadingAiProviders={loadingAiProviders}
|
loadingAiProviders={loadingAiProviders}
|
||||||
providerDialogOpen={providerDialogOpen}
|
|
||||||
setProviderDialogOpen={setProviderDialogOpen}
|
|
||||||
providerDialogKey={providerDialogKey}
|
|
||||||
setProviderDialogKey={setProviderDialogKey}
|
|
||||||
handlePreview={preview.handlePreview}
|
handlePreview={preview.handlePreview}
|
||||||
handleBackgroundPreview={preview.handleBackgroundPreview}
|
handleBackgroundPreview={preview.handleBackgroundPreview}
|
||||||
previewLoading={preview.previewLoading}
|
previewLoading={preview.previewLoading}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
BookCopy,
|
BookCopy,
|
||||||
Files,
|
Files,
|
||||||
BookX,
|
BookX,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
@@ -134,6 +135,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/admin/error-book",
|
href: "/admin/error-book",
|
||||||
permission: Permissions.ERROR_BOOK_ANALYTICS_READ,
|
permission: Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "课案管理",
|
||||||
|
icon: PenTool,
|
||||||
|
href: "/admin/lesson-plans",
|
||||||
|
permission: Permissions.LESSON_PLAN_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Audit Logs",
|
title: "Audit Logs",
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
@@ -146,6 +153,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
...COMMON_NAV_ITEMS,
|
...COMMON_NAV_ITEMS,
|
||||||
|
{
|
||||||
|
title: "AI 配置",
|
||||||
|
icon: Sparkles,
|
||||||
|
href: "/admin/ai-settings",
|
||||||
|
permission: Permissions.AI_CONFIGURE,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
@@ -268,6 +281,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
permission: Permissions.GRADE_MANAGE,
|
permission: Permissions.GRADE_MANAGE,
|
||||||
items: [
|
items: [
|
||||||
{ title: "年级班级", href: "/management/grade/classes" },
|
{ title: "年级班级", href: "/management/grade/classes" },
|
||||||
|
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -287,6 +301,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
permission: Permissions.GRADE_MANAGE,
|
permission: Permissions.GRADE_MANAGE,
|
||||||
items: [
|
items: [
|
||||||
{ title: "年级班级", href: "/management/grade/classes" },
|
{ title: "年级班级", href: "/management/grade/classes" },
|
||||||
|
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -332,6 +347,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
permission: Permissions.GRADE_MANAGE,
|
permission: Permissions.GRADE_MANAGE,
|
||||||
items: [
|
items: [
|
||||||
{ title: "年级班级", href: "/management/grade/classes" },
|
{ title: "年级班级", href: "/management/grade/classes" },
|
||||||
|
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -392,6 +408,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/student/grades",
|
href: "/student/grades",
|
||||||
permission: Permissions.GRADE_RECORD_READ,
|
permission: Permissions.GRADE_RECORD_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "我的课案",
|
||||||
|
icon: PenTool,
|
||||||
|
href: "/student/lesson-plans",
|
||||||
|
permission: Permissions.LESSON_PLAN_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Attendance",
|
title: "Attendance",
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
@@ -430,6 +452,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/parent/grades",
|
href: "/parent/grades",
|
||||||
permission: Permissions.GRADE_RECORD_READ,
|
permission: Permissions.GRADE_RECORD_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "孩子课案",
|
||||||
|
icon: PenTool,
|
||||||
|
href: "/parent/lesson-plans",
|
||||||
|
permission: Permissions.LESSON_PLAN_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Attendance",
|
title: "Attendance",
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderC
|
|||||||
import {
|
import {
|
||||||
countDefaultAiProviders,
|
countDefaultAiProviders,
|
||||||
createAiProvider,
|
createAiProvider,
|
||||||
|
deleteAiProvider as deleteAiProviderRecord,
|
||||||
getAiProviderForUpdate,
|
getAiProviderForUpdate,
|
||||||
getAiProviderSummaries as fetchAiProviderSummaries,
|
getAiProviderSummaries as fetchAiProviderSummaries,
|
||||||
updateAiProvider,
|
updateAiProvider,
|
||||||
@@ -181,3 +182,31 @@ export async function testAiProviderAction(
|
|||||||
return { success: false, message: getAiErrorMessage(error) }
|
return { success: false, message: getAiErrorMessage(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeleteAiProviderSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 AI Provider
|
||||||
|
*
|
||||||
|
* 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。
|
||||||
|
*/
|
||||||
|
export async function deleteAiProviderAction(
|
||||||
|
input: z.infer<typeof DeleteAiProviderSchema>
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
await ensureUser()
|
||||||
|
const parsed = DeleteAiProviderSchema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: "Invalid provider id" }
|
||||||
|
}
|
||||||
|
await deleteAiProviderRecord(parsed.data.id)
|
||||||
|
revalidatePath("/admin/ai-settings")
|
||||||
|
revalidatePath("/settings")
|
||||||
|
return { success: true, message: "AI provider deleted", data: null }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||||||
|
return { success: false, message: "Failed to delete AI provider" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ import { z } from "zod"
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2, Save, Sparkles } from "lucide-react"
|
import { Loader2, Save, Sparkles, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -28,7 +39,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
|
import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
|
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
|
||||||
|
|
||||||
@@ -242,6 +253,44 @@ export function AiProviderSettingsCard({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
const id = form.getValues("id")
|
||||||
|
if (!id?.trim()) {
|
||||||
|
toast.error(t("deleteNeedSelect"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteAiProviderAction({ id: id.trim() })
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message ?? t("deleteSuccess"))
|
||||||
|
const summariesResult = await getAiProviderSummaries()
|
||||||
|
if (summariesResult.success && summariesResult.data) {
|
||||||
|
const rows = summariesResult.data
|
||||||
|
setProviders(rows)
|
||||||
|
onProvidersChanged?.(rows)
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const next = rows[0]
|
||||||
|
setSelectedId(next.id)
|
||||||
|
form.reset({
|
||||||
|
id: next.id,
|
||||||
|
provider: next.provider,
|
||||||
|
baseUrl: next.baseUrl ?? "",
|
||||||
|
model: next.model,
|
||||||
|
apiKey: "",
|
||||||
|
isDefault: next.isDefault,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resetToNew()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetToNew()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(result.message ?? t("deleteFailure"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -339,6 +388,29 @@ export function AiProviderSettingsCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CardFooter className="flex justify-between border-t px-0 pt-4">
|
<CardFooter className="flex justify-between border-t px-0 pt-4">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending || !form.getValues("id")?.trim()}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>{t("deleteConfirm")}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
||||||
{testStatus === "testing" ? (
|
{testStatus === "testing" ? (
|
||||||
<>
|
<>
|
||||||
@@ -365,6 +437,7 @@ export function AiProviderSettingsCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import Link from "next/link"
|
|||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, type ReactNode } from "react"
|
import { Suspense, type ReactNode } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
|
import { User, Palette, Lock, Bell } from "lucide-react"
|
||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
|
|
||||||
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
||||||
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
|
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
|
||||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||||
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
|
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
|
||||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
|
||||||
import { SecurityCenterCard } from "@/modules/settings/components/security-center-card"
|
import { SecurityCenterCard } from "@/modules/settings/components/security-center-card"
|
||||||
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -31,8 +30,6 @@ import {
|
|||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import type { UserProfile } from "@/modules/users/data-access"
|
import type { UserProfile } from "@/modules/users/data-access"
|
||||||
import type { NotificationPreferences } from "@/modules/notifications/types"
|
import type { NotificationPreferences } from "@/modules/notifications/types"
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
/** 页面副标题描述(i18n 键) */
|
/** 页面副标题描述(i18n 键) */
|
||||||
@@ -49,7 +46,7 @@ interface SettingsViewProps {
|
|||||||
currentUserAgent?: string
|
currentUserAgent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
|
const VALID_TABS = ["general", "notifications", "appearance", "security"] as const
|
||||||
type TabValue = (typeof VALID_TABS)[number]
|
type TabValue = (typeof VALID_TABS)[number]
|
||||||
|
|
||||||
function isTabValue(value: string | null): value is TabValue {
|
function isTabValue(value: string | null): value is TabValue {
|
||||||
@@ -95,15 +92,12 @@ function SettingsViewInner({
|
|||||||
const t = useTranslations("settings")
|
const t = useTranslations("settings")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { hasPermission } = usePermission()
|
|
||||||
|
|
||||||
const tabParam = searchParams.get("tab")
|
const tabParam = searchParams.get("tab")
|
||||||
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
|
|
||||||
|
|
||||||
// 解析 tab 参数,对无权限的 tab(如非管理员的 ai)回退到 general
|
// 解析 tab 参数
|
||||||
function resolveTab(value: string | null): TabValue {
|
function resolveTab(value: string | null): TabValue {
|
||||||
if (!isTabValue(value)) return "general"
|
if (!isTabValue(value)) return "general"
|
||||||
if (value === "ai" && !canConfigureAi) return "general"
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
const activeTab: TabValue = resolveTab(tabParam)
|
const activeTab: TabValue = resolveTab(tabParam)
|
||||||
@@ -151,12 +145,6 @@ function SettingsViewInner({
|
|||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4" />
|
||||||
{t("tabs.security")}
|
{t("tabs.security")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{canConfigureAi ? (
|
|
||||||
<TabsTrigger value="ai" className="gap-2">
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
{t("tabs.ai")}
|
|
||||||
</TabsTrigger>
|
|
||||||
) : null}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-6 space-y-6">
|
<TabsContent value="general" className="mt-6 space-y-6">
|
||||||
@@ -223,16 +211,6 @@ function SettingsViewInner({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</SettingsSectionErrorBoundary>
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{canConfigureAi ? (
|
|
||||||
<TabsContent value="ai" className="mt-6 space-y-6">
|
|
||||||
<SettingsSectionErrorBoundary>
|
|
||||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
|
||||||
<AiProviderSettingsCard />
|
|
||||||
</Suspense>
|
|
||||||
</SettingsSectionErrorBoundary>
|
|
||||||
</TabsContent>
|
|
||||||
) : null}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -111,6 +111,42 @@ export async function createAiProvider(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 AI Provider
|
||||||
|
*
|
||||||
|
* 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。
|
||||||
|
*/
|
||||||
|
export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolean }> {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ isDefault: aiProviders.isDefault })
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { wasDefault: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.delete(aiProviders).where(eq(aiProviders.id, id))
|
||||||
|
|
||||||
|
// 如果删除的是默认 Provider,自动选一条最新的设为默认
|
||||||
|
if (existing.isDefault) {
|
||||||
|
const [next] = await tx
|
||||||
|
.select({ id: aiProviders.id })
|
||||||
|
.from(aiProviders)
|
||||||
|
.orderBy(desc(aiProviders.updatedAt))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
await tx.update(aiProviders).set({ isDefault: true }).where(eq(aiProviders.id, next.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { wasDefault: existing.isDefault }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Password change operations ---
|
// --- Password change operations ---
|
||||||
|
|
||||||
export async function getUserPasswordHash(
|
export async function getUserPasswordHash(
|
||||||
|
|||||||
@@ -227,7 +227,15 @@
|
|||||||
"saveFailure": "Failed to save",
|
"saveFailure": "Failed to save",
|
||||||
"loadFailure": "Failed to load AI providers",
|
"loadFailure": "Failed to load AI providers",
|
||||||
"needKey": "Please enter API key to test",
|
"needKey": "Please enter API key to test",
|
||||||
"needTest": "Please test the configuration before saving"
|
"needTest": "Please test the configuration before saving",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteNeedSelect": "Please select a provider to delete",
|
||||||
|
"deleteSuccess": "Provider deleted",
|
||||||
|
"deleteFailure": "Failed to delete provider",
|
||||||
|
"deleteConfirmTitle": "Confirm deletion",
|
||||||
|
"deleteConfirmDescription": "This action cannot be undone. If the deleted provider was the default, the most recent provider will be set as default automatically.",
|
||||||
|
"deleteConfirm": "Delete",
|
||||||
|
"deleteCancel": "Cancel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quickLinks": {
|
"quickLinks": {
|
||||||
|
|||||||
@@ -227,7 +227,15 @@
|
|||||||
"saveFailure": "保存失败",
|
"saveFailure": "保存失败",
|
||||||
"loadFailure": "加载 AI 服务商失败",
|
"loadFailure": "加载 AI 服务商失败",
|
||||||
"needKey": "请输入 API 密钥进行测试",
|
"needKey": "请输入 API 密钥进行测试",
|
||||||
"needTest": "保存前请先测试配置"
|
"needTest": "保存前请先测试配置",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteNeedSelect": "请先选择要删除的服务商",
|
||||||
|
"deleteSuccess": "已删除服务商",
|
||||||
|
"deleteFailure": "删除失败",
|
||||||
|
"deleteConfirmTitle": "确认删除",
|
||||||
|
"deleteConfirmDescription": "此操作不可撤销。删除后若该服务商为默认,将自动选择最新的服务商作为默认。",
|
||||||
|
"deleteConfirm": "确认删除",
|
||||||
|
"deleteCancel": "取消"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quickLinks": {
|
"quickLinks": {
|
||||||
|
|||||||
Reference in New Issue
Block a user