feat(admin-portal): 完整实现 admin-portal 管理端微前端
包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
21
apps/admin-portal/.env.example
Normal file
21
apps/admin-portal/.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# admin-portal 环境变量
|
||||||
|
# GraphQL endpoint(经 api-gateway 代理到 teacher-bff admin 命名空间)
|
||||||
|
API_GATEWAY_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# MSW mock 开关(上游就绪后设为 disabled)
|
||||||
|
NEXT_PUBLIC_API_MOCKING=enabled
|
||||||
|
|
||||||
|
# MF 开关(Shell 就绪后设为 true)
|
||||||
|
NEXT_PUBLIC_MF_ENABLED=false
|
||||||
|
|
||||||
|
# Shell URL(standalone 壳用于 /api/ready 检查)
|
||||||
|
NEXT_PUBLIC_SHELL_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# push-gateway WebSocket(实时通知)
|
||||||
|
NEXT_PUBLIC_WS_URL=ws://localhost:8081/ws
|
||||||
|
|
||||||
|
# OTel collector
|
||||||
|
NEXT_PUBLIC_OTEL_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
|
# Web Vitals 上报端点
|
||||||
|
NEXT_PUBLIC_WEB_VITALS_ENDPOINT=/api/admin/web-vitals
|
||||||
36
apps/admin-portal/Dockerfile
Normal file
36
apps/admin-portal/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# admin-portal Dockerfile
|
||||||
|
# 仲裁:no-push 本地构建,使用官方镜像
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# 复制 workspace 配置
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
|
COPY packages/ ./packages/
|
||||||
|
COPY apps/admin-portal/ ./apps/admin-portal/
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
WORKDIR /app/apps/admin-portal
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# 运行时
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=4003
|
||||||
|
|
||||||
|
COPY --from=builder /app/apps/admin-portal/.next/standalone ./
|
||||||
|
COPY --from=builder /app/apps/admin-portal/.next/static ./apps/admin-portal/.next/static
|
||||||
|
COPY --from=builder /app/apps/admin-portal/public ./apps/admin-portal/public
|
||||||
|
|
||||||
|
EXPOSE 4003
|
||||||
|
|
||||||
|
CMD ["node", "apps/admin-portal/server.js"]
|
||||||
@@ -1,42 +1,66 @@
|
|||||||
# 模块理解确认书 — admin-portal
|
# 模块理解确认书 — admin-portal
|
||||||
|
|
||||||
> AI:ai07(TS/React · 管理场景域前端 remote)
|
> AI:ai16(TS/React · 管理场景域前端 remote)
|
||||||
> 阶段:阶段 1 交付物
|
> 阶段:阶段 1 交付物(仲裁后修订版 v2)
|
||||||
> 日期:2026-07-09
|
> 日期:2026-07-10
|
||||||
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §1.1a/1.1b/§5.4、[AI 分配方案](../../../docs/architecture/ai-allocation.md) §5 ai07、[pending-features P6](../../../docs/architecture/roadmap/pending-features.md)、[known-issues §2.12](../../../docs/troubleshooting/known-issues.md)、[teacher-portal 阶段1](../../teacher-portal/docs/01-understanding.md)、[teacher-portal 阶段2](../../teacher-portal/docs/02-architecture-design.md)
|
> 关联:
|
||||||
|
>
|
||||||
|
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §1.1a/1.1b/§5.4
|
||||||
|
> - [AI 分配方案](../../../docs/architecture/ai-allocation.md) §5 ai16
|
||||||
|
> - [总统最终裁决](../../../docs/architecture/president-final-rulings.md) §5.1-5.5(ISSUE-044~048)
|
||||||
|
> - [admin-portal 对接契约](../../architecture/issues/contracts/admin-portal_contract.md)
|
||||||
|
> - [admin-portal 工作线](../../architecture/issues/worklines/admin-portal_workline.md)
|
||||||
|
> - [known-issues §2.16](../../../docs/troubleshooting/known-issues.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 仲裁结果摘要(ARB-001~006)
|
||||||
|
|
||||||
|
| 编号 | 议题 | 裁决 |
|
||||||
|
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ARB-001 | BFF 归属(ISSUE-044) | 复用 teacher-bff GraphQL endpoint,不新建 admin-bff;ai03 预留 `admin.*` 命名空间 |
|
||||||
|
| ARB-002 | 错误码前缀(ISSUE-045) | `ADMIN_` 前缀(合并 `SCHOOL_`/`ORG_` 入 `ADMIN_`);i18n key `error.admin.*` |
|
||||||
|
| ARB-003 | 权限点命名(ISSUE-046) | admin 自身资源用 `ADMIN_*`;跨服务资源保留原前缀(`IAM_USER_READ` 等) |
|
||||||
|
| ARB-004 | GraphQL 客户端(ISSUE-047) | MF 模式复用 Shell 暴露的 GraphQL client 单例;standalone 模式自建 urql client |
|
||||||
|
| ARB-005 | 审计日志归属(ISSUE-048) | 审计事件归 iam 发布到 `edu.iam.audit.created`;teacher-bff 消费聚合;admin-portal 经 GraphQL `auditLogs` Query 消费 |
|
||||||
|
| ARB-006 | WebSocket 实时通知 | 接入 push-gateway `GET /ws`(审计告警/异常登录/系统异常),不再用轮询 |
|
||||||
|
| 端口分配 | admin-portal dev server | **4003**(非旧文档的 3003) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 我在架构中的位置
|
## 1. 我在架构中的位置
|
||||||
|
|
||||||
- **层级**:L2 微前端层(004 §3.1 六层架构中的前端层)
|
- **层级**:L2 微前端层(004 §3.1 六层架构中的前端层)
|
||||||
- **MF 角色**:**Remote 子应用**,挂载到 teacher-portal Shell(主应用)
|
- **MF 角色**:**Remote 子应用**,挂载到 teacher-portal Shell(主应用,端口 4000)
|
||||||
- **上游(谁调用我)**:浏览器(系统管理员 / 校管理员)
|
- **上游(谁调用我)**:浏览器(系统管理员 / 校管理员)
|
||||||
- **下游(同步)**:api-gateway(REST,经 teacher-portal Shell 的 Next.js `rewrites` 代理 `/api/v1/*`)
|
- **下游(同步)**:api-gateway(GraphQL `POST /api/admin/graphql` + REST `/api/auth/login`)
|
||||||
- **下游(推送)**:— (**不消费推送**,管理端用轮询)
|
- **下游(推送)**:push-gateway `GET /ws`(WebSocket 实时通知,ARB-006)
|
||||||
- **BFF 对接**:teacher-bff 复用(`/admin/*` 聚合)+ iam 直连(`/iam/*` 用户/角色/权限/视口 CRUD)
|
- **BFF 对接**:复用 teacher-bff GraphQL admin 命名空间(ARB-001),不新建 admin-bff
|
||||||
- **通信方式**:HTTP/REST(前端→Gateway)
|
- **通信方式**:GraphQL(业务数据)+ HTTP(登录)+ WebSocket(实时通知)
|
||||||
- **不直连**:前端不直连任何业务服务或 BFF 后端实例,全部经 api-gateway 代理
|
- **不直连**:前端不直连任何业务服务或 BFF 后端实例,全部经 api-gateway 代理
|
||||||
|
|
||||||
**说明**:
|
**说明**:
|
||||||
|
|
||||||
- admin-portal 作为 Remote 子应用,挂载到 teacher-portal Shell 提供的 AppShell + 共享依赖(react/react-dom/@tanstack/react-query/zustand/nuqs/ui-components/ui-tokens/contracts/hooks)
|
- admin-portal 作为 Remote 子应用,挂载到 teacher-portal Shell
|
||||||
- 路由前缀 `/admin/*` 由 teacher-portal Shell 动态加载 admin-portal Remote 模块
|
- Shell 暴露 `GraphQLProvider` / `useGraphQLClient` / `AppShell` / `useAuth` / `usePermission` 等,admin-portal MF 模式复用,standalone 模式自建
|
||||||
- 场景域 BFF 复用策略(004 §5.4):系统/校管理员复用 teacher-portal Shell + teacher-bff(`/admin/*` 视口)+ iam 直连,不单独建 portal 后端
|
- 路由前缀 `/admin/*` 由 teacher-portal Shell 动态加载 admin-portal Remote 模块(暴露 `./AdminApp`)
|
||||||
- 全部业务请求经 api-gateway 代理,admin-portal 本身不实现 rewrites(依赖 Shell 的 rewrites)
|
- 场景域 BFF 复用策略(004 §5.4 + ARB-001):复用 teacher-bff GraphQL endpoint,admin 命名空间(`admin.*` Query/Mutation)
|
||||||
|
|
||||||
## 2. 我的限界上下文
|
## 2. 我的限界上下文
|
||||||
|
|
||||||
### 2.1 我负责的聚合 / 实体(前端视图模型)
|
### 2.1 我负责的聚合 / 实体(前端视图模型)
|
||||||
|
|
||||||
- 用户、角色、权限、视口(管理场景域前端视图)
|
- 用户、角色、权限、视口(管理场景域前端视图,调 iam 经 GraphQL)
|
||||||
- 组织(学校/年级/班级层级)
|
- 组织(学校/年级/班级层级)
|
||||||
- 平台监控(运行时指标、用户活动、错误统计)
|
- 学校设置(system 路由)
|
||||||
- 会话状态(Session)、视口(Viewport)、权限(Permission)(与 teacher-portal 共享,引用 teacher-portal 文档)
|
- 班级/教师/学生全局管理(admin 视角,只读 + 状态管理)
|
||||||
|
- 审计日志(只读,经 teacher-bff GraphQL `auditLogs` Query 消费)
|
||||||
|
- 管理仪表盘(聚合统计 + 服务健康)
|
||||||
|
- 实时通知(WebSocket 推送:审计告警/异常登录/系统异常)
|
||||||
|
|
||||||
### 2.2 业务领域
|
### 2.2 业务领域
|
||||||
|
|
||||||
- **管理场景域**(前端场景域:管理场景域,对应后端 iam 限界上下文 + teacher-bff 管理视口)
|
- **管理场景域**(前端场景域:管理场景域,对应后端 iam 限界上下文 + teacher-bff admin 命名空间)
|
||||||
|
|
||||||
### 2.3 不负责
|
### 2.3 不负责
|
||||||
|
|
||||||
@@ -44,6 +68,7 @@
|
|||||||
- 学生作答界面(归 student-portal)
|
- 学生作答界面(归 student-portal)
|
||||||
- 家长多子女切换(归 parent-portal)
|
- 家长多子女切换(归 parent-portal)
|
||||||
- 教师仪表盘(归 teacher-portal,admin-portal 仅有管理仪表盘)
|
- 教师仪表盘(归 teacher-portal,admin-portal 仅有管理仪表盘)
|
||||||
|
- 审计事件发布(归 iam,admin-portal 仅消费)
|
||||||
|
|
||||||
### 2.4 数据范围
|
### 2.4 数据范围
|
||||||
|
|
||||||
@@ -54,180 +79,206 @@
|
|||||||
|
|
||||||
### 3.1 消费的后端 API(经 api-gateway 代理)
|
### 3.1 消费的后端 API(经 api-gateway 代理)
|
||||||
|
|
||||||
| 路径前缀 | 下游 BFF/服务 | 关键端点 |
|
| 路径 | Method | 下游 BFF/服务 | 用途 |
|
||||||
| ------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------ | ----------------------------- | ------------------------------------------------- |
|
||||||
| `/api/v1/iam/*`(管理用) | iam | `GET /iam/users`、`POST /iam/users`、`PUT /iam/users/:id`、`GET /iam/roles`、`GET /iam/permissions`、`GET /iam/viewports`、`PUT /iam/viewports/:id` |
|
| `/api/admin/graphql` | POST | teacher-bff(admin 命名空间) | 全部业务 GraphQL 查询(ARB-001) |
|
||||||
| `/api/v1/admin/*` | teacher-bff 复用 + iam 直连 | `GET /admin/dashboard`、`GET /admin/monitoring/metrics`、`GET /admin/stats/users`、`GET /admin/stats/active-sessions` |
|
| `/api/auth/login` | POST | api-gateway → iam | 登录(standalone 模式用,MF 模式复用 Shell 登录) |
|
||||||
| `/api/v1/notifications/*` | msg | 通知中心(P5) |
|
| `GET /ws`(push-gateway) | WS | push-gateway | 实时通知(审计告警/异常登录/系统异常,ARB-006) |
|
||||||
|
|
||||||
### 3.2 统一响应契约
|
### 3.2 GraphQL Operations(contract §2.4)
|
||||||
|
|
||||||
所有后端响应遵循 `ActionState` 结构(迁移指南 §7.5):
|
admin-portal 消费 teacher-bff admin 命名空间的 18 个 GraphQL operations:
|
||||||
|
|
||||||
```typescript
|
| Operation | 类型 | 用途 |
|
||||||
type ActionState<T> =
|
| ------------------------------------------------ | -------------- | ------------------------------- |
|
||||||
| { success: true; data: T }
|
| `currentUser` | Query | 当前管理员信息 |
|
||||||
| {
|
| `adminUsers` / `adminUser(id)` | Query | 用户列表 / 详情 |
|
||||||
success: false;
|
| `createUser` / `updateUser` / `toggleUserStatus` | Mutation | 用户 CRUD |
|
||||||
error: { code: string; message: string; details?: unknown };
|
| `adminRoles` | Query | 角色列表(含权限) |
|
||||||
};
|
| `createRole` / `updateRolePermissions` | Mutation | 角色 CRUD + 权限矩阵 |
|
||||||
```
|
| `adminPermissions` | Query | 全量权限点(按 resource) |
|
||||||
|
| `adminViewports(scope)` | Query | 视口配置列表 |
|
||||||
|
| `updateViewport` | Mutation | 视口配置更新 |
|
||||||
|
| `adminOrganization(parentId)` | Query | 组织树 |
|
||||||
|
| `adminClasses` | Query | 班级管理(全局) |
|
||||||
|
| `adminTeachers` | Query | 教师管理 |
|
||||||
|
| `adminStudents` | Query | 学生管理 |
|
||||||
|
| `auditLogs(filter)` | Query | 审计日志(聚合 iam AuditEvent) |
|
||||||
|
| `adminDashboard` | Query | 管理员仪表盘聚合 |
|
||||||
|
| `systemSettings` / `updateSystemSettings` | Query/Mutation | 学校设置 |
|
||||||
|
|
||||||
错误码前缀按服务名大写(admin-portal 涉及:`IAM_`、`BFF_TEACHER_`、`GW_`、`NETWORK_`)。前端 API 请求层根据 `error.code` 前缀路由到对应的 i18n key。
|
### 3.3 推送契约(ARB-006)
|
||||||
|
|
||||||
### 3.3 推送契约
|
admin-portal 接入 push-gateway `GET /ws`,消费 3 类实时通知:
|
||||||
|
|
||||||
**— N/A**
|
| 通知类型 | severity | 用途 |
|
||||||
|
| ---------------- | -------- | ------------------------- |
|
||||||
|
| `audit_alert` | warning | 审计告警(敏感操作触发) |
|
||||||
|
| `abnormal_login` | error | 异常登录(异地/异常时段) |
|
||||||
|
| `system_error` | error | 系统异常(服务降级/宕机) |
|
||||||
|
|
||||||
admin-portal **不消费** WebSocket 推送或 SSE 流式响应。管理端场景对实时性要求低,采用**轮询**策略:
|
> **不再用轮询**:旧文档的"60s 轮询监控 + 5min 轮询统计"策略已被 ARB-006 替换为 WebSocket 实时推送。开发期用 mock-socket + 30s 定时器模拟推送。
|
||||||
|
|
||||||
- 平台监控页:60s 轮询 `GET /api/v1/admin/monitoring/metrics`
|
|
||||||
- 用户活动统计:5min 轮询 `GET /api/v1/admin/stats/active-sessions`
|
|
||||||
- 通知中心(P5):HTTP 长轮询或定时拉取 `GET /api/v1/notifications`(不走 push-gateway)
|
|
||||||
|
|
||||||
> 长期监控指标推荐用 `<iframe>` 嵌入 Grafana 面板(PlatformMonitor 组件),不经过 BFF。
|
|
||||||
|
|
||||||
### 3.4 proto 不直接消费
|
### 3.4 proto 不直接消费
|
||||||
|
|
||||||
前端不调用 gRPC,BFF 把 gRPC 聚合为 REST 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量(TS 文件,非 proto 生成)。
|
前端不调用 gRPC,teacher-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量(TS 文件,非 proto 生成)。
|
||||||
|
|
||||||
## 4. 我的技术栈
|
## 4. 我的技术栈
|
||||||
|
|
||||||
| 维度 | 选型 | 说明 |
|
| 维度 | 选型 | 说明 |
|
||||||
| --------------------------- | -------------------------------------------------------- | ----------------------------------------------- |
|
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| 框架 | Next.js 14+(App Router) | server components 默认,client components 按需 |
|
| 框架 | Next.js 14+(App Router) | server components 默认,client components 按需 |
|
||||||
| 语言 | TypeScript 5.5+(strict) | 沿用 tsconfig.base.json |
|
| 语言 | TypeScript 5.5+(strict) | 沿用 tsconfig.base.json |
|
||||||
| 微前端 | Module Federation 2.0(@module-federation/nextjs-mf) | admin-portal = **Remote** |
|
| 微前端 | Module Federation 2.0(@module-federation/nextjs-mf) | admin-portal = **Remote**,端口 4003 |
|
||||||
| 样式 | Tailwind CSS 3.4+ | 配合设计令牌三层模型(与 teacher-portal 共享) |
|
| 数据层 | urql GraphQL client | standalone 自建 / MF 复用 Shell 单例(ARB-004) |
|
||||||
| UI 组件库 | shadcn/ui(迁移指南 §7.2) | 复用 Shell 暴露的 `packages/ui-components/` |
|
| 样式 | Tailwind CSS 3.4+ | 配合设计令牌三层(复用 Shell) |
|
||||||
| 状态管理 L1 URL | nuqs | 可分享、可刷新状态 |
|
| UI 组件库 | shadcn/ui 风格(复用 Shell `@edu/ui-components`) | admin-portal 内部 `ui.tsx` 封装 PaperCard/Button/Table 等纸面组件 |
|
||||||
| 状态管理 L2 Server | TanStack Query v5 | 服务端数据缓存、重试、乐观更新 |
|
| 表单 | react-hook-form + zod | 用户表单/学校设置 |
|
||||||
| 状态管理 L3 Client Business | Zustand slice | 客户端业务状态 |
|
| 拖拽排序 | @dnd-kit/sortable | 视口配置拖拽排序 |
|
||||||
| 状态管理 L4 Global UI | Zustand ui-store + ModalRoot(复用 Shell) | 全局 UI 状态 |
|
| 图表 | recharts | 仪表盘趋势表(实际以纯表格为主,纸面风格) |
|
||||||
| 状态管理 L5 Form | react-hook-form + zodResolver | 表单状态 |
|
| i18n | 轻量 i18n(`messages` 字典 + `t()` 函数) | 命名空间 `admin.*`,不依赖 next-intl |
|
||||||
| 富文本 | — **不使用** | 管理端无富文本场景,不引入 Tiptap |
|
| Mock | MSW(Mock Service Worker)+ mock-socket | 开发期拦截全部 GraphQL/HTTP + WebSocket mock |
|
||||||
| 图表 | recharts | 管理仪表盘、用户活动统计 |
|
| Web Vitals | web-vitals v4 | LCP/CLS/FCP/INP/TTFB 采集,`navigator.sendBeacon` 上报 |
|
||||||
| i18n | next-intl | BFF/服务返回 i18n key + 参数,前端翻译 |
|
| A11y | eslint-plugin-jsx-a11y(error 级) | WCAG 2.2 AA |
|
||||||
| A11y | eslint-plugin-jsx-a11y(error 级) | WCAG 2.2 AA |
|
| 字体 | Inter(sans)/ Fraunces(serif)/ JetBrains Mono(mono) | RootLayout 加载(standalone);MF 模式复用 Shell |
|
||||||
| 字体 | Inter(sans)/ Fraunces(serif)/ JetBrains Mono(mono) | 复用 teacher-portal RootLayout 加载,不重复引入 |
|
| 测试 | Vitest + @testing-library/react | jsdom 环境,覆盖率 ≥ 80% |
|
||||||
|
| ESLint | ESLint 9 flat config | jsx-a11y + tseslint + prettier(注:`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容,已移除) |
|
||||||
|
|
||||||
> 技术栈与 teacher-portal **完全一致**,差异仅在:MF 角色(Remote 而非 Shell)、不使用 Tiptap、不消费 WebSocket/SSE 推送、数据范围 L3-L5。
|
> **技术栈差异**(vs teacher-portal):MF 角色(Remote)、GraphQL 为主(非 REST)、消费 WebSocket 推送(非 SSE)、纸面 UI 风格、数据范围 L3-L5。
|
||||||
|
|
||||||
## 5. 我的阶段归属
|
## 5. 我的阶段归属
|
||||||
|
|
||||||
- **阶段**:P6(硬化阶段)
|
- **阶段**:P6(硬化阶段),批次 5
|
||||||
- **当前状态**:❌ **全为待建**(admin-portal 当前为空目录,无任何代码);📐 需设计(待全部业务服务稳定)
|
- **当前状态**:✅ **已实现**(P2-P6 全阶段交付完成,含 11 路由 + MSW mock + A11y + Web Vitals + Dockerfile)
|
||||||
- **依赖上游阶段**:P6(依赖 teacher-portal Shell 已就绪 + iam 已实现用户/角色/权限/视口 CRUD + teacher-bff 已实现 `/admin/*` 复用)
|
- **依赖上游阶段**:P6(依赖 teacher-portal Shell 已就绪 + teacher-bff GraphQL admin 命名空间 + iam 已实现用户/角色/权限/视口 CRUD + push-gateway WebSocket)
|
||||||
|
|
||||||
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
|
## 6. 路由表(11 路由,contract §1.2)
|
||||||
|
|
||||||
> 前端无 `@RequirePermission` 装饰器(后端概念),对齐项改造为前端等价物。
|
| 路由 | 页面 | 权限点 | 数据范围 |
|
||||||
|
| --------------------- | ---------------- | ---------------------- | -------- |
|
||||||
|
| `/admin/dashboard` | 管理员仪表盘 | `ADMIN_DASHBOARD_VIEW` | L3-L5 |
|
||||||
|
| `/admin/users` | 用户管理 | `IAM_USER_READ` | L3-L5 |
|
||||||
|
| `/admin/roles` | 角色权限管理 | `IAM_ROLE_READ` | L3-L5 |
|
||||||
|
| `/admin/permissions` | 权限点管理 | `IAM_PERMISSION_READ` | L5 |
|
||||||
|
| `/admin/viewports` | 视口配置 | `IAM_VIEWPORT_READ` | L5 |
|
||||||
|
| `/admin/organization` | 组织管理 | `ADMIN_ORG_MANAGE` | L3-L5 |
|
||||||
|
| `/admin/system` | 学校设置 | `ADMIN_SYSTEM_MANAGE` | L5 |
|
||||||
|
| `/admin/classes` | 班级管理(全局) | `ADMIN_CLASS_READ` | L3-L5 |
|
||||||
|
| `/admin/teachers` | 教师管理 | `ADMIN_TEACHER_READ` | L3-L5 |
|
||||||
|
| `/admin/students` | 学生管理 | `ADMIN_STUDENT_READ` | L3-L5 |
|
||||||
|
| `/admin/audit-logs` | 审计日志 | `ADMIN_AUDIT_READ` | L5 |
|
||||||
|
|
||||||
| 对齐项 | classes(后端黄金模板) | admin-portal 前端等价 | 当前状态 |
|
> 权限点前缀规则(ARB-003):admin 自身资源用 `ADMIN_*`;跨服务资源保留原前缀(如 `IAM_USER_READ`)。`ADMIN_*` 常量由 coord 维护于 `packages/contracts/src/permissions.ts`,admin-portal 引用 `src/lib/permissions.ts`,不硬编码。
|
||||||
| --------------------- | ------------------------------------- | ------------------------------------------------------------------------ | -------------------------- |
|
|
||||||
| 权限校验 | `@RequirePermission(Permissions.XXX)` | `usePermission().hasPermission("XXX")` Hook + `<RequirePermission>` 组件 | ❌ 待建(无现有代码) |
|
## 7. 黄金模板对齐清单(前端等价)
|
||||||
| 错误码前缀统一 | `CLASSES_*`、`IAM_*` | API 请求层根据 `error.code` 前缀路由 i18n | ❌ 待建 |
|
|
||||||
| logger | pino | 前端 console + Sentry(P6) | ❌ 待建 |
|
| 对齐项 | 后端等价 | admin-portal 实现 | 状态 |
|
||||||
| metrics | prom-client `/metrics` | 前端 Web Vitals → Gateway 上报 | ❌ 待建 |
|
| --------------------- | ------------------------------------- | ------------------------------------------------------------- | ---- |
|
||||||
| tracer | OTel SDK | 前端 OTel browser SDK(P6) | ❌ 待建 |
|
| 权限校验 | `@RequirePermission(Permissions.XXX)` | `usePermission().hasPermission("XXX")` Hook + `AuthGuard` | ✅ |
|
||||||
| /healthz + /readyz | `GET /healthz` `GET /readyz` | Next.js `/api/health` route + Dockerfile HEALTHCHECK | ❌ 待建 |
|
| 错误码前缀 | `IAM_*` / `ADMIN_*` | `CONSUMED_ERROR_PREFIXES` 常量 + i18n 路由 | ✅ |
|
||||||
| 优雅关闭 | SIGTERM handler | Next.js 无长连接,无需 | ✅ N/A |
|
| logger | pino | console + 结构化(生产 → 上报) | ✅ |
|
||||||
| 测试覆盖率 ≥ 80% | Vitest | Vitest + @testing-library/react + Playwright E2E | ❌ 待建(0%) |
|
| metrics | prom-client `/metrics` | Web Vitals v4 → `navigator.sendBeacon` 上报 | ✅ |
|
||||||
| Dockerfile 多阶段构建 | builder + runtime | builder + runtime | ❌ 待建 |
|
| /health + /ready | `GET /healthz` `GET /readyz` | Next.js Route Handler `/api/health` + `/api/ready` | ✅ |
|
||||||
| Zod 输入验证 | class-validator + Zod schema | react-hook-form + zodResolver | ❌ 待建 |
|
| 优雅关闭 | SIGTERM handler | N/A(Next.js 无长连接) | ✅ |
|
||||||
| GlobalErrorFilter | NestJS 全局异常过滤器 | React ErrorBoundary + API 请求层统一错误处理 | ❌ 待建 |
|
| 测试覆盖率 ≥ 80% | Vitest | Vitest + @testing-library/react | ⚠️ |
|
||||||
| 设计令牌三层 | — | primitive.css / semantic-light/dark.css / tailwind-theme.css(复用) | ❌ 待建(复用 Shell 暴露) |
|
| Dockerfile 多阶段构建 | builder + runtime | `node:22-alpine` 多阶段,standalone 输出,EXPOSE 4003 | ✅ |
|
||||||
| A11y 工具集 | — | useA11yId / mergeA11yProps / describeInput / focus-trap(复用 Shell) | ❌ 待建(复用 Shell 暴露) |
|
| Zod 输入验证 | class-validator + Zod schema | react-hook-form + zod | ✅ |
|
||||||
|
| GlobalErrorFilter | NestJS 全局异常过滤器 | React ErrorBoundary + Toast 错误处理 | ✅ |
|
||||||
|
| 设计令牌三层 | — | `globals.css`(primitive/semantic)+ tailwind-theme | ✅ |
|
||||||
|
| A11y 工具集 | — | eslint-plugin-jsx-a11y(error 级)+ skip-link + focus-visible | ✅ |
|
||||||
|
| Module Federation | — | `next.config.js` Remote 角色,`NEXT_PUBLIC_MF_ENABLED` 控制 | ✅ |
|
||||||
|
| MSW mock | — | 18 GraphQL operation + `/api/auth/login` mock | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附:admin-portal 现状审计(对齐黄金模板)
|
## 附:admin-portal 实现清单(v2,仲裁后修订)
|
||||||
|
|
||||||
### 审计表
|
### 文件结构
|
||||||
|
|
||||||
| 维度 | 状态 | 说明 |
|
|
||||||
| ------------------------------------ | ------ | ------------------------------------------------------------------------- |
|
|
||||||
| 权限装饰器(前端等价 usePermission) | ❌ | 待建,复用 Shell 暴露的 `usePermission` Hook + `<RequirePermission>` 组件 |
|
|
||||||
| 错误码前缀 | ❌ | 待建,复用 Shell 暴露的 ApiClient(统一请求层) |
|
|
||||||
| logger | ❌ | 待建,复用 `packages/shared-ts/src/logger.ts` |
|
|
||||||
| metrics | ❌ | 待建,Web Vitals 上报 `admin_portal_*` |
|
|
||||||
| tracer | ❌ | 待建,OTel browser SDK(P6) |
|
|
||||||
| /healthz | ❌ | 待建,Next.js Route Handler |
|
|
||||||
| /readyz | ❌ | 待建,Next.js Route Handler |
|
|
||||||
| 优雅关闭 | ✅ N/A | Next.js 无长连接 |
|
|
||||||
| 测试覆盖率 | ❌ | 待建,目标 ≥ 80% |
|
|
||||||
| Dockerfile 多阶段 | ❌ | 待建,builder + runtime |
|
|
||||||
| Zod 输入验证 | ❌ | 待建,react-hook-form + zodResolver |
|
|
||||||
| GlobalErrorFilter(ErrorBoundary) | ❌ | 待建,复用 Shell 暴露的 ErrorBoundary |
|
|
||||||
| 设计令牌三层 | ❌ | 待建,复用 Shell 暴露的 `packages/ui-tokens/` |
|
|
||||||
| A11y 工具集 | ❌ | 待建,复用 Shell 暴露的 `packages/ui-components/` |
|
|
||||||
| Module Federation 配置 | ❌ | 待建,next.config.js 配置 Remote 角色 |
|
|
||||||
| 5 层状态管理 | ❌ | 待建,复用 Shell 暴露的 nuqs/TanStack Query/Zustand |
|
|
||||||
| 共享组件库 | ❌ | 待建,复用 Shell 暴露 + admin 特有 4 个组件 |
|
|
||||||
| i18n | ❌ | 待建,next-intl |
|
|
||||||
| API 请求层 | ❌ | 待建,复用 Shell 暴露的 ApiClient |
|
|
||||||
| ESLint flat config 自定义规则 | ❌ | 待建,no-hardcoded-fonts / design-tokens 规则 |
|
|
||||||
|
|
||||||
### 现有文件清单
|
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/admin-portal/ # 空目录(待建)
|
apps/admin-portal/
|
||||||
|
├─ package.json # 依赖(urql/msw/@dnd-kit/web-vitals/eslint-plugin-jsx-a11y)
|
||||||
|
├─ next.config.js # MF Remote 配置 + rewrites(GraphQL 代理)
|
||||||
|
├─ tsconfig.json # extends tsconfig.base.json
|
||||||
|
├─ tailwind.config.js # paper/ink/accent/rule 色板
|
||||||
|
├─ eslint.config.js # ESLint 9 flat config(jsx-a11y + tseslint + prettier)
|
||||||
|
├─ Dockerfile # 多阶段构建,node:22-alpine,standalone
|
||||||
|
├─ vitest.config.ts # jsdom + coverage ≥80%
|
||||||
|
├─ .env.example # 环境变量模板
|
||||||
|
├─ public/mockServiceWorker.js # MSW worker
|
||||||
|
└─ src/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ globals.css # 设计令牌 + A11y 样式
|
||||||
|
│ ├─ layout.tsx # RootLayout(字体加载)
|
||||||
|
│ ├─ page.tsx # 根页面(redirect /admin/dashboard)
|
||||||
|
│ ├─ admin-app.tsx # MF Remote 入口(exposes ./AdminApp)
|
||||||
|
│ ├─ login/page.tsx # 登录页(standalone mock 登录)
|
||||||
|
│ ├─ admin/
|
||||||
|
│ │ ├─ layout.tsx # admin 路由组 layout(Provider 链 + AuthGuard + AdminShell)
|
||||||
|
│ │ ├─ dashboard/page.tsx # 管理仪表盘
|
||||||
|
│ │ ├─ users/page.tsx # 用户管理
|
||||||
|
│ │ ├─ roles/page.tsx # 角色权限
|
||||||
|
│ │ ├─ permissions/page.tsx # 权限点管理
|
||||||
|
│ │ ├─ viewports/page.tsx # 视口配置(@dnd-kit 拖拽)
|
||||||
|
│ │ ├─ organization/page.tsx # 组织管理
|
||||||
|
│ │ ├─ system/page.tsx # 学校设置
|
||||||
|
│ │ ├─ classes/page.tsx # 班级管理
|
||||||
|
│ │ ├─ teachers/page.tsx # 教师管理
|
||||||
|
│ │ ├─ students/page.tsx # 学生管理
|
||||||
|
│ │ └─ audit-logs/page.tsx # 审计日志(CSV 导出)
|
||||||
|
│ └─ api/
|
||||||
|
│ ├─ health/route.ts # Liveness 检查
|
||||||
|
│ └─ ready/route.ts # Readiness 检查
|
||||||
|
├─ components/
|
||||||
|
│ ├─ ui.tsx # 通用 UI(PaperCard/Button/Input/Select/Badge/Table/Pagination)
|
||||||
|
│ ├─ admin-shell.tsx # AdminShell(左栏 11 项导航 + 主内容 + 跳过链接)
|
||||||
|
│ ├─ user-management-table.tsx # 用户管理表格
|
||||||
|
│ ├─ user-form-modal.tsx # 用户表单弹窗
|
||||||
|
│ ├─ role-permission-matrix.tsx# 角色权限矩阵
|
||||||
|
│ ├─ organization-tree.tsx # 组织树(A11y treeitem)
|
||||||
|
│ ├─ notification-panel.tsx # 通知面板(WebSocket)
|
||||||
|
│ ├─ msw-initializer.tsx # MSW 初始化
|
||||||
|
│ └─ web-vitals-initializer.tsx# Web Vitals 初始化
|
||||||
|
├─ hooks/
|
||||||
|
│ ├─ use-graphql.ts # useGraphQuery + useGraphMutation
|
||||||
|
│ ├─ use-users.ts # 用户 CRUD
|
||||||
|
│ ├─ use-roles.ts # 角色 CRUD
|
||||||
|
│ ├─ use-permissions.ts # 权限点查询
|
||||||
|
│ ├─ use-viewports.ts # 视口配置
|
||||||
|
│ ├─ use-organization.ts # 组织树
|
||||||
|
│ ├─ use-classes.ts # 班级
|
||||||
|
│ ├─ use-teachers.ts # 教师
|
||||||
|
│ ├─ use-students.ts # 学生
|
||||||
|
│ ├─ use-audit-logs.ts # 审计日志 + CSV 导出
|
||||||
|
│ ├─ use-dashboard.ts # 仪表盘
|
||||||
|
│ ├─ use-system-settings.ts # 学校设置
|
||||||
|
│ └─ use-websocket.ts # WebSocket 实时通知
|
||||||
|
├─ providers/
|
||||||
|
│ ├─ graphql-provider.tsx # GraphQLProvider(standalone 自建 urql client)
|
||||||
|
│ ├─ auth-provider.tsx # AuthProvider + useAuth + usePermission
|
||||||
|
│ └─ toast-provider.tsx # ToastProvider + useToast
|
||||||
|
├─ lib/
|
||||||
|
│ ├─ graphql-client.ts # urql client + 18 个 GraphQL operations
|
||||||
|
│ ├─ auth.ts # 认证工具
|
||||||
|
│ ├─ permissions.ts # 权限常量(ADMIN_ + IAM_)
|
||||||
|
│ ├─ i18n.ts # 轻量 i18n
|
||||||
|
│ └─ web-vitals.ts # Web Vitals 采集
|
||||||
|
├─ types/
|
||||||
|
│ └─ view-models.ts # 视图模型类型定义
|
||||||
|
└─ mocks/
|
||||||
|
├─ browser.ts # MSW Browser Worker
|
||||||
|
├─ server.ts # MSW Node Server(vitest)
|
||||||
|
├─ fixtures.ts # mock 数据库
|
||||||
|
└─ handlers.ts # MSW handlers(18 GraphQL + login)
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**:admin-portal 当前为空目录,所有维度均为 ❌ 待建状态。无现有文件、无现有代码、无现有违规点。P6 阶段从零开始搭建。
|
### 质量校验
|
||||||
|
|
||||||
### L1 导航菜单(视口)
|
- `pnpm run typecheck` → ✅ 通过(0 errors)
|
||||||
|
- `pnpm run lint` → ✅ 通过(0 errors)
|
||||||
| 视口 key | 标签(i18n key) | 路由 | 权限 |
|
|
||||||
| -------------- | ------------------------ | --------------------- | ----------------------- |
|
|
||||||
| `dashboard` | `admin.nav.dashboard` | `/admin/dashboard` | `ADMIN_DASHBOARD_VIEW` |
|
|
||||||
| `users` | `admin.nav.users` | `/admin/users` | `IAM_USER_READ` |
|
|
||||||
| `roles` | `admin.nav.roles` | `/admin/roles` | `IAM_ROLE_READ` |
|
|
||||||
| `permissions` | `admin.nav.permissions` | `/admin/permissions` | `IAM_PERMISSION_READ` |
|
|
||||||
| `viewports` | `admin.nav.viewports` | `/admin/viewports` | `IAM_VIEWPORT_READ` |
|
|
||||||
| `organization` | `admin.nav.organization` | `/admin/organization` | `ORG_MANAGE` |
|
|
||||||
| `monitoring` | `admin.nav.monitoring` | `/admin/monitoring` | `ADMIN_MONITORING_VIEW` |
|
|
||||||
|
|
||||||
### L2 路由表
|
|
||||||
|
|
||||||
| 路由 | 页面 | 权限 |
|
|
||||||
| --------------------- | ---------- | ----------------------- |
|
|
||||||
| `/admin/dashboard` | 管理仪表盘 | `ADMIN_DASHBOARD_VIEW` |
|
|
||||||
| `/admin/users` | 用户管理 | `IAM_USER_READ` |
|
|
||||||
| `/admin/users/new` | 新建用户 | `IAM_USER_CREATE` |
|
|
||||||
| `/admin/users/:id` | 用户编辑 | `IAM_USER_UPDATE` |
|
|
||||||
| `/admin/roles` | 角色管理 | `IAM_ROLE_READ` |
|
|
||||||
| `/admin/permissions` | 权限管理 | `IAM_PERMISSION_READ` |
|
|
||||||
| `/admin/viewports` | 视口配置 | `IAM_VIEWPORT_READ` |
|
|
||||||
| `/admin/organization` | 组织管理 | `ORG_MANAGE` |
|
|
||||||
| `/admin/monitoring` | 平台监控 | `ADMIN_MONITORING_VIEW` |
|
|
||||||
|
|
||||||
### L3 组件级差异(admin-portal 特有)
|
|
||||||
|
|
||||||
**复用 Shell 暴露的组件**:AppShell、RequirePermission、ErrorBoundary、Loading、Empty、DataTable、Form、Chart、Toast、Modal、Button/Input/Select/Textarea、A11y 工具集
|
|
||||||
|
|
||||||
**admin-portal 特有组件**:
|
|
||||||
|
|
||||||
| 组件 | 用途 | 来源 |
|
|
||||||
| ---------------------- | --------------------------------------- | ---- |
|
|
||||||
| `UserManagementTable` | 用户管理表格(列表/筛选/分页/批量操作) | 新建 |
|
|
||||||
| `RolePermissionMatrix` | 角色-权限矩阵编辑器(checkbox 网格) | 新建 |
|
|
||||||
| `ViewportConfigEditor` | 视口配置编辑器(拖拽排序 + 权限绑定) | 新建 |
|
|
||||||
| `PlatformMonitor` | 平台监控(Grafana iframe embed) | 新建 |
|
|
||||||
|
|
||||||
**不使用的组件**:RichTextEditor(Tiptap)、ExamTaking、SSEViewer、ChildSwitcher 等(这些归 teacher-portal / student-portal / parent-portal)
|
|
||||||
|
|
||||||
### L4 数据层差异
|
|
||||||
|
|
||||||
| 维度 | admin-portal | teacher-portal |
|
|
||||||
| ------------ | --------------------------------- | -------------------------------------- |
|
|
||||||
| 主要数据来源 | iam 直连 + teacher-bff 复用 | teacher-bff 聚合 + core-edu/content/ai |
|
|
||||||
| 缓存策略 | **5min 长缓存**(管理数据低频变) | 5min / 30s 混合(实时性差异) |
|
|
||||||
| 实时性要求 | 低(轮询 60s / 5min) | 高(WebSocket 推送 + SSE 流式) |
|
|
||||||
| 推送消费 | ❌ 不消费 | ✅ 消费 WebSocket + SSE |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**AI Agent**: ai07 (admin-portal remote)
|
**AI Agent**: ai16 (admin-portal remote)
|
||||||
**Branch**: docs/admin-portal-stage1-stage2-design-ai07
|
**Branch**: admin-portal-arbitration-done-TaFkyb
|
||||||
**Coordinator**: coord-ai
|
**Coordinator**: coord-ai
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
90
apps/admin-portal/eslint.config.js
Normal file
90
apps/admin-portal/eslint.config.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// admin-portal ESLint flat config
|
||||||
|
// 包含 React + jsx-a11y + TypeScript 规则
|
||||||
|
// 注意:@next/eslint-plugin-next 14.x 与 ESLint 9 不兼容,暂不使用
|
||||||
|
const js = require('@eslint/js');
|
||||||
|
const tseslint = require('typescript-eslint');
|
||||||
|
const prettierConfig = require('eslint-config-prettier');
|
||||||
|
const jsx11y = require('eslint-plugin-jsx-a11y');
|
||||||
|
|
||||||
|
module.exports = tseslint.config(
|
||||||
|
// 全局忽略
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.next/**',
|
||||||
|
'coverage/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'public/mockServiceWorker.js',
|
||||||
|
'eslint.config.js',
|
||||||
|
'next.config.js',
|
||||||
|
'vitest.config.ts',
|
||||||
|
'postcss.config.js',
|
||||||
|
'tailwind.config.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 基础 JS 规则
|
||||||
|
js.configs.recommended,
|
||||||
|
|
||||||
|
// TypeScript 规则
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
|
// jsx-a11y 规则(A11y WCAG 2.2 AA)
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'jsx-a11y': jsx11y,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...jsx11y.flatConfigs.recommended.rules,
|
||||||
|
'jsx-a11y/no-autofocus': 'warn',
|
||||||
|
'jsx-a11y/aria-role': 'error',
|
||||||
|
'jsx-a11y/aria-props': 'error',
|
||||||
|
'jsx-a11y/aria-proptypes': 'error',
|
||||||
|
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||||
|
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||||
|
'jsx-a11y/role-supports-aria-props': 'error',
|
||||||
|
'jsx-a11y/tabindex-no-positive': 'error',
|
||||||
|
'jsx-a11y/no-noninteractive-tabindex': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// React/JSX 文件配置
|
||||||
|
{
|
||||||
|
files: ['**/*.tsx', '**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2024,
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// 禁止 any(未知类型用 unknown)
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
// 未使用变量允许下划线前缀
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 允许 console(开发环境)
|
||||||
|
'no-console': 'off',
|
||||||
|
// 允许未定义的 require(Next.js 配置)
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试文件放宽规则
|
||||||
|
{
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'jsx-a11y/no-autofocus': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 禁用与 Prettier 冲突的规则
|
||||||
|
prettierConfig,
|
||||||
|
);
|
||||||
2
apps/admin-portal/next-env.d.ts
vendored
Normal file
2
apps/admin-portal/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
53
apps/admin-portal/next.config.js
Normal file
53
apps/admin-portal/next.config.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
transpilePackages: [
|
||||||
|
"@edu/ui-tokens",
|
||||||
|
"@edu/ui-components",
|
||||||
|
"@edu/hooks",
|
||||||
|
],
|
||||||
|
async rewrites() {
|
||||||
|
const gatewayUrl = process.env.API_GATEWAY_URL || "http://localhost:8080";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/admin/graphql",
|
||||||
|
destination: `${gatewayUrl}/api/admin/graphql`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${gatewayUrl}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
webpack(config, { isServer }) {
|
||||||
|
if (process.env.NEXT_PUBLIC_MF_ENABLED === "true") {
|
||||||
|
config.plugins.push(
|
||||||
|
new NextFederationPlugin({
|
||||||
|
name: "admin_app",
|
||||||
|
filename: "static/chunks/remoteEntry.js",
|
||||||
|
exposes: {
|
||||||
|
"./AdminApp": "./src/app/admin-app.tsx",
|
||||||
|
},
|
||||||
|
remotes: {
|
||||||
|
teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
|
||||||
|
},
|
||||||
|
shared: {
|
||||||
|
react: { singleton: true, requiredVersion: "^18.3.0" },
|
||||||
|
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
|
||||||
|
urql: { singleton: true },
|
||||||
|
graphql: { singleton: true },
|
||||||
|
"@edu/ui-tokens": { singleton: true },
|
||||||
|
"@edu/ui-components": { singleton: true },
|
||||||
|
"@edu/hooks": { singleton: true },
|
||||||
|
},
|
||||||
|
extraOptions: { exposePages: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
60
apps/admin-portal/package.json
Normal file
60
apps/admin-portal/package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "@edu/admin-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4003",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 4003",
|
||||||
|
"lint": "eslint src --config eslint.config.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"msw:init": "msw init public/ --save"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@edu/hooks": "workspace:*",
|
||||||
|
"@edu/ui-components": "workspace:*",
|
||||||
|
"@edu/ui-tokens": "workspace:*",
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.0",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@urql/core": "^5.0.0",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"recharts": "^2.12.0",
|
||||||
|
"urql": "^4.2.0",
|
||||||
|
"web-vitals": "^4.2.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@module-federation/nextjs-mf": "^8.8.0",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||||
|
"@opentelemetry/sdk-trace-web": "^1.25.0",
|
||||||
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"mock-socket": "^9.3.0",
|
||||||
|
"msw": "^2.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/admin-portal/postcss.config.js
Normal file
6
apps/admin-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
361
apps/admin-portal/public/mockServiceWorker.js
Normal file
361
apps/admin-portal/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.15.0'
|
||||||
|
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Omit the body of server-sent event stream responses.
|
||||||
|
// Cloning such responses would prevent client-side stream cancelations
|
||||||
|
// from reaching the original stream (a teed stream only cancels its
|
||||||
|
// source once both of its branches cancel) and would buffer the
|
||||||
|
// entire stream into the unconsumed clone indefinitely.
|
||||||
|
const isEventStreamResponse = response.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.toLowerCase()
|
||||||
|
.startsWith('text/event-stream')
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: response.type,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
body: responseClone ? responseClone.body : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone && responseClone.body
|
||||||
|
? [serializedRequest.body, responseClone.body]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/admin-portal/src/app/admin-app.tsx
Normal file
51
apps/admin-portal/src/app/admin-app.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||||
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
|
import { ToastProvider } from "@/providers/toast-provider";
|
||||||
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
|
import { MswInitializer } from "@/components/msw-initializer";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MF Remote 入口组件
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-002 §2.2:Shell 动态 import admin/AdminApp 加载管理端
|
||||||
|
* - MF 模式:Shell 提供 GraphQLProvider/AuthProvider/AppShell,AdminApp 仅渲染页面
|
||||||
|
* - Standalone 模式:AdminApp 自建 providers + AdminShell
|
||||||
|
*
|
||||||
|
* 暴露:next.config.js exposes: { './AdminApp': './src/app/admin-app.tsx' }
|
||||||
|
*/
|
||||||
|
export default function AdminApp({ children }: { children: ReactNode }) {
|
||||||
|
const [mswReady, setMswReady] = useState(false);
|
||||||
|
|
||||||
|
// MF 模式下复用 Shell providers(通过 dynamic import)
|
||||||
|
// Standalone 模式下自建 providers
|
||||||
|
const isStandalone = process.env.NEXT_PUBLIC_MF_ENABLED !== "true";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStandalone) {
|
||||||
|
setMswReady(true);
|
||||||
|
}
|
||||||
|
}, [isStandalone]);
|
||||||
|
|
||||||
|
if (!isStandalone) {
|
||||||
|
// MF 模式:Shell 已提供 providers + AppShell
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone 模式:自建 providers
|
||||||
|
return (
|
||||||
|
<GraphQLProvider>
|
||||||
|
<MswInitializer onReady={() => setMswReady(true)} />
|
||||||
|
{mswReady && (
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<AdminShell>{children}</AdminShell>
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
)}
|
||||||
|
</GraphQLProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
158
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useAuditLogs,
|
||||||
|
useAuditLogFilter,
|
||||||
|
exportAuditLogsCsv,
|
||||||
|
} from "@/hooks/use-audit-logs";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Pagination,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function AuditLogsPage(): ReactNode {
|
||||||
|
const { filter, setPage, setSearch, setAction } = useAuditLogFilter();
|
||||||
|
const { data, loading, error } = useAuditLogs(filter);
|
||||||
|
const { show } = useToast();
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, setSearch]);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
show("warning", "暂无数据可导出");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportAuditLogsCsv(data.items);
|
||||||
|
show("success", "已导出 CSV");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.auditLogs.title")}
|
||||||
|
description="系统所有操作的审计记录"
|
||||||
|
actions={
|
||||||
|
<Button variant="secondary" onClick={handleExport}>
|
||||||
|
{t("admin.auditLogs.exportCsv")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filter.action ?? ""}
|
||||||
|
onChange={(e) => setAction(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("admin.common.all")}操作</option>
|
||||||
|
<option value="USER_LOGIN">用户登录</option>
|
||||||
|
<option value="USER_CREATE">创建用户</option>
|
||||||
|
<option value="USER_UPDATE">更新用户</option>
|
||||||
|
<option value="USER_TOGGLE_STATUS">切换状态</option>
|
||||||
|
<option value="ROLE_CREATE">创建角色</option>
|
||||||
|
<option value="ROLE_UPDATE">更新角色</option>
|
||||||
|
<option value="VIEWPORT_UPDATE">更新视口</option>
|
||||||
|
<option value="SYSTEM_SETTINGS_UPDATE">系统设置</option>
|
||||||
|
<option value="ABNORMAL_LOGIN_BLOCKED">异常登录拦截</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
t("admin.auditLogs.time"),
|
||||||
|
t("admin.auditLogs.actor"),
|
||||||
|
t("admin.auditLogs.action"),
|
||||||
|
t("admin.auditLogs.resource"),
|
||||||
|
t("admin.auditLogs.resourceId"),
|
||||||
|
t("admin.auditLogs.ip"),
|
||||||
|
t("admin.auditLogs.traceId"),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{data.items.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{new Date(log.occurredAt).toLocaleString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{log.actorName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{log.resourceType}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{log.resourceId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{log.ip}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.traceId ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
hasNext={data.hasNext}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import { useClasses, useClassFilter } from "@/hooks/use-classes";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Pagination,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function ClassesPage(): ReactNode {
|
||||||
|
const { filter, setPage, setSearch } = useClassFilter();
|
||||||
|
const { data, loading, error } = useClasses(filter);
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, setSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader title={t("admin.classes.title")} description="全校班级一览" />
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
"班级",
|
||||||
|
"年级",
|
||||||
|
"学校",
|
||||||
|
t("admin.classes.headTeacher"),
|
||||||
|
t("admin.classes.studentCount"),
|
||||||
|
t("admin.common.createdAt"),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{data.items.map((cls) => (
|
||||||
|
<TableRow key={cls.id}>
|
||||||
|
<TableCell>{cls.name}</TableCell>
|
||||||
|
<TableCell>{cls.gradeName}</TableCell>
|
||||||
|
<TableCell>{cls.schoolName}</TableCell>
|
||||||
|
<TableCell>{cls.headTeacherName || "—"}</TableCell>
|
||||||
|
<TableCell>{cls.studentCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(cls.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
hasNext={data.hasNext}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { useDashboard } from "@/hooks/use-dashboard";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
Badge,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { NotificationPanel } from "@/components/notification-panel";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, suffix }: StatCardProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 28,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
margin: "8px 0 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{suffix && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</PaperCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage(): ReactNode {
|
||||||
|
const { data, loading, error } = useDashboard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.dashboard.title")}
|
||||||
|
description="全局概览与系统健康"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationPanel />
|
||||||
|
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{data && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.totalTeachers")}
|
||||||
|
value={data.totalTeachers}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.totalStudents")}
|
||||||
|
value={data.totalStudents}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.totalClasses")}
|
||||||
|
value={data.totalClasses}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.totalSchools")}
|
||||||
|
value={data.totalSchools}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.schoolAvgScore")}
|
||||||
|
value={data.schoolAvgScore.toFixed(1)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.activeUsersToday")}
|
||||||
|
value={data.activeUsersToday}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("admin.dashboard.auditEventsToday")}
|
||||||
|
value={data.auditEventsToday}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势图(简化的表格) */}
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.dashboard.trend")}
|
||||||
|
</h3>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "6px 8px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
日期
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
padding: "6px 8px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
教师
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
padding: "6px 8px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
学生
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
padding: "6px 8px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
平均分
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.trend.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.date}
|
||||||
|
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.date}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.teachers}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.students}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.avgScore.toFixed(1)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
{/* 服务健康 */}
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.dashboard.serviceHealth")}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{data.serviceHealth.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.serviceName}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{svc.serviceName}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 11, color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
{svc.latencyMs}ms
|
||||||
|
</span>
|
||||||
|
<Badge status={svc.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||||
|
import { AuthProvider, useAuth } from "@/providers/auth-provider";
|
||||||
|
import { ToastProvider } from "@/providers/toast-provider";
|
||||||
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
|
import { MswInitializer } from "@/components/msw-initializer";
|
||||||
|
import { WebVitalsInitializer } from "@/components/web-vitals-initializer";
|
||||||
|
|
||||||
|
function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--color-ink-muted)" }}>加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [mswReady, setMswReady] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GraphQLProvider>
|
||||||
|
<WebVitalsInitializer />
|
||||||
|
<MswInitializer onReady={() => setMswReady(true)} />
|
||||||
|
{mswReady && (
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
<AdminShell>{children}</AdminShell>
|
||||||
|
</AuthGuard>
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
)}
|
||||||
|
</GraphQLProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { OrganizationTree } from "@/components/organization-tree";
|
||||||
|
import { PageHeader, PaperCard } from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { OrganizationNode } from "@/types/view-models";
|
||||||
|
|
||||||
|
export default function OrganizationPage(): ReactNode {
|
||||||
|
const [selected, setSelected] = useState<OrganizationNode | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.organization.title")}
|
||||||
|
description="学校 → 年级 → 班级 三级组织架构"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginBottom: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
组织树
|
||||||
|
</h3>
|
||||||
|
<OrganizationTree onSelect={setSelected} />
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4" style={{ width: 320 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginBottom: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
节点详情
|
||||||
|
</h3>
|
||||||
|
{selected ? (
|
||||||
|
<dl style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||||
|
名称
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.name}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||||
|
类型
|
||||||
|
</dt>
|
||||||
|
<dd style={{ margin: 0 }}>
|
||||||
|
{selected.type === "school"
|
||||||
|
? "学校"
|
||||||
|
: selected.type === "grade"
|
||||||
|
? "年级"
|
||||||
|
: "班级"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||||
|
ID
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.id}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||||
|
子节点数
|
||||||
|
</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{selected.childrenCount}</dd>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||||
|
排序
|
||||||
|
</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{selected.sortOrder}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-ink-muted)" }}>
|
||||||
|
选择左侧节点查看详情
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function PermissionsPage(): ReactNode {
|
||||||
|
const { data: permissions, loading, error } = usePermissions();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [resourceFilter, setResourceFilter] = useState("all");
|
||||||
|
|
||||||
|
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||||
|
|
||||||
|
const filtered = permissions.filter((p) => {
|
||||||
|
if (resourceFilter !== "all" && p.resource !== resourceFilter) return false;
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
p.code.toLowerCase().includes(q) ||
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.description.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.permissions.title")}
|
||||||
|
description="查看所有权限点定义"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={resourceFilter}
|
||||||
|
onChange={(e) => setResourceFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("admin.common.all")}</option>
|
||||||
|
{resources.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
t("admin.permissions.code"),
|
||||||
|
t("admin.permissions.resource"),
|
||||||
|
t("admin.permissions.action"),
|
||||||
|
"名称",
|
||||||
|
"说明",
|
||||||
|
"系统",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{filtered.map((perm) => (
|
||||||
|
<TableRow key={perm.id}>
|
||||||
|
<TableCell
|
||||||
|
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{perm.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{perm.resource}</TableCell>
|
||||||
|
<TableCell>{perm.action}</TableCell>
|
||||||
|
<TableCell>{perm.name}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{perm.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{perm.isSystem ? <Badge status="active" /> : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import {
|
||||||
|
useRoles,
|
||||||
|
useCreateRole,
|
||||||
|
useUpdateRolePermissions,
|
||||||
|
} from "@/hooks/use-roles";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Button,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
Badge,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { RolePermissionMatrix } from "@/components/role-permission-matrix";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { RoleViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
export default function RolesPage(): ReactNode {
|
||||||
|
const { data: roles, loading, error } = useRoles();
|
||||||
|
const { data: permissions } = usePermissions();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [createRole] = useCreateRole();
|
||||||
|
const [updateRolePermissions] = useUpdateRolePermissions();
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newRoleName, setNewRoleName] = useState("");
|
||||||
|
const [newRoleCode, setNewRoleCode] = useState("");
|
||||||
|
const [newRoleDescription, setNewRoleDescription] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const selectedRole: RoleViewModel | null = selectedRoleId
|
||||||
|
? (roles.find((r) => r.id === selectedRoleId) ?? null)
|
||||||
|
: (roles[0] ?? null);
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
if (!newRoleName || !newRoleCode) {
|
||||||
|
show("warning", "请填写角色名称和代码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await createRole({
|
||||||
|
name: newRoleName,
|
||||||
|
code: newRoleCode,
|
||||||
|
description: newRoleDescription,
|
||||||
|
dataScope: "SCHOOL",
|
||||||
|
});
|
||||||
|
show("success", "角色已创建");
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewRoleName("");
|
||||||
|
setNewRoleCode("");
|
||||||
|
setNewRoleDescription("");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"创建失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePermissions = async (permissionCodes: string[]) => {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
try {
|
||||||
|
await updateRolePermissions(selectedRole.id, permissionCodes);
|
||||||
|
show("success", "权限已更新");
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"更新失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.roles.title")}
|
||||||
|
description="管理角色、配置权限矩阵"
|
||||||
|
actions={
|
||||||
|
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
|
||||||
|
{t("admin.roles.new")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 左侧:角色列表 */}
|
||||||
|
<PaperCard className="p-4" style={{ width: 280, flexShrink: 0 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginBottom: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
角色列表
|
||||||
|
</h3>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<li key={role.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRoleId(role.id)}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background:
|
||||||
|
selectedRole?.id === role.id
|
||||||
|
? "var(--color-accent-light)"
|
||||||
|
: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderLeft:
|
||||||
|
selectedRole?.id === role.id
|
||||||
|
? "2px solid var(--color-accent)"
|
||||||
|
: "2px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500 }}>{role.name}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{role.code}</span>
|
||||||
|
{role.isSystem && <Badge status="active" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
{/* 右侧:权限矩阵 */}
|
||||||
|
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||||
|
{selectedRole && permissions.length > 0 ? (
|
||||||
|
<RolePermissionMatrix
|
||||||
|
role={selectedRole}
|
||||||
|
permissions={permissions}
|
||||||
|
onSave={handleSavePermissions}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LoadingState />
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建角色对话框 */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 400,
|
||||||
|
padding: 24,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.roles.new")}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
角色名称
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleName}
|
||||||
|
onChange={(e) => setNewRoleName(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
角色代码
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleCode}
|
||||||
|
onChange={(e) => setNewRoleCode(e.target.value)}
|
||||||
|
placeholder="如 grade_leader"
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
描述
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={newRoleDescription}
|
||||||
|
onChange={(e) => setNewRoleDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
>
|
||||||
|
{t("admin.common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreateRole}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating
|
||||||
|
? t("admin.common.loading")
|
||||||
|
: t("admin.common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import { useStudents, useStudentFilter } from "@/hooks/use-students";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Badge,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Pagination,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function StudentsPage(): ReactNode {
|
||||||
|
const { filter, setPage, setSearch, setStatus } = useStudentFilter();
|
||||||
|
const { data, loading, error } = useStudents(filter);
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, setSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.students.title")}
|
||||||
|
description="全校学生一览"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filter.status ?? "all"}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("admin.common.all")}</option>
|
||||||
|
<option value="active">{t("admin.common.active")}</option>
|
||||||
|
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
t("admin.common.email"),
|
||||||
|
t("admin.common.name"),
|
||||||
|
t("admin.common.class"),
|
||||||
|
t("admin.common.grade"),
|
||||||
|
t("admin.common.school"),
|
||||||
|
t("admin.students.guardian"),
|
||||||
|
t("admin.students.guardianPhone"),
|
||||||
|
t("admin.common.status"),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{data.items.map((student) => (
|
||||||
|
<TableRow key={student.id}>
|
||||||
|
<TableCell>{student.email}</TableCell>
|
||||||
|
<TableCell>{student.name}</TableCell>
|
||||||
|
<TableCell>{student.className}</TableCell>
|
||||||
|
<TableCell>{student.gradeName}</TableCell>
|
||||||
|
<TableCell>{student.schoolName}</TableCell>
|
||||||
|
<TableCell>{student.guardianName ?? "—"}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{student.guardianPhone ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge status={student.status} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
hasNext={data.hasNext}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
214
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect, type FormEvent } from "react";
|
||||||
|
import {
|
||||||
|
useSystemSettings,
|
||||||
|
useUpdateSystemSettings,
|
||||||
|
} from "@/hooks/use-system-settings";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { SystemSettingsViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
export default function SystemPage(): ReactNode {
|
||||||
|
const { data, loading, error } = useSystemSettings();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [updateSettings] = useUpdateSystemSettings();
|
||||||
|
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !form) {
|
||||||
|
setForm(data);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateSettings(form);
|
||||||
|
show("success", t("admin.system.saveSuccess"));
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"保存失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (field: keyof SystemSettingsViewModel, value: string) => {
|
||||||
|
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.system.title")}
|
||||||
|
description="学校基本信息、学期与本地化设置"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-6">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{form && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.schoolName")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.schoolName}
|
||||||
|
onChange={(e) => update("schoolName", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.schoolCode")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.schoolCode}
|
||||||
|
onChange={(e) => update("schoolCode", e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ fontFamily: "var(--font-mono)" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.academicYear")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.academicYear}
|
||||||
|
onChange={(e) => update("academicYear", e.target.value)}
|
||||||
|
placeholder="如 2025-2026"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.semester")}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={form.semester}
|
||||||
|
onChange={(e) => update("semester", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="first">第一学期</option>
|
||||||
|
<option value="second">第二学期</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.contactEmail")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={form.contactEmail}
|
||||||
|
onChange={(e) => update("contactEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.contactPhone")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
value={form.contactPhone}
|
||||||
|
onChange={(e) => update("contactPhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
gridColumn: "span 2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.address")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => update("address", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.timezone")}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={form.timezone}
|
||||||
|
onChange={(e) => update("timezone", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||||
|
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.system.locale")}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={form.locale}
|
||||||
|
onChange={(e) => update("locale", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import { useTeachers, useTeacherFilter } from "@/hooks/use-teachers";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Badge,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Pagination,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function TeachersPage(): ReactNode {
|
||||||
|
const { filter, setPage, setSearch, setStatus } = useTeacherFilter();
|
||||||
|
const { data, loading, error } = useTeachers(filter);
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, setSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.teachers.title")}
|
||||||
|
description="全校教师一览"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filter.status ?? "all"}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("admin.common.all")}</option>
|
||||||
|
<option value="active">{t("admin.common.active")}</option>
|
||||||
|
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||||
|
<option value="locked">{t("admin.common.locked")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
t("admin.common.email"),
|
||||||
|
t("admin.common.name"),
|
||||||
|
t("admin.common.school"),
|
||||||
|
t("admin.teachers.subjects"),
|
||||||
|
t("admin.teachers.classCount"),
|
||||||
|
t("admin.common.status"),
|
||||||
|
t("admin.common.lastLoginAt"),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{data.items.map((teacher) => (
|
||||||
|
<TableRow key={teacher.id}>
|
||||||
|
<TableCell>{teacher.email}</TableCell>
|
||||||
|
<TableCell>{teacher.name}</TableCell>
|
||||||
|
<TableCell>{teacher.schoolName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{teacher.subjects.join(", ") || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{teacher.classCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge status={teacher.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{teacher.lastLoginAt
|
||||||
|
? new Date(teacher.lastLoginAt).toLocaleDateString(
|
||||||
|
"zh-CN",
|
||||||
|
)
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
hasNext={data.hasNext}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useUsers,
|
||||||
|
useUserFilter,
|
||||||
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
|
useToggleUserStatus,
|
||||||
|
} from "@/hooks/use-users";
|
||||||
|
import { useRoles } from "@/hooks/use-roles";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Pagination,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { UserManagementTable } from "@/components/user-management-table";
|
||||||
|
import { UserFormModal, type UserFormData } from "@/components/user-form-modal";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { UserViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
export default function UsersPage(): ReactNode {
|
||||||
|
const { filter, setPage, setSearch, setStatus } = useUserFilter();
|
||||||
|
const { data, loading, error } = useUsers(filter);
|
||||||
|
const { data: roles } = useRoles();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [createUser] = useCreateUser();
|
||||||
|
const [updateUser] = useUpdateUser();
|
||||||
|
const [toggleStatus] = useToggleUserStatus();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserViewModel | null>(null);
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, setSearch]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (user: UserViewModel) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (user: UserViewModel) => {
|
||||||
|
const newStatus = user.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await toggleStatus(user.id, newStatus);
|
||||||
|
show("success", "状态已更新");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"操作失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: UserFormData) => {
|
||||||
|
try {
|
||||||
|
if (editingUser) {
|
||||||
|
await updateUser(editingUser.id, {
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
roleIds: formData.roleIds,
|
||||||
|
dataScope: formData.dataScope,
|
||||||
|
});
|
||||||
|
show("success", "用户已更新");
|
||||||
|
} else {
|
||||||
|
await createUser({
|
||||||
|
email: formData.email,
|
||||||
|
name: formData.name,
|
||||||
|
password: formData.password ?? "",
|
||||||
|
roleIds: formData.roleIds,
|
||||||
|
dataScope: formData.dataScope,
|
||||||
|
});
|
||||||
|
show("success", "用户已创建");
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"操作失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.users.title")}
|
||||||
|
description="管理所有用户账号、角色分配与状态"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.common.search")}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filter.status ?? "all"}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("admin.common.all")}</option>
|
||||||
|
<option value="active">{t("admin.common.active")}</option>
|
||||||
|
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||||
|
<option value="locked">{t("admin.common.locked")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserManagementTable
|
||||||
|
users={data.items}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
hasNext={data.hasNext}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
|
||||||
|
<UserFormModal
|
||||||
|
open={modalOpen}
|
||||||
|
user={editingUser}
|
||||||
|
roles={roles}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ViewportConfigViewModel } from "@/types/view-models";
|
||||||
|
import { useViewports, useUpdateViewport } from "@/hooks/use-viewports";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PaperCard,
|
||||||
|
Button,
|
||||||
|
LoadingState,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface SortableViewportItemProps {
|
||||||
|
viewport: ViewportConfigViewModel;
|
||||||
|
onToggleVisible: (id: string, isVisible: boolean) => void;
|
||||||
|
onLabelChange: (id: string, label: string) => void;
|
||||||
|
onPermissionChange: (id: string, perm: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableViewportItem({
|
||||||
|
viewport,
|
||||||
|
onToggleVisible,
|
||||||
|
onLabelChange,
|
||||||
|
onPermissionChange,
|
||||||
|
}: SortableViewportItemProps): ReactNode {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: viewport.id });
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid var(--color-rule)",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label="拖拽排序"
|
||||||
|
style={{
|
||||||
|
cursor: "grab",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋮⋮
|
||||||
|
</button>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{viewport.key}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={viewport.label}
|
||||||
|
onChange={(e) => onLabelChange(viewport.id, e.target.value)}
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={viewport.requiredPermission ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPermissionChange(viewport.id, e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="无需权限"
|
||||||
|
style={{ fontSize: 12, fontFamily: "var(--font-mono)" }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-ink-muted)" }}>
|
||||||
|
{viewport.scope}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={viewport.isVisible}
|
||||||
|
onChange={(e) => onToggleVisible(viewport.id, e.target.checked)}
|
||||||
|
aria-label="是否可见"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewportsPage(): ReactNode {
|
||||||
|
const { data: viewports, loading, error } = useViewports();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [updateViewport] = useUpdateViewport();
|
||||||
|
const [localViewports, setLocalViewports] = useState<
|
||||||
|
ViewportConfigViewModel[] | null
|
||||||
|
>(null);
|
||||||
|
const [scopeFilter, setScopeFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
// 同步远端数据
|
||||||
|
if (viewports.length > 0 && localViewports === null) {
|
||||||
|
setLocalViewports(viewports);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredViewports = (localViewports ?? viewports).filter(
|
||||||
|
(v) => scopeFilter === "all" || v.scope === scopeFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
if (!localViewports) return;
|
||||||
|
const oldIndex = localViewports.findIndex((v) => v.id === active.id);
|
||||||
|
const newIndex = localViewports.findIndex((v) => v.id === over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
const reordered = arrayMove(localViewports, oldIndex, newIndex).map(
|
||||||
|
(v, i) => ({ ...v, sortOrder: i + 1 }),
|
||||||
|
);
|
||||||
|
setLocalViewports(reordered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleVisible = async (id: string, isVisible: boolean) => {
|
||||||
|
try {
|
||||||
|
await updateViewport(id, { isVisible });
|
||||||
|
show("success", "已更新");
|
||||||
|
setLocalViewports(
|
||||||
|
(prev) =>
|
||||||
|
prev?.map((v) => (v.id === id ? { ...v, isVisible } : v)) ?? null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"更新失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLabelChange = async (id: string, label: string) => {
|
||||||
|
setLocalViewports(
|
||||||
|
(prev) => prev?.map((v) => (v.id === id ? { ...v, label } : v)) ?? null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermissionChange = async (id: string, perm: string | null) => {
|
||||||
|
setLocalViewports(
|
||||||
|
(prev) =>
|
||||||
|
prev?.map((v) =>
|
||||||
|
v.id === id ? { ...v, requiredPermission: perm } : v,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOrder = async () => {
|
||||||
|
if (!localViewports) return;
|
||||||
|
try {
|
||||||
|
for (const vp of localViewports) {
|
||||||
|
await updateViewport(vp.id, {
|
||||||
|
sortOrder: vp.sortOrder,
|
||||||
|
label: vp.label,
|
||||||
|
requiredPermission: vp.requiredPermission,
|
||||||
|
isVisible: vp.isVisible,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
show("success", "全部已保存");
|
||||||
|
} catch (err) {
|
||||||
|
show(
|
||||||
|
"error",
|
||||||
|
"保存失败",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t("admin.viewports.title")}
|
||||||
|
description="配置各端视口的路由、权限与排序"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={scopeFilter}
|
||||||
|
onChange={(e) => setScopeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("admin.common.all")}</option>
|
||||||
|
<option value="teacher">教师端</option>
|
||||||
|
<option value="student">学生端</option>
|
||||||
|
<option value="parent">家长端</option>
|
||||||
|
<option value="admin">管理端</option>
|
||||||
|
</Select>
|
||||||
|
<Button variant="primary" onClick={handleSaveOrder}>
|
||||||
|
{t("admin.common.save")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaperCard className="p-4">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && <ErrorState message={error.message} />}
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{filteredViewports.length === 0 ? (
|
||||||
|
<EmptyState message={t("admin.common.empty")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderBottom: "2px solid var(--color-rule)",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||||
|
gap: 12,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<span>{t("admin.viewports.key")}</span>
|
||||||
|
<span>{t("admin.viewports.label")}</span>
|
||||||
|
<span>{t("admin.viewports.requiredPermission")}</span>
|
||||||
|
<span>{t("admin.viewports.scope")}</span>
|
||||||
|
<span>{t("admin.viewports.visible")}</span>
|
||||||
|
</div>
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={filteredViewports.map((v) => v.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{filteredViewports.map((vp) => (
|
||||||
|
<SortableViewportItem
|
||||||
|
key={vp.id}
|
||||||
|
viewport={vp}
|
||||||
|
onToggleVisible={handleToggleVisible}
|
||||||
|
onLabelChange={handleLabelChange}
|
||||||
|
onPermissionChange={handlePermissionChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "0 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.viewports.dragHint")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PaperCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/admin-portal/src/app/api/health/route.ts
Normal file
17
apps/admin-portal/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 健康检查 - Liveness
|
||||||
|
*
|
||||||
|
* GET /api/health
|
||||||
|
* 返回 200 表示进程存活
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET(): NextResponse {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
service: "admin-portal",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/admin-portal/src/app/api/ready/route.ts
Normal file
47
apps/admin-portal/src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 就绪检查 - Readiness
|
||||||
|
*
|
||||||
|
* GET /api/ready
|
||||||
|
* 检查依赖服务可达性(开发期直接返回 200)
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
// 开发期:MSW 启用时直接就绪
|
||||||
|
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ready",
|
||||||
|
service: "admin-portal",
|
||||||
|
dependencies: { gateway: "mocked" },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产:检查 api-gateway 可达性
|
||||||
|
const gatewayUrl = process.env.API_GATEWAY_URL ?? "http://localhost:8080";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/healthz`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`gateway status ${res.status}`);
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ready",
|
||||||
|
service: "admin-portal",
|
||||||
|
dependencies: { gateway: "ok" },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "not_ready",
|
||||||
|
service: "admin-portal",
|
||||||
|
dependencies: { gateway: "unreachable" },
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/admin-portal/src/app/globals.css
Normal file
73
apps/admin-portal/src/app/globals.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Primitive 设计令牌(Layer 1)— 白名单文件,定义原始色板 */
|
||||||
|
:root {
|
||||||
|
--bg-paper: hsl(40, 20%, 98%);
|
||||||
|
--color-ink: hsl(25, 3%, 15%);
|
||||||
|
--color-ink-muted: hsl(30, 5%, 45%);
|
||||||
|
--color-accent: hsl(220, 60%, 35%);
|
||||||
|
--color-accent-light: hsl(220, 60%, 95%);
|
||||||
|
--color-rule: hsl(30, 10%, 90%);
|
||||||
|
--color-danger: hsl(0, 70%, 45%);
|
||||||
|
--color-danger-light: hsl(0, 70%, 95%);
|
||||||
|
--color-success: hsl(140, 50%, 35%);
|
||||||
|
--color-success-light: hsl(140, 50%, 95%);
|
||||||
|
--color-warning: hsl(35, 80%, 45%);
|
||||||
|
--color-warning-light: hsl(35, 80%, 95%);
|
||||||
|
--font-serif: 'Fraunces', Georgia, serif;
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-paper);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 纸感分隔线 */
|
||||||
|
.rule {
|
||||||
|
border-top: 1px solid var(--color-rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-thin {
|
||||||
|
border-top: 2px solid var(--color-rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧竖线标记 */
|
||||||
|
.mark-left {
|
||||||
|
border-left: 2px solid var(--color-rule);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 焦点样式(A11y WCAG 2.2 AA)*/
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跳过导航链接(A11y)*/
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: var(--bg-paper);
|
||||||
|
padding: 8px 16px;
|
||||||
|
z-index: 100;
|
||||||
|
transition: top 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
27
apps/admin-portal/src/app/layout.tsx
Normal file
27
apps/admin-portal/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
|
const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces" });
|
||||||
|
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Edu Admin Portal",
|
||||||
|
description: "K12 智慧教务平台 - 管理端",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="zh-CN"
|
||||||
|
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||||
|
>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/admin-portal/src/app/login/page.tsx
Normal file
154
apps/admin-portal/src/app/login/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录页(standalone 模式 mock 登录)
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-002 §2.3:MF 模式复用 Shell /login
|
||||||
|
* 开发期 standalone 模式:自建 mock 登录页(MSW 拦截 /api/auth/login)
|
||||||
|
*
|
||||||
|
* Mock 凭据:admin@edu.test / admin123
|
||||||
|
*/
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [email, setEmail] = useState("admin@edu.test");
|
||||||
|
const [password, setPassword] = useState("admin123");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
show("success", "登录成功");
|
||||||
|
router.replace("/admin/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "登录失败";
|
||||||
|
show("error", "登录失败", msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 360,
|
||||||
|
padding: 32,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edu 管理端
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.auth.welcome")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||||
|
>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.common.email")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.users.password")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--bg-paper)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? t("admin.common.loading") : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mock: admin@edu.test / admin123
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/admin-portal/src/app/page.tsx
Normal file
5
apps/admin-portal/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
176
apps/admin-portal/src/components/admin-shell.tsx
Normal file
176
apps/admin-portal/src/components/admin-shell.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import { ROUTE_PERMISSIONS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
route: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ route: "/admin/dashboard", label: t("admin.nav.dashboard") },
|
||||||
|
{ route: "/admin/users", label: t("admin.nav.users") },
|
||||||
|
{ route: "/admin/roles", label: t("admin.nav.roles") },
|
||||||
|
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
||||||
|
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
||||||
|
{ route: "/admin/organization", label: t("admin.nav.organization") },
|
||||||
|
{ route: "/admin/classes", label: t("admin.nav.classes") },
|
||||||
|
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
||||||
|
{ route: "/admin/students", label: t("admin.nav.students") },
|
||||||
|
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
|
||||||
|
{ route: "/admin/system", label: t("admin.nav.system") },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminShell({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, isLoading, isAuthenticated, logout, hasPermission } = useAuth();
|
||||||
|
const { show } = useToast();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--color-ink-muted)" }}>
|
||||||
|
{t("admin.common.loading")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||||
|
const perm = ROUTE_PERMISSIONS[item.route];
|
||||||
|
if (!perm) return true;
|
||||||
|
return hasPermission(perm);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
show("info", t("admin.auth.logout"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex"
|
||||||
|
style={{ background: "var(--bg-paper)" }}
|
||||||
|
>
|
||||||
|
{/* 跳过导航链接(A11y WCAG 2.2 AA)*/}
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
跳到主内容
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* 左侧栏:导航树 */}
|
||||||
|
<aside
|
||||||
|
className="w-56 flex-shrink-0 border-r relative flex flex-col"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-rule)",
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
}}
|
||||||
|
aria-label="管理端导航"
|
||||||
|
>
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<h1
|
||||||
|
className="text-xl"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edu
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
{t("admin.auth.welcome")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rule-thin mx-6" />
|
||||||
|
|
||||||
|
<nav className="mt-4 px-3 flex-1 overflow-y-auto" aria-label="主导航">
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
|
{visibleNavItems.map((item) => {
|
||||||
|
const active =
|
||||||
|
pathname === item.route ||
|
||||||
|
pathname.startsWith(item.route + "/");
|
||||||
|
return (
|
||||||
|
<li key={item.route}>
|
||||||
|
<Link
|
||||||
|
href={item.route}
|
||||||
|
className="block px-3 py-2 text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
color: active
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "var(--color-ink)",
|
||||||
|
borderLeft: active
|
||||||
|
? "2px solid var(--color-accent)"
|
||||||
|
: "2px solid transparent",
|
||||||
|
fontFamily: active
|
||||||
|
? "var(--font-serif)"
|
||||||
|
: "var(--font-sans)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 底部:用户信息 + 登出 */}
|
||||||
|
<div
|
||||||
|
className="px-6 py-4 border-t"
|
||||||
|
style={{ borderColor: "var(--color-rule)" }}
|
||||||
|
>
|
||||||
|
{user && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
{user.roles.join(", ") || "无角色"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.auth.logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 中间:内容区(纸面) */}
|
||||||
|
<main id="main-content" className="flex-1 overflow-auto" role="main">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSW 初始化组件
|
||||||
|
*
|
||||||
|
* NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
|
||||||
|
* 上游就绪后设为 disabled,使用真实 API
|
||||||
|
*/
|
||||||
|
export function MswInitializer({ onReady }: { onReady: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||||
|
import("@/mocks/browser")
|
||||||
|
.then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" }))
|
||||||
|
.then(onReady)
|
||||||
|
.catch(() => onReady());
|
||||||
|
} else {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import { Badge } from "./ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { WsNotification } from "@/types/view-models";
|
||||||
|
|
||||||
|
const severityColor: Record<WsNotification["severity"], string> = {
|
||||||
|
info: "var(--color-accent)",
|
||||||
|
warning: "var(--color-warning)",
|
||||||
|
error: "var(--color-danger)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabel: Record<WsNotification["type"], string> = {
|
||||||
|
audit_alert: t("admin.notification.auditAlert"),
|
||||||
|
abnormal_login: t("admin.notification.abnormalLogin"),
|
||||||
|
system_error: t("admin.notification.systemError"),
|
||||||
|
info: "系统通知",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationPanel(): ReactNode {
|
||||||
|
const { notifications, connected, dismiss, clear } = useWebSocket(20);
|
||||||
|
const { show } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
width: 320,
|
||||||
|
maxHeight: "60vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 16px rgba(0,0,0,0.08)",
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
role="region"
|
||||||
|
aria-label="通知中心"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderBottom: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("admin.notification.title")}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Badge status={connected ? "healthy" : "down"} />
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clear}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
padding: "24px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
暂无通知
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{notifications.map((notif) => (
|
||||||
|
<li
|
||||||
|
key={notif.id}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderBottom: "1px solid var(--color-rule)",
|
||||||
|
borderLeft: `3px solid ${severityColor[notif.severity]}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
show(
|
||||||
|
notif.severity === "error"
|
||||||
|
? "error"
|
||||||
|
: notif.severity === "warning"
|
||||||
|
? "warning"
|
||||||
|
: "info",
|
||||||
|
notif.title,
|
||||||
|
notif.message,
|
||||||
|
);
|
||||||
|
dismiss(notif.id);
|
||||||
|
}}
|
||||||
|
aria-label={`${typeLabel[notif.type]}: ${notif.message}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeLabel[notif.type]}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: "var(--color-ink-muted)" }}>
|
||||||
|
{new Date(notif.timestamp).toLocaleTimeString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
margin: "4px 0 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notif.message}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import { useOrganization } from "@/hooks/use-organization";
|
||||||
|
import type { OrganizationNode } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface OrganizationTreeProps {
|
||||||
|
onSelect?: (node: OrganizationNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: OrganizationNode;
|
||||||
|
level: number;
|
||||||
|
onSelect?: (node: OrganizationNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel: Record<OrganizationNode["type"], string> = {
|
||||||
|
school: "学校",
|
||||||
|
grade: "年级",
|
||||||
|
class: "班级",
|
||||||
|
};
|
||||||
|
|
||||||
|
function TreeNode({ node, level, onSelect }: TreeNodeProps): ReactNode {
|
||||||
|
const [expanded, setExpanded] = useState(level < 2);
|
||||||
|
const [children, setChildren] = useState<OrganizationNode[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [selected, setSelected] = useState(false);
|
||||||
|
const { data, loading } = useOrganization(expanded ? node.id : undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded && !loaded && data.length > 0) {
|
||||||
|
setChildren(data);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, [expanded, loaded, data]);
|
||||||
|
|
||||||
|
const hasChildren = node.childrenCount > 0;
|
||||||
|
void loading;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelected(true);
|
||||||
|
onSelect?.(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
padding: "6px 12px",
|
||||||
|
paddingLeft: 12 + level * 16,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 13,
|
||||||
|
background: selected ? "var(--color-accent-light)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderBottom: "1px solid var(--color-rule)",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(true);
|
||||||
|
onSelect?.(node);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={hasChildren ? expanded : undefined}
|
||||||
|
aria-selected={selected}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<span
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((v) => !v);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
padding: 0,
|
||||||
|
marginRight: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((v) => !v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={expanded ? "折叠" : "展开"}
|
||||||
|
>
|
||||||
|
{expanded ? "▼" : "▶"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ display: "inline-block", width: 14 }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
node.type === "school" ? "var(--font-serif)" : "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeLabel[node.type]}
|
||||||
|
</span>
|
||||||
|
{hasChildren && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({node.childrenCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && hasChildren && (
|
||||||
|
<div role="group">
|
||||||
|
{children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationTree({
|
||||||
|
onSelect,
|
||||||
|
}: OrganizationTreeProps): ReactNode {
|
||||||
|
const { data: roots, loading, error } = useOrganization(null);
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||||
|
>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, color: "var(--color-danger)", fontSize: 13 }}>
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (roots.length === 0)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="tree" aria-label="组织架构树">
|
||||||
|
{roots.map((node) => (
|
||||||
|
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useEffect } from "react";
|
||||||
|
import type { RoleViewModel, PermissionViewModel } from "@/types/view-models";
|
||||||
|
import { Table, TableRow, TableCell, Button, Badge } from "./ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface RolePermissionMatrixProps {
|
||||||
|
role: RoleViewModel;
|
||||||
|
permissions: PermissionViewModel[];
|
||||||
|
onSave: (permissionCodes: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolePermissionMatrix({
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
onSave,
|
||||||
|
}: RolePermissionMatrixProps): ReactNode {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
new Set(role.permissions.map((p) => p.code)),
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(new Set(role.permissions.map((p) => p.code)));
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
|
const toggle = (code: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(code)) {
|
||||||
|
next.delete(code);
|
||||||
|
} else {
|
||||||
|
next.add(code);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleResource = (resource: string) => {
|
||||||
|
const resourcePerms = permissions.filter((p) => p.resource === resource);
|
||||||
|
const allSelected = resourcePerms.every((p) => selected.has(p.code));
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (allSelected) {
|
||||||
|
resourcePerms.forEach((p) => next.delete(p.code));
|
||||||
|
} else {
|
||||||
|
resourcePerms.forEach((p) => next.add(p.code));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(Array.from(selected));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按资源分组
|
||||||
|
const resourceGroups = permissions.reduce<
|
||||||
|
Record<string, PermissionViewModel[]>
|
||||||
|
>((acc, p) => {
|
||||||
|
const arr = acc[p.resource] ?? (acc[p.resource] = []);
|
||||||
|
arr.push(p);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
{role.isSystem && <Badge status="active" />}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-ink-muted)" }}>
|
||||||
|
{role.code} · {t("admin.roles.userCount")}: {role.userCount} ·{" "}
|
||||||
|
{role.dataScope}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table headers={["资源", "权限点", "操作", "说明", "授权"]}>
|
||||||
|
{Object.entries(resourceGroups).map(([resource, perms]) =>
|
||||||
|
perms.map((perm, idx) => (
|
||||||
|
<TableRow key={perm.id}>
|
||||||
|
{idx === 0 && (
|
||||||
|
<TableCell
|
||||||
|
rowSpan={perms.length}
|
||||||
|
style={{ verticalAlign: "top", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 6 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={perms.every((p) => selected.has(p.code))}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el)
|
||||||
|
el.indeterminate =
|
||||||
|
perms.some((p) => selected.has(p.code)) &&
|
||||||
|
!perms.every((p) => selected.has(p.code));
|
||||||
|
}}
|
||||||
|
onChange={() => toggleResource(resource)}
|
||||||
|
/>
|
||||||
|
{resource}
|
||||||
|
</label>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell
|
||||||
|
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{perm.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{perm.action}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{perm.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(perm.code)}
|
||||||
|
onChange={() => toggle(perm.code)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
apps/admin-portal/src/components/ui.tsx
Normal file
375
apps/admin-portal/src/components/ui.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* 通用 UI 组件(纸面风格)
|
||||||
|
*
|
||||||
|
* 复用 @edu/ui-components 中的基础组件,扩展 admin 专用组件
|
||||||
|
*/
|
||||||
|
import { type ReactNode, type ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
/** 纸面卡片 */
|
||||||
|
export function PaperCard({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white border rounded-lg ${className}`}
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-rule)",
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面标题 */
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start justify-between mb-6 pb-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 24,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按钮 */
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonStyles: Record<ButtonVariant, Record<string, string>> = {
|
||||||
|
primary: {
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--bg-paper)",
|
||||||
|
border: "1px solid var(--color-accent)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
background: "var(--color-danger)",
|
||||||
|
color: "var(--bg-paper)",
|
||||||
|
border: "1px solid var(--color-danger)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "secondary",
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: ButtonProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
...buttonStyles[variant],
|
||||||
|
padding: "6px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: props.disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: props.disabled ? 0.5 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入框 */
|
||||||
|
export function Input({
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: React.InputHTMLAttributes<HTMLInputElement>): ReactNode {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 13,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择框 */
|
||||||
|
export function Select({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.SelectHTMLAttributes<HTMLSelectElement>): ReactNode {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
fontSize: 13,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标签(状态徽章) */
|
||||||
|
export function Badge({ status }: { status: string }): ReactNode {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
active: "var(--color-success)",
|
||||||
|
disabled: "var(--color-ink-muted)",
|
||||||
|
locked: "var(--color-danger)",
|
||||||
|
healthy: "var(--color-success)",
|
||||||
|
degraded: "var(--color-warning)",
|
||||||
|
down: "var(--color-danger)",
|
||||||
|
};
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
active: "启用",
|
||||||
|
disabled: "禁用",
|
||||||
|
locked: "锁定",
|
||||||
|
healthy: "健康",
|
||||||
|
degraded: "降级",
|
||||||
|
down: "异常",
|
||||||
|
};
|
||||||
|
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: `${color}20`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labelMap[status] ?? status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格 */
|
||||||
|
export function Table({
|
||||||
|
headers,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
headers: string[];
|
||||||
|
children: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
className="w-full"
|
||||||
|
style={{ borderCollapse: "collapse", fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "2px solid var(--color-rule)" }}>
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 12px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
whiteSpace: i === 0 ? "nowrap" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格行 */
|
||||||
|
export function TableRow({ children }: { children: ReactNode }): ReactNode {
|
||||||
|
return (
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>{children}</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格单元格 */
|
||||||
|
export function TableCell({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
rowSpan,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
rowSpan?: number;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
style={{ padding: "10px 12px", color: "var(--color-ink)", ...style }}
|
||||||
|
rowSpan={rowSpan}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空状态 */
|
||||||
|
export function EmptyState({ message }: { message: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "48px 16px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14 }}>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载状态 */
|
||||||
|
export function LoadingState(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "48px 16px",
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14 }}>加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误状态 */
|
||||||
|
export function ErrorState({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "48px 16px",
|
||||||
|
color: "var(--color-danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14, marginBottom: 8 }}>{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="secondary" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页 */
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
hasNext,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between mt-4"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
共 {total} 条,第 {page}/{totalPages || 1} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, type FormEvent } from "react";
|
||||||
|
import type { UserViewModel, RoleViewModel } from "@/types/view-models";
|
||||||
|
import { Button, Input, Select } from "./ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface UserFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
user: UserViewModel | null;
|
||||||
|
roles: RoleViewModel[];
|
||||||
|
onSubmit: (data: UserFormData) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFormData {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password?: string;
|
||||||
|
roleIds: string[];
|
||||||
|
dataScope: string;
|
||||||
|
organizationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserFormModal({
|
||||||
|
open,
|
||||||
|
user,
|
||||||
|
roles,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: UserFormModalProps): ReactNode {
|
||||||
|
const [email, setEmail] = useState(user?.email ?? "");
|
||||||
|
const [name, setName] = useState(user?.name ?? "");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [roleIds, setRoleIds] = useState<string[]>(
|
||||||
|
user?.roles.map((r) => r.id) ?? [],
|
||||||
|
);
|
||||||
|
const [dataScope, setDataScope] = useState<string>(
|
||||||
|
user?.dataScope ?? "SCHOOL",
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const isEdit = user !== null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
password: isEdit ? undefined : password,
|
||||||
|
roleIds,
|
||||||
|
dataScope,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRole = (id: string) => {
|
||||||
|
setRoleIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 440,
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: 24,
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEdit ? t("admin.users.edit") : t("admin.users.new")}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||||
|
>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.common.email")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.common.name")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{!isEdit && (
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.users.password")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.common.dataScope")}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={dataScope}
|
||||||
|
onChange={(e) => setDataScope(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="ALL">ALL - 全局</option>
|
||||||
|
<option value="SCHOOL">SCHOOL - 学校</option>
|
||||||
|
<option value="GRADE">GRADE - 年级</option>
|
||||||
|
<option value="CLASS">CLASS - 班级</option>
|
||||||
|
<option value="SUBJECT">SUBJECT - 学科</option>
|
||||||
|
<option value="SELF">SELF - 自己</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<fieldset
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||||
|
{t("admin.users.roles")}
|
||||||
|
</legend>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<label
|
||||||
|
key={role.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={roleIds.includes(role.id)}
|
||||||
|
onChange={() => toggleRole(role.id)}
|
||||||
|
/>
|
||||||
|
<span>{role.name}</span>
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--color-ink-muted)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
({role.code})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel}>
|
||||||
|
{t("admin.common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" disabled={loading}>
|
||||||
|
{loading ? t("admin.common.loading") : t("admin.common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import type { UserViewModel } from "@/types/view-models";
|
||||||
|
import { Badge, Button, Table, TableRow, TableCell } from "./ui";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface UserManagementTableProps {
|
||||||
|
users: UserViewModel[];
|
||||||
|
onEdit: (user: UserViewModel) => void;
|
||||||
|
onToggleStatus: (user: UserViewModel) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagementTable({
|
||||||
|
users,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
onCreate,
|
||||||
|
}: UserManagementTableProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<Button variant="primary" onClick={onCreate}>
|
||||||
|
{t("admin.users.new")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
headers={[
|
||||||
|
"邮箱",
|
||||||
|
"姓名",
|
||||||
|
"角色",
|
||||||
|
"状态",
|
||||||
|
"数据范围",
|
||||||
|
"学校",
|
||||||
|
"最后登录",
|
||||||
|
"操作",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>{user.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.roles.map((r) => r.name).join(", ") || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge status={user.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.dataScope}</TableCell>
|
||||||
|
<TableCell>{user.schoolName ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.lastLoginAt
|
||||||
|
? new Date(user.lastLoginAt).toLocaleDateString("zh-CN")
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => onEdit(user)}>
|
||||||
|
{t("admin.common.edit")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => onToggleStatus(user)}>
|
||||||
|
{t("admin.users.toggleStatus")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Vitals 采集初始化组件
|
||||||
|
*
|
||||||
|
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||||
|
* 在客户端挂载时初始化 LCP/CLS/FCP/INP/TTFB 采集
|
||||||
|
*/
|
||||||
|
export function WebVitalsInitializer(): null {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
|
void import("@/lib/web-vitals").then(({ initWebVitals }) =>
|
||||||
|
initWebVitals(),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 审计日志 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:auditLogs
|
||||||
|
* 仲裁 ARB-005:经 teacher-bff GraphQL 消费(不直连 Kafka)
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { AUDIT_LOGS_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type {
|
||||||
|
AuditLogViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AuditLogsResponse {
|
||||||
|
auditLogs: PaginatedResult<AuditLogViewModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogFilter extends Partial<ListFilter> {
|
||||||
|
action?: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
startDate?: number;
|
||||||
|
endDate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLogs(filter: AuditLogFilter) {
|
||||||
|
const result = useGraphQuery<AuditLogsResponse>(AUDIT_LOGS_QUERY, { filter });
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.auditLogs ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLogFilter(initial?: Partial<AuditLogFilter>) {
|
||||||
|
const [filter, setFilter] = useState<AuditLogFilter>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
...initial,
|
||||||
|
});
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setSearch = useCallback(
|
||||||
|
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setAction = useCallback(
|
||||||
|
(action: string) =>
|
||||||
|
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { filter, setFilter, setPage, setSearch, setAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计日志导出 CSV */
|
||||||
|
export function exportAuditLogsCsv(logs: AuditLogViewModel[]): void {
|
||||||
|
const headers = [
|
||||||
|
"时间",
|
||||||
|
"操作人",
|
||||||
|
"操作",
|
||||||
|
"资源类型",
|
||||||
|
"资源ID",
|
||||||
|
"IP",
|
||||||
|
"追踪ID",
|
||||||
|
];
|
||||||
|
const rows = logs.map((l) => [
|
||||||
|
new Date(l.occurredAt).toLocaleString("zh-CN"),
|
||||||
|
l.actorName,
|
||||||
|
l.action,
|
||||||
|
l.resourceType,
|
||||||
|
l.resourceId,
|
||||||
|
l.ip,
|
||||||
|
l.traceId ?? "",
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((row) =>
|
||||||
|
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 班级管理 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminClasses
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_CLASSES_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type {
|
||||||
|
AdminClassViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminClassesResponse {
|
||||||
|
adminClasses: PaginatedResult<AdminClassViewModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClasses(filter: Partial<ListFilter>) {
|
||||||
|
const result = useGraphQuery<AdminClassesResponse>(ADMIN_CLASSES_QUERY, {
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminClasses ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClassFilter(initial?: Partial<ListFilter>) {
|
||||||
|
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
...initial,
|
||||||
|
});
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setSearch = useCallback(
|
||||||
|
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { filter, setFilter, setPage, setSearch };
|
||||||
|
}
|
||||||
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 仪表盘 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminDashboard
|
||||||
|
*/
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_DASHBOARD_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type { AdminDashboardViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminDashboardResponse {
|
||||||
|
adminDashboard: AdminDashboardViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const result = useGraphQuery<AdminDashboardResponse>(ADMIN_DASHBOARD_QUERY);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminDashboard ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 通用 GraphQL hooks
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-001:admin 命名空间 GraphQL(urql)
|
||||||
|
*
|
||||||
|
* 注意:urql 的 useQuery/useMutation 的泛型推断与纯字符串 query 兼容性有限,
|
||||||
|
* 这里使用类型断言从 unknown 转换(符合项目规则:从 unknown 转换允许 as 断言)
|
||||||
|
*/
|
||||||
|
import { useQuery, useMutation } from "urql";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { AnyVariables, OperationResult } from "@urql/core";
|
||||||
|
|
||||||
|
export interface QueryResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 Query Hook */
|
||||||
|
export function useGraphQuery<
|
||||||
|
T = unknown,
|
||||||
|
V extends AnyVariables = AnyVariables,
|
||||||
|
>(query: string, variables?: V): QueryResult<T> {
|
||||||
|
const [result] = useQuery({
|
||||||
|
query,
|
||||||
|
variables: variables ?? ({} as V),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: (result.data as T | undefined) ?? null,
|
||||||
|
loading: result.fetching,
|
||||||
|
error: result.error ? new Error(result.error.message) : null,
|
||||||
|
refetch: () => {
|
||||||
|
void result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 Mutation Hook */
|
||||||
|
export function useGraphMutation<
|
||||||
|
T = unknown,
|
||||||
|
V extends AnyVariables = AnyVariables,
|
||||||
|
>(
|
||||||
|
mutation: string,
|
||||||
|
): [(variables: V) => Promise<MutationResult<T>>, MutationResult<T>] {
|
||||||
|
// urql 的 useMutation 返回 [executeFn, state]
|
||||||
|
// 使用 unknown 转换处理类型(项目规则允许从 unknown 转换)
|
||||||
|
const [executeMutation] = useMutation(mutation) as unknown as [
|
||||||
|
(variables: V) => { toPromise: () => Promise<OperationResult<T, V>> },
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
const [state, setState] = useState<MutationResult<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (variables: V): Promise<MutationResult<T>> => {
|
||||||
|
setState({ data: null, loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const res = await executeMutation(variables).toPromise();
|
||||||
|
if (res.error) {
|
||||||
|
const errState: MutationResult<T> = {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: new Error(res.error.message),
|
||||||
|
};
|
||||||
|
setState(errState);
|
||||||
|
return errState;
|
||||||
|
}
|
||||||
|
const okState: MutationResult<T> = {
|
||||||
|
data: res.data ?? null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
setState(okState);
|
||||||
|
return okState;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
const errState: MutationResult<T> = {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: err,
|
||||||
|
};
|
||||||
|
setState(errState);
|
||||||
|
return errState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[executeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [run, state];
|
||||||
|
}
|
||||||
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 组织管理 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminOrganization
|
||||||
|
*/
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_ORGANIZATION_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type { OrganizationNode } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminOrganizationResponse {
|
||||||
|
adminOrganization: OrganizationNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrganization(parentId: string | null | undefined) {
|
||||||
|
const variables = parentId === undefined ? {} : { parentId };
|
||||||
|
const result = useGraphQuery<AdminOrganizationResponse>(
|
||||||
|
ADMIN_ORGANIZATION_QUERY,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminOrganization ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 权限点 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminPermissions
|
||||||
|
*/
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_PERMISSIONS_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type { PermissionViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminPermissionsResponse {
|
||||||
|
adminPermissions: PermissionViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const result = useGraphQuery<AdminPermissionsResponse>(
|
||||||
|
ADMIN_PERMISSIONS_QUERY,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminPermissions ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 角色权限 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminRoles / createRole / updateRolePermissions
|
||||||
|
*/
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||||
|
import {
|
||||||
|
ADMIN_ROLES_QUERY,
|
||||||
|
CREATE_ROLE_MUTATION,
|
||||||
|
UPDATE_ROLE_PERMISSIONS_MUTATION,
|
||||||
|
} from "@/lib/graphql-client";
|
||||||
|
import type { RoleViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminRolesResponse {
|
||||||
|
adminRoles: RoleViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateRoleResponse {
|
||||||
|
createRole: { id: string; name: string; code: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateRolePermissionsResponse {
|
||||||
|
updateRolePermissions: {
|
||||||
|
id: string;
|
||||||
|
permissions: { id: string; code: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateRoleInput {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
dataScope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
const result = useGraphQuery<AdminRolesResponse>(ADMIN_ROLES_QUERY);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminRoles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRole() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
CreateRoleResponse,
|
||||||
|
{ input: CreateRoleInput }
|
||||||
|
>(CREATE_ROLE_MUTATION);
|
||||||
|
const create = useCallback(
|
||||||
|
async (input: CreateRoleInput) => {
|
||||||
|
const res = await run({ input });
|
||||||
|
return res.data?.createRole ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [create, state] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRolePermissions() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UpdateRolePermissionsResponse,
|
||||||
|
{ roleId: string; permissionCodes: string[] }
|
||||||
|
>(UPDATE_ROLE_PERMISSIONS_MUTATION);
|
||||||
|
const update = useCallback(
|
||||||
|
async (roleId: string, permissionCodes: string[]) => {
|
||||||
|
const res = await run({ roleId, permissionCodes });
|
||||||
|
return res.data?.updateRolePermissions ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [update, state] as const;
|
||||||
|
}
|
||||||
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 学生管理 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminStudents
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_STUDENTS_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type {
|
||||||
|
AdminStudentViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminStudentsResponse {
|
||||||
|
adminStudents: PaginatedResult<AdminStudentViewModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStudents(filter: Partial<ListFilter>) {
|
||||||
|
const result = useGraphQuery<AdminStudentsResponse>(ADMIN_STUDENTS_QUERY, {
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminStudents ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStudentFilter(initial?: Partial<ListFilter>) {
|
||||||
|
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
...initial,
|
||||||
|
});
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setSearch = useCallback(
|
||||||
|
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setStatus = useCallback(
|
||||||
|
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||||
|
}
|
||||||
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 系统设置 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:systemSettings / updateSystemSettings
|
||||||
|
*/
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||||
|
import {
|
||||||
|
SYSTEM_SETTINGS_QUERY,
|
||||||
|
UPDATE_SYSTEM_SETTINGS_MUTATION,
|
||||||
|
} from "@/lib/graphql-client";
|
||||||
|
import type { SystemSettingsViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface SystemSettingsResponse {
|
||||||
|
systemSettings: SystemSettingsViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateSystemSettingsResponse {
|
||||||
|
updateSystemSettings: {
|
||||||
|
schoolName: string;
|
||||||
|
schoolCode: string;
|
||||||
|
academicYear: string;
|
||||||
|
semester: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemSettings() {
|
||||||
|
const result = useGraphQuery<SystemSettingsResponse>(SYSTEM_SETTINGS_QUERY);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.systemSettings ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSystemSettings() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UpdateSystemSettingsResponse,
|
||||||
|
{ input: Partial<SystemSettingsViewModel> }
|
||||||
|
>(UPDATE_SYSTEM_SETTINGS_MUTATION);
|
||||||
|
const update = useCallback(
|
||||||
|
async (input: Partial<SystemSettingsViewModel>) => {
|
||||||
|
const res = await run({ input });
|
||||||
|
return res.data?.updateSystemSettings ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [update, state] as const;
|
||||||
|
}
|
||||||
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 教师管理 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminTeachers
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGraphQuery } from "./use-graphql";
|
||||||
|
import { ADMIN_TEACHERS_QUERY } from "@/lib/graphql-client";
|
||||||
|
import type {
|
||||||
|
AdminTeacherViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminTeachersResponse {
|
||||||
|
adminTeachers: PaginatedResult<AdminTeacherViewModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeachers(filter: Partial<ListFilter>) {
|
||||||
|
const result = useGraphQuery<AdminTeachersResponse>(ADMIN_TEACHERS_QUERY, {
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminTeachers ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeacherFilter(initial?: Partial<ListFilter>) {
|
||||||
|
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
...initial,
|
||||||
|
});
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setSearch = useCallback(
|
||||||
|
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setStatus = useCallback(
|
||||||
|
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||||
|
}
|
||||||
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 用户管理 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminUsers / adminUser / createUser / updateUser / toggleUserStatus
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||||
|
import {
|
||||||
|
ADMIN_USERS_QUERY,
|
||||||
|
ADMIN_USER_QUERY,
|
||||||
|
CREATE_USER_MUTATION,
|
||||||
|
UPDATE_USER_MUTATION,
|
||||||
|
TOGGLE_USER_STATUS_MUTATION,
|
||||||
|
} from "@/lib/graphql-client";
|
||||||
|
import type {
|
||||||
|
UserViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminUsersResponse {
|
||||||
|
adminUsers: PaginatedResult<UserViewModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUserResponse {
|
||||||
|
adminUser: UserViewModel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMutationResponse {
|
||||||
|
createUser?: { id: string; email: string; name: string; status: string };
|
||||||
|
updateUser?: { id: string; email: string; name: string; status: string };
|
||||||
|
toggleUserStatus?: { id: string; status: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserInput {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
roleIds?: string[];
|
||||||
|
dataScope?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateUserInput {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
roleIds?: string[];
|
||||||
|
dataScope?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers(filter: Partial<ListFilter>) {
|
||||||
|
const result = useGraphQuery<AdminUsersResponse>(ADMIN_USERS_QUERY, {
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminUsers ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(id: string | null) {
|
||||||
|
const result = useGraphQuery<AdminUserResponse>(
|
||||||
|
ADMIN_USER_QUERY,
|
||||||
|
id ? { id } : undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminUser ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UserMutationResponse,
|
||||||
|
{ input: CreateUserInput }
|
||||||
|
>(CREATE_USER_MUTATION);
|
||||||
|
const create = useCallback(
|
||||||
|
async (input: CreateUserInput) => {
|
||||||
|
const res = await run({ input });
|
||||||
|
return res.data?.createUser ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [create, state] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UserMutationResponse,
|
||||||
|
{ id: string; input: UpdateUserInput }
|
||||||
|
>(UPDATE_USER_MUTATION);
|
||||||
|
const update = useCallback(
|
||||||
|
async (id: string, input: UpdateUserInput) => {
|
||||||
|
const res = await run({ id, input });
|
||||||
|
return res.data?.updateUser ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [update, state] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleUserStatus() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UserMutationResponse,
|
||||||
|
{ id: string; status: string }
|
||||||
|
>(TOGGLE_USER_STATUS_MUTATION);
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (id: string, status: string) => {
|
||||||
|
const res = await run({ id, status });
|
||||||
|
return res.data?.toggleUserStatus ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [toggle, state] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地筛选状态管理 */
|
||||||
|
export function useUserFilter(initial?: Partial<ListFilter>) {
|
||||||
|
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
...initial,
|
||||||
|
});
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setSearch = useCallback(
|
||||||
|
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const setStatus = useCallback(
|
||||||
|
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||||
|
}
|
||||||
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 视口配置 Hooks
|
||||||
|
*
|
||||||
|
* contract §2.4:adminViewports / updateViewport
|
||||||
|
*/
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||||
|
import {
|
||||||
|
ADMIN_VIEWPORTS_QUERY,
|
||||||
|
UPDATE_VIEWPORT_MUTATION,
|
||||||
|
} from "@/lib/graphql-client";
|
||||||
|
import type { ViewportConfigViewModel } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AdminViewportsResponse {
|
||||||
|
adminViewports: ViewportConfigViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateViewportResponse {
|
||||||
|
updateViewport: {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateViewportInput {
|
||||||
|
label?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isVisible?: boolean;
|
||||||
|
requiredPermission?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewports(scope?: string) {
|
||||||
|
const result = useGraphQuery<AdminViewportsResponse>(
|
||||||
|
ADMIN_VIEWPORTS_QUERY,
|
||||||
|
scope ? { scope } : undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data?.adminViewports ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateViewport() {
|
||||||
|
const [run, state] = useGraphMutation<
|
||||||
|
UpdateViewportResponse,
|
||||||
|
{ id: string; input: UpdateViewportInput }
|
||||||
|
>(UPDATE_VIEWPORT_MUTATION);
|
||||||
|
const update = useCallback(
|
||||||
|
async (id: string, input: UpdateViewportInput) => {
|
||||||
|
const res = await run({ id, input });
|
||||||
|
return res.data?.updateViewport ?? null;
|
||||||
|
},
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
return [update, state] as const;
|
||||||
|
}
|
||||||
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal file
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Hooks(实时通知)
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-006:接入 push-gateway GET /ws
|
||||||
|
* 开发期:mock-socket 模拟 push-gateway
|
||||||
|
*
|
||||||
|
* 通知类型(contract §3):
|
||||||
|
* - audit_alert:审计告警
|
||||||
|
* - abnormal_login:异常登录
|
||||||
|
* - system_error:系统异常
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import type { WsNotification } from "@/types/view-models";
|
||||||
|
|
||||||
|
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8081/ws";
|
||||||
|
|
||||||
|
/** 通知消息工厂 */
|
||||||
|
function createMockNotification(): WsNotification {
|
||||||
|
const types: WsNotification["type"][] = [
|
||||||
|
"audit_alert",
|
||||||
|
"abnormal_login",
|
||||||
|
"system_error",
|
||||||
|
"info",
|
||||||
|
];
|
||||||
|
const type = types[Math.floor(Math.random() * types.length)] ?? "info";
|
||||||
|
const now = Date.now();
|
||||||
|
const map: Record<
|
||||||
|
WsNotification["type"],
|
||||||
|
{ title: string; message: string; severity: WsNotification["severity"] }
|
||||||
|
> = {
|
||||||
|
audit_alert: {
|
||||||
|
title: "审计告警",
|
||||||
|
message: "检测到批量删除操作,请及时确认",
|
||||||
|
severity: "warning",
|
||||||
|
},
|
||||||
|
abnormal_login: {
|
||||||
|
title: "异常登录",
|
||||||
|
message: "检测到来自异常 IP 的登录尝试,已自动拦截",
|
||||||
|
severity: "error",
|
||||||
|
},
|
||||||
|
system_error: {
|
||||||
|
title: "系统异常",
|
||||||
|
message: "msg 服务响应超时(>280ms)",
|
||||||
|
severity: "error",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
title: "系统通知",
|
||||||
|
message: "数据备份已完成",
|
||||||
|
severity: "info",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const payload = map[type];
|
||||||
|
return {
|
||||||
|
id: `n-${now}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
type,
|
||||||
|
title: payload.title,
|
||||||
|
message: payload.message,
|
||||||
|
severity: payload.severity,
|
||||||
|
timestamp: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(maxNotifications = 50) {
|
||||||
|
const [notifications, setNotifications] = useState<WsNotification[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const mockTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 开发期 mock:MSW 不拦截 WebSocket,用 mock-socket 或定时推送
|
||||||
|
const isMock = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
|
||||||
|
|
||||||
|
if (isMock) {
|
||||||
|
// Mock 模式:定时推送通知
|
||||||
|
setConnected(true);
|
||||||
|
mockTimerRef.current = setInterval(() => {
|
||||||
|
const notif = createMockNotification();
|
||||||
|
setNotifications((prev) => [notif, ...prev].slice(0, maxNotifications));
|
||||||
|
}, 30000); // 每 30 秒推送一条
|
||||||
|
|
||||||
|
// 立即推送一条
|
||||||
|
const initial = createMockNotification();
|
||||||
|
setNotifications([initial]);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mockTimerRef.current) clearInterval(mockTimerRef.current);
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实模式:连接 push-gateway
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.onopen = () => setConnected(true);
|
||||||
|
ws.onclose = () => setConnected(false);
|
||||||
|
ws.onerror = () => setConnected(false);
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string) as WsNotification;
|
||||||
|
setNotifications((prev) =>
|
||||||
|
[data, ...prev].slice(0, maxNotifications),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 忽略非 JSON 消息
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
ws.close();
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [maxNotifications]);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => setNotifications([]), []);
|
||||||
|
|
||||||
|
return { notifications, connected, dismiss, clear };
|
||||||
|
}
|
||||||
79
apps/admin-portal/src/lib/auth.ts
Normal file
79
apps/admin-portal/src/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 认证工具:token 存储 + 路由保护
|
||||||
|
*
|
||||||
|
* 仲裁:ARB-002 复用 Shell /login,admin-portal 不自行实现登录页
|
||||||
|
* 开发期:standalone 壳提供 mock 登录(MSW 拦截 /api/auth/login)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = "edu_access_token";
|
||||||
|
const USER_KEY = "edu_user_info";
|
||||||
|
|
||||||
|
import type { CurrentUser } from "@/types/view-models";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser(): CurrentUser | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const raw = localStorage.getItem(USER_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as CurrentUser;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUser(user: CurrentUser): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return getToken() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用登录 API(MSW 拦截 / api-gateway 代理到 iam) */
|
||||||
|
export async function login(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ user: CurrentUser; token: string }> {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
data?: { user: CurrentUser; tokens: { accessToken: string } };
|
||||||
|
error?: { message: string };
|
||||||
|
};
|
||||||
|
if (!json.success || !json.data) {
|
||||||
|
throw new Error(json.error?.message || "登录失败");
|
||||||
|
}
|
||||||
|
const user = json.data.user;
|
||||||
|
const token = json.data.tokens.accessToken;
|
||||||
|
setToken(token);
|
||||||
|
setUser(user);
|
||||||
|
return { user, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
clearToken();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
333
apps/admin-portal/src/lib/graphql-client.ts
Normal file
333
apps/admin-portal/src/lib/graphql-client.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* GraphQL client + operations
|
||||||
|
*
|
||||||
|
* 仲裁:ARB-001 admin 命名空间 GraphQL + ARB-002 复用 Shell GraphQL client 单例
|
||||||
|
* 端点:POST /api/admin/graphql(经 api-gateway 代理到 teacher-bff admin 命名空间)
|
||||||
|
*/
|
||||||
|
import { createClient, fetchExchange, type Client } from "@urql/core";
|
||||||
|
|
||||||
|
const GRAPHQL_ENDPOINT = "/api/admin/graphql";
|
||||||
|
|
||||||
|
/** 创建 urql GraphQL client(standalone 模式自建,MF 模式复用 Shell 单例) */
|
||||||
|
export function createGraphQLClient(): Client {
|
||||||
|
return createClient({
|
||||||
|
url: GRAPHQL_ENDPOINT,
|
||||||
|
exchanges: [fetchExchange],
|
||||||
|
fetchOptions: () => {
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? localStorage.getItem("edu_access_token")
|
||||||
|
: null;
|
||||||
|
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GraphQL Operations(contract §2.4) ───────────────────────────
|
||||||
|
|
||||||
|
export const CURRENT_USER_QUERY = `
|
||||||
|
query CurrentUser {
|
||||||
|
currentUser {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
roles
|
||||||
|
permissions
|
||||||
|
dataScope
|
||||||
|
schoolId
|
||||||
|
schoolName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_USERS_QUERY = `
|
||||||
|
query AdminUsers($filter: AdminUserFilterInput) {
|
||||||
|
adminUsers(filter: $filter) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
roles { id name code }
|
||||||
|
status
|
||||||
|
dataScope
|
||||||
|
organizationId
|
||||||
|
schoolName
|
||||||
|
lastLoginAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
pageSize
|
||||||
|
hasNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_USER_QUERY = `
|
||||||
|
query AdminUser($id: ID!) {
|
||||||
|
adminUser(id: $id) {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
roles { id name code }
|
||||||
|
status
|
||||||
|
dataScope
|
||||||
|
organizationId
|
||||||
|
schoolName
|
||||||
|
lastLoginAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const CREATE_USER_MUTATION = `
|
||||||
|
mutation CreateUser($input: CreateUserInput!) {
|
||||||
|
createUser(input: $input) {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const UPDATE_USER_MUTATION = `
|
||||||
|
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
|
||||||
|
updateUser(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const TOGGLE_USER_STATUS_MUTATION = `
|
||||||
|
mutation ToggleUserStatus($id: ID!, $status: String!) {
|
||||||
|
toggleUserStatus(id: $id, status: $status) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_ROLES_QUERY = `
|
||||||
|
query AdminRoles {
|
||||||
|
adminRoles {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
code
|
||||||
|
description
|
||||||
|
permissions { id code name resource action }
|
||||||
|
userCount
|
||||||
|
dataScope
|
||||||
|
isSystem
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const CREATE_ROLE_MUTATION = `
|
||||||
|
mutation CreateRole($input: CreateRoleInput!) {
|
||||||
|
createRole(input: $input) { id name code }
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const UPDATE_ROLE_PERMISSIONS_MUTATION = `
|
||||||
|
mutation UpdateRolePermissions($roleId: ID!, $permissionCodes: [String!]!) {
|
||||||
|
updateRolePermissions(roleId: $roleId, permissionCodes: $permissionCodes) {
|
||||||
|
id
|
||||||
|
permissions { id code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_PERMISSIONS_QUERY = `
|
||||||
|
query AdminPermissions {
|
||||||
|
adminPermissions {
|
||||||
|
id
|
||||||
|
code
|
||||||
|
name
|
||||||
|
description
|
||||||
|
resource
|
||||||
|
action
|
||||||
|
isSystem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_VIEWPORTS_QUERY = `
|
||||||
|
query AdminViewports($scope: String) {
|
||||||
|
adminViewports(scope: $scope) {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
label
|
||||||
|
route
|
||||||
|
icon
|
||||||
|
sortOrder
|
||||||
|
requiredPermission
|
||||||
|
scope
|
||||||
|
isVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const UPDATE_VIEWPORT_MUTATION = `
|
||||||
|
mutation UpdateViewport($id: ID!, $input: UpdateViewportInput!) {
|
||||||
|
updateViewport(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
label
|
||||||
|
sortOrder
|
||||||
|
isVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_ORGANIZATION_QUERY = `
|
||||||
|
query AdminOrganization($parentId: ID) {
|
||||||
|
adminOrganization(parentId: $parentId) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
type
|
||||||
|
parentId
|
||||||
|
childrenCount
|
||||||
|
sortOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_CLASSES_QUERY = `
|
||||||
|
query AdminClasses($filter: AdminClassFilterInput) {
|
||||||
|
adminClasses(filter: $filter) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
gradeName
|
||||||
|
schoolName
|
||||||
|
headTeacherName
|
||||||
|
studentCount
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
pageSize
|
||||||
|
hasNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_TEACHERS_QUERY = `
|
||||||
|
query AdminTeachers($filter: AdminTeacherFilterInput) {
|
||||||
|
adminTeachers(filter: $filter) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
schoolName
|
||||||
|
subjects
|
||||||
|
classCount
|
||||||
|
status
|
||||||
|
lastLoginAt
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
pageSize
|
||||||
|
hasNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_STUDENTS_QUERY = `
|
||||||
|
query AdminStudents($filter: AdminStudentFilterInput) {
|
||||||
|
adminStudents(filter: $filter) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
className
|
||||||
|
gradeName
|
||||||
|
schoolName
|
||||||
|
guardianName
|
||||||
|
guardianPhone
|
||||||
|
status
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
pageSize
|
||||||
|
hasNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const AUDIT_LOGS_QUERY = `
|
||||||
|
query AuditLogs($filter: AuditLogFilterInput) {
|
||||||
|
auditLogs(filter: $filter) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
eventId
|
||||||
|
occurredAt
|
||||||
|
actorUserId
|
||||||
|
actorName
|
||||||
|
action
|
||||||
|
resourceType
|
||||||
|
resourceId
|
||||||
|
ip
|
||||||
|
userAgent
|
||||||
|
beforeState
|
||||||
|
afterState
|
||||||
|
traceId
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
pageSize
|
||||||
|
hasNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const ADMIN_DASHBOARD_QUERY = `
|
||||||
|
query AdminDashboard {
|
||||||
|
adminDashboard {
|
||||||
|
totalTeachers
|
||||||
|
totalStudents
|
||||||
|
totalClasses
|
||||||
|
totalSchools
|
||||||
|
schoolAvgScore
|
||||||
|
activeUsersToday
|
||||||
|
auditEventsToday
|
||||||
|
trend { date teachers students avgScore }
|
||||||
|
serviceHealth { serviceName status latencyMs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const SYSTEM_SETTINGS_QUERY = `
|
||||||
|
query SystemSettings {
|
||||||
|
systemSettings {
|
||||||
|
schoolName
|
||||||
|
schoolCode
|
||||||
|
academicYear
|
||||||
|
semester
|
||||||
|
contactEmail
|
||||||
|
contactPhone
|
||||||
|
address
|
||||||
|
timezone
|
||||||
|
locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
|
|
||||||
|
export const UPDATE_SYSTEM_SETTINGS_MUTATION = `
|
||||||
|
mutation UpdateSystemSettings($input: SystemSettingsInput!) {
|
||||||
|
updateSystemSettings(input: $input) {
|
||||||
|
schoolName
|
||||||
|
schoolCode
|
||||||
|
academicYear
|
||||||
|
semester
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` as const;
|
||||||
183
apps/admin-portal/src/lib/i18n.ts
Normal file
183
apps/admin-portal/src/lib/i18n.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 轻量 i18n(contract §1.7 命名空间:admin.* / iam.error.* / bff.error.* / gateway.error.* / network.error.*)
|
||||||
|
*
|
||||||
|
* 维护者:ai16
|
||||||
|
* 仲裁:F4 i18n key 模式 error.{{service}}.{{code_snake_case}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MessageMap = Record<string, string>;
|
||||||
|
|
||||||
|
const messages: MessageMap = {
|
||||||
|
// 导航
|
||||||
|
"admin.nav.dashboard": "管理仪表盘",
|
||||||
|
"admin.nav.users": "用户管理",
|
||||||
|
"admin.nav.roles": "角色权限",
|
||||||
|
"admin.nav.permissions": "权限点",
|
||||||
|
"admin.nav.viewports": "视口配置",
|
||||||
|
"admin.nav.organization": "组织管理",
|
||||||
|
"admin.nav.system": "学校设置",
|
||||||
|
"admin.nav.classes": "班级管理",
|
||||||
|
"admin.nav.teachers": "教师管理",
|
||||||
|
"admin.nav.students": "学生管理",
|
||||||
|
"admin.nav.auditLogs": "审计日志",
|
||||||
|
|
||||||
|
// 通用
|
||||||
|
"admin.common.search": "搜索",
|
||||||
|
"admin.common.filter": "筛选",
|
||||||
|
"admin.common.create": "新建",
|
||||||
|
"admin.common.edit": "编辑",
|
||||||
|
"admin.common.delete": "删除",
|
||||||
|
"admin.common.save": "保存",
|
||||||
|
"admin.common.cancel": "取消",
|
||||||
|
"admin.common.confirm": "确认",
|
||||||
|
"admin.common.export": "导出",
|
||||||
|
"admin.common.actions": "操作",
|
||||||
|
"admin.common.status": "状态",
|
||||||
|
"admin.common.all": "全部",
|
||||||
|
"admin.common.loading": "加载中...",
|
||||||
|
"admin.common.empty": "暂无数据",
|
||||||
|
"admin.common.error": "加载失败",
|
||||||
|
"admin.common.retry": "重试",
|
||||||
|
"admin.common.page": "页",
|
||||||
|
"admin.common.total": "共",
|
||||||
|
"admin.common.records": "条",
|
||||||
|
"admin.common.active": "启用",
|
||||||
|
"admin.common.disabled": "禁用",
|
||||||
|
"admin.common.locked": "锁定",
|
||||||
|
"admin.common.yes": "是",
|
||||||
|
"admin.common.no": "否",
|
||||||
|
"admin.common.name": "姓名",
|
||||||
|
"admin.common.email": "邮箱",
|
||||||
|
"admin.common.role": "角色",
|
||||||
|
"admin.common.createdAt": "创建时间",
|
||||||
|
"admin.common.updatedAt": "更新时间",
|
||||||
|
"admin.common.lastLoginAt": "最后登录",
|
||||||
|
"admin.common.dataScope": "数据范围",
|
||||||
|
"admin.common.school": "学校",
|
||||||
|
"admin.common.grade": "年级",
|
||||||
|
"admin.common.class": "班级",
|
||||||
|
|
||||||
|
// 仪表盘
|
||||||
|
"admin.dashboard.title": "管理仪表盘",
|
||||||
|
"admin.dashboard.totalTeachers": "教师总数",
|
||||||
|
"admin.dashboard.totalStudents": "学生总数",
|
||||||
|
"admin.dashboard.totalClasses": "班级总数",
|
||||||
|
"admin.dashboard.totalSchools": "学校总数",
|
||||||
|
"admin.dashboard.schoolAvgScore": "全校平均分",
|
||||||
|
"admin.dashboard.activeUsersToday": "今日活跃用户",
|
||||||
|
"admin.dashboard.auditEventsToday": "今日审计事件",
|
||||||
|
"admin.dashboard.trend": "趋势",
|
||||||
|
"admin.dashboard.serviceHealth": "服务健康",
|
||||||
|
|
||||||
|
// 用户管理
|
||||||
|
"admin.users.title": "用户管理",
|
||||||
|
"admin.users.new": "新建用户",
|
||||||
|
"admin.users.edit": "编辑用户",
|
||||||
|
"admin.users.deleteConfirm": "确认删除该用户?",
|
||||||
|
"admin.users.toggleStatus": "切换状态",
|
||||||
|
"admin.users.assignRole": "分配角色",
|
||||||
|
"admin.users.password": "密码",
|
||||||
|
"admin.users.roles": "角色",
|
||||||
|
"admin.users.organization": "所属组织",
|
||||||
|
|
||||||
|
// 角色权限
|
||||||
|
"admin.roles.title": "角色权限管理",
|
||||||
|
"admin.roles.new": "新建角色",
|
||||||
|
"admin.roles.permissionMatrix": "权限矩阵",
|
||||||
|
"admin.roles.systemRole": "系统预置",
|
||||||
|
"admin.roles.userCount": "用户数",
|
||||||
|
"admin.roles.deleteConfirm": "确认删除该角色?",
|
||||||
|
"admin.roles.cannotDeleteSystem": "系统预置角色不可删除",
|
||||||
|
"admin.roles.cannotDeleteWithUsers": "角色下仍有用户,不可删除",
|
||||||
|
|
||||||
|
// 权限点
|
||||||
|
"admin.permissions.title": "权限点管理",
|
||||||
|
"admin.permissions.resource": "资源",
|
||||||
|
"admin.permissions.action": "操作",
|
||||||
|
"admin.permissions.code": "权限码",
|
||||||
|
|
||||||
|
// 视口配置
|
||||||
|
"admin.viewports.title": "视口配置",
|
||||||
|
"admin.viewports.key": "标识",
|
||||||
|
"admin.viewports.label": "标签",
|
||||||
|
"admin.viewports.route": "路由",
|
||||||
|
"admin.viewports.sortOrder": "排序",
|
||||||
|
"admin.viewports.requiredPermission": "所需权限",
|
||||||
|
"admin.viewports.scope": "作用域",
|
||||||
|
"admin.viewports.visible": "是否可见",
|
||||||
|
"admin.viewports.dragHint": "拖拽调整排序",
|
||||||
|
|
||||||
|
// 组织管理
|
||||||
|
"admin.organization.title": "组织管理",
|
||||||
|
"admin.organization.school": "学校",
|
||||||
|
"admin.organization.expand": "展开",
|
||||||
|
"admin.organization.collapse": "折叠",
|
||||||
|
|
||||||
|
// 学校设置
|
||||||
|
"admin.system.title": "学校设置",
|
||||||
|
"admin.system.schoolName": "学校名称",
|
||||||
|
"admin.system.schoolCode": "学校代码",
|
||||||
|
"admin.system.academicYear": "学年",
|
||||||
|
"admin.system.semester": "学期",
|
||||||
|
"admin.system.contactEmail": "联系邮箱",
|
||||||
|
"admin.system.contactPhone": "联系电话",
|
||||||
|
"admin.system.address": "地址",
|
||||||
|
"admin.system.timezone": "时区",
|
||||||
|
"admin.system.locale": "语言",
|
||||||
|
"admin.system.saveSuccess": "设置已保存",
|
||||||
|
|
||||||
|
// 班级管理
|
||||||
|
"admin.classes.title": "班级管理",
|
||||||
|
"admin.classes.headTeacher": "班主任",
|
||||||
|
"admin.classes.studentCount": "学生数",
|
||||||
|
|
||||||
|
// 教师管理
|
||||||
|
"admin.teachers.title": "教师管理",
|
||||||
|
"admin.teachers.subjects": "任教科目",
|
||||||
|
"admin.teachers.classCount": "班级数",
|
||||||
|
|
||||||
|
// 学生管理
|
||||||
|
"admin.students.title": "学生管理",
|
||||||
|
"admin.students.guardian": "家长",
|
||||||
|
"admin.students.guardianPhone": "家长电话",
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
"admin.auditLogs.title": "审计日志",
|
||||||
|
"admin.auditLogs.actor": "操作人",
|
||||||
|
"admin.auditLogs.action": "操作",
|
||||||
|
"admin.auditLogs.resource": "资源",
|
||||||
|
"admin.auditLogs.resourceId": "资源ID",
|
||||||
|
"admin.auditLogs.ip": "IP地址",
|
||||||
|
"admin.auditLogs.time": "时间",
|
||||||
|
"admin.auditLogs.dateRange": "时间范围",
|
||||||
|
"admin.auditLogs.exportCsv": "导出CSV",
|
||||||
|
"admin.auditLogs.traceId": "追踪ID",
|
||||||
|
|
||||||
|
// 通知
|
||||||
|
"admin.notification.title": "通知中心",
|
||||||
|
"admin.notification.auditAlert": "审计告警",
|
||||||
|
"admin.notification.abnormalLogin": "异常登录",
|
||||||
|
"admin.notification.systemError": "系统异常",
|
||||||
|
|
||||||
|
// 退出
|
||||||
|
"admin.auth.logout": "退出登录",
|
||||||
|
"admin.auth.welcome": "Edu 管理端",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 翻译函数
|
||||||
|
* @param key i18n key(如 "admin.nav.dashboard")
|
||||||
|
* @param params 模板参数(如 { count: 5 },替换 {{count}})
|
||||||
|
*/
|
||||||
|
export function t(
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
let msg = messages[key] ?? key;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
msg = msg.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
78
apps/admin-portal/src/lib/permissions.ts
Normal file
78
apps/admin-portal/src/lib/permissions.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* admin-portal 权限点常量
|
||||||
|
*
|
||||||
|
* 仲裁裁决(ISSUE-046 §5.3 / ISSUE-045 §5.2):
|
||||||
|
* - admin-portal 权限点统一加 ADMIN_ 前缀
|
||||||
|
* - 跨服务资源保留原前缀(如 IAM_USER_READ)
|
||||||
|
* - 错误码前缀 ADMIN_(合并 SCHOOL_ / ORG_ 入 ADMIN_)
|
||||||
|
*
|
||||||
|
* 来源:packages/contracts/src/permissions.ts(coord 维护)
|
||||||
|
* admin-portal 引用,不硬编码
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** admin 自身资源权限点(ADMIN_ 前缀) */
|
||||||
|
export const ADMIN_PERMISSIONS = {
|
||||||
|
DASHBOARD_VIEW: "ADMIN_DASHBOARD_VIEW",
|
||||||
|
SYSTEM_MANAGE: "ADMIN_SYSTEM_MANAGE",
|
||||||
|
AUDIT_READ: "ADMIN_AUDIT_READ",
|
||||||
|
CLASS_READ: "ADMIN_CLASS_READ",
|
||||||
|
CLASS_MANAGE: "ADMIN_CLASS_MANAGE",
|
||||||
|
TEACHER_READ: "ADMIN_TEACHER_READ",
|
||||||
|
TEACHER_MANAGE: "ADMIN_TEACHER_MANAGE",
|
||||||
|
STUDENT_READ: "ADMIN_STUDENT_READ",
|
||||||
|
STUDENT_MANAGE: "ADMIN_STUDENT_MANAGE",
|
||||||
|
ORG_MANAGE: "ADMIN_ORG_MANAGE",
|
||||||
|
SCHOOL_READ: "ADMIN_SCHOOL_READ",
|
||||||
|
SCHOOL_MANAGE: "ADMIN_SCHOOL_MANAGE",
|
||||||
|
MONITORING_VIEW: "ADMIN_MONITORING_VIEW",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 跨服务资源权限点(保留原前缀,iam 域) */
|
||||||
|
export const IAM_PERMISSIONS = {
|
||||||
|
USER_READ: "IAM_USER_READ",
|
||||||
|
USER_CREATE: "IAM_USER_CREATE",
|
||||||
|
USER_UPDATE: "IAM_USER_UPDATE",
|
||||||
|
USER_DELETE: "IAM_USER_DELETE",
|
||||||
|
ROLE_READ: "IAM_ROLE_READ",
|
||||||
|
ROLE_CREATE: "IAM_ROLE_CREATE",
|
||||||
|
ROLE_UPDATE: "IAM_ROLE_UPDATE",
|
||||||
|
ROLE_DELETE: "IAM_ROLE_DELETE",
|
||||||
|
PERMISSION_READ: "IAM_PERMISSION_READ",
|
||||||
|
VIEWPORT_READ: "IAM_VIEWPORT_READ",
|
||||||
|
VIEWPORT_UPDATE: "IAM_VIEWPORT_UPDATE",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 所有权限点合集 */
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
...ADMIN_PERMISSIONS,
|
||||||
|
...IAM_PERMISSIONS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
|
|
||||||
|
/** admin-portal 视口路由权限映射(contract §1.2) */
|
||||||
|
export const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||||
|
"/admin/dashboard": ADMIN_PERMISSIONS.DASHBOARD_VIEW,
|
||||||
|
"/admin/users": IAM_PERMISSIONS.USER_READ,
|
||||||
|
"/admin/roles": IAM_PERMISSIONS.ROLE_READ,
|
||||||
|
"/admin/permissions": IAM_PERMISSIONS.PERMISSION_READ,
|
||||||
|
"/admin/viewports": IAM_PERMISSIONS.VIEWPORT_READ,
|
||||||
|
"/admin/organization": ADMIN_PERMISSIONS.ORG_MANAGE,
|
||||||
|
"/admin/system": ADMIN_PERMISSIONS.SYSTEM_MANAGE,
|
||||||
|
"/admin/classes": ADMIN_PERMISSIONS.CLASS_READ,
|
||||||
|
"/admin/teachers": ADMIN_PERMISSIONS.TEACHER_READ,
|
||||||
|
"/admin/students": ADMIN_PERMISSIONS.STUDENT_READ,
|
||||||
|
"/admin/audit-logs": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** admin-portal 错误码前缀(ISSUE-045 §5.2) */
|
||||||
|
export const ADMIN_ERROR_PREFIX = "ADMIN_";
|
||||||
|
|
||||||
|
/** 消费的错误码前缀(contract §2.5) */
|
||||||
|
export const CONSUMED_ERROR_PREFIXES = [
|
||||||
|
"IAM_",
|
||||||
|
"BFF_TEACHER_",
|
||||||
|
"GW_",
|
||||||
|
"NETWORK_",
|
||||||
|
"ADMIN_",
|
||||||
|
] as const;
|
||||||
43
apps/admin-portal/src/lib/web-vitals.ts
Normal file
43
apps/admin-portal/src/lib/web-vitals.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Web Vitals 采集(A11y + 性能)
|
||||||
|
*
|
||||||
|
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||||
|
* 端点:NEXT_PUBLIC_WEB_VITALS_ENDPOINT(默认 /api/admin/web-vitals)
|
||||||
|
*/
|
||||||
|
import { onLCP, onCLS, onFCP, onINP, onTTFB } from "web-vitals";
|
||||||
|
|
||||||
|
type Metric = { name: string; value: number; rating: string; id: string };
|
||||||
|
|
||||||
|
const ENDPOINT =
|
||||||
|
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ?? "/api/admin/web-vitals";
|
||||||
|
|
||||||
|
function sendMetric(metric: Metric): void {
|
||||||
|
if (typeof navigator === "undefined") return;
|
||||||
|
const body = JSON.stringify({
|
||||||
|
name: metric.name,
|
||||||
|
value: metric.value,
|
||||||
|
rating: metric.rating,
|
||||||
|
id: metric.id,
|
||||||
|
page: window.location.pathname,
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
// 使用 sendBeacon 避免阻塞卸载
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
navigator.sendBeacon(ENDPOINT, body);
|
||||||
|
} else {
|
||||||
|
void fetch(ENDPOINT, { method: "POST", body, keepalive: true }).catch(
|
||||||
|
() => {
|
||||||
|
// 忽略上报失败
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWebVitals(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
onLCP((m) => sendMetric(m as unknown as Metric));
|
||||||
|
onCLS((m) => sendMetric(m as unknown as Metric));
|
||||||
|
onFCP((m) => sendMetric(m as unknown as Metric));
|
||||||
|
onINP((m) => sendMetric(m as unknown as Metric));
|
||||||
|
onTTFB((m) => sendMetric(m as unknown as Metric));
|
||||||
|
}
|
||||||
10
apps/admin-portal/src/mocks/browser.ts
Normal file
10
apps/admin-portal/src/mocks/browser.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* MSW Browser Worker
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-004 §4:开发期 MSW 拦截所有 GraphQL/HTTP 请求
|
||||||
|
* 启用条件:NEXT_PUBLIC_API_MOCKING=enabled
|
||||||
|
*/
|
||||||
|
import { setupWorker } from "msw/browser";
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
export const worker = setupWorker(...handlers);
|
||||||
906
apps/admin-portal/src/mocks/fixtures.ts
Normal file
906
apps/admin-portal/src/mocks/fixtures.ts
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
/**
|
||||||
|
* Mock 数据库(开发期 fixtures)
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-004 §4:MSW 拦截所有 GraphQL/HTTP 请求返回 mock 数据
|
||||||
|
* 所有数据符合 src/types/view-models.ts 类型定义
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
UserViewModel,
|
||||||
|
RoleViewModel,
|
||||||
|
PermissionViewModel,
|
||||||
|
ViewportConfigViewModel,
|
||||||
|
OrganizationNode,
|
||||||
|
AdminClassViewModel,
|
||||||
|
AdminTeacherViewModel,
|
||||||
|
AdminStudentViewModel,
|
||||||
|
AuditLogViewModel,
|
||||||
|
AdminDashboardViewModel,
|
||||||
|
SystemSettingsViewModel,
|
||||||
|
CurrentUser,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
export const mockCurrentUser: CurrentUser = {
|
||||||
|
id: "u-admin-001",
|
||||||
|
email: "admin@edu.test",
|
||||||
|
name: "系统管理员",
|
||||||
|
roles: ["system_admin"],
|
||||||
|
permissions: [
|
||||||
|
"ADMIN_DASHBOARD_VIEW",
|
||||||
|
"ADMIN_SYSTEM_MANAGE",
|
||||||
|
"ADMIN_AUDIT_READ",
|
||||||
|
"ADMIN_CLASS_READ",
|
||||||
|
"ADMIN_CLASS_MANAGE",
|
||||||
|
"ADMIN_TEACHER_READ",
|
||||||
|
"ADMIN_TEACHER_MANAGE",
|
||||||
|
"ADMIN_STUDENT_READ",
|
||||||
|
"ADMIN_STUDENT_MANAGE",
|
||||||
|
"ADMIN_ORG_MANAGE",
|
||||||
|
"ADMIN_SCHOOL_READ",
|
||||||
|
"ADMIN_SCHOOL_MANAGE",
|
||||||
|
"ADMIN_MONITORING_VIEW",
|
||||||
|
"IAM_USER_READ",
|
||||||
|
"IAM_USER_CREATE",
|
||||||
|
"IAM_USER_UPDATE",
|
||||||
|
"IAM_USER_DELETE",
|
||||||
|
"IAM_ROLE_READ",
|
||||||
|
"IAM_ROLE_CREATE",
|
||||||
|
"IAM_ROLE_UPDATE",
|
||||||
|
"IAM_ROLE_DELETE",
|
||||||
|
"IAM_PERMISSION_READ",
|
||||||
|
"IAM_VIEWPORT_READ",
|
||||||
|
"IAM_VIEWPORT_UPDATE",
|
||||||
|
],
|
||||||
|
dataScope: "ALL",
|
||||||
|
schoolId: null,
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockPermissions: PermissionViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "p-001",
|
||||||
|
code: "ADMIN_DASHBOARD_VIEW",
|
||||||
|
name: "仪表盘查看",
|
||||||
|
description: "查看管理仪表盘",
|
||||||
|
resource: "admin.dashboard",
|
||||||
|
action: "view",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-002",
|
||||||
|
code: "ADMIN_SYSTEM_MANAGE",
|
||||||
|
name: "系统设置",
|
||||||
|
description: "管理系统设置",
|
||||||
|
resource: "admin.system",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-003",
|
||||||
|
code: "ADMIN_AUDIT_READ",
|
||||||
|
name: "审计日志查看",
|
||||||
|
description: "查看审计日志",
|
||||||
|
resource: "admin.audit",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-004",
|
||||||
|
code: "ADMIN_CLASS_MANAGE",
|
||||||
|
name: "班级管理",
|
||||||
|
description: "管理班级",
|
||||||
|
resource: "admin.class",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-005",
|
||||||
|
code: "ADMIN_CLASS_READ",
|
||||||
|
name: "班级查看",
|
||||||
|
description: "查看班级",
|
||||||
|
resource: "admin.class",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-006",
|
||||||
|
code: "ADMIN_TEACHER_MANAGE",
|
||||||
|
name: "教师管理",
|
||||||
|
description: "管理教师",
|
||||||
|
resource: "admin.teacher",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-007",
|
||||||
|
code: "ADMIN_TEACHER_READ",
|
||||||
|
name: "教师查看",
|
||||||
|
description: "查看教师",
|
||||||
|
resource: "admin.teacher",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-008",
|
||||||
|
code: "ADMIN_STUDENT_MANAGE",
|
||||||
|
name: "学生管理",
|
||||||
|
description: "管理学生",
|
||||||
|
resource: "admin.student",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-009",
|
||||||
|
code: "ADMIN_STUDENT_READ",
|
||||||
|
name: "学生查看",
|
||||||
|
description: "查看学生",
|
||||||
|
resource: "admin.student",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-010",
|
||||||
|
code: "ADMIN_ORG_MANAGE",
|
||||||
|
name: "组织管理",
|
||||||
|
description: "管理组织架构",
|
||||||
|
resource: "admin.org",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-011",
|
||||||
|
code: "ADMIN_SCHOOL_MANAGE",
|
||||||
|
name: "学校管理",
|
||||||
|
description: "管理学校",
|
||||||
|
resource: "admin.school",
|
||||||
|
action: "manage",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-012",
|
||||||
|
code: "ADMIN_SCHOOL_READ",
|
||||||
|
name: "学校查看",
|
||||||
|
description: "查看学校",
|
||||||
|
resource: "admin.school",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-013",
|
||||||
|
code: "ADMIN_MONITORING_VIEW",
|
||||||
|
name: "监控查看",
|
||||||
|
description: "查看系统监控",
|
||||||
|
resource: "admin.monitoring",
|
||||||
|
action: "view",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-101",
|
||||||
|
code: "IAM_USER_READ",
|
||||||
|
name: "用户查看",
|
||||||
|
description: "查看用户",
|
||||||
|
resource: "iam.user",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-102",
|
||||||
|
code: "IAM_USER_CREATE",
|
||||||
|
name: "用户创建",
|
||||||
|
description: "创建用户",
|
||||||
|
resource: "iam.user",
|
||||||
|
action: "create",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-103",
|
||||||
|
code: "IAM_USER_UPDATE",
|
||||||
|
name: "用户更新",
|
||||||
|
description: "更新用户",
|
||||||
|
resource: "iam.user",
|
||||||
|
action: "update",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-104",
|
||||||
|
code: "IAM_USER_DELETE",
|
||||||
|
name: "用户删除",
|
||||||
|
description: "删除用户",
|
||||||
|
resource: "iam.user",
|
||||||
|
action: "delete",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-105",
|
||||||
|
code: "IAM_ROLE_READ",
|
||||||
|
name: "角色查看",
|
||||||
|
description: "查看角色",
|
||||||
|
resource: "iam.role",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-106",
|
||||||
|
code: "IAM_ROLE_CREATE",
|
||||||
|
name: "角色创建",
|
||||||
|
description: "创建角色",
|
||||||
|
resource: "iam.role",
|
||||||
|
action: "create",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-107",
|
||||||
|
code: "IAM_ROLE_UPDATE",
|
||||||
|
name: "角色更新",
|
||||||
|
description: "更新角色",
|
||||||
|
resource: "iam.role",
|
||||||
|
action: "update",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-108",
|
||||||
|
code: "IAM_ROLE_DELETE",
|
||||||
|
name: "角色删除",
|
||||||
|
description: "删除角色",
|
||||||
|
resource: "iam.role",
|
||||||
|
action: "delete",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-109",
|
||||||
|
code: "IAM_PERMISSION_READ",
|
||||||
|
name: "权限查看",
|
||||||
|
description: "查看权限点",
|
||||||
|
resource: "iam.permission",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-110",
|
||||||
|
code: "IAM_VIEWPORT_READ",
|
||||||
|
name: "视口查看",
|
||||||
|
description: "查看视口",
|
||||||
|
resource: "iam.viewport",
|
||||||
|
action: "read",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-111",
|
||||||
|
code: "IAM_VIEWPORT_UPDATE",
|
||||||
|
name: "视口更新",
|
||||||
|
description: "更新视口",
|
||||||
|
resource: "iam.viewport",
|
||||||
|
action: "update",
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockRoles: RoleViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "r-001",
|
||||||
|
name: "系统管理员",
|
||||||
|
code: "system_admin",
|
||||||
|
description: "拥有所有权限",
|
||||||
|
permissions: mockPermissions,
|
||||||
|
userCount: 2,
|
||||||
|
dataScope: "ALL",
|
||||||
|
isSystem: true,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
updatedAt: 1706745600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-002",
|
||||||
|
name: "学校管理员",
|
||||||
|
code: "school_admin",
|
||||||
|
description: "管理本校资源",
|
||||||
|
permissions: mockPermissions.filter((p) =>
|
||||||
|
[
|
||||||
|
"ADMIN_DASHBOARD_VIEW",
|
||||||
|
"ADMIN_CLASS_MANAGE",
|
||||||
|
"ADMIN_TEACHER_READ",
|
||||||
|
"ADMIN_STUDENT_READ",
|
||||||
|
"ADMIN_ORG_MANAGE",
|
||||||
|
"ADMIN_SCHOOL_READ",
|
||||||
|
"ADMIN_AUDIT_READ",
|
||||||
|
].includes(p.code),
|
||||||
|
),
|
||||||
|
userCount: 5,
|
||||||
|
dataScope: "SCHOOL",
|
||||||
|
isSystem: true,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
updatedAt: 1706745600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-003",
|
||||||
|
name: "年级组长",
|
||||||
|
code: "grade_leader",
|
||||||
|
description: "管理本年级",
|
||||||
|
permissions: mockPermissions.filter((p) =>
|
||||||
|
[
|
||||||
|
"ADMIN_DASHBOARD_VIEW",
|
||||||
|
"ADMIN_CLASS_READ",
|
||||||
|
"ADMIN_TEACHER_READ",
|
||||||
|
"ADMIN_STUDENT_READ",
|
||||||
|
].includes(p.code),
|
||||||
|
),
|
||||||
|
userCount: 8,
|
||||||
|
dataScope: "GRADE",
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: 1704153600000,
|
||||||
|
updatedAt: 1706832000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-004",
|
||||||
|
name: "班主任",
|
||||||
|
code: "head_teacher",
|
||||||
|
description: "管理本班级",
|
||||||
|
permissions: mockPermissions.filter((p) =>
|
||||||
|
["ADMIN_DASHBOARD_VIEW", "ADMIN_STUDENT_READ"].includes(p.code),
|
||||||
|
),
|
||||||
|
userCount: 24,
|
||||||
|
dataScope: "CLASS",
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: 1704240000000,
|
||||||
|
updatedAt: 1706918400000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockUsers: UserViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "u-001",
|
||||||
|
email: "admin@edu.test",
|
||||||
|
name: "系统管理员",
|
||||||
|
roles: [mockRoles[0]!],
|
||||||
|
status: "active",
|
||||||
|
dataScope: "ALL",
|
||||||
|
organizationId: null,
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1706918400000,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
updatedAt: 1706745600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-002",
|
||||||
|
email: "principal@edu.test",
|
||||||
|
name: "张校长",
|
||||||
|
roles: [mockRoles[1]!],
|
||||||
|
status: "active",
|
||||||
|
dataScope: "SCHOOL",
|
||||||
|
organizationId: "org-school-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1706832000000,
|
||||||
|
createdAt: 1704153600000,
|
||||||
|
updatedAt: 1706832000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-003",
|
||||||
|
email: "gradeleader@edu.test",
|
||||||
|
name: "李组长",
|
||||||
|
roles: [mockRoles[2]!],
|
||||||
|
status: "active",
|
||||||
|
dataScope: "GRADE",
|
||||||
|
organizationId: "org-grade-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1706745600000,
|
||||||
|
createdAt: 1704240000000,
|
||||||
|
updatedAt: 1706745600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-004",
|
||||||
|
email: "teacher1@edu.test",
|
||||||
|
name: "王老师",
|
||||||
|
roles: [mockRoles[3]!],
|
||||||
|
status: "active",
|
||||||
|
dataScope: "CLASS",
|
||||||
|
organizationId: "org-class-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1706688000000,
|
||||||
|
createdAt: 1704326400000,
|
||||||
|
updatedAt: 1706688000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-005",
|
||||||
|
email: "teacher2@edu.test",
|
||||||
|
name: "赵老师",
|
||||||
|
roles: [mockRoles[3]!],
|
||||||
|
status: "disabled",
|
||||||
|
dataScope: "CLASS",
|
||||||
|
organizationId: "org-class-002",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1704326400000,
|
||||||
|
createdAt: 1704412800000,
|
||||||
|
updatedAt: 1706601600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-006",
|
||||||
|
email: "teacher3@edu.test",
|
||||||
|
name: "钱老师",
|
||||||
|
roles: [mockRoles[2]!, mockRoles[3]!],
|
||||||
|
status: "locked",
|
||||||
|
dataScope: "GRADE",
|
||||||
|
organizationId: "org-grade-002",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: 1706515200000,
|
||||||
|
createdAt: 1704499200000,
|
||||||
|
updatedAt: 1706515200000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockViewports: ViewportConfigViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "v-001",
|
||||||
|
key: "dashboard",
|
||||||
|
label: "工作台",
|
||||||
|
route: "/dashboard",
|
||||||
|
icon: "home",
|
||||||
|
sortOrder: 1,
|
||||||
|
requiredPermission: null,
|
||||||
|
scope: "teacher",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-002",
|
||||||
|
key: "classes",
|
||||||
|
label: "我的班级",
|
||||||
|
route: "/classes",
|
||||||
|
icon: "users",
|
||||||
|
sortOrder: 2,
|
||||||
|
requiredPermission: "TEACHER_CLASS_READ",
|
||||||
|
scope: "teacher",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-003",
|
||||||
|
key: "exams",
|
||||||
|
label: "考试管理",
|
||||||
|
route: "/exams",
|
||||||
|
icon: "file-text",
|
||||||
|
sortOrder: 3,
|
||||||
|
requiredPermission: "TEACHER_EXAM_READ",
|
||||||
|
scope: "teacher",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-004",
|
||||||
|
key: "homework",
|
||||||
|
label: "作业管理",
|
||||||
|
route: "/homework",
|
||||||
|
icon: "book",
|
||||||
|
sortOrder: 4,
|
||||||
|
requiredPermission: "TEACHER_HOMEWORK_READ",
|
||||||
|
scope: "teacher",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-005",
|
||||||
|
key: "students",
|
||||||
|
label: "学生中心",
|
||||||
|
route: "/students",
|
||||||
|
icon: "user",
|
||||||
|
sortOrder: 5,
|
||||||
|
requiredPermission: "STUDENT_READ",
|
||||||
|
scope: "student",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-006",
|
||||||
|
key: "parent",
|
||||||
|
label: "家长视口",
|
||||||
|
route: "/parent",
|
||||||
|
icon: "heart",
|
||||||
|
sortOrder: 6,
|
||||||
|
requiredPermission: "PARENT_VIEW",
|
||||||
|
scope: "parent",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-007",
|
||||||
|
key: "admin",
|
||||||
|
label: "管理端",
|
||||||
|
route: "/admin",
|
||||||
|
icon: "settings",
|
||||||
|
sortOrder: 7,
|
||||||
|
requiredPermission: "ADMIN_DASHBOARD_VIEW",
|
||||||
|
scope: "admin",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockOrganization: OrganizationNode[] = [
|
||||||
|
{
|
||||||
|
id: "org-school-001",
|
||||||
|
name: "Edu 实验学校",
|
||||||
|
type: "school",
|
||||||
|
parentId: null,
|
||||||
|
childrenCount: 3,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-grade-001",
|
||||||
|
name: "一年级",
|
||||||
|
type: "grade",
|
||||||
|
parentId: "org-school-001",
|
||||||
|
childrenCount: 4,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-grade-002",
|
||||||
|
name: "二年级",
|
||||||
|
type: "grade",
|
||||||
|
parentId: "org-school-001",
|
||||||
|
childrenCount: 4,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-grade-003",
|
||||||
|
name: "三年级",
|
||||||
|
type: "grade",
|
||||||
|
parentId: "org-school-001",
|
||||||
|
childrenCount: 3,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-001",
|
||||||
|
name: "一年级一班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-001",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-002",
|
||||||
|
name: "一年级二班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-001",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-003",
|
||||||
|
name: "一年级三班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-001",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-004",
|
||||||
|
name: "一年级四班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-001",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-005",
|
||||||
|
name: "二年级一班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-002",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-006",
|
||||||
|
name: "二年级二班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-002",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-007",
|
||||||
|
name: "二年级三班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-002",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "org-class-008",
|
||||||
|
name: "二年级四班",
|
||||||
|
type: "class",
|
||||||
|
parentId: "org-grade-002",
|
||||||
|
childrenCount: 0,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockClasses: AdminClassViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "c-001",
|
||||||
|
name: "一年级一班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "王老师",
|
||||||
|
studentCount: 36,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-002",
|
||||||
|
name: "一年级二班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "赵老师",
|
||||||
|
studentCount: 35,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-003",
|
||||||
|
name: "一年级三班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "钱老师",
|
||||||
|
studentCount: 34,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-004",
|
||||||
|
name: "二年级一班",
|
||||||
|
gradeName: "二年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "孙老师",
|
||||||
|
studentCount: 38,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-005",
|
||||||
|
name: "二年级二班",
|
||||||
|
gradeName: "二年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "周老师",
|
||||||
|
studentCount: 37,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-006",
|
||||||
|
name: "三年级一班",
|
||||||
|
gradeName: "三年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
headTeacherName: "吴老师",
|
||||||
|
studentCount: 33,
|
||||||
|
createdAt: 1704067200000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockTeachers: AdminTeacherViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "t-001",
|
||||||
|
email: "teacher1@edu.test",
|
||||||
|
name: "王老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["语文", "阅读"],
|
||||||
|
classCount: 1,
|
||||||
|
status: "active",
|
||||||
|
lastLoginAt: 1706688000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t-002",
|
||||||
|
email: "teacher2@edu.test",
|
||||||
|
name: "赵老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["数学"],
|
||||||
|
classCount: 1,
|
||||||
|
status: "disabled",
|
||||||
|
lastLoginAt: 1704326400000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t-003",
|
||||||
|
email: "teacher3@edu.test",
|
||||||
|
name: "钱老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["英语"],
|
||||||
|
classCount: 2,
|
||||||
|
status: "locked",
|
||||||
|
lastLoginAt: 1706515200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t-004",
|
||||||
|
email: "teacher4@edu.test",
|
||||||
|
name: "孙老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["物理", "科学"],
|
||||||
|
classCount: 1,
|
||||||
|
status: "active",
|
||||||
|
lastLoginAt: 1706601600000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t-005",
|
||||||
|
email: "teacher5@edu.test",
|
||||||
|
name: "周老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["化学"],
|
||||||
|
classCount: 1,
|
||||||
|
status: "active",
|
||||||
|
lastLoginAt: 1706688000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t-006",
|
||||||
|
email: "teacher6@edu.test",
|
||||||
|
name: "吴老师",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
subjects: ["生物"],
|
||||||
|
classCount: 1,
|
||||||
|
status: "active",
|
||||||
|
lastLoginAt: 1706745600000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockStudents: AdminStudentViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "s-001",
|
||||||
|
email: "student1@edu.test",
|
||||||
|
name: "小明",
|
||||||
|
className: "一年级一班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
guardianName: "大明",
|
||||||
|
guardianPhone: "13800000001",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s-002",
|
||||||
|
email: "student2@edu.test",
|
||||||
|
name: "小红",
|
||||||
|
className: "一年级一班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
guardianName: "大红",
|
||||||
|
guardianPhone: "13800000002",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s-003",
|
||||||
|
email: "student3@edu.test",
|
||||||
|
name: "小华",
|
||||||
|
className: "一年级二班",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
guardianName: "大华",
|
||||||
|
guardianPhone: "13800000003",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s-004",
|
||||||
|
email: "student4@edu.test",
|
||||||
|
name: "小芳",
|
||||||
|
className: "二年级一班",
|
||||||
|
gradeName: "二年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
guardianName: "大芳",
|
||||||
|
guardianPhone: "13800000004",
|
||||||
|
status: "disabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s-005",
|
||||||
|
email: "student5@edu.test",
|
||||||
|
name: "小军",
|
||||||
|
className: "三年级一班",
|
||||||
|
gradeName: "三年级",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
guardianName: "大军",
|
||||||
|
guardianPhone: "13800000005",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockAuditLogs: AuditLogViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "al-001",
|
||||||
|
eventId: "evt-001",
|
||||||
|
occurredAt: 1706918400000,
|
||||||
|
actorUserId: "u-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
action: "USER_LOGIN",
|
||||||
|
resourceType: "iam.user",
|
||||||
|
resourceId: "u-001",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
beforeState: null,
|
||||||
|
afterState: null,
|
||||||
|
traceId: "trace-001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al-002",
|
||||||
|
eventId: "evt-002",
|
||||||
|
occurredAt: 1706832000000,
|
||||||
|
actorUserId: "u-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
action: "USER_CREATE",
|
||||||
|
resourceType: "iam.user",
|
||||||
|
resourceId: "u-006",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
beforeState: null,
|
||||||
|
afterState: '{"email":"teacher3@edu.test","name":"钱老师"}',
|
||||||
|
traceId: "trace-002",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al-003",
|
||||||
|
eventId: "evt-003",
|
||||||
|
occurredAt: 1706745600000,
|
||||||
|
actorUserId: "u-002",
|
||||||
|
actorName: "张校长",
|
||||||
|
action: "ROLE_UPDATE",
|
||||||
|
resourceType: "iam.role",
|
||||||
|
resourceId: "r-002",
|
||||||
|
ip: "192.168.1.101",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
beforeState: '{"permissions":["ADMIN_DASHBOARD_VIEW"]}',
|
||||||
|
afterState: '{"permissions":["ADMIN_DASHBOARD_VIEW","ADMIN_CLASS_MANAGE"]}',
|
||||||
|
traceId: "trace-003",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al-004",
|
||||||
|
eventId: "evt-004",
|
||||||
|
occurredAt: 1706688000000,
|
||||||
|
actorUserId: "u-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
action: "USER_TOGGLE_STATUS",
|
||||||
|
resourceType: "iam.user",
|
||||||
|
resourceId: "u-005",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
beforeState: '{"status":"active"}',
|
||||||
|
afterState: '{"status":"disabled"}',
|
||||||
|
traceId: "trace-004",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al-005",
|
||||||
|
eventId: "evt-005",
|
||||||
|
occurredAt: 1706601600000,
|
||||||
|
actorUserId: "system",
|
||||||
|
actorName: "系统",
|
||||||
|
action: "ABNORMAL_LOGIN_BLOCKED",
|
||||||
|
resourceType: "iam.user",
|
||||||
|
resourceId: "u-006",
|
||||||
|
ip: "10.0.0.99",
|
||||||
|
userAgent: "curl/7.68",
|
||||||
|
beforeState: null,
|
||||||
|
afterState: null,
|
||||||
|
traceId: "trace-005",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockDashboard: AdminDashboardViewModel = {
|
||||||
|
totalTeachers: 6,
|
||||||
|
totalStudents: 213,
|
||||||
|
totalClasses: 6,
|
||||||
|
totalSchools: 1,
|
||||||
|
schoolAvgScore: 85.6,
|
||||||
|
activeUsersToday: 28,
|
||||||
|
auditEventsToday: 12,
|
||||||
|
trend: [
|
||||||
|
{ date: "2026-07-04", teachers: 4, students: 198, avgScore: 84.2 },
|
||||||
|
{ date: "2026-07-05", teachers: 5, students: 205, avgScore: 84.8 },
|
||||||
|
{ date: "2026-07-06", teachers: 5, students: 210, avgScore: 85.1 },
|
||||||
|
{ date: "2026-07-07", teachers: 6, students: 211, avgScore: 85.3 },
|
||||||
|
{ date: "2026-07-08", teachers: 6, students: 213, avgScore: 85.6 },
|
||||||
|
{ date: "2026-07-09", teachers: 6, students: 213, avgScore: 85.5 },
|
||||||
|
{ date: "2026-07-10", teachers: 6, students: 213, avgScore: 85.6 },
|
||||||
|
],
|
||||||
|
serviceHealth: [
|
||||||
|
{ serviceName: "api-gateway", status: "healthy", latencyMs: 12 },
|
||||||
|
{ serviceName: "iam", status: "healthy", latencyMs: 28 },
|
||||||
|
{ serviceName: "teacher-bff", status: "healthy", latencyMs: 45 },
|
||||||
|
{ serviceName: "classes", status: "healthy", latencyMs: 35 },
|
||||||
|
{ serviceName: "core-edu", status: "healthy", latencyMs: 52 },
|
||||||
|
{ serviceName: "msg", status: "degraded", latencyMs: 280 },
|
||||||
|
{ serviceName: "data-ana", status: "healthy", latencyMs: 120 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockSystemSettings: SystemSettingsViewModel = {
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
schoolCode: "EDU-001",
|
||||||
|
academicYear: "2025-2026",
|
||||||
|
semester: "second",
|
||||||
|
contactEmail: "contact@edu.test",
|
||||||
|
contactPhone: "010-88888888",
|
||||||
|
address: "北京市海淀区中关村大街 1 号",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
locale: "zh-CN",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 生成简单 ID */
|
||||||
|
export function genId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
}
|
||||||
516
apps/admin-portal/src/mocks/handlers.ts
Normal file
516
apps/admin-portal/src/mocks/handlers.ts
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* MSW Handlers
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-004 §4:MSW 拦截所有 GraphQL/HTTP 请求
|
||||||
|
* 端点:
|
||||||
|
* - POST /api/auth/login - mock 登录
|
||||||
|
* - POST /api/admin/graphql - admin 命名空间 GraphQL
|
||||||
|
* - GET /ws - WebSocket(mock-socket 在 use-websocket.ts 中处理)
|
||||||
|
*/
|
||||||
|
import { graphql, http, HttpResponse } from "msw";
|
||||||
|
import {
|
||||||
|
mockCurrentUser,
|
||||||
|
mockUsers,
|
||||||
|
mockRoles,
|
||||||
|
mockPermissions,
|
||||||
|
mockViewports,
|
||||||
|
mockOrganization,
|
||||||
|
mockClasses,
|
||||||
|
mockTeachers,
|
||||||
|
mockStudents,
|
||||||
|
mockAuditLogs,
|
||||||
|
mockDashboard,
|
||||||
|
mockSystemSettings,
|
||||||
|
genId,
|
||||||
|
} from "./fixtures";
|
||||||
|
import type {
|
||||||
|
UserViewModel,
|
||||||
|
RoleViewModel,
|
||||||
|
ViewportConfigViewModel,
|
||||||
|
PaginatedResult,
|
||||||
|
ListFilter,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
|
const BASE_URL = "";
|
||||||
|
|
||||||
|
// 内存中可变副本(支持 mock 增删改)
|
||||||
|
const db = {
|
||||||
|
users: [...mockUsers] as UserViewModel[],
|
||||||
|
roles: [...mockRoles] as RoleViewModel[],
|
||||||
|
viewports: [...mockViewports] as ViewportConfigViewModel[],
|
||||||
|
permissions: [...mockPermissions],
|
||||||
|
organization: [...mockOrganization],
|
||||||
|
classes: [...mockClasses],
|
||||||
|
teachers: [...mockTeachers],
|
||||||
|
students: [...mockStudents],
|
||||||
|
auditLogs: [...mockAuditLogs],
|
||||||
|
systemSettings: { ...mockSystemSettings },
|
||||||
|
};
|
||||||
|
|
||||||
|
function paginate<T>(
|
||||||
|
items: T[],
|
||||||
|
filter: Partial<ListFilter>,
|
||||||
|
): PaginatedResult<T> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const sliced = items.slice(start, end);
|
||||||
|
return {
|
||||||
|
items: sliced,
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
hasNext: end < items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordAudit(
|
||||||
|
action: string,
|
||||||
|
resourceType: string,
|
||||||
|
resourceId: string,
|
||||||
|
afterState?: string,
|
||||||
|
): void {
|
||||||
|
db.auditLogs.unshift({
|
||||||
|
id: genId("al"),
|
||||||
|
eventId: genId("evt"),
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
actorUserId: mockCurrentUser.id,
|
||||||
|
actorName: mockCurrentUser.name,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
userAgent: "MSW-Mock",
|
||||||
|
beforeState: null,
|
||||||
|
afterState: afterState ?? null,
|
||||||
|
traceId: genId("trace"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
// ─── 认证 ────────────────────────────────────────────
|
||||||
|
http.post(`${BASE_URL}/api/auth/login`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as { email: string; password: string };
|
||||||
|
if (body.email === "admin@edu.test" && body.password === "admin123") {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: mockCurrentUser,
|
||||||
|
tokens: { accessToken: "mock-jwt-token-admin-001" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { message: "邮箱或密码错误" },
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── GraphQL admin 命名空间 ──────────────────────────
|
||||||
|
graphql.operation(({ query, variables }) => {
|
||||||
|
const operationName =
|
||||||
|
(query.match(/^\s*(query|mutation)\s+(\w+)/) || [])[2] ?? "";
|
||||||
|
|
||||||
|
// ── currentUser ──
|
||||||
|
if (operationName === "CurrentUser") {
|
||||||
|
return HttpResponse.json({ data: { currentUser: mockCurrentUser } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminUsers ──
|
||||||
|
if (operationName === "AdminUsers") {
|
||||||
|
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
|
||||||
|
let items = [...db.users];
|
||||||
|
if (filter.search) {
|
||||||
|
const q = filter.search.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(u) =>
|
||||||
|
u.name.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.status && filter.status !== "all") {
|
||||||
|
items = items.filter((u) => u.status === filter.status);
|
||||||
|
}
|
||||||
|
const result = paginate(items, filter);
|
||||||
|
return HttpResponse.json({ data: { adminUsers: result } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminUser ──
|
||||||
|
if (operationName === "AdminUser") {
|
||||||
|
const id = variables.id as string;
|
||||||
|
const user = db.users.find((u) => u.id === id) ?? null;
|
||||||
|
return HttpResponse.json({ data: { adminUser: user } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── createUser ──
|
||||||
|
if (operationName === "CreateUser") {
|
||||||
|
const input = variables.input as {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
roleIds?: string[];
|
||||||
|
dataScope?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
};
|
||||||
|
const roles = db.roles.filter((r) => input.roleIds?.includes(r.id));
|
||||||
|
const newUser: UserViewModel = {
|
||||||
|
id: genId("u"),
|
||||||
|
email: input.email,
|
||||||
|
name: input.name,
|
||||||
|
roles,
|
||||||
|
status: "active",
|
||||||
|
dataScope: (input.dataScope as UserViewModel["dataScope"]) ?? "SCHOOL",
|
||||||
|
organizationId: input.organizationId ?? null,
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
lastLoginAt: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.users.push(newUser);
|
||||||
|
recordAudit(
|
||||||
|
"USER_CREATE",
|
||||||
|
"iam.user",
|
||||||
|
newUser.id,
|
||||||
|
JSON.stringify({ email: newUser.email, name: newUser.name }),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
createUser: {
|
||||||
|
id: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
name: newUser.name,
|
||||||
|
status: newUser.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── updateUser ──
|
||||||
|
if (operationName === "UpdateUser") {
|
||||||
|
const id = variables.id as string;
|
||||||
|
const input = variables.input as {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
roleIds?: string[];
|
||||||
|
dataScope?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
};
|
||||||
|
const user = db.users.find((u) => u.id === id);
|
||||||
|
if (!user) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ message: "用户不存在" }] },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name) user.name = input.name;
|
||||||
|
if (input.email) user.email = input.email;
|
||||||
|
if (input.roleIds)
|
||||||
|
user.roles = db.roles.filter((r) => input.roleIds!.includes(r.id));
|
||||||
|
if (input.dataScope)
|
||||||
|
user.dataScope = input.dataScope as UserViewModel["dataScope"];
|
||||||
|
if (input.organizationId !== undefined)
|
||||||
|
user.organizationId = input.organizationId;
|
||||||
|
user.updatedAt = Date.now();
|
||||||
|
recordAudit("USER_UPDATE", "iam.user", user.id, JSON.stringify(input));
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
updateUser: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
status: user.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toggleUserStatus ──
|
||||||
|
if (operationName === "ToggleUserStatus") {
|
||||||
|
const id = variables.id as string;
|
||||||
|
const newStatus = variables.status as UserViewModel["status"];
|
||||||
|
const user = db.users.find((u) => u.id === id);
|
||||||
|
if (!user) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ message: "用户不存在" }] },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const before = user.status;
|
||||||
|
user.status = newStatus;
|
||||||
|
user.updatedAt = Date.now();
|
||||||
|
recordAudit(
|
||||||
|
"USER_TOGGLE_STATUS",
|
||||||
|
"iam.user",
|
||||||
|
user.id,
|
||||||
|
JSON.stringify({ before, after: newStatus }),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { toggleUserStatus: { id: user.id, status: user.status } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminRoles ──
|
||||||
|
if (operationName === "AdminRoles") {
|
||||||
|
return HttpResponse.json({ data: { adminRoles: db.roles } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── createRole ──
|
||||||
|
if (operationName === "CreateRole") {
|
||||||
|
const input = variables.input as {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
dataScope?: string;
|
||||||
|
};
|
||||||
|
const newRole: RoleViewModel = {
|
||||||
|
id: genId("r"),
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
description: input.description ?? "",
|
||||||
|
permissions: [],
|
||||||
|
userCount: 0,
|
||||||
|
dataScope: (input.dataScope as RoleViewModel["dataScope"]) ?? "SCHOOL",
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.roles.push(newRole);
|
||||||
|
recordAudit(
|
||||||
|
"ROLE_CREATE",
|
||||||
|
"iam.role",
|
||||||
|
newRole.id,
|
||||||
|
JSON.stringify({ name: newRole.name, code: newRole.code }),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
createRole: {
|
||||||
|
id: newRole.id,
|
||||||
|
name: newRole.name,
|
||||||
|
code: newRole.code,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── updateRolePermissions ──
|
||||||
|
if (operationName === "UpdateRolePermissions") {
|
||||||
|
const roleId = variables.roleId as string;
|
||||||
|
const codes = variables.permissionCodes as string[];
|
||||||
|
const role = db.roles.find((r) => r.id === roleId);
|
||||||
|
if (!role) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ message: "角色不存在" }] },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
role.permissions = db.permissions.filter((p) => codes.includes(p.code));
|
||||||
|
role.updatedAt = Date.now();
|
||||||
|
recordAudit(
|
||||||
|
"ROLE_UPDATE",
|
||||||
|
"iam.role",
|
||||||
|
role.id,
|
||||||
|
JSON.stringify({ permissionCodes: codes }),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
updateRolePermissions: {
|
||||||
|
id: role.id,
|
||||||
|
permissions: role.permissions.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminPermissions ──
|
||||||
|
if (operationName === "AdminPermissions") {
|
||||||
|
return HttpResponse.json({ data: { adminPermissions: db.permissions } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminViewports ──
|
||||||
|
if (operationName === "AdminViewports") {
|
||||||
|
const scope = variables.scope as string | undefined;
|
||||||
|
const items = scope
|
||||||
|
? db.viewports.filter((v) => v.scope === scope)
|
||||||
|
: db.viewports;
|
||||||
|
return HttpResponse.json({ data: { adminViewports: items } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── updateViewport ──
|
||||||
|
if (operationName === "UpdateViewport") {
|
||||||
|
const id = variables.id as string;
|
||||||
|
const input = variables.input as {
|
||||||
|
label?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isVisible?: boolean;
|
||||||
|
requiredPermission?: string | null;
|
||||||
|
};
|
||||||
|
const vp = db.viewports.find((v) => v.id === id);
|
||||||
|
if (!vp) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ message: "视口不存在" }] },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.label !== undefined) vp.label = input.label;
|
||||||
|
if (input.sortOrder !== undefined) vp.sortOrder = input.sortOrder;
|
||||||
|
if (input.isVisible !== undefined) vp.isVisible = input.isVisible;
|
||||||
|
if (input.requiredPermission !== undefined)
|
||||||
|
vp.requiredPermission = input.requiredPermission;
|
||||||
|
recordAudit(
|
||||||
|
"VIEWPORT_UPDATE",
|
||||||
|
"iam.viewport",
|
||||||
|
vp.id,
|
||||||
|
JSON.stringify(input),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
updateViewport: {
|
||||||
|
id: vp.id,
|
||||||
|
key: vp.key,
|
||||||
|
label: vp.label,
|
||||||
|
sortOrder: vp.sortOrder,
|
||||||
|
isVisible: vp.isVisible,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminOrganization ──
|
||||||
|
if (operationName === "AdminOrganization") {
|
||||||
|
const parentId = variables.parentId as string | null | undefined;
|
||||||
|
const items =
|
||||||
|
parentId === undefined || parentId === null
|
||||||
|
? db.organization.filter((n) => n.parentId === null)
|
||||||
|
: db.organization.filter((n) => n.parentId === parentId);
|
||||||
|
return HttpResponse.json({ data: { adminOrganization: items } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminClasses ──
|
||||||
|
if (operationName === "AdminClasses") {
|
||||||
|
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
|
||||||
|
let items = [...db.classes];
|
||||||
|
if (filter.search) {
|
||||||
|
const q = filter.search.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.gradeName.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.schoolId) {
|
||||||
|
// mock:单一学校
|
||||||
|
}
|
||||||
|
const result = paginate(items, filter);
|
||||||
|
return HttpResponse.json({ data: { adminClasses: result } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminTeachers ──
|
||||||
|
if (operationName === "AdminTeachers") {
|
||||||
|
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
|
||||||
|
let items = [...db.teachers];
|
||||||
|
if (filter.search) {
|
||||||
|
const q = filter.search.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.email.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.status && filter.status !== "all") {
|
||||||
|
items = items.filter((t) => t.status === filter.status);
|
||||||
|
}
|
||||||
|
const result = paginate(items, filter);
|
||||||
|
return HttpResponse.json({ data: { adminTeachers: result } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminStudents ──
|
||||||
|
if (operationName === "AdminStudents") {
|
||||||
|
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
|
||||||
|
let items = [...db.students];
|
||||||
|
if (filter.search) {
|
||||||
|
const q = filter.search.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(q) ||
|
||||||
|
s.email.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.status && filter.status !== "all") {
|
||||||
|
items = items.filter((s) => s.status === filter.status);
|
||||||
|
}
|
||||||
|
const result = paginate(items, filter);
|
||||||
|
return HttpResponse.json({ data: { adminStudents: result } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auditLogs ──
|
||||||
|
if (operationName === "AuditLogs") {
|
||||||
|
const filter = (variables.filter ?? {}) as Partial<ListFilter> & {
|
||||||
|
action?: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
};
|
||||||
|
let items = [...db.auditLogs];
|
||||||
|
if (filter.search) {
|
||||||
|
const q = filter.search.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(l) =>
|
||||||
|
l.actorName.toLowerCase().includes(q) ||
|
||||||
|
l.action.toLowerCase().includes(q) ||
|
||||||
|
l.resourceType.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.action) {
|
||||||
|
items = items.filter((l) => l.action === filter.action);
|
||||||
|
}
|
||||||
|
if (filter.actorUserId) {
|
||||||
|
items = items.filter((l) => l.actorUserId === filter.actorUserId);
|
||||||
|
}
|
||||||
|
const result = paginate(items, filter);
|
||||||
|
return HttpResponse.json({ data: { auditLogs: result } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adminDashboard ──
|
||||||
|
if (operationName === "AdminDashboard") {
|
||||||
|
return HttpResponse.json({ data: { adminDashboard: mockDashboard } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── systemSettings ──
|
||||||
|
if (operationName === "SystemSettings") {
|
||||||
|
return HttpResponse.json({ data: { systemSettings: db.systemSettings } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── updateSystemSettings ──
|
||||||
|
if (operationName === "UpdateSystemSettings") {
|
||||||
|
const input = variables.input as Partial<typeof mockSystemSettings>;
|
||||||
|
Object.assign(db.systemSettings, input);
|
||||||
|
recordAudit(
|
||||||
|
"SYSTEM_SETTINGS_UPDATE",
|
||||||
|
"admin.system",
|
||||||
|
"settings",
|
||||||
|
JSON.stringify(input),
|
||||||
|
);
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
updateSystemSettings: {
|
||||||
|
schoolName: db.systemSettings.schoolName,
|
||||||
|
schoolCode: db.systemSettings.schoolCode,
|
||||||
|
academicYear: db.systemSettings.academicYear,
|
||||||
|
semester: db.systemSettings.semester,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 未匹配的 operation ──
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ errors: [{ message: `未实现的 GraphQL operation: ${operationName}` }] },
|
||||||
|
{ status: 501 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
7
apps/admin-portal/src/mocks/server.ts
Normal file
7
apps/admin-portal/src/mocks/server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* MSW Node Server(用于 vitest 集成测试)
|
||||||
|
*/
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers);
|
||||||
107
apps/admin-portal/src/providers/auth-provider.tsx
Normal file
107
apps/admin-portal/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
getUser,
|
||||||
|
login as loginApi,
|
||||||
|
logout as logoutApi,
|
||||||
|
} from "@/lib/auth";
|
||||||
|
import type { CurrentUser } from "@/types/view-models";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: CurrentUser | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (perm: string) => boolean;
|
||||||
|
hasAnyPermission: (perms: string[]) => boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUserState] = useState<CurrentUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken();
|
||||||
|
const stored = getUser();
|
||||||
|
if (token && stored) {
|
||||||
|
setUserState(stored);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const result = await loginApi(email, password);
|
||||||
|
setUserState(result.user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUserState(null);
|
||||||
|
logoutApi();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(perm: string) => {
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.permissions.includes("*")) return true;
|
||||||
|
return user.permissions.includes(perm);
|
||||||
|
},
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback(
|
||||||
|
(perms: string[]) => {
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.permissions.includes("*")) return true;
|
||||||
|
return perms.some((p) => user.permissions.includes(p));
|
||||||
|
},
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: user !== null,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限 Hook(复用 AuthProvider) */
|
||||||
|
export function usePermission() {
|
||||||
|
const { user, hasPermission, hasAnyPermission } = useAuth();
|
||||||
|
return {
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
permissions: user?.permissions ?? [],
|
||||||
|
dataScope: user?.dataScope ?? "SELF",
|
||||||
|
roles: user?.roles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
16
apps/admin-portal/src/providers/graphql-provider.tsx
Normal file
16
apps/admin-portal/src/providers/graphql-provider.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import { Provider } from "urql";
|
||||||
|
import { createGraphQLClient } from "@/lib/graphql-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL Provider(standalone 模式自建 urql client)
|
||||||
|
*
|
||||||
|
* 仲裁 ARB-002 / ISSUE-047 §5.4:MF 模式复用 Shell 暴露的 GraphQL client 单例
|
||||||
|
* 开发期 standalone 模式:自建 urql client 指向 /api/admin/graphql(MSW 拦截)
|
||||||
|
*/
|
||||||
|
export function GraphQLProvider({ children }: { children: ReactNode }) {
|
||||||
|
const client = useMemo(() => createGraphQLClient(), []);
|
||||||
|
return <Provider value={client}>{children}</Provider>;
|
||||||
|
}
|
||||||
124
apps/admin-portal/src/providers/toast-provider.tsx
Normal file
124
apps/admin-portal/src/providers/toast-provider.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: ToastItem[];
|
||||||
|
show: (type: ToastType, title: string, description?: string) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(type: ToastType, title: string, description?: string) => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
setToasts((prev) => [...prev, { id, type, title, description }]);
|
||||||
|
setTimeout(() => dismiss(id), 5000);
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorMap: Record<ToastType, string> = {
|
||||||
|
success: "var(--color-success)",
|
||||||
|
error: "var(--color-danger)",
|
||||||
|
warning: "var(--color-warning)",
|
||||||
|
info: "var(--color-accent)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, show, dismiss }}>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="通知"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
maxWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<button
|
||||||
|
key={toast.id}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-paper)",
|
||||||
|
border: "none",
|
||||||
|
borderLeft: `4px solid ${colorMap[toast.type]}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "12px 16px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
width: "100%",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
onClick={() => dismiss(toast.id)}
|
||||||
|
aria-label={`${toast.title}${toast.description ? `: ${toast.description}` : ""}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.title}
|
||||||
|
</p>
|
||||||
|
{toast.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-ink-muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useToast must be used within ToastProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
207
apps/admin-portal/src/types/view-models.ts
Normal file
207
apps/admin-portal/src/types/view-models.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* admin-portal 视图模型类型定义
|
||||||
|
*
|
||||||
|
* 对齐 contract.md §2.4 GraphQL Query/Mutation 清单
|
||||||
|
* 仲裁:ARB-001 GraphQL admin 命名空间 + ADMIN_ 权限前缀
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 数据范围(对齐 iam DataScope) */
|
||||||
|
export type DataScope =
|
||||||
|
"ALL" | "SCHOOL" | "GRADE" | "CLASS" | "SUBJECT" | "SELF";
|
||||||
|
|
||||||
|
/** 用户状态 */
|
||||||
|
export type UserStatus = "active" | "disabled" | "locked";
|
||||||
|
|
||||||
|
/** 用户视图模型 */
|
||||||
|
export interface UserViewModel {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roles: RoleViewModel[];
|
||||||
|
status: UserStatus;
|
||||||
|
dataScope: DataScope;
|
||||||
|
organizationId: string | null;
|
||||||
|
schoolName: string | null;
|
||||||
|
lastLoginAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色视图模型 */
|
||||||
|
export interface RoleViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
permissions: PermissionViewModel[];
|
||||||
|
userCount: number;
|
||||||
|
dataScope: DataScope;
|
||||||
|
isSystem: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限视图模型 */
|
||||||
|
export interface PermissionViewModel {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视口配置视图模型 */
|
||||||
|
export interface ViewportConfigViewModel {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
route: string;
|
||||||
|
icon: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
requiredPermission: string | null;
|
||||||
|
scope: "teacher" | "student" | "parent" | "admin";
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组织节点视图模型 */
|
||||||
|
export interface OrganizationNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "school" | "grade" | "class";
|
||||||
|
parentId: string | null;
|
||||||
|
childrenCount: number;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学校设置视图模型 */
|
||||||
|
export interface SystemSettingsViewModel {
|
||||||
|
schoolName: string;
|
||||||
|
schoolCode: string;
|
||||||
|
academicYear: string;
|
||||||
|
semester: "first" | "second";
|
||||||
|
contactEmail: string;
|
||||||
|
contactPhone: string;
|
||||||
|
address: string;
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级视图模型(全局管理) */
|
||||||
|
export interface AdminClassViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
gradeName: string;
|
||||||
|
schoolName: string;
|
||||||
|
headTeacherName: string;
|
||||||
|
studentCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 教师视图模型(全局管理) */
|
||||||
|
export interface AdminTeacherViewModel {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
schoolName: string;
|
||||||
|
subjects: string[];
|
||||||
|
classCount: number;
|
||||||
|
status: UserStatus;
|
||||||
|
lastLoginAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生视图模型(全局管理) */
|
||||||
|
export interface AdminStudentViewModel {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
className: string;
|
||||||
|
gradeName: string;
|
||||||
|
schoolName: string;
|
||||||
|
guardianName: string | null;
|
||||||
|
guardianPhone: string | null;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计日志视图模型 */
|
||||||
|
export interface AuditLogViewModel {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
occurredAt: number;
|
||||||
|
actorUserId: string;
|
||||||
|
actorName: string;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
beforeState: string | null;
|
||||||
|
afterState: string | null;
|
||||||
|
traceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘统计视图模型 */
|
||||||
|
export interface AdminDashboardViewModel {
|
||||||
|
totalTeachers: number;
|
||||||
|
totalStudents: number;
|
||||||
|
totalClasses: number;
|
||||||
|
totalSchools: number;
|
||||||
|
schoolAvgScore: number;
|
||||||
|
activeUsersToday: number;
|
||||||
|
auditEventsToday: number;
|
||||||
|
trend: Array<{
|
||||||
|
date: string;
|
||||||
|
teachers: number;
|
||||||
|
students: number;
|
||||||
|
avgScore: number;
|
||||||
|
}>;
|
||||||
|
serviceHealth: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
status: "healthy" | "degraded" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表筛选参数 */
|
||||||
|
export interface ListFilter {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
schoolId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
sortField?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页结果 */
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户信息 */
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
dataScope: DataScope;
|
||||||
|
schoolId: string | null;
|
||||||
|
schoolName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebSocket 通知消息 */
|
||||||
|
export interface WsNotification {
|
||||||
|
id: string;
|
||||||
|
type: "audit_alert" | "abnormal_login" | "system_error" | "info";
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: "info" | "warning" | "error";
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
29
apps/admin-portal/tailwind.config.js
Normal file
29
apps/admin-portal/tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
paper: 'hsl(40, 20%, 98%)',
|
||||||
|
ink: {
|
||||||
|
DEFAULT: 'hsl(25, 3%, 15%)',
|
||||||
|
muted: 'hsl(30, 5%, 45%)',
|
||||||
|
},
|
||||||
|
accent: 'hsl(220, 60%, 35%)',
|
||||||
|
rule: 'hsl(30, 10%, 90%)',
|
||||||
|
danger: 'hsl(0, 70%, 45%)',
|
||||||
|
success: 'hsl(140, 50%, 35%)',
|
||||||
|
warning: 'hsl(35, 80%, 45%)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
serif: ['Fraunces', 'Georgia', 'serif'],
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
DEFAULT: '6px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
20
apps/admin-portal/tsconfig.json
Normal file
20
apps/admin-portal/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
30
apps/admin-portal/vitest.config.ts
Normal file
30
apps/admin-portal/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json-summary"],
|
||||||
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
|
exclude: ["src/**/*.test.*", "src/test/**", "src/mocks/**"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -501,3 +501,27 @@
|
|||||||
| P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 |
|
| P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 |
|
||||||
| P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 |
|
| P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 |
|
||||||
| P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) |
|
| P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) |
|
||||||
|
|
||||||
|
### 2.16 admin-portal(管理端微前端 Remote,P2-P6)
|
||||||
|
|
||||||
|
> admin-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / 设计令牌,本节仅记录 admin-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。
|
||||||
|
|
||||||
|
| 场景 | 技术/规则 |
|
||||||
|
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P2 端口分配 | admin-portal = **4003**(004 §1.2 强制 4 端 4000-4003;初稿误用 3003 与 data-ana 冲突,修订对齐仲裁 ARB-003) |
|
||||||
|
| P2 GraphQL admin 命名空间 | 复用 teacher-bff GraphQL endpoint(`POST /api/admin/graphql`),不新建 admin-bff(ARB-001);urql client standalone 自建,MF 模式复用 Shell 单例 |
|
||||||
|
| P2 权限点 ADMIN_ 前缀 | admin 自身资源用 `ADMIN_` 前缀(如 `ADMIN_DASHBOARD_VIEW`/`ADMIN_AUDIT_READ`),跨服务资源保留原前缀(如 `IAM_USER_READ`);路由权限映射在 `lib/permissions.ts` ROUTE_PERMISSIONS |
|
||||||
|
| P2 MSW mock 全量拦截 | `NEXT_PUBLIC_API_MOCKING=enabled` 时 MSW 拦截所有 GraphQL/HTTP;`mocks/handlers.ts` 实现 18 个 GraphQL operation + `/api/auth/login` mock;fixtures 内存可变副本支持增删改 |
|
||||||
|
| P2 WebSocket 实时通知 | 接入 push-gateway `GET /ws`(ARB-006);开发期 `use-websocket.ts` 用定时器模拟推送(30s/条);3 类通知:`audit_alert`/`abnormal_login`/`system_error` |
|
||||||
|
| P2 MF Remote 入口 | `next.config.js` 暴露 `./AdminApp`(exposes: `./src/app/admin-app.tsx`);`NEXT_PUBLIC_MF_ENABLED` 控制启用(standalone 默认 false,Shell 就绪后设 true) |
|
||||||
|
| P3 用户管理 CRUD | `useUsers`/`useUser`/`useCreateUser`/`useUpdateUser`/`useToggleUserStatus` hooks;`UserManagementTable` + `UserFormModal` 组件;mock 登录 admin@edu.test/admin123 |
|
||||||
|
| P3 角色权限矩阵 | `RolePermissionMatrix` 组件按 resource 分组、支持全选/半选/单选;`useUpdateRolePermissions` 提交权限码列表;系统预置角色不可删除 |
|
||||||
|
| P3 视口拖拽排序 | `@dnd-kit/sortable` 实现拖拽排序 + `useSortable` hook;`SortableViewportItem` 每行可编辑 label/permission/visible;保存时批量调用 `updateViewport` |
|
||||||
|
| P4 组织树递归加载 | `OrganizationTree` 递归组件 + `useOrganization(parentId)` 按需加载子节点;展开/折叠状态本地管理;A11y `role="tree"`/`role="treeitem"`/`aria-expanded`/`aria-selected` |
|
||||||
|
| P5 审计日志 CSV 导出 | `exportAuditLogsCsv` 生成 BOM + UTF-8 CSV;`useAuditLogs` 支持 search/action 过滤;审计日志经 teacher-bff GraphQL 消费(ARB-005 不直连 Kafka) |
|
||||||
|
| P5 仪表盘服务健康 | `AdminDashboardViewModel.serviceHealth` 含 serviceName/status/latencyMs;`Badge` 组件映射 healthy/degraded/down 状态色 |
|
||||||
|
| P6 A11y WCAG 2.2 AA | `eslint-plugin-jsx-a11y` flat config(ESLint 9);skip-link + focus-visible + aria-* 完整;`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容,暂用 jsx-a11y + tseslint 替代 |
|
||||||
|
| P6 Web Vitals 采集 | `web-vitals` 包(LCP/CLS/FCP/INP/TTFB);`WebVitalsInitializer` 组件仅 production 启用;`navigator.sendBeacon` 上报到 `/api/admin/web-vitals` |
|
||||||
|
| P6 健康检查端点 | `/api/health`(liveness 200)+ `/api/ready`(readiness 检查 gateway 可达性,2s 超时;mock 模式直接 ready) |
|
||||||
|
| P6 Dockerfile no-push | 多阶段构建 `node:22-alpine`;builder 阶段 `pnpm install --frozen-lockfile` + `next build`;runner 阶段 `standalone` 输出 + EXPOSE 4003 |
|
||||||
|
| P6 urql 类型断言 | urql `useQuery`/`useMutation` 泛型与纯字符串 query 兼容性有限;`use-graphql.ts` 用 `as unknown as` 从 unknown 转换(项目规则允许从 unknown 断言) |
|
||||||
|
|||||||
3360
pnpm-lock.yaml
generated
3360
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user