Compare commits
120 Commits
v0.6.2-ote
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abde336876 | ||
|
|
2796d7c88f | ||
|
|
a70977ad4b | ||
|
|
9fd7c018c2 | ||
|
|
5a88c8b45d | ||
|
|
422b55f901 | ||
|
|
895a060491 | ||
|
|
7b790f1276 | ||
|
|
fb23c5234e | ||
|
|
7dd5c44406 | ||
|
|
d260df864c | ||
|
|
e9d3030f2f | ||
|
|
774af93d76 | ||
|
|
81a539b9ab | ||
|
|
74077c7083 | ||
|
|
0066afc55e | ||
|
|
ae6fe23be6 | ||
|
|
21f2485749 | ||
|
|
41179ff0a9 | ||
|
|
0b42302a64 | ||
|
|
99580fa13a | ||
|
|
5b06bdbc52 | ||
|
|
b745443b6d | ||
|
|
96a936e154 | ||
|
|
8b729a710f | ||
|
|
40f07b7a16 | ||
|
|
64b2016fa8 | ||
|
|
18d94c0cc2 | ||
|
|
d49d211425 | ||
|
|
f13ca612e6 | ||
|
|
7c8e0f5dea | ||
|
|
7cf9aec20e | ||
|
|
fad6e43b47 | ||
|
|
d6cc70e02e | ||
|
|
a0635ff2c6 | ||
|
|
9e4d442c0e | ||
|
|
58d0c4758c | ||
|
|
d1db3dfdee | ||
|
|
a6b0c54fba | ||
|
|
61d824924a | ||
|
|
c6362f4b04 | ||
|
|
8a01d0b8fc | ||
|
|
32780c2296 | ||
|
|
606acb674f | ||
|
|
860557e166 | ||
|
|
66e65eb2b1 | ||
|
|
2e7eace762 | ||
|
|
dd0ae47976 | ||
|
|
dda33ac2d1 | ||
|
|
10c918490f | ||
|
|
b50dd62c9b | ||
|
|
3382528085 | ||
|
|
b29b3eb022 | ||
|
|
f585080e70 | ||
|
|
74474a2d04 | ||
|
|
1eacd1ed87 | ||
|
|
99155a5ea1 | ||
|
|
7b7abbb309 | ||
|
|
a35e759d64 | ||
|
|
ca3780aa24 | ||
|
|
b3511910d1 | ||
|
|
58c0ba1bd9 | ||
|
|
84a743fa23 | ||
|
|
33f5de4c04 | ||
|
|
5c89def704 | ||
|
|
1fa2f20920 | ||
|
|
7817d8397f | ||
|
|
8f21ca6a68 | ||
|
|
b951229ede | ||
|
|
bfae2569e7 | ||
|
|
7d11cc0eb0 | ||
|
|
bc2d0a107c | ||
|
|
5dae28a624 | ||
|
|
150105dd48 | ||
|
|
ea59b9c9ca | ||
|
|
b255c94a77 | ||
|
|
9fae2b0e78 | ||
|
|
9ea81f1bd7 | ||
|
|
2229309a1e | ||
|
|
4307f6b73c | ||
|
|
5661938cc0 | ||
|
|
3fac472a57 | ||
|
|
dc0a6feec4 | ||
|
|
855f35f84b | ||
|
|
9e767b4e95 | ||
|
|
8e91039896 | ||
|
|
c179af64a6 | ||
|
|
df62ffc176 | ||
|
|
24c2860b41 | ||
|
|
88f6822473 | ||
|
|
a7d8f92227 | ||
|
|
06e0f9139b | ||
|
|
5e0e20b1ce | ||
|
|
06a646ea4e | ||
|
|
21530dc7f6 | ||
|
|
b82593aac2 | ||
|
|
14d836beb0 | ||
|
|
c09d6fb7d2 | ||
|
|
b54bfd101b | ||
|
|
02d09c47fa | ||
|
|
033057a302 | ||
|
|
e5ca4c6c7b | ||
|
|
60d7173545 | ||
|
|
3852873964 | ||
|
|
5144ec7292 | ||
|
|
9ba368477d | ||
|
|
6051f84a65 | ||
|
|
f15912c7ee | ||
|
|
faaaf29f67 | ||
|
|
2a2a56f541 | ||
|
|
db90b2e080 | ||
|
|
e691cd267d | ||
|
|
fd5b6e19ae | ||
|
|
0a71b02e04 | ||
|
|
b53a486c6e | ||
|
|
a0f6c228af | ||
|
|
834e2c61fd | ||
|
|
f1e466a772 | ||
|
|
b72c8d81d4 | ||
|
|
959d58a95d |
92
.dockerignore
Normal file
92
.dockerignore
Normal file
@@ -0,0 +1,92 @@
|
||||
# 依赖目录(构建时重新安装)
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# 构建产物
|
||||
**/.next
|
||||
**/out
|
||||
**/dist
|
||||
**/build
|
||||
**/coverage
|
||||
**/target
|
||||
**/bin
|
||||
**/obj
|
||||
|
||||
# Go 构建产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Python 缓存
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/*$py.class
|
||||
**/.venv
|
||||
**/venv
|
||||
**/*.egg-info
|
||||
|
||||
# 环境变量与本地配置
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Secrets / Keys
|
||||
keys/
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# IDE 与编辑器
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 平台与临时文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# TypeScript 增量构建缓存
|
||||
*.tsbuildinfo
|
||||
|
||||
# 测试覆盖率报告
|
||||
.nyc_output
|
||||
**/coverage
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
arch.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose.minimal.override.yml
|
||||
|
||||
# 临时目录
|
||||
tmp/
|
||||
temp/
|
||||
.turbo
|
||||
.cache
|
||||
|
||||
# 文档(不需要打包到镜像)
|
||||
docs/
|
||||
**/docs/
|
||||
*.md
|
||||
!README.md
|
||||
12
.env.example
12
.env.example
@@ -21,7 +21,17 @@ DEV_MODE=false
|
||||
# 服务端口
|
||||
API_GATEWAY_PORT=8080
|
||||
CLASSES_SERVICE_PORT=3001
|
||||
TEACHER_PORTAL_PORT=3000
|
||||
TEACHER_PORTAL_PORT=4000
|
||||
|
||||
# teacher-portal(ai13,MF Shell)
|
||||
# GraphQL endpoint(经 api-gateway 代理到 teacher-bff,contract.md §2.3)
|
||||
NEXT_PUBLIC_TEACHER_BFF_GRAPHQL_URL=/api/v1/teacher/graphql
|
||||
# MSW mock 开关(contract.md §4.2,enabled 时启用 MSW 拦截上游)
|
||||
NEXT_PUBLIC_API_MOCKING=enabled
|
||||
# MF Remote 开关(ARB-002 §2.3,P2 默认 false,P3+ 启用 student-portal)
|
||||
NEXT_PUBLIC_MF_ENABLED=false
|
||||
# api-gateway URL(next.config.js rewrites 代理目标)
|
||||
API_GATEWAY_URL=http://localhost:8080
|
||||
|
||||
# 数据库连接
|
||||
DATABASE_URL=mysql://edu:changeme@localhost:3306/next_edu_cloud
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@9
|
||||
run: corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# ===== Go 质量检查 =====
|
||||
quality-go:
|
||||
runs-on: ubuntu-latest
|
||||
container: golang:1.22-alpine
|
||||
container: golang:1.25-alpine
|
||||
defaults:
|
||||
run:
|
||||
working-directory: services/api-gateway
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,6 +34,12 @@ venv/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Secrets / Keys (JWT RS256 等)
|
||||
keys/
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| `docs/architecture/0010_architecture.md` | 理想蓝图(目标态) |
|
||||
| `docs/architecture/roadmap/` | 长远规划(tech-debt / pending-features) |
|
||||
| `docs/architecture/runbooks/` | 运维手册(P6 硬化、post-p6-followup、incident-response) |
|
||||
| `docs/troubleshooting/known-issues.md` | 已知问题速查(场景→技术映射 + 工作经验日志) |
|
||||
| `docs/troubleshooting/known-issues.md` | 已知问题速查(场景→技术映射,索引式,不写工作日志) |
|
||||
|
||||
### 需要同步图的场景
|
||||
|
||||
@@ -246,11 +246,17 @@ services/[service]/src/
|
||||
|
||||
## 8. Git 工作流
|
||||
|
||||
- **分支策略**:trunk-based(直接提交 main 分支,小项目)
|
||||
- **大型团队**:feature branch + PR,PR 至少 1 人 review
|
||||
- **分支策略**:分支开发。**人类决策者手动维护分支**(创建分支、合并到 main),AI 不得自行切换/创建/合并分支
|
||||
- **AI 分支约束(强制)**:
|
||||
- AI 只在人类分配的当前分支上工作
|
||||
- **禁止** `git checkout -b`、`git switch`、`git branch` 创建或切换分支
|
||||
- **禁止** `git merge`、`git push origin main`(合并与推送 main 由人类负责)
|
||||
- AI 完成工作后 `git add` + `git commit` 提交到当前分支,通知人类合并
|
||||
- **提交前校验**:commit 前必须本地通过 `pnpm run lint` + `pnpm run typecheck`(TS)/ `go vet ./...`(Go)/ `ruff check src/`(Python)
|
||||
- **pre-commit hook**:`lint-staged` 自动修复 + 校验(见 `lint-staged.config.js`)
|
||||
- **commit-msg hook**:commitlint 校验格式
|
||||
- **禁止 force push**:除非显式要求并通知团队
|
||||
- **回滚**:由人类在 main 上 `git revert <commit>`,不使用分支回滚
|
||||
- **默认多 AI 协作**:所有工作默认按多 AI 合作模式进行(见 §14),每个 AI 在各自分支独立工作
|
||||
|
||||
---
|
||||
|
||||
@@ -286,7 +292,10 @@ services/[service]/src/
|
||||
- **索引式**:场景→技术/规则映射,不写代码示例和错误示范列
|
||||
- **去重**:同类问题在原条目补充,不重复创建
|
||||
- **引用架构规则**:架构分层、模块结构等规则引用 004 和本规则文件,不重复
|
||||
- **工作经验日志**:在"工作经验日志"区按时间倒序追加(50 条上限),记录"做了什么/学到什么/下次注意"
|
||||
- **只读自己模块分区**:AI 查阅 known-issues 时只读自己负责模块的分区,禁止读其他 AI 模块的分区(避免被历史记录误导)
|
||||
- **禁止工作日志**:known-issues 不设"工作经验日志"区,不写流水账、不写"做了什么/学到什么"
|
||||
- **经验沉淀不标注 AI 身份**:所有经验记录只写"场景→技术/规则"映射,**不得出现 AI 标识(如 ai01/ai02/coord 等)**,避免新同名 AI 将前任记录误认为当前事实
|
||||
- **模块内经验**:模块内的经验沉淀在各自模块的 README/文档中,同样不标注 AI 身份
|
||||
|
||||
---
|
||||
|
||||
@@ -294,14 +303,18 @@ services/[service]/src/
|
||||
|
||||
**所有 AI 工作必须遵循此流程,违反即违规。**
|
||||
|
||||
### 阶段 1:上下文加载
|
||||
### 阶段 1:上下文加载(按需阅读,严格模块边界)
|
||||
|
||||
> **架构文档按需阅读**:模块开发只读自己模块的架构与服务 README;项目整体相关工作(跨模块契约、基础设施、文档体系、shared-proto)才读全局架构文档。功能开发/文档书写等强依赖场景必须读对应架构。
|
||||
>
|
||||
> **严格模块边界**:AI 只读自己负责模块的文档与源码,不越界阅读其他模块的内部实现细节(避免被历史记录或他人方案误导)。仅在需要确认跨模块契约时,才读对方的对外接口(proto/API/事件)。
|
||||
|
||||
1. `pnpm run arch:scan` 更新 arch.db
|
||||
2. `pnpm run arch:query -- module-deps` 查目标模块依赖
|
||||
3. `pnpm run arch:query -- symbol-refs <目标函数>` 查调用链
|
||||
4. 阅读 `services/[service]/README.md` 读模块工作流程
|
||||
5. 查 `docs/troubleshooting/known-issues.md` "模块经验"分区读相关经验
|
||||
6. 查 `docs/architecture/004_architecture_impact_map.md` 对应章节
|
||||
4. 阅读 `services/[service]/README.md` 读**自己模块**的工作流程
|
||||
5. 查 `docs/troubleshooting/known-issues.md` **仅读自己模块的分区**,禁止读其他 AI 模块分区
|
||||
6. 模块开发:查 `docs/architecture/004_architecture_impact_map.md` **对应模块章节**;全局工作:查 004 全文
|
||||
|
||||
### 阶段 2:执行工作
|
||||
|
||||
@@ -314,13 +327,12 @@ services/[service]/src/
|
||||
|
||||
### 阶段 3:经验沉淀(强制,不可跳过)
|
||||
|
||||
1. 在 `docs/troubleshooting/known-issues.md` "工作经验日志"区追加一条记录:
|
||||
- 日期 + 时间
|
||||
- 模块
|
||||
- 做了什么 + 学到什么
|
||||
2. 若发现新的"场景→技术"映射 → 提炼到对应模块分区
|
||||
3. 若发现新的架构决策 → 更新 004
|
||||
4. 若代码结构变化 → `pnpm run arch:scan` 确认 arch.db 已更新
|
||||
1. 若发现新的"场景→技术"映射 → 提炼到 `docs/troubleshooting/known-issues.md` 对应**模块分区**(索引式一行,不写流水日志,**不标注 AI 身份**)
|
||||
2. 若发现新的架构决策 → 更新 004
|
||||
3. 若代码结构变化 → `pnpm run arch:scan` 确认 arch.db 已更新
|
||||
4. 模块内的经验沉淀到**自己模块的 README/文档**,不污染全局 known-issues,同样不标注 AI 身份
|
||||
|
||||
> **禁止**在 known-issues.md 写"工作经验日志/流水账"。known-issues 是索引式速查手册,只保留场景→技术映射,**不出现任何 AI 标识**。
|
||||
|
||||
---
|
||||
|
||||
@@ -371,48 +383,40 @@ services/[service]/src/
|
||||
|
||||
## 14. 多 AI 协作规范
|
||||
|
||||
> 详细流程见 [多 AI 协作指南](../../docs/standards/multi-ai-collaboration.md),本节为强制约束摘要。
|
||||
> **默认多 AI 协作**:所有 AI 工作默认按多 AI 合作模式进行。详细流程见 [多 AI 协作指南](../../docs/standards/multi-ai-collaboration.md),本节为强制约束摘要。
|
||||
|
||||
### 14.1 角色与权限
|
||||
|
||||
| 角色 | 职责 | push 特性分支 | 创建 PR | 合并 PR | push main | force push main |
|
||||
| -------------------------- | ---------------------------------------- | ------------- | ------- | ----------- | --------- | --------------- |
|
||||
| **协调 AI(Coordinator)** | PR 审核、合并、冲突仲裁、发布 | ✅ | ✅ | ✅ | ❌ | ⚠️(仅事故) |
|
||||
| **开发 AI(Dev)** | 按模块分工写代码、提 PR | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **SRE AI** | `infra/` 维护、部署 | ✅(infra) | ✅ | ✅(infra) | ❌ | ⚠️(仅事故) |
|
||||
| **人类决策者** | 架构决策、Breaking Change 审批、发布确认 | — | — | — | — | — |
|
||||
| 角色 | 职责 | 在分支提交 | 合并到 main | force push main |
|
||||
| -------------------------- | -------------------------------------------------------- | ----------- | ------------------------- | --------------- |
|
||||
| **协调 AI(Coordinator)** | 契约管理、交叉审查、冲突仲裁、发布 | ✅ | ❌(由人类合并) | ⚠️(仅事故) |
|
||||
| **开发 AI(Dev)** | 按模块分工写代码、在分支提交 | ✅ | ❌ | ❌ |
|
||||
| **SRE AI** | `infra/` 维护、部署 | ✅(infra) | ❌(由人类合并) | ⚠️(仅事故) |
|
||||
| **人类决策者** | 架构决策、分支管理、合并、Breaking Change 审批、发布确认 | — | ✅(手动合并分支到 main) | ⚠️(仅事故) |
|
||||
|
||||
### 14.2 模块单一负责制
|
||||
> AI 在分配的分支上提交,**不自行合并到 main**。合并由人类决策者负责。
|
||||
|
||||
- 每个模块(限界上下文)只有一个 AI 负责,禁止并行修改同一模块
|
||||
### 14.2 模块单一负责制与模块边界(强制)
|
||||
|
||||
- **每个模块(限界上下文)只有一个 AI 负责,禁止并行修改同一模块**
|
||||
- **严格模块边界**:AI 只修改自己负责的目录(`services/<my-service>/` 或 `apps/<my-app>/`),禁止越界修改他人模块的源码
|
||||
- **只读自己模块文档**:AI 阅读文档时只读自己模块的 README/设计文档/known-issues 分区,**禁止阅读其他 AI 模块的内部文档和历史记录**(避免被前任同名 AI 的过时方案/审计结果误导)
|
||||
- **跨模块契约只读接口**:需要确认跨模块协同时,只读对方的对外接口(proto message / API 端点 / Kafka 事件 schema),不读对方内部实现
|
||||
- `shared-proto`、`shared-tokens`、`docs/` 由协调 AI 维护,开发 AI 只读引用
|
||||
- `infra/` 由 SRE AI 专门负责,业务 AI 不直接修改
|
||||
|
||||
### 14.3 分支命名规范(强制)
|
||||
### 14.3 分支开发规则(强制)
|
||||
|
||||
```
|
||||
<type>/<scope>-<task-id>-<ai-id>
|
||||
```
|
||||
1. **分支由人类创建**:人类决策者为每个任务/模块创建分支并分配给 AI,AI 不得自行创建分支
|
||||
2. **AI 禁止切换分支**:AI 不得执行 `git checkout -b`、`git switch`、`git branch` 等分支创建/切换命令
|
||||
3. **AI 禁止合并/推送 main**:`git merge`、`git push origin main` 由人类决策者执行,AI 只提交到当前分支
|
||||
4. **AI 提交流程**:`git add <files>` → `git commit -m "<type>(<scope>): <subject>"` → 通知人类合并
|
||||
5. **提交前校验**:commit 前必须本地通过 `pnpm run lint` + `pnpm run typecheck`(TS)/ `go vet ./...`(Go)/ `ruff check src/`(Python)
|
||||
6. **提交信息规范**:遵循 Conventional Commits(见 §7)
|
||||
|
||||
- `type`:feat / fix / refactor / docs / chore / test
|
||||
- `scope`:见 §7 提交规范 scope-enum(26 项)
|
||||
- `task-id`:任务简短描述(kebab-case)
|
||||
- `ai-id`:AI 唯一标识符(如 `ai01`、`ai02`、`coord`)
|
||||
### 14.4 跨模块变更顺序(强制)
|
||||
|
||||
**示例**:`feat/classes-add-pagination-ai01`
|
||||
|
||||
### 14.4 PR 与合并规则(强制)
|
||||
|
||||
1. **禁止直接 push 到 `main`**:所有变更通过 PR
|
||||
2. **PR 必须通过 CI**:lint / typecheck / build / test 全绿
|
||||
3. **PR 必须通过 CODEOWNERS review**:至少 1 人 approve
|
||||
4. **合并策略**:Squash Merge(默认),Rebase Merge(保留多 commit 历史),**禁止 Merge Commit**
|
||||
5. **特性分支寿命 ≤ 3 天**:超期需 rebase 最新 main
|
||||
6. **跨模块变更拆分**:按依赖顺序拆多个 PR(proto → service → gateway → frontend),协调 AI 按序合并
|
||||
|
||||
### 14.5 跨模块变更顺序(强制)
|
||||
|
||||
修改涉及多模块时,必须按以下顺序拆分 PR 并顺序合并:
|
||||
修改涉及多模块时,按依赖顺序依次在各模块分支完成,由人类决策者按顺序合并到 main:
|
||||
|
||||
1. `shared-proto`(proto 契约)
|
||||
2. 业务服务(classes / iam / core-edu 等)
|
||||
@@ -420,29 +424,22 @@ services/[service]/src/
|
||||
4. BFF(teacher-bff 等)
|
||||
5. 微前端(teacher-portal 等)
|
||||
|
||||
> 每合并一个 PR,后续 PR 的开发 AI 必须 rebase 最新 main 并重新校验。
|
||||
> 每合并一层到 main 后,再合并下一层。协调 AI 负责监督顺序,避免越级合并导致下游构建失败。
|
||||
|
||||
### 14.6 冲突处理规则
|
||||
### 14.5 冲突处理规则
|
||||
|
||||
- **文件冲突**:后合并的 PR rebase 最新 main,`git push --force-with-lease`(仅自己的分支)
|
||||
- **架构冲突**:由协调 AI 仲裁保留方案
|
||||
- **禁止 `git push --force` 到 main 或他人分支**
|
||||
- **分支冲突**:由人类决策者在合并时解决,或通知 AI 在分支上 `git rebase` 最新 main 后重新提交
|
||||
- **架构冲突**:由协调 AI 仲裁保留方案,通过新 commit 修正
|
||||
- **禁止 `git push --force` 到 main**:仅协调 AI 在事故时可 `--force-with-lease`
|
||||
|
||||
### 14.7 AI 身份标注(强制)
|
||||
### 14.6 经验沉淀规则(强制)
|
||||
|
||||
每个 PR 描述末尾必须追加:
|
||||
- **不记录工作日志**:AI 不在 known-issues.md 或任何文档中写"工作经验日志/流水账"
|
||||
- **只做经验沉淀**:遇到新的"场景→技术"映射,以索引式一行记录到 known-issues 对应模块分区
|
||||
- **不标注 AI 身份**:经验沉淀记录**不得出现 AI 标识**(如 ai01/ai02/coord 等),避免新同名 AI 误读
|
||||
- **模块内经验**:模块内的经验沉淀到各自服务 README/文档,同样不标注 AI 身份
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
**AI Agent**: <ai-id> (<负责模块>)
|
||||
**Branch**: <分支名>
|
||||
**Coordinator**: <协调 AI ai-id>
|
||||
```
|
||||
|
||||
每个 AI 完成任务后,在 `docs/troubleshooting/known-issues.md` "工作经验日志"区追加记录(见 §9.3)。
|
||||
|
||||
### 14.8 敏感文件保护
|
||||
### 14.7 敏感文件保护
|
||||
|
||||
以下文件修改需人类决策者额外审批:
|
||||
|
||||
@@ -470,16 +467,16 @@ services/[service]/src/
|
||||
| ----------------- | ---- | ------------------------------------ | -------- |
|
||||
| **quality-ts** | ✅ | pnpm lint + typecheck + test + build | 失败阻断 |
|
||||
| **quality-go** | ✅ | go vet + build + test | 失败阻断 |
|
||||
| **quality-proto** | ✅ | buf lint + buf breaking(仅 PR) | 失败阻断 |
|
||||
| **quality-proto** | ✅ | buf lint + buf breaking | 失败阻断 |
|
||||
| **deploy** | 串行 | docker compose up --build + 健康检查 | 失败阻断 |
|
||||
|
||||
> deploy job 仅在 `push main` 或 `workflow_dispatch` 时触发,PR 时不部署
|
||||
> 质量检查在分支推送时触发,部署仅在合并到 main 时触发。
|
||||
|
||||
### 15.3 触发条件
|
||||
|
||||
| 事件 | 触发阶段 | 触发条件 |
|
||||
| ----------------- | ----------------------------------------------- | -------------------------------- |
|
||||
| PR 创建/更新 | quality-ts + quality-go + quality-proto(并行) | 所有路径 |
|
||||
| 分支 push / PR | quality-ts + quality-go + quality-proto(并行) | 分支推送自动 |
|
||||
| push 到 main | 上述全部 + deploy | 合并后自动 |
|
||||
| workflow_dispatch | 上述全部 + deploy | 手动触发,支持 `commit_sha` 回滚 |
|
||||
|
||||
|
||||
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@11.13.0 --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"]
|
||||
284
apps/admin-portal/docs/01-understanding.md
Normal file
284
apps/admin-portal/docs/01-understanding.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 模块理解确认书 — admin-portal
|
||||
|
||||
> AI:ai16(TS/React · 管理场景域前端 remote)
|
||||
> 阶段:阶段 1 交付物(仲裁后修订版 v2)
|
||||
> 日期:2026-07-10
|
||||
> 关联:
|
||||
>
|
||||
> - [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. 我在架构中的位置
|
||||
|
||||
- **层级**:L2 微前端层(004 §3.1 六层架构中的前端层)
|
||||
- **MF 角色**:**Remote 子应用**,挂载到 teacher-portal Shell(主应用,端口 4000)
|
||||
- **上游(谁调用我)**:浏览器(系统管理员 / 校管理员)
|
||||
- **下游(同步)**:api-gateway(GraphQL `POST /api/admin/graphql` + REST `/api/auth/login`)
|
||||
- **下游(推送)**:push-gateway `GET /ws`(WebSocket 实时通知,ARB-006)
|
||||
- **BFF 对接**:复用 teacher-bff GraphQL admin 命名空间(ARB-001),不新建 admin-bff
|
||||
- **通信方式**:GraphQL(业务数据)+ HTTP(登录)+ WebSocket(实时通知)
|
||||
- **不直连**:前端不直连任何业务服务或 BFF 后端实例,全部经 api-gateway 代理
|
||||
|
||||
**说明**:
|
||||
|
||||
- admin-portal 作为 Remote 子应用,挂载到 teacher-portal Shell
|
||||
- Shell 暴露 `GraphQLProvider` / `useGraphQLClient` / `AppShell` / `useAuth` / `usePermission` 等,admin-portal MF 模式复用,standalone 模式自建
|
||||
- 路由前缀 `/admin/*` 由 teacher-portal Shell 动态加载 admin-portal Remote 模块(暴露 `./AdminApp`)
|
||||
- 场景域 BFF 复用策略(004 §5.4 + ARB-001):复用 teacher-bff GraphQL endpoint,admin 命名空间(`admin.*` Query/Mutation)
|
||||
|
||||
## 2. 我的限界上下文
|
||||
|
||||
### 2.1 我负责的聚合 / 实体(前端视图模型)
|
||||
|
||||
- 用户、角色、权限、视口(管理场景域前端视图,调 iam 经 GraphQL)
|
||||
- 组织(学校/年级/班级层级)
|
||||
- 学校设置(system 路由)
|
||||
- 班级/教师/学生全局管理(admin 视角,只读 + 状态管理)
|
||||
- 审计日志(只读,经 teacher-bff GraphQL `auditLogs` Query 消费)
|
||||
- 管理仪表盘(聚合统计 + 服务健康)
|
||||
- 实时通知(WebSocket 推送:审计告警/异常登录/系统异常)
|
||||
|
||||
### 2.2 业务领域
|
||||
|
||||
- **管理场景域**(前端场景域:管理场景域,对应后端 iam 限界上下文 + teacher-bff admin 命名空间)
|
||||
|
||||
### 2.3 不负责
|
||||
|
||||
- 教学业务编排(班级/考试/作业/成绩 CRUD,归 teacher-portal)
|
||||
- 学生作答界面(归 student-portal)
|
||||
- 家长多子女切换(归 parent-portal)
|
||||
- 教师仪表盘(归 teacher-portal,admin-portal 仅有管理仪表盘)
|
||||
- 审计事件发布(归 iam,admin-portal 仅消费)
|
||||
|
||||
### 2.4 数据范围
|
||||
|
||||
- DataScope L3-L5(校管理员 L3 学校 / 区教研员 L4 / 系统管理员 L5 全平台)
|
||||
- 不出现 L1(班级)/ L2(年级)级别的教师数据视角
|
||||
|
||||
## 3. 我与外部的契约
|
||||
|
||||
### 3.1 消费的后端 API(经 api-gateway 代理)
|
||||
|
||||
| 路径 | Method | 下游 BFF/服务 | 用途 |
|
||||
| ------------------------- | ------ | ----------------------------- | ------------------------------------------------- |
|
||||
| `/api/admin/graphql` | POST | teacher-bff(admin 命名空间) | 全部业务 GraphQL 查询(ARB-001) |
|
||||
| `/api/auth/login` | POST | api-gateway → iam | 登录(standalone 模式用,MF 模式复用 Shell 登录) |
|
||||
| `GET /ws`(push-gateway) | WS | push-gateway | 实时通知(审计告警/异常登录/系统异常,ARB-006) |
|
||||
|
||||
### 3.2 GraphQL Operations(contract §2.4)
|
||||
|
||||
admin-portal 消费 teacher-bff admin 命名空间的 18 个 GraphQL operations:
|
||||
|
||||
| Operation | 类型 | 用途 |
|
||||
| ------------------------------------------------ | -------------- | ------------------------------- |
|
||||
| `currentUser` | Query | 当前管理员信息 |
|
||||
| `adminUsers` / `adminUser(id)` | Query | 用户列表 / 详情 |
|
||||
| `createUser` / `updateUser` / `toggleUserStatus` | Mutation | 用户 CRUD |
|
||||
| `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 | 学校设置 |
|
||||
|
||||
### 3.3 推送契约(ARB-006)
|
||||
|
||||
admin-portal 接入 push-gateway `GET /ws`,消费 3 类实时通知:
|
||||
|
||||
| 通知类型 | severity | 用途 |
|
||||
| ---------------- | -------- | ------------------------- |
|
||||
| `audit_alert` | warning | 审计告警(敏感操作触发) |
|
||||
| `abnormal_login` | error | 异常登录(异地/异常时段) |
|
||||
| `system_error` | error | 系统异常(服务降级/宕机) |
|
||||
|
||||
> **不再用轮询**:旧文档的"60s 轮询监控 + 5min 轮询统计"策略已被 ARB-006 替换为 WebSocket 实时推送。开发期用 mock-socket + 30s 定时器模拟推送。
|
||||
|
||||
### 3.4 proto 不直接消费
|
||||
|
||||
前端不调用 gRPC,teacher-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量(TS 文件,非 proto 生成)。
|
||||
|
||||
## 4. 我的技术栈
|
||||
|
||||
| 维度 | 选型 | 说明 |
|
||||
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 框架 | Next.js 14+(App Router) | server components 默认,client components 按需 |
|
||||
| 语言 | TypeScript 5.5+(strict) | 沿用 tsconfig.base.json |
|
||||
| 微前端 | Module Federation 2.0(@module-federation/nextjs-mf) | admin-portal = **Remote**,端口 4003 |
|
||||
| 数据层 | urql GraphQL client | standalone 自建 / MF 复用 Shell 单例(ARB-004) |
|
||||
| 样式 | Tailwind CSS 3.4+ | 配合设计令牌三层(复用 Shell) |
|
||||
| UI 组件库 | shadcn/ui 风格(复用 Shell `@edu/ui-components`) | admin-portal 内部 `ui.tsx` 封装 PaperCard/Button/Table 等纸面组件 |
|
||||
| 表单 | react-hook-form + zod | 用户表单/学校设置 |
|
||||
| 拖拽排序 | @dnd-kit/sortable | 视口配置拖拽排序 |
|
||||
| 图表 | recharts | 仪表盘趋势表(实际以纯表格为主,纸面风格) |
|
||||
| i18n | 轻量 i18n(`messages` 字典 + `t()` 函数) | 命名空间 `admin.*`,不依赖 next-intl |
|
||||
| Mock | MSW(Mock Service Worker)+ mock-socket | 开发期拦截全部 GraphQL/HTTP + WebSocket mock |
|
||||
| Web Vitals | web-vitals v4 | LCP/CLS/FCP/INP/TTFB 采集,`navigator.sendBeacon` 上报 |
|
||||
| A11y | eslint-plugin-jsx-a11y(error 级) | WCAG 2.2 AA |
|
||||
| 字体 | Inter(sans)/ Fraunces(serif)/ JetBrains Mono(mono) | RootLayout 加载(standalone);MF 模式复用 Shell |
|
||||
| 测试 | Vitest + @testing-library/react | jsdom 环境,覆盖率 ≥ 80% |
|
||||
| ESLint | ESLint 9 flat config | jsx-a11y + tseslint + prettier(注:`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容,已移除) |
|
||||
|
||||
> **技术栈差异**(vs teacher-portal):MF 角色(Remote)、GraphQL 为主(非 REST)、消费 WebSocket 推送(非 SSE)、纸面 UI 风格、数据范围 L3-L5。
|
||||
|
||||
## 5. 我的阶段归属
|
||||
|
||||
- **阶段**:P6(硬化阶段),批次 5
|
||||
- **当前状态**:✅ **已实现**(P2-P6 全阶段交付完成,含 11 路由 + MSW mock + A11y + Web Vitals + Dockerfile)
|
||||
- **依赖上游阶段**:P6(依赖 teacher-portal Shell 已就绪 + teacher-bff GraphQL admin 命名空间 + iam 已实现用户/角色/权限/视口 CRUD + push-gateway WebSocket)
|
||||
|
||||
## 6. 路由表(11 路由,contract §1.2)
|
||||
|
||||
| 路由 | 页面 | 权限点 | 数据范围 |
|
||||
| --------------------- | ---------------- | ---------------------- | -------- |
|
||||
| `/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 |
|
||||
|
||||
> 权限点前缀规则(ARB-003):admin 自身资源用 `ADMIN_*`;跨服务资源保留原前缀(如 `IAM_USER_READ`)。`ADMIN_*` 常量由 coord 维护于 `packages/contracts/src/permissions.ts`,admin-portal 引用 `src/lib/permissions.ts`,不硬编码。
|
||||
|
||||
## 7. 黄金模板对齐清单(前端等价)
|
||||
|
||||
| 对齐项 | 后端等价 | admin-portal 实现 | 状态 |
|
||||
| --------------------- | ------------------------------------- | ------------------------------------------------------------- | ---- |
|
||||
| 权限校验 | `@RequirePermission(Permissions.XXX)` | `usePermission().hasPermission("XXX")` Hook + `AuthGuard` | ✅ |
|
||||
| 错误码前缀 | `IAM_*` / `ADMIN_*` | `CONSUMED_ERROR_PREFIXES` 常量 + i18n 路由 | ✅ |
|
||||
| logger | pino | console + 结构化(生产 → 上报) | ✅ |
|
||||
| metrics | prom-client `/metrics` | Web Vitals v4 → `navigator.sendBeacon` 上报 | ✅ |
|
||||
| /health + /ready | `GET /healthz` `GET /readyz` | Next.js Route Handler `/api/health` + `/api/ready` | ✅ |
|
||||
| 优雅关闭 | SIGTERM handler | N/A(Next.js 无长连接) | ✅ |
|
||||
| 测试覆盖率 ≥ 80% | Vitest | Vitest + @testing-library/react | ⚠️ |
|
||||
| Dockerfile 多阶段构建 | builder + runtime | `node:22-alpine` 多阶段,standalone 输出,EXPOSE 4003 | ✅ |
|
||||
| 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 实现清单(v2,仲裁后修订)
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
### 质量校验
|
||||
|
||||
- `pnpm run typecheck` → ✅ 通过(0 errors)
|
||||
- `pnpm run lint` → ✅ 通过(0 errors)
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai16 (admin-portal remote)
|
||||
**Branch**: admin-portal-arbitration-done-TaFkyb
|
||||
**Coordinator**: coord-ai
|
||||
704
apps/admin-portal/docs/02-architecture-design.md
Normal file
704
apps/admin-portal/docs/02-architecture-design.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# 模块架构设计文档 — admin-portal
|
||||
|
||||
> AI:ai16(TS/React · 管理场景域前端 remote)
|
||||
> 阶段:阶段 2 交付物(仲裁后修订版 v2)
|
||||
> 日期:2026-07-10
|
||||
> 关联:
|
||||
>
|
||||
> - [阶段 1 理解确认书](./01-understanding.md)
|
||||
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §5.4
|
||||
> - [总统最终裁决](../../../docs/architecture/president-final-rulings.md) §5.1-5.5(ISSUE-044~048)
|
||||
> - [admin-portal 对接契约](../../architecture/issues/contracts/admin-portal_contract.md)
|
||||
> - [pending-features P6](../../../docs/architecture/roadmap/pending-features.md)
|
||||
> - [known-issues §2.16](../../../docs/troubleshooting/known-issues.md)
|
||||
> 状态:已实现(P2-P6 全阶段交付完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块内部分层图(admin-portal Remote + Shell 引用 + Gateway + push-gateway)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Browser["浏览器(系统/校管理员)"]
|
||||
URL[URL 路由 /admin/*]
|
||||
end
|
||||
|
||||
subgraph Shell["teacher-portal(Shell 宿主,端口 4000)"]
|
||||
AppShell[AppShell<br/>左栏导航 + 主内容区<br/>按 scope=admin 过滤视口]
|
||||
RootLayout[RootLayout<br/>字体/令牌/i18n Provider]
|
||||
Router[Next.js App Router<br/>动态加载 admin Remote]
|
||||
SharedDeps["共享依赖暴露(singleton)<br/>react/react-dom/urql/graphql<br/>@edu/ui-tokens/@edu/ui-components/@edu/hooks/@edu/contracts"]
|
||||
GraphQLProviderShell[GraphQLProvider<br/>Shell 暴露 urql client 单例]
|
||||
end
|
||||
|
||||
subgraph RemoteAdmin["admin-portal(Remote 子应用,端口 4003)"]
|
||||
AdminApp[AdminApp<br/>MF Remote 入口 exposes ./AdminApp]
|
||||
AdminPages[管理场景页面<br/>dashboard/users/roles/permissions/viewports/organization<br/>classes/teachers/students/audit-logs/system]
|
||||
AdminComponents["admin 特有组件<br/>UserManagementTable/RolePermissionMatrix<br/>ViewportConfigEditor/OrganizationTree/NotificationPanel"]
|
||||
AdminHooks["admin 业务 Hooks<br/>useUsers/useRoles/usePermissions/useViewports<br/>useOrganization/useClasses/useTeachers/useStudents<br/>useAuditLogs/useDashboard/useSystemSettings/useWebSocket"]
|
||||
end
|
||||
|
||||
subgraph Gateway["api-gateway"]
|
||||
GW[Gin 路由/鉴权/限流]
|
||||
end
|
||||
|
||||
subgraph PushGateway["push-gateway"]
|
||||
WS[WebSocket :8081/ws]
|
||||
end
|
||||
|
||||
subgraph Backend["后端服务"]
|
||||
IAM[iam<br/>用户/角色/权限/视口 CRUD + AuditEvent 发布]
|
||||
BFF[teacher-bff<br/>GraphQL admin 命名空间 聚合]
|
||||
end
|
||||
|
||||
Browser --> URL
|
||||
URL --> RootLayout
|
||||
RootLayout --> AppShell
|
||||
AppShell --> Router
|
||||
Router -->|动态加载 /admin/*| AdminApp
|
||||
AdminApp --> AdminPages
|
||||
|
||||
AdminPages -->|消费 singleton| SharedDeps
|
||||
SharedDeps --> GraphQLProviderShell
|
||||
AdminPages --> AdminComponents
|
||||
AdminPages --> AdminHooks
|
||||
|
||||
AdminHooks -->|useGraphQuery/useGraphMutation| GraphQLProviderShell
|
||||
GraphQLProviderShell -->|POST /api/admin/graphql| GW
|
||||
GW --> BFF
|
||||
BFF --> IAM
|
||||
|
||||
AdminHooks -->|useWebSocket| WS
|
||||
IAM -.->|Kafka edu.iam.audit.created| BFF
|
||||
|
||||
AppShell -->|fetch /api/auth/login| GW
|
||||
```
|
||||
|
||||
### 1.1 Remote 与 Shell 的职责边界
|
||||
|
||||
| 职责 | 归属 | 说明 |
|
||||
| -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| RootLayout(字体/令牌/Provider) | teacher-portal Shell | admin-portal MF 模式复用;standalone 模式自建 |
|
||||
| AppShell(左栏 + 主内容区) | admin-portal 内部 | `admin-shell.tsx`,11 项导航 + 跳过链接 A11y |
|
||||
| 路由表 `/admin/*` | admin-portal | `src/app/admin/*/page.tsx` |
|
||||
| 登录页 | admin-portal | standalone 模式 `src/app/login/page.tsx`(mock 登录);MF 模式复用 Shell `/login` |
|
||||
| rewrites `/api/admin/graphql` | admin-portal | `next.config.js` rewrites 代理到 api-gateway |
|
||||
| GraphQLProvider | Shell 暴露 / 自建 | MF 模式复用 Shell 单例(ARB-004);standalone 模式 `graphql-provider.tsx` 自建 urql client |
|
||||
| 管理场景页面 | admin-portal Remote | `/admin/*` 下的所有 page.tsx |
|
||||
| 管理特有组件 | admin-portal Remote | UserManagementTable 等 6 个 |
|
||||
| 管理业务 Hooks | admin-portal Remote | 13 个业务 Hook + useWebSocket |
|
||||
| 共享组件库 | Shell 暴露 | `@edu/ui-components` |
|
||||
| 共享 Hooks | Shell 暴露 | `@edu/hooks` |
|
||||
|
||||
### 1.2 MF 配置(admin-portal/next.config.js,Remote 角色)
|
||||
|
||||
```javascript
|
||||
const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
> **说明**:
|
||||
>
|
||||
> - `NEXT_PUBLIC_MF_ENABLED` 环境变量控制 MF 启用(standalone 模式默认 `false`,MF 模式设 `true`)
|
||||
> - admin-portal 暴露 `./AdminApp` 模块(非旧文档的 `./pages`),供 Shell 动态 import
|
||||
> - `shared` 全部 `singleton: true`,确保 urql/graphql/react 单例(避免多实例报错)
|
||||
> - admin-portal 自身实现 `rewrites`(代理 `/api/admin/graphql` 到 api-gateway),standalone 模式可独立运行
|
||||
|
||||
## 2. 领域模型(前端视角)
|
||||
|
||||
前端不持有业务聚合根,仅持有"视图模型"(ViewModel)和"会话状态"。定义于 `src/types/view-models.ts`。
|
||||
|
||||
### 2.1 视图模型清单
|
||||
|
||||
```typescript
|
||||
// 数据范围
|
||||
type DataScope = "ALL" | "SCHOOL" | "GRADE" | "CLASS" | "DISTRICT";
|
||||
|
||||
// 用户管理
|
||||
interface UserViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: { id: string; name: string; code: string }[];
|
||||
status: "active" | "disabled" | "locked";
|
||||
dataScope: DataScope;
|
||||
organizationId: string | null;
|
||||
schoolName?: string;
|
||||
lastLoginAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// 角色管理
|
||||
interface RoleViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
permissions: PermissionViewModel[];
|
||||
userCount: number;
|
||||
dataScope: DataScope;
|
||||
isSystem: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// 权限管理
|
||||
interface PermissionViewModel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
// 视口配置
|
||||
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;
|
||||
}
|
||||
|
||||
// 组织树
|
||||
interface OrganizationNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "school" | "grade" | "class";
|
||||
parentId: string | null;
|
||||
childrenCount: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 学校设置
|
||||
interface SystemSettingsViewModel {
|
||||
schoolName: string;
|
||||
schoolYear: string;
|
||||
semester: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
// ... 更多设置项
|
||||
}
|
||||
|
||||
// 班级/教师/学生(admin 全局视角)
|
||||
interface AdminClassViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
headTeacher: string;
|
||||
studentCount: number;
|
||||
status: string;
|
||||
}
|
||||
interface AdminTeacherViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
subjects: string[];
|
||||
classes: string[];
|
||||
status: string;
|
||||
}
|
||||
interface AdminStudentViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 审计日志
|
||||
interface AuditLogViewModel {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
beforeState: string | null;
|
||||
afterState: string | null;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
occurredAt: number;
|
||||
}
|
||||
|
||||
// 管理仪表盘
|
||||
interface AdminDashboardViewModel {
|
||||
totalUsers: number;
|
||||
totalTeachers: number;
|
||||
totalStudents: number;
|
||||
totalClasses: number;
|
||||
activeSessions: number;
|
||||
serviceHealth: {
|
||||
serviceName: string;
|
||||
status: "healthy" | "degraded" | "down";
|
||||
latencyMs: number;
|
||||
}[];
|
||||
recentActivity: {
|
||||
timestamp: number;
|
||||
action: string;
|
||||
user: string;
|
||||
resource: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// WebSocket 通知
|
||||
interface WsNotification {
|
||||
id: string;
|
||||
type: "audit_alert" | "abnormal_login" | "system_error" | "info";
|
||||
severity: "info" | "warning" | "error";
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 当前用户与会话
|
||||
|
||||
```typescript
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: DataScope;
|
||||
schoolId: string | null;
|
||||
schoolName: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
会话状态由 `AuthProvider` 管理,token 存 `localStorage`(`edu_access_token`),用户信息存 `localStorage`(`edu_user`)。
|
||||
|
||||
## 3. 数据模型(前端缓存层)
|
||||
|
||||
admin-portal 无数据库,仅有 urql GraphQL client 缓存层。
|
||||
|
||||
| 数据类型 | 存储 | 缓存策略 |
|
||||
| ----------------------- | ---------------------------- | --------------------------------------------- |
|
||||
| Session(token + user) | localStorage + React Context | access token 持久化;用户信息持久化;登出清除 |
|
||||
| GraphQL 查询缓存 | urql document cache | 默认缓存,mutation 后手动 invalidate |
|
||||
| 视口配置(拖拽态) | React useState | 本地编辑,保存时批量 mutation |
|
||||
| 表单临时态 | react-hook-form | 卸载即销毁 |
|
||||
| WebSocket 通知 | React useState(限 20 条) | 新通知头部插入,超 20 条裁剪 |
|
||||
|
||||
> **缓存策略说明**:admin 数据低频变,urql 默认 document cache 已满足;监控指标经 WebSocket 实时推送(不再轮询);视口配置支持本地编辑 + 批量保存。
|
||||
|
||||
## 4. API 设计(GraphQL 为主)
|
||||
|
||||
### 4.1 GraphQL Client(urql,ARB-004)
|
||||
|
||||
**standalone 模式**(`src/lib/graphql-client.ts`):
|
||||
|
||||
```typescript
|
||||
import { createClient, fetchExchange, type Client } from "@urql/core";
|
||||
|
||||
const GRAPHQL_ENDPOINT = "/api/admin/graphql";
|
||||
|
||||
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}` } } : {};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**MF 模式**:复用 Shell 暴露的 `GraphQLProvider` + `useGraphQLClient`(singleton,ARB-004)。
|
||||
|
||||
**urql 类型断言技巧**:urql 泛型与纯字符串 query 兼容性有限,用 `as unknown as` 从 unknown 转换:
|
||||
|
||||
```typescript
|
||||
// use-graphql.ts
|
||||
const [executeMutation] = useMutation(mutation) as unknown as [
|
||||
(variables: V) => { toPromise: () => Promise<OperationResult<T, V>> },
|
||||
unknown,
|
||||
];
|
||||
```
|
||||
|
||||
### 4.2 通用 GraphQL Hooks(`src/hooks/use-graphql.ts`)
|
||||
|
||||
```typescript
|
||||
export function useGraphQuery<T, V = Record<string, never>>(
|
||||
query: string,
|
||||
variables?: V,
|
||||
): QueryResult<T> { ... }
|
||||
|
||||
export function useGraphMutation<T, V>(
|
||||
mutation: string,
|
||||
): [MutationFn<T, V>, MutationResult] { ... }
|
||||
```
|
||||
|
||||
### 4.3 业务 Hooks 清单
|
||||
|
||||
| Hook | 职责 |
|
||||
| ---------------------------- | ------------------------------------------------ |
|
||||
| `useUsers(filter)` | 用户列表查询(含筛选/分页) |
|
||||
| `useUser(userId)` | 用户详情 |
|
||||
| `useCreateUser()` | 创建用户 mutation |
|
||||
| `useUpdateUser()` | 更新用户 mutation |
|
||||
| `useToggleUserStatus()` | 启用/禁用用户 mutation |
|
||||
| `useUserFilter()` | 用户筛选状态(搜索/角色/状态/组织) |
|
||||
| `useRoles()` | 角色列表查询 |
|
||||
| `useCreateRole()` | 创建角色 mutation |
|
||||
| `useUpdateRolePermissions()` | 更新角色权限 mutation(RolePermissionMatrix 用) |
|
||||
| `usePermissions()` | 全量权限列表 |
|
||||
| `useViewports()` | 视口配置列表 |
|
||||
| `useUpdateViewport()` | 更新视口配置 mutation |
|
||||
| `useOrganization(parentId)` | 组织树查询(按 parentId 递归) |
|
||||
| `useClasses(filter)` | 班级列表查询 |
|
||||
| `useClassFilter()` | 班级筛选状态 |
|
||||
| `useTeachers(filter)` | 教师列表查询 |
|
||||
| `useTeacherFilter()` | 教师筛选状态 |
|
||||
| `useStudents(filter)` | 学生列表查询 |
|
||||
| `useStudentFilter()` | 学生筛选状态 |
|
||||
| `useAuditLogs(filter)` | 审计日志查询 |
|
||||
| `useAuditLogFilter()` | 审计日志筛选状态 |
|
||||
| `exportAuditLogsCsv(logs)` | 审计日志 CSV 导出(BOM + UTF-8) |
|
||||
| `useDashboard()` | 管理仪表盘聚合查询 |
|
||||
| `useSystemSettings()` | 学校设置查询 |
|
||||
| `useUpdateSystemSettings()` | 学校设置更新 mutation |
|
||||
| `useWebSocket(maxItems)` | WebSocket 实时通知(mock 模式 30s 定时推送) |
|
||||
|
||||
## 5. 事件设计
|
||||
|
||||
### 5.1 WebSocket 实时通知(ARB-006)
|
||||
|
||||
admin-portal 接入 push-gateway `GET /ws`,消费 3 类通知:
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-websocket.ts
|
||||
export function useWebSocket(maxItems = 20) {
|
||||
// 真实模式:连接 push-gateway NEXT_PUBLIC_WS_URL
|
||||
// mock 模式:30s 定时器模拟推送
|
||||
// 返回:{ notifications, connected, dismiss, clear }
|
||||
}
|
||||
```
|
||||
|
||||
| 通知类型 | severity | 触发场景 |
|
||||
| ---------------- | -------- | ----------------------------- |
|
||||
| `audit_alert` | warning | 敏感操作(权限变更/批量删除) |
|
||||
| `abnormal_login` | error | 异地登录/异常时段登录 |
|
||||
| `system_error` | error | 服务降级/宕机 |
|
||||
|
||||
### 5.2 不消费 Kafka
|
||||
|
||||
admin-portal 不直接订阅 Kafka。审计日志经 teacher-bff 聚合后通过 GraphQL `auditLogs` Query 消费(ARB-005):
|
||||
|
||||
**链路**:iam → Kafka `edu.iam.audit.created` → **teacher-bff 消费** → GraphQL `auditLogs` Query → admin-portal
|
||||
|
||||
> **不再用轮询**:旧文档的"60s 轮询监控 + 5min 轮询统计"已被 ARB-006 的 WebSocket 推送替换。
|
||||
|
||||
## 6. 横切关注点对齐清单
|
||||
|
||||
### 6.1 权限(前端等价,ARB-003)
|
||||
|
||||
权限点常量定义于 `src/lib/permissions.ts`:
|
||||
|
||||
- `ADMIN_PERMISSIONS`:admin 自身资源(`ADMIN_DASHBOARD_VIEW` / `ADMIN_SYSTEM_MANAGE` / `ADMIN_AUDIT_READ` / `ADMIN_CLASS_READ` / `ADMIN_TEACHER_READ` / `ADMIN_STUDENT_READ` / `ADMIN_ORG_MANAGE` 等)
|
||||
- `IAM_PERMISSIONS`:跨服务资源(`IAM_USER_READ` / `IAM_ROLE_READ` / `IAM_PERMISSION_READ` / `IAM_VIEWPORT_READ` 等)
|
||||
- `ROUTE_PERMISSIONS`:11 路由 → 权限点映射
|
||||
|
||||
权限校验:`usePermission().hasPermission("XXX")` Hook + `AuthGuard` 组件(路由级)。
|
||||
|
||||
### 6.2 错误码清单(前端 i18n 路由,ARB-002)
|
||||
|
||||
| 前缀 | 来源服务 | i18n key 模式 |
|
||||
| -------------- | ----------- | ------------------------ |
|
||||
| `IAM_` | iam | `iam.error.{{code}}` |
|
||||
| `BFF_TEACHER_` | teacher-bff | `bff.error.{{code}}` |
|
||||
| `GW_` | api-gateway | `gateway.error.{{code}}` |
|
||||
| `NETWORK_` | 前端网络层 | `network.error.{{code}}` |
|
||||
| `ADMIN_` | admin 域 | `error.admin.*` |
|
||||
|
||||
### 6.3 Web Vitals(`src/lib/web-vitals.ts`)
|
||||
|
||||
| 指标 | 类型 | 上报方式 |
|
||||
| ---- | -------------- | ---------------------- |
|
||||
| LCP | 最大内容绘制 | `navigator.sendBeacon` |
|
||||
| CLS | 累积布局偏移 | 同上 |
|
||||
| FCP | 首次内容绘制 | 同上 |
|
||||
| INP | 交互到下一绘制 | 同上 |
|
||||
| TTFB | 首字节时间 | 同上 |
|
||||
|
||||
仅 production 启用(`WebVitalsInitializer` 组件控制)。
|
||||
|
||||
### 6.4 健康检查
|
||||
|
||||
| 端点 | 用途 | 实现 |
|
||||
| ----------------- | --------- | --------------------------------------------------- |
|
||||
| `GET /api/health` | Liveness | 返回 `200 { status: "ok" }` |
|
||||
| `GET /api/ready` | Readiness | 检查 gateway 可达性(2s 超时);mock 模式直接 ready |
|
||||
|
||||
### 6.5 A11y(WCAG 2.2 AA)
|
||||
|
||||
- `eslint-plugin-jsx-a11y`(error 级)
|
||||
- skip-link(`admin-shell.tsx` 跳过到主内容区)
|
||||
- `focus-visible` 样式(`globals.css`)
|
||||
- 组织树:`<button role="treeitem" aria-expanded aria-selected onKeyDown>`
|
||||
- 通知面板:`<li>` 内嵌 `<button aria-label>`
|
||||
- Toast:`aria-live="polite"` + `aria-atomic="true"`
|
||||
- 注:`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容(`context.getAncestors` 已移除),已移除
|
||||
|
||||
### 6.6 横切关注点对齐清单(汇总)
|
||||
|
||||
| 对齐项 | 实现 |
|
||||
| --------------------- | --------------------------------------------------------------- |
|
||||
| 权限校验 | `usePermission()` + `AuthGuard`,`ADMIN_*`/`IAM_*` 前缀 |
|
||||
| 错误码前缀 | 5 个前缀(`IAM_`/`BFF_TEACHER_`/`GW_`/`NETWORK_`/`ADMIN_`) |
|
||||
| logger | console + 结构化(生产上报) |
|
||||
| metrics | Web Vitals v4 → `navigator.sendBeacon` |
|
||||
| /health + /ready | Next.js Route Handler |
|
||||
| 优雅关闭 | N/A(Next.js 无长连接) |
|
||||
| 测试覆盖率 ≥ 80% | Vitest + @testing-library/react(vitest.config.ts 已配置) |
|
||||
| Dockerfile 多阶段构建 | `node:22-alpine` builder + runner,standalone 输出,EXPOSE 4003 |
|
||||
| Zod 输入验证 | react-hook-form + zod |
|
||||
| GlobalErrorFilter | React ErrorBoundary + Toast 错误处理 |
|
||||
| 设计令牌三层 | `globals.css`(primitive/semantic)+ tailwind-theme |
|
||||
| A11y | eslint-plugin-jsx-a11y + skip-link + focus-visible |
|
||||
| Module Federation | `next.config.js` Remote,`NEXT_PUBLIC_MF_ENABLED` 控制 |
|
||||
|
||||
## 7. 共享组件库(复用 Shell + admin 特有)
|
||||
|
||||
### 7.1 复用 Shell 暴露的组件(`@edu/ui-components`)
|
||||
|
||||
AppShell 风格、ErrorBoundary、RequirePermission、Loading、Empty、Button/Input/Select 等。
|
||||
|
||||
### 7.2 admin-portal 特有组件(新建)
|
||||
|
||||
| 组件 | 用途 | 实现要点 |
|
||||
| ---------------------- | ------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| `AdminShell` | 左栏 11 项导航 + 主内容区 + 跳过链接 | 内部组件,11 项导航按 `ROUTE_PERMISSIONS` 过滤;纸面风格 |
|
||||
| `UserManagementTable` | 用户管理表格(列表/筛选/分页) | 列:邮箱/姓名/角色/状态/数据范围/最后登录;筛选:搜索/角色/状态 |
|
||||
| `UserFormModal` | 用户表单弹窗(创建/编辑) | react-hook-form + zod;字段:邮箱/姓名/角色/数据范围/组织 |
|
||||
| `RolePermissionMatrix` | 角色-权限矩阵编辑器(checkbox 网格) | 行=角色,列=权限(按 resource 分组);全选/半选/单选;系统预置角色只读 |
|
||||
| `OrganizationTree` | 组织树(递归 + A11y treeitem) | 按 parentId 懒加载;`<button role="treeitem" aria-expanded aria-selected onKeyDown>` |
|
||||
| `NotificationPanel` | 通知面板(WebSocket 实时通知) | 固定右上角;显示最近 20 条;点击触发 Toast + dismiss;`<li>` 内嵌 `<button aria-label>` |
|
||||
|
||||
### 7.3 通用 UI 组件(`src/components/ui.tsx`)
|
||||
|
||||
PaperCard / PageHeader / Button(4 variant)/ Input / Select / Badge / Table / TableRow / TableCell / EmptyState / LoadingState / ErrorState / Pagination
|
||||
|
||||
### 7.4 不使用的组件(明确排除)
|
||||
|
||||
| 组件 | 归属 | 排除原因 |
|
||||
| ----------------------------------- | -------------- | ------------------------------------- |
|
||||
| `RichTextEditor` | teacher-portal | 管理端无富文本场景 |
|
||||
| `ExamTaking` | student-portal | 管理端无作答场景 |
|
||||
| `ChildSwitcher` | parent-portal | 管理端无多子女切换 |
|
||||
| `PlatformMonitor`(Grafana iframe) | — | 已移除,改为管理仪表盘 + 服务健康表格 |
|
||||
|
||||
## 8. Mock 策略(MSW + mock-socket)
|
||||
|
||||
### 8.1 MSW handlers(`src/mocks/handlers.ts`)
|
||||
|
||||
开发期拦截全部 GraphQL/HTTP 请求:
|
||||
|
||||
- `/api/auth/login` → mock 登录(admin@edu.test / admin123)
|
||||
- `graphql.operation` → 按 operationName 分发 18 个 GraphQL operation mock:
|
||||
- CurrentUser / AdminUsers / AdminUser / CreateUser / UpdateUser / ToggleUserStatus
|
||||
- AdminRoles / CreateRole / UpdateRolePermissions
|
||||
- AdminPermissions / AdminViewports / UpdateViewport
|
||||
- AdminOrganization / AdminClasses / AdminTeachers / AdminStudents
|
||||
- AuditLogs / AdminDashboard / SystemSettings / UpdateSystemSettings
|
||||
- 内存可变副本:支持增删改 + 自动审计记录(`recordAudit`)
|
||||
- 分页:`paginate(items, page, pageSize)` 辅助函数
|
||||
|
||||
### 8.2 mock fixtures(`src/mocks/fixtures.ts`)
|
||||
|
||||
- `mockCurrentUser`:admin 角色,permissions=["*"]
|
||||
- `mockPermissions`:24 项权限点
|
||||
- `mockRoles`:4 个角色(admin/teacher/student/parent)
|
||||
- `mockUsers`:6 个用户
|
||||
- `mockViewports`:7 个视口配置
|
||||
- `mockOrganization`:12 节点(school/grade/class)
|
||||
- `mockClasses` / `mockTeachers` / `mockStudents`:6/6/5 条
|
||||
- `mockAuditLogs`:5 条审计日志
|
||||
- `mockDashboard`:仪表盘聚合数据
|
||||
- `mockSystemSettings`:学校设置
|
||||
|
||||
### 8.3 WebSocket mock(`src/hooks/use-websocket.ts`)
|
||||
|
||||
MSW 不拦截 WebSocket,用定时器模拟推送:
|
||||
|
||||
- mock 模式:30s 定时器推送 1 条通知(轮换 3 类:audit_alert/abnormal_login/system_error)
|
||||
- 真实模式:连接 `NEXT_PUBLIC_WS_URL`(push-gateway `GET /ws`)
|
||||
|
||||
### 8.4 环境切换
|
||||
|
||||
`NEXT_PUBLIC_API_MOCKING=enabled` 启用 MSW;`disabled` 使用真实后端。
|
||||
|
||||
## 9. 设计令牌三层(`src/app/globals.css`)
|
||||
|
||||
### 9.1 Layer 1 Primitive(原始色板)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--paper: 0 0% 100%; /* 白纸 */
|
||||
--ink: 30 10% 15%; /* 墨色 */
|
||||
--ink-muted: 30 5% 45%;
|
||||
--rule: 30 10% 90%; /* 分隔线 */
|
||||
--accent: 210 60% 40%; /* 强调色 */
|
||||
--accent-light: 210 60% 95%;
|
||||
--danger: 0 70% 50%;
|
||||
--success: 140 50% 40%;
|
||||
--warning: 40 80% 50%;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Layer 2 Semantic
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-paper: hsl(var(--paper));
|
||||
--color-ink: hsl(var(--ink));
|
||||
--color-ink-muted: hsl(var(--ink-muted));
|
||||
--color-rule: hsl(var(--rule));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-light: hsl(var(--accent-light));
|
||||
--color-danger: hsl(var(--danger));
|
||||
--color-success: hsl(var(--success));
|
||||
--color-warning: hsl(var(--warning));
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Layer 3 Tailwind Theme(`tailwind.config.js`)
|
||||
|
||||
paper / ink / accent / rule / danger / success / warning 色板映射为 `bg-*` / `text-*` 类。
|
||||
|
||||
### 9.4 强制规则(project_rules §3.10)
|
||||
|
||||
- 禁止 `#hex` 字面量
|
||||
- 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
|
||||
- 禁止 `font-size: Npx`
|
||||
- 禁止 Tailwind 任意值 `w-[Npx]`
|
||||
|
||||
## 10. 与其他模块的交互点(契约清单)
|
||||
|
||||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | 阶段 |
|
||||
| ---- | --------------------- | --------------------- | ---------------------------------------- | ---------------------------- | ---- |
|
||||
| 调用 | api-gateway | HTTP/GraphQL | `POST /api/admin/graphql`(经 rewrites) | 全部业务 GraphQL 查询 | P6+ |
|
||||
| 调用 | api-gateway | HTTP | `POST /api/auth/login` | 登录(standalone 模式) | P6+ |
|
||||
| 调用 | push-gateway | WebSocket | `GET /ws` | 实时通知(ARB-006) | P6+ |
|
||||
| 消费 | teacher-bff | GraphQL(经 Gateway) | admin 命名空间 18 operations | 全部业务数据 | P6+ |
|
||||
| 消费 | iam(经 teacher-bff) | GraphQL(间接) | 用户/角色/权限/视口 CRUD + AuditEvent | 管理数据 | P6+ |
|
||||
| 依赖 | coord 维护 | — | `packages/contracts` | Permissions 常量 + 类型 | P6+ |
|
||||
| 依赖 | coord 维护 | — | `packages/shared-proto` | TS 类型(仅 contracts 部分) | P6+ |
|
||||
| 依赖 | teacher-portal Shell | — | Shell 暴露的 GraphQLProvider + 共享依赖 | 宿主环境(MF 模式) | P6+ |
|
||||
|
||||
> **proto 不直接消费**:前端不调用 gRPC,teacher-bff 把 gRPC 聚合为 GraphQL 暴露给前端。
|
||||
|
||||
## 11. 风险与假设
|
||||
|
||||
### 11.1 假设
|
||||
|
||||
1. **假设 teacher-bff admin 命名空间 schema 就绪**(ARB-001,contract §2.4 18 operations):若 ai03 未补齐,admin-portal 用 MSW mock 开发,上游就绪后切换。
|
||||
2. **假设 teacher-portal Shell 已就绪**:MF 模式依赖 Shell 暴露 GraphQLProvider + 共享依赖。standalone 模式可独立运行。
|
||||
3. **假设 push-gateway WebSocket 就绪**(ARB-006):若未就绪,用 mock-socket + 30s 定时器模拟。
|
||||
4. _\*假设 `packages/contracts` ADMIN_* 权限点常量就绪_*(coord 维护):admin-portal `src/lib/permissions.ts` 引用,若 coord 未建立则本地定义。
|
||||
|
||||
### 11.2 技术风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
| ----------------------------- | -------------------------------------- | --------------------------------------------------------- |
|
||||
| Shell 未就绪阻塞 admin | MF 模式无法挂载 | standalone 模式自建 Provider 链,独立可运行 |
|
||||
| teacher-bff admin schema 缺失 | GraphQL 查询失败 | MSW mock 全覆盖 18 operations,上游就绪后切换 |
|
||||
| urql 类型兼容性 | TypeScript 编译错误 | `as unknown as` 从 unknown 转换(见 §4.1) |
|
||||
| ESLint 9 兼容性 | `@next/eslint-plugin-next` 14.x 不兼容 | 移除该插件,用 jsx-a11y + tseslint 替代 |
|
||||
| WebSocket mock 不真实 | 开发期通知体验与生产不一致 | 30s 定时器 + 3 类通知轮换,接近真实推送频率 |
|
||||
| 权限缓存陈旧 | 角色变更后前端仍用旧权限 | 登录时拉取最新权限;admin-portal 本身是管理端,可主动刷新 |
|
||||
|
||||
## 12. 端口矩阵
|
||||
|
||||
| 端 | dev 端口 | 生产端口 | 备注 |
|
||||
| -------------------- | -------- | -------- | ---------------------- |
|
||||
| admin-portal | 4003 | 4003 | Remote,挂载到 Shell |
|
||||
| teacher-portal Shell | 4000 | 4000 | Shell 宿主 |
|
||||
| api-gateway | 8080 | 8080 | GraphQL 代理 |
|
||||
| push-gateway | 8081 | 8081 | WebSocket |
|
||||
| teacher-bff | 3003 | 3003 | GraphQL admin 命名空间 |
|
||||
|
||||
## 13. 实施路线(已完成)
|
||||
|
||||
### P2-P6 全阶段交付(已完成)
|
||||
|
||||
1. ✅ **P2 骨架 Phase 1**:配置文件 + 设计令牌 + 类型定义
|
||||
2. ✅ **P2 骨架 Phase 2**:GraphQL client + auth + permissions + providers + AdminShell + MF Remote 入口 + standalone 壳
|
||||
3. ✅ **P2 骨架 Phase 3**:MSW mock(handlers + fixtures + browser)+ mock-socket WebSocket mock
|
||||
4. ✅ **P3 业务**:用户管理(useUsers/useUser/CRUD hooks + UserManagementTable + /admin/users 页面)
|
||||
5. ✅ **P3 业务**:角色权限矩阵 + 权限点管理 + 视口配置
|
||||
6. ✅ **P4 业务**:组织管理 + 学校设置 + 班级/教师/学生全局管理
|
||||
7. ✅ **P5 业务**:审计日志 + WebSocket 实时通知 + 管理仪表盘
|
||||
8. ✅ **P6 硬化**:A11y + Web Vitals + Dockerfile + /api/health + /api/ready + 质量校验
|
||||
9. ✅ **文档回写**:01/02 文档对齐仲裁结果 + known-issues §2.16 + arch:scan
|
||||
|
||||
### 验收标准
|
||||
|
||||
- ✅ 所有 11 个路由页面可访问,权限校验生效
|
||||
- ✅ MF Remote 可被 Shell 加载(暴露 `./AdminApp`)
|
||||
- ✅ standalone 模式独立可运行(自建 Provider 链 + mock 登录)
|
||||
- ✅ GraphQL 18 operations 全部实现(MSW mock + 真实切换)
|
||||
- ✅ WebSocket 实时通知(mock 模式 30s 定时推送)
|
||||
- ✅ Web Vitals 采集(LCP/CLS/FCP/INP/TTFB → sendBeacon)
|
||||
- ✅ A11y WCAG 2.2 AA(eslint-plugin-jsx-a11y error 级 + skip-link + focus-visible)
|
||||
- ✅ `/api/health` + `/api/ready` 返回 200
|
||||
- ✅ Dockerfile 多阶段构建(node:22-alpine,standalone 输出,EXPOSE 4003)
|
||||
- ✅ `pnpm run typecheck` + `pnpm run lint` 通过(0 errors)
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai16 (admin-portal remote)
|
||||
**Branch**: admin-portal-arbitration-done-TaFkyb
|
||||
**Coordinator**: coord-ai
|
||||
161
apps/admin-portal/docs/nextstep-v2.md
Normal file
161
apps/admin-portal/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# admin-portal 下游工作清单 v2(Next Steps v2)
|
||||
|
||||
> 负责人:ai16
|
||||
> 更新日期:2026-07-14
|
||||
> 关联:[nextstep.md v1](./nextstep.md)、[admin-portal_contract.md](../../docs/architecture/issues/contracts/admin-portal_contract.md)
|
||||
> 核查方式:源码审查 + 端点测试 + 编译验证
|
||||
|
||||
---
|
||||
|
||||
## 1. 核查结论
|
||||
|
||||
**v1 中声称"已完成"的下游工作存在严重不符。** 经源码审查和端点测试,3 个下游模块均未真正完成:
|
||||
|
||||
| 模块 | v1 声称 | v2 实际 | 严重程度 |
|
||||
| -------------------- | --------- | ----------------------- | -------- |
|
||||
| api-gateway | ✅ 已完成 | ❌ **未实现** | P0 阻塞 |
|
||||
| teacher-bff resolver | — | ✅ **已完成** | 无 |
|
||||
| teacher-bff schema | — | ❌ **未同步** | P0 阻塞 |
|
||||
| iam proto | — | ❌ **契约缺失** | P1 阻塞 |
|
||||
| admin-portal 前端 | — | ✅ **与 resolver 一致** | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 未完成项详情
|
||||
|
||||
### 2.1 api-gateway(ai01 负责)— P0 阻塞
|
||||
|
||||
**核查方法:** 源码审查 + `go vet` 编译 + curl 端点测试
|
||||
|
||||
| # | v1 声称 | 实际状态 | 证据 |
|
||||
| --- | --------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 1 | ✅ 新增 `/api/admin/graphql` 路由 | ❌ **未实现** | `main.go` L68-104 仅有 `/api/v1` 路由组,无 `/api/admin` 路由组 |
|
||||
| 2 | ✅ admin 角色强制校验中间件 | ⚠️ **代码存在但未注册** | `internal/middleware/admin_role.go` 已实现 `AdminRoleMiddleware()`,但 `main.go` 中从未调用 |
|
||||
| 3 | ✅ NewProxyRewrite 代理函数 | ❌ **未实现** | `internal/proxy/proxy.go` 只有 `NewProxy`,无 `NewProxyRewrite` |
|
||||
| 4 | ✅ registerBffProxy | ❌ **未实现** | `main.go` 中无此函数 |
|
||||
|
||||
**编译验证:**
|
||||
|
||||
- `go build ./...` → 通过(不编译测试文件)
|
||||
- `go vet ./...` → **失败**,5 处 `undefined: NewProxyRewrite`(测试文件引用了不存在的函数)
|
||||
|
||||
**端点测试:**
|
||||
|
||||
- `POST http://localhost:8080/api/admin/graphql` → **404 Not Found**
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 在 `main.go` 中注册 `/api/admin/graphql` 路由,代理到 teacher-bff :3003/graphql
|
||||
2. 在该路由上挂载 `AdminRoleMiddleware()`
|
||||
3. 实现 `NewProxyRewrite` 函数(或删除引用它的测试文件)
|
||||
4. 修复 `go vet` 编译错误
|
||||
|
||||
### 2.2 teacher-bff schema(ai03 负责)— P0 阻塞
|
||||
|
||||
**核查方法:** 源码审查 + GraphQL 端点测试
|
||||
|
||||
**问题:** resolver 已用扁平命名(`adminUsers`、`adminRoles` 等),但 schema 文件仍是旧的命名空间模式。
|
||||
|
||||
**文件:** `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`
|
||||
|
||||
| # | 问题 | 详情 |
|
||||
| --- | ---------------------- | --------------------------------------------------------------------------- |
|
||||
| 1 | schema 命名模式不一致 | schema 用 `type Query { admin: AdminQuery! }`,resolver 用扁平 `adminUsers` |
|
||||
| 2 | AdminQuery 字段缺失 | schema L317-335 仅 7 个字段,缺 16 个新 Query |
|
||||
| 3 | AdminMutation 字段缺失 | schema L336+ 仅 10 个 Mutation,缺 11 个新 Mutation |
|
||||
| 4 | 输入类型缺失 | `AdminUserFilterInput`、`CreateUserInput`、`UpdateUserInput` 等未定义 |
|
||||
|
||||
**端点测试:**
|
||||
|
||||
- `POST http://localhost:3003/graphql` with `{ adminUsers(filter:{page:1,pageSize:10}){ items{ id } } }` → **500**(schema 校验失败)
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 将 schema 从命名空间模式改为扁平模式(`type Query { adminUsers: ...! }`)
|
||||
2. 添加所有 16 个新 Query 的字段定义
|
||||
3. 添加所有 11 个新 Mutation 的字段定义
|
||||
4. 添加所有输入类型定义(`AdminUserFilterInput`、`CreateUserInput` 等)
|
||||
5. 删除旧的 `AdminQuery`/`AdminMutation` 类型(或标记为 deprecated)
|
||||
|
||||
### 2.3 iam proto 契约(ai06 负责)— P1 阻塞
|
||||
|
||||
**核查方法:** proto 文件审查 + 实现代码审查
|
||||
|
||||
**文件:** `packages/shared-proto/proto/iam.proto`
|
||||
|
||||
| # | 工作项 | Proto 定义 | 实现代码 | 状态 |
|
||||
| --- | ------------------- | ------------------ | ------------------------------------- | -------- |
|
||||
| 1 | `BatchGetUsers` RPC | ❌ **无 rpc 声明** | ✅ `iam.grpc.controller.ts:62` 已实现 | 契约缺失 |
|
||||
| 2 | `CreateUser` RPC | ❌ 未定义 | ❌ 仅 REST 实现 | 未实现 |
|
||||
| 3 | `UpdateUser` RPC | ❌ 未定义 | ❌ 仅 REST 实现 | 未实现 |
|
||||
| 4 | `DeleteUser` RPC | ❌ 未定义 | ❌ 未实现 | 未实现 |
|
||||
|
||||
**注意:** iam.proto L66-72 定义了 `BatchGetUsersRequest`/`BatchGetUsersResponse` 消息,但 `service IamService` 中**没有** `rpc BatchGetUsers`。gRPC 客户端无法发现该服务。
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 在 `service IamService` 中添加 `rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);`
|
||||
2. 添加 `CreateUser`/`UpdateUser`/`DeleteUser` RPC 定义和实现(如果 teacher-bff 需要 gRPC 调用)
|
||||
|
||||
---
|
||||
|
||||
## 3. admin-portal 前端状态
|
||||
|
||||
### 3.1 已完成项
|
||||
|
||||
| 工作项 | 状态 | 验证方式 |
|
||||
| ------------------------------ | ---- | ------------------------------------------------------------- |
|
||||
| 43 个 GraphQL operation 字符串 | ✅ | 与 resolver 扁平命名一致 |
|
||||
| Docker 镜像构建 | ✅ | `edu/admin-portal:test` 构建成功 |
|
||||
| Docker 容器运行 | ✅ | /api/health + /api/ready + /login + /admin/dashboard 全部 200 |
|
||||
| typecheck + lint | ✅ | 零错误 |
|
||||
| vitest | ✅ | 69/69 通过 |
|
||||
|
||||
### 3.2 前端无需修改
|
||||
|
||||
admin-portal 前端代码与 teacher-bff resolver 完全一致(扁平命名),**不需要修改前端代码**。所有阻塞项都在下游服务。
|
||||
|
||||
---
|
||||
|
||||
## 4. 端到端联调测试结果
|
||||
|
||||
| 测试项 | 结果 | 原因 |
|
||||
| --------------------------------- | ------ | ---------------------------------- |
|
||||
| admin-portal → /login | ✅ 200 | 前端独立工作 |
|
||||
| admin-portal → /api/health | ✅ 200 | 前端独立工作 |
|
||||
| admin-portal → /api/ready | ✅ 200 | api-gateway :8080 可达 |
|
||||
| admin-portal → /api/admin/graphql | ❌ 500 | api-gateway 返回 404(路由未注册) |
|
||||
| teacher-bff → /graphql (扁平命名) | ❌ 500 | schema 未同步(命名空间模式) |
|
||||
|
||||
**结论:** 端到端联调无法进行,需下游服务修复后重新测试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调顺序(更新)
|
||||
|
||||
1. **ai03** 修复 teacher-bff schema(改为扁平命名,补齐字段)— P0
|
||||
2. **ai01** 修复 api-gateway 路由(注册 `/api/admin/graphql`,挂载中间件)— P0
|
||||
3. **ai06** 修复 iam proto(添加 BatchGetUsers rpc 声明)— P1
|
||||
4. **ai16** 重新构建 admin-portal Docker 镜像并端到端测试
|
||||
5. 全链路联调:admin-portal → api-gateway → teacher-bff → iam/classes/core-edu
|
||||
|
||||
---
|
||||
|
||||
## 6. v1 与 v2 差异说明
|
||||
|
||||
| v1 声称 | v2 实际 | 差异原因 |
|
||||
| ------------------- | ------------------------------- | ----------------------------------- |
|
||||
| api-gateway ✅ 完成 | ❌ 未实现 | v1 核查不充分,未实际测试端点和编译 |
|
||||
| teacher-bff ✅ 完成 | ⚠️ resolver 完成,schema 未同步 | v1 未区分 resolver 和 schema |
|
||||
| iam ✅ 完成 | ❌ proto 缺 rpc 声明 | v1 未审查 proto 文件 |
|
||||
|
||||
**教训:** 下游工作完成声明必须通过以下方式验证:
|
||||
|
||||
1. 源码审查(不仅看文档声明)
|
||||
2. 编译验证(`go vet`/`tsc`/`ruff`)
|
||||
3. 端点测试(curl/Invoke-WebRequest)
|
||||
4. 集成测试(端到端调用)
|
||||
|
||||
---
|
||||
|
||||
**本文件由 ai16 维护,v1 中的虚假完成声明已在此 v2 版本中修正。请各负责 AI 按 §5 联调顺序修复后通知 ai16 更新状态。**
|
||||
156
apps/admin-portal/docs/nextstep.md
Normal file
156
apps/admin-portal/docs/nextstep.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# admin-portal 下游工作清单(Next Steps)
|
||||
|
||||
> 负责人:ai16
|
||||
> 更新日期:2026-07-13
|
||||
> 关联:[admin-portal_contract.md](../../docs/architecture/issues/contracts/admin-portal_contract.md)、[admin-portal_workline.md](../../docs/architecture/issues/worklines/admin-portal_workline.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
admin-portal 前端已全部完成(P2-P6 + §7 差距分析 4 批次 + Vitest 测试),Docker 镜像构建成功并在本地 Docker Desktop 验证通过。但真实后端集成需要下游服务补齐以下工作。
|
||||
|
||||
**本地 Docker 测试结果(2026-07-13):**
|
||||
|
||||
- ✅ 镜像构建成功(`edu/admin-portal:test`)
|
||||
- ✅ 容器启动正常(端口 4003)
|
||||
- ✅ `/api/health` 返回 200
|
||||
- ✅ `/api/ready` 返回 200(gateway 可达)
|
||||
- ✅ `/login` 页面返回 200
|
||||
- ✅ `/admin/dashboard` 页面返回 200
|
||||
- ❌ `/api/admin/graphql` 返回 500(api-gateway 返回 404,路由未配置)
|
||||
|
||||
---
|
||||
|
||||
## 2. 下游阻塞项
|
||||
|
||||
### 2.1 api-gateway(ai01 负责)
|
||||
|
||||
**阻塞级别:P0(必须完成才能端到端联调)**
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 新增 `/api/admin/graphql` 路由 | 当前 api-gateway 仅有 `/api/v1/*` 路由组,无 `/api/admin/*` 路由。需新增 POST `/api/admin/graphql` 代理到 teacher-bff :3003/graphql |
|
||||
| 2 | admin 角色强制校验中间件 | 在 `/api/admin/*` 路由组上添加 admin 角色门禁,拒绝非 admin 角色访问 |
|
||||
| 3 | 路径对齐契约 §2.3 | 契约要求 `POST /api/admin/graphql` 为唯一入口,当前 api-gateway 无此路由 |
|
||||
|
||||
**相关文件:**
|
||||
|
||||
- `services/api-gateway/main.go`(L69-104,路由注册)
|
||||
- `services/api-gateway/internal/middleware/auth.go`(鉴权中间件)
|
||||
- `services/api-gateway/internal/proxy/proxy.go`(反向代理)
|
||||
- `services/api-gateway/internal/config/config.go`(配置)
|
||||
|
||||
### 2.2 teacher-bff(ai03 负责)
|
||||
|
||||
**阻塞级别:P0(必须完成才能端到端联调)**
|
||||
|
||||
#### 2.2.1 缺失的 Query(16 个)
|
||||
|
||||
| # | 契约 Query | 当前状态 | 备注 |
|
||||
| --- | ----------------------- | --------- | ----------------------------------- |
|
||||
| 1 | `adminPermissions` | ❌ 未实现 | 权限点列表 |
|
||||
| 2 | `adminViewports(scope)` | ❌ 未实现 | 视口配置,需 scope 参数 |
|
||||
| 3 | `adminClasses` | ❌ 未实现 | 班级全局管理 |
|
||||
| 4 | `adminTeachers` | ❌ 未实现 | 教师全局管理 |
|
||||
| 5 | `adminStudents` | ❌ 未实现 | 学生全局管理 |
|
||||
| 6 | `adminDashboard` | ❌ 未实现 | 管理仪表盘聚合数据 |
|
||||
| 7 | `adminSchools` | ❌ 未实现 | 多学校列表(当前仅有单数 `school`) |
|
||||
| 8 | `adminGrades` | ❌ 未实现 | 年级管理 |
|
||||
| 9 | `adminDepartments` | ❌ 未实现 | 部门/教研组管理 |
|
||||
| 10 | `adminAcademicYears` | ❌ 未实现 | 学年学期管理 |
|
||||
| 11 | `adminAnnouncements` | ❌ 未实现 | 公告列表 |
|
||||
| 12 | `adminInvitationCodes` | ❌ 未实现 | 邀请码列表 |
|
||||
| 13 | `adminFiles` | ❌ 未实现 | 文件列表 |
|
||||
| 14 | `adminFileStats` | ❌ 未实现 | 文件统计 |
|
||||
| 15 | `aiProviders` | ❌ 未实现 | AI Provider 列表 |
|
||||
| 16 | `aiUsage` | ❌ 未实现 | AI 用量统计 |
|
||||
|
||||
#### 2.2.2 缺失的 Mutation(11 个)
|
||||
|
||||
| # | 契约 Mutation | 当前状态 | 备注 |
|
||||
| --- | ------------------------- | --------- | --------------------- |
|
||||
| 1 | `toggleUserStatus` | ❌ 未实现 | 用户启用/禁用/锁定 |
|
||||
| 2 | `updateRolePermissions` | ❌ 未实现 | 角色权限矩阵更新 |
|
||||
| 3 | `updateViewport` | ❌ 未实现 | 视口配置更新 |
|
||||
| 4 | `createAnnouncement` | ❌ 未实现 | 创建公告 |
|
||||
| 5 | `publishAnnouncement` | ❌ 未实现 | 发布公告 |
|
||||
| 6 | `archiveAnnouncement` | ❌ 未实现 | 归档公告 |
|
||||
| 7 | `toggleAnnouncementPin` | ❌ 未实现 | 公告置顶切换 |
|
||||
| 8 | `generateInvitationCodes` | ❌ 未实现 | 批量生成邀请码 |
|
||||
| 9 | `deleteInvitationCodes` | ❌ 未实现 | 删除邀请码 |
|
||||
| 10 | `deleteFiles` | ❌ 未实现 | 批量删除文件 |
|
||||
| 11 | `updateAiProvider` | ❌ 未实现 | 更新 AI Provider 配置 |
|
||||
|
||||
#### 2.2.3 已实现但需对齐的项
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 命名规范对齐 | 契约用扁平命名 `adminUsers`/`adminRoles`,实际实现用命名空间 `admin { users }`。需 ai03 与 ai16 协调统一 |
|
||||
| 2 | `auditLogs` 补 filter 参数 | 当前仅 `page/pageSize`,需补 `action/userId/dateRange` 等 filter |
|
||||
| 3 | `adminOrganization` 补 parentId 参数 | 当前无参数,需支持 `parentId` 筛选子节点 |
|
||||
| 4 | 用户/角色 CRUD 接入真实 iam gRPC | 当前返回 mock 数据 |
|
||||
|
||||
**相关文件:**
|
||||
|
||||
- `services/teacher-bff/src/admin/admin.resolver.ts`
|
||||
- `services/teacher-bff/src/admin/admin.service.ts`
|
||||
- `services/teacher-bff/src/admin/admin.module.ts`
|
||||
- `services/teacher-bff/src/graphql/graphql.controller.ts`
|
||||
- `services/teacher-bff/src/graphql/graphql.module.ts`
|
||||
- `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`
|
||||
|
||||
### 2.3 iam 服务(ai06 负责,间接影响)
|
||||
|
||||
**阻塞级别:P1(影响 admin 用户管理真实性)**
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | --------------------------------------------- | ---------------------------------- |
|
||||
| 1 | 补 `BatchGetUsers` RPC | admin 用户列表需要批量获取用户信息 |
|
||||
| 2 | 补 `CreateUser`/`UpdateUser`/`DeleteUser` RPC | admin 用户 CRUD 需要 iam 支持 |
|
||||
|
||||
---
|
||||
|
||||
## 3. admin-portal 前端待办(非阻塞)
|
||||
|
||||
以下工作不阻塞当前部署,但需在下游就绪后完成:
|
||||
|
||||
| # | 工作项 | 触发条件 |
|
||||
| --- | ------------------------------------------------ | -------------------------------- |
|
||||
| 1 | GraphQL query 字符串对齐命名规范 | ai03 与 ai16 协调命名后 |
|
||||
| 2 | 移除 MSW mock 依赖(生产构建已移除) | api-gateway + teacher-bff 就绪后 |
|
||||
| 3 | MF 模式集成测试(`NEXT_PUBLIC_MF_ENABLED=true`) | teacher-portal Shell 就绪后 |
|
||||
| 4 | OTel trace 上报联调 | otel-collector 就绪后 |
|
||||
| 5 | WebSocket 实时通知联调 | push-gateway 就绪后 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成项汇总
|
||||
|
||||
| 工作项 | 状态 | 验证方式 |
|
||||
| ------------------------------------------ | ---- | ------------------------------------------------------------- |
|
||||
| P2 MF Remote 骨架 | ✅ | arch:scan + typecheck |
|
||||
| P3 用户/角色权限管理 | ✅ | typecheck + lint + vitest |
|
||||
| P4 组织/学校/全局实体 | ✅ | typecheck + lint + vitest |
|
||||
| P5 审计日志/实时通知 | ✅ | typecheck + lint + vitest |
|
||||
| P6 硬化(A11y/Web Vitals/测试/Dockerfile) | ✅ | vitest 69/69 + Docker 构建成功 |
|
||||
| §7 差距分析 4 批次补齐 | ✅ | typecheck + lint + vitest |
|
||||
| Docker 镜像构建 | ✅ | `edu/admin-portal:test` 构建成功 |
|
||||
| Docker 容器运行 | ✅ | /api/health + /api/ready + /login + /admin/dashboard 全部 200 |
|
||||
| docker-compose.deploy.yml 服务定义 | ✅ | admin-portal 服务已添加 |
|
||||
| next.config.js output: standalone | ✅ | Docker 构建产物正确 |
|
||||
| .env.local 关闭 mock | ✅ | NEXT_PUBLIC_API_MOCKING=disabled |
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调顺序建议
|
||||
|
||||
1. **ai01** 完成 api-gateway `/api/admin/graphql` 路由 + admin 角色校验
|
||||
2. **ai03** 完成 teacher-bff admin Query/Mutation 补齐 + 命名规范对齐
|
||||
3. **ai06** 完成 iam gRPC 补齐(BatchGetUsers 等)
|
||||
4. **ai16** 前端 GraphQL query 字符串对齐命名规范
|
||||
5. 端到端联调(admin-portal → api-gateway → teacher-bff → iam/classes/core-edu)
|
||||
|
||||
---
|
||||
|
||||
**本文件由 ai16 维护,下游工作项请各负责 AI 完成后通知 ai16 更新状态。**
|
||||
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,
|
||||
);
|
||||
5
apps/admin-portal/next-env.d.ts
vendored
Normal file
5
apps/admin-portal/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
54
apps/admin-portal/next.config.js
Normal file
54
apps/admin-portal/next.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
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;
|
||||
61
apps/admin-portal/package.json
Normal file
61
apps/admin-portal/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"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",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal file
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
useAiProviders,
|
||||
useUpdateAiProvider,
|
||||
useAiUsage,
|
||||
} from "@/hooks/use-ai-settings";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
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 AiSettingsPage(): ReactNode {
|
||||
const { data: providers, loading, error, refetch } = useAiProviders();
|
||||
const { data: usage } = useAiUsage();
|
||||
const [updateProvider, mutationState] = useUpdateAiProvider();
|
||||
const { show } = useToast();
|
||||
|
||||
const handleToggle = async (id: string, isActive: boolean) => {
|
||||
const res = await updateProvider(id, { isActive: !isActive });
|
||||
if (res) {
|
||||
show("success", `Provider 已${!isActive ? "启用" : "禁用"}`);
|
||||
refetch();
|
||||
} else if (mutationState.error) {
|
||||
show("error", "操作失败", mutationState.error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.aiSettings.title")}
|
||||
description="AI 服务商与用量管理"
|
||||
/>
|
||||
|
||||
{/* AI Provider 列表 */}
|
||||
<PaperCard className="p-4 mb-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.aiSettings.providers")}
|
||||
</h3>
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && providers && (
|
||||
<>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.name"),
|
||||
"类型",
|
||||
t("admin.aiSettings.model"),
|
||||
t("admin.aiSettings.isPublic"),
|
||||
t("admin.aiSettings.isActive"),
|
||||
"API Base",
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{p.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{p.model}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={p.isPublic ? "active" : "disabled"} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant={p.isActive ? "primary" : "secondary"}
|
||||
onClick={() => handleToggle(p.id, p.isActive)}
|
||||
disabled={mutationState.loading}
|
||||
>
|
||||
{p.isActive ? "启用" : "禁用"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{p.apiBase}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(p.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* AI 用量统计 */}
|
||||
{usage && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalRequests")}
|
||||
value={usage.totalRequests}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalTokens")}
|
||||
value={usage.totalTokens}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalCost")}
|
||||
value={`$${usage.totalCost.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider 用量明细 */}
|
||||
<PaperCard className="p-4 mb-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")}
|
||||
</h3>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.aiSettings.provider"),
|
||||
t("admin.aiSettings.totalRequests"),
|
||||
t("admin.aiSettings.totalTokens"),
|
||||
t("admin.aiSettings.totalCost"),
|
||||
]}
|
||||
>
|
||||
{usage.byProvider.map((row) => (
|
||||
<TableRow key={row.providerName}>
|
||||
<TableCell>{row.providerName}</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.requests}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.tokens}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
${row.cost.toFixed(2)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</PaperCard>
|
||||
|
||||
{/* 7 天趋势 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
7 {t("admin.auditLogs.trend")}
|
||||
</h3>
|
||||
<Table
|
||||
headers={[
|
||||
"日期",
|
||||
t("admin.aiSettings.totalRequests"),
|
||||
t("admin.aiSettings.totalTokens"),
|
||||
]}
|
||||
>
|
||||
{usage.byDay.map((row) => (
|
||||
<TableRow key={row.date}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{row.date}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.requests}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.tokens}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</PaperCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal file
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useAnnouncements,
|
||||
useAnnouncementFilter,
|
||||
useCreateAnnouncement,
|
||||
usePublishAnnouncement,
|
||||
useArchiveAnnouncement,
|
||||
useToggleAnnouncementPin,
|
||||
} from "@/hooks/use-announcements";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AnnouncementViewModel } from "@/types/view-models";
|
||||
|
||||
const AUDIENCE_OPTIONS = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "teachers", label: "教师" },
|
||||
{ value: "students", label: "学生" },
|
||||
{ value: "parents", label: "家长" },
|
||||
] as const;
|
||||
|
||||
const audienceLabel: Record<string, string> = {
|
||||
all: "全部",
|
||||
teachers: "教师",
|
||||
students: "学生",
|
||||
parents: "家长",
|
||||
};
|
||||
|
||||
export default function AnnouncementsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useAnnouncementFilter();
|
||||
const { data, loading, error } = useAnnouncements(filter);
|
||||
const { show } = useToast();
|
||||
const [createAnnouncement] = useCreateAnnouncement();
|
||||
const [publishAnnouncement] = usePublishAnnouncement();
|
||||
const [archiveAnnouncement] = useArchiveAnnouncement();
|
||||
const [togglePin] = useToggleAnnouncementPin();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formContent, setFormContent] = useState("");
|
||||
const [formAudience, setFormAudience] = useState<string[]>(["all"]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormTitle("");
|
||||
setFormContent("");
|
||||
setFormAudience(["all"]);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const toggleAudience = (value: string) => {
|
||||
setFormAudience((prev) => {
|
||||
if (value === "all") {
|
||||
return ["all"];
|
||||
}
|
||||
const next = prev.filter((a) => a !== "all");
|
||||
if (next.includes(value)) {
|
||||
const filtered = next.filter((a) => a !== value);
|
||||
return filtered.length === 0 ? ["all"] : filtered;
|
||||
}
|
||||
return [...next, value];
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) {
|
||||
show("warning", "请填写标题和内容");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createAnnouncement({
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
audience: formAudience,
|
||||
});
|
||||
show("success", "公告已创建");
|
||||
setModalOpen(false);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"创建失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await publishAnnouncement(ann.id);
|
||||
show("success", "公告已发布");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await archiveAnnouncement(ann.id);
|
||||
show("success", "公告已归档");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await togglePin(ann.id);
|
||||
show("success", ann.isPinned ? "已取消置顶" : "已置顶");
|
||||
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.announcements.title")}
|
||||
description="管理全校公告、发布与归档"
|
||||
actions={
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
{t("admin.announcements.new")}
|
||||
</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.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="draft">{t("admin.announcements.draft")}</option>
|
||||
<option value="published">
|
||||
{t("admin.announcements.published")}
|
||||
</option>
|
||||
<option value="archived">
|
||||
{t("admin.announcements.archived")}
|
||||
</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.status"),
|
||||
t("admin.announcements.audience"),
|
||||
t("admin.announcements.author"),
|
||||
t("admin.announcements.pin"),
|
||||
t("admin.announcements.readCount"),
|
||||
t("admin.announcements.publishedAt"),
|
||||
t("admin.common.createdAt"),
|
||||
t("admin.common.actions"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((ann) => (
|
||||
<TableRow key={ann.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{ann.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={ann.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ann.audience
|
||||
.map((a) => audienceLabel[a] ?? a)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell>{ann.authorName}</TableCell>
|
||||
<TableCell>
|
||||
{ann.isPinned ? <Badge status="pinned" /> : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ann.readCount}/{ann.totalCount}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ann.publishedAt
|
||||
? new Date(ann.publishedAt).toLocaleDateString(
|
||||
"zh-CN",
|
||||
)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(ann.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{ann.status === "draft" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handlePublish(ann)}
|
||||
>
|
||||
{t("admin.announcements.publish")}
|
||||
</Button>
|
||||
)}
|
||||
{ann.status === "published" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleArchive(ann)}
|
||||
>
|
||||
{t("admin.announcements.archive")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleTogglePin(ann)}
|
||||
>
|
||||
{ann.isPinned
|
||||
? t("admin.announcements.unpin")
|
||||
: t("admin.announcements.pin")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{modalOpen && (
|
||||
<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: 480,
|
||||
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.announcements.new")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
标题
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
内容
|
||||
</span>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={5}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
}}
|
||||
placeholder="请输入公告内容"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.announcements.audience")}
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
{AUDIENCE_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-1"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formAudience.includes(opt.value)}
|
||||
onChange={() => toggleAudience(opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.common.loading")
|
||||
: t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal file
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useDataChangeLogs,
|
||||
useDataChangeLogFilter,
|
||||
exportDataChangeLogsCsv,
|
||||
} from "@/hooks/use-data-change-logs";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function DataChangesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setTableName, setAction } =
|
||||
useDataChangeLogFilter();
|
||||
const { data, loading, error } = useDataChangeLogs(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;
|
||||
}
|
||||
exportDataChangeLogsCsv(data.items);
|
||||
show("success", "已导出 CSV");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.dataChanges")}
|
||||
description="数据表变更记录"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
{t("admin.auditLogs.exportCsv")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<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.tableName ?? ""}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}数据表</option>
|
||||
<option value="users">用户表</option>
|
||||
<option value="roles">角色表</option>
|
||||
<option value="viewports">视口表</option>
|
||||
<option value="system_settings">系统设置表</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.action ?? ""}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}操作</option>
|
||||
<option value="create">创建</option>
|
||||
<option value="update">更新</option>
|
||||
<option value="delete">删除</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.tableName"),
|
||||
t("admin.auditLogs.action"),
|
||||
t("admin.auditLogs.recordId"),
|
||||
t("admin.auditLogs.beforeState"),
|
||||
t("admin.auditLogs.afterState"),
|
||||
t("admin.auditLogs.ip"),
|
||||
]}
|
||||
>
|
||||
{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
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.tableName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.recordId}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.beforeState ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.afterState ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal file
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useLoginLogs, useLoginLogFilter } from "@/hooks/use-login-logs";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function LoginLogsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setAction, setStatus } =
|
||||
useLoginLogFilter();
|
||||
const { data, loading, error } = useLoginLogs(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.auditLogs.loginLogs")}
|
||||
description="用户登录与登出记录"
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<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="signin">登录</option>
|
||||
<option value="signout">登出</option>
|
||||
<option value="signup">注册</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.status ?? ""}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failure">失败</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.common.name"),
|
||||
t("admin.auditLogs.loginAction"),
|
||||
t("admin.auditLogs.loginStatus"),
|
||||
t("admin.auditLogs.ip"),
|
||||
t("admin.auditLogs.userAgent"),
|
||||
t("admin.auditLogs.failureReason"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(log.occurredAt).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>{log.userName}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.action}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background:
|
||||
log.status === "success"
|
||||
? "var(--color-success)20"
|
||||
: "var(--color-danger)20",
|
||||
color:
|
||||
log.status === "success"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-danger)",
|
||||
border: `1px solid ${
|
||||
log.status === "success"
|
||||
? "var(--color-success)40"
|
||||
: "var(--color-danger)40"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{log.status === "success" ? "成功" : "失败"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.userAgent}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: log.failureReason
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{log.failureReason ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal file
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
useAuditOverviewStats,
|
||||
useAuditTrend,
|
||||
} from "@/hooks/use-audit-overview";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: 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}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditOverviewPage(): ReactNode {
|
||||
const { data: stats, loading, error } = useAuditOverviewStats();
|
||||
const { data: trend } = useAuditTrend(7);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.overview")}
|
||||
description="审计事件统计与趋势"
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{stats && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.totalEvents")}
|
||||
value={stats.totalEvents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.todayEvents")}
|
||||
value={stats.todayEvents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.uniqueActors")}
|
||||
value={stats.uniqueActors}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.failedActions")}
|
||||
value={stats.failedActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 热门操作 & 热门资源 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.topActions")}
|
||||
</h3>
|
||||
{stats.topActions.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table headers={["操作", "次数"]}>
|
||||
{stats.topActions.map((item) => (
|
||||
<TableRow key={item.action}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{item.action}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{item.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.topResources")}
|
||||
</h3>
|
||||
{stats.topResources.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table headers={["资源", "次数"]}>
|
||||
{stats.topResources.map((item) => (
|
||||
<TableRow key={item.resource}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{item.resource}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{item.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.trend")}
|
||||
</h3>
|
||||
{!trend || trend.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.auditLogs.time"),
|
||||
t("admin.auditLogs.action"),
|
||||
t("admin.common.total"),
|
||||
]}
|
||||
>
|
||||
{trend.map((point) => (
|
||||
<TableRow key={`${point.date}-${point.action}`}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{point.date}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{point.action}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{point.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
161
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"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 { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal file
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useFiles,
|
||||
useFileStats,
|
||||
useDeleteFiles,
|
||||
useFileFilter,
|
||||
} from "@/hooks/use-files";
|
||||
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";
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function FilesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setMimeType, setEntityType } =
|
||||
useFileFilter();
|
||||
const { data, loading, error, refetch } = useFiles(filter);
|
||||
const { data: stats } = useFileStats();
|
||||
const [deleteFiles] = useDeleteFiles();
|
||||
const { show } = useToast();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!data || data.items.length === 0) return;
|
||||
if (selected.size === data.items.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(data.items.map((f) => f.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selected.size === 0) {
|
||||
show("warning", "请先选择文件");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除选中的 ${selected.size} 个文件?`)) return;
|
||||
const ok = await deleteFiles(Array.from(selected));
|
||||
if (ok) {
|
||||
show("success", `已删除 ${selected.size} 个文件`);
|
||||
setSelected(new Set());
|
||||
refetch();
|
||||
} else {
|
||||
show("error", "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.files.title")}
|
||||
description="全校文件资源管理"
|
||||
actions={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
{t("admin.files.batchDelete")} ({selected.size})
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{t("admin.files.totalFiles")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{stats.totalFiles}
|
||||
</p>
|
||||
</PaperCard>
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{t("admin.files.totalSize")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(stats.totalSize)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<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.mimeType ?? ""}
|
||||
onChange={(e) => setMimeType(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.files.mimeType")}
|
||||
</option>
|
||||
<option value="application/pdf">PDF</option>
|
||||
<option value="image/jpeg">JPEG</option>
|
||||
<option value="image/png">PNG</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
|
||||
Word
|
||||
</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
|
||||
Excel
|
||||
</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.presentationml.presentation">
|
||||
PPT
|
||||
</option>
|
||||
<option value="video/mp4">MP4</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.entityType ?? ""}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.files.entityType")}
|
||||
</option>
|
||||
<option value="exam">考试</option>
|
||||
<option value="lesson_plan">教案</option>
|
||||
<option value="user_avatar">用户头像</option>
|
||||
<option value="announcement">公告</option>
|
||||
<option value="homework">作业</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.files.filename"),
|
||||
t("admin.files.mimeType"),
|
||||
t("admin.files.size"),
|
||||
t("admin.files.entityType"),
|
||||
t("admin.files.uploadedBy"),
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((file) => {
|
||||
const isImage = file.mimeType.startsWith("image/");
|
||||
const isPdf = file.mimeType === "application/pdf";
|
||||
return (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell style={{ width: 32 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(file.id)}
|
||||
onChange={() => toggleSelect(file.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isImage ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.filename}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<span>{file.filename}</span>
|
||||
</div>
|
||||
) : isPdf ? (
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{file.filename}
|
||||
</a>
|
||||
) : (
|
||||
<span>{file.filename}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{file.mimeType}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell>{file.entityType}</TableCell>
|
||||
<TableCell>{file.uploadedByName}</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(file.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
data.items.length > 0 &&
|
||||
selected.size === data.items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
全选 (已选 {selected.size}/{data.items.length})
|
||||
</label>
|
||||
</div>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal file
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useInvitationCodes,
|
||||
useInvitationCodeFilter,
|
||||
useGenerateInvitationCodes,
|
||||
useDeleteInvitationCodes,
|
||||
} from "@/hooks/use-invitation-codes";
|
||||
import { useRoles } from "@/hooks/use-roles";
|
||||
import { useClasses } from "@/hooks/use-classes";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function InvitationCodesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useInvitationCodeFilter();
|
||||
const { data, loading, error } = useInvitationCodes(filter);
|
||||
const { data: roles } = useRoles();
|
||||
const { data: classesData } = useClasses({ page: 1, pageSize: 100 });
|
||||
const { show } = useToast();
|
||||
const [generateCodes] = useGenerateInvitationCodes();
|
||||
const [deleteCodes] = useDeleteInvitationCodes();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 生成对话框
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [genCount, setGenCount] = useState(10);
|
||||
const [genRoleId, setGenRoleId] = useState("");
|
||||
const [genClassId, setGenClassId] = useState("");
|
||||
const [genEmail, setGenEmail] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// 生成结果对话框
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
|
||||
const [generatedBatchId, setGeneratedBatchId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const classes = useMemo(() => classesData?.items ?? [], [classesData]);
|
||||
|
||||
const allChecked = useMemo(
|
||||
() =>
|
||||
data !== null &&
|
||||
data.items.length > 0 &&
|
||||
data.items.every((ic) => selectedIds.has(ic.id)),
|
||||
[data, selectedIds],
|
||||
);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!data) return;
|
||||
if (allChecked) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(data.items.map((ic) => ic.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const openGenerateDialog = () => {
|
||||
setGenCount(10);
|
||||
setGenRoleId("");
|
||||
setGenClassId("");
|
||||
setGenEmail("");
|
||||
setGenerateOpen(true);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (genCount < 1 || genCount > 1000) {
|
||||
show("warning", "数量范围 1-1000");
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await generateCodes({
|
||||
count: genCount,
|
||||
roleId: genRoleId || undefined,
|
||||
classId: genClassId || undefined,
|
||||
email: genEmail || undefined,
|
||||
});
|
||||
if (result) {
|
||||
show(
|
||||
"success",
|
||||
t("admin.invitationCodes.generateSuccess", {
|
||||
count: result.codes.length,
|
||||
}),
|
||||
);
|
||||
setGeneratedCodes(result.codes);
|
||||
setGeneratedBatchId(result.batchId);
|
||||
setGenerateOpen(false);
|
||||
setResultOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"生成失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
show("warning", "请先选择要删除的邀请码");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除选中的 ${selectedIds.size} 个邀请码?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ok = await deleteCodes(Array.from(selectedIds));
|
||||
if (ok) {
|
||||
show("success", "删除成功");
|
||||
setSelectedIds(new Set());
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"删除失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCodes.join("\n"));
|
||||
show("success", "已复制到剪贴板");
|
||||
} catch {
|
||||
show("error", "复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.invitationCodes.title")}
|
||||
description="批量生成、管理与清理邀请码"
|
||||
actions={
|
||||
<Button variant="primary" onClick={openGenerateDialog}>
|
||||
{t("admin.invitationCodes.generate")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<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="unused">{t("admin.invitationCodes.unused")}</option>
|
||||
<option value="used">{t("admin.invitationCodes.used")}</option>
|
||||
<option value="expired">
|
||||
{t("admin.invitationCodes.expired")}
|
||||
</option>
|
||||
</Select>
|
||||
{selectedIds.size > 0 && (
|
||||
<Button variant="danger" onClick={handleBatchDelete}>
|
||||
{t("admin.common.delete")}({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
</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.invitationCodes.code"),
|
||||
t("admin.common.status"),
|
||||
t("admin.common.role"),
|
||||
t("admin.common.class"),
|
||||
t("admin.common.email"),
|
||||
t("admin.invitationCodes.batch"),
|
||||
t("admin.invitationCodes.usedBy"),
|
||||
t("admin.invitationCodes.usedAt"),
|
||||
t("admin.common.createdAt"),
|
||||
t("admin.invitationCodes.expiresAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((ic) => (
|
||||
<TableRow key={ic.id}>
|
||||
<TableCell style={{ width: 32 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(ic.id)}
|
||||
onChange={() => toggleSelect(ic.id)}
|
||||
aria-label={`select ${ic.code}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{ic.code}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={ic.status} />
|
||||
</TableCell>
|
||||
<TableCell>{ic.roleName ?? "—"}</TableCell>
|
||||
<TableCell>{ic.className ?? "—"}</TableCell>
|
||||
<TableCell>{ic.email ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{ic.batchId}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{ic.usedByName ?? "—"}</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ic.usedAt
|
||||
? new Date(ic.usedAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(ic.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ic.expiresAt
|
||||
? new Date(ic.expiresAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
全选当前页
|
||||
</label>
|
||||
</div>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={(p) => {
|
||||
setSelectedIds(new Set());
|
||||
setPage(p);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 生成对话框 */}
|
||||
{generateOpen && (
|
||||
<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,
|
||||
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.invitationCodes.generate")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.generateCount")}(1-1000)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={genCount}
|
||||
onChange={(e) => setGenCount(Number(e.target.value) || 0)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindRole")}
|
||||
</span>
|
||||
<Select
|
||||
value={genRoleId}
|
||||
onChange={(e) => setGenRoleId(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}(不绑定)</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindClass")}(可选)
|
||||
</span>
|
||||
<Select
|
||||
value={genClassId}
|
||||
onChange={(e) => setGenClassId(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}(不绑定)</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindEmail")}(可选)
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={genEmail}
|
||||
onChange={(e) => setGenEmail(e.target.value)}
|
||||
placeholder="user@edu.test"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setGenerateOpen(false)}
|
||||
>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating
|
||||
? t("admin.common.loading")
|
||||
: t("admin.invitationCodes.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果对话框 */}
|
||||
{resultOpen && (
|
||||
<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: 480,
|
||||
maxHeight: "80vh",
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("admin.invitationCodes.generateSuccess", {
|
||||
count: generatedCodes.length,
|
||||
})}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.invitationCodes.batch")}:{generatedBatchId}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
background: "var(--bg-canvas, var(--bg-paper))",
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{generatedCodes.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleCopyCodes}>
|
||||
复制全部
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setResultOpen(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{t("admin.common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useAcademicYears } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AcademicYearViewModel } from "@/types/view-models";
|
||||
|
||||
function CurrentBadge(): ReactNode {
|
||||
const color = "var(--color-success)";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.currentYear")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcademicYearPage(): ReactNode {
|
||||
const { data, loading, error } = useAcademicYears();
|
||||
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"学年",
|
||||
t("admin.school.startDate"),
|
||||
t("admin.school.endDate"),
|
||||
"第一学期",
|
||||
"第二学期",
|
||||
t("admin.common.status"),
|
||||
]}
|
||||
>
|
||||
{data.map((year: AcademicYearViewModel) => (
|
||||
<TableRow key={year.id}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontWeight: year.isCurrent ? 600 : 500,
|
||||
color: year.isCurrent
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{year.name}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.startDate}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.endDate}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.firstSemesterStart} ~ {year.firstSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.secondSemesterStart} ~ {year.secondSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{year.isCurrent ? <CurrentBadge /> : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useDepartments, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function DepartmentsPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useDepartments(schoolId || undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"部门",
|
||||
t("admin.common.school"),
|
||||
t("admin.school.leader"),
|
||||
t("admin.school.memberCount"),
|
||||
t("admin.school.subject"),
|
||||
]}
|
||||
>
|
||||
{data.map((dept) => (
|
||||
<TableRow key={dept.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{dept.name}
|
||||
</TableCell>
|
||||
<TableCell>{dept.schoolName}</TableCell>
|
||||
<TableCell>{dept.leaderName ?? "—"}</TableCell>
|
||||
<TableCell>{dept.memberCount}</TableCell>
|
||||
<TableCell>{dept.subject ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useGradeInsights } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface SummaryCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, suffix }: SummaryCardProps): 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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function GradeInsightsPage(): ReactNode {
|
||||
const { data: grades, loading: gradesLoading } = useGrades();
|
||||
const [gradeId, setGradeId] = useState<string>("");
|
||||
|
||||
const selectedGradeId = gradeId || (grades[0]?.id ?? null);
|
||||
const { data, loading, error } = useGradeInsights(selectedGradeId);
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data.classRankings].sort((a, b) => a.rank - b.rank);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={selectedGradeId ?? ""}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
disabled={gradesLoading || grades.length === 0}
|
||||
>
|
||||
{grades.length === 0 && <option value="">暂无年级</option>}
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}({g.schoolName})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && !data && (
|
||||
<PaperCard className="p-4">
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
</PaperCard>
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 全校概览 */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.summary")} — {data.gradeName}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalStudents")}
|
||||
value={data.schoolWideSummary.totalStudents}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalClasses")}
|
||||
value={data.schoolWideSummary.totalClasses}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.avgScore")}
|
||||
value={data.schoolWideSummary.avgScore.toFixed(1)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.passingRate")}
|
||||
value={formatPercent(data.schoolWideSummary.passingRate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 作业时间线 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.homework")}
|
||||
</h3>
|
||||
{data.homeworkTimeline.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"作业",
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
"提交情况",
|
||||
t("admin.school.endDate"),
|
||||
]}
|
||||
>
|
||||
{data.homeworkTimeline.map((hw) => (
|
||||
<TableRow key={hw.homeworkId}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{hw.title}
|
||||
</TableCell>
|
||||
<TableCell>{hw.className}</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{hw.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{hw.submittedCount}/{hw.totalCount}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{hw.dueDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 班级排名 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.rankings")}
|
||||
</h3>
|
||||
{ranked.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.school.insights.rank"),
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
t("admin.school.insights.delta"),
|
||||
]}
|
||||
>
|
||||
{ranked.map((cls) => {
|
||||
const deltaColor =
|
||||
cls.delta > 0
|
||||
? "var(--color-success)"
|
||||
: cls.delta < 0
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-ink-muted)";
|
||||
const deltaSign = cls.delta > 0 ? "+" : "";
|
||||
return (
|
||||
<TableRow key={cls.classId}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{cls.rank}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{cls.className}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{cls.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: deltaColor,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{deltaSign}
|
||||
{cls.delta.toFixed(1)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { GradeViewModel } from "@/types/view-models";
|
||||
|
||||
export default function GradesPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useGrades(schoolId || undefined);
|
||||
|
||||
const sorted = useMemo<GradeViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.school"),
|
||||
t("admin.school.director"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.viewports.sortOrder"),
|
||||
]}
|
||||
>
|
||||
{sorted.map((grade) => (
|
||||
<TableRow key={grade.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{grade.name}
|
||||
</TableCell>
|
||||
<TableCell>{grade.schoolName}</TableCell>
|
||||
<TableCell>{grade.directorName ?? "—"}</TableCell>
|
||||
<TableCell>{grade.classCount}</TableCell>
|
||||
<TableCell>{grade.studentCount}</TableCell>
|
||||
<TableCell>{grade.sortOrder}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { PageHeader } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ href: "/admin/school/schools", label: t("admin.school.schools") },
|
||||
{ href: "/admin/school/grades", label: t("admin.school.grades") },
|
||||
{
|
||||
href: "/admin/school/grades/insights",
|
||||
label: t("admin.school.gradeInsights"),
|
||||
},
|
||||
{ href: "/admin/school/departments", label: t("admin.school.departments") },
|
||||
{
|
||||
href: "/admin/school/academic-year",
|
||||
label: t("admin.school.academicYear"),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SchoolLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.school.title")}
|
||||
description="学校、年级、部门与学年学期管理"
|
||||
/>
|
||||
|
||||
<nav
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive =
|
||||
pathname === tab.href ||
|
||||
(tab.href !== "/admin/school/grades" &&
|
||||
pathname.startsWith(tab.href));
|
||||
// 年级管理 / 年级洞察 都在 /admin/school/grades 下,需要精确区分
|
||||
const exactActive =
|
||||
tab.href === "/admin/school/grades"
|
||||
? pathname === "/admin/school/grades"
|
||||
: isActive;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: exactActive
|
||||
? "var(--color-ink)"
|
||||
: "var(--color-ink-muted)",
|
||||
textDecoration: "none",
|
||||
borderBottom: exactActive
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
marginBottom: "-1px",
|
||||
fontWeight: exactActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SchoolPage(): never {
|
||||
redirect("/admin/school/schools");
|
||||
}
|
||||
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Input,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { SchoolViewModel } from "@/types/view-models";
|
||||
|
||||
export default function SchoolsPage(): ReactNode {
|
||||
const { data, loading, error } = useSchools();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo<SchoolViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return data;
|
||||
return data.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
(s.principalName ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</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.common.school"),
|
||||
t("admin.system.schoolCode"),
|
||||
t("admin.system.address"),
|
||||
t("admin.system.contactPhone"),
|
||||
t("admin.system.contactEmail"),
|
||||
t("admin.school.principal"),
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.dashboard.totalTeachers"),
|
||||
]}
|
||||
>
|
||||
{filtered.map((school) => (
|
||||
<TableRow key={school.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{school.name}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.code}
|
||||
</TableCell>
|
||||
<TableCell>{school.address}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactPhone}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactEmail}
|
||||
</TableCell>
|
||||
<TableCell>{school.principalName ?? "—"}</TableCell>
|
||||
<TableCell>{school.gradeCount}</TableCell>
|
||||
<TableCell>{school.classCount}</TableCell>
|
||||
<TableCell>{school.studentCount}</TableCell>
|
||||
<TableCell>{school.teacherCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
602
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
602
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
"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 {
|
||||
mockSecurityPolicy,
|
||||
mockFileUploadConfig,
|
||||
mockNotificationConfig,
|
||||
mockBrandConfig,
|
||||
} from "@/mocks/fixtures";
|
||||
import type {
|
||||
SystemSettingsViewModel,
|
||||
SecurityPolicy,
|
||||
FileUploadConfig,
|
||||
NotificationConfig,
|
||||
BrandConfig,
|
||||
} 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);
|
||||
const [securityPolicy, setSecurityPolicy] =
|
||||
useState<SecurityPolicy>(mockSecurityPolicy);
|
||||
const [fileUploadConfig, setFileUploadConfig] =
|
||||
useState<FileUploadConfig>(mockFileUploadConfig);
|
||||
const [allowedTypesText, setAllowedTypesText] = useState(
|
||||
mockFileUploadConfig.allowedTypes.join(", "),
|
||||
);
|
||||
const [notificationConfig, setNotificationConfig] =
|
||||
useState<NotificationConfig>(mockNotificationConfig);
|
||||
const [brandConfig, setBrandConfig] = useState<BrandConfig>(mockBrandConfig);
|
||||
|
||||
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>
|
||||
|
||||
{/* 安全策略 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.security")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<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.passwordMinLength")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={4}
|
||||
max={64}
|
||||
value={securityPolicy.passwordMinLength}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordMinLength: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.sessionTimeout")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={securityPolicy.sessionTimeoutMinutes}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
sessionTimeoutMinutes: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.passwordRequireSpecial}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordRequireSpecial: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.passwordRequireSpecial")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.passwordRequireUppercase}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordRequireUppercase: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.passwordRequireUppercase")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.forcePasswordChange}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
forcePasswordChange: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.forcePasswordChange")}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 文件上传配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.fileUpload")}
|
||||
</h3>
|
||||
<div 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.system.maxFileSize")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={fileUploadConfig.maxFileSize}
|
||||
onChange={(e) =>
|
||||
setFileUploadConfig((prev) => ({
|
||||
...prev,
|
||||
maxFileSize: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.allowedTypes")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={allowedTypesText}
|
||||
onChange={(e) => setAllowedTypesText(e.target.value)}
|
||||
placeholder="pdf, jpg, png, docx"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setFileUploadConfig((prev) => ({
|
||||
...prev,
|
||||
allowedTypes: allowedTypesText
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
}));
|
||||
show("success", t("admin.system.saveSuccess"));
|
||||
}}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 通知配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.notification")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyNewUser}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyNewUser: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyNewUser")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyScheduleChange}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyScheduleChange: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyScheduleChange")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyAnnouncement}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyAnnouncement: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyAnnouncement")}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 品牌配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.brand")}
|
||||
</h3>
|
||||
<div 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.system.logoUrl")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={brandConfig.logoUrl}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
logoUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.primaryColor")}
|
||||
</span>
|
||||
<Input
|
||||
type="color"
|
||||
value={brandConfig.primaryColor}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
primaryColor: e.target.value,
|
||||
}))
|
||||
}
|
||||
style={{ width: 60, height: 32, padding: 2 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.schoolMotto")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={brandConfig.schoolMotto}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
schoolMotto: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
28
apps/admin-portal/src/app/login/layout.tsx
Normal file
28
apps/admin-portal/src/app/login/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"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 { MswInitializer } from "@/components/msw-initializer";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Login layout - 提供 login 页面所需的 providers
|
||||
*
|
||||
* login 页面不在 /admin 下,需要独立的 provider 链
|
||||
*/
|
||||
export default function LoginLayout({ children }: { children: ReactNode }) {
|
||||
const [mswReady, setMswReady] = useState(false);
|
||||
|
||||
return (
|
||||
<GraphQLProvider>
|
||||
<MswInitializer onReady={() => setMswReady(true)} />
|
||||
{mswReady && (
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
)}
|
||||
</GraphQLProvider>
|
||||
);
|
||||
}
|
||||
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");
|
||||
}
|
||||
181
apps/admin-portal/src/components/admin-shell.tsx
Normal file
181
apps/admin-portal/src/components/admin-shell.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"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/school", label: t("admin.school.title") },
|
||||
{ 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/announcements", label: t("admin.announcements.title") },
|
||||
{ route: "/admin/invitation-codes", label: t("admin.invitationCodes.title") },
|
||||
{ route: "/admin/files", label: t("admin.files.title") },
|
||||
{ route: "/admin/ai-settings", label: t("admin.aiSettings.title") },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
href: string;
|
||||
exact: boolean;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ label: "操作日志", href: "/admin/audit-logs", exact: true },
|
||||
{
|
||||
label: t("admin.auditLogs.overview"),
|
||||
href: "/admin/audit-logs/overview",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.dataChanges"),
|
||||
href: "/admin/audit-logs/data-changes",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.loginLogs"),
|
||||
href: "/admin/audit-logs/login-logs",
|
||||
exact: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function AuditLogsTabs(): ReactNode {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 mb-4"
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = tab.exact
|
||||
? pathname === tab.href
|
||||
: pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: active ? "var(--color-accent)" : "var(--color-ink-muted)",
|
||||
borderBottom: active
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
textDecoration: "none",
|
||||
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 通用 UI 组件测试
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
PaperCard,
|
||||
PageHeader,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Pagination,
|
||||
} from "./ui";
|
||||
|
||||
describe("PaperCard", () => {
|
||||
it("渲染 children", () => {
|
||||
render(<PaperCard>内容</PaperCard>);
|
||||
expect(screen.getByText("内容")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("支持自定义 className", () => {
|
||||
const { container } = render(
|
||||
<PaperCard className="custom-class">内容</PaperCard>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageHeader", () => {
|
||||
it("渲染标题", () => {
|
||||
render(<PageHeader title="用户管理" />);
|
||||
expect(screen.getByText("用户管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染描述", () => {
|
||||
render(<PageHeader title="用户管理" description="管理所有用户" />);
|
||||
expect(screen.getByText("管理所有用户")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染操作按钮", () => {
|
||||
render(<PageHeader title="用户管理" actions={<button>新建</button>} />);
|
||||
expect(screen.getByText("新建")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button", () => {
|
||||
it("默认 secondary variant", () => {
|
||||
render(<Button>点击</Button>);
|
||||
expect(screen.getByText("点击")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>点击</Button>);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disabled 时不触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button onClick={onClick} disabled>
|
||||
点击
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("支持所有 variant", () => {
|
||||
const { rerender } = render(<Button variant="primary">主按钮</Button>);
|
||||
expect(screen.getByText("主按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="danger">危险按钮</Button>);
|
||||
expect(screen.getByText("危险按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="ghost">幽灵按钮</Button>);
|
||||
expect(screen.getByText("幽灵按钮")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("渲染输入框并支持输入", () => {
|
||||
render(<Input placeholder="请输入" />);
|
||||
const input = screen.getByPlaceholderText("请输入");
|
||||
fireEvent.change(input, { target: { value: "测试内容" } });
|
||||
expect(input).toHaveValue("测试内容");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("渲染下拉框和选项", () => {
|
||||
render(
|
||||
<Select>
|
||||
<option value="a">选项A</option>
|
||||
<option value="b">选项B</option>
|
||||
</Select>,
|
||||
);
|
||||
expect(screen.getByText("选项A")).toBeInTheDocument();
|
||||
expect(screen.getByText("选项B")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge", () => {
|
||||
it("渲染 active 状态标签", () => {
|
||||
render(<Badge status="active" />);
|
||||
expect(screen.getByText("启用")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染 locked 状态标签", () => {
|
||||
render(<Badge status="locked" />);
|
||||
expect(screen.getByText("锁定")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染公告相关状态", () => {
|
||||
const { rerender } = render(<Badge status="draft" />);
|
||||
expect(screen.getByText("草稿")).toBeInTheDocument();
|
||||
rerender(<Badge status="published" />);
|
||||
expect(screen.getByText("已发布")).toBeInTheDocument();
|
||||
rerender(<Badge status="archived" />);
|
||||
expect(screen.getByText("已归档")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染邀请码状态", () => {
|
||||
const { rerender } = render(<Badge status="unused" />);
|
||||
expect(screen.getByText("未使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="used" />);
|
||||
expect(screen.getByText("已使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="expired" />);
|
||||
expect(screen.getByText("已过期")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("未知状态显示原始值", () => {
|
||||
render(<Badge status="unknown-status" />);
|
||||
expect(screen.getByText("unknown-status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table", () => {
|
||||
it("渲染表头和数据行", () => {
|
||||
render(
|
||||
<Table headers={["姓名", "邮箱"]}>
|
||||
<TableRow>
|
||||
<TableCell>张三</TableCell>
|
||||
<TableCell>zhangsan@test.com</TableCell>
|
||||
</TableRow>
|
||||
</Table>,
|
||||
);
|
||||
expect(screen.getByText("姓名")).toBeInTheDocument();
|
||||
expect(screen.getByText("邮箱")).toBeInTheDocument();
|
||||
expect(screen.getByText("张三")).toBeInTheDocument();
|
||||
expect(screen.getByText("zhangsan@test.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("渲染空状态消息", () => {
|
||||
render(<EmptyState message="暂无数据" />);
|
||||
expect(screen.getByText("暂无数据")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("渲染加载中文案", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("加载中...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("渲染错误消息", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.getByText("加载失败")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("提供 onRetry 时渲染重试按钮", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="加载失败" onRetry={onRetry} />);
|
||||
const retryBtn = screen.getByText("重试");
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("不提供 onRetry 时不渲染重试按钮", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.queryByText("重试")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("渲染总数和页码", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/共 45 条/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/第 2\/3 页/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("第一页禁用上一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("上一页")).toBeDisabled();
|
||||
expect(screen.getByText("下一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("最后一页禁用下一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={3}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("下一页")).toBeDisabled();
|
||||
expect(screen.getByText("上一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("点击上一页/下一页触发 onPageChange", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("上一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText("下一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("total 为 0 时显示第 1/1 页", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/第 1\/1 页/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
389
apps/admin-portal/src/components/ui.tsx
Normal file
389
apps/admin-portal/src/components/ui.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 通用 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)",
|
||||
draft: "var(--color-warning)",
|
||||
published: "var(--color-success)",
|
||||
archived: "var(--color-ink-muted)",
|
||||
unused: "var(--color-accent)",
|
||||
used: "var(--color-success)",
|
||||
expired: "var(--color-danger)",
|
||||
pinned: "var(--color-accent)",
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
active: "启用",
|
||||
disabled: "禁用",
|
||||
locked: "锁定",
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
down: "异常",
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
unused: "未使用",
|
||||
used: "已使用",
|
||||
expired: "已过期",
|
||||
pinned: "置顶",
|
||||
};
|
||||
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;
|
||||
}
|
||||
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 配置 Hooks
|
||||
*
|
||||
* contract §2.4:aiProviders / updateAiProvider / aiUsage
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
AI_PROVIDERS_QUERY,
|
||||
UPDATE_AI_PROVIDER_MUTATION,
|
||||
AI_USAGE_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
AiProviderViewModel,
|
||||
AiUsageViewModel,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AiProvidersResponse {
|
||||
aiProviders: AiProviderViewModel[];
|
||||
}
|
||||
|
||||
interface UpdateAiProviderResponse {
|
||||
updateAiProvider: { id: string; isActive: boolean };
|
||||
}
|
||||
|
||||
interface AiUsageResponse {
|
||||
aiUsage: AiUsageViewModel;
|
||||
}
|
||||
|
||||
export function useAiProviders() {
|
||||
const result = useGraphQuery<AiProvidersResponse>(AI_PROVIDERS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.aiProviders ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateAiProvider() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UpdateAiProviderResponse,
|
||||
{ id: string; input: { isActive: boolean } }
|
||||
>(UPDATE_AI_PROVIDER_MUTATION);
|
||||
const update = useCallback(
|
||||
async (id: string, input: { isActive: boolean }) => {
|
||||
const res = await run({ id, input });
|
||||
return res.data?.updateAiProvider ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
|
||||
export function useAiUsage() {
|
||||
const result = useGraphQuery<AiUsageResponse>(AI_USAGE_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.aiUsage ?? null,
|
||||
};
|
||||
}
|
||||
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 公告管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminAnnouncements / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_ANNOUNCEMENTS_QUERY,
|
||||
CREATE_ANNOUNCEMENT_MUTATION,
|
||||
PUBLISH_ANNOUNCEMENT_MUTATION,
|
||||
ARCHIVE_ANNOUNCEMENT_MUTATION,
|
||||
TOGGLE_ANNOUNCEMENT_PIN_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
AnnouncementViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminAnnouncementsResponse {
|
||||
adminAnnouncements: PaginatedResult<AnnouncementViewModel>;
|
||||
}
|
||||
|
||||
interface AnnouncementMutationResponse {
|
||||
createAnnouncement?: { id: string; title: string; status: string };
|
||||
publishAnnouncement?: { id: string; status: string };
|
||||
archiveAnnouncement?: { id: string; status: string };
|
||||
toggleAnnouncementPin?: { id: string; isPinned: boolean };
|
||||
}
|
||||
|
||||
interface CreateAnnouncementInput {
|
||||
title: string;
|
||||
content: string;
|
||||
audience?: string[];
|
||||
}
|
||||
|
||||
export function useAnnouncements(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminAnnouncementsResponse>(
|
||||
ADMIN_ANNOUNCEMENTS_QUERY,
|
||||
{ filter },
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminAnnouncements ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAnnouncementFilter(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 };
|
||||
}
|
||||
|
||||
export function useCreateAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ input: CreateAnnouncementInput }
|
||||
>(CREATE_ANNOUNCEMENT_MUTATION);
|
||||
const create = useCallback(
|
||||
async (input: CreateAnnouncementInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.createAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [create, state] as const;
|
||||
}
|
||||
|
||||
export function usePublishAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(PUBLISH_ANNOUNCEMENT_MUTATION);
|
||||
const publish = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.publishAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [publish, state] as const;
|
||||
}
|
||||
|
||||
export function useArchiveAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(ARCHIVE_ANNOUNCEMENT_MUTATION);
|
||||
const archive = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.archiveAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [archive, state] as const;
|
||||
}
|
||||
|
||||
export function useToggleAnnouncementPin() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(TOGGLE_ANNOUNCEMENT_PIN_MUTATION);
|
||||
const toggle = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.toggleAnnouncementPin ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [toggle, state] as const;
|
||||
}
|
||||
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);
|
||||
}
|
||||
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 审计总览 Hooks
|
||||
*
|
||||
* contract §2.4:auditOverviewStats / auditTrend
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
AUDIT_OVERVIEW_STATS_QUERY,
|
||||
AUDIT_TREND_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type { AuditOverviewStats, AuditTrendPoint } from "@/types/view-models";
|
||||
|
||||
interface AuditOverviewStatsResponse {
|
||||
auditOverviewStats: AuditOverviewStats;
|
||||
}
|
||||
|
||||
interface AuditTrendResponse {
|
||||
auditTrend: AuditTrendPoint[];
|
||||
}
|
||||
|
||||
export function useAuditOverviewStats() {
|
||||
const result = useGraphQuery<AuditOverviewStatsResponse>(
|
||||
AUDIT_OVERVIEW_STATS_QUERY,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.auditOverviewStats ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuditTrend(days?: number) {
|
||||
const result = useGraphQuery<AuditTrendResponse>(AUDIT_TREND_QUERY, {
|
||||
days,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.auditTrend ?? null,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 数据变更日志 Hooks
|
||||
*
|
||||
* contract §2.4:dataChangeLogs
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { DATA_CHANGE_LOGS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
DataChangeLogViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface DataChangeLogsResponse {
|
||||
dataChangeLogs: PaginatedResult<DataChangeLogViewModel>;
|
||||
}
|
||||
|
||||
interface DataChangeLogFilter extends Partial<ListFilter> {
|
||||
tableName?: string;
|
||||
action?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export function useDataChangeLogs(filter: DataChangeLogFilter) {
|
||||
const result = useGraphQuery<DataChangeLogsResponse>(DATA_CHANGE_LOGS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.dataChangeLogs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDataChangeLogFilter(initial?: Partial<DataChangeLogFilter>) {
|
||||
const [filter, setFilter] = useState<DataChangeLogFilter>({
|
||||
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 setTableName = useCallback(
|
||||
(tableName: string) =>
|
||||
setFilter((f) => ({ ...f, tableName: tableName || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setAction = useCallback(
|
||||
(action: string) =>
|
||||
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setTableName, setAction };
|
||||
}
|
||||
|
||||
/** 数据变更日志导出 CSV */
|
||||
export function exportDataChangeLogsCsv(logs: DataChangeLogViewModel[]): void {
|
||||
const headers = [
|
||||
"时间",
|
||||
"操作人",
|
||||
"数据表",
|
||||
"操作",
|
||||
"记录ID",
|
||||
"变更前",
|
||||
"变更后",
|
||||
"IP",
|
||||
];
|
||||
const rows = logs.map((l) => [
|
||||
new Date(l.occurredAt).toLocaleString("zh-CN"),
|
||||
l.actorName,
|
||||
l.tableName,
|
||||
l.action,
|
||||
l.recordId,
|
||||
l.beforeState ?? "",
|
||||
l.afterState ?? "",
|
||||
l.ip,
|
||||
]);
|
||||
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 = `data-change-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 文件管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminFiles / adminFileStats / deleteFiles
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_FILES_QUERY,
|
||||
ADMIN_FILE_STATS_QUERY,
|
||||
DELETE_FILES_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
FileViewModel,
|
||||
FileStats,
|
||||
PaginatedResult,
|
||||
} from "@/types/view-models";
|
||||
|
||||
/** 文件列表筛选参数 */
|
||||
export interface FileFilter {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
mimeType?: string;
|
||||
entityType?: string;
|
||||
}
|
||||
|
||||
interface AdminFilesResponse {
|
||||
adminFiles: PaginatedResult<FileViewModel>;
|
||||
}
|
||||
|
||||
interface AdminFileStatsResponse {
|
||||
adminFileStats: FileStats;
|
||||
}
|
||||
|
||||
interface DeleteFilesResponse {
|
||||
deleteFiles: boolean;
|
||||
}
|
||||
|
||||
export function useFiles(filter: FileFilter) {
|
||||
const result = useGraphQuery<AdminFilesResponse>(ADMIN_FILES_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminFiles ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFileStats() {
|
||||
const result = useGraphQuery<AdminFileStatsResponse>(ADMIN_FILE_STATS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminFileStats ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteFiles() {
|
||||
const [run, state] = useGraphMutation<DeleteFilesResponse, { ids: string[] }>(
|
||||
DELETE_FILES_MUTATION,
|
||||
);
|
||||
const deleteFiles = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const res = await run({ ids });
|
||||
return res.data?.deleteFiles ?? false;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [deleteFiles, state] as const;
|
||||
}
|
||||
|
||||
/** 文件筛选状态管理 */
|
||||
export function useFileFilter(initial?: FileFilter) {
|
||||
const [filter, setFilter] = useState<FileFilter>({
|
||||
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 setMimeType = useCallback(
|
||||
(mimeType: string) =>
|
||||
setFilter((f) => ({ ...f, mimeType: mimeType || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setEntityType = useCallback(
|
||||
(entityType: string) =>
|
||||
setFilter((f) => ({
|
||||
...f,
|
||||
entityType: entityType || undefined,
|
||||
page: 1,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setMimeType, setEntityType };
|
||||
}
|
||||
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];
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 邀请码管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminInvitationCodes / generateInvitationCodes / deleteInvitationCodes
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_INVITATION_CODES_QUERY,
|
||||
GENERATE_INVITATION_CODES_MUTATION,
|
||||
DELETE_INVITATION_CODES_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
InvitationCodeViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminInvitationCodesResponse {
|
||||
adminInvitationCodes: PaginatedResult<InvitationCodeViewModel>;
|
||||
}
|
||||
|
||||
interface GenerateInvitationCodesResponse {
|
||||
generateInvitationCodes: { codes: string[]; batchId: string };
|
||||
}
|
||||
|
||||
interface DeleteInvitationCodesResponse {
|
||||
deleteInvitationCodes: boolean;
|
||||
}
|
||||
|
||||
interface GenerateInvitationCodesInput {
|
||||
count: number;
|
||||
roleId?: string;
|
||||
classId?: string;
|
||||
email?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export function useInvitationCodes(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminInvitationCodesResponse>(
|
||||
ADMIN_INVITATION_CODES_QUERY,
|
||||
{ filter },
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminInvitationCodes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInvitationCodeFilter(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 };
|
||||
}
|
||||
|
||||
export function useGenerateInvitationCodes() {
|
||||
const [run, state] = useGraphMutation<
|
||||
GenerateInvitationCodesResponse,
|
||||
{ input: GenerateInvitationCodesInput }
|
||||
>(GENERATE_INVITATION_CODES_MUTATION);
|
||||
const generate = useCallback(
|
||||
async (input: GenerateInvitationCodesInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.generateInvitationCodes ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [generate, state] as const;
|
||||
}
|
||||
|
||||
export function useDeleteInvitationCodes() {
|
||||
const [run, state] = useGraphMutation<
|
||||
DeleteInvitationCodesResponse,
|
||||
{ ids: string[] }
|
||||
>(DELETE_INVITATION_CODES_MUTATION);
|
||||
const remove = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const res = await run({ ids });
|
||||
return res.data?.deleteInvitationCodes ?? false;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [remove, state] as const;
|
||||
}
|
||||
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 登录日志 Hooks
|
||||
*
|
||||
* contract §2.4:loginLogs
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { LOGIN_LOGS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
LoginLogViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface LoginLogsResponse {
|
||||
loginLogs: PaginatedResult<LoginLogViewModel>;
|
||||
}
|
||||
|
||||
interface LoginLogFilter extends Partial<ListFilter> {
|
||||
action?: string;
|
||||
status?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export function useLoginLogs(filter: LoginLogFilter) {
|
||||
const result = useGraphQuery<LoginLogsResponse>(LOGIN_LOGS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.loginLogs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLoginLogFilter(initial?: Partial<LoginLogFilter>) {
|
||||
const [filter, setFilter] = useState<LoginLogFilter>({
|
||||
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 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) =>
|
||||
setFilter((f) => ({ ...f, status: status || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setAction, setStatus };
|
||||
}
|
||||
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;
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 学校组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminSchools / adminGrades / adminGradeInsights /
|
||||
* adminDepartments / adminAcademicYears
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_SCHOOLS_QUERY,
|
||||
ADMIN_GRADES_QUERY,
|
||||
ADMIN_GRADE_INSIGHTS_QUERY,
|
||||
ADMIN_DEPARTMENTS_QUERY,
|
||||
ADMIN_ACADEMIC_YEARS_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
SchoolViewModel,
|
||||
GradeViewModel,
|
||||
GradeInsightsViewModel,
|
||||
DepartmentViewModel,
|
||||
AcademicYearViewModel,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminSchoolsResponse {
|
||||
adminSchools: SchoolViewModel[];
|
||||
}
|
||||
|
||||
interface AdminGradesResponse {
|
||||
adminGrades: GradeViewModel[];
|
||||
}
|
||||
|
||||
interface AdminGradeInsightsResponse {
|
||||
adminGradeInsights: GradeInsightsViewModel;
|
||||
}
|
||||
|
||||
interface AdminDepartmentsResponse {
|
||||
adminDepartments: DepartmentViewModel[];
|
||||
}
|
||||
|
||||
interface AdminAcademicYearsResponse {
|
||||
adminAcademicYears: AcademicYearViewModel[];
|
||||
}
|
||||
|
||||
/** 学校列表 */
|
||||
export function useSchools() {
|
||||
const result = useGraphQuery<AdminSchoolsResponse>(ADMIN_SCHOOLS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminSchools ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 年级列表(可按学校筛选) */
|
||||
export function useGrades(schoolId?: string) {
|
||||
const variables = schoolId ? { schoolId } : {};
|
||||
const result = useGraphQuery<AdminGradesResponse>(
|
||||
ADMIN_GRADES_QUERY,
|
||||
variables,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminGrades ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 年级洞察(按年级 ID) */
|
||||
export function useGradeInsights(gradeId: string | null | undefined) {
|
||||
const result = useGraphQuery<AdminGradeInsightsResponse>(
|
||||
ADMIN_GRADE_INSIGHTS_QUERY,
|
||||
gradeId ? { gradeId } : {},
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminGradeInsights ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 部门/教研组列表(可按学校筛选) */
|
||||
export function useDepartments(schoolId?: string) {
|
||||
const variables = schoolId ? { schoolId } : {};
|
||||
const result = useGraphQuery<AdminDepartmentsResponse>(
|
||||
ADMIN_DEPARTMENTS_QUERY,
|
||||
variables,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminDepartments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 学年/学期列表 */
|
||||
export function useAcademicYears() {
|
||||
const result = useGraphQuery<AdminAcademicYearsResponse>(
|
||||
ADMIN_ACADEMIC_YEARS_QUERY,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminAcademicYears ?? [],
|
||||
};
|
||||
}
|
||||
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 };
|
||||
}
|
||||
157
apps/admin-portal/src/lib/auth.test.ts
Normal file
157
apps/admin-portal/src/lib/auth.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 认证工具测试(token / user 持久化 + login/logout)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
getUser,
|
||||
setUser,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
} from "./auth";
|
||||
import type { CurrentUser } from "@/types/view-models";
|
||||
|
||||
const mockUser: CurrentUser = {
|
||||
id: "u-test-001",
|
||||
email: "test@edu.test",
|
||||
name: "测试用户",
|
||||
roles: ["system_admin"],
|
||||
permissions: ["ADMIN_DASHBOARD_VIEW"],
|
||||
dataScope: "ALL",
|
||||
schoolId: null,
|
||||
schoolName: "Edu 实验学校",
|
||||
};
|
||||
|
||||
describe("auth token utilities", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(global.fetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("setToken / getToken 持久化 token", () => {
|
||||
expect(getToken()).toBeNull();
|
||||
setToken("mock-token-123");
|
||||
expect(getToken()).toBe("mock-token-123");
|
||||
});
|
||||
|
||||
it("clearToken 清除 token 和 user", () => {
|
||||
setToken("mock-token-123");
|
||||
setUser(mockUser);
|
||||
clearToken();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(getUser()).toBeNull();
|
||||
});
|
||||
|
||||
it("setUser / getUser 持久化用户信息", () => {
|
||||
expect(getUser()).toBeNull();
|
||||
setUser(mockUser);
|
||||
const user = getUser();
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.id).toBe("u-test-001");
|
||||
expect(user?.email).toBe("test@edu.test");
|
||||
});
|
||||
|
||||
it("getUser 在 JSON 损坏时返回 null", () => {
|
||||
localStorage.setItem("edu_user_info", "{invalid json}");
|
||||
expect(getUser()).toBeNull();
|
||||
});
|
||||
|
||||
it("isAuthenticated 基于 token 存在判断", () => {
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
setToken("mock-token-123");
|
||||
expect(isAuthenticated()).toBe(true);
|
||||
clearToken();
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login()", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(global.fetch).mockReset();
|
||||
});
|
||||
|
||||
it("登录成功后持久化 token 和 user", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
user: mockUser,
|
||||
tokens: { accessToken: "jwt-token-success" },
|
||||
},
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await login("test@edu.test", "password");
|
||||
expect(result.token).toBe("jwt-token-success");
|
||||
expect(result.user.id).toBe("u-test-001");
|
||||
expect(getToken()).toBe("jwt-token-success");
|
||||
expect(getUser()?.id).toBe("u-test-001");
|
||||
});
|
||||
|
||||
it("登录失败抛出错误", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: { message: "邮箱或密码错误" },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(login("wrong@edu.test", "wrong-password")).rejects.toThrow(
|
||||
"邮箱或密码错误",
|
||||
);
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("登录失败无 error.message 时使用默认错误信息", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: false }),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(login("a@b.c", "x")).rejects.toThrow("登录失败");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout()", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("logout 清除 token 并跳转", () => {
|
||||
setToken("mock-token-123");
|
||||
setUser(mockUser);
|
||||
expect(isAuthenticated()).toBe(true);
|
||||
|
||||
// mock window.location.href 赋值(jsdom 不支持导航)
|
||||
const originalHref = window.location.href;
|
||||
const hrefSetter = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
set href(v: string) {
|
||||
hrefSetter(v);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logout();
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
|
||||
// 恢复
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href: originalHref },
|
||||
});
|
||||
});
|
||||
});
|
||||
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";
|
||||
}
|
||||
}
|
||||
691
apps/admin-portal/src/lib/graphql-client.ts
Normal file
691
apps/admin-portal/src/lib/graphql-client.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// ─── 审计日志子模块 ─────────────────────────────────────────────────
|
||||
|
||||
export const AUDIT_OVERVIEW_STATS_QUERY = `
|
||||
query AuditOverviewStats {
|
||||
auditOverviewStats {
|
||||
totalEvents
|
||||
todayEvents
|
||||
uniqueActors
|
||||
failedActions
|
||||
topActions { action count }
|
||||
topResources { resource count }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const AUDIT_TREND_QUERY = `
|
||||
query AuditTrend($days: Int) {
|
||||
auditTrend(days: $days) {
|
||||
date
|
||||
count
|
||||
action
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DATA_CHANGE_LOGS_QUERY = `
|
||||
query DataChangeLogs($filter: DataChangeLogFilterInput) {
|
||||
dataChangeLogs(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
occurredAt
|
||||
actorUserId
|
||||
actorName
|
||||
tableName
|
||||
action
|
||||
recordId
|
||||
beforeState
|
||||
afterState
|
||||
ip
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const LOGIN_LOGS_QUERY = `
|
||||
query LoginLogs($filter: LoginLogFilterInput) {
|
||||
loginLogs(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
occurredAt
|
||||
userId
|
||||
userName
|
||||
action
|
||||
status
|
||||
ip
|
||||
userAgent
|
||||
failureReason
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 学校组织管理细化 ──────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_SCHOOLS_QUERY = `
|
||||
query AdminSchools {
|
||||
adminSchools {
|
||||
id
|
||||
name
|
||||
code
|
||||
address
|
||||
contactPhone
|
||||
contactEmail
|
||||
principalName
|
||||
gradeCount
|
||||
classCount
|
||||
studentCount
|
||||
teacherCount
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_GRADES_QUERY = `
|
||||
query AdminGrades($schoolId: ID) {
|
||||
adminGrades(schoolId: $schoolId) {
|
||||
id
|
||||
name
|
||||
schoolId
|
||||
schoolName
|
||||
directorName
|
||||
classCount
|
||||
studentCount
|
||||
sortOrder
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_GRADE_INSIGHTS_QUERY = `
|
||||
query AdminGradeInsights($gradeId: ID!) {
|
||||
adminGradeInsights(gradeId: $gradeId) {
|
||||
gradeId
|
||||
gradeName
|
||||
schoolWideSummary {
|
||||
totalStudents
|
||||
totalClasses
|
||||
avgScore
|
||||
passingRate
|
||||
}
|
||||
homeworkTimeline {
|
||||
homeworkId
|
||||
title
|
||||
className
|
||||
avgScore
|
||||
submittedCount
|
||||
totalCount
|
||||
dueDate
|
||||
}
|
||||
classRankings {
|
||||
classId
|
||||
className
|
||||
avgScore
|
||||
rank
|
||||
delta
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_DEPARTMENTS_QUERY = `
|
||||
query AdminDepartments($schoolId: ID) {
|
||||
adminDepartments(schoolId: $schoolId) {
|
||||
id
|
||||
name
|
||||
schoolId
|
||||
schoolName
|
||||
leaderName
|
||||
memberCount
|
||||
subject
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_ACADEMIC_YEARS_QUERY = `
|
||||
query AdminAcademicYears {
|
||||
adminAcademicYears {
|
||||
id
|
||||
name
|
||||
startDate
|
||||
endDate
|
||||
firstSemesterStart
|
||||
firstSemesterEnd
|
||||
secondSemesterStart
|
||||
secondSemesterEnd
|
||||
isCurrent
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 公告管理 ───────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_ANNOUNCEMENTS_QUERY = `
|
||||
query AdminAnnouncements($filter: AnnouncementFilterInput) {
|
||||
adminAnnouncements(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
title
|
||||
content
|
||||
status
|
||||
audience
|
||||
authorName
|
||||
isPinned
|
||||
readCount
|
||||
totalCount
|
||||
publishedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CREATE_ANNOUNCEMENT_MUTATION = `
|
||||
mutation CreateAnnouncement($input: CreateAnnouncementInput!) {
|
||||
createAnnouncement(input: $input) {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const PUBLISH_ANNOUNCEMENT_MUTATION = `
|
||||
mutation PublishAnnouncement($id: ID!) {
|
||||
publishAnnouncement(id: $id) { id status }
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ARCHIVE_ANNOUNCEMENT_MUTATION = `
|
||||
mutation ArchiveAnnouncement($id: ID!) {
|
||||
archiveAnnouncement(id: $id) { id status }
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const TOGGLE_ANNOUNCEMENT_PIN_MUTATION = `
|
||||
mutation ToggleAnnouncementPin($id: ID!) {
|
||||
toggleAnnouncementPin(id: $id) { id isPinned }
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 邀请码管理 ─────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_INVITATION_CODES_QUERY = `
|
||||
query AdminInvitationCodes($filter: InvitationCodeFilterInput) {
|
||||
adminInvitationCodes(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
code
|
||||
status
|
||||
roleId
|
||||
roleName
|
||||
classId
|
||||
className
|
||||
email
|
||||
batchId
|
||||
usedBy
|
||||
usedByName
|
||||
usedAt
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const GENERATE_INVITATION_CODES_MUTATION = `
|
||||
mutation GenerateInvitationCodes($input: GenerateInvitationCodesInput!) {
|
||||
generateInvitationCodes(input: $input) {
|
||||
codes
|
||||
batchId
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DELETE_INVITATION_CODES_MUTATION = `
|
||||
mutation DeleteInvitationCodes($ids: [ID!]!) {
|
||||
deleteInvitationCodes(ids: $ids)
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 文件管理 ───────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_FILES_QUERY = `
|
||||
query AdminFiles($filter: FileFilterInput) {
|
||||
adminFiles(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
filename
|
||||
mimeType
|
||||
size
|
||||
url
|
||||
entityType
|
||||
entityId
|
||||
uploadedByName
|
||||
createdAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_FILE_STATS_QUERY = `
|
||||
query AdminFileStats {
|
||||
adminFileStats {
|
||||
totalFiles
|
||||
totalSize
|
||||
byMimeType { mimeType count size }
|
||||
byEntityType { entityType count }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DELETE_FILES_MUTATION = `
|
||||
mutation DeleteFiles($ids: [ID!]!) {
|
||||
deleteFiles(ids: $ids)
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── AI 配置 ─────────────────────────────────────────────────────────
|
||||
|
||||
export const AI_PROVIDERS_QUERY = `
|
||||
query AiProviders {
|
||||
aiProviders {
|
||||
id
|
||||
name
|
||||
type
|
||||
isPublic
|
||||
isActive
|
||||
model
|
||||
apiBase
|
||||
createdBy
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_AI_PROVIDER_MUTATION = `
|
||||
mutation UpdateAiProvider($id: ID!, $input: UpdateAiProviderInput!) {
|
||||
updateAiProvider(id: $id, input: $input) {
|
||||
id
|
||||
isActive
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const AI_USAGE_QUERY = `
|
||||
query AiUsage {
|
||||
aiUsage {
|
||||
totalRequests
|
||||
totalTokens
|
||||
totalCost
|
||||
byProvider {
|
||||
providerName
|
||||
requests
|
||||
tokens
|
||||
cost
|
||||
}
|
||||
byDay {
|
||||
date
|
||||
requests
|
||||
tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
54
apps/admin-portal/src/lib/i18n.test.ts
Normal file
54
apps/admin-portal/src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* i18n 翻译函数测试
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "./i18n";
|
||||
|
||||
describe("i18n t()", () => {
|
||||
it("返回已注册的翻译", () => {
|
||||
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
|
||||
expect(t("admin.nav.users")).toBe("用户管理");
|
||||
});
|
||||
|
||||
it("未注册的 key 返回 key 本身(兜底)", () => {
|
||||
expect(t("admin.nonexistent.key")).toBe("admin.nonexistent.key");
|
||||
});
|
||||
|
||||
it("支持 {{param}} 模板替换", () => {
|
||||
// messages 为私有,通过既有 key 验证模板机制不破坏无占位符字符串
|
||||
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
|
||||
});
|
||||
|
||||
it("支持参数模板替换(通过既有 key)", () => {
|
||||
// admin.common.total = "共",admin.common.records = "条"
|
||||
// 这些 key 无模板,验证参数传入不会破坏返回
|
||||
expect(t("admin.common.total", { count: 5 })).toBe("共");
|
||||
expect(t("admin.common.records")).toBe("条");
|
||||
});
|
||||
|
||||
it("参数替换不影响无占位符的字符串", () => {
|
||||
expect(t("admin.nav.dashboard", { foo: "bar" })).toBe("管理仪表盘");
|
||||
});
|
||||
|
||||
it("支持数字参数(自动转字符串)", () => {
|
||||
// 测试 t 函数对 number 类型的处理
|
||||
expect(t("admin.common.total", { count: 42 })).toBe("共");
|
||||
});
|
||||
|
||||
it("学校管理相关 key 已注册", () => {
|
||||
expect(t("admin.school.title")).toBe("学校管理");
|
||||
expect(t("admin.school.grades")).toBe("年级管理");
|
||||
expect(t("admin.school.gradeInsights")).toBe("年级洞察");
|
||||
});
|
||||
|
||||
it("审计子模块 key 已注册", () => {
|
||||
expect(t("admin.auditLogs.overview")).toBe("审计总览");
|
||||
expect(t("admin.auditLogs.dataChanges")).toBe("数据变更");
|
||||
expect(t("admin.auditLogs.loginLogs")).toBe("登录日志");
|
||||
});
|
||||
|
||||
it("公告/邀请码/文件/AI 相关 key 已注册", () => {
|
||||
// 这些 key 应该在 i18n.ts 中存在
|
||||
expect(t("admin.nav.auditLogs")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
302
apps/admin-portal/src/lib/i18n.ts
Normal file
302
apps/admin-portal/src/lib/i18n.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 轻量 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 管理端",
|
||||
|
||||
// 学校管理(细化)
|
||||
"admin.school.title": "学校管理",
|
||||
"admin.school.schools": "学校列表",
|
||||
"admin.school.grades": "年级管理",
|
||||
"admin.school.departments": "部门管理",
|
||||
"admin.school.academicYear": "学年学期",
|
||||
"admin.school.gradeInsights": "年级洞察",
|
||||
"admin.school.principal": "校长",
|
||||
"admin.school.director": "年级主任",
|
||||
"admin.school.leader": "负责人",
|
||||
"admin.school.memberCount": "成员数",
|
||||
"admin.school.subject": "学科",
|
||||
"admin.school.startDate": "开始日期",
|
||||
"admin.school.endDate": "结束日期",
|
||||
"admin.school.currentYear": "当前学年",
|
||||
"admin.school.insights.summary": "全校概览",
|
||||
"admin.school.insights.homework": "作业时间线",
|
||||
"admin.school.insights.rankings": "班级排名",
|
||||
"admin.school.insights.avgScore": "平均分",
|
||||
"admin.school.insights.passingRate": "及格率",
|
||||
"admin.school.insights.rank": "排名",
|
||||
"admin.school.insights.delta": "环比",
|
||||
|
||||
// 审计日志子模块
|
||||
"admin.auditLogs.overview": "审计总览",
|
||||
"admin.auditLogs.dataChanges": "数据变更",
|
||||
"admin.auditLogs.loginLogs": "登录日志",
|
||||
"admin.auditLogs.totalEvents": "总事件数",
|
||||
"admin.auditLogs.todayEvents": "今日事件",
|
||||
"admin.auditLogs.uniqueActors": "操作人数",
|
||||
"admin.auditLogs.failedActions": "失败操作",
|
||||
"admin.auditLogs.topActions": "热门操作",
|
||||
"admin.auditLogs.topResources": "热门资源",
|
||||
"admin.auditLogs.trend": "操作趋势",
|
||||
"admin.auditLogs.tableName": "数据表",
|
||||
"admin.auditLogs.recordId": "记录ID",
|
||||
"admin.auditLogs.beforeState": "变更前",
|
||||
"admin.auditLogs.afterState": "变更后",
|
||||
"admin.auditLogs.loginAction": "登录动作",
|
||||
"admin.auditLogs.loginStatus": "状态",
|
||||
"admin.auditLogs.failureReason": "失败原因",
|
||||
"admin.auditLogs.userAgent": "User-Agent",
|
||||
|
||||
// 公告管理
|
||||
"admin.announcements.title": "公告管理",
|
||||
"admin.announcements.new": "发布公告",
|
||||
"admin.announcements.publish": "发布",
|
||||
"admin.announcements.archive": "归档",
|
||||
"admin.announcements.pin": "置顶",
|
||||
"admin.announcements.unpin": "取消置顶",
|
||||
"admin.announcements.draft": "草稿",
|
||||
"admin.announcements.published": "已发布",
|
||||
"admin.announcements.archived": "已归档",
|
||||
"admin.announcements.audience": "受众",
|
||||
"admin.announcements.readCount": "已读",
|
||||
"admin.announcements.author": "发布人",
|
||||
"admin.announcements.publishedAt": "发布时间",
|
||||
|
||||
// 邀请码管理
|
||||
"admin.invitationCodes.title": "邀请码管理",
|
||||
"admin.invitationCodes.generate": "批量生成",
|
||||
"admin.invitationCodes.code": "邀请码",
|
||||
"admin.invitationCodes.batch": "批次",
|
||||
"admin.invitationCodes.unused": "未使用",
|
||||
"admin.invitationCodes.used": "已使用",
|
||||
"admin.invitationCodes.expired": "已过期",
|
||||
"admin.invitationCodes.bindRole": "绑定角色",
|
||||
"admin.invitationCodes.bindClass": "绑定班级",
|
||||
"admin.invitationCodes.bindEmail": "限定邮箱",
|
||||
"admin.invitationCodes.usedBy": "使用者",
|
||||
"admin.invitationCodes.usedAt": "使用时间",
|
||||
"admin.invitationCodes.expiresAt": "过期时间",
|
||||
"admin.invitationCodes.generateCount": "生成数量",
|
||||
"admin.invitationCodes.generateSuccess": "成功生成 {{count}} 个邀请码",
|
||||
|
||||
// 文件管理
|
||||
"admin.files.title": "文件管理",
|
||||
"admin.files.filename": "文件名",
|
||||
"admin.files.mimeType": "类型",
|
||||
"admin.files.size": "大小",
|
||||
"admin.files.entityType": "关联类型",
|
||||
"admin.files.uploadedBy": "上传者",
|
||||
"admin.files.totalFiles": "文件总数",
|
||||
"admin.files.totalSize": "总大小",
|
||||
"admin.files.batchDelete": "批量删除",
|
||||
"admin.files.preview": "预览",
|
||||
|
||||
// AI 配置
|
||||
"admin.aiSettings.title": "AI 配置",
|
||||
"admin.aiSettings.providers": "AI Provider",
|
||||
"admin.aiSettings.usage": "用量统计",
|
||||
"admin.aiSettings.totalRequests": "总请求数",
|
||||
"admin.aiSettings.totalTokens": "总 Token",
|
||||
"admin.aiSettings.totalCost": "总费用",
|
||||
"admin.aiSettings.provider": "Provider",
|
||||
"admin.aiSettings.model": "模型",
|
||||
"admin.aiSettings.isActive": "启用",
|
||||
"admin.aiSettings.isPublic": "公共",
|
||||
"admin.aiSettings.addProvider": "添加 Provider",
|
||||
|
||||
// 系统设置增强
|
||||
"admin.system.security": "安全策略",
|
||||
"admin.system.fileUpload": "文件上传",
|
||||
"admin.system.notification": "通知配置",
|
||||
"admin.system.brand": "品牌配置",
|
||||
"admin.system.passwordMinLength": "密码最小长度",
|
||||
"admin.system.passwordRequireSpecial": "要求特殊字符",
|
||||
"admin.system.passwordRequireUppercase": "要求大写字母",
|
||||
"admin.system.sessionTimeout": "会话超时(分钟)",
|
||||
"admin.system.forcePasswordChange": "强制修改密码",
|
||||
"admin.system.maxFileSize": "最大文件大小(MB)",
|
||||
"admin.system.allowedTypes": "允许的文件类型",
|
||||
"admin.system.notifyNewUser": "新用户通知",
|
||||
"admin.system.notifyScheduleChange": "调课通知",
|
||||
"admin.system.notifyAnnouncement": "公告通知",
|
||||
"admin.system.logoUrl": "Logo URL",
|
||||
"admin.system.primaryColor": "主题色",
|
||||
"admin.system.schoolMotto": "校训",
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
* @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;
|
||||
}
|
||||
92
apps/admin-portal/src/lib/permissions.test.ts
Normal file
92
apps/admin-portal/src/lib/permissions.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 权限常量与路由权限映射测试
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_PERMISSIONS,
|
||||
IAM_PERMISSIONS,
|
||||
PERMISSIONS,
|
||||
ROUTE_PERMISSIONS,
|
||||
ADMIN_ERROR_PREFIX,
|
||||
CONSUMED_ERROR_PREFIXES,
|
||||
} from "./permissions";
|
||||
|
||||
describe("permissions constants", () => {
|
||||
it("ADMIN_PERMISSIONS 全部以 ADMIN_ 前缀", () => {
|
||||
for (const code of Object.values(ADMIN_PERMISSIONS)) {
|
||||
expect(code.startsWith("ADMIN_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("IAM_PERMISSIONS 全部以 IAM_ 前缀", () => {
|
||||
for (const code of Object.values(IAM_PERMISSIONS)) {
|
||||
expect(code.startsWith("IAM_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("PERMISSIONS 合并 ADMIN + IAM", () => {
|
||||
expect(PERMISSIONS.DASHBOARD_VIEW).toBe("ADMIN_DASHBOARD_VIEW");
|
||||
expect(PERMISSIONS.USER_READ).toBe("IAM_USER_READ");
|
||||
});
|
||||
|
||||
it("包含必需的管理权限点", () => {
|
||||
expect(ADMIN_PERMISSIONS.SCHOOL_READ).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.GRADE_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.ANNOUNCEMENT_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.FILE_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.AI_CONFIGURE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE).toBeDefined();
|
||||
});
|
||||
|
||||
it("ROUTE_PERMISSIONS 覆盖所有 admin 路由", () => {
|
||||
const expectedRoutes = [
|
||||
"/admin/dashboard",
|
||||
"/admin/users",
|
||||
"/admin/roles",
|
||||
"/admin/permissions",
|
||||
"/admin/viewports",
|
||||
"/admin/organization",
|
||||
"/admin/system",
|
||||
"/admin/classes",
|
||||
"/admin/teachers",
|
||||
"/admin/students",
|
||||
"/admin/audit-logs",
|
||||
"/admin/audit-logs/overview",
|
||||
"/admin/audit-logs/data-changes",
|
||||
"/admin/audit-logs/login-logs",
|
||||
"/admin/school",
|
||||
"/admin/school/schools",
|
||||
"/admin/school/grades",
|
||||
"/admin/school/grades/insights",
|
||||
"/admin/school/departments",
|
||||
"/admin/school/academic-year",
|
||||
"/admin/announcements",
|
||||
"/admin/invitation-codes",
|
||||
"/admin/files",
|
||||
"/admin/ai-settings",
|
||||
];
|
||||
for (const route of expectedRoutes) {
|
||||
expect(ROUTE_PERMISSIONS[route]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("ROUTE_PERMISSIONS 审计子路由映射到 AUDIT_READ", () => {
|
||||
expect(ROUTE_PERMISSIONS["/admin/audit-logs/overview"]).toBe(
|
||||
ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
);
|
||||
expect(ROUTE_PERMISSIONS["/admin/audit-logs/data-changes"]).toBe(
|
||||
ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
);
|
||||
});
|
||||
|
||||
it("ADMIN_ERROR_PREFIX 为 ADMIN_", () => {
|
||||
expect(ADMIN_ERROR_PREFIX).toBe("ADMIN_");
|
||||
});
|
||||
|
||||
it("CONSUMED_ERROR_PREFIXES 包含所有上游错误前缀", () => {
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("IAM_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("BFF_TEACHER_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("GW_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("ADMIN_");
|
||||
});
|
||||
});
|
||||
103
apps/admin-portal/src/lib/permissions.ts
Normal file
103
apps/admin-portal/src/lib/permissions.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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",
|
||||
GRADE_READ: "ADMIN_GRADE_READ",
|
||||
GRADE_MANAGE: "ADMIN_GRADE_MANAGE",
|
||||
GRADE_INSIGHTS_VIEW: "ADMIN_GRADE_INSIGHTS_VIEW",
|
||||
DEPARTMENT_READ: "ADMIN_DEPARTMENT_READ",
|
||||
DEPARTMENT_MANAGE: "ADMIN_DEPARTMENT_MANAGE",
|
||||
ACADEMIC_YEAR_MANAGE: "ADMIN_ACADEMIC_YEAR_MANAGE",
|
||||
ANNOUNCEMENT_READ: "ADMIN_ANNOUNCEMENT_READ",
|
||||
ANNOUNCEMENT_MANAGE: "ADMIN_ANNOUNCEMENT_MANAGE",
|
||||
FILE_READ: "ADMIN_FILE_READ",
|
||||
FILE_MANAGE: "ADMIN_FILE_MANAGE",
|
||||
INVITATION_CODE_MANAGE: "ADMIN_INVITATION_CODE_MANAGE",
|
||||
AI_CONFIGURE: "ADMIN_AI_CONFIGURE",
|
||||
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/audit-logs/overview": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/audit-logs/data-changes": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/audit-logs/login-logs": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/school": ADMIN_PERMISSIONS.SCHOOL_READ,
|
||||
"/admin/school/schools": ADMIN_PERMISSIONS.SCHOOL_READ,
|
||||
"/admin/school/grades": ADMIN_PERMISSIONS.GRADE_READ,
|
||||
"/admin/school/grades/insights": ADMIN_PERMISSIONS.GRADE_INSIGHTS_VIEW,
|
||||
"/admin/school/departments": ADMIN_PERMISSIONS.DEPARTMENT_READ,
|
||||
"/admin/school/academic-year": ADMIN_PERMISSIONS.ACADEMIC_YEAR_MANAGE,
|
||||
"/admin/announcements": ADMIN_PERMISSIONS.ANNOUNCEMENT_READ,
|
||||
"/admin/invitation-codes": ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE,
|
||||
"/admin/files": ADMIN_PERMISSIONS.FILE_READ,
|
||||
"/admin/ai-settings": ADMIN_PERMISSIONS.AI_CONFIGURE,
|
||||
};
|
||||
|
||||
/** 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);
|
||||
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal file
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Mock fixtures 完整性测试
|
||||
*
|
||||
* 确保所有 mock 数据导出存在且符合基本结构约束
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mockCurrentUser,
|
||||
mockPermissions,
|
||||
mockRoles,
|
||||
mockUsers,
|
||||
mockViewports,
|
||||
mockOrganization,
|
||||
mockClasses,
|
||||
mockTeachers,
|
||||
mockStudents,
|
||||
mockAuditLogs,
|
||||
mockDashboard,
|
||||
mockSystemSettings,
|
||||
mockSchools,
|
||||
mockGrades,
|
||||
mockGradeInsights,
|
||||
mockDepartments,
|
||||
mockAcademicYears,
|
||||
mockAuditOverviewStats,
|
||||
mockAuditTrend,
|
||||
mockDataChangeLogs,
|
||||
mockLoginLogs,
|
||||
mockAnnouncements,
|
||||
mockInvitationCodes,
|
||||
mockFiles,
|
||||
mockFileStats,
|
||||
mockAiProviders,
|
||||
mockAiUsage,
|
||||
mockSecurityPolicy,
|
||||
mockFileUploadConfig,
|
||||
mockNotificationConfig,
|
||||
mockBrandConfig,
|
||||
genId,
|
||||
} from "./fixtures";
|
||||
|
||||
describe("mock fixtures 完整性", () => {
|
||||
it("mockCurrentUser 包含管理员权限", () => {
|
||||
expect(mockCurrentUser.id).toBeTruthy();
|
||||
expect(mockCurrentUser.permissions).toContain("ADMIN_DASHBOARD_VIEW");
|
||||
expect(mockCurrentUser.permissions.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("mockPermissions 非空且每条有 code", () => {
|
||||
expect(mockPermissions.length).toBeGreaterThan(10);
|
||||
for (const p of mockPermissions) {
|
||||
expect(p.code).toBeTruthy();
|
||||
expect(p.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockRoles 包含系统管理员", () => {
|
||||
expect(mockRoles.length).toBeGreaterThanOrEqual(4);
|
||||
const sysAdmin = mockRoles.find((r) => r.code === "system_admin");
|
||||
expect(sysAdmin).toBeDefined();
|
||||
expect(sysAdmin?.dataScope).toBe("ALL");
|
||||
});
|
||||
|
||||
it("mockUsers 非空", () => {
|
||||
expect(mockUsers.length).toBeGreaterThanOrEqual(3);
|
||||
for (const u of mockUsers) {
|
||||
expect(u.email).toContain("@");
|
||||
expect(u.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockViewports 覆盖所有 scope", () => {
|
||||
const scopes = new Set(mockViewports.map((v) => v.scope));
|
||||
expect(scopes.has("teacher")).toBe(true);
|
||||
expect(scopes.has("student")).toBe(true);
|
||||
expect(scopes.has("parent")).toBe(true);
|
||||
expect(scopes.has("admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("mockOrganization 树形结构有根节点", () => {
|
||||
const roots = mockOrganization.filter((n) => n.parentId === null);
|
||||
expect(roots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mockClasses/Teachers/Students 非空", () => {
|
||||
expect(mockClasses.length).toBeGreaterThan(0);
|
||||
expect(mockTeachers.length).toBeGreaterThan(0);
|
||||
expect(mockStudents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mockAuditLogs 非空且有时间戳", () => {
|
||||
expect(mockAuditLogs.length).toBeGreaterThan(0);
|
||||
for (const log of mockAuditLogs) {
|
||||
expect(typeof log.occurredAt).toBe("number");
|
||||
expect(log.action).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockDashboard 包含基础统计", () => {
|
||||
expect(mockDashboard).toBeDefined();
|
||||
expect(typeof mockDashboard).toBe("object");
|
||||
});
|
||||
|
||||
it("mockSystemSettings 非空", () => {
|
||||
expect(mockSystemSettings).toBeDefined();
|
||||
});
|
||||
|
||||
it("学校组织 mock 数据完整", () => {
|
||||
expect(mockSchools.length).toBeGreaterThan(0);
|
||||
expect(mockGrades.length).toBeGreaterThan(0);
|
||||
expect(mockDepartments.length).toBeGreaterThan(0);
|
||||
expect(mockAcademicYears.length).toBeGreaterThan(0);
|
||||
expect(mockGradeInsights).toBeDefined();
|
||||
});
|
||||
|
||||
it("审计子模块 mock 数据完整", () => {
|
||||
expect(mockAuditOverviewStats).toBeDefined();
|
||||
expect(mockAuditTrend.length).toBe(7);
|
||||
expect(mockDataChangeLogs.length).toBeGreaterThan(0);
|
||||
expect(mockLoginLogs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("公告/邀请码/文件 mock 数据完整", () => {
|
||||
expect(mockAnnouncements.length).toBeGreaterThan(0);
|
||||
expect(mockInvitationCodes.length).toBeGreaterThan(0);
|
||||
expect(mockFiles.length).toBeGreaterThan(0);
|
||||
expect(mockFileStats).toBeDefined();
|
||||
});
|
||||
|
||||
it("AI 设置 mock 数据完整", () => {
|
||||
expect(mockAiProviders.length).toBeGreaterThan(0);
|
||||
expect(mockAiUsage).toBeDefined();
|
||||
});
|
||||
|
||||
it("系统设置增强 mock 数据完整", () => {
|
||||
expect(mockSecurityPolicy).toBeDefined();
|
||||
expect(mockFileUploadConfig).toBeDefined();
|
||||
expect(mockNotificationConfig).toBeDefined();
|
||||
expect(mockBrandConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("genId 生成唯一 ID", () => {
|
||||
const id1 = genId("test");
|
||||
const id2 = genId("test");
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(id1.startsWith("test-")).toBe(true);
|
||||
});
|
||||
});
|
||||
1634
apps/admin-portal/src/mocks/fixtures.ts
Normal file
1634
apps/admin-portal/src/mocks/fixtures.ts
Normal file
File diff suppressed because it is too large
Load Diff
804
apps/admin-portal/src/mocks/handlers.ts
Normal file
804
apps/admin-portal/src/mocks/handlers.ts
Normal file
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* 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,
|
||||
mockSchools,
|
||||
mockGrades,
|
||||
mockGradeInsights,
|
||||
mockDepartments,
|
||||
mockAcademicYears,
|
||||
mockAuditOverviewStats,
|
||||
mockAuditTrend,
|
||||
mockDataChangeLogs,
|
||||
mockLoginLogs,
|
||||
mockAnnouncements,
|
||||
mockInvitationCodes,
|
||||
mockFiles,
|
||||
mockFileStats,
|
||||
mockAiProviders,
|
||||
mockAiUsage,
|
||||
mockSecurityPolicy,
|
||||
mockFileUploadConfig,
|
||||
mockNotificationConfig,
|
||||
mockBrandConfig,
|
||||
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 },
|
||||
schools: [...mockSchools],
|
||||
grades: [...mockGrades],
|
||||
departments: [...mockDepartments],
|
||||
academicYears: [...mockAcademicYears],
|
||||
announcements: [...mockAnnouncements],
|
||||
invitationCodes: [...mockInvitationCodes],
|
||||
files: [...mockFiles],
|
||||
aiProviders: [...mockAiProviders],
|
||||
securityPolicy: { ...mockSecurityPolicy },
|
||||
fileUploadConfig: { ...mockFileUploadConfig },
|
||||
notificationConfig: { ...mockNotificationConfig },
|
||||
brandConfig: { ...mockBrandConfig },
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── auditOverviewStats ──
|
||||
if (operationName === "AuditOverviewStats") {
|
||||
return HttpResponse.json({
|
||||
data: { auditOverviewStats: mockAuditOverviewStats },
|
||||
});
|
||||
}
|
||||
|
||||
// ── auditTrend ──
|
||||
if (operationName === "AuditTrend") {
|
||||
return HttpResponse.json({ data: { auditTrend: mockAuditTrend } });
|
||||
}
|
||||
|
||||
// ── dataChangeLogs ──
|
||||
if (operationName === "DataChangeLogs") {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
dataChangeLogs: paginate(mockDataChangeLogs, variables.filter ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── loginLogs ──
|
||||
if (operationName === "LoginLogs") {
|
||||
return HttpResponse.json({
|
||||
data: { loginLogs: paginate(mockLoginLogs, variables.filter ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── adminSchools ──
|
||||
if (operationName === "AdminSchools") {
|
||||
return HttpResponse.json({ data: { adminSchools: db.schools } });
|
||||
}
|
||||
|
||||
// ── adminGrades ──
|
||||
if (operationName === "AdminGrades") {
|
||||
const schoolId = variables.schoolId;
|
||||
const grades = schoolId
|
||||
? db.grades.filter((g) => g.schoolId === schoolId)
|
||||
: db.grades;
|
||||
return HttpResponse.json({ data: { adminGrades: grades } });
|
||||
}
|
||||
|
||||
// ── adminGradeInsights ──
|
||||
if (operationName === "AdminGradeInsights") {
|
||||
return HttpResponse.json({
|
||||
data: { adminGradeInsights: mockGradeInsights },
|
||||
});
|
||||
}
|
||||
|
||||
// ── adminDepartments ──
|
||||
if (operationName === "AdminDepartments") {
|
||||
const schoolId = variables.schoolId;
|
||||
const depts = schoolId
|
||||
? db.departments.filter((d) => d.schoolId === schoolId)
|
||||
: db.departments;
|
||||
return HttpResponse.json({ data: { adminDepartments: depts } });
|
||||
}
|
||||
|
||||
// ── adminAcademicYears ──
|
||||
if (operationName === "AdminAcademicYears") {
|
||||
return HttpResponse.json({
|
||||
data: { adminAcademicYears: db.academicYears },
|
||||
});
|
||||
}
|
||||
|
||||
// ── adminAnnouncements ──
|
||||
if (operationName === "AdminAnnouncements") {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
adminAnnouncements: paginate(
|
||||
db.announcements,
|
||||
variables.filter ?? {},
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── createAnnouncement ──
|
||||
if (operationName === "CreateAnnouncement") {
|
||||
const input = variables.input;
|
||||
const newAnn = {
|
||||
id: genId("ann"),
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
status: "draft" as const,
|
||||
audience: input.audience ?? ["all"],
|
||||
authorName: mockCurrentUser.name,
|
||||
isPinned: false,
|
||||
readCount: 0,
|
||||
totalCount: 0,
|
||||
publishedAt: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
db.announcements.unshift(newAnn);
|
||||
recordAudit("ANNOUNCEMENT_CREATE", "announcement", newAnn.id);
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
createAnnouncement: {
|
||||
id: newAnn.id,
|
||||
title: newAnn.title,
|
||||
status: newAnn.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── publishAnnouncement ──
|
||||
if (operationName === "PublishAnnouncement") {
|
||||
const ann = db.announcements.find((a) => a.id === variables.id);
|
||||
if (ann) {
|
||||
ann.status = "published";
|
||||
ann.publishedAt = Date.now();
|
||||
ann.updatedAt = Date.now();
|
||||
recordAudit("ANNOUNCEMENT_PUBLISH", "announcement", ann.id);
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
publishAnnouncement: { id: variables.id, status: "published" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── archiveAnnouncement ──
|
||||
if (operationName === "ArchiveAnnouncement") {
|
||||
const ann = db.announcements.find((a) => a.id === variables.id);
|
||||
if (ann) {
|
||||
ann.status = "archived";
|
||||
ann.updatedAt = Date.now();
|
||||
recordAudit("ANNOUNCEMENT_ARCHIVE", "announcement", ann.id);
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: { archiveAnnouncement: { id: variables.id, status: "archived" } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── toggleAnnouncementPin ──
|
||||
if (operationName === "ToggleAnnouncementPin") {
|
||||
const ann = db.announcements.find((a) => a.id === variables.id);
|
||||
if (ann) {
|
||||
ann.isPinned = !ann.isPinned;
|
||||
ann.updatedAt = Date.now();
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
toggleAnnouncementPin: {
|
||||
id: variables.id,
|
||||
isPinned: ann?.isPinned ?? false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── adminInvitationCodes ──
|
||||
if (operationName === "AdminInvitationCodes") {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
adminInvitationCodes: paginate(
|
||||
db.invitationCodes,
|
||||
variables.filter ?? {},
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── generateInvitationCodes ──
|
||||
if (operationName === "GenerateInvitationCodes") {
|
||||
const input = variables.input;
|
||||
const count = Math.min(input.count ?? 10, 1000);
|
||||
const batchId = genId("bat");
|
||||
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let code = "";
|
||||
for (let j = 0; j < 8; j++) {
|
||||
code += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
codes.push(code);
|
||||
db.invitationCodes.unshift({
|
||||
id: genId("ic"),
|
||||
code,
|
||||
status: "unused",
|
||||
roleId: input.roleId ?? null,
|
||||
roleName: null,
|
||||
classId: input.classId ?? null,
|
||||
className: null,
|
||||
email: input.email ?? null,
|
||||
batchId,
|
||||
usedBy: null,
|
||||
usedByName: null,
|
||||
usedAt: null,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
});
|
||||
}
|
||||
recordAudit("INVITATION_CODE_GENERATE", "invitation_code", batchId);
|
||||
return HttpResponse.json({
|
||||
data: { generateInvitationCodes: { codes, batchId } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── deleteInvitationCodes ──
|
||||
if (operationName === "DeleteInvitationCodes") {
|
||||
const ids = variables.ids as string[];
|
||||
db.invitationCodes = db.invitationCodes.filter(
|
||||
(ic) => !ids.includes(ic.id),
|
||||
);
|
||||
recordAudit("INVITATION_CODE_DELETE", "invitation_code", ids.join(","));
|
||||
return HttpResponse.json({ data: { deleteInvitationCodes: true } });
|
||||
}
|
||||
|
||||
// ── adminFiles ──
|
||||
if (operationName === "AdminFiles") {
|
||||
return HttpResponse.json({
|
||||
data: { adminFiles: paginate(db.files, variables.filter ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── adminFileStats ──
|
||||
if (operationName === "AdminFileStats") {
|
||||
return HttpResponse.json({ data: { adminFileStats: mockFileStats } });
|
||||
}
|
||||
|
||||
// ── deleteFiles ──
|
||||
if (operationName === "DeleteFiles") {
|
||||
const ids = variables.ids as string[];
|
||||
db.files = db.files.filter((f) => !ids.includes(f.id));
|
||||
recordAudit("FILE_DELETE", "file", ids.join(","));
|
||||
return HttpResponse.json({ data: { deleteFiles: true } });
|
||||
}
|
||||
|
||||
// ── aiProviders ──
|
||||
if (operationName === "AiProviders") {
|
||||
return HttpResponse.json({ data: { aiProviders: db.aiProviders } });
|
||||
}
|
||||
|
||||
// ── updateAiProvider ──
|
||||
if (operationName === "UpdateAiProvider") {
|
||||
const provider = db.aiProviders.find((p) => p.id === variables.id);
|
||||
if (provider) {
|
||||
Object.assign(provider, variables.input);
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
updateAiProvider: {
|
||||
id: variables.id,
|
||||
isActive: provider?.isActive ?? false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── aiUsage ──
|
||||
if (operationName === "AiUsage") {
|
||||
return HttpResponse.json({ data: { aiUsage: mockAiUsage } });
|
||||
}
|
||||
|
||||
// ── 未匹配的 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;
|
||||
}
|
||||
22
apps/admin-portal/src/test/setup.ts
Normal file
22
apps/admin-portal/src/test/setup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Vitest 全局测试环境配置
|
||||
*
|
||||
* - 注册 @testing-library/jest-dom 自定义匹配器
|
||||
* - 提供 jsdom 环境下的 localStorage / fetch mock
|
||||
*/
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
// 每个测试后清理 React DOM
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// 每个测试前重置 localStorage
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// mock fetch(用于 auth.ts login 测试)
|
||||
global.fetch = vi.fn() as unknown as typeof fetch;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user