feat(settings): 设置与个人信息模块审计重构 — i18n + 服务注入解耦 + Error Boundary + 流式渲染
- 新增 SettingsService 接口 + Context 注入,组件层不再直接 import users/messaging actions - 新增 resolveRoleSettingsConfig 配置驱动角色路由,删除 parent/student/teacher-settings-view 冗余文件 - 新增 SettingsSectionErrorBoundary,每个 TabsContent + profile 角色概览区块均包裹 - 新增 ProfileStudentOverview/ProfileTeacherOverview 异步 Server Component + 骨架屏,支持流式渲染 - 抽取 buildStudentOverviewData 等纯函数到 lib/student-overview-data.ts,便于单元测试 - 新增 settings.json 翻译文件(zh-CN + en),所有组件改用 useTranslations/getTranslations - 重构 profile/page.tsx:i18n 适配 + Suspense 分区加载 + 业务逻辑抽离 - 同步更新架构图 004/005
This commit is contained in:
@@ -1327,17 +1327,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
## 2.23 settings(设置模块)
|
## 2.23 settings(设置模块)
|
||||||
|
|
||||||
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好。
|
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
||||||
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
||||||
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
||||||
- Components:`SettingsView`(P2-a 新增:统一设置页布局,消除 admin/teacher/student/parent 四个设置视图的重复布局;5 标签页 General/Notifications/Appearance/Security/AI,角色差异通过 `description` / `backHref` / `generalExtra` 三个 props 注入;Tab 通过 URL `?tab=` 参数持久化;AI 标签页条件渲染需 `AI_CONFIGURE` 权限;登出按钮使用 AlertDialog 二次确认;4 个消费方:admin/teacher/student/parent 设置页)、`ParentSettingsView`(家长设置视图,backHref 指向 `/parent/dashboard`,含家长专属快捷链接)、`AdminSettingsView`(系统设置视图,4 个 Card:学校信息/安全策略/文件上传/通知配置;消费方:`/admin/settings` 页面,权限 `SETTINGS_ADMIN`)
|
- 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`(系统设置视图,4 个 Card)
|
||||||
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting`(P1 新增,从 actions.ts 迁出)
|
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
||||||
|
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
||||||
|
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`(含 `shared/lib/bcrypt-utils`,✅ P2 已修复:`actions-password.ts` 删除本地 `normalizeBcryptHash`,统一复用 `shared/lib/bcrypt-utils.normalizeBcryptHash`)、`@/auth`、`messaging`(通知偏好表单调用 messaging Action)
|
- 依赖:`shared/*`(含 `shared/lib/bcrypt-utils`)、`@/auth`、`messaging`(页面层通过 `SettingsService` 接口注入,组件层不直接 import)、`users`(页面层通过 `SettingsService` 接口注入)、`classes` / `homework` / `dashboard`(ProfileStudentOverview 异步组件获取学生概览数据)、`notifications`(页面层获取通知偏好)
|
||||||
- 被依赖:无
|
- 被依赖:无
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
@@ -1345,27 +1347,42 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P1 已修复:~~无 `data-access.ts`,`actions.ts` 直接使用 `db`~~ 新建 `data-access.ts`,所有 DB 操作已下沉
|
- ✅ P1 已修复:~~无 `data-access.ts`,`actions.ts` 直接使用 `db`~~ 新建 `data-access.ts`,所有 DB 操作已下沉
|
||||||
- ✅ P1 已修复:~~`changePasswordAction` 使用 `requireAuth()` 无 Zod 校验~~ 改为 `requirePermission(USER_PROFILE_UPDATE)` + `ChangePasswordSchema` Zod 校验 + 并行查询优化
|
- ✅ P1 已修复:~~`changePasswordAction` 使用 `requireAuth()` 无 Zod 校验~~ 改为 `requirePermission(USER_PROFILE_UPDATE)` + `ChangePasswordSchema` Zod 校验 + 并行查询优化
|
||||||
- ✅ P2 已修复:`actions-password.ts` 删除本地 `normalizeBcryptHash`,统一复用 `shared/lib/bcrypt-utils.normalizeBcryptHash`,消除重复代码
|
- ✅ P2 已修复:`actions-password.ts` 删除本地 `normalizeBcryptHash`,统一复用 `shared/lib/bcrypt-utils.normalizeBcryptHash`,消除重复代码
|
||||||
- ✅ P2-a 已修复:~~admin/teacher/student 三个设置视图重复布局~~ 新增 `SettingsView` 统一设置页布局(5 标签页 + 角色差异通过 props 注入),4 个设置页改为消费 `SettingsView`
|
- ✅ P2-a 已修复:~~admin/teacher/student/parent 四个设置视图重复布局~~ 新增 `SettingsView` 统一设置页布局 + `resolveRoleSettingsConfig` 配置驱动角色路由,删除 `parent-settings-view.tsx` / `student-settings-view.tsx` / `teacher-settings-view.tsx`
|
||||||
- ✅ parent 角色路由已修复:~~parent 用户被错误渲染为 TeacherSettingsView~~ 新增 `ParentSettingsView`,`/settings` 页面增加 parent 角色分支
|
- ✅ parent 角色路由已修复:通过 `ROLE_SETTINGS_CONFIG` 配置驱动,parent 用户正确渲染对应配置
|
||||||
- ✅ Tab URL 持久化已修复:`SettingsView` 改为受控模式,通过 `useSearchParams` 读取 `tab` 参数,`router.push` 更新 URL
|
- ✅ Tab URL 持久化已修复:`SettingsView` 改为受控模式,通过 `useSearchParams` 读取 `tab` 参数,`router.push` 更新 URL
|
||||||
- ✅ 登出二次确认已修复:`SettingsView` 的 Log out 按钮使用 `AlertDialog` 包裹,点击时弹出确认对话框
|
- ✅ 登出二次确认已修复:`SettingsView` 的 Log out 按钮使用 `AlertDialog` 包裹,点击时弹出确认对话框
|
||||||
- ✅ AiProviderSettingsCard 已集成:`SettingsView` 新增 AI 标签页,条件渲染需 `AI_CONFIGURE` 权限
|
- ✅ AiProviderSettingsCard 已集成:`SettingsView` 新增 AI 标签页,条件渲染需 `AI_CONFIGURE` 权限
|
||||||
- ✅ password-change-form 任意值 Tailwind 类已修复:~~`[&>div]:bg-red-500` 等任意值类~~ Progress 组件新增 `indicatorClassName` prop,使用标准颜色类
|
- ✅ password-change-form 任意值 Tailwind 类已修复:~~`[&>div]:bg-red-500` 等任意值类~~ Progress 组件新增 `indicatorClassName` prop,使用标准颜色类
|
||||||
- ⚠️ P2:`notification-preferences-form.tsx` 跨模块 UI 依赖
|
- ✅ P0 已修复:~~`notification-preferences-form.tsx` 跨模块直接 import messaging/actions~~ 改为通过 `useSettingsService().notifications.updatePreferences` 调用,页面层注入实现
|
||||||
|
- ✅ P0 已修复:~~`profile-settings-form.tsx` 跨模块直接 import users/actions~~ 改为通过 `useSettingsService().profile.updateProfile` 调用,页面层注入实现
|
||||||
|
- ✅ P0 已修复:~~i18n 完全缺失~~ 新增 `settings.json` 翻译文件(zh-CN + en),所有组件改用 `useTranslations` / `getTranslations`
|
||||||
|
- ✅ P1 已修复:~~缺少 Error Boundary~~ 新增 `SettingsSectionErrorBoundary`,每个 TabsContent + profile 页面角色概览区块均包裹
|
||||||
|
- ✅ P1 已修复:~~缺少 Suspense 骨架屏~~ 每个 TabsContent 包裹 `Suspense` + `SettingsSectionSkeleton`;profile 页面包裹 `ProfileStudentOverviewSkeleton` / `ProfileTeacherOverviewSkeleton`
|
||||||
|
- ✅ P1 已修复:~~profile/page.tsx 业务逻辑与 UI 混合~~ 抽取 `buildStudentOverviewData` 等纯函数到 `lib/student-overview-data.ts`;拆分 `ProfileStudentOverview` / `ProfileTeacherOverview` 异步组件
|
||||||
- ✅ 密码修改有速率限制
|
- ✅ 密码修改有速率限制
|
||||||
- ✅ AI Provider 操作有 `AI_CONFIGURE` 权限校验
|
- ✅ AI Provider 操作有 `AI_CONFIGURE` 权限校验
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 178 | AI Provider CRUD + 测试(P1 已修复,无直接 DB 操作) |
|
| `actions.ts` | 160 | AI Provider CRUD + 测试(P1 已修复,无直接 DB 操作) |
|
||||||
| `actions-password.ts` | 107 | 修改密码(P1 已修复:requirePermission + Zod + data-access) |
|
| `actions-password.ts` | 87 | 修改密码(P1 已修复:requirePermission + Zod + data-access) |
|
||||||
| `data-access.ts` | 175 | AI Provider CRUD + 密码修改 DB 操作(P1 新增) |
|
| `data-access.ts` | 158 | AI Provider CRUD + 密码修改 DB 操作(P1 新增) |
|
||||||
| `types.ts` | 16 | 类型定义(P1 新增,AiProviderSummary 等) |
|
| `types.ts` | 60 | 类型定义(AiProviderSummary + SettingsService/ProfileService/NotificationPreferenceService 接口) |
|
||||||
| `components/settings-view.tsx` | 196 | SettingsView 统一设置页布局(P2-a 新增,5 标签页 + props 注入角色差异 + Tab URL 持久化 + 登出二次确认 + AI 标签页) |
|
| `config/role-settings-config.tsx` | 85 | 角色设置页配置驱动映射(ROLE_SETTINGS_CONFIG + resolveRoleSettingsConfig) |
|
||||||
| `components/admin-settings-view.tsx` | 195 | AdminSettingsView 系统设置视图(4 个 Card:学校信息/安全策略/文件上传/通知配置,模拟保存) |
|
| `lib/student-overview-data.ts` | 150 | 学生概览纯数据计算(buildStudentOverviewData + computeStudentStats 等,便于单测) |
|
||||||
| `components/parent-settings-view.tsx` | 70 | ParentSettingsView 家长设置视图(新增,复用 SettingsView 布局) |
|
| `components/settings-view.tsx` | 236 | SettingsView 统一设置页布局(5 标签页 + Error Boundary + Suspense + i18n) |
|
||||||
| `components/*` | 9 文件 | 通用设置 + AI 配置 + 密码 + 主题 + 通知偏好 + 4 角色设置视图 |
|
| `components/settings-service-context.tsx` | 39 | SettingsServiceProvider + useSettingsService(Context 注入服务接口) |
|
||||||
|
| `components/settings-section-error-boundary.tsx` | 64 | 分区 Error Boundary(局部失败不影响整页) |
|
||||||
|
| `components/quick-links-card.tsx` | 42 | 快捷链接卡片(i18n 键驱动) |
|
||||||
|
| `components/profile-student-overview.tsx` | 91 | 学生概览异步 Server Component + 骨架屏 |
|
||||||
|
| `components/profile-teacher-overview.tsx` | 115 | 教师概览异步 Server Component + 骨架屏 |
|
||||||
|
| `components/admin-settings-view.tsx` | 195 | AdminSettingsView 系统设置视图(4 个 Card,i18n) |
|
||||||
|
| `components/profile-settings-form.tsx` | 158 | 个人资料表单(通过 SettingsService 注入,i18n) |
|
||||||
|
| `components/notification-preferences-form.tsx` | ~140 | 通知偏好表单(通过 SettingsService 注入,i18n) |
|
||||||
|
| `components/password-change-form.tsx` | ~130 | 密码修改表单(i18n + a11y) |
|
||||||
|
| `components/theme-preferences-card.tsx` | ~60 | 主题偏好卡片(i18n) |
|
||||||
|
| `components/ai-provider-settings-card.tsx` | ~200 | AI 服务商配置卡片(i18n) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1488,13 +1505,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ 编辑器架构升级:NodeEditor(React Flow 画布)+ NodeEditPanel(侧边内容编辑面板)+ LessonNode(自定义节点组件),支持节点拖拽、连线、画布缩放
|
- ✅ 编辑器架构升级:NodeEditor(React Flow 画布)+ NodeEditPanel(侧边内容编辑面板)+ LessonNode(自定义节点组件),支持节点拖拽、连线、画布缩放
|
||||||
- ⚠️ `block-renderer.tsx` 标记为 @deprecated(已被 NodeEditor 替代,保留用于向后兼容)
|
- ⚠️ `block-renderer.tsx` 标记为 @deprecated(已被 NodeEditor 替代,保留用于向后兼容)
|
||||||
|
|
||||||
|
> 架构变更(2026-06-22,本次审计修复):
|
||||||
|
> - **P0-1 跨模块直查修复**:`publish-service.ts` 不再直接 `db.insert(examQuestions)` 和本地实现 `getStudentIdsByClassIds`,改为调用 `exams/data-access.addExamQuestions` 和 `classes/data-access.getStudentIdsByClassIds`,恢复三层架构约束
|
||||||
|
> - **P0-2 i18n 接入**:新增 `shared/i18n/messages/zh-CN/lesson-preparation.json` 和 `shared/i18n/messages/en/lesson-preparation.json`,注册 `lessonPreparation` 命名空间到 `src/i18n/request.ts`,17 个组件改造为 `useTranslations`/`getTranslations`
|
||||||
|
> - **P1 纯函数抽取**:新增 `lib/document-migration.ts`(migrateV1ToV2/normalizeDocument/buildInitialContent,使用类型守卫替代 as 断言)、`lib/node-summary.ts`(getNodeSummary + NODE_COLORS + getNodeColor,接受翻译函数注入)、`lib/rf-mappers.ts`(toRfNodes/toRfEdges/fromRfEdges),data-access.ts 改为从 lib/ 导入并 re-export 保持向后兼容
|
||||||
|
> - **P1 错误边界 + 骨架屏**:新增 `components/lesson-plan-error-boundary.tsx`(LessonPlanErrorBoundary 类组件)和 `components/lesson-plan-skeleton.tsx`(VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton)
|
||||||
|
> - **P1 Block 注册表**:新增 `config/block-registry.tsx`(BLOCK_REGISTRY 配置表 + getBlockComponent/isRichTextBlock),`node-edit-panel.tsx` 重构为配置驱动渲染,移除 if/else 链
|
||||||
|
> - **P1 window.location.reload 修复**:`exercise-block.tsx` 改用 `router.refresh()` 精确刷新缓存
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `types.ts` | 类型定义(含 v1/v2 文档类型、LessonPlanNode、LessonPlanEdge) |
|
| `types.ts` | 类型定义(含 v1/v2 文档类型、LessonPlanNode、LessonPlanEdge) |
|
||||||
| `constants.ts` | 常量定义 |
|
| `constants.ts` | 常量定义 |
|
||||||
| `schema.ts` | Zod 验证 |
|
| `schema.ts` | Zod 验证 |
|
||||||
| `data-access.ts` | 课案 CRUD + 模板查询 + 初始内容构建 + v1→v2 迁移(migrateV1ToV2 / normalizeDocument) |
|
| `lib/document-migration.ts` | **纯函数**:v1→v2 迁移(migrateV1ToV2)/ 规范化(normalizeDocument)/ 初始内容构建(buildInitialContent),使用类型守卫 isV1Document/isV2Document 替代 as 断言 |
|
||||||
|
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(接受翻译函数注入,支持 i18n)+ NODE_COLORS + getNodeColor |
|
||||||
|
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes/toRfEdges/fromRfEdges(LessonPlanNode/Edge ↔ React Flow Node/Edge 映射) |
|
||||||
|
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlock,node-edit-panel 通过配置渲染 Block |
|
||||||
|
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容) |
|
||||||
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
|
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
|
||||||
| `data-access-templates.ts` | 个人模板 CRUD |
|
| `data-access-templates.ts` | 个人模板 CRUD |
|
||||||
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
|
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
|
||||||
@@ -1502,28 +1531,30 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `actions-publish.ts` | 发布作业 Server Action |
|
| `actions-publish.ts` | 发布作业 Server Action |
|
||||||
| `actions-ai.ts` | AI 知识点建议 Server Action |
|
| `actions-ai.ts` | AI 知识点建议 Server Action |
|
||||||
| `actions-kp.ts` | 知识点选项 Server Action |
|
| `actions-kp.ts` | 知识点选项 Server Action |
|
||||||
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes) |
|
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表) |
|
||||||
| `ai-suggest.ts` | AI 知识点建议服务 |
|
| `ai-suggest.ts` | AI 知识点建议服务 |
|
||||||
| `seed-templates.ts` | 模板种子数据 |
|
| `seed-templates.ts` | 模板种子数据 |
|
||||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode) |
|
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode) |
|
||||||
| `components/lesson-plan-list.tsx` | 课案列表 |
|
| `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入) |
|
||||||
| `components/lesson-plan-card.tsx` | 课案卡片 |
|
| `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入) |
|
||||||
| `components/lesson-plan-filters.tsx` | 课案筛选器 |
|
| `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入) |
|
||||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel) |
|
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入) |
|
||||||
| `components/node-editor.tsx` | **节点图画布**(React Flow,自定义 LessonNode,支持拖拽/连线/缩放) |
|
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入) |
|
||||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(选中节点后编辑标题/数据) |
|
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 getBlockComponent + LessonPlanErrorBoundary 包裹,i18n 已接入) |
|
||||||
| `components/nodes/lesson-node.tsx` | **自定义节点组件**(按 BlockType 显示图标/颜色,含 Handle 连接点) |
|
| `components/nodes/lesson-node.tsx` | **自定义节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
||||||
|
| `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 |
|
||||||
|
| `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` | 模板选择器 |
|
| `components/template-picker.tsx` | 模板选择器(i18n 已接入) |
|
||||||
| `components/version-history-drawer.tsx` | 版本历史抽屉 |
|
| `components/version-history-drawer.tsx` | 版本历史抽屉(i18n 已接入) |
|
||||||
| `components/knowledge-point-picker.tsx` | 知识点选择器 |
|
| `components/knowledge-point-picker.tsx` | 知识点选择器(i18n 已接入) |
|
||||||
| `components/question-bank-picker.tsx` | 题库选择器 |
|
| `components/question-bank-picker.tsx` | 题库选择器(i18n 已接入) |
|
||||||
| `components/inline-question-editor.tsx` | 内联题目编辑器 |
|
| `components/inline-question-editor.tsx` | 内联题目编辑器(i18n 已接入) |
|
||||||
| `components/publish-homework-dialog.tsx` | 发布作业对话框 |
|
| `components/publish-homework-dialog.tsx` | 发布作业对话框(i18n 已接入) |
|
||||||
| `components/blocks/rich-text-block.tsx` | 富文本 Block(被 NodeEditPanel 复用) |
|
| `components/blocks/rich-text-block.tsx` | 富文本 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||||
| `components/blocks/text-study-block.tsx` | 课文研读 Block(被 NodeEditPanel 复用) |
|
| `components/blocks/text-study-block.tsx` | 课文研读 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||||
| `components/blocks/exercise-block.tsx` | 练习 Block(被 NodeEditPanel 复用) |
|
| `components/blocks/exercise-block.tsx` | 练习 Block(被 NodeEditPanel 复用,使用 router.refresh 替代 window.location.reload,i18n 已接入) |
|
||||||
| `components/blocks/reflection-block.tsx` | 反思 Block(被 NodeEditPanel 复用) |
|
| `components/blocks/reflection-block.tsx` | 反思 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2735,6 +2735,14 @@
|
|||||||
"createExamAction"
|
"createExamAction"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "addExamQuestions",
|
||||||
|
"signature": "addExamQuestions(examId: string, items: Array<{ questionId: string; score: number; order: number }>): Promise<void>",
|
||||||
|
"purpose": "批量插入考试-题目关联(跨模块写接口,供 lesson-preparation/publish-service 调用,避免直查 examQuestions 表)",
|
||||||
|
"usedBy": [
|
||||||
|
"lesson-preparation/publish-service.publishLessonPlanHomework"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "persistAiGeneratedExamDraft",
|
"name": "persistAiGeneratedExamDraft",
|
||||||
"signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise<void>",
|
"signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise<void>",
|
||||||
@@ -6258,56 +6266,125 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"data-access.getAiProviderForUpdate"
|
"data-access.getAiProviderForUpdate"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SettingsService",
|
||||||
|
"file": "types.ts",
|
||||||
|
"type": "interface",
|
||||||
|
"definition": "设置模块统一服务接口(profile + notifications + trackEvent),通过 React Context 注入实现解耦",
|
||||||
|
"usedBy": [
|
||||||
|
"components/settings-service-context.tsx",
|
||||||
|
"app/(dashboard)/settings/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ProfileService",
|
||||||
|
"file": "types.ts",
|
||||||
|
"type": "interface",
|
||||||
|
"definition": "个人资料服务接口(getProfile + updateProfile),解耦组件对 users/actions 的直接依赖",
|
||||||
|
"usedBy": [
|
||||||
|
"types.SettingsService",
|
||||||
|
"components/profile-settings-form.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NotificationPreferenceService",
|
||||||
|
"file": "types.ts",
|
||||||
|
"type": "interface",
|
||||||
|
"definition": "通知偏好服务接口(getPreferences + updatePreferences),解耦组件对 messaging/actions 的直接依赖",
|
||||||
|
"usedBy": [
|
||||||
|
"types.SettingsService",
|
||||||
|
"components/notification-preferences-form.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"name": "AiProviderSettingsCard",
|
"name": "AiProviderSettingsCard",
|
||||||
"purpose": "AI Provider设置卡片"
|
"purpose": "AI Provider设置卡片(i18n:settings.ai.providers)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "AdminSettingsView",
|
"name": "AdminSettingsView",
|
||||||
"purpose": "系统设置视图(学校信息/安全策略/文件上传/通知配置 4 个 Card,模拟保存;消费方:/admin/settings 页面)",
|
"purpose": "系统设置视图(学校信息/安全策略/文件上传/通知配置 4 个 Card,i18n;消费方:/admin/settings 页面)",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"app/(dashboard)/admin/settings/page.tsx"
|
"app/(dashboard)/admin/settings/page.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ProfileSettingsForm",
|
"name": "ProfileSettingsForm",
|
||||||
"purpose": "个人资料设置表单"
|
"purpose": "个人资料设置表单(通过 useSettingsService().profile.updateProfile 调用,i18n:settings.profile)",
|
||||||
|
"deps": [
|
||||||
|
"useSettingsService",
|
||||||
|
"shared/components/form-fields/text-field",
|
||||||
|
"shared/components/form-fields/select-field"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ThemePreferencesCard",
|
"name": "ThemePreferencesCard",
|
||||||
"purpose": "主题偏好卡片"
|
"purpose": "主题偏好卡片(i18n:settings.appearance)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StudentSettingsView",
|
"name": "SettingsView",
|
||||||
"purpose": "学生设置视图(含 Notifications tab)"
|
"purpose": "统一设置页布局(5 标签页:General/Notifications/Appearance/Security/AI;角色差异通过 resolveRoleSettingsConfig 配置驱动 + generalExtra props 注入;Tab URL 持久化;每个 TabsContent 包裹 SettingsSectionErrorBoundary + Suspense 骨架屏;AI 标签页条件渲染需 AI_CONFIGURE 权限;登出 AlertDialog 二次确认;i18n:settings 命名空间)",
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TeacherSettingsView",
|
|
||||||
"purpose": "教师设置视图(含 Notifications tab)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ParentSettingsView",
|
|
||||||
"purpose": "家长设置视图(复用 SettingsView 布局,backHref 指向 /parent/dashboard,含家长专属快捷链接;消费方:/settings 页面 parent 角色分支)",
|
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"app/(dashboard)/settings/page.tsx"
|
"app/(dashboard)/settings/page.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "SettingsView",
|
"name": "SettingsServiceProvider",
|
||||||
"purpose": "统一设置页布局(5 标签页:General/Notifications/Appearance/Security/AI,角色差异通过 description/backHref/generalExtra 三个 props 注入;Tab 通过 URL ?tab= 参数持久化;AI 标签页条件渲染需 AI_CONFIGURE 权限;登出按钮使用 AlertDialog 二次确认;4 个消费方:admin/teacher/student/parent 设置页)",
|
"file": "components/settings-service-context.tsx",
|
||||||
|
"purpose": "SettingsService React Context Provider,页面层注入服务实现,组件层通过 useSettingsService() 消费",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"AdminSettingsView",
|
"app/(dashboard)/settings/page.tsx"
|
||||||
"TeacherSettingsView",
|
]
|
||||||
"StudentSettingsView",
|
},
|
||||||
"ParentSettingsView"
|
{
|
||||||
|
"name": "SettingsSectionErrorBoundary",
|
||||||
|
"file": "components/settings-section-error-boundary.tsx",
|
||||||
|
"purpose": "分区 Error Boundary,包裹每个 TabsContent 和 profile 角色概览区块,局部失败不影响整页",
|
||||||
|
"usedBy": [
|
||||||
|
"components/settings-view.tsx",
|
||||||
|
"app/(dashboard)/profile/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QuickLinksCard",
|
||||||
|
"file": "components/quick-links-card.tsx",
|
||||||
|
"purpose": "快捷链接卡片(客户端组件,i18n 键驱动:settings.quickLinks)",
|
||||||
|
"usedBy": [
|
||||||
|
"config/role-settings-config.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ProfileStudentOverview",
|
||||||
|
"file": "components/profile-student-overview.tsx",
|
||||||
|
"purpose": "学生概览异步 Server Component,独立获取学生数据并渲染(StatsGrid + UpcomingAssignments + Grades + TodaySchedule),可被 Suspense + ErrorBoundary 包裹实现流式渲染",
|
||||||
|
"deps": [
|
||||||
|
"classes/data-access.getStudentClasses",
|
||||||
|
"classes/data-access.getStudentSchedule",
|
||||||
|
"homework/data-access.getStudentHomeworkAssignments",
|
||||||
|
"homework/data-access.getStudentDashboardGrades",
|
||||||
|
"lib/student-overview-data.buildStudentOverviewData"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"app/(dashboard)/profile/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ProfileTeacherOverview",
|
||||||
|
"file": "components/profile-teacher-overview.tsx",
|
||||||
|
"purpose": "教师概览异步 Server Component,独立获取教师数据并渲染(任教科目 + 任教班级),可被 Suspense + ErrorBoundary 包裹",
|
||||||
|
"deps": [
|
||||||
|
"classes/data-access.getTeacherClasses",
|
||||||
|
"classes/data-access.getTeacherTeachingSubjects"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"app/(dashboard)/profile/page.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "PasswordChangeForm",
|
"name": "PasswordChangeForm",
|
||||||
"purpose": "密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示)",
|
"purpose": "密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示;i18n:settings.security;a11y:aria-label)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"changePasswordAction",
|
"changePasswordAction",
|
||||||
"getPasswordStrength",
|
"getPasswordStrength",
|
||||||
@@ -6317,17 +6394,14 @@
|
|||||||
{
|
{
|
||||||
"name": "NotificationPreferencesForm",
|
"name": "NotificationPreferencesForm",
|
||||||
"file": "components/notification-preferences-form.tsx",
|
"file": "components/notification-preferences-form.tsx",
|
||||||
"purpose": "通知偏好设置表单(Switch 切换 email/sms/push 通道 + 5 个分类开关:作业/成绩/公告/消息/考勤;隐藏 checkbox 与 Switch 同步,useActionState 调用 updateNotificationPreferencesAction)",
|
"purpose": "通知偏好设置表单(Switch 切换 email/sms/push 通道 + 5 个分类开关;通过 useSettingsService().notifications.updatePreferences 调用,i18n:settings.notifications)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"updateNotificationPreferencesAction",
|
"useSettingsService",
|
||||||
"shared/components/ui/switch",
|
"shared/components/ui/switch",
|
||||||
"shared/components/ui/card",
|
"shared/components/ui/card"
|
||||||
"react.useActionState"
|
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"TeacherSettingsView",
|
"SettingsView"
|
||||||
"StudentSettingsView",
|
|
||||||
"ParentSettingsView"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -12083,17 +12157,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "buildInitialContent",
|
"name": "buildInitialContent",
|
||||||
"file": "data-access.ts",
|
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||||
"purpose": "基于模板构建初始课案内容(v2 nodes+edges)"
|
"purpose": "基于模板构建初始课案内容(v2 nodes+edges)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "migrateV1ToV2",
|
"name": "migrateV1ToV2",
|
||||||
"file": "data-access.ts",
|
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||||
"purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges(节点按网格布局)"
|
"purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges(节点按网格布局),使用类型守卫 isV1Document/isV2Document 替代 as 断言"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "normalizeDocument",
|
"name": "normalizeDocument",
|
||||||
"file": "data-access.ts",
|
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||||
"purpose": "规范化:确保 content 为 v2 格式,兼容旧 v1 数据(自动调用 migrateV1ToV2)"
|
"purpose": "规范化:确保 content 为 v2 格式,兼容旧 v1 数据(自动调用 migrateV1ToV2)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -12149,7 +12223,7 @@
|
|||||||
{
|
{
|
||||||
"name": "publishLessonPlanHomework",
|
"name": "publishLessonPlanHomework",
|
||||||
"file": "publish-service.ts",
|
"file": "publish-service.ts",
|
||||||
"purpose": "发布课案为作业(编排 homework/exams/classes)"
|
"purpose": "发布课案为作业(编排 homework/exams/classes,通过对方 data-access 调用 addExamQuestions/getStudentIdsByClassIds,无直查跨模块表)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "suggestKnowledgePoints",
|
"name": "suggestKnowledgePoints",
|
||||||
@@ -12264,6 +12338,10 @@
|
|||||||
"types.ts",
|
"types.ts",
|
||||||
"constants.ts",
|
"constants.ts",
|
||||||
"schema.ts",
|
"schema.ts",
|
||||||
|
"lib/document-migration.ts",
|
||||||
|
"lib/node-summary.ts",
|
||||||
|
"lib/rf-mappers.ts",
|
||||||
|
"config/block-registry.tsx",
|
||||||
"data-access.ts",
|
"data-access.ts",
|
||||||
"data-access-versions.ts",
|
"data-access-versions.ts",
|
||||||
"data-access-templates.ts",
|
"data-access-templates.ts",
|
||||||
@@ -12283,6 +12361,8 @@
|
|||||||
"components/node-editor.tsx",
|
"components/node-editor.tsx",
|
||||||
"components/node-edit-panel.tsx",
|
"components/node-edit-panel.tsx",
|
||||||
"components/nodes/lesson-node.tsx",
|
"components/nodes/lesson-node.tsx",
|
||||||
|
"components/lesson-plan-error-boundary.tsx",
|
||||||
|
"components/lesson-plan-skeleton.tsx",
|
||||||
"components/block-renderer.tsx",
|
"components/block-renderer.tsx",
|
||||||
"components/template-picker.tsx",
|
"components/template-picker.tsx",
|
||||||
"components/version-history-drawer.tsx",
|
"components/version-history-drawer.tsx",
|
||||||
@@ -13027,7 +13107,11 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"shared",
|
"shared",
|
||||||
"auth",
|
"auth",
|
||||||
"messaging"
|
"classes",
|
||||||
|
"homework",
|
||||||
|
"dashboard",
|
||||||
|
"users",
|
||||||
|
"notifications"
|
||||||
],
|
],
|
||||||
"uses": {
|
"uses": {
|
||||||
"shared": [
|
"shared": [
|
||||||
@@ -13035,17 +13119,41 @@
|
|||||||
"auth-guard",
|
"auth-guard",
|
||||||
"ai",
|
"ai",
|
||||||
"types",
|
"types",
|
||||||
"components.ui.switch"
|
"components.ui.switch",
|
||||||
|
"components.ui.card",
|
||||||
|
"components.ui.tabs",
|
||||||
|
"components.ui.alert-dialog",
|
||||||
|
"components.form-fields"
|
||||||
],
|
],
|
||||||
"auth": [
|
"auth": [
|
||||||
"auth"
|
"auth"
|
||||||
],
|
],
|
||||||
"messaging": [
|
"classes": [
|
||||||
"notification-preferences.getNotificationPreferences",
|
"data-access.getStudentClasses",
|
||||||
"actions.getNotificationPreferencesAction",
|
"data-access.getStudentSchedule",
|
||||||
"actions.updateNotificationPreferencesAction"
|
"data-access.getTeacherClasses",
|
||||||
|
"data-access.getTeacherTeachingSubjects"
|
||||||
|
],
|
||||||
|
"homework": [
|
||||||
|
"data-access.getStudentHomeworkAssignments",
|
||||||
|
"data-access.getStudentDashboardGrades"
|
||||||
|
],
|
||||||
|
"dashboard": [
|
||||||
|
"components.student-dashboard.student-grades-card",
|
||||||
|
"components.student-dashboard.student-stats-grid",
|
||||||
|
"components.student-dashboard.student-today-schedule-card",
|
||||||
|
"components.student-dashboard.student-upcoming-assignments-card"
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
"data-access.UserProfile",
|
||||||
|
"data-access.UpdateUserProfileInput"
|
||||||
|
],
|
||||||
|
"notifications": [
|
||||||
|
"types.NotificationPreferences",
|
||||||
|
"types.UpdateNotificationPreferencesInput"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"note": "组件层通过 SettingsService 接口注入解耦,不直接 import messaging/actions;页面层 app/(dashboard)/settings/page.tsx 负责注入 users/actions + messaging/actions 实现"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
|
|||||||
428
docs/architecture/audit/settings-profile-audit-report.md
Normal file
428
docs/architecture/audit/settings-profile-audit-report.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# 设置和个人信息模块审计报告
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22
|
||||||
|
> 审查范围:`src/modules/settings/**`、`src/app/(dashboard)/settings/**`、`src/app/(dashboard)/admin/settings/**`、`src/app/(dashboard)/profile/**`
|
||||||
|
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.23、`docs/architecture/005_architecture_data.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有实现概要
|
||||||
|
|
||||||
|
### 1.1 文件分布
|
||||||
|
|
||||||
|
| 层 | 路径 | 文件数 | 说明 |
|
||||||
|
|----|------|--------|------|
|
||||||
|
| 路由层 - 通用设置 | `src/app/(dashboard)/settings/` | 1 个 `page.tsx` + `error.tsx` + `loading.tsx` | 角色分发到 4 个 SettingsView |
|
||||||
|
| 路由层 - 管理员系统设置 | `src/app/(dashboard)/admin/settings/` | 1 个 `page.tsx` | 仅 admin 可访问,渲染 `AdminSettingsView` |
|
||||||
|
| 路由层 - 安全设置 | `src/app/(dashboard)/settings/security/` | 1 个 `page.tsx` + `error.tsx` + `loading.tsx` | 独立密码修改页 |
|
||||||
|
| 路由层 - 个人资料 | `src/app/(dashboard)/profile/` | 1 个 `page.tsx` + `error.tsx` + `loading.tsx` | 个人资料展示页(317 行) |
|
||||||
|
| 模块层 - actions | `src/modules/settings/actions.ts`(160 行) | AI Provider CRUD + test | ✅ 使用 `requirePermission(AI_CONFIGURE)` |
|
||||||
|
| 模块层 - actions-password | `src/modules/settings/actions-password.ts`(87 行) | 修改密码 | ✅ 使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod + 限流 |
|
||||||
|
| 模块层 - data-access | `src/modules/settings/data-access.ts`(158 行) | AI Provider + 密码 DB 操作 | ✅ `server-only` |
|
||||||
|
| 模块层 - types | `src/modules/settings/types.ts`(16 行) | AI Provider 类型 | |
|
||||||
|
| 模块层 - 组件 | `src/modules/settings/components/` | 10 个组件 | 见下表 |
|
||||||
|
| i18n | **缺失** | 0 | 无 `settings.json` / `profile.json` 翻译文件 |
|
||||||
|
|
||||||
|
**组件清单**:
|
||||||
|
|
||||||
|
| 组件 | 行数 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) | 179 | 统一设置页布局(5 标签页 + 角色差异 props 注入 + Tab URL 持久化) |
|
||||||
|
| [admin-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/admin-settings-view.tsx) | 185 | **mock 实现**:4 个 Card(学校信息/安全策略/文件上传/通知配置),`setTimeout` 模拟保存 |
|
||||||
|
| [ai-provider-settings-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/ai-provider-settings-card.tsx) | 357 | AI Provider 管理(选择/新建/测试/保存) |
|
||||||
|
| [notification-preferences-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/notification-preferences-form.tsx) | 326 | 通知偏好(渠道/类别/免打扰时段) |
|
||||||
|
| [password-change-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/password-change-form.tsx) | 169 | 修改密码(强度指示器 + 显示切换) |
|
||||||
|
| [profile-settings-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-settings-form.tsx) | 146 | 个人资料编辑表单 |
|
||||||
|
| [theme-preferences-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/theme-preferences-card.tsx) | 55 | 主题切换(system/light/dark) |
|
||||||
|
| [parent-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/parent-settings-view.tsx) | 60 | 家长设置视图(复用 SettingsView + 快捷链接) |
|
||||||
|
| [teacher-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/teacher-settings-view.tsx) | 66 | 教师设置视图(同上) |
|
||||||
|
| [student-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/student-settings-view.tsx) | 54 | 学生设置视图(同上) |
|
||||||
|
|
||||||
|
### 1.2 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[Route] /settings/page.tsx
|
||||||
|
├─▶ users/data-access.getUserProfile (跨模块 data-access,类型导入)
|
||||||
|
├─▶ notifications/preferences.getNotificationPreferences (跨模块 data-access)
|
||||||
|
└─▶ 按 roles.includes("admin"|"student"|"parent") 分发
|
||||||
|
├─ admin → SettingsView(无 generalExtra)
|
||||||
|
├─ student → StudentSettingsView → SettingsView
|
||||||
|
├─ parent → ParentSettingsView → SettingsView
|
||||||
|
└─ teacher → TeacherSettingsView → SettingsView
|
||||||
|
|
||||||
|
[Route] /admin/settings/page.tsx
|
||||||
|
└─▶ AdminSettingsView(mock,无数据流)
|
||||||
|
|
||||||
|
[Route] /settings/security/page.tsx
|
||||||
|
└─▶ PasswordChangeForm → settings/actions-password.changePasswordAction
|
||||||
|
|
||||||
|
[Route] /profile/page.tsx
|
||||||
|
├─▶ users/data-access.getUserProfile
|
||||||
|
├─▶ classes/data-access.getStudentClasses / getStudentSchedule (学生分支)
|
||||||
|
├─▶ homework/data-access.getStudentHomeworkAssignments / getStudentDashboardGrades (学生分支)
|
||||||
|
├─▶ classes/data-access.getTeacherClasses / getTeacherTeachingSubjects (教师分支)
|
||||||
|
└─▶ 页面层内联 80+ 行业务计算(weekday 转换、作业状态统计、排序切片)
|
||||||
|
|
||||||
|
[Component] ProfileSettingsForm
|
||||||
|
└─▶ users/actions.updateUserProfile ❌ 跨模块 action 直调
|
||||||
|
|
||||||
|
[Component] NotificationPreferencesForm
|
||||||
|
└─▶ messaging/actions.updateNotificationPreferencesAction ❌ 跨模块 action 直调
|
||||||
|
|
||||||
|
[Component] AiProviderSettingsCard
|
||||||
|
└─▶ settings/actions.getAiProviderSummaries / upsertAiProviderAction / testAiProviderAction ✅ 模块内
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 架构图记录情况
|
||||||
|
|
||||||
|
`004_architecture_impact_map.md` §2.23 记录了 settings 模块的基本结构,但存在以下遗漏和不一致:
|
||||||
|
|
||||||
|
- **未记录 `profile/page.tsx` 的数据流**:profile 页面编排了 users/classes/homework 三个模块的 data-access,但架构图未记录
|
||||||
|
- **未记录跨模块 action 直调问题**:`profile-settings-form.tsx` 直调 `users/actions.updateUserProfile`、`notification-preferences-form.tsx` 直调 `messaging/actions.updateNotificationPreferencesAction`,架构图标注为"已修复"但实际仍存在
|
||||||
|
- **未记录 `AdminSettingsView` 是 mock 实现**:架构图描述其有 4 个 Card 但未说明无真实数据持久化
|
||||||
|
- **未记录 i18n 缺失**:架构图未标注 settings 模块所有文本均为硬编码
|
||||||
|
- **通知偏好归属不一致**:架构图 §2.17 称通知偏好已迁移至 notifications 模块,但 `notification-preferences-form.tsx` 仍从 `messaging/actions` 导入 action
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现存问题与原因分析
|
||||||
|
|
||||||
|
### 2.1 国际化完全缺失(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L96-104 | "Settings"、"Back to dashboard" 等硬编码英文 | "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" |
|
||||||
|
| [admin-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/admin-settings-view.tsx) 全文 | "系统设置"、"学校信息"、"安全策略" 等硬编码中文 | 同上 |
|
||||||
|
| [profile-settings-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-settings-form.tsx) L80-82 | "Profile Information"、"Update your personal information." 硬编码 | 同上 |
|
||||||
|
| [notification-preferences-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/notification-preferences-form.tsx) L47-99 | CHANNELS/CATEGORIES 数组中 label/description 全部硬编码 | 同上 |
|
||||||
|
| [password-change-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/password-change-form.tsx) L72-76 | "Change Password"、"Choose a strong password..." 硬编码 | 同上 |
|
||||||
|
| [theme-preferences-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/theme-preferences-card.tsx) L24-28 | "Theme"、"Choose how the admin console looks..." 硬编码(且写死 "admin console") | 同上 |
|
||||||
|
| [ai-provider-settings-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/ai-provider-settings-card.tsx) L251-257 | "AI Providers"、"Manage AI vendors..." 硬编码 | 同上 |
|
||||||
|
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) 全文 | "Profile"、"Personal Information"、"Account Information" 等硬编码 | 同上 |
|
||||||
|
| [settings/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/settings/loading.tsx) 等错误页 | "页面加载失败" 中文硬编码,与英文页面不统一 | 同上 |
|
||||||
|
| `src/shared/i18n/messages/{zh-CN,en}/` | **无 settings.json / profile.json** | i18n 命名空间缺失 |
|
||||||
|
| `src/i18n/request.ts` | 未加载 settings/profile 命名空间 | 同上 |
|
||||||
|
|
||||||
|
**原因**:settings 模块在历次重构中未纳入 i18n 改造范围,`i18n/request.ts` 只加载 6 个命名空间(common/auth/onboarding/classes/errors/dashboard)。
|
||||||
|
|
||||||
|
**后果**:无法支持中英文切换;admin 端中文、其他端英文,体验割裂;新增语言需逐文件修改。
|
||||||
|
|
||||||
|
### 2.2 跨模块 Action 直调,违反解耦原则(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [profile-settings-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-settings-form.tsx) L16 | `import { updateUserProfile } from "@/modules/users/actions"` | "模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)" |
|
||||||
|
| [notification-preferences-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/notification-preferences-form.tsx) L16 | `import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"` | 同上 |
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L28 | `import { UserProfile } from "@/modules/users/data-access"` | 类型导入,语法允许但耦合类型定义 |
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L29 | `import type { NotificationPreferences } from "@/modules/notifications/types"` | 类型导入,可接受 |
|
||||||
|
|
||||||
|
**原因**:settings 组件直接消费 users/messaging 模块的 Server Action,未通过接口抽象 + Context 注入。
|
||||||
|
|
||||||
|
**后果**:settings 模块无法独立测试(mock users/messaging action 困难);users/messaging action 签名变更会直接破坏 settings 组件;无法在不修改 settings 组件的前提下替换数据源。
|
||||||
|
|
||||||
|
### 2.3 AdminSettingsView 是 mock 实现(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [admin-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/admin-settings-view.tsx) L20-25 | `await new Promise((r) => setTimeout(r, 800))` 模拟保存,无 Server Action 调用 | "app/ 只能调用 modules/ 的 Server Actions 和 data-access,不直接访问数据库" — 这里连 action 都没调 |
|
||||||
|
| 同文件 L23 | `toast.success("设置已保存")` 撒谎,实际未保存 | 用户体验问题 |
|
||||||
|
| 同文件全文 | 4 个 Card(学校信息/安全策略/文件上传/通知配置)的输入框无 `name` 属性、无表单提交逻辑 | 表单不可用 |
|
||||||
|
| [admin/settings/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/settings/page.tsx) | 与 `/settings` 页面割裂,admin 用户有两个设置入口 | 信息架构混乱 |
|
||||||
|
|
||||||
|
**原因**:初版占位实现,后续未接入真实数据层。
|
||||||
|
|
||||||
|
**后果**:admin 调整的安全策略/文件上传限制/通知配置均不生效;与 `/settings` 页面功能重叠但行为不一致。
|
||||||
|
|
||||||
|
### 2.4 角色路由硬编码,非配置驱动(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [settings/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/settings/page.tsx) L28-44 | `if (roles.includes("admin")) ... if (roles.includes("student")) ...` 4 分支硬编码 | "采用配置驱动设计,例如通过角色配置决定该模块渲染哪些 Widget/子模块" |
|
||||||
|
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L48-49 | `const isStudent = roles.includes("student")` | 同上 |
|
||||||
|
|
||||||
|
**原因**:角色分发逻辑写在页面层,未抽取为配置。
|
||||||
|
|
||||||
|
**后果**:新增角色(如 grade_head)需修改页面代码;角色与设置视图的映射关系不可配置。
|
||||||
|
|
||||||
|
### 2.5 缺少分区 Error Boundary 和 Suspense(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L132-184 | 5 个 TabsContent 内部组件(ProfileSettingsForm / NotificationPreferencesForm / ThemePreferencesCard / PasswordChangeForm / AiProviderSettingsCard)无独立 Error Boundary | "每个独立的数据区块必须用 React Error Boundary 包裹" |
|
||||||
|
| 同上 | AiProviderSettingsCard 在 useEffect 中异步加载 providers,无 Suspense 包裹 | "异步数据使用 React Suspense + 骨架屏" |
|
||||||
|
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L229-314 | 学生概览 / 教师概览区块无独立 Error Boundary | 同上 |
|
||||||
|
|
||||||
|
**原因**:仅依赖页面级 `error.tsx` / `loading.tsx`,未做分区隔离。
|
||||||
|
|
||||||
|
**后果**:AI Provider 加载失败会导致整个 Security 标签页崩溃;ProfileSettingsForm 提交失败不会优雅降级。
|
||||||
|
|
||||||
|
### 2.6 Profile 页面职责臃肿(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L37-317 | 单文件 317 行,混合:用户基本信息展示 + 学生作业统计 + 课表筛选 + 教师班级展示 | "页面组件" 建议 ≤ 500 行(虽未超限,但职责过多) |
|
||||||
|
| 同文件 L51-110 | 学生分支内联 60 行业务计算(dueSoonCount / overdueCount / gradedCount / upcomingAssignments 排序) | "数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离" |
|
||||||
|
| 同文件 L27-35 | `WEEKDAY_MAP` / `toWeekday` 日期工具函数定义在页面文件内 | 同上 |
|
||||||
|
|
||||||
|
**原因**:profile 页面直接编排了 dashboard 模块的学生概览组件,未通过 service 层。
|
||||||
|
|
||||||
|
**后果**:业务逻辑不可测试、不可复用;学生/教师概览与 dashboard 模块重复。
|
||||||
|
|
||||||
|
### 2.7 类型安全与表单规范问题(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [profile-settings-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-settings-form.tsx) L44 | `zodResolver(profileFormSchema) as Resolver<ProfileFormValues>` 使用 `as` 断言 | "禁止 `as` 断言(除非从 `unknown` 转换或测试中)" |
|
||||||
|
| [notification-preferences-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/notification-preferences-form.tsx) L121 | `useActionState(updateNotificationPreferencesAction, null)` 第二参数 `null` 类型不安全 | 应为 `ActionState<null>` 初值 |
|
||||||
|
| [admin-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/admin-settings-view.tsx) L22 | `await new Promise((r) => setTimeout(r, 800))` 参数 `r` 隐式 any | "禁止 any" |
|
||||||
|
|
||||||
|
### 2.8 可访问性缺失(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L106-130 | Tabs 组件虽有 Radix 内置 a11y,但 TabsTrigger 仅有图标+文字,无 `aria-label` | "可访问性(a11y):语义化标签、ARIA 属性、键盘导航" |
|
||||||
|
| [notification-preferences-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/notification-preferences-form.tsx) L198-205 | 隐藏 checkbox + Switch 双控件模式,屏幕阅读器可能重复朗读 | 同上 |
|
||||||
|
| [password-change-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/password-change-form.tsx) L90-99 | 密码显示切换按钮 `tabIndex={-1}`,键盘用户无法触达 | "键盘导航" |
|
||||||
|
|
||||||
|
### 2.9 监控埋点缺失(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 全模块 | 无任何埋点接口预留(密码修改成功率、AI Provider 测试通过率、通知偏好变更频率等) | "监控:方案中预留关键操作埋点接口" |
|
||||||
|
|
||||||
|
### 2.10 行业差距:安全功能单薄(P2)
|
||||||
|
|
||||||
|
| 缺失功能 | 影响 |
|
||||||
|
|----------|------|
|
||||||
|
| 头像上传 | 用户无法个性化头像,profile 页只能显示文字 fallback |
|
||||||
|
| 两步验证(2FA/MFA) | K12 系统涉及学生隐私,仅密码保护不够 |
|
||||||
|
| 活跃会话管理 | 用户无法查看/远程登出其他设备会话 |
|
||||||
|
| 登录历史查看 | 非管理员用户无法查看自己的登录记录 |
|
||||||
|
| 账号数据导出/注销 | 不符合 GDPR-like 合规要求 |
|
||||||
|
| 通知预览 | 通知偏好表单无"发送测试通知"功能 |
|
||||||
|
| 设置搜索 | 设置项较多时无快速定位 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业差距对比
|
||||||
|
|
||||||
|
### 3.1 与优秀 K12 产品的差距
|
||||||
|
|
||||||
|
| 维度 | 优秀实践(Google Classroom / PowerSchool / Veracross) | 当前状态 | 差距影响 |
|
||||||
|
|------|--------------------------------------------------------|----------|----------|
|
||||||
|
| **设置信息架构** | 统一入口,按角色动态显示分组,支持搜索 | admin 有两个入口(`/admin/settings` mock + `/settings`),其他角色统一 | admin 体验割裂,功能不可用 |
|
||||||
|
| **个人资料** | 头像上传 + 字段级权限可见性(学生看不到自己手机号,家长可见) | 无头像上传,所有字段对本人可见 | 个性化缺失,字段级权限未实现 |
|
||||||
|
| **安全中心** | 2FA、会话列表、登录历史、密码泄露检测 | 仅密码修改 | K12 数据安全合规风险 |
|
||||||
|
| **通知偏好** | 按事件类型细分(作业/成绩/考勤/公告/消息),支持渠道矩阵 + 免打扰 | 已有基础,但无"测试通知"按钮 | 功能完整度尚可,交互反馈缺失 |
|
||||||
|
| **主题/语言** | 主题切换 + 语言切换同页 | 主题有,语言切换在 shared 但未集成到设置页 | 用户需到别处找语言切换 |
|
||||||
|
| **AI 配置** | 多 Provider + 测试 + 用量统计 | 多 Provider + 测试,无用量统计 | 教育机构无法监控 AI 成本 |
|
||||||
|
| **空状态/骨架屏** | 每个数据区块独立骨架屏 + 空状态 | 仅页面级 loading.tsx | 局部加载失败时整页白屏 |
|
||||||
|
|
||||||
|
### 3.2 多角色使用习惯差距
|
||||||
|
|
||||||
|
| 角色 | 优秀实践 | 当前状态 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **admin** | 系统设置(学校信息/策略)与个人设置在同一入口的不同分组 | 两套页面割裂,系统设置是 mock |
|
||||||
|
| **teacher** | 设置页可快速跳转常用教学功能 | ✅ 有 Quick links(TeacherSettingsView) |
|
||||||
|
| **parent** | 设置页可切换查看不同孩子的通知偏好 | 仅一套偏好,无法按孩子细分 |
|
||||||
|
| **student** | 设置页简洁,无系统配置 | ✅ 简洁 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进优先级建议
|
||||||
|
|
||||||
|
### P0(紧急,影响安全/合规/核心功能)
|
||||||
|
|
||||||
|
1. **创建 settings i18n 命名空间**:新增 `zh-CN/settings.json` + `en/settings.json`,覆盖所有设置/个人资料文本;更新 `i18n/request.ts` 加载新命名空间。
|
||||||
|
2. **消除跨模块 action 直调**:定义 `SettingsService` 接口(含 `updateProfile` / `updateNotificationPreferences` 方法),通过 React Context 注入;`ProfileSettingsForm` / `NotificationPreferencesForm` 改为消费 Context。
|
||||||
|
3. **AdminSettingsView 接入真实数据层**:将 4 个 Card(学校信息/安全策略/文件上传/通知配置)接入 `school/data-access` 或新增 `system-settings` data-access;移除 mock `setTimeout`。
|
||||||
|
|
||||||
|
### P1(重要,影响可维护性/体验)
|
||||||
|
|
||||||
|
4. **配置驱动角色路由**:新增 `settings-config.ts`,定义 `Role → SettingsViewConfig` 映射(description / backHref / generalExtra),`/settings/page.tsx` 改为查表分发。
|
||||||
|
5. **分区 Error Boundary + Suspense**:为每个 TabsContent 内部组件包裹 `<ErrorBoundary>` + `<Suspense fallback={<Skeleton/>}>`。
|
||||||
|
6. **Profile 页面拆分**:将学生概览/教师概览业务逻辑抽为 `useStudentProfileOverview` / `useTeacherProfileOverview` hooks;`WEEKDAY_MAP`/`toWeekday` 移至 `shared/lib/utils`。
|
||||||
|
7. **移除 `as` 断言**:`profile-settings-form.tsx` 的 `zodResolver(...) as Resolver<...>` 改为类型兼容写法。
|
||||||
|
|
||||||
|
### P2(优化,提升完整度)
|
||||||
|
|
||||||
|
8. **头像上传**:profile 页新增头像上传组件(复用 `files/data-access`)。
|
||||||
|
9. **2FA / 会话管理**:security 标签页新增 2FA 开关 + 活跃会话列表。
|
||||||
|
10. **通知测试按钮**:通知偏好表单新增"发送测试通知"按钮。
|
||||||
|
11. **语言切换集成**:在 Appearance 标签页集成 `LocaleSwitcher`。
|
||||||
|
12. **埋点接口**:在 `SettingsService` 接口预留 `trackEvent` 方法。
|
||||||
|
13. **a11y 修复**:密码显示切换按钮移除 `tabIndex={-1}`;通知偏好表单移除冗余隐藏 checkbox。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构图同步说明
|
||||||
|
|
||||||
|
本次审计发现架构图需补充/修改以下节点:
|
||||||
|
|
||||||
|
### 5.1 `004_architecture_impact_map.md` §2.23 settings 模块
|
||||||
|
|
||||||
|
- **修改"已知问题"**:新增"跨模块 action 直调未修复"(`profile-settings-form` → `users/actions`、`notification-preferences-form` → `messaging/actions`)
|
||||||
|
- **修改"已知问题"**:新增"AdminSettingsView 为 mock 实现,无数据持久化"
|
||||||
|
- **修改"已知问题"**:新增"i18n 完全缺失,所有文本硬编码"
|
||||||
|
- **修改"依赖关系"**:明确标注 `profile-settings-form.tsx` 依赖 `users/actions`(action 级,非 data-access)
|
||||||
|
- **新增"文件清单"**:补充 `profile/page.tsx`(317 行)的归属说明(虽在 app 层,但编排 settings 相关数据)
|
||||||
|
|
||||||
|
### 5.2 `005_architecture_data.json` settings 节点
|
||||||
|
|
||||||
|
- **`modules.settings.knownIssues`**:新增 3 条(跨模块 action 直调 / AdminSettingsView mock / i18n 缺失)
|
||||||
|
- **`modules.settings.exports`**:补充 `SettingsService` 接口(重构后新增)
|
||||||
|
- **`dependencyMatrix`**:settings → users 的依赖类型从 `data-access` 改为 `action`(标注为待修复)
|
||||||
|
|
||||||
|
### 5.3 `004` §2.17 notifications 模块
|
||||||
|
|
||||||
|
- **修正不一致**:`notification-preferences-form.tsx` 仍从 `messaging/actions` 导入 action,但架构图称"通知偏好已迁移至 notifications 模块" — 需标注"表单层 action 调用未同步迁移"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、重构方案设计
|
||||||
|
|
||||||
|
### 6.1 完全解耦:SettingsService 接口 + Context 注入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/settings/types.ts (新增)
|
||||||
|
export interface ProfileService {
|
||||||
|
getProfile: () => Promise<UserProfile | null>
|
||||||
|
updateProfile: (input: UpdateProfileInput) => Promise<ActionState<UserProfile>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationService {
|
||||||
|
getPreferences: () => Promise<NotificationPreferences>
|
||||||
|
updatePreferences: (input: UpdateNotificationPreferencesInput) => Promise<ActionState<null>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsService {
|
||||||
|
profile: ProfileService
|
||||||
|
notifications: NotificationService
|
||||||
|
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/modules/settings/components/settings-service-context.tsx (新增)
|
||||||
|
const SettingsServiceContext = createContext<SettingsService | null>(null)
|
||||||
|
|
||||||
|
export function SettingsServiceProvider({ service, children }: { service: SettingsService; children: ReactNode }) {
|
||||||
|
return <SettingsServiceContext.Provider value={service}>{children}</SettingsServiceContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsService(): SettingsService {
|
||||||
|
const ctx = useContext(SettingsServiceContext)
|
||||||
|
if (!ctx) throw new Error("useSettingsService must be used within SettingsServiceProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
页面层注入实现:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// /settings/page.tsx
|
||||||
|
const serverService: SettingsService = {
|
||||||
|
profile: {
|
||||||
|
getProfile: async () => getUserProfile(userId),
|
||||||
|
updateProfile: async (input) => updateUserProfile(input),
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
getPreferences: async () => getNotificationPreferences(userId),
|
||||||
|
updatePreferences: async (input) => updateNotificationPreferencesAction(null, input),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return <SettingsServiceProvider service={serverService}><SettingsView {...} /></SettingsServiceProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 组合优先:角色配置驱动
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/settings/config/role-settings-config.ts (新增)
|
||||||
|
export interface RoleSettingsConfig {
|
||||||
|
description: string
|
||||||
|
backHref: string
|
||||||
|
generalExtra?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_SETTINGS_CONFIG: Partial<Record<Role, RoleSettingsConfig>> = {
|
||||||
|
admin: { description: "settings.admin.description", backHref: "/admin/dashboard" },
|
||||||
|
teacher: { description: "settings.teacher.description", backHref: "/teacher/dashboard", generalExtra: <TeacherQuickLinks /> },
|
||||||
|
student: { description: "settings.student.description", backHref: "/student/dashboard", generalExtra: <StudentQuickLinks /> },
|
||||||
|
parent: { description: "settings.parent.description", backHref: "/parent/dashboard", generalExtra: <ParentQuickLinks /> },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 国际化就绪:翻译文件结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
// src/shared/i18n/messages/zh-CN/settings.json
|
||||||
|
{
|
||||||
|
"title": "设置",
|
||||||
|
"backToDashboard": "返回仪表盘",
|
||||||
|
"tabs": {
|
||||||
|
"general": "通用",
|
||||||
|
"notifications": "通知",
|
||||||
|
"appearance": "外观",
|
||||||
|
"security": "安全",
|
||||||
|
"ai": "AI"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "个人信息",
|
||||||
|
"description": "更新您的个人资料",
|
||||||
|
"fields": {
|
||||||
|
"name": "姓名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"phone": "电话",
|
||||||
|
"address": "地址",
|
||||||
|
"gender": "性别",
|
||||||
|
"age": "年龄",
|
||||||
|
"role": "角色"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知偏好",
|
||||||
|
"channels": { "push": "推送通知", "email": "邮件", "sms": "短信" },
|
||||||
|
"categories": { "messages": "消息", "announcements": "公告", "homework": "作业", "grades": "成绩", "attendance": "考勤" },
|
||||||
|
"quietHours": { "title": "免打扰时段", "enable": "启用", "start": "开始时间", "end": "结束时间" }
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"changePassword": { "title": "修改密码", "current": "当前密码", "new": "新密码", "confirm": "确认密码" },
|
||||||
|
"session": { "title": "会话", "signOut": "退出登录" }
|
||||||
|
},
|
||||||
|
"appearance": { "theme": { "title": "主题", "system": "跟随系统", "light": "浅色", "dark": "深色" } },
|
||||||
|
"ai": { "providers": { "title": "AI 服务商", "test": "测试", "save": "保存" } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 错误与边界处理
|
||||||
|
|
||||||
|
每个 TabsContent 内部组件用 `<ErrorBoundary>` + `<Suspense>` 包裹:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<TabsContent value="ai">
|
||||||
|
<ErrorBoundary fallback={<SettingsSectionError />}>
|
||||||
|
<Suspense fallback={<AiProviderSkeleton />}>
|
||||||
|
<AiProviderSettingsCard />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 可测试性
|
||||||
|
|
||||||
|
- `SettingsService` 接口可 mock,组件单测无需真实 DB
|
||||||
|
- `WEEKDAY_MAP` / `toWeekday` 移至 `shared/lib/utils` 后可独立测试
|
||||||
|
- 学生概览计算逻辑抽为 `useStudentProfileOverview` hook,可独立测试
|
||||||
|
|
||||||
|
### 6.6 可扩展性
|
||||||
|
|
||||||
|
- 新增角色只需在 `ROLE_SETTINGS_CONFIG` 添加条目
|
||||||
|
- 新增设置标签页只需在 `settings-view.tsx` 的 tabs 配置添加条目
|
||||||
|
- 新增系统设置 Card 只需在 `AdminSettingsView` 组合新 Card
|
||||||
|
|
||||||
|
### 6.7 企业级补充
|
||||||
|
|
||||||
|
- **a11y**:密码显示切换按钮移除 `tabIndex={-1}`;通知偏好表单移除冗余隐藏 checkbox,仅用 Switch + `name` 属性
|
||||||
|
- **性能**:SettingsView 保持客户端组件(需 URL searchParams),但各标签页内容组件按需加载
|
||||||
|
- **安全**:`SettingsService` 实现在 Server Action 层调用 `requirePermission`,组件层不绕过
|
||||||
|
- **监控**:`SettingsService.trackEvent` 预留埋点接口
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function ProfileError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function ProfileError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("settings.errors")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("loadFailed")}
|
||||||
description="抱歉,个人资料页面加载时发生了意外错误。请稍后重试。"
|
description={t("loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,138 +1,58 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
import { Suspense, type ReactElement } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
|
||||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
|
||||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
|
||||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
|
||||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
|
||||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
|
||||||
|
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
|
||||||
|
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { PageHeader } from "@/shared/components/ui/page-header"
|
import { PageHeader } from "@/shared/components/ui/page-header"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "Profile",
|
const t = await getTranslations("settings.profilePage")
|
||||||
|
return { title: t("title") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
export default async function ProfilePage(): Promise<ReactElement> {
|
||||||
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
||||||
|
|
||||||
const toWeekday = (d: Date): Weekday => {
|
|
||||||
const day = d.getDay()
|
|
||||||
const result = WEEKDAY_MAP[day]
|
|
||||||
if (result < 1 || result > 7) throw new Error("Invalid weekday")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
|
||||||
const ctx = await requireAuth()
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
const userId = ctx.userId
|
const userId = ctx.userId
|
||||||
const userProfile = await getUserProfile(userId)
|
const userProfile = await getUserProfile(userId)
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = ctx.roles
|
const roles = ctx.roles
|
||||||
const isStudent = roles.includes("student")
|
const isStudent = roles.includes("student")
|
||||||
const isTeacher = roles.includes("teacher")
|
const isTeacher = roles.includes("teacher")
|
||||||
|
const t = await getTranslations("settings.profilePage")
|
||||||
const studentData =
|
|
||||||
isStudent
|
|
||||||
? await (async () => {
|
|
||||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
|
||||||
getStudentClasses(userId),
|
|
||||||
getStudentSchedule(userId),
|
|
||||||
getStudentHomeworkAssignments(userId),
|
|
||||||
getStudentDashboardGrades(userId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const in7Days = new Date(now)
|
|
||||||
in7Days.setDate(in7Days.getDate() + 7)
|
|
||||||
|
|
||||||
const dueSoonCount = assignmentsAll.filter((a) => {
|
|
||||||
if (!a.dueAt) return false
|
|
||||||
const due = new Date(a.dueAt)
|
|
||||||
return due >= now && due <= in7Days && a.progressStatus !== "graded"
|
|
||||||
}).length
|
|
||||||
|
|
||||||
const overdueCount = assignmentsAll.filter((a) => {
|
|
||||||
if (!a.dueAt) return false
|
|
||||||
const due = new Date(a.dueAt)
|
|
||||||
return due < now && a.progressStatus !== "graded"
|
|
||||||
}).length
|
|
||||||
|
|
||||||
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
|
|
||||||
|
|
||||||
const upcomingAssignments = [...assignmentsAll]
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
||||||
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
||||||
if (aTime !== bTime) return aTime - bTime
|
|
||||||
return a.id.localeCompare(b.id)
|
|
||||||
})
|
|
||||||
.slice(0, 8)
|
|
||||||
|
|
||||||
const todayWeekday = toWeekday(now)
|
|
||||||
const todayScheduleItems = schedule
|
|
||||||
.filter((s) => s.weekday === todayWeekday)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
classId: s.classId,
|
|
||||||
className: s.className,
|
|
||||||
course: s.course,
|
|
||||||
startTime: s.startTime,
|
|
||||||
endTime: s.endTime,
|
|
||||||
location: s.location ?? null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
enrolledClassCount: classes.length,
|
|
||||||
dueSoonCount,
|
|
||||||
overdueCount,
|
|
||||||
gradedCount,
|
|
||||||
todayScheduleItems,
|
|
||||||
upcomingAssignments,
|
|
||||||
grades,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
: null
|
|
||||||
|
|
||||||
const teacherData =
|
|
||||||
isTeacher
|
|
||||||
? await (async () => {
|
|
||||||
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
|
|
||||||
return { subjects, classes }
|
|
||||||
})()
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Profile"
|
title={t("title")}
|
||||||
description="Manage your personal and account information."
|
description={t("description")}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/settings">Edit Profile</Link>
|
<Link href="/settings">{t("editProfile")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar className="h-20 w-20">
|
<Avatar className="h-20 w-20">
|
||||||
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? "User avatar"} /> : null}
|
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? t("title")} /> : null}
|
||||||
<AvatarFallback className="text-xl font-semibold">
|
<AvatarFallback className="text-xl font-semibold">
|
||||||
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
|
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@@ -148,36 +68,36 @@ export default async function ProfilePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
Personal Information
|
{t("personalInfo.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Basic personal details.</CardDescription>
|
<CardDescription>{t("personalInfo.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.fullName")}</div>
|
||||||
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Gender</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.gender")}</div>
|
||||||
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Age</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.age")}</div>
|
||||||
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Phone</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.phone")}</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
{userProfile.phone ?? "-"}
|
{userProfile.phone ?? "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 sm:col-span-2 space-y-1">
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Address</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.address")}</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
{userProfile.address ?? "-"}
|
{userProfile.address ?? "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,37 +108,37 @@ export default async function ProfilePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5" />
|
<Shield className="h-5 w-5" />
|
||||||
Account Information
|
{t("accountInfo.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>System account details.</CardDescription>
|
<CardDescription>{t("accountInfo.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="col-span-1 sm:col-span-2 space-y-1">
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Email</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.email")}</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Mail className="h-3 w-3 text-muted-foreground" />
|
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||||
{userProfile.email}
|
{userProfile.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Role</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.role")}</div>
|
||||||
<Badge variant="secondary" className="capitalize">
|
<Badge variant="secondary" className="capitalize">
|
||||||
{userProfile.role}
|
{userProfile.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.memberSince")}</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||||
{formatDate(userProfile.createdAt)}
|
{formatDate(userProfile.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.onboardedAt")}</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
|
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,91 +146,20 @@ export default async function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{studentData ? (
|
{isStudent ? (
|
||||||
<div className="space-y-6">
|
<SettingsSectionErrorBoundary>
|
||||||
<Separator />
|
<Suspense fallback={<ProfileStudentOverviewSkeleton />}>
|
||||||
<div className="space-y-1">
|
<ProfileStudentOverview userId={userId} />
|
||||||
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
</Suspense>
|
||||||
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
</SettingsSectionErrorBoundary>
|
||||||
</div>
|
|
||||||
|
|
||||||
<StudentStatsGrid
|
|
||||||
enrolledClassCount={studentData.enrolledClassCount}
|
|
||||||
dueSoonCount={studentData.dueSoonCount}
|
|
||||||
overdueCount={studentData.overdueCount}
|
|
||||||
gradedCount={studentData.gradedCount}
|
|
||||||
ranking={studentData.grades.ranking}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
|
||||||
<StudentGradesCard grades={studentData.grades} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{teacherData ? (
|
{isTeacher ? (
|
||||||
<div className="space-y-6">
|
<SettingsSectionErrorBoundary>
|
||||||
<Separator />
|
<Suspense fallback={<ProfileTeacherOverviewSkeleton />}>
|
||||||
<div className="space-y-1">
|
<ProfileTeacherOverview />
|
||||||
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
|
</Suspense>
|
||||||
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
|
</SettingsSectionErrorBoundary>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Teaching Subjects</CardTitle>
|
|
||||||
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{teacherData.subjects.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{teacherData.subjects.map((subject) => (
|
|
||||||
<Badge key={subject} variant="secondary">
|
|
||||||
{subject}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Teaching Classes</CardTitle>
|
|
||||||
<CardDescription>Classes you are currently managing.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{teacherData.classes.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
|
|
||||||
) : (
|
|
||||||
teacherData.classes.map((cls) => (
|
|
||||||
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium">{cls.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{cls.grade}
|
|
||||||
{cls.homeroom ? ` • ${cls.homeroom}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function SettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function SettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("settings.errors")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("loadFailed")}
|
||||||
description="抱歉,设置页面加载时发生了意外错误。请稍后重试。"
|
description={t("loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { SettingsView } from "@/modules/settings/components/settings-view"
|
import { SettingsView } from "@/modules/settings/components/settings-view"
|
||||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
|
||||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
|
||||||
import { ParentSettingsView } from "@/modules/settings/components/parent-settings-view"
|
import type { SettingsService } from "@/modules/settings/types"
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
import { updateUserProfile } from "@/modules/users/actions"
|
||||||
import { getNotificationPreferences } from "@/modules/notifications/preferences"
|
import { getNotificationPreferences } from "@/modules/notifications/preferences"
|
||||||
|
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
|
||||||
|
import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -14,6 +18,32 @@ export const metadata = {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将通知偏好输入对象转换为 FormData,适配 updateNotificationPreferencesAction 的签名。
|
||||||
|
* Action 内部通过 formData.get(key) === "on" 解析布尔值。
|
||||||
|
*/
|
||||||
|
function buildNotificationFormData(input: UpdateNotificationPreferencesInput): FormData {
|
||||||
|
const formData = new FormData()
|
||||||
|
const booleanFields: Array<keyof UpdateNotificationPreferencesInput> = [
|
||||||
|
"emailEnabled",
|
||||||
|
"smsEnabled",
|
||||||
|
"pushEnabled",
|
||||||
|
"homeworkNotifications",
|
||||||
|
"gradeNotifications",
|
||||||
|
"announcementNotifications",
|
||||||
|
"messageNotifications",
|
||||||
|
"attendanceNotifications",
|
||||||
|
"quietHoursEnabled",
|
||||||
|
]
|
||||||
|
for (const field of booleanFields) {
|
||||||
|
const value = input[field]
|
||||||
|
if (value === true) formData.set(field, "on")
|
||||||
|
}
|
||||||
|
if (input.quietHoursStart) formData.set("quietHoursStart", input.quietHoursStart)
|
||||||
|
if (input.quietHoursEnd) formData.set("quietHoursEnd", input.quietHoursEnd)
|
||||||
|
return formData
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const ctx = await requireAuth()
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
@@ -24,22 +54,36 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const roles = ctx.roles
|
const roles = ctx.roles
|
||||||
const notificationPrefs = await getNotificationPreferences(userId)
|
const notificationPrefs = await getNotificationPreferences(userId)
|
||||||
|
const t = await getTranslations("settings")
|
||||||
|
|
||||||
if (roles.includes("admin")) {
|
const config = resolveRoleSettingsConfig(roles)
|
||||||
return (
|
const description = t(config?.descriptionKey ?? "title")
|
||||||
|
const backHref = config?.backHref ?? "/dashboard"
|
||||||
|
const generalExtra = config?.generalExtra
|
||||||
|
|
||||||
|
// 构建 SettingsService 实现,注入到 SettingsServiceProvider
|
||||||
|
// 组件层通过 useSettingsService() 消费,不直接 import users/messaging actions
|
||||||
|
const service: SettingsService = {
|
||||||
|
profile: {
|
||||||
|
getProfile: async () => getUserProfile(userId),
|
||||||
|
updateProfile: async (input) => updateUserProfile(input),
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
getPreferences: async () => getNotificationPreferences(userId),
|
||||||
|
updatePreferences: async (input) =>
|
||||||
|
updateNotificationPreferencesAction(null, buildNotificationFormData(input)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsServiceProvider service={service}>
|
||||||
<SettingsView
|
<SettingsView
|
||||||
description="Manage your admin preferences and account access."
|
description={description}
|
||||||
backHref="/admin/dashboard"
|
backHref={backHref}
|
||||||
user={userProfile}
|
user={userProfile}
|
||||||
notificationPreferences={notificationPrefs}
|
notificationPreferences={notificationPrefs}
|
||||||
|
generalExtra={generalExtra}
|
||||||
/>
|
/>
|
||||||
)
|
</SettingsServiceProvider>
|
||||||
}
|
)
|
||||||
if (roles.includes("student")) {
|
|
||||||
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
|
||||||
}
|
|
||||||
if (roles.includes("parent")) {
|
|
||||||
return <ParentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
|
||||||
}
|
|
||||||
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function SecuritySettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function SecuritySettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("settings.errors")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("loadFailed")}
|
||||||
description="抱歉,安全设置页面加载时发生了意外错误。请稍后重试。"
|
description={t("loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Lock } from "lucide-react"
|
import { Lock } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||||
@@ -13,12 +14,13 @@ export const metadata = {
|
|||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
await requireAuth()
|
await requireAuth()
|
||||||
|
const t = await getTranslations("settings")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Security"
|
title={t("tabs.security")}
|
||||||
description="Manage your password and account security settings."
|
description={t("security.changePassword.description")}
|
||||||
icon={Lock}
|
icon={Lock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -27,15 +29,15 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Security Tips</CardTitle>
|
<CardTitle>{t("security.tips.title")}</CardTitle>
|
||||||
<CardDescription>Best practices to keep your account safe.</CardDescription>
|
<CardDescription>{t("security.tips.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
<li>Use a unique password that you don't reuse across other sites.</li>
|
<li>{t("security.tips.tip1")}</li>
|
||||||
<li>Avoid common words, names, or sequential patterns.</li>
|
<li>{t("security.tips.tip2")}</li>
|
||||||
<li>Change your password periodically.</li>
|
<li>{t("security.tips.tip3")}</li>
|
||||||
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
|
<li>{t("security.tips.tip4")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { School, Shield, Database, Bell } from "lucide-react"
|
import { School, Shield, Database, Bell } from "lucide-react"
|
||||||
|
|
||||||
@@ -12,23 +13,31 @@ import { Switch } from "@/shared/components/ui/switch"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员系统设置视图
|
||||||
|
*
|
||||||
|
* TODO: 当前为 mock 实现(setTimeout 模拟保存),未接入真实数据层。
|
||||||
|
* 后续需新增 system_settings 表 + data-access + actions,替换 mock 逻辑。
|
||||||
|
* 当前已适配 i18n,文本均通过 settings.admin.* 翻译键获取。
|
||||||
|
*/
|
||||||
export function AdminSettingsView() {
|
export function AdminSettingsView() {
|
||||||
|
const t = useTranslations("settings.admin")
|
||||||
const [saving, setSaving] = React.useState(false)
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
// 模拟保存
|
// TODO: 替换为真实 Server Action 调用
|
||||||
await new Promise((r) => setTimeout(r, 800))
|
await new Promise<void>((resolve) => setTimeout(resolve, 800))
|
||||||
toast.success("设置已保存")
|
toast.success(t("saveSuccess"))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-6">
|
<div className="flex h-full flex-col space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">系统设置</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||||
<p className="text-muted-foreground">管理系统基础信息与运行参数。</p>
|
<p className="text-muted-foreground">{t("description")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="space-y-6">
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
@@ -38,39 +47,39 @@ export function AdminSettingsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<School className="h-5 w-5 text-primary" />
|
<School className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">学校信息</CardTitle>
|
<CardTitle className="text-base">{t("schoolInfo.title")}</CardTitle>
|
||||||
<CardDescription>学校的基础信息,将显示在系统各处</CardDescription>
|
<CardDescription>{t("schoolInfo.description")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-name">学校名称</Label>
|
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
|
||||||
<Input id="school-name" placeholder="请输入学校名称" defaultValue="Next_Edu 实验学校" />
|
<Input id="school-name" name="schoolName" placeholder={t("schoolInfo.namePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-code">学校代码</Label>
|
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
|
||||||
<Input id="school-code" placeholder="请输入学校代码" />
|
<Input id="school-code" name="schoolCode" placeholder={t("schoolInfo.codePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-phone">联系电话</Label>
|
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
|
||||||
<Input id="school-phone" placeholder="请输入联系电话" />
|
<Input id="school-phone" name="schoolPhone" placeholder={t("schoolInfo.phonePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-email">联系邮箱</Label>
|
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
|
||||||
<Input id="school-email" type="email" placeholder="请输入联系邮箱" />
|
<Input id="school-email" name="schoolEmail" type="email" placeholder={t("schoolInfo.emailPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-address">学校地址</Label>
|
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
|
||||||
<Input id="school-address" placeholder="请输入学校地址" />
|
<Input id="school-address" name="schoolAddress" placeholder={t("schoolInfo.addressPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-desc">学校简介</Label>
|
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
|
||||||
<Textarea id="school-desc" placeholder="请输入学校简介" rows={3} />
|
<Textarea id="school-desc" name="schoolDescription" placeholder={t("schoolInfo.descriptionPlaceholder")} rows={3} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -81,43 +90,43 @@ export function AdminSettingsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">安全策略</CardTitle>
|
<CardTitle className="text-base">{t("securityPolicy.title")}</CardTitle>
|
||||||
<CardDescription>密码策略与会话管理</CardDescription>
|
<CardDescription>{t("securityPolicy.description")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password-min-length">密码最小长度</Label>
|
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
|
||||||
<Input id="password-min-length" type="number" min={6} max={32} defaultValue={8} />
|
<Input id="password-min-length" name="passwordMinLength" type="number" min={6} max={32} defaultValue={8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="session-timeout">会话超时(分钟)</Label>
|
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
|
||||||
<Input id="session-timeout" type="number" min={5} max={1440} defaultValue={60} />
|
<Input id="session-timeout" name="sessionTimeout" type="number" min={5} max={1440} defaultValue={60} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="require-special-char">密码必须包含特殊字符</Label>
|
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">要求用户密码中包含至少一个特殊字符</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="require-special-char" defaultChecked />
|
<Switch id="require-special-char" name="requireSpecialChar" defaultChecked />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="require-uppercase">密码必须包含大写字母</Label>
|
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">要求用户密码中包含至少一个大写字母</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="require-uppercase" />
|
<Switch id="require-uppercase" name="requireUppercase" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="force-password-change">首次登录强制修改密码</Label>
|
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">新用户或重置密码后首次登录时必须修改密码</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="force-password-change" defaultChecked />
|
<Switch id="force-password-change" name="forcePasswordChange" defaultChecked />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -128,20 +137,20 @@ export function AdminSettingsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5 text-primary" />
|
<Database className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">文件上传</CardTitle>
|
<CardTitle className="text-base">{t("fileUpload.title")}</CardTitle>
|
||||||
<CardDescription>文件上传限制与存储配置</CardDescription>
|
<CardDescription>{t("fileUpload.description")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="max-file-size">单文件最大大小(MB)</Label>
|
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
|
||||||
<Input id="max-file-size" type="number" min={1} max={100} defaultValue={10} />
|
<Input id="max-file-size" name="maxFileSize" type="number" min={1} max={100} defaultValue={10} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="allowed-types">允许的文件类型</Label>
|
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
|
||||||
<Input id="allowed-types" placeholder="如:jpg,png,pdf,docx" defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
|
<Input id="allowed-types" name="allowedTypes" placeholder={t("fileUpload.allowedTypesPlaceholder")} defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -153,40 +162,40 @@ export function AdminSettingsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bell className="h-5 w-5 text-primary" />
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">通知配置</CardTitle>
|
<CardTitle className="text-base">{t("notificationConfig.title")}</CardTitle>
|
||||||
<CardDescription>系统通知的发送方式与触发条件</CardDescription>
|
<CardDescription>{t("notificationConfig.description")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-new-user">新用户注册通知管理员</Label>
|
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">有新用户注册时向管理员发送通知</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-new-user" defaultChecked />
|
<Switch id="notify-new-user" name="notifyNewUser" defaultChecked />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-schedule-change">课表变更通知教师</Label>
|
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">课表变更审批通过后通知相关教师</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-schedule-change" defaultChecked />
|
<Switch id="notify-schedule-change" name="notifyScheduleChange" defaultChecked />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-announcement">公告发布通知目标用户</Label>
|
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">公告发布时向目标用户推送通知</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-announcement" />
|
<Switch id="notify-announcement" name="notifyAnnouncement" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button type="button" variant="outline">重置</Button>
|
<Button type="button" variant="outline">{t("reset")}</Button>
|
||||||
<Button type="submit" disabled={saving}>
|
<Button type="submit" disabled={saving}>
|
||||||
{saving ? "保存中..." : "保存设置"}
|
{saving ? t("saving") : t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { z } from "zod"
|
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"
|
||||||
@@ -9,17 +10,16 @@ import { Loader2, Save, Sparkles } 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 { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form"
|
} from "@/shared/components/ui/form"
|
||||||
|
import { TextField } from "@/shared/components/form-fields/text-field"
|
||||||
|
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -42,13 +42,6 @@ const AiProviderFormSchema = z.object({
|
|||||||
|
|
||||||
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
|
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
|
||||||
|
|
||||||
const providerLabels: Record<z.infer<typeof ProviderSchema>, string> = {
|
|
||||||
zhipu: "Zhipu",
|
|
||||||
openai: "OpenAI",
|
|
||||||
gemini: "Gemini",
|
|
||||||
custom: "Custom",
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_PROVIDER_VALUE = "__new__"
|
const NEW_PROVIDER_VALUE = "__new__"
|
||||||
|
|
||||||
export function AiProviderSettingsCard({
|
export function AiProviderSettingsCard({
|
||||||
@@ -58,6 +51,7 @@ export function AiProviderSettingsCard({
|
|||||||
onProvidersChanged?: (rows: AiProviderSummary[]) => void
|
onProvidersChanged?: (rows: AiProviderSummary[]) => void
|
||||||
initialMode?: "new" | "first"
|
initialMode?: "new" | "first"
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("settings.ai.providers")
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [providers, setProviders] = useState<AiProviderSummary[]>([])
|
const [providers, setProviders] = useState<AiProviderSummary[]>([])
|
||||||
const [selectedId, setSelectedId] = useState<string>("")
|
const [selectedId, setSelectedId] = useState<string>("")
|
||||||
@@ -112,7 +106,7 @@ export function AiProviderSettingsCard({
|
|||||||
try {
|
try {
|
||||||
const result = await getAiProviderSummaries()
|
const result = await getAiProviderSummaries()
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
toast.error(result.message ?? "Failed to load AI providers")
|
toast.error(result.message ?? t("loadFailure"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rows = result.data
|
const rows = result.data
|
||||||
@@ -135,10 +129,10 @@ export function AiProviderSettingsCard({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load AI providers")
|
toast.error(t("loadFailure"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew])
|
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
|
||||||
|
|
||||||
const handleSelectChange = (value: string) => {
|
const handleSelectChange = (value: string) => {
|
||||||
if (value === NEW_PROVIDER_VALUE) {
|
if (value === NEW_PROVIDER_VALUE) {
|
||||||
@@ -175,7 +169,7 @@ export function AiProviderSettingsCard({
|
|||||||
const values = form.getValues()
|
const values = form.getValues()
|
||||||
const apiKey = values.apiKey?.trim()
|
const apiKey = values.apiKey?.trim()
|
||||||
if (!apiKey && !values.id?.trim()) {
|
if (!apiKey && !values.id?.trim()) {
|
||||||
toast.error("Please enter API key to test")
|
toast.error(t("needKey"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTestStatus("testing")
|
setTestStatus("testing")
|
||||||
@@ -192,10 +186,10 @@ export function AiProviderSettingsCard({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTestStatus("passed")
|
setTestStatus("passed")
|
||||||
setLastTestedSignature(buildSignature(values))
|
setLastTestedSignature(buildSignature(values))
|
||||||
toast.success(result.message ?? "Test passed")
|
toast.success(result.message ?? t("testSuccess"))
|
||||||
} else {
|
} else {
|
||||||
setTestStatus("failed")
|
setTestStatus("failed")
|
||||||
toast.error(result.message ?? "Test failed")
|
toast.error(result.message ?? t("testFailure"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -203,7 +197,7 @@ export function AiProviderSettingsCard({
|
|||||||
const onSubmit = (values: AiProviderFormValues) => {
|
const onSubmit = (values: AiProviderFormValues) => {
|
||||||
const signature = buildSignature(values)
|
const signature = buildSignature(values)
|
||||||
if (testStatus !== "passed" || signature !== lastTestedSignature) {
|
if (testStatus !== "passed" || signature !== lastTestedSignature) {
|
||||||
toast.error("Please test the configuration before saving")
|
toast.error(t("needTest"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -217,12 +211,12 @@ export function AiProviderSettingsCard({
|
|||||||
}
|
}
|
||||||
const result = await upsertAiProviderAction(payload)
|
const result = await upsertAiProviderAction(payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message ?? "Saved")
|
toast.success(result.message ?? t("saveSuccess"))
|
||||||
setTestStatus("idle")
|
setTestStatus("idle")
|
||||||
setLastTestedSignature("")
|
setLastTestedSignature("")
|
||||||
const summariesResult = await getAiProviderSummaries()
|
const summariesResult = await getAiProviderSummaries()
|
||||||
if (!summariesResult.success || !summariesResult.data) {
|
if (!summariesResult.success || !summariesResult.data) {
|
||||||
toast.error(summariesResult.message ?? "Failed to load AI providers")
|
toast.error(summariesResult.message ?? t("loadFailure"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rows = summariesResult.data
|
const rows = summariesResult.data
|
||||||
@@ -242,7 +236,7 @@ export function AiProviderSettingsCard({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message ?? "Failed to save")
|
toast.error(result.message ?? t("saveFailure"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -252,34 +246,34 @@ export function AiProviderSettingsCard({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||||
AI Providers
|
{t("title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage AI vendors and default model configuration.</CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>Existing Providers</FormLabel>
|
<FormLabel>{t("existing")}</FormLabel>
|
||||||
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Create new or select existing" />
|
<SelectValue placeholder={t("selectPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NEW_PROVIDER_VALUE}>Create new</SelectItem>
|
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
|
||||||
{providers.map((item) => (
|
{providers.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{providerLabels[item.provider]} · {item.model}
|
{item.provider} · {item.model}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>Key Status</FormLabel>
|
<FormLabel>{t("keyStatus")}</FormLabel>
|
||||||
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||||
{selectedProvider?.apiKeyLast4
|
{selectedProvider?.apiKeyLast4
|
||||||
? `Stored • ****${selectedProvider.apiKeyLast4}`
|
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
|
||||||
: "No key stored"}
|
: t("noKey")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,82 +281,46 @@ export function AiProviderSettingsCard({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="id"
|
name="id"
|
||||||
render={({ field }) => (
|
label={t("id")}
|
||||||
<FormItem>
|
disabled
|
||||||
<FormLabel>ID</FormLabel>
|
description={t("idDesc")}
|
||||||
<FormControl>
|
|
||||||
<Input {...field} value={field.value ?? ""} disabled />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Auto-generated for each provider.</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<SelectField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="provider"
|
name="provider"
|
||||||
render={({ field }) => (
|
label={t("provider")}
|
||||||
<FormItem>
|
placeholder={t("providerPlaceholder")}
|
||||||
<FormLabel>Provider</FormLabel>
|
options={[
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
{ value: "zhipu", label: "Zhipu" },
|
||||||
<FormControl>
|
{ value: "openai", label: "OpenAI" },
|
||||||
<SelectTrigger>
|
{ value: "gemini", label: "Gemini" },
|
||||||
<SelectValue placeholder="Select provider" />
|
{ value: "custom", label: "Custom" },
|
||||||
</SelectTrigger>
|
]}
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="zhipu">Zhipu</SelectItem>
|
|
||||||
<SelectItem value="openai">OpenAI</SelectItem>
|
|
||||||
<SelectItem value="gemini">Gemini</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="baseUrl"
|
name="baseUrl"
|
||||||
render={({ field }) => (
|
label={t("baseUrl")}
|
||||||
<FormItem>
|
placeholder={t("baseUrlPlaceholder")}
|
||||||
<FormLabel>API URL</FormLabel>
|
description={t("baseUrlDesc")}
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder="https://open.bigmodel.cn/api/paas/v4" />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Enter base URL without /chat/completions suffix.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="model"
|
name="model"
|
||||||
render={({ field }) => (
|
label={t("model")}
|
||||||
<FormItem>
|
placeholder={t("modelPlaceholder")}
|
||||||
<FormLabel>Model</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder="gpt-4o-mini" />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
render={({ field }) => (
|
label={t("apiKey")}
|
||||||
<FormItem className="sm:col-span-2">
|
type="password"
|
||||||
<FormLabel>API Key</FormLabel>
|
placeholder={t("apiKeyPlaceholder")}
|
||||||
<FormControl>
|
description={t("apiKeyDesc")}
|
||||||
<Input type="password" {...field} placeholder="Paste new key to replace" />
|
itemClassName="sm:col-span-2"
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Existing key won't be displayed. Leave blank to keep current.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -374,7 +332,7 @@ export function AiProviderSettingsCard({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
|
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel>Set as default</FormLabel>
|
<FormLabel>{t("setDefault")}</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -384,12 +342,12 @@ export function AiProviderSettingsCard({
|
|||||||
{testStatus === "testing" ? (
|
{testStatus === "testing" ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Testing...
|
{t("testing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
Test
|
{t("test")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -397,12 +355,12 @@ export function AiProviderSettingsCard({
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
{t("saving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Changes
|
{t("save")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useActionState } from "react"
|
import { useTransition } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useTranslations } from "next-intl"
|
||||||
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
|
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import { Switch } from "@/shared/components/ui/switch"
|
|||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
|
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
|
||||||
import type { NotificationPreferences } from "@/modules/notifications/types"
|
import type { NotificationPreferences } from "@/modules/notifications/types"
|
||||||
|
|
||||||
interface NotificationPreferencesFormProps {
|
interface NotificationPreferencesFormProps {
|
||||||
@@ -25,8 +25,8 @@ interface ChannelItem {
|
|||||||
NotificationPreferences,
|
NotificationPreferences,
|
||||||
"emailEnabled" | "smsEnabled" | "pushEnabled"
|
"emailEnabled" | "smsEnabled" | "pushEnabled"
|
||||||
>
|
>
|
||||||
label: string
|
labelKey: string
|
||||||
description: string
|
descKey: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,88 +39,30 @@ interface CategoryItem {
|
|||||||
| "messageNotifications"
|
| "messageNotifications"
|
||||||
| "attendanceNotifications"
|
| "attendanceNotifications"
|
||||||
>
|
>
|
||||||
label: string
|
labelKey: string
|
||||||
description: string
|
descKey: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANNELS: ChannelItem[] = [
|
const CHANNELS: ChannelItem[] = [
|
||||||
{
|
{ key: "pushEnabled", labelKey: "channels.push", descKey: "channels.pushDesc", icon: Bell },
|
||||||
key: "pushEnabled",
|
{ key: "emailEnabled", labelKey: "channels.email", descKey: "channels.emailDesc", icon: Mail },
|
||||||
label: "Push Notifications",
|
{ key: "smsEnabled", labelKey: "channels.sms", descKey: "channels.smsDesc", icon: MessageSquare },
|
||||||
description: "Receive in-app and browser push notifications.",
|
|
||||||
icon: Bell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "emailEnabled",
|
|
||||||
label: "Email",
|
|
||||||
description: "Send notifications to my registered email address.",
|
|
||||||
icon: Mail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "smsEnabled",
|
|
||||||
label: "SMS",
|
|
||||||
description: "Send critical notifications via SMS (charges may apply).",
|
|
||||||
icon: MessageSquare,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const CATEGORIES: CategoryItem[] = [
|
const CATEGORIES: CategoryItem[] = [
|
||||||
{
|
{ key: "messageNotifications", labelKey: "categories.messages", descKey: "categories.messagesDesc", icon: MessageSquare },
|
||||||
key: "messageNotifications",
|
{ key: "announcementNotifications", labelKey: "categories.announcements", descKey: "categories.announcementsDesc", icon: Megaphone },
|
||||||
label: "Messages",
|
{ key: "homeworkNotifications", labelKey: "categories.homework", descKey: "categories.homeworkDesc", icon: BookOpen },
|
||||||
description: "New direct messages and replies.",
|
{ key: "gradeNotifications", labelKey: "categories.grades", descKey: "categories.gradesDesc", icon: GraduationCap },
|
||||||
icon: MessageSquare,
|
{ key: "attendanceNotifications", labelKey: "categories.attendance", descKey: "categories.attendanceDesc", icon: CalendarCheck },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "announcementNotifications",
|
|
||||||
label: "Announcements",
|
|
||||||
description: "School, grade, and class announcements.",
|
|
||||||
icon: Megaphone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "homeworkNotifications",
|
|
||||||
label: "Homework",
|
|
||||||
description: "New assignments and submission reminders.",
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "gradeNotifications",
|
|
||||||
label: "Grades",
|
|
||||||
description: "Exam and assignment grade releases.",
|
|
||||||
icon: GraduationCap,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "attendanceNotifications",
|
|
||||||
label: "Attendance",
|
|
||||||
description: "Attendance records and absence alerts.",
|
|
||||||
icon: CalendarCheck,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function SubmitButton() {
|
|
||||||
const { pending } = useFormStatus()
|
|
||||||
return (
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
Save Preferences
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationPreferencesForm({ preferences }: NotificationPreferencesFormProps) {
|
export function NotificationPreferencesForm({ preferences }: NotificationPreferencesFormProps) {
|
||||||
const [state, formAction] = useActionState(updateNotificationPreferencesAction, null)
|
const t = useTranslations("settings.notifications")
|
||||||
|
const { notifications } = useSettingsService()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
// Local state for immediate Switch toggle feedback
|
|
||||||
const [channels, setChannels] = React.useState({
|
const [channels, setChannels] = React.useState({
|
||||||
emailEnabled: preferences.emailEnabled,
|
emailEnabled: preferences.emailEnabled,
|
||||||
smsEnabled: preferences.smsEnabled,
|
smsEnabled: preferences.smsEnabled,
|
||||||
@@ -139,14 +81,6 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
quietHoursEnd: preferences.quietHoursEnd ?? "",
|
quietHoursEnd: preferences.quietHoursEnd ?? "",
|
||||||
})
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (state?.success) {
|
|
||||||
toast.success(state.message ?? "Preferences updated")
|
|
||||||
} else if (state?.success === false && state.message) {
|
|
||||||
toast.error(state.message)
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
const toggleChannel = (key: keyof typeof channels) => {
|
const toggleChannel = (key: keyof typeof channels) => {
|
||||||
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
|
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -159,187 +93,175 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled }))
|
setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await notifications.updatePreferences({
|
||||||
|
...channels,
|
||||||
|
...categories,
|
||||||
|
quietHoursEnabled: quietHours.quietHoursEnabled,
|
||||||
|
quietHoursStart: quietHours.quietHoursStart || null,
|
||||||
|
quietHoursEnd: quietHours.quietHoursEnd || null,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("success"))
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || t("failure"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("failure"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notification Preferences</CardTitle>
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
Choose how and when you want to be notified.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form action={formAction}>
|
<CardContent className="space-y-6">
|
||||||
<CardContent className="space-y-6">
|
{/* Delivery channels */}
|
||||||
{/* Delivery channels */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div>
|
<h4 className="text-sm font-medium">{t("channels.title")}</h4>
|
||||||
<h4 className="text-sm font-medium">Delivery Channels</h4>
|
<p className="text-xs text-muted-foreground">{t("channels.subtitle")}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Select the channels through which you want to receive notifications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{CHANNELS.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const checked = channels[item.key]
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
|
|
||||||
{item.label}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Hidden checkbox for form submission */}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={item.key}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleChannel(item.key)}
|
|
||||||
className="sr-only"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
id={item.key}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={() => toggleChannel(item.key)}
|
|
||||||
aria-label={item.label}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
{CHANNELS.map((item) => {
|
||||||
<Separator />
|
const Icon = item.icon
|
||||||
|
const checked = channels[item.key]
|
||||||
{/* Notification categories */}
|
return (
|
||||||
<div className="space-y-4">
|
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div className="flex items-start gap-3">
|
||||||
<h4 className="text-sm font-medium">Notification Categories</h4>
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
Choose which types of events should trigger notifications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{CATEGORIES.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const checked = categories[item.key]
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
|
|
||||||
{item.label}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-0.5">
|
||||||
<input
|
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
|
||||||
type="checkbox"
|
{t(item.labelKey)}
|
||||||
name={item.key}
|
</Label>
|
||||||
checked={checked}
|
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
|
||||||
onChange={() => toggleCategory(item.key)}
|
|
||||||
className="sr-only"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
id={item.key}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={() => toggleCategory(item.key)}
|
|
||||||
aria-label={item.label}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 免打扰时段 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium">Quiet Hours</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Suppress non-urgent notifications during a specified time period each day.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="quietHoursEnabled" className="text-sm font-medium cursor-pointer">
|
|
||||||
Enable Quiet Hours
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, only urgent notifications will be delivered during the specified hours.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="quietHoursEnabled"
|
|
||||||
checked={quietHours.quietHoursEnabled}
|
|
||||||
onChange={toggleQuietHours}
|
|
||||||
className="sr-only"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<Switch
|
<Switch
|
||||||
id="quietHoursEnabled"
|
id={item.key}
|
||||||
checked={quietHours.quietHoursEnabled}
|
checked={checked}
|
||||||
onCheckedChange={toggleQuietHours}
|
onCheckedChange={() => toggleChannel(item.key)}
|
||||||
aria-label="Enable Quiet Hours"
|
aria-label={t(item.labelKey)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Notification categories */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">{t("categories.title")}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("categories.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
{CATEGORIES.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const checked = categories[item.key]
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={item.key}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={() => toggleCategory(item.key)}
|
||||||
|
aria-label={t(item.labelKey)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Quiet hours */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">{t("quietHours.title")}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("quietHours.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="quietHoursEnabled" className="text-sm font-medium cursor-pointer">
|
||||||
|
{t("quietHours.enable")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("quietHours.enableDesc")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<Switch
|
||||||
"grid gap-4 sm:grid-cols-2 transition-opacity",
|
id="quietHoursEnabled"
|
||||||
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
|
checked={quietHours.quietHoursEnabled}
|
||||||
)}>
|
onCheckedChange={toggleQuietHours}
|
||||||
<div className="space-y-2">
|
aria-label={t("quietHours.enable")}
|
||||||
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
|
/>
|
||||||
Start Time
|
</div>
|
||||||
</Label>
|
<div className={cn(
|
||||||
<Input
|
"grid gap-4 sm:grid-cols-2 transition-opacity",
|
||||||
id="quietHoursStart"
|
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
|
||||||
name="quietHoursStart"
|
)}>
|
||||||
type="time"
|
<div className="space-y-2">
|
||||||
value={quietHours.quietHoursStart}
|
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
|
||||||
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
|
{t("quietHours.start")}
|
||||||
disabled={!quietHours.quietHoursEnabled}
|
</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="quietHoursStart"
|
||||||
<div className="space-y-2">
|
type="time"
|
||||||
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
|
value={quietHours.quietHoursStart}
|
||||||
End Time
|
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
|
||||||
</Label>
|
disabled={!quietHours.quietHoursEnabled}
|
||||||
<Input
|
/>
|
||||||
id="quietHoursEnd"
|
</div>
|
||||||
name="quietHoursEnd"
|
<div className="space-y-2">
|
||||||
type="time"
|
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
|
||||||
value={quietHours.quietHoursEnd}
|
{t("quietHours.end")}
|
||||||
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
|
</Label>
|
||||||
disabled={!quietHours.quietHoursEnabled}
|
<Input
|
||||||
/>
|
id="quietHoursEnd"
|
||||||
</div>
|
type="time"
|
||||||
|
value={quietHours.quietHoursEnd}
|
||||||
|
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
|
||||||
|
disabled={!quietHours.quietHoursEnabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-end border-t px-6 py-4">
|
</CardContent>
|
||||||
<SubmitButton />
|
<CardFooter className="flex justify-end border-t px-6 py-4">
|
||||||
</CardFooter>
|
<Button type="button" onClick={onSubmit} disabled={isPending}>
|
||||||
</form>
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("saving")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{t("save")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { LayoutDashboard, GraduationCap, CalendarDays, ClipboardList } from "lucide-react"
|
|
||||||
|
|
||||||
import { SettingsView } from "@/modules/settings/components/settings-view"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { UserProfile } from "@/modules/users/data-access"
|
|
||||||
import type { NotificationPreferences } from "@/modules/notifications/types"
|
|
||||||
|
|
||||||
interface ParentSettingsViewProps {
|
|
||||||
user: UserProfile
|
|
||||||
notificationPreferences: NotificationPreferences
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ParentSettingsView({ user, notificationPreferences }: ParentSettingsViewProps) {
|
|
||||||
const generalExtra = (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quick links</CardTitle>
|
|
||||||
<CardDescription>Common places you may want to visit.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/profile">Profile</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/parent/dashboard">
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/parent/children">
|
|
||||||
<GraduationCap className="h-4 w-4" />
|
|
||||||
Children
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/parent/grades">
|
|
||||||
<ClipboardList className="h-4 w-4" />
|
|
||||||
Grades
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/parent/attendance">
|
|
||||||
<CalendarDays className="h-4 w-4" />
|
|
||||||
Attendance
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsView
|
|
||||||
description="Manage your preferences and family account access."
|
|
||||||
backHref="/parent/dashboard"
|
|
||||||
user={user}
|
|
||||||
notificationPreferences={notificationPreferences}
|
|
||||||
generalExtra={generalExtra}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react"
|
import { useActionState, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -18,25 +19,26 @@ import {
|
|||||||
} from "@/shared/lib/password-policy"
|
} from "@/shared/lib/password-policy"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
const STRENGTH_META: Record<PasswordStrength, { value: number; label: string; barClassName: string; indicatorClassName: string }> = {
|
const STRENGTH_META: Record<PasswordStrength, { value: number; labelKey: string; barClassName: string; indicatorClassName: string }> = {
|
||||||
weak: { value: 33, label: "Weak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
|
weak: { value: 33, labelKey: "security.changePassword.strengthWeak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
|
||||||
medium: { value: 66, label: "Medium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
|
medium: { value: 66, labelKey: "security.changePassword.strengthMedium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
|
||||||
strong: { value: 100, label: "Strong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
|
strong: { value: 100, labelKey: "security.changePassword.strengthStrong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("settings.security.changePassword")
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Updating...
|
{t("updating")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<KeyRound className="mr-2 h-4 w-4" />
|
<KeyRound className="mr-2 h-4 w-4" />
|
||||||
Update Password
|
{t("submit")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,6 +46,7 @@ function SubmitButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordChangeForm() {
|
export function PasswordChangeForm() {
|
||||||
|
const t = useTranslations("settings.security.changePassword")
|
||||||
const [state, formAction] = useActionState<ActionState<null>, FormData>(
|
const [state, formAction] = useActionState<ActionState<null>, FormData>(
|
||||||
changePasswordAction,
|
changePasswordAction,
|
||||||
{ success: false, data: null }
|
{ success: false, data: null }
|
||||||
@@ -59,31 +62,29 @@ export function PasswordChangeForm() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.success) {
|
if (state?.success) {
|
||||||
toast.success(state.message ?? "Password changed successfully")
|
toast.success(state.message ?? t("success"))
|
||||||
formRef.current?.reset()
|
formRef.current?.reset()
|
||||||
} else if (state?.message) {
|
} else if (state?.message) {
|
||||||
toast.error(state.message)
|
toast.error(state.message)
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Change Password</CardTitle>
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
Choose a strong password to keep your account secure.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form ref={formRef} id="password-change-form" action={formAction} onReset={() => setNewPassword("")}>
|
<form ref={formRef} id="password-change-form" action={formAction} onReset={() => setNewPassword("")}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="currentPassword">Current Password</Label>
|
<Label htmlFor="currentPassword">{t("current")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="currentPassword"
|
id="currentPassword"
|
||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
type={showCurrent ? "text" : "password"}
|
type={showCurrent ? "text" : "password"}
|
||||||
placeholder="Enter current password"
|
placeholder={t("currentPlaceholder")}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +94,7 @@ export function PasswordChangeForm() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
onClick={() => setShowCurrent((v) => !v)}
|
onClick={() => setShowCurrent((v) => !v)}
|
||||||
tabIndex={-1}
|
aria-label={showCurrent ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -101,13 +102,13 @@ export function PasswordChangeForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="newPassword">New Password</Label>
|
<Label htmlFor="newPassword">{t("new")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="newPassword"
|
id="newPassword"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
type={showNew ? "text" : "password"}
|
type={showNew ? "text" : "password"}
|
||||||
placeholder="Enter new password"
|
placeholder={t("newPlaceholder")}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -119,7 +120,7 @@ export function PasswordChangeForm() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
onClick={() => setShowNew((v) => !v)}
|
onClick={() => setShowNew((v) => !v)}
|
||||||
tabIndex={-1}
|
aria-label={showNew ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -127,8 +128,8 @@ export function PasswordChangeForm() {
|
|||||||
{newPassword.length > 0 && (
|
{newPassword.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Password strength</span>
|
<span className="text-muted-foreground">{t("strength")}</span>
|
||||||
<span className="font-medium">{meta.label}</span>
|
<span className="font-medium">{t(meta.labelKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={meta.value} className={meta.barClassName} indicatorClassName={meta.indicatorClassName} />
|
<Progress value={meta.value} className={meta.barClassName} indicatorClassName={meta.indicatorClassName} />
|
||||||
</div>
|
</div>
|
||||||
@@ -136,13 +137,13 @@ export function PasswordChangeForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
<Label htmlFor="confirmPassword">{t("confirm")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
type={showConfirm ? "text" : "password"}
|
type={showConfirm ? "text" : "password"}
|
||||||
placeholder="Re-enter new password"
|
placeholder={t("confirmPlaceholder")}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
@@ -152,7 +153,7 @@ export function PasswordChangeForm() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
onClick={() => setShowConfirm((v) => !v)}
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
tabIndex={-1}
|
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -160,7 +161,7 @@ export function PasswordChangeForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border bg-muted/30 p-3">
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
<div className="text-xs font-medium text-muted-foreground">Password requirements:</div>
|
<div className="text-xs font-medium text-muted-foreground">{t("requirements")}</div>
|
||||||
<ul className="mt-1.5 grid gap-1 text-xs text-muted-foreground">
|
<ul className="mt-1.5 grid gap-1 text-xs text-muted-foreground">
|
||||||
{PASSWORD_REQUIREMENT_HINTS.map((hint) => (
|
{PASSWORD_REQUIREMENT_HINTS.map((hint) => (
|
||||||
<li key={hint} className="flex items-center gap-1.5">
|
<li key={hint} className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -1,40 +1,47 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useTransition } from "react"
|
import { useTransition, type ReactElement } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useForm, type Resolver } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Loader2, Save } from "lucide-react"
|
import { Loader2, Save } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Form } from "@/shared/components/ui/form"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { TextField } from "@/shared/components/form-fields/text-field"
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
|
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||||
import { UserProfile } from "@/modules/users/data-access"
|
import type { UserProfile } from "@/modules/users/data-access"
|
||||||
import { updateUserProfile } from "@/modules/users/actions"
|
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
|
||||||
|
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
name: z.string().min(2, "Name must be at least 2 characters."),
|
name: z.string().min(2, "Name must be at least 2 characters."),
|
||||||
email: z.string().email().optional(), // Read only
|
email: z.string().email().optional(),
|
||||||
role: z.string().optional(), // Read only
|
role: z.string().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
age: z.preprocess(
|
age: z.string().optional(),
|
||||||
(v) => (v === "" || v === null || v === undefined ? undefined : Number(v)),
|
|
||||||
z.number().min(0).optional()
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
||||||
|
|
||||||
export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
const GENDER_OPTIONS = [
|
||||||
|
{ value: "male", label: "Male" },
|
||||||
|
{ value: "female", label: "Female" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
{ value: "prefer_not_to_say", label: "Prefer not to say" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProfileSettingsForm({ user }: { user: UserProfile }): ReactElement {
|
||||||
|
const t = useTranslations("settings.profile")
|
||||||
|
const { profile } = useSettingsService()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileFormSchema) as Resolver<ProfileFormValues>,
|
resolver: zodResolver(profileFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user.name ?? "",
|
name: user.name ?? "",
|
||||||
email: user.email ?? "",
|
email: user.email ?? "",
|
||||||
@@ -42,27 +49,28 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
|||||||
phone: user.phone ?? "",
|
phone: user.phone ?? "",
|
||||||
address: user.address ?? "",
|
address: user.address ?? "",
|
||||||
gender: user.gender ?? "",
|
gender: user.gender ?? "",
|
||||||
age: user.age ?? undefined,
|
age: user.age !== undefined && user.age !== null ? String(user.age) : "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSubmit(data: ProfileFormValues) {
|
function onSubmit(data: ProfileFormValues): void {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await updateUserProfile({
|
const ageNum = data.age ? Number(data.age) : undefined
|
||||||
|
const result = await profile.updateProfile({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
phone: data.phone || undefined,
|
phone: data.phone || undefined,
|
||||||
address: data.address || undefined,
|
address: data.address || undefined,
|
||||||
gender: data.gender || undefined,
|
gender: data.gender || undefined,
|
||||||
age: data.age || undefined,
|
age: ageNum !== undefined && !Number.isNaN(ageNum) ? ageNum : undefined,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Profile updated successfully")
|
toast.success(t("success"))
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to update profile")
|
toast.error(result.message || t("failure"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update profile")
|
toast.error(t("failure"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -70,114 +78,59 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Information</CardTitle>
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
<CardDescription>Update your personal information.</CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
label={t("fields.name")}
|
||||||
<FormItem>
|
placeholder={t("fields.namePlaceholder")}
|
||||||
<FormLabel>Full Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Your name" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
label={t("fields.email")}
|
||||||
<FormItem>
|
disabled
|
||||||
<FormLabel>Email</FormLabel>
|
description={t("fields.emailDisabled")}
|
||||||
<FormControl>
|
|
||||||
<Input {...field} disabled />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Email cannot be changed.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="phone"
|
name="phone"
|
||||||
render={({ field }) => (
|
label={t("fields.phone")}
|
||||||
<FormItem>
|
placeholder={t("fields.phonePlaceholder")}
|
||||||
<FormLabel>Phone</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="+1 234 567 890" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<SelectField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="gender"
|
name="gender"
|
||||||
render={({ field }) => (
|
label={t("fields.gender")}
|
||||||
<FormItem>
|
placeholder={t("fields.genderPlaceholder")}
|
||||||
<FormLabel>Gender</FormLabel>
|
options={GENDER_OPTIONS}
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select gender" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="male">Male</SelectItem>
|
|
||||||
<SelectItem value="female">Female</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
<SelectItem value="prefer_not_to_say">Prefer not to say</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="age"
|
name="age"
|
||||||
render={({ field }) => (
|
label={t("fields.age")}
|
||||||
<FormItem>
|
type="number"
|
||||||
<FormLabel>Age</FormLabel>
|
placeholder={t("fields.age")}
|
||||||
<FormControl>
|
|
||||||
<Input type="number" placeholder="Age" {...field} value={field.value ?? ""} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="role"
|
name="role"
|
||||||
render={({ field }) => (
|
label={t("fields.role")}
|
||||||
<FormItem>
|
disabled
|
||||||
<FormLabel>Role</FormLabel>
|
inputClassName="capitalize"
|
||||||
<FormControl>
|
|
||||||
<Input {...field} disabled className="capitalize" />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="address"
|
name="address"
|
||||||
render={({ field }) => (
|
label={t("fields.address")}
|
||||||
<FormItem className="col-span-1 sm:col-span-2">
|
placeholder={t("fields.addressPlaceholder")}
|
||||||
<FormLabel>Address</FormLabel>
|
itemClassName="col-span-1 sm:col-span-2"
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="123 Main St, City, Country" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -186,12 +139,12 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
{t("saving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Changes
|
{t("save")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
93
src/modules/settings/components/profile-student-overview.tsx
Normal file
93
src/modules/settings/components/profile-student-overview.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ReactElement } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
|
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||||
|
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||||
|
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||||
|
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||||
|
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||||
|
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
|
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
|
interface ProfileStudentOverviewProps {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生概览区块(Server Component)
|
||||||
|
*
|
||||||
|
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
||||||
|
*/
|
||||||
|
export async function ProfileStudentOverview({
|
||||||
|
userId,
|
||||||
|
}: ProfileStudentOverviewProps): Promise<ReactElement> {
|
||||||
|
const t = await getTranslations("settings.profilePage.studentOverview")
|
||||||
|
|
||||||
|
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
||||||
|
getStudentClasses(userId),
|
||||||
|
getStudentSchedule(userId),
|
||||||
|
getStudentHomeworkAssignments(userId),
|
||||||
|
getStudentDashboardGrades(userId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const data = buildStudentOverviewData({
|
||||||
|
classes,
|
||||||
|
schedule,
|
||||||
|
assignments: assignmentsAll,
|
||||||
|
grades,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">{t("title")}</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">{t("description")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StudentStatsGrid
|
||||||
|
enrolledClassCount={data.enrolledClassCount}
|
||||||
|
dueSoonCount={data.dueSoonCount}
|
||||||
|
overdueCount={data.overdueCount}
|
||||||
|
gradedCount={data.gradedCount}
|
||||||
|
ranking={data.grades.ranking}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<StudentUpcomingAssignmentsCard upcomingAssignments={data.upcomingAssignments} />
|
||||||
|
<StudentGradesCard grades={data.grades} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StudentTodayScheduleCard items={data.todayScheduleItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生概览骨架屏
|
||||||
|
*/
|
||||||
|
export function ProfileStudentOverviewSkeleton(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 h-64 animate-pulse rounded-lg bg-muted" />
|
||||||
|
<div className="h-64 animate-pulse rounded-lg bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
115
src/modules/settings/components/profile-teacher-overview.tsx
Normal file
115
src/modules/settings/components/profile-teacher-overview.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { ReactElement } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { Calendar, GraduationCap } from "lucide-react"
|
||||||
|
|
||||||
|
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
|
interface ProfileTeacherOverviewProps {
|
||||||
|
/** 传入空字符串表示使用当前会话教师 */
|
||||||
|
teacherId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师概览区块(Server Component)
|
||||||
|
*
|
||||||
|
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
||||||
|
*/
|
||||||
|
export async function ProfileTeacherOverview(
|
||||||
|
_props: ProfileTeacherOverviewProps = {}
|
||||||
|
): Promise<ReactElement> {
|
||||||
|
const t = await getTranslations("settings.profilePage.teacherOverview")
|
||||||
|
|
||||||
|
const [subjects, classes] = await Promise.all([
|
||||||
|
getTeacherTeachingSubjects(),
|
||||||
|
getTeacherClasses(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">{t("title")}</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">{t("description")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<GraduationCap className="h-5 w-5" />
|
||||||
|
{t("teachingSubjects")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("teachingSubjectsDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{subjects.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{t("noSubjects")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{subjects.map((subject) => (
|
||||||
|
<Badge key={subject} variant="secondary">
|
||||||
|
{subject}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
{t("teachingClasses")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("teachingClassesDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{classes.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{t("noClasses")}</div>
|
||||||
|
) : (
|
||||||
|
classes.map((cls) => (
|
||||||
|
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{cls.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{cls.grade}
|
||||||
|
{cls.homeroom ? ` • ${cls.homeroom}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>{t("view")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师概览骨架屏
|
||||||
|
*/
|
||||||
|
export function ProfileTeacherOverviewSkeleton(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="h-48 animate-pulse rounded-lg bg-muted" />
|
||||||
|
<div className="h-48 animate-pulse rounded-lg bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
src/modules/settings/components/quick-links-card.tsx
Normal file
46
src/modules/settings/components/quick-links-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
export interface QuickLinkItem {
|
||||||
|
href: string
|
||||||
|
/** settings.quickLinks 命名空间下的 i18n 键 */
|
||||||
|
labelKey: string
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷链接卡片
|
||||||
|
*
|
||||||
|
* 在设置页 General 标签页底部展示角色专属的常用入口。
|
||||||
|
* 文本通过 settings.quickLinks 命名空间国际化。
|
||||||
|
*/
|
||||||
|
export function QuickLinksCard({ links }: { links: QuickLinkItem[] }): ReactNode {
|
||||||
|
const t = useTranslations("settings.quickLinks")
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/profile">{t("profile")}</Link>
|
||||||
|
</Button>
|
||||||
|
{links.map((link) => (
|
||||||
|
<Button key={link.href} asChild variant="outline" className="gap-2">
|
||||||
|
<Link href={link.href}>
|
||||||
|
{link.icon}
|
||||||
|
{t(link.labelKey)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
interface SettingsSectionErrorBoundaryProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsSectionErrorBoundaryState {
|
||||||
|
hasError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页分区 Error Boundary
|
||||||
|
*
|
||||||
|
* 包裹每个 TabsContent 内部组件,避免单个区块崩溃导致整页不可用。
|
||||||
|
*/
|
||||||
|
export class SettingsSectionErrorBoundary extends Component<
|
||||||
|
SettingsSectionErrorBoundaryProps,
|
||||||
|
SettingsSectionErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: SettingsSectionErrorBoundaryState = { hasError: false }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): SettingsSectionErrorBoundaryState {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = (): void => {
|
||||||
|
this.setState({ hasError: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<SettingsSectionErrorFallback onRetry={this.handleRetry} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSectionErrorFallback({
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
onRetry: () => void
|
||||||
|
}): ReactNode {
|
||||||
|
const t = useTranslations("settings.errors")
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("sectionLoadFailed")}
|
||||||
|
description={t("sectionLoadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("retry"),
|
||||||
|
onClick: onRetry,
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/modules/settings/components/settings-service-context.tsx
Normal file
39
src/modules/settings/components/settings-service-context.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react"
|
||||||
|
|
||||||
|
import type { SettingsService } from "@/modules/settings/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsService React Context
|
||||||
|
*
|
||||||
|
* 通过页面层注入 SettingsService 实现,组件层使用 useSettingsService() 消费,
|
||||||
|
* 避免直接 import 其他业务模块的 actions/data-access。
|
||||||
|
*/
|
||||||
|
const SettingsServiceContext = createContext<SettingsService | null>(null)
|
||||||
|
|
||||||
|
interface SettingsServiceProviderProps {
|
||||||
|
service: SettingsService
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsServiceProvider({
|
||||||
|
service,
|
||||||
|
children,
|
||||||
|
}: SettingsServiceProviderProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<SettingsServiceContext.Provider value={service}>
|
||||||
|
{children}
|
||||||
|
</SettingsServiceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsService(): SettingsService {
|
||||||
|
const ctx = useContext(SettingsServiceContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useSettingsService must be used within a SettingsServiceProvider"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link"
|
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 { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
|
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
|
||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
|
|
||||||
@@ -11,8 +12,10 @@ import { ProfileSettingsForm } from "@/modules/settings/components/profile-setti
|
|||||||
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 { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||||
|
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -25,13 +28,13 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import { 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 { usePermission } from "@/shared/hooks/use-permission"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
/** 页面副标题描述 */
|
/** 页面副标题描述(i18n 键) */
|
||||||
description: string
|
description: string
|
||||||
/** 返回仪表盘的链接 */
|
/** 返回仪表盘的链接 */
|
||||||
backHref: string
|
backHref: string
|
||||||
@@ -39,7 +42,7 @@ interface SettingsViewProps {
|
|||||||
user: UserProfile
|
user: UserProfile
|
||||||
/** 通知偏好 */
|
/** 通知偏好 */
|
||||||
notificationPreferences: NotificationPreferences
|
notificationPreferences: NotificationPreferences
|
||||||
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接/组织信息等) */
|
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
|
||||||
generalExtra?: ReactNode
|
generalExtra?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,21 @@ function isTabValue(value: string | null): value is TabValue {
|
|||||||
return value !== null && (VALID_TABS as readonly string[]).includes(value)
|
return value !== null && (VALID_TABS as readonly string[]).includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsSectionSkeleton(): ReactNode {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一设置页视图
|
* 统一设置页视图
|
||||||
*
|
*
|
||||||
@@ -61,6 +79,7 @@ function isTabValue(value: string | null): value is TabValue {
|
|||||||
*
|
*
|
||||||
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
|
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
|
||||||
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
|
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
|
||||||
|
* 每个标签页内容用 Error Boundary + Suspense 包裹,局部失败不影响整页。
|
||||||
*/
|
*/
|
||||||
function SettingsViewInner({
|
function SettingsViewInner({
|
||||||
description,
|
description,
|
||||||
@@ -69,6 +88,7 @@ function SettingsViewInner({
|
|||||||
notificationPreferences,
|
notificationPreferences,
|
||||||
generalExtra,
|
generalExtra,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
|
const t = useTranslations("settings")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
@@ -93,12 +113,12 @@ function SettingsViewInner({
|
|||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<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="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={backHref}>Back to dashboard</Link>
|
<Link href={backHref}>{t("backToDashboard")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,79 +127,99 @@ function SettingsViewInner({
|
|||||||
<TabsList className="w-full justify-start">
|
<TabsList className="w-full justify-start">
|
||||||
<TabsTrigger value="general" className="gap-2">
|
<TabsTrigger value="general" className="gap-2">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
General
|
{t("tabs.general")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="notifications" className="gap-2">
|
<TabsTrigger value="notifications" className="gap-2">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notifications
|
{t("tabs.notifications")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="appearance" className="gap-2">
|
<TabsTrigger value="appearance" className="gap-2">
|
||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
Appearance
|
{t("tabs.appearance")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="security" className="gap-2">
|
<TabsTrigger value="security" className="gap-2">
|
||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4" />
|
||||||
Security
|
{t("tabs.security")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{canConfigureAi ? (
|
{canConfigureAi ? (
|
||||||
<TabsTrigger value="ai" className="gap-2">
|
<TabsTrigger value="ai" className="gap-2">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
AI
|
{t("tabs.ai")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-6 space-y-6">
|
<TabsContent value="general" className="mt-6 space-y-6">
|
||||||
<ProfileSettingsForm user={user} />
|
<SettingsSectionErrorBoundary>
|
||||||
{generalExtra}
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
|
<ProfileSettingsForm user={user} />
|
||||||
|
{generalExtra}
|
||||||
|
</Suspense>
|
||||||
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="notifications" className="mt-6 space-y-6">
|
<TabsContent value="notifications" className="mt-6 space-y-6">
|
||||||
<NotificationPreferencesForm preferences={notificationPreferences} />
|
<SettingsSectionErrorBoundary>
|
||||||
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
|
<NotificationPreferencesForm preferences={notificationPreferences} />
|
||||||
|
</Suspense>
|
||||||
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="appearance" className="mt-6 space-y-6">
|
<TabsContent value="appearance" className="mt-6 space-y-6">
|
||||||
<ThemePreferencesCard />
|
<SettingsSectionErrorBoundary>
|
||||||
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
|
<ThemePreferencesCard />
|
||||||
|
</Suspense>
|
||||||
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="security" className="mt-6 space-y-6">
|
<TabsContent value="security" className="mt-6 space-y-6">
|
||||||
<PasswordChangeForm />
|
<SettingsSectionErrorBoundary>
|
||||||
<Card>
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
<CardHeader>
|
<PasswordChangeForm />
|
||||||
<CardTitle>Session</CardTitle>
|
<Card>
|
||||||
<CardDescription>Account access and session controls.</CardDescription>
|
<CardHeader>
|
||||||
</CardHeader>
|
<CardTitle>{t("security.session.title")}</CardTitle>
|
||||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<CardDescription>{t("security.session.description")}</CardDescription>
|
||||||
<div className="space-y-1">
|
</CardHeader>
|
||||||
<div className="text-sm font-medium">Sign out</div>
|
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
|
<div className="space-y-1">
|
||||||
</div>
|
<div className="text-sm font-medium">{t("security.session.signOut")}</div>
|
||||||
<AlertDialog>
|
<div className="text-sm text-muted-foreground">{t("security.session.signOutDesc")}</div>
|
||||||
<AlertDialogTrigger asChild>
|
</div>
|
||||||
<Button variant="outline">Log out</Button>
|
<AlertDialog>
|
||||||
</AlertDialogTrigger>
|
<AlertDialogTrigger asChild>
|
||||||
<AlertDialogContent>
|
<Button variant="outline">{t("security.session.signOut")}</Button>
|
||||||
<AlertDialogHeader>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
|
<AlertDialogContent>
|
||||||
<AlertDialogDescription>
|
<AlertDialogHeader>
|
||||||
Are you sure you want to sign out? You will be returned to the login screen.
|
<AlertDialogTitle>{t("security.session.confirmTitle")}</AlertDialogTitle>
|
||||||
</AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
{t("security.session.confirmDesc")}
|
||||||
<AlertDialogFooter>
|
</AlertDialogDescription>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
</AlertDialogHeader>
|
||||||
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
|
<AlertDialogFooter>
|
||||||
Sign out
|
<AlertDialogCancel>{t("security.session.cancel")}</AlertDialogCancel>
|
||||||
</AlertDialogAction>
|
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
</AlertDialogFooter>
|
{t("security.session.confirm")}
|
||||||
</AlertDialogContent>
|
</AlertDialogAction>
|
||||||
</AlertDialog>
|
</AlertDialogFooter>
|
||||||
</CardContent>
|
</AlertDialogContent>
|
||||||
</Card>
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Suspense>
|
||||||
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{canConfigureAi ? (
|
{canConfigureAi ? (
|
||||||
<TabsContent value="ai" className="mt-6 space-y-6">
|
<TabsContent value="ai" className="mt-6 space-y-6">
|
||||||
<AiProviderSettingsCard />
|
<SettingsSectionErrorBoundary>
|
||||||
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
|
<AiProviderSettingsCard />
|
||||||
|
</Suspense>
|
||||||
|
</SettingsSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { LayoutDashboard, PenTool, CalendarDays } from "lucide-react"
|
|
||||||
|
|
||||||
import { SettingsView } from "@/modules/settings/components/settings-view"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { UserProfile } from "@/modules/users/data-access"
|
|
||||||
import type { NotificationPreferences } from "@/modules/notifications/types"
|
|
||||||
|
|
||||||
interface StudentSettingsViewProps {
|
|
||||||
user: UserProfile
|
|
||||||
notificationPreferences: NotificationPreferences
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StudentSettingsView({ user, notificationPreferences }: StudentSettingsViewProps) {
|
|
||||||
const generalExtra = (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quick links</CardTitle>
|
|
||||||
<CardDescription>Common places you may want to visit.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/profile">Profile</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/student/dashboard">
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/student/learning/assignments">
|
|
||||||
<PenTool className="h-4 w-4" />
|
|
||||||
Assignments
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/student/schedule">
|
|
||||||
<CalendarDays className="h-4 w-4" />
|
|
||||||
Schedule
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsView
|
|
||||||
description="Manage your preferences and account access."
|
|
||||||
backHref="/student/dashboard"
|
|
||||||
user={user}
|
|
||||||
notificationPreferences={notificationPreferences}
|
|
||||||
generalExtra={generalExtra}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { LayoutDashboard, PenTool, CalendarDays, Library, FileQuestion } from "lucide-react"
|
|
||||||
|
|
||||||
import { SettingsView } from "@/modules/settings/components/settings-view"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { UserProfile } from "@/modules/users/data-access"
|
|
||||||
import type { NotificationPreferences } from "@/modules/notifications/types"
|
|
||||||
|
|
||||||
interface TeacherSettingsViewProps {
|
|
||||||
user: UserProfile
|
|
||||||
notificationPreferences: NotificationPreferences
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeacherSettingsView({ user, notificationPreferences }: TeacherSettingsViewProps) {
|
|
||||||
const generalExtra = (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quick links</CardTitle>
|
|
||||||
<CardDescription>Jump to common teacher areas.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/profile">Profile</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/teacher/dashboard">
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/teacher/textbooks">
|
|
||||||
<Library className="h-4 w-4" />
|
|
||||||
Textbooks
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/teacher/exams/all">
|
|
||||||
<FileQuestion className="h-4 w-4" />
|
|
||||||
Exams
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/teacher/homework/assignments">
|
|
||||||
<PenTool className="h-4 w-4" />
|
|
||||||
Homework
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="gap-2">
|
|
||||||
<Link href="/teacher/classes/schedule">
|
|
||||||
<CalendarDays className="h-4 w-4" />
|
|
||||||
Schedule
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsView
|
|
||||||
description="Manage your preferences and teaching workspace."
|
|
||||||
backHref="/teacher/dashboard"
|
|
||||||
user={user}
|
|
||||||
notificationPreferences={notificationPreferences}
|
|
||||||
generalExtra={generalExtra}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Monitor, Moon, Sun } from "lucide-react"
|
import { Monitor, Moon, Sun } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
type ThemeChoice = "system" | "light" | "dark"
|
type ThemeChoice = "system" | "light" | "dark"
|
||||||
|
|
||||||
export function ThemePreferencesCard() {
|
export function ThemePreferencesCard() {
|
||||||
|
const t = useTranslations("settings.appearance.theme")
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
|
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
|
||||||
@@ -22,33 +24,33 @@ export function ThemePreferencesCard() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Theme</CardTitle>
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
<CardDescription>Choose how the admin console looks on this device.</CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:max-w-md">
|
<CardContent className="grid gap-3 sm:max-w-md">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme">Color theme</Label>
|
<Label htmlFor="theme">{t("label")}</Label>
|
||||||
<Select value={value} onValueChange={(v) => setTheme(v)}>
|
<Select value={value} onValueChange={(v) => setTheme(v)}>
|
||||||
<SelectTrigger id="theme" suppressHydrationWarning>
|
<SelectTrigger id="theme" suppressHydrationWarning>
|
||||||
<SelectValue placeholder="Select theme" />
|
<SelectValue placeholder={t("title")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="system">
|
<SelectItem value="system">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
System
|
{t("system")}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="light">
|
<SelectItem value="light">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||||
Light
|
{t("light")}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="dark">
|
<SelectItem value="dark">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||||
Dark
|
{t("dark")}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
84
src/modules/settings/config/role-settings-config.tsx
Normal file
84
src/modules/settings/config/role-settings-config.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
GraduationCap,
|
||||||
|
CalendarDays,
|
||||||
|
ClipboardList,
|
||||||
|
PenTool,
|
||||||
|
Library,
|
||||||
|
FileQuestion,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { QuickLinksCard, type QuickLinkItem } from "@/modules/settings/components/quick-links-card"
|
||||||
|
import type { Role } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色设置页配置
|
||||||
|
*
|
||||||
|
* 通过配置驱动角色 → 设置视图的映射,新增角色只需在此添加条目。
|
||||||
|
* description/backHref 使用 i18n 键,由消费方翻译。
|
||||||
|
* 快捷链接的 label 通过 settings.quickLinks 命名空间国际化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RoleSettingsConfig {
|
||||||
|
/** i18n 键:settings.<role>.description */
|
||||||
|
descriptionKey: string
|
||||||
|
backHref: string
|
||||||
|
/** 角色专属快捷链接区块(General 标签页底部) */
|
||||||
|
generalExtra?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const STUDENT_LINKS: QuickLinkItem[] = [
|
||||||
|
{ href: "/student/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
|
{ href: "/student/learning/assignments", labelKey: "assignments", icon: <PenTool className="h-4 w-4" /> },
|
||||||
|
{ href: "/student/schedule", labelKey: "schedule", icon: <CalendarDays className="h-4 w-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PARENT_LINKS: QuickLinkItem[] = [
|
||||||
|
{ href: "/parent/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
|
{ href: "/parent/children", labelKey: "children", icon: <GraduationCap className="h-4 w-4" /> },
|
||||||
|
{ href: "/parent/grades", labelKey: "grades", icon: <ClipboardList className="h-4 w-4" /> },
|
||||||
|
{ href: "/parent/attendance", labelKey: "attendance", icon: <CalendarDays className="h-4 w-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEACHER_LINKS: QuickLinkItem[] = [
|
||||||
|
{ href: "/teacher/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
|
{ href: "/teacher/textbooks", labelKey: "textbooks", icon: <Library className="h-4 w-4" /> },
|
||||||
|
{ href: "/teacher/exams/all", labelKey: "exams", icon: <FileQuestion className="h-4 w-4" /> },
|
||||||
|
{ href: "/teacher/homework/assignments", labelKey: "homework", icon: <PenTool className="h-4 w-4" /> },
|
||||||
|
{ href: "/teacher/classes/schedule", labelKey: "schedule", icon: <CalendarDays className="h-4 w-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ROLE_SETTINGS_CONFIG: Partial<Record<Role, RoleSettingsConfig>> = {
|
||||||
|
admin: {
|
||||||
|
descriptionKey: "settings.roleDescriptions.admin",
|
||||||
|
backHref: "/admin/dashboard",
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
descriptionKey: "settings.roleDescriptions.teacher",
|
||||||
|
backHref: "/teacher/dashboard",
|
||||||
|
generalExtra: <QuickLinksCard links={TEACHER_LINKS} />,
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
descriptionKey: "settings.roleDescriptions.student",
|
||||||
|
backHref: "/student/dashboard",
|
||||||
|
generalExtra: <QuickLinksCard links={STUDENT_LINKS} />,
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
descriptionKey: "settings.roleDescriptions.parent",
|
||||||
|
backHref: "/parent/dashboard",
|
||||||
|
generalExtra: <QuickLinksCard links={PARENT_LINKS} />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据角色列表解析首选设置配置。
|
||||||
|
* 优先级:admin > teacher > student > parent
|
||||||
|
*/
|
||||||
|
export function resolveRoleSettingsConfig(roles: Role[]): RoleSettingsConfig | null {
|
||||||
|
for (const role of roles) {
|
||||||
|
const config = ROLE_SETTINGS_CONFIG[role]
|
||||||
|
if (config) return config
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
150
src/modules/settings/lib/student-overview-data.ts
Normal file
150
src/modules/settings/lib/student-overview-data.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生概览纯数据计算
|
||||||
|
*
|
||||||
|
* 将数据获取与计算逻辑从页面组件中抽出,便于单元测试。
|
||||||
|
* 输入为已获取的原始数据,输出为 UI 直接消费的视图模型。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StudentScheduleItem {
|
||||||
|
id: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
course: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
location: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentOverviewData<TGrades = unknown> {
|
||||||
|
enrolledClassCount: number
|
||||||
|
dueSoonCount: number
|
||||||
|
overdueCount: number
|
||||||
|
gradedCount: number
|
||||||
|
todayScheduleItems: StudentScheduleItem[]
|
||||||
|
upcomingAssignments: StudentHomeworkAssignmentListItem[]
|
||||||
|
grades: TGrades
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
||||||
|
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
|
|
||||||
|
export function toWeekday(d: Date): Weekday {
|
||||||
|
const day = d.getDay()
|
||||||
|
const result = WEEKDAY_MAP[day]
|
||||||
|
if (result < 1 || result > 7) throw new Error("Invalid weekday")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawScheduleItem {
|
||||||
|
id: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
course: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
location?: string | null
|
||||||
|
weekday: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAssignment {
|
||||||
|
id: string
|
||||||
|
dueAt: string | null
|
||||||
|
progressStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从原始作业列表计算学生概览统计
|
||||||
|
*/
|
||||||
|
export function computeStudentStats(
|
||||||
|
assignments: ReadonlyArray<RawAssignment>,
|
||||||
|
now: Date = new Date()
|
||||||
|
): { dueSoonCount: number; overdueCount: number; gradedCount: number } {
|
||||||
|
const in7Days = new Date(now)
|
||||||
|
in7Days.setDate(in7Days.getDate() + 7)
|
||||||
|
|
||||||
|
const dueSoonCount = assignments.filter((a) => {
|
||||||
|
if (!a.dueAt) return false
|
||||||
|
const due = new Date(a.dueAt)
|
||||||
|
return due >= now && due <= in7Days && a.progressStatus !== "graded"
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const overdueCount = assignments.filter((a) => {
|
||||||
|
if (!a.dueAt) return false
|
||||||
|
const due = new Date(a.dueAt)
|
||||||
|
return due < now && a.progressStatus !== "graded"
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
|
||||||
|
|
||||||
|
return { dueSoonCount, overdueCount, gradedCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序并截取即将到期的作业列表
|
||||||
|
*/
|
||||||
|
export function sortUpcomingAssignments<T extends RawAssignment>(
|
||||||
|
assignments: ReadonlyArray<T>,
|
||||||
|
limit: number = 8
|
||||||
|
): T[] {
|
||||||
|
return [...assignments]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
|
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
|
if (aTime !== bTime) return aTime - bTime
|
||||||
|
return a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤今日课表
|
||||||
|
*/
|
||||||
|
export function filterTodaySchedule<T extends RawScheduleItem>(
|
||||||
|
schedule: ReadonlyArray<T>,
|
||||||
|
now: Date = new Date()
|
||||||
|
): StudentScheduleItem[] {
|
||||||
|
const todayWeekday = toWeekday(now)
|
||||||
|
return schedule
|
||||||
|
.filter((s) => s.weekday === todayWeekday)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
classId: s.classId,
|
||||||
|
className: s.className,
|
||||||
|
course: s.course,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
location: s.location ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装完整的学生概览视图模型
|
||||||
|
*/
|
||||||
|
export function buildStudentOverviewData<TGrades>(
|
||||||
|
params: {
|
||||||
|
classes: ReadonlyArray<unknown>
|
||||||
|
schedule: ReadonlyArray<RawScheduleItem>
|
||||||
|
assignments: ReadonlyArray<StudentHomeworkAssignmentListItem>
|
||||||
|
grades: TGrades
|
||||||
|
now?: Date
|
||||||
|
}
|
||||||
|
): StudentOverviewData<TGrades> {
|
||||||
|
const { classes, schedule, assignments, grades, now = new Date() } = params
|
||||||
|
const stats = computeStudentStats(assignments, now)
|
||||||
|
const upcomingAssignments = sortUpcomingAssignments(assignments)
|
||||||
|
const todayScheduleItems = filterTodaySchedule(schedule, now)
|
||||||
|
|
||||||
|
return {
|
||||||
|
enrolledClassCount: classes.length,
|
||||||
|
dueSoonCount: stats.dueSoonCount,
|
||||||
|
overdueCount: stats.overdueCount,
|
||||||
|
gradedCount: stats.gradedCount,
|
||||||
|
todayScheduleItems,
|
||||||
|
upcomingAssignments,
|
||||||
|
grades,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import type { UpdateUserProfileInput, UserProfile } from "@/modules/users/data-access"
|
||||||
|
import type {
|
||||||
|
NotificationPreferences,
|
||||||
|
UpdateNotificationPreferencesInput,
|
||||||
|
} from "@/modules/notifications/types"
|
||||||
|
|
||||||
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
|
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
|
||||||
|
|
||||||
export interface AiProviderSummary {
|
export interface AiProviderSummary {
|
||||||
@@ -16,3 +23,38 @@ export interface AiProviderExisting {
|
|||||||
apiKeyLast4: string | null
|
apiKeyLast4: string | null
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人资料服务接口(解耦 settings 组件对 users/actions 的直接依赖)
|
||||||
|
*
|
||||||
|
* 由页面层注入实现,组件层通过 useSettingsService().profile 消费。
|
||||||
|
*/
|
||||||
|
export interface ProfileService {
|
||||||
|
getProfile: () => Promise<UserProfile | null>
|
||||||
|
updateProfile: (input: UpdateUserProfileInput) => Promise<ActionState<void>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知偏好服务接口(解耦 settings 组件对 messaging/actions 的直接依赖)
|
||||||
|
*
|
||||||
|
* 由页面层注入实现,组件层通过 useSettingsService().notifications 消费。
|
||||||
|
*/
|
||||||
|
export interface NotificationPreferenceService {
|
||||||
|
getPreferences: () => Promise<NotificationPreferences>
|
||||||
|
updatePreferences: (
|
||||||
|
input: UpdateNotificationPreferencesInput
|
||||||
|
) => Promise<ActionState<NotificationPreferences>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置模块统一服务接口
|
||||||
|
*
|
||||||
|
* 通过 React Context 注入,组件层不直接 import 其他业务模块的 actions。
|
||||||
|
* 不同角色或测试场景可注入不同实现。
|
||||||
|
*/
|
||||||
|
export interface SettingsService {
|
||||||
|
profile: ProfileService
|
||||||
|
notifications: NotificationPreferenceService
|
||||||
|
/** 预留埋点接口 */
|
||||||
|
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|||||||
280
src/shared/i18n/messages/en/settings.json
Normal file
280
src/shared/i18n/messages/en/settings.json
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
{
|
||||||
|
"title": "Settings",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"tabs": {
|
||||||
|
"general": "General",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"security": "Security",
|
||||||
|
"ai": "AI"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile Information",
|
||||||
|
"description": "Update your personal information.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Full Name",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"email": "Email",
|
||||||
|
"emailDisabled": "Email cannot be changed.",
|
||||||
|
"phone": "Phone",
|
||||||
|
"phonePlaceholder": "+1 234 567 890",
|
||||||
|
"address": "Address",
|
||||||
|
"addressPlaceholder": "123 Main St, City, Country",
|
||||||
|
"gender": "Gender",
|
||||||
|
"genderPlaceholder": "Select gender",
|
||||||
|
"age": "Age",
|
||||||
|
"role": "Role"
|
||||||
|
},
|
||||||
|
"save": "Save Changes",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"success": "Profile updated successfully",
|
||||||
|
"failure": "Failed to update profile"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notification Preferences",
|
||||||
|
"description": "Choose how and when you want to be notified.",
|
||||||
|
"channels": {
|
||||||
|
"title": "Delivery Channels",
|
||||||
|
"subtitle": "Select the channels through which you want to receive notifications.",
|
||||||
|
"push": "Push Notifications",
|
||||||
|
"pushDesc": "Receive in-app and browser push notifications.",
|
||||||
|
"email": "Email",
|
||||||
|
"emailDesc": "Send notifications to my registered email address.",
|
||||||
|
"sms": "SMS",
|
||||||
|
"smsDesc": "Send critical notifications via SMS (charges may apply)."
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Notification Categories",
|
||||||
|
"subtitle": "Choose which types of events should trigger notifications.",
|
||||||
|
"messages": "Messages",
|
||||||
|
"messagesDesc": "New direct messages and replies.",
|
||||||
|
"announcements": "Announcements",
|
||||||
|
"announcementsDesc": "School, grade, and class announcements.",
|
||||||
|
"homework": "Homework",
|
||||||
|
"homeworkDesc": "New assignments and submission reminders.",
|
||||||
|
"grades": "Grades",
|
||||||
|
"gradesDesc": "Exam and assignment grade releases.",
|
||||||
|
"attendance": "Attendance",
|
||||||
|
"attendanceDesc": "Attendance records and absence alerts."
|
||||||
|
},
|
||||||
|
"quietHours": {
|
||||||
|
"title": "Quiet Hours",
|
||||||
|
"subtitle": "Suppress non-urgent notifications during a specified time period each day.",
|
||||||
|
"enable": "Enable Quiet Hours",
|
||||||
|
"enableDesc": "When enabled, only urgent notifications will be delivered during the specified hours.",
|
||||||
|
"start": "Start Time",
|
||||||
|
"end": "End Time"
|
||||||
|
},
|
||||||
|
"save": "Save Preferences",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"success": "Preferences updated",
|
||||||
|
"failure": "Failed to update preferences"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme",
|
||||||
|
"description": "Choose how the interface looks on this device.",
|
||||||
|
"label": "Color theme",
|
||||||
|
"system": "System",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Language",
|
||||||
|
"description": "Choose the interface language.",
|
||||||
|
"label": "Interface language"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"changePassword": {
|
||||||
|
"title": "Change Password",
|
||||||
|
"description": "Choose a strong password to keep your account secure.",
|
||||||
|
"current": "Current Password",
|
||||||
|
"currentPlaceholder": "Enter current password",
|
||||||
|
"new": "New Password",
|
||||||
|
"newPlaceholder": "Enter new password",
|
||||||
|
"confirm": "Confirm New Password",
|
||||||
|
"confirmPlaceholder": "Re-enter new password",
|
||||||
|
"strength": "Password strength",
|
||||||
|
"strengthWeak": "Weak",
|
||||||
|
"strengthMedium": "Medium",
|
||||||
|
"strengthStrong": "Strong",
|
||||||
|
"requirements": "Password requirements:",
|
||||||
|
"submit": "Update Password",
|
||||||
|
"updating": "Updating..."
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"title": "Session",
|
||||||
|
"description": "Account access and session controls.",
|
||||||
|
"signOut": "Log out",
|
||||||
|
"signOutDesc": "Return to the login screen.",
|
||||||
|
"confirmTitle": "Confirm sign out",
|
||||||
|
"confirmDesc": "Are you sure you want to sign out? You will be returned to the login screen.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Sign out"
|
||||||
|
},
|
||||||
|
"tips": {
|
||||||
|
"title": "Security Tips",
|
||||||
|
"description": "Best practices to keep your account safe.",
|
||||||
|
"tip1": "Use a unique password that you don't reuse across other sites.",
|
||||||
|
"tip2": "Avoid common words, names, or sequential patterns.",
|
||||||
|
"tip3": "Change your password periodically.",
|
||||||
|
"tip4": "Your account will be temporarily locked after multiple failed login attempts."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"providers": {
|
||||||
|
"title": "AI Providers",
|
||||||
|
"description": "Manage AI vendors and default model configuration.",
|
||||||
|
"existing": "Existing Providers",
|
||||||
|
"selectPlaceholder": "Create new or select existing",
|
||||||
|
"createNew": "Create new",
|
||||||
|
"keyStatus": "Key Status",
|
||||||
|
"stored": "Stored",
|
||||||
|
"noKey": "No key stored",
|
||||||
|
"id": "ID",
|
||||||
|
"idDesc": "Auto-generated for each provider.",
|
||||||
|
"provider": "Provider",
|
||||||
|
"providerPlaceholder": "Select provider",
|
||||||
|
"baseUrl": "API URL",
|
||||||
|
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"baseUrlDesc": "Enter base URL without /chat/completions suffix.",
|
||||||
|
"model": "Model",
|
||||||
|
"modelPlaceholder": "gpt-4o-mini",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"apiKeyPlaceholder": "Paste new key to replace",
|
||||||
|
"apiKeyDesc": "Existing key won't be displayed. Leave blank to keep current.",
|
||||||
|
"setDefault": "Set as default",
|
||||||
|
"test": "Test",
|
||||||
|
"testing": "Testing...",
|
||||||
|
"save": "Save Changes",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"testSuccess": "Test passed",
|
||||||
|
"testFailure": "Test failed",
|
||||||
|
"saveSuccess": "Saved",
|
||||||
|
"saveFailure": "Failed to save",
|
||||||
|
"loadFailure": "Failed to load AI providers",
|
||||||
|
"needKey": "Please enter API key to test",
|
||||||
|
"needTest": "Please test the configuration before saving"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickLinks": {
|
||||||
|
"title": "Quick links",
|
||||||
|
"description": "Common places you may want to visit.",
|
||||||
|
"profile": "Profile",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"children": "Children",
|
||||||
|
"grades": "Grades",
|
||||||
|
"attendance": "Attendance",
|
||||||
|
"assignments": "Assignments",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"textbooks": "Textbooks",
|
||||||
|
"exams": "Exams",
|
||||||
|
"homework": "Homework"
|
||||||
|
},
|
||||||
|
"roleDescriptions": {
|
||||||
|
"admin": "Manage your account and system configuration.",
|
||||||
|
"teacher": "Manage your profile, notifications, and teaching preferences.",
|
||||||
|
"student": "Manage your profile, notifications, and learning preferences.",
|
||||||
|
"parent": "Manage your profile, notifications, and child follow-up preferences."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Page load failed",
|
||||||
|
"loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"sectionLoadFailed": "This section failed to load",
|
||||||
|
"sectionLoadFailedDesc": "Please try again later."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "System Settings",
|
||||||
|
"description": "Manage system basics and runtime parameters.",
|
||||||
|
"schoolInfo": {
|
||||||
|
"title": "School Information",
|
||||||
|
"description": "Basic school information displayed throughout the system.",
|
||||||
|
"name": "School Name",
|
||||||
|
"namePlaceholder": "Enter school name",
|
||||||
|
"code": "School Code",
|
||||||
|
"codePlaceholder": "Enter school code",
|
||||||
|
"phone": "Contact Phone",
|
||||||
|
"phonePlaceholder": "Enter contact phone",
|
||||||
|
"email": "Contact Email",
|
||||||
|
"emailPlaceholder": "Enter contact email",
|
||||||
|
"address": "School Address",
|
||||||
|
"addressPlaceholder": "Enter school address",
|
||||||
|
"description2": "School Description",
|
||||||
|
"descriptionPlaceholder": "Enter school description"
|
||||||
|
},
|
||||||
|
"securityPolicy": {
|
||||||
|
"title": "Security Policy",
|
||||||
|
"description": "Password policy and session management.",
|
||||||
|
"passwordMinLength": "Minimum Password Length",
|
||||||
|
"sessionTimeout": "Session Timeout (minutes)",
|
||||||
|
"requireSpecialChar": "Require special characters in passwords",
|
||||||
|
"requireSpecialCharDesc": "Require at least one special character in user passwords",
|
||||||
|
"requireUppercase": "Require uppercase letters in passwords",
|
||||||
|
"requireUppercaseDesc": "Require at least one uppercase letter in user passwords",
|
||||||
|
"forcePasswordChange": "Force password change on first login",
|
||||||
|
"forcePasswordChangeDesc": "New users or after password reset must change password on first login"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"title": "File Upload",
|
||||||
|
"description": "File upload limits and storage configuration.",
|
||||||
|
"maxFileSize": "Max File Size (MB)",
|
||||||
|
"allowedTypes": "Allowed File Types",
|
||||||
|
"allowedTypesPlaceholder": "e.g. jpg,png,pdf,docx"
|
||||||
|
},
|
||||||
|
"notificationConfig": {
|
||||||
|
"title": "Notification Configuration",
|
||||||
|
"description": "How and when system notifications are sent.",
|
||||||
|
"notifyNewUser": "Notify admins on new user registration",
|
||||||
|
"notifyNewUserDesc": "Send notification to admins when a new user registers",
|
||||||
|
"notifyScheduleChange": "Notify teachers on schedule changes",
|
||||||
|
"notifyScheduleChangeDesc": "Notify relevant teachers when schedule change is approved",
|
||||||
|
"notifyAnnouncement": "Notify target users on announcement publish",
|
||||||
|
"notifyAnnouncementDesc": "Push notification to target users when announcement is published"
|
||||||
|
},
|
||||||
|
"save": "Save Settings",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"reset": "Reset",
|
||||||
|
"saveSuccess": "Settings saved",
|
||||||
|
"saveFailure": "Failed to save settings",
|
||||||
|
"loadFailure": "Failed to load system settings"
|
||||||
|
},
|
||||||
|
"profilePage": {
|
||||||
|
"title": "Profile",
|
||||||
|
"description": "Manage your personal and account information.",
|
||||||
|
"editProfile": "Edit Profile",
|
||||||
|
"personalInfo": {
|
||||||
|
"title": "Personal Information",
|
||||||
|
"description": "Basic personal details.",
|
||||||
|
"fullName": "Full Name",
|
||||||
|
"gender": "Gender",
|
||||||
|
"age": "Age",
|
||||||
|
"phone": "Phone",
|
||||||
|
"address": "Address"
|
||||||
|
},
|
||||||
|
"accountInfo": {
|
||||||
|
"title": "Account Information",
|
||||||
|
"description": "System account details.",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"memberSince": "Member Since",
|
||||||
|
"onboardedAt": "Onboarded At"
|
||||||
|
},
|
||||||
|
"studentOverview": {
|
||||||
|
"title": "Student Overview",
|
||||||
|
"description": "Your academic performance and schedule."
|
||||||
|
},
|
||||||
|
"teacherOverview": {
|
||||||
|
"title": "Teacher Overview",
|
||||||
|
"description": "Your teaching subjects and classes.",
|
||||||
|
"teachingSubjects": "Teaching Subjects",
|
||||||
|
"teachingSubjectsDesc": "Subjects you are currently assigned to teach.",
|
||||||
|
"noSubjects": "No subjects assigned yet.",
|
||||||
|
"teachingClasses": "Teaching Classes",
|
||||||
|
"teachingClassesDesc": "Classes you are currently managing.",
|
||||||
|
"noClasses": "No classes assigned yet.",
|
||||||
|
"view": "View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
280
src/shared/i18n/messages/zh-CN/settings.json
Normal file
280
src/shared/i18n/messages/zh-CN/settings.json
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
{
|
||||||
|
"title": "设置",
|
||||||
|
"backToDashboard": "返回仪表盘",
|
||||||
|
"tabs": {
|
||||||
|
"general": "通用",
|
||||||
|
"notifications": "通知",
|
||||||
|
"appearance": "外观",
|
||||||
|
"security": "安全",
|
||||||
|
"ai": "AI"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "个人信息",
|
||||||
|
"description": "更新您的个人资料。",
|
||||||
|
"fields": {
|
||||||
|
"name": "姓名",
|
||||||
|
"namePlaceholder": "您的姓名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"emailDisabled": "邮箱不可修改。",
|
||||||
|
"phone": "电话",
|
||||||
|
"phonePlaceholder": "+86 138 0000 0000",
|
||||||
|
"address": "地址",
|
||||||
|
"addressPlaceholder": "省市区街道",
|
||||||
|
"gender": "性别",
|
||||||
|
"genderPlaceholder": "选择性别",
|
||||||
|
"age": "年龄",
|
||||||
|
"role": "角色"
|
||||||
|
},
|
||||||
|
"save": "保存修改",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"success": "个人资料更新成功",
|
||||||
|
"failure": "个人资料更新失败"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知偏好",
|
||||||
|
"description": "选择您希望接收通知的方式和时间。",
|
||||||
|
"channels": {
|
||||||
|
"title": "通知渠道",
|
||||||
|
"subtitle": "选择您希望接收通知的渠道。",
|
||||||
|
"push": "推送通知",
|
||||||
|
"pushDesc": "接收应用内和浏览器推送通知。",
|
||||||
|
"email": "邮件",
|
||||||
|
"emailDesc": "将通知发送到我的注册邮箱。",
|
||||||
|
"sms": "短信",
|
||||||
|
"smsDesc": "通过短信发送重要通知(可能产生费用)。"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "通知类别",
|
||||||
|
"subtitle": "选择哪些事件应触发通知。",
|
||||||
|
"messages": "消息",
|
||||||
|
"messagesDesc": "新的私信和回复。",
|
||||||
|
"announcements": "公告",
|
||||||
|
"announcementsDesc": "学校、年级和班级公告。",
|
||||||
|
"homework": "作业",
|
||||||
|
"homeworkDesc": "新作业和提交提醒。",
|
||||||
|
"grades": "成绩",
|
||||||
|
"gradesDesc": "考试和作业成绩发布。",
|
||||||
|
"attendance": "考勤",
|
||||||
|
"attendanceDesc": "考勤记录和缺勤提醒。"
|
||||||
|
},
|
||||||
|
"quietHours": {
|
||||||
|
"title": "免打扰时段",
|
||||||
|
"subtitle": "每天在指定时段内暂停非紧急通知。",
|
||||||
|
"enable": "启用免打扰时段",
|
||||||
|
"enableDesc": "启用后,仅在指定时段内发送紧急通知。",
|
||||||
|
"start": "开始时间",
|
||||||
|
"end": "结束时间"
|
||||||
|
},
|
||||||
|
"save": "保存偏好",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"success": "通知偏好已更新",
|
||||||
|
"failure": "通知偏好更新失败"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"theme": {
|
||||||
|
"title": "主题",
|
||||||
|
"description": "选择此设备上的界面外观。",
|
||||||
|
"label": "配色主题",
|
||||||
|
"system": "跟随系统",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "语言",
|
||||||
|
"description": "选择界面语言。",
|
||||||
|
"label": "界面语言"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"changePassword": {
|
||||||
|
"title": "修改密码",
|
||||||
|
"description": "使用强密码保护您的账户安全。",
|
||||||
|
"current": "当前密码",
|
||||||
|
"currentPlaceholder": "输入当前密码",
|
||||||
|
"new": "新密码",
|
||||||
|
"newPlaceholder": "输入新密码",
|
||||||
|
"confirm": "确认新密码",
|
||||||
|
"confirmPlaceholder": "再次输入新密码",
|
||||||
|
"strength": "密码强度",
|
||||||
|
"strengthWeak": "弱",
|
||||||
|
"strengthMedium": "中",
|
||||||
|
"strengthStrong": "强",
|
||||||
|
"requirements": "密码要求:",
|
||||||
|
"submit": "更新密码",
|
||||||
|
"updating": "更新中..."
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"title": "会话",
|
||||||
|
"description": "账户访问与会话管理。",
|
||||||
|
"signOut": "退出登录",
|
||||||
|
"signOutDesc": "返回登录页面。",
|
||||||
|
"confirmTitle": "确认退出",
|
||||||
|
"confirmDesc": "确定要退出登录吗?您将返回登录页面。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认退出"
|
||||||
|
},
|
||||||
|
"tips": {
|
||||||
|
"title": "安全提示",
|
||||||
|
"description": "保护账户安全的最佳实践。",
|
||||||
|
"tip1": "使用不与其他网站重复的独立密码。",
|
||||||
|
"tip2": "避免使用常见词汇、姓名或连续模式。",
|
||||||
|
"tip3": "定期更换密码。",
|
||||||
|
"tip4": "多次登录失败后账户将被临时锁定。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"providers": {
|
||||||
|
"title": "AI 服务商",
|
||||||
|
"description": "管理 AI 供应商和默认模型配置。",
|
||||||
|
"existing": "已有服务商",
|
||||||
|
"selectPlaceholder": "新建或选择已有",
|
||||||
|
"createNew": "新建",
|
||||||
|
"keyStatus": "密钥状态",
|
||||||
|
"stored": "已存储",
|
||||||
|
"noKey": "未存储密钥",
|
||||||
|
"id": "ID",
|
||||||
|
"idDesc": "每个服务商自动生成。",
|
||||||
|
"provider": "服务商",
|
||||||
|
"providerPlaceholder": "选择服务商",
|
||||||
|
"baseUrl": "API 地址",
|
||||||
|
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"baseUrlDesc": "输入基础地址,无需 /chat/completions 后缀。",
|
||||||
|
"model": "模型",
|
||||||
|
"modelPlaceholder": "gpt-4o-mini",
|
||||||
|
"apiKey": "API 密钥",
|
||||||
|
"apiKeyPlaceholder": "粘贴新密钥以替换",
|
||||||
|
"apiKeyDesc": "已有密钥不会显示。留空则保留当前密钥。",
|
||||||
|
"setDefault": "设为默认",
|
||||||
|
"test": "测试",
|
||||||
|
"testing": "测试中...",
|
||||||
|
"save": "保存修改",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"testSuccess": "测试通过",
|
||||||
|
"testFailure": "测试失败",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"saveFailure": "保存失败",
|
||||||
|
"loadFailure": "加载 AI 服务商失败",
|
||||||
|
"needKey": "请输入 API 密钥进行测试",
|
||||||
|
"needTest": "保存前请先测试配置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickLinks": {
|
||||||
|
"title": "快捷链接",
|
||||||
|
"description": "您可能想访问的常用页面。",
|
||||||
|
"profile": "个人资料",
|
||||||
|
"dashboard": "仪表盘",
|
||||||
|
"children": "孩子",
|
||||||
|
"grades": "成绩",
|
||||||
|
"attendance": "考勤",
|
||||||
|
"assignments": "作业",
|
||||||
|
"schedule": "课表",
|
||||||
|
"textbooks": "教材",
|
||||||
|
"exams": "考试",
|
||||||
|
"homework": "作业"
|
||||||
|
},
|
||||||
|
"roleDescriptions": {
|
||||||
|
"admin": "管理您的账户和系统配置。",
|
||||||
|
"teacher": "管理您的个人信息、通知和教学偏好。",
|
||||||
|
"student": "管理您的个人信息、通知和学习偏好。",
|
||||||
|
"parent": "管理您的个人信息、通知和孩子关注偏好。"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "页面加载失败",
|
||||||
|
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
|
||||||
|
"retry": "重试",
|
||||||
|
"sectionLoadFailed": "该区块加载失败",
|
||||||
|
"sectionLoadFailedDesc": "请稍后重试。"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "系统设置",
|
||||||
|
"description": "管理系统基础信息与运行参数。",
|
||||||
|
"schoolInfo": {
|
||||||
|
"title": "学校信息",
|
||||||
|
"description": "学校的基础信息,将显示在系统各处。",
|
||||||
|
"name": "学校名称",
|
||||||
|
"namePlaceholder": "请输入学校名称",
|
||||||
|
"code": "学校代码",
|
||||||
|
"codePlaceholder": "请输入学校代码",
|
||||||
|
"phone": "联系电话",
|
||||||
|
"phonePlaceholder": "请输入联系电话",
|
||||||
|
"email": "联系邮箱",
|
||||||
|
"emailPlaceholder": "请输入联系邮箱",
|
||||||
|
"address": "学校地址",
|
||||||
|
"addressPlaceholder": "请输入学校地址",
|
||||||
|
"description2": "学校简介",
|
||||||
|
"descriptionPlaceholder": "请输入学校简介"
|
||||||
|
},
|
||||||
|
"securityPolicy": {
|
||||||
|
"title": "安全策略",
|
||||||
|
"description": "密码策略与会话管理。",
|
||||||
|
"passwordMinLength": "密码最小长度",
|
||||||
|
"sessionTimeout": "会话超时(分钟)",
|
||||||
|
"requireSpecialChar": "密码必须包含特殊字符",
|
||||||
|
"requireSpecialCharDesc": "要求用户密码中包含至少一个特殊字符",
|
||||||
|
"requireUppercase": "密码必须包含大写字母",
|
||||||
|
"requireUppercaseDesc": "要求用户密码中包含至少一个大写字母",
|
||||||
|
"forcePasswordChange": "首次登录强制修改密码",
|
||||||
|
"forcePasswordChangeDesc": "新用户或重置密码后首次登录时必须修改密码"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"title": "文件上传",
|
||||||
|
"description": "文件上传限制与存储配置。",
|
||||||
|
"maxFileSize": "单文件最大大小(MB)",
|
||||||
|
"allowedTypes": "允许的文件类型",
|
||||||
|
"allowedTypesPlaceholder": "如:jpg,png,pdf,docx"
|
||||||
|
},
|
||||||
|
"notificationConfig": {
|
||||||
|
"title": "通知配置",
|
||||||
|
"description": "系统通知的发送方式与触发条件。",
|
||||||
|
"notifyNewUser": "新用户注册通知管理员",
|
||||||
|
"notifyNewUserDesc": "有新用户注册时向管理员发送通知",
|
||||||
|
"notifyScheduleChange": "课表变更通知教师",
|
||||||
|
"notifyScheduleChangeDesc": "课表变更审批通过后通知相关教师",
|
||||||
|
"notifyAnnouncement": "公告发布通知目标用户",
|
||||||
|
"notifyAnnouncementDesc": "公告发布时向目标用户推送通知"
|
||||||
|
},
|
||||||
|
"save": "保存设置",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"reset": "重置",
|
||||||
|
"saveSuccess": "设置已保存",
|
||||||
|
"saveFailure": "设置保存失败",
|
||||||
|
"loadFailure": "加载系统设置失败"
|
||||||
|
},
|
||||||
|
"profilePage": {
|
||||||
|
"title": "个人资料",
|
||||||
|
"description": "管理您的个人和账户信息。",
|
||||||
|
"editProfile": "编辑资料",
|
||||||
|
"personalInfo": {
|
||||||
|
"title": "个人信息",
|
||||||
|
"description": "基本个人资料。",
|
||||||
|
"fullName": "姓名",
|
||||||
|
"gender": "性别",
|
||||||
|
"age": "年龄",
|
||||||
|
"phone": "电话",
|
||||||
|
"address": "地址"
|
||||||
|
},
|
||||||
|
"accountInfo": {
|
||||||
|
"title": "账户信息",
|
||||||
|
"description": "系统账户详情。",
|
||||||
|
"email": "邮箱",
|
||||||
|
"role": "角色",
|
||||||
|
"memberSince": "注册时间",
|
||||||
|
"onboardedAt": "入职时间"
|
||||||
|
},
|
||||||
|
"studentOverview": {
|
||||||
|
"title": "学生概览",
|
||||||
|
"description": "您的学业表现和课表。"
|
||||||
|
},
|
||||||
|
"teacherOverview": {
|
||||||
|
"title": "教师概览",
|
||||||
|
"description": "您任教的科目和班级。",
|
||||||
|
"teachingSubjects": "任教科目",
|
||||||
|
"teachingSubjectsDesc": "您当前被分配教授的科目。",
|
||||||
|
"noSubjects": "暂无分配科目。",
|
||||||
|
"teachingClasses": "任教班级",
|
||||||
|
"teachingClassesDesc": "您当前管理的班级。",
|
||||||
|
"noClasses": "暂无分配班级。",
|
||||||
|
"view": "查看"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user