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:
SpecialX
2026-06-22 16:15:36 +08:00
parent 21c7e65fee
commit 5d42495480
29 changed files with 2445 additions and 1094 deletions

View File

@@ -1327,17 +1327,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
## 2.23 settings设置模块
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好。
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)
**导出函数**
- Actions`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
- 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 下沉)
- 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`
- Types`AiProviderSummary` / `AiProviderName` / `AiProviderExisting`P1 新增,从 actions.ts 迁出
- 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
- 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 已修复:~~`changePasswordAction` 使用 `requireAuth()` 无 Zod 校验~~ 改为 `requirePermission(USER_PROFILE_UPDATE)` + `ChangePasswordSchema` Zod 校验 + 并行查询优化
- ✅ P2 已修复:`actions-password.ts` 删除本地 `normalizeBcryptHash`,统一复用 `shared/lib/bcrypt-utils.normalizeBcryptHash`,消除重复代码
- ✅ P2-a 已修复:~~admin/teacher/student个设置视图重复布局~~ 新增 `SettingsView` 统一设置页布局5 标签页 + 角色差异通过 props 注入4 个设置页改为消费 `SettingsView`
- ✅ parent 角色路由已修复:~~parent 用户被错误渲染为 TeacherSettingsView~~ 新增 `ParentSettingsView``/settings` 页面增加 parent 角色分支
- ✅ P2-a 已修复:~~admin/teacher/student/parent 四个设置视图重复布局~~ 新增 `SettingsView` 统一设置页布局 + `resolveRoleSettingsConfig` 配置驱动角色路由,删除 `parent-settings-view.tsx` / `student-settings-view.tsx` / `teacher-settings-view.tsx`
- ✅ parent 角色路由已修复:通过 `ROLE_SETTINGS_CONFIG` 配置驱动parent 用户正确渲染对应配置
- ✅ Tab URL 持久化已修复:`SettingsView` 改为受控模式,通过 `useSearchParams` 读取 `tab` 参数,`router.push` 更新 URL
- ✅ 登出二次确认已修复:`SettingsView` 的 Log out 按钮使用 `AlertDialog` 包裹,点击时弹出确认对话框
- ✅ AiProviderSettingsCard 已集成:`SettingsView` 新增 AI 标签页,条件渲染需 `AI_CONFIGURE` 权限
- ✅ 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` 权限校验
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `actions.ts` | 178 | AI Provider CRUD + 测试P1 已修复,无直接 DB 操作) |
| `actions-password.ts` | 107 | 修改密码P1 已修复requirePermission + Zod + data-access |
| `data-access.ts` | 175 | AI Provider CRUD + 密码修改 DB 操作P1 新增) |
| `types.ts` | 16 | 类型定义(P1 新增,AiProviderSummary |
| `components/settings-view.tsx` | 196 | SettingsView 统一设置页布局P2-a 新增5 标签页 + props 注入角色差异 + Tab URL 持久化 + 登出二次确认 + AI 标签页 |
| `components/admin-settings-view.tsx` | 195 | AdminSettingsView 系统设置视图4 个 Card学校信息/安全策略/文件上传/通知配置,模拟保存 |
| `components/parent-settings-view.tsx` | 70 | ParentSettingsView 家长设置视图(新增,复用 SettingsView 布局 |
| `components/*` | 9 文件 | 通用设置 + AI 配置 + 密码 + 主题 + 通知偏好 + 4 角色设置视图 |
| `actions.ts` | 160 | AI Provider CRUD + 测试P1 已修复,无直接 DB 操作) |
| `actions-password.ts` | 87 | 修改密码P1 已修复requirePermission + Zod + data-access |
| `data-access.ts` | 158 | AI Provider CRUD + 密码修改 DB 操作P1 新增) |
| `types.ts` | 60 | 类型定义AiProviderSummary + SettingsService/ProfileService/NotificationPreferenceService 接口 |
| `config/role-settings-config.tsx` | 85 | 角色设置页配置驱动映射ROLE_SETTINGS_CONFIG + resolveRoleSettingsConfig |
| `lib/student-overview-data.ts` | 150 | 学生概览纯数据计算buildStudentOverviewData + computeStudentStats 等,便于单测 |
| `components/settings-view.tsx` | 236 | SettingsView 统一设置页布局5 标签页 + Error Boundary + Suspense + i18n |
| `components/settings-service-context.tsx` | 39 | SettingsServiceProvider + useSettingsServiceContext 注入服务接口) |
| `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 个 Cardi18n |
| `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"
- ✅ 编辑器架构升级NodeEditorReact Flow 画布)+ NodeEditPanel侧边内容编辑面板+ LessonNode自定义节点组件支持节点拖拽、连线、画布缩放
- ⚠️ `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/fromRfEdgesdata-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 |
| `constants.ts` | 常量定义 |
| `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/fromRfEdgesLessonPlanNode/Edge ↔ React Flow Node/Edge 映射) |
| `config/block-registry.tsx` | **配置驱动**BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlocknode-edit-panel 通过配置渲染 Block |
| `data-access.ts` | 课案 CRUD + 模板查询migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容) |
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
| `data-access-templates.ts` | 个人模板 CRUD |
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
@@ -1502,28 +1531,30 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `actions-publish.ts` | 发布作业 Server Action |
| `actions-ai.ts` | AI 知识点建议 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 知识点建议服务 |
| `seed-templates.ts` | 模板种子数据 |
| `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-card.tsx` | 课案卡片 |
| `components/lesson-plan-filters.tsx` | 课案筛选器 |
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel |
| `components/node-editor.tsx` | **节点图画布**React Flow自定义 LessonNode支持拖拽/连线/缩放 |
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**选中节点后编辑标题/数据 |
| `components/nodes/lesson-node.tsx` | **自定义节点组件**按 BlockType 显示图标/颜色,含 Handle 连接点 |
| `components/lesson-plan-list.tsx` | 课案列表i18n 已接入) |
| `components/lesson-plan-card.tsx` | 课案卡片i18n 已接入) |
| `components/lesson-plan-filters.tsx` | 课案筛选器i18n 已接入) |
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPaneli18n 已接入 |
| `components/node-editor.tsx` | **节点图画布**React Flow使用 lib/rf-mappers + lib/node-summary 纯函数i18n 已接入 |
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**配置驱动渲染 Block通过 getBlockComponent + LessonPlanErrorBoundary 包裹i18n 已接入 |
| `components/nodes/lesson-node.tsx` | **自定义节点组件**使用 lib/node-summary 的 getNodeSummary/getNodeColori18n 已接入 |
| `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/template-picker.tsx` | 模板选择器 |
| `components/version-history-drawer.tsx` | 版本历史抽屉 |
| `components/knowledge-point-picker.tsx` | 知识点选择器 |
| `components/question-bank-picker.tsx` | 题库选择器 |
| `components/inline-question-editor.tsx` | 内联题目编辑器 |
| `components/publish-homework-dialog.tsx` | 发布作业对话框 |
| `components/blocks/rich-text-block.tsx` | 富文本 Block被 NodeEditPanel 复用) |
| `components/blocks/text-study-block.tsx` | 课文研读 Block被 NodeEditPanel 复用) |
| `components/blocks/exercise-block.tsx` | 练习 Block被 NodeEditPanel 复用) |
| `components/blocks/reflection-block.tsx` | 反思 Block被 NodeEditPanel 复用) |
| `components/template-picker.tsx` | 模板选择器i18n 已接入) |
| `components/version-history-drawer.tsx` | 版本历史抽屉i18n 已接入) |
| `components/knowledge-point-picker.tsx` | 知识点选择器i18n 已接入) |
| `components/question-bank-picker.tsx` | 题库选择器i18n 已接入) |
| `components/inline-question-editor.tsx` | 内联题目编辑器i18n 已接入) |
| `components/publish-homework-dialog.tsx` | 发布作业对话框i18n 已接入) |
| `components/blocks/rich-text-block.tsx` | 富文本 Block被 NodeEditPanel 复用i18n 已接入 |
| `components/blocks/text-study-block.tsx` | 课文研读 Block被 NodeEditPanel 复用i18n 已接入 |
| `components/blocks/exercise-block.tsx` | 练习 Block被 NodeEditPanel 复用,使用 router.refresh 替代 window.location.reloadi18n 已接入 |
| `components/blocks/reflection-block.tsx` | 反思 Block被 NodeEditPanel 复用i18n 已接入 |
---

View File

@@ -2735,6 +2735,14 @@
"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",
"signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise<void>",
@@ -6258,56 +6266,125 @@
"usedBy": [
"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": [
{
"name": "AiProviderSettingsCard",
"purpose": "AI Provider设置卡片"
"purpose": "AI Provider设置卡片i18nsettings.ai.providers"
},
{
"name": "AdminSettingsView",
"purpose": "系统设置视图(学校信息/安全策略/文件上传/通知配置 4 个 Card模拟保存;消费方:/admin/settings 页面)",
"purpose": "系统设置视图(学校信息/安全策略/文件上传/通知配置 4 个 Cardi18n;消费方:/admin/settings 页面)",
"usedBy": [
"app/(dashboard)/admin/settings/page.tsx"
]
},
{
"name": "ProfileSettingsForm",
"purpose": "个人资料设置表单"
"purpose": "个人资料设置表单(通过 useSettingsService().profile.updateProfile 调用i18nsettings.profile",
"deps": [
"useSettingsService",
"shared/components/form-fields/text-field",
"shared/components/form-fields/select-field"
]
},
{
"name": "ThemePreferencesCard",
"purpose": "主题偏好卡片"
"purpose": "主题偏好卡片i18nsettings.appearance"
},
{
"name": "StudentSettingsView",
"purpose": "学生设置视图(含 Notifications tab"
},
{
"name": "TeacherSettingsView",
"purpose": "教师设置视图(含 Notifications tab"
},
{
"name": "ParentSettingsView",
"purpose": "家长设置视图(复用 SettingsView 布局backHref 指向 /parent/dashboard含家长专属快捷链接消费方/settings 页面 parent 角色分支)",
"name": "SettingsView",
"purpose": "统一设置页布局5 标签页General/Notifications/Appearance/Security/AI角色差异通过 resolveRoleSettingsConfig 配置驱动 + generalExtra props 注入Tab URL 持久化;每个 TabsContent 包裹 SettingsSectionErrorBoundary + Suspense 骨架屏AI 标签页条件渲染需 AI_CONFIGURE 权限;登出 AlertDialog 二次确认i18nsettings 命名空间)",
"usedBy": [
"app/(dashboard)/settings/page.tsx"
]
},
{
"name": "SettingsView",
"purpose": "统一设置页布局5 标签页General/Notifications/Appearance/Security/AI角色差异通过 description/backHref/generalExtra 三个 props 注入Tab 通过 URL ?tab= 参数持久化AI 标签页条件渲染需 AI_CONFIGURE 权限;登出按钮使用 AlertDialog 二次确认4 个消费方admin/teacher/student/parent 设置页)",
"name": "SettingsServiceProvider",
"file": "components/settings-service-context.tsx",
"purpose": "SettingsService React Context Provider页面层注入服务实现组件层通过 useSettingsService() 消费",
"usedBy": [
"AdminSettingsView",
"TeacherSettingsView",
"StudentSettingsView",
"ParentSettingsView"
"app/(dashboard)/settings/page.tsx"
]
},
{
"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",
"purpose": "密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示)",
"purpose": "密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示i18nsettings.securitya11yaria-label",
"deps": [
"changePasswordAction",
"getPasswordStrength",
@@ -6317,17 +6394,14 @@
{
"name": "NotificationPreferencesForm",
"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 调用i18nsettings.notifications",
"deps": [
"updateNotificationPreferencesAction",
"useSettingsService",
"shared/components/ui/switch",
"shared/components/ui/card",
"react.useActionState"
"shared/components/ui/card"
],
"usedBy": [
"TeacherSettingsView",
"StudentSettingsView",
"ParentSettingsView"
"SettingsView"
]
}
]
@@ -12083,17 +12157,17 @@
},
{
"name": "buildInitialContent",
"file": "data-access.ts",
"file": "lib/document-migration.tsdata-access.ts re-export",
"purpose": "基于模板构建初始课案内容v2 nodes+edges"
},
{
"name": "migrateV1ToV2",
"file": "data-access.ts",
"purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges节点按网格布局"
"file": "lib/document-migration.tsdata-access.ts re-export",
"purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges节点按网格布局,使用类型守卫 isV1Document/isV2Document 替代 as 断言"
},
{
"name": "normalizeDocument",
"file": "data-access.ts",
"file": "lib/document-migration.tsdata-access.ts re-export",
"purpose": "规范化:确保 content 为 v2 格式,兼容旧 v1 数据(自动调用 migrateV1ToV2"
},
{
@@ -12149,7 +12223,7 @@
{
"name": "publishLessonPlanHomework",
"file": "publish-service.ts",
"purpose": "发布课案为作业(编排 homework/exams/classes"
"purpose": "发布课案为作业(编排 homework/exams/classes,通过对方 data-access 调用 addExamQuestions/getStudentIdsByClassIds无直查跨模块表"
},
{
"name": "suggestKnowledgePoints",
@@ -12264,6 +12338,10 @@
"types.ts",
"constants.ts",
"schema.ts",
"lib/document-migration.ts",
"lib/node-summary.ts",
"lib/rf-mappers.ts",
"config/block-registry.tsx",
"data-access.ts",
"data-access-versions.ts",
"data-access-templates.ts",
@@ -12283,6 +12361,8 @@
"components/node-editor.tsx",
"components/node-edit-panel.tsx",
"components/nodes/lesson-node.tsx",
"components/lesson-plan-error-boundary.tsx",
"components/lesson-plan-skeleton.tsx",
"components/block-renderer.tsx",
"components/template-picker.tsx",
"components/version-history-drawer.tsx",
@@ -13027,7 +13107,11 @@
"dependsOn": [
"shared",
"auth",
"messaging"
"classes",
"homework",
"dashboard",
"users",
"notifications"
],
"uses": {
"shared": [
@@ -13035,17 +13119,41 @@
"auth-guard",
"ai",
"types",
"components.ui.switch"
"components.ui.switch",
"components.ui.card",
"components.ui.tabs",
"components.ui.alert-dialog",
"components.form-fields"
],
"auth": [
"auth"
],
"messaging": [
"notification-preferences.getNotificationPreferences",
"actions.getNotificationPreferencesAction",
"actions.updateNotificationPreferencesAction"
"classes": [
"data-access.getStudentClasses",
"data-access.getStudentSchedule",
"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": {
"dependsOn": [

View 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
└─▶ AdminSettingsViewmock无数据流
[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 和 SuspenseP1
| 位置 | 问题 | 违反规则 |
|------|------|----------|
| [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 linksTeacherSettingsView |
| **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` 预留埋点接口

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ProfileError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,个人资料页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,138 +1,58 @@
import Link from "next/link"
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 { 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 { 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 { 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 { PageHeader } from "@/shared/components/ui/page-header"
import { Separator } from "@/shared/components/ui/separator"
import { formatDate } from "@/shared/lib/utils"
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Profile",
export async function generateMetadata() {
const t = await getTranslations("settings.profilePage")
return { title: t("title") }
}
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
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() {
export default async function ProfilePage(): Promise<ReactElement> {
const ctx = await requireAuth()
const userId = ctx.userId
const userProfile = await getUserProfile(userId)
if (!userProfile) {
redirect("/login")
redirect("/login")
}
const roles = ctx.roles
const isStudent = roles.includes("student")
const isTeacher = roles.includes("teacher")
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
const t = await getTranslations("settings.profilePage")
return (
<div className="flex h-full flex-col gap-8 p-8">
<PageHeader
title="Profile"
description="Manage your personal and account information."
title={t("title")}
description={t("description")}
actions={
<Button asChild variant="outline">
<Link href="/settings">Edit Profile</Link>
<Link href="/settings">{t("editProfile")}</Link>
</Button>
}
/>
<div className="flex items-center gap-4">
<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">
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
</AvatarFallback>
@@ -148,36 +68,36 @@ export default async function ProfilePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
{t("personalInfo.title")}
</CardTitle>
<CardDescription>Basic personal details.</CardDescription>
<CardDescription>{t("personalInfo.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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>
<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>
<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>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Phone</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.phone")}</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
</div>
</div>
<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">
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
</div>
</div>
</div>
@@ -188,37 +108,37 @@ export default async function ProfilePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Account Information
{t("accountInfo.title")}
</CardTitle>
<CardDescription>System account details.</CardDescription>
<CardDescription>{t("accountInfo.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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="text-sm font-medium text-muted-foreground">Email</div>
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.email")}</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
</div>
</div>
<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">
{userProfile.role}
</Badge>
</div>
<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">
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.onboardedAt")}</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
</div>
</div>
</div>
@@ -226,91 +146,20 @@ export default async function ProfilePage() {
</Card>
</div>
{studentData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
</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>
{isStudent ? (
<SettingsSectionErrorBoundary>
<Suspense fallback={<ProfileStudentOverviewSkeleton />}>
<ProfileStudentOverview userId={userId} />
</Suspense>
</SettingsSectionErrorBoundary>
) : null}
{teacherData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
</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>
{isTeacher ? (
<SettingsSectionErrorBoundary>
<Suspense fallback={<ProfileTeacherOverviewSkeleton />}>
<ProfileTeacherOverview />
</Suspense>
</SettingsSectionErrorBoundary>
) : null}
</div>
)

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function SettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,设置页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,12 +1,16 @@
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { requireAuth } from "@/shared/lib/auth-guard"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { ParentSettingsView } from "@/modules/settings/components/parent-settings-view"
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
import type { SettingsService } from "@/modules/settings/types"
import { getUserProfile } from "@/modules/users/data-access"
import { updateUserProfile } from "@/modules/users/actions"
import { getNotificationPreferences } from "@/modules/notifications/preferences"
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types"
export const dynamic = "force-dynamic"
@@ -14,6 +18,32 @@ export const metadata = {
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() {
const ctx = await requireAuth()
@@ -24,22 +54,36 @@ export default async function SettingsPage() {
const roles = ctx.roles
const notificationPrefs = await getNotificationPreferences(userId)
const t = await getTranslations("settings")
if (roles.includes("admin")) {
return (
const config = resolveRoleSettingsConfig(roles)
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
description="Manage your admin preferences and account access."
backHref="/admin/dashboard"
description={description}
backHref={backHref}
user={userProfile}
notificationPreferences={notificationPrefs}
generalExtra={generalExtra}
/>
)
}
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} />
</SettingsServiceProvider>
)
}

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function SecuritySettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,安全设置页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,4 +1,5 @@
import { Lock } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requireAuth } from "@/shared/lib/auth-guard"
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
@@ -13,12 +14,13 @@ export const metadata = {
export default async function SecuritySettingsPage() {
await requireAuth()
const t = await getTranslations("settings")
return (
<div className="flex h-full flex-col gap-8 p-8">
<PageHeader
title="Security"
description="Manage your password and account security settings."
title={t("tabs.security")}
description={t("security.changePassword.description")}
icon={Lock}
/>
@@ -27,15 +29,15 @@ export default async function SecuritySettingsPage() {
<Card>
<CardHeader>
<CardTitle>Security Tips</CardTitle>
<CardDescription>Best practices to keep your account safe.</CardDescription>
<CardTitle>{t("security.tips.title")}</CardTitle>
<CardDescription>{t("security.tips.description")}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>Use a unique password that you don&apos;t reuse across other sites.</li>
<li>Avoid common words, names, or sequential patterns.</li>
<li>Change your password periodically.</li>
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
<li>{t("security.tips.tip1")}</li>
<li>{t("security.tips.tip2")}</li>
<li>{t("security.tips.tip3")}</li>
<li>{t("security.tips.tip4")}</li>
</ul>
</CardContent>
</Card>

View File

@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
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 { Separator } from "@/shared/components/ui/separator"
/**
* 管理员系统设置视图
*
* TODO: 当前为 mock 实现setTimeout 模拟保存),未接入真实数据层。
* 后续需新增 system_settings 表 + data-access + actions替换 mock 逻辑。
* 当前已适配 i18n文本均通过 settings.admin.* 翻译键获取。
*/
export function AdminSettingsView() {
const t = useTranslations("settings.admin")
const [saving, setSaving] = React.useState(false)
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
// 模拟保存
await new Promise((r) => setTimeout(r, 800))
toast.success("设置已保存")
// TODO: 替换为真实 Server Action 调用
await new Promise<void>((resolve) => setTimeout(resolve, 800))
toast.success(t("saveSuccess"))
setSaving(false)
}
return (
<div className="flex h-full flex-col space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<form onSubmit={handleSave} className="space-y-6">
@@ -38,39 +47,39 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<School className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("schoolInfo.title")}</CardTitle>
<CardDescription>{t("schoolInfo.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-name"></Label>
<Input id="school-name" placeholder="请输入学校名称" defaultValue="Next_Edu 实验学校" />
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
<Input id="school-name" name="schoolName" placeholder={t("schoolInfo.namePlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-code"></Label>
<Input id="school-code" placeholder="请输入学校代码" />
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
<Input id="school-code" name="schoolCode" placeholder={t("schoolInfo.codePlaceholder")} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-phone"></Label>
<Input id="school-phone" placeholder="请输入联系电话" />
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
<Input id="school-phone" name="schoolPhone" placeholder={t("schoolInfo.phonePlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-email"></Label>
<Input id="school-email" type="email" placeholder="请输入联系邮箱" />
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
<Input id="school-email" name="schoolEmail" type="email" placeholder={t("schoolInfo.emailPlaceholder")} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address"></Label>
<Input id="school-address" placeholder="请输入学校地址" />
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
<Input id="school-address" name="schoolAddress" placeholder={t("schoolInfo.addressPlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-desc"></Label>
<Textarea id="school-desc" placeholder="请输入学校简介" rows={3} />
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
<Textarea id="school-desc" name="schoolDescription" placeholder={t("schoolInfo.descriptionPlaceholder")} rows={3} />
</div>
</CardContent>
</Card>
@@ -81,43 +90,43 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("securityPolicy.title")}</CardTitle>
<CardDescription>{t("securityPolicy.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password-min-length"></Label>
<Input id="password-min-length" type="number" min={6} max={32} defaultValue={8} />
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
<Input id="password-min-length" name="passwordMinLength" type="number" min={6} max={32} defaultValue={8} />
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout"></Label>
<Input id="session-timeout" type="number" min={5} max={1440} defaultValue={60} />
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
<Input id="session-timeout" name="sessionTimeout" type="number" min={5} max={1440} defaultValue={60} />
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-special-char"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
</div>
<Switch id="require-special-char" defaultChecked />
<Switch id="require-special-char" name="requireSpecialChar" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
</div>
<Switch id="require-uppercase" />
<Switch id="require-uppercase" name="requireUppercase" />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
</div>
<Switch id="force-password-change" defaultChecked />
<Switch id="force-password-change" name="forcePasswordChange" defaultChecked />
</div>
</CardContent>
</Card>
@@ -128,20 +137,20 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("fileUpload.title")}</CardTitle>
<CardDescription>{t("fileUpload.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="max-file-size">MB</Label>
<Input id="max-file-size" type="number" min={1} max={100} defaultValue={10} />
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
<Input id="max-file-size" name="maxFileSize" type="number" min={1} max={100} defaultValue={10} />
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types"></Label>
<Input id="allowed-types" placeholder="如jpg,png,pdf,docx" defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
<Input id="allowed-types" name="allowedTypes" placeholder={t("fileUpload.allowedTypesPlaceholder")} defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
</div>
</div>
</CardContent>
@@ -153,40 +162,40 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("notificationConfig.title")}</CardTitle>
<CardDescription>{t("notificationConfig.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-user"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
</div>
<Switch id="notify-new-user" defaultChecked />
<Switch id="notify-new-user" name="notifyNewUser" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
</div>
<Switch id="notify-schedule-change" defaultChecked />
<Switch id="notify-schedule-change" name="notifyScheduleChange" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
</div>
<Switch id="notify-announcement" />
<Switch id="notify-announcement" name="notifyAnnouncement" />
</div>
</CardContent>
</Card>
<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}>
{saving ? "保存中..." : "保存设置"}
{saving ? t("saving") : t("save")}
</Button>
</div>
</form>

View File

@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { useTranslations } from "next-intl"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
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 { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { TextField } from "@/shared/components/form-fields/text-field"
import { SelectField } from "@/shared/components/form-fields/select-field"
import {
Select,
SelectContent,
@@ -42,13 +42,6 @@ const AiProviderFormSchema = z.object({
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__"
export function AiProviderSettingsCard({
@@ -58,6 +51,7 @@ export function AiProviderSettingsCard({
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
}) {
const t = useTranslations("settings.ai.providers")
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
const [selectedId, setSelectedId] = useState<string>("")
@@ -112,7 +106,7 @@ export function AiProviderSettingsCard({
try {
const result = await getAiProviderSummaries()
if (!result.success || !result.data) {
toast.error(result.message ?? "Failed to load AI providers")
toast.error(result.message ?? t("loadFailure"))
return
}
const rows = result.data
@@ -135,10 +129,10 @@ export function AiProviderSettingsCard({
})
}
} 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) => {
if (value === NEW_PROVIDER_VALUE) {
@@ -175,7 +169,7 @@ export function AiProviderSettingsCard({
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
toast.error("Please enter API key to test")
toast.error(t("needKey"))
return
}
setTestStatus("testing")
@@ -192,10 +186,10 @@ export function AiProviderSettingsCard({
if (result.success) {
setTestStatus("passed")
setLastTestedSignature(buildSignature(values))
toast.success(result.message ?? "Test passed")
toast.success(result.message ?? t("testSuccess"))
} else {
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 signature = buildSignature(values)
if (testStatus !== "passed" || signature !== lastTestedSignature) {
toast.error("Please test the configuration before saving")
toast.error(t("needTest"))
return
}
startTransition(async () => {
@@ -217,12 +211,12 @@ export function AiProviderSettingsCard({
}
const result = await upsertAiProviderAction(payload)
if (result.success) {
toast.success(result.message ?? "Saved")
toast.success(result.message ?? t("saveSuccess"))
setTestStatus("idle")
setLastTestedSignature("")
const summariesResult = await getAiProviderSummaries()
if (!summariesResult.success || !summariesResult.data) {
toast.error(summariesResult.message ?? "Failed to load AI providers")
toast.error(summariesResult.message ?? t("loadFailure"))
return
}
const rows = summariesResult.data
@@ -242,7 +236,7 @@ export function AiProviderSettingsCard({
})
}
} else {
toast.error(result.message ?? "Failed to save")
toast.error(result.message ?? t("saveFailure"))
}
})
}
@@ -252,34 +246,34 @@ export function AiProviderSettingsCard({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Providers
{t("title")}
</CardTitle>
<CardDescription>Manage AI vendors and default model configuration.</CardDescription>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel>Existing Providers</FormLabel>
<FormLabel>{t("existing")}</FormLabel>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder="Create new or select existing" />
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>Create new</SelectItem>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{providerLabels[item.provider]} · {item.model}
{item.provider} · {item.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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">
{selectedProvider?.apiKeyLast4
? `Stored • ****${selectedProvider.apiKeyLast4}`
: "No key stored"}
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</div>
</div>
</div>
@@ -287,82 +281,46 @@ export function AiProviderSettingsCard({
<Form {...form}>
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
<TextField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} disabled />
</FormControl>
<FormDescription>Auto-generated for each provider.</FormDescription>
</FormItem>
)}
label={t("id")}
disabled
description={t("idDesc")}
/>
<FormField
<SelectField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</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>
)}
label={t("provider")}
placeholder={t("providerPlaceholder")}
options={[
{ value: "zhipu", label: "Zhipu" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "custom", label: "Custom" },
]}
/>
<FormField
<TextField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://open.bigmodel.cn/api/paas/v4" />
</FormControl>
<FormDescription>Enter base URL without /chat/completions suffix.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("baseUrl")}
placeholder={t("baseUrlPlaceholder")}
description={t("baseUrlDesc")}
/>
<FormField
<TextField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<FormControl>
<Input {...field} placeholder="gpt-4o-mini" />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("model")}
placeholder={t("modelPlaceholder")}
/>
<FormField
<TextField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Paste new key to replace" />
</FormControl>
<FormDescription>Existing key won&apos;t be displayed. Leave blank to keep current.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("apiKey")}
type="password"
placeholder={t("apiKeyPlaceholder")}
description={t("apiKeyDesc")}
itemClassName="sm:col-span-2"
/>
</div>
@@ -374,7 +332,7 @@ export function AiProviderSettingsCard({
<FormControl>
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
</FormControl>
<FormLabel>Set as default</FormLabel>
<FormLabel>{t("setDefault")}</FormLabel>
</FormItem>
)}
/>
@@ -384,12 +342,12 @@ export function AiProviderSettingsCard({
{testStatus === "testing" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
{t("testing")}
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Test
{t("test")}
</>
)}
</Button>
@@ -397,12 +355,12 @@ export function AiProviderSettingsCard({
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t("saving")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
{t("save")}
</>
)}
</Button>

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { useTransition } from "react"
import { useTranslations } from "next-intl"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
import { toast } from "sonner"
@@ -13,7 +13,7 @@ import { Switch } from "@/shared/components/ui/switch"
import { Label } from "@/shared/components/ui/label"
import { Separator } from "@/shared/components/ui/separator"
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"
interface NotificationPreferencesFormProps {
@@ -25,8 +25,8 @@ interface ChannelItem {
NotificationPreferences,
"emailEnabled" | "smsEnabled" | "pushEnabled"
>
label: string
description: string
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
@@ -39,88 +39,30 @@ interface CategoryItem {
| "messageNotifications"
| "attendanceNotifications"
>
label: string
description: string
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
const CHANNELS: ChannelItem[] = [
{
key: "pushEnabled",
label: "Push Notifications",
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,
},
{ key: "pushEnabled", labelKey: "channels.push", descKey: "channels.pushDesc", icon: Bell },
{ key: "emailEnabled", labelKey: "channels.email", descKey: "channels.emailDesc", icon: Mail },
{ key: "smsEnabled", labelKey: "channels.sms", descKey: "channels.smsDesc", icon: MessageSquare },
]
const CATEGORIES: CategoryItem[] = [
{
key: "messageNotifications",
label: "Messages",
description: "New direct messages and replies.",
icon: MessageSquare,
},
{
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,
},
{ key: "messageNotifications", labelKey: "categories.messages", descKey: "categories.messagesDesc", icon: MessageSquare },
{ key: "announcementNotifications", labelKey: "categories.announcements", descKey: "categories.announcementsDesc", icon: Megaphone },
{ key: "homeworkNotifications", labelKey: "categories.homework", descKey: "categories.homeworkDesc", icon: BookOpen },
{ key: "gradeNotifications", labelKey: "categories.grades", descKey: "categories.gradesDesc", icon: GraduationCap },
{ key: "attendanceNotifications", labelKey: "categories.attendance", descKey: "categories.attendanceDesc", 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) {
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({
emailEnabled: preferences.emailEnabled,
smsEnabled: preferences.smsEnabled,
@@ -139,14 +81,6 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
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) => {
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
}
@@ -159,187 +93,175 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
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 (
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
Choose how and when you want to be notified.
</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<form action={formAction}>
<CardContent className="space-y-6">
{/* Delivery channels */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Delivery Channels</h4>
<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>
)
})}
<CardContent className="space-y-6">
{/* Delivery channels */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">{t("channels.title")}</h4>
<p className="text-xs text-muted-foreground">{t("channels.subtitle")}</p>
</div>
<Separator />
{/* Notification categories */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Notification Categories</h4>
<p className="text-xs 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>
{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="flex items-center gap-2">
<input
type="checkbox"
name={item.key}
checked={checked}
onChange={() => toggleCategory(item.key)}
className="sr-only"
tabIndex={-1}
/>
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleCategory(item.key)}
aria-label={item.label}
/>
<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>
)
})}
</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
id="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onCheckedChange={toggleQuietHours}
aria-label="Enable Quiet Hours"
id={item.key}
checked={checked}
onCheckedChange={() => toggleChannel(item.key)}
aria-label={t(item.labelKey)}
/>
</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 className={cn(
"grid gap-4 sm:grid-cols-2 transition-opacity",
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
)}>
<div className="space-y-2">
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
Start Time
</Label>
<Input
id="quietHoursStart"
name="quietHoursStart"
type="time"
value={quietHours.quietHoursStart}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
End Time
</Label>
<Input
id="quietHoursEnd"
name="quietHoursEnd"
type="time"
value={quietHours.quietHoursEnd}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
<Switch
id="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onCheckedChange={toggleQuietHours}
aria-label={t("quietHours.enable")}
/>
</div>
<div className={cn(
"grid gap-4 sm:grid-cols-2 transition-opacity",
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
)}>
<div className="space-y-2">
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
{t("quietHours.start")}
</Label>
<Input
id="quietHoursStart"
type="time"
value={quietHours.quietHoursStart}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
{t("quietHours.end")}
</Label>
<Input
id="quietHoursEnd"
type="time"
value={quietHours.quietHoursEnd}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<SubmitButton />
</CardFooter>
</form>
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<Button type="button" onClick={onSubmit} disabled={isPending}>
{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>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -2,6 +2,7 @@
import { useActionState, useEffect, useMemo, useRef, useState } from "react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"
import { toast } from "sonner"
@@ -18,25 +19,26 @@ import {
} from "@/shared/lib/password-policy"
import type { ActionState } from "@/shared/types/action-state"
const STRENGTH_META: Record<PasswordStrength, { value: number; label: string; barClassName: string; indicatorClassName: string }> = {
weak: { value: 33, label: "Weak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
medium: { value: 66, label: "Medium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
strong: { value: 100, label: "Strong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
const STRENGTH_META: Record<PasswordStrength, { value: number; labelKey: string; barClassName: string; indicatorClassName: string }> = {
weak: { value: 33, labelKey: "security.changePassword.strengthWeak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
medium: { value: 66, labelKey: "security.changePassword.strengthMedium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
strong: { value: 100, labelKey: "security.changePassword.strengthStrong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
}
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("settings.security.changePassword")
return (
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
{t("updating")}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
Update Password
{t("submit")}
</>
)}
</Button>
@@ -44,6 +46,7 @@ function SubmitButton() {
}
export function PasswordChangeForm() {
const t = useTranslations("settings.security.changePassword")
const [state, formAction] = useActionState<ActionState<null>, FormData>(
changePasswordAction,
{ success: false, data: null }
@@ -59,31 +62,29 @@ export function PasswordChangeForm() {
useEffect(() => {
if (state?.success) {
toast.success(state.message ?? "Password changed successfully")
toast.success(state.message ?? t("success"))
formRef.current?.reset()
} else if (state?.message) {
toast.error(state.message)
}
}, [state])
}, [state, t])
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Choose a strong password to keep your account secure.
</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<form ref={formRef} id="password-change-form" action={formAction} onReset={() => setNewPassword("")}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Label htmlFor="currentPassword">{t("current")}</Label>
<div className="relative">
<Input
id="currentPassword"
name="currentPassword"
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
placeholder={t("currentPlaceholder")}
required
autoComplete="current-password"
/>
@@ -93,7 +94,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
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" />}
</Button>
@@ -101,13 +102,13 @@ export function PasswordChangeForm() {
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Label htmlFor="newPassword">{t("new")}</Label>
<div className="relative">
<Input
id="newPassword"
name="newPassword"
type={showNew ? "text" : "password"}
placeholder="Enter new password"
placeholder={t("newPlaceholder")}
required
autoComplete="new-password"
value={newPassword}
@@ -119,7 +120,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
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" />}
</Button>
@@ -127,8 +128,8 @@ export function PasswordChangeForm() {
{newPassword.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Password strength</span>
<span className="font-medium">{meta.label}</span>
<span className="text-muted-foreground">{t("strength")}</span>
<span className="font-medium">{t(meta.labelKey)}</span>
</div>
<Progress value={meta.value} className={meta.barClassName} indicatorClassName={meta.indicatorClassName} />
</div>
@@ -136,13 +137,13 @@ export function PasswordChangeForm() {
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Label htmlFor="confirmPassword">{t("confirm")}</Label>
<div className="relative">
<Input
id="confirmPassword"
name="confirmPassword"
type={showConfirm ? "text" : "password"}
placeholder="Re-enter new password"
placeholder={t("confirmPlaceholder")}
required
autoComplete="new-password"
/>
@@ -152,7 +153,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
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" />}
</Button>
@@ -160,7 +161,7 @@ export function PasswordChangeForm() {
</div>
<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">
{PASSWORD_REQUIREMENT_HINTS.map((hint) => (
<li key={hint} className="flex items-center gap-1.5">

View File

@@ -1,40 +1,47 @@
"use client"
import { useTransition } from "react"
import { useTransition, type ReactElement } from "react"
import { useTranslations } from "next-intl"
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 { Loader2, Save } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
import { UserProfile } from "@/modules/users/data-access"
import { updateUserProfile } from "@/modules/users/actions"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { 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 type { UserProfile } from "@/modules/users/data-access"
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
const profileFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.string().email().optional(), // Read only
role: z.string().optional(), // Read only
email: z.string().email().optional(),
role: z.string().optional(),
phone: z.string().optional(),
address: z.string().optional(),
gender: z.string().optional(),
age: z.preprocess(
(v) => (v === "" || v === null || v === undefined ? undefined : Number(v)),
z.number().min(0).optional()
),
age: z.string().optional(),
})
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 form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema) as Resolver<ProfileFormValues>,
resolver: zodResolver(profileFormSchema),
defaultValues: {
name: user.name ?? "",
email: user.email ?? "",
@@ -42,27 +49,28 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
phone: user.phone ?? "",
address: user.address ?? "",
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 () => {
try {
const result = await updateUserProfile({
const ageNum = data.age ? Number(data.age) : undefined
const result = await profile.updateProfile({
name: data.name,
phone: data.phone || undefined,
address: data.address || undefined,
gender: data.gender || undefined,
age: data.age || undefined,
age: ageNum !== undefined && !Number.isNaN(ageNum) ? ageNum : undefined,
})
if (result.success) {
toast.success("Profile updated successfully")
toast.success(t("success"))
} else {
toast.error(result.message || "Failed to update profile")
toast.error(result.message || t("failure"))
}
} catch {
toast.error("Failed to update profile")
toast.error(t("failure"))
}
})
}
@@ -70,114 +78,59 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
return (
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information.</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
<TextField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.name")}
placeholder={t("fields.namePlaceholder")}
/>
<FormField
<TextField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} disabled />
</FormControl>
<FormDescription>Email cannot be changed.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("fields.email")}
disabled
description={t("fields.emailDisabled")}
/>
<FormField
<TextField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input placeholder="+1 234 567 890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.phone")}
placeholder={t("fields.phonePlaceholder")}
/>
<FormField
<SelectField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel>Gender</FormLabel>
<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>
)}
label={t("fields.gender")}
placeholder={t("fields.genderPlaceholder")}
options={GENDER_OPTIONS}
/>
<FormField
<TextField
control={form.control}
name="age"
render={({ field }) => (
<FormItem>
<FormLabel>Age</FormLabel>
<FormControl>
<Input type="number" placeholder="Age" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.age")}
type="number"
placeholder={t("fields.age")}
/>
<FormField
<TextField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Input {...field} disabled className="capitalize" />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.role")}
disabled
inputClassName="capitalize"
/>
<FormField
<TextField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Address</FormLabel>
<FormControl>
<Input placeholder="123 Main St, City, Country" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.address")}
placeholder={t("fields.addressPlaceholder")}
itemClassName="col-span-1 sm:col-span-2"
/>
</div>
</CardContent>
@@ -186,12 +139,12 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t("saving")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
{t("save")}
</>
)}
</Button>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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"
/>
)
}

View 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
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { User, Palette, Lock, Bell, Sparkles } from "lucide-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 { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
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 { 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 {
AlertDialog,
@@ -25,13 +28,13 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} 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 { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
interface SettingsViewProps {
/** 页面副标题描述 */
/** 页面副标题描述i18n 键) */
description: string
/** 返回仪表盘的链接 */
backHref: string
@@ -39,7 +42,7 @@ interface SettingsViewProps {
user: UserProfile
/** 通知偏好 */
notificationPreferences: NotificationPreferences
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接/组织信息等) */
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
generalExtra?: ReactNode
}
@@ -50,6 +53,21 @@ function isTabValue(value: string | null): value is TabValue {
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 注入。
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
* 每个标签页内容用 Error Boundary + Suspense 包裹,局部失败不影响整页。
*/
function SettingsViewInner({
description,
@@ -69,6 +88,7 @@ function SettingsViewInner({
notificationPreferences,
generalExtra,
}: SettingsViewProps) {
const t = useTranslations("settings")
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
@@ -93,12 +113,12 @@ function SettingsViewInner({
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={backHref}>Back to dashboard</Link>
<Link href={backHref}>{t("backToDashboard")}</Link>
</Button>
</div>
</div>
@@ -107,79 +127,99 @@ function SettingsViewInner({
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
{t("tabs.general")}
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
Notifications
{t("tabs.notifications")}
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
{t("tabs.appearance")}
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
{t("tabs.security")}
</TabsTrigger>
{canConfigureAi ? (
<TabsTrigger value="ai" className="gap-2">
<Sparkles className="h-4 w-4" />
AI
{t("tabs.ai")}
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<ProfileSettingsForm user={user} />
{generalExtra}
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ProfileSettingsForm user={user} />
{generalExtra}
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="notifications" className="mt-6 space-y-6">
<NotificationPreferencesForm preferences={notificationPreferences} />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<NotificationPreferencesForm preferences={notificationPreferences} />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ThemePreferencesCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Log out</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to sign out? You will be returned to the login screen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>{t("security.session.title")}</CardTitle>
<CardDescription>{t("security.session.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{t("security.session.signOut")}</div>
<div className="text-sm text-muted-foreground">{t("security.session.signOutDesc")}</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">{t("security.session.signOut")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("security.session.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("security.session.confirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("security.session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
{t("security.session.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
{canConfigureAi ? (
<TabsContent value="ai" className="mt-6 space-y-6">
<AiProviderSettingsCard />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<AiProviderSettingsCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
) : null}
</Tabs>

View File

@@ -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}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
@@ -15,6 +16,7 @@ import {
type ThemeChoice = "system" | "light" | "dark"
export function ThemePreferencesCard() {
const t = useTranslations("settings.appearance.theme")
const { theme, setTheme } = useTheme()
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
@@ -22,33 +24,33 @@ export function ThemePreferencesCard() {
return (
<Card>
<CardHeader>
<CardTitle>Theme</CardTitle>
<CardDescription>Choose how the admin console looks on this device.</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:max-w-md">
<div className="space-y-2">
<Label htmlFor="theme">Color theme</Label>
<Label htmlFor="theme">{t("label")}</Label>
<Select value={value} onValueChange={(v) => setTheme(v)}>
<SelectTrigger id="theme" suppressHydrationWarning>
<SelectValue placeholder="Select theme" />
<SelectValue placeholder={t("title")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
System
{t("system")}
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
Light
{t("light")}
</div>
</SelectItem>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-muted-foreground" />
Dark
{t("dark")}
</div>
</SelectItem>
</SelectContent>

View 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
}

View 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,
}
}

View File

@@ -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 interface AiProviderSummary {
@@ -16,3 +23,38 @@ export interface AiProviderExisting {
apiKeyLast4: string | null
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
}

View 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"
}
}
}

View 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": "查看"
}
}
}