Compare commits
27 Commits
c6362f4b04
...
81a539b9ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||||
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 }}
|
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
run: npm install -g pnpm@9
|
run: corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
# ===== Go 质量检查 =====
|
# ===== Go 质量检查 =====
|
||||||
quality-go:
|
quality-go:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: golang:1.22-alpine
|
container: golang:1.25-alpine
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: services/api-gateway
|
working-directory: services/api-gateway
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,6 +34,12 @@ venv/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Secrets / Keys (JWT RS256 等)
|
||||||
|
keys/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM node:22-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装 pnpm
|
# 安装 pnpm
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||||
|
|
||||||
# 复制 workspace 配置
|
# 复制 workspace 配置
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
|
|||||||
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 更新状态。**
|
||||||
3
apps/admin-portal/next-env.d.ts
vendored
3
apps/admin-portal/next-env.d.ts
vendored
@@ -1,2 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
output: "standalone",
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@edu/ui-tokens",
|
"@edu/ui-tokens",
|
||||||
"@edu/ui-components",
|
"@edu/ui-components",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
exportAuditLogsCsv,
|
exportAuditLogsCsv,
|
||||||
} from "@/hooks/use-audit-logs";
|
} from "@/hooks/use-audit-logs";
|
||||||
import { useToast } from "@/providers/toast-provider";
|
import { useToast } from "@/providers/toast-provider";
|
||||||
|
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||||
import {
|
import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PaperCard,
|
PaperCard,
|
||||||
@@ -55,6 +56,8 @@ export default function AuditLogsPage(): ReactNode {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AuditLogsTabs />
|
||||||
|
|
||||||
<PaperCard className="p-4 mb-4">
|
<PaperCard className="p-4 mb-4">
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,19 @@ import {
|
|||||||
ErrorState,
|
ErrorState,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import type { SystemSettingsViewModel } from "@/types/view-models";
|
import {
|
||||||
|
mockSecurityPolicy,
|
||||||
|
mockFileUploadConfig,
|
||||||
|
mockNotificationConfig,
|
||||||
|
mockBrandConfig,
|
||||||
|
} from "@/mocks/fixtures";
|
||||||
|
import type {
|
||||||
|
SystemSettingsViewModel,
|
||||||
|
SecurityPolicy,
|
||||||
|
FileUploadConfig,
|
||||||
|
NotificationConfig,
|
||||||
|
BrandConfig,
|
||||||
|
} from "@/types/view-models";
|
||||||
|
|
||||||
export default function SystemPage(): ReactNode {
|
export default function SystemPage(): ReactNode {
|
||||||
const { data, loading, error } = useSystemSettings();
|
const { data, loading, error } = useSystemSettings();
|
||||||
@@ -24,6 +36,16 @@ export default function SystemPage(): ReactNode {
|
|||||||
const [updateSettings] = useUpdateSystemSettings();
|
const [updateSettings] = useUpdateSystemSettings();
|
||||||
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
|
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (data && !form) {
|
if (data && !form) {
|
||||||
@@ -209,6 +231,372 @@ export default function SystemPage(): ReactNode {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</PaperCard>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,14 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
||||||
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
||||||
{ route: "/admin/organization", label: t("admin.nav.organization") },
|
{ 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/classes", label: t("admin.nav.classes") },
|
||||||
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
||||||
{ route: "/admin/students", label: t("admin.nav.students") },
|
{ 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/audit-logs", label: t("admin.nav.auditLogs") },
|
||||||
{ route: "/admin/system", label: t("admin.nav.system") },
|
{ route: "/admin/system", label: t("admin.nav.system") },
|
||||||
];
|
];
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,13 @@ export function Badge({ status }: { status: string }): ReactNode {
|
|||||||
healthy: "var(--color-success)",
|
healthy: "var(--color-success)",
|
||||||
degraded: "var(--color-warning)",
|
degraded: "var(--color-warning)",
|
||||||
down: "var(--color-danger)",
|
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> = {
|
const labelMap: Record<string, string> = {
|
||||||
active: "启用",
|
active: "启用",
|
||||||
@@ -188,6 +195,13 @@ export function Badge({ status }: { status: string }): ReactNode {
|
|||||||
healthy: "健康",
|
healthy: "健康",
|
||||||
degraded: "降级",
|
degraded: "降级",
|
||||||
down: "异常",
|
down: "异常",
|
||||||
|
draft: "草稿",
|
||||||
|
published: "已发布",
|
||||||
|
archived: "已归档",
|
||||||
|
unused: "未使用",
|
||||||
|
used: "已使用",
|
||||||
|
expired: "已过期",
|
||||||
|
pinned: "置顶",
|
||||||
};
|
};
|
||||||
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
||||||
return (
|
return (
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
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 ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -331,3 +331,361 @@ export const UPDATE_SYSTEM_SETTINGS_MUTATION = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
` as const;
|
` 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,6 +162,125 @@ const messages: MessageMap = {
|
|||||||
// 退出
|
// 退出
|
||||||
"admin.auth.logout": "退出登录",
|
"admin.auth.logout": "退出登录",
|
||||||
"admin.auth.welcome": "Edu 管理端",
|
"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": "校训",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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_");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,18 @@ export const ADMIN_PERMISSIONS = {
|
|||||||
ORG_MANAGE: "ADMIN_ORG_MANAGE",
|
ORG_MANAGE: "ADMIN_ORG_MANAGE",
|
||||||
SCHOOL_READ: "ADMIN_SCHOOL_READ",
|
SCHOOL_READ: "ADMIN_SCHOOL_READ",
|
||||||
SCHOOL_MANAGE: "ADMIN_SCHOOL_MANAGE",
|
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",
|
MONITORING_VIEW: "ADMIN_MONITORING_VIEW",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -63,6 +75,19 @@ export const ROUTE_PERMISSIONS: Record<string, string> = {
|
|||||||
"/admin/teachers": ADMIN_PERMISSIONS.TEACHER_READ,
|
"/admin/teachers": ADMIN_PERMISSIONS.TEACHER_READ,
|
||||||
"/admin/students": ADMIN_PERMISSIONS.STUDENT_READ,
|
"/admin/students": ADMIN_PERMISSIONS.STUDENT_READ,
|
||||||
"/admin/audit-logs": ADMIN_PERMISSIONS.AUDIT_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) */
|
/** admin-portal 错误码前缀(ISSUE-045 §5.2) */
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,25 @@ import type {
|
|||||||
AdminDashboardViewModel,
|
AdminDashboardViewModel,
|
||||||
SystemSettingsViewModel,
|
SystemSettingsViewModel,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
SchoolViewModel,
|
||||||
|
GradeViewModel,
|
||||||
|
GradeInsightsViewModel,
|
||||||
|
DepartmentViewModel,
|
||||||
|
AcademicYearViewModel,
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeLogViewModel,
|
||||||
|
LoginLogViewModel,
|
||||||
|
AnnouncementViewModel,
|
||||||
|
InvitationCodeViewModel,
|
||||||
|
FileViewModel,
|
||||||
|
FileStats,
|
||||||
|
AiProviderViewModel,
|
||||||
|
AiUsageViewModel,
|
||||||
|
SecurityPolicy,
|
||||||
|
FileUploadConfig,
|
||||||
|
NotificationConfig,
|
||||||
|
BrandConfig,
|
||||||
} from "@/types/view-models";
|
} from "@/types/view-models";
|
||||||
|
|
||||||
export const mockCurrentUser: CurrentUser = {
|
export const mockCurrentUser: CurrentUser = {
|
||||||
@@ -904,3 +923,712 @@ export const mockSystemSettings: SystemSettingsViewModel = {
|
|||||||
export function genId(prefix: string): string {
|
export function genId(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 学校组织管理细化 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockSchools: SchoolViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "sch-001",
|
||||||
|
name: "Edu 实验学校",
|
||||||
|
code: "EDU-001",
|
||||||
|
address: "北京市海淀区中关村大街 1 号",
|
||||||
|
contactPhone: "010-88888888",
|
||||||
|
contactEmail: "contact@edu.test",
|
||||||
|
principalName: "张校长",
|
||||||
|
gradeCount: 6,
|
||||||
|
classCount: 24,
|
||||||
|
studentCount: 1200,
|
||||||
|
teacherCount: 80,
|
||||||
|
createdAt: Date.now() - 86400000 * 365,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sch-002",
|
||||||
|
name: "Edu 附属中学",
|
||||||
|
code: "EDU-002",
|
||||||
|
address: "北京市西城区西长安街 2 号",
|
||||||
|
contactPhone: "010-66666666",
|
||||||
|
contactEmail: "fz@edu.test",
|
||||||
|
principalName: "李校长",
|
||||||
|
gradeCount: 3,
|
||||||
|
classCount: 18,
|
||||||
|
studentCount: 900,
|
||||||
|
teacherCount: 60,
|
||||||
|
createdAt: Date.now() - 86400000 * 300,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockGrades: GradeViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "grd-001",
|
||||||
|
name: "一年级",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
directorName: "王主任",
|
||||||
|
classCount: 4,
|
||||||
|
studentCount: 200,
|
||||||
|
sortOrder: 1,
|
||||||
|
createdAt: Date.now() - 86400000 * 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grd-002",
|
||||||
|
name: "二年级",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
directorName: "刘主任",
|
||||||
|
classCount: 4,
|
||||||
|
studentCount: 210,
|
||||||
|
sortOrder: 2,
|
||||||
|
createdAt: Date.now() - 86400000 * 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grd-003",
|
||||||
|
name: "三年级",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
directorName: "陈主任",
|
||||||
|
classCount: 4,
|
||||||
|
studentCount: 195,
|
||||||
|
sortOrder: 3,
|
||||||
|
createdAt: Date.now() - 86400000 * 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grd-004",
|
||||||
|
name: "初一",
|
||||||
|
schoolId: "sch-002",
|
||||||
|
schoolName: "Edu 附属中学",
|
||||||
|
directorName: "赵主任",
|
||||||
|
classCount: 6,
|
||||||
|
studentCount: 300,
|
||||||
|
sortOrder: 1,
|
||||||
|
createdAt: Date.now() - 86400000 * 250,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockGradeInsights: GradeInsightsViewModel = {
|
||||||
|
gradeId: "grd-001",
|
||||||
|
gradeName: "一年级",
|
||||||
|
schoolWideSummary: {
|
||||||
|
totalStudents: 1200,
|
||||||
|
totalClasses: 24,
|
||||||
|
avgScore: 82.5,
|
||||||
|
passingRate: 0.95,
|
||||||
|
},
|
||||||
|
homeworkTimeline: [
|
||||||
|
{
|
||||||
|
homeworkId: "hw-001",
|
||||||
|
title: "数学练习一",
|
||||||
|
className: "一班",
|
||||||
|
avgScore: 85,
|
||||||
|
submittedCount: 48,
|
||||||
|
totalCount: 50,
|
||||||
|
dueDate: "2026-07-10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homeworkId: "hw-002",
|
||||||
|
title: "语文阅读",
|
||||||
|
className: "二班",
|
||||||
|
avgScore: 78,
|
||||||
|
submittedCount: 45,
|
||||||
|
totalCount: 50,
|
||||||
|
dueDate: "2026-07-11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homeworkId: "hw-003",
|
||||||
|
title: "英语听力",
|
||||||
|
className: "三班",
|
||||||
|
avgScore: 82,
|
||||||
|
submittedCount: 50,
|
||||||
|
totalCount: 50,
|
||||||
|
dueDate: "2026-07-12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homeworkId: "hw-004",
|
||||||
|
title: "数学练习二",
|
||||||
|
className: "四班",
|
||||||
|
avgScore: 88,
|
||||||
|
submittedCount: 47,
|
||||||
|
totalCount: 50,
|
||||||
|
dueDate: "2026-07-13",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classRankings: [
|
||||||
|
{
|
||||||
|
classId: "cls-001",
|
||||||
|
className: "一班",
|
||||||
|
avgScore: 85.2,
|
||||||
|
rank: 1,
|
||||||
|
delta: 2.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classId: "cls-002",
|
||||||
|
className: "四班",
|
||||||
|
avgScore: 83.8,
|
||||||
|
rank: 2,
|
||||||
|
delta: -0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classId: "cls-003",
|
||||||
|
className: "二班",
|
||||||
|
avgScore: 81.5,
|
||||||
|
rank: 3,
|
||||||
|
delta: 1.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
classId: "cls-004",
|
||||||
|
className: "三班",
|
||||||
|
avgScore: 79.6,
|
||||||
|
rank: 4,
|
||||||
|
delta: -1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockDepartments: DepartmentViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "dept-001",
|
||||||
|
name: "数学教研组",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
leaderName: "王老师",
|
||||||
|
memberCount: 12,
|
||||||
|
subject: "数学",
|
||||||
|
createdAt: Date.now() - 86400000 * 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dept-002",
|
||||||
|
name: "语文教研组",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
leaderName: "李老师",
|
||||||
|
memberCount: 10,
|
||||||
|
subject: "语文",
|
||||||
|
createdAt: Date.now() - 86400000 * 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dept-003",
|
||||||
|
name: "英语教研组",
|
||||||
|
schoolId: "sch-001",
|
||||||
|
schoolName: "Edu 实验学校",
|
||||||
|
leaderName: "张老师",
|
||||||
|
memberCount: 8,
|
||||||
|
subject: "英语",
|
||||||
|
createdAt: Date.now() - 86400000 * 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dept-004",
|
||||||
|
name: "理科教研组",
|
||||||
|
schoolId: "sch-002",
|
||||||
|
schoolName: "Edu 附属中学",
|
||||||
|
leaderName: "赵老师",
|
||||||
|
memberCount: 15,
|
||||||
|
subject: "物理",
|
||||||
|
createdAt: Date.now() - 86400000 * 180,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockAcademicYears: AcademicYearViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "ay-001",
|
||||||
|
name: "2025-2026学年",
|
||||||
|
startDate: "2025-09-01",
|
||||||
|
endDate: "2026-07-15",
|
||||||
|
firstSemesterStart: "2025-09-01",
|
||||||
|
firstSemesterEnd: "2026-01-15",
|
||||||
|
secondSemesterStart: "2026-02-15",
|
||||||
|
secondSemesterEnd: "2026-07-15",
|
||||||
|
isCurrent: true,
|
||||||
|
createdAt: Date.now() - 86400000 * 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ay-002",
|
||||||
|
name: "2024-2025学年",
|
||||||
|
startDate: "2024-09-01",
|
||||||
|
endDate: "2025-07-15",
|
||||||
|
firstSemesterStart: "2024-09-01",
|
||||||
|
firstSemesterEnd: "2025-01-15",
|
||||||
|
secondSemesterStart: "2025-02-15",
|
||||||
|
secondSemesterEnd: "2025-07-15",
|
||||||
|
isCurrent: false,
|
||||||
|
createdAt: Date.now() - 86400000 * 665,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 审计日志子模块 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockAuditOverviewStats: AuditOverviewStats = {
|
||||||
|
totalEvents: 15420,
|
||||||
|
todayEvents: 86,
|
||||||
|
uniqueActors: 142,
|
||||||
|
failedActions: 23,
|
||||||
|
topActions: [
|
||||||
|
{ action: "USER_LOGIN", count: 3200 },
|
||||||
|
{ action: "USER_CREATE", count: 156 },
|
||||||
|
{ action: "USER_UPDATE", count: 892 },
|
||||||
|
{ action: "ROLE_UPDATE", count: 45 },
|
||||||
|
{ action: "SYSTEM_SETTINGS_UPDATE", count: 12 },
|
||||||
|
],
|
||||||
|
topResources: [
|
||||||
|
{ resource: "user", count: 4200 },
|
||||||
|
{ resource: "role", count: 380 },
|
||||||
|
{ resource: "viewport", count: 120 },
|
||||||
|
{ resource: "system_settings", count: 45 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockAuditTrend: AuditTrendPoint[] = Array.from(
|
||||||
|
{ length: 7 },
|
||||||
|
(_, i) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
return {
|
||||||
|
date: d.toISOString().slice(0, 10),
|
||||||
|
count: Math.floor(50 + Math.random() * 50),
|
||||||
|
action: "USER_LOGIN",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mockDataChangeLogs: DataChangeLogViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "dcl-001",
|
||||||
|
occurredAt: Date.now() - 3600000,
|
||||||
|
actorUserId: "u-admin-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
tableName: "users",
|
||||||
|
action: "create",
|
||||||
|
recordId: "u-006",
|
||||||
|
beforeState: null,
|
||||||
|
afterState: '{"email":"new@edu.test","name":"新用户"}',
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dcl-002",
|
||||||
|
occurredAt: Date.now() - 7200000,
|
||||||
|
actorUserId: "u-admin-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
tableName: "roles",
|
||||||
|
action: "update",
|
||||||
|
recordId: "r-002",
|
||||||
|
beforeState: '{"name":"教师"}',
|
||||||
|
afterState: '{"name":"教师","permissions":["READ","WRITE"]}',
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dcl-003",
|
||||||
|
occurredAt: Date.now() - 10800000,
|
||||||
|
actorUserId: "u-admin-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
tableName: "users",
|
||||||
|
action: "delete",
|
||||||
|
recordId: "u-005",
|
||||||
|
beforeState: '{"email":"old@edu.test"}',
|
||||||
|
afterState: null,
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dcl-004",
|
||||||
|
occurredAt: Date.now() - 14400000,
|
||||||
|
actorUserId: "u-admin-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
tableName: "viewports",
|
||||||
|
action: "update",
|
||||||
|
recordId: "v-001",
|
||||||
|
beforeState: '{"sortOrder":1}',
|
||||||
|
afterState: '{"sortOrder":2}',
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dcl-005",
|
||||||
|
occurredAt: Date.now() - 18000000,
|
||||||
|
actorUserId: "u-admin-001",
|
||||||
|
actorName: "系统管理员",
|
||||||
|
tableName: "system_settings",
|
||||||
|
action: "update",
|
||||||
|
recordId: "settings",
|
||||||
|
beforeState: '{"schoolName":"旧名称"}',
|
||||||
|
afterState: '{"schoolName":"Edu 实验学校"}',
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockLoginLogs: LoginLogViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "ll-001",
|
||||||
|
occurredAt: Date.now() - 1800000,
|
||||||
|
userId: "u-admin-001",
|
||||||
|
userName: "系统管理员",
|
||||||
|
action: "signin",
|
||||||
|
status: "success",
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
userAgent: "Chrome/126.0",
|
||||||
|
failureReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ll-002",
|
||||||
|
occurredAt: Date.now() - 3600000,
|
||||||
|
userId: "u-002",
|
||||||
|
userName: "王老师",
|
||||||
|
action: "signin",
|
||||||
|
status: "success",
|
||||||
|
ip: "10.0.0.5",
|
||||||
|
userAgent: "Firefox/127.0",
|
||||||
|
failureReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ll-003",
|
||||||
|
occurredAt: Date.now() - 5400000,
|
||||||
|
userId: "unknown",
|
||||||
|
userName: "unknown",
|
||||||
|
action: "signin",
|
||||||
|
status: "failure",
|
||||||
|
ip: "203.0.113.5",
|
||||||
|
userAgent: "curl/8.0",
|
||||||
|
failureReason: "密码错误",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ll-004",
|
||||||
|
occurredAt: Date.now() - 7200000,
|
||||||
|
userId: "u-003",
|
||||||
|
userName: "李老师",
|
||||||
|
action: "signout",
|
||||||
|
status: "success",
|
||||||
|
ip: "10.0.0.8",
|
||||||
|
userAgent: "Chrome/126.0",
|
||||||
|
failureReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ll-005",
|
||||||
|
occurredAt: Date.now() - 9000000,
|
||||||
|
userId: "u-006",
|
||||||
|
userName: "新用户",
|
||||||
|
action: "signup",
|
||||||
|
status: "success",
|
||||||
|
ip: "10.0.0.12",
|
||||||
|
userAgent: "Safari/17.0",
|
||||||
|
failureReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ll-006",
|
||||||
|
occurredAt: Date.now() - 10800000,
|
||||||
|
userId: "unknown",
|
||||||
|
userName: "unknown",
|
||||||
|
action: "signin",
|
||||||
|
status: "failure",
|
||||||
|
ip: "198.51.100.1",
|
||||||
|
userAgent: "bot/1.0",
|
||||||
|
failureReason: "账户锁定",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 公告管理 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockAnnouncements: AnnouncementViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "ann-001",
|
||||||
|
title: "2026年春季学期开学通知",
|
||||||
|
content:
|
||||||
|
"各位师生:2026年春季学期将于2月15日正式开学,请提前做好准备工作。",
|
||||||
|
status: "published",
|
||||||
|
audience: ["all"],
|
||||||
|
authorName: "系统管理员",
|
||||||
|
isPinned: true,
|
||||||
|
readCount: 1150,
|
||||||
|
totalCount: 1200,
|
||||||
|
publishedAt: Date.now() - 86400000 * 3,
|
||||||
|
createdAt: Date.now() - 86400000 * 5,
|
||||||
|
updatedAt: Date.now() - 86400000 * 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-002",
|
||||||
|
title: "期中考试安排",
|
||||||
|
content: "期中考试将于第10周进行,具体安排另行通知。",
|
||||||
|
status: "published",
|
||||||
|
audience: ["teachers", "students"],
|
||||||
|
authorName: "系统管理员",
|
||||||
|
isPinned: false,
|
||||||
|
readCount: 800,
|
||||||
|
totalCount: 1280,
|
||||||
|
publishedAt: Date.now() - 86400000 * 2,
|
||||||
|
createdAt: Date.now() - 86400000 * 4,
|
||||||
|
updatedAt: Date.now() - 86400000 * 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-003",
|
||||||
|
title: "家长会通知(草稿)",
|
||||||
|
content: "定于下周六召开家长会,具体时间待定。",
|
||||||
|
status: "draft",
|
||||||
|
audience: ["parents"],
|
||||||
|
authorName: "系统管理员",
|
||||||
|
isPinned: false,
|
||||||
|
readCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
publishedAt: null,
|
||||||
|
createdAt: Date.now() - 86400000,
|
||||||
|
updatedAt: Date.now() - 86400000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-004",
|
||||||
|
title: "寒假放假通知",
|
||||||
|
content: "寒假时间为1月16日至2月14日。",
|
||||||
|
status: "archived",
|
||||||
|
audience: ["all"],
|
||||||
|
authorName: "系统管理员",
|
||||||
|
isPinned: false,
|
||||||
|
readCount: 1190,
|
||||||
|
totalCount: 1200,
|
||||||
|
publishedAt: Date.now() - 86400000 * 30,
|
||||||
|
createdAt: Date.now() - 86400000 * 32,
|
||||||
|
updatedAt: Date.now() - 86400000 * 25,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 邀请码管理 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockInvitationCodes: InvitationCodeViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "ic-001",
|
||||||
|
code: "AB3D7F9H",
|
||||||
|
status: "unused",
|
||||||
|
roleId: "r-001",
|
||||||
|
roleName: "教师",
|
||||||
|
classId: null,
|
||||||
|
className: null,
|
||||||
|
email: null,
|
||||||
|
batchId: "bat-001",
|
||||||
|
usedBy: null,
|
||||||
|
usedByName: null,
|
||||||
|
usedAt: null,
|
||||||
|
createdAt: Date.now() - 86400000,
|
||||||
|
expiresAt: Date.now() + 86400000 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ic-002",
|
||||||
|
code: "JC5K8M2P",
|
||||||
|
status: "unused",
|
||||||
|
roleId: "r-002",
|
||||||
|
roleName: "学生",
|
||||||
|
classId: "cls-001",
|
||||||
|
className: "一年级一班",
|
||||||
|
email: null,
|
||||||
|
batchId: "bat-001",
|
||||||
|
usedBy: null,
|
||||||
|
usedByName: null,
|
||||||
|
usedAt: null,
|
||||||
|
createdAt: Date.now() - 86400000,
|
||||||
|
expiresAt: Date.now() + 86400000 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ic-003",
|
||||||
|
code: "QR7S4T6V",
|
||||||
|
status: "used",
|
||||||
|
roleId: "r-003",
|
||||||
|
roleName: "家长",
|
||||||
|
classId: null,
|
||||||
|
className: null,
|
||||||
|
email: "parent@edu.test",
|
||||||
|
batchId: "bat-001",
|
||||||
|
usedBy: "u-007",
|
||||||
|
usedByName: "张家长",
|
||||||
|
usedAt: Date.now() - 3600000,
|
||||||
|
createdAt: Date.now() - 86400000,
|
||||||
|
expiresAt: Date.now() + 86400000 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ic-004",
|
||||||
|
code: "WX9Y2Z4B",
|
||||||
|
status: "expired",
|
||||||
|
roleId: null,
|
||||||
|
roleName: null,
|
||||||
|
classId: null,
|
||||||
|
className: null,
|
||||||
|
email: null,
|
||||||
|
batchId: "bat-000",
|
||||||
|
usedBy: null,
|
||||||
|
usedByName: null,
|
||||||
|
usedAt: null,
|
||||||
|
createdAt: Date.now() - 86400000 * 60,
|
||||||
|
expiresAt: Date.now() - 86400000 * 30,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 文件管理 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockFiles: FileViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "file-001",
|
||||||
|
filename: "期中考试试卷.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: 1024000,
|
||||||
|
url: "/files/exam-mid.pdf",
|
||||||
|
entityType: "exam",
|
||||||
|
entityId: "exam-001",
|
||||||
|
uploadedByName: "王老师",
|
||||||
|
createdAt: Date.now() - 86400000 * 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file-002",
|
||||||
|
filename: "数学课件.pptx",
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
size: 5120000,
|
||||||
|
url: "/files/math.pptx",
|
||||||
|
entityType: "lesson_plan",
|
||||||
|
entityId: "lp-001",
|
||||||
|
uploadedByName: "李老师",
|
||||||
|
createdAt: Date.now() - 86400000 * 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file-003",
|
||||||
|
filename: "学生头像.jpg",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
size: 256000,
|
||||||
|
url: "/files/avatar.jpg",
|
||||||
|
entityType: "user_avatar",
|
||||||
|
entityId: "u-005",
|
||||||
|
uploadedByName: "系统管理员",
|
||||||
|
createdAt: Date.now() - 86400000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file-004",
|
||||||
|
filename: "开学通知.docx",
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
size: 89000,
|
||||||
|
url: "/files/notice.docx",
|
||||||
|
entityType: "announcement",
|
||||||
|
entityId: "ann-001",
|
||||||
|
uploadedByName: "系统管理员",
|
||||||
|
createdAt: Date.now() - 86400000 * 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file-005",
|
||||||
|
filename: "成绩统计.xlsx",
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
size: 348000,
|
||||||
|
url: "/files/grades.xlsx",
|
||||||
|
entityType: "homework",
|
||||||
|
entityId: "hw-001",
|
||||||
|
uploadedByName: "王老师",
|
||||||
|
createdAt: Date.now() - 86400000 * 7,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockFileStats: FileStats = {
|
||||||
|
totalFiles: 348,
|
||||||
|
totalSize: 2840000000,
|
||||||
|
byMimeType: [
|
||||||
|
{ mimeType: "application/pdf", count: 85, size: 95000000 },
|
||||||
|
{ mimeType: "image/jpeg", count: 120, size: 340000000 },
|
||||||
|
{
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
count: 45,
|
||||||
|
size: 12000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
count: 30,
|
||||||
|
size: 8900000,
|
||||||
|
},
|
||||||
|
{ mimeType: "video/mp4", count: 12, size: 2300000000 },
|
||||||
|
],
|
||||||
|
byEntityType: [
|
||||||
|
{ entityType: "exam", count: 85 },
|
||||||
|
{ entityType: "lesson_plan", count: 60 },
|
||||||
|
{ entityType: "user_avatar", count: 120 },
|
||||||
|
{ entityType: "announcement", count: 25 },
|
||||||
|
{ entityType: "homework", count: 58 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── AI 配置 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockAiProviders: AiProviderViewModel[] = [
|
||||||
|
{
|
||||||
|
id: "aip-001",
|
||||||
|
name: "OpenAI",
|
||||||
|
type: "openai",
|
||||||
|
isPublic: true,
|
||||||
|
isActive: true,
|
||||||
|
model: "gpt-4o",
|
||||||
|
apiBase: "https://api.openai.com/v1",
|
||||||
|
createdBy: "系统管理员",
|
||||||
|
createdAt: Date.now() - 86400000 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aip-002",
|
||||||
|
name: "通义千问",
|
||||||
|
type: "qwen",
|
||||||
|
isPublic: true,
|
||||||
|
isActive: true,
|
||||||
|
model: "qwen-max",
|
||||||
|
apiBase: "https://dashscope.aliyuncs.com/api/v1",
|
||||||
|
createdBy: "系统管理员",
|
||||||
|
createdAt: Date.now() - 86400000 * 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aip-003",
|
||||||
|
name: "个人 DeepSeek",
|
||||||
|
type: "deepseek",
|
||||||
|
isPublic: false,
|
||||||
|
isActive: false,
|
||||||
|
model: "deepseek-chat",
|
||||||
|
apiBase: "https://api.deepseek.com/v1",
|
||||||
|
createdBy: "王老师",
|
||||||
|
createdAt: Date.now() - 86400000 * 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockAiUsage: AiUsageViewModel = {
|
||||||
|
totalRequests: 12580,
|
||||||
|
totalTokens: 4520000,
|
||||||
|
totalCost: 68.5,
|
||||||
|
byProvider: [
|
||||||
|
{ providerName: "OpenAI", requests: 8200, tokens: 3200000, cost: 52.3 },
|
||||||
|
{ providerName: "通义千问", requests: 3380, tokens: 1100000, cost: 12.8 },
|
||||||
|
{
|
||||||
|
providerName: "个人 DeepSeek",
|
||||||
|
requests: 1000,
|
||||||
|
tokens: 220000,
|
||||||
|
cost: 3.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
byDay: Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
return {
|
||||||
|
date: d.toISOString().slice(0, 10),
|
||||||
|
requests: Math.floor(800 + Math.random() * 400),
|
||||||
|
tokens: Math.floor(300000 + Math.random() * 200000),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 增强系统设置 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const mockSecurityPolicy: SecurityPolicy = {
|
||||||
|
passwordMinLength: 8,
|
||||||
|
passwordRequireSpecial: true,
|
||||||
|
passwordRequireUppercase: true,
|
||||||
|
sessionTimeoutMinutes: 30,
|
||||||
|
forcePasswordChange: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockFileUploadConfig: FileUploadConfig = {
|
||||||
|
maxFileSize: 50,
|
||||||
|
allowedTypes: ["pdf", "jpg", "jpeg", "png", "docx", "xlsx", "pptx", "mp4"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockNotificationConfig: NotificationConfig = {
|
||||||
|
notifyNewUser: true,
|
||||||
|
notifyScheduleChange: true,
|
||||||
|
notifyAnnouncement: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockBrandConfig: BrandConfig = {
|
||||||
|
logoUrl: "/logo.png",
|
||||||
|
primaryColor: "#1a56db",
|
||||||
|
schoolMotto: "博学笃行,求实创新",
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ import {
|
|||||||
mockAuditLogs,
|
mockAuditLogs,
|
||||||
mockDashboard,
|
mockDashboard,
|
||||||
mockSystemSettings,
|
mockSystemSettings,
|
||||||
|
mockSchools,
|
||||||
|
mockGrades,
|
||||||
|
mockGradeInsights,
|
||||||
|
mockDepartments,
|
||||||
|
mockAcademicYears,
|
||||||
|
mockAuditOverviewStats,
|
||||||
|
mockAuditTrend,
|
||||||
|
mockDataChangeLogs,
|
||||||
|
mockLoginLogs,
|
||||||
|
mockAnnouncements,
|
||||||
|
mockInvitationCodes,
|
||||||
|
mockFiles,
|
||||||
|
mockFileStats,
|
||||||
|
mockAiProviders,
|
||||||
|
mockAiUsage,
|
||||||
|
mockSecurityPolicy,
|
||||||
|
mockFileUploadConfig,
|
||||||
|
mockNotificationConfig,
|
||||||
|
mockBrandConfig,
|
||||||
genId,
|
genId,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
import type {
|
import type {
|
||||||
@@ -45,6 +64,18 @@ const db = {
|
|||||||
students: [...mockStudents],
|
students: [...mockStudents],
|
||||||
auditLogs: [...mockAuditLogs],
|
auditLogs: [...mockAuditLogs],
|
||||||
systemSettings: { ...mockSystemSettings },
|
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>(
|
function paginate<T>(
|
||||||
@@ -507,6 +538,263 @@ export const handlers = [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ──
|
// ── 未匹配的 operation ──
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ errors: [{ message: `未实现的 GraphQL operation: ${operationName}` }] },
|
{ errors: [{ message: `未实现的 GraphQL operation: ${operationName}` }] },
|
||||||
|
|||||||
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;
|
||||||
@@ -205,3 +205,270 @@ export interface WsNotification {
|
|||||||
severity: "info" | "warning" | "error";
|
severity: "info" | "warning" | "error";
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 学校组织管理细化 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 学校视图模型 */
|
||||||
|
export interface SchoolViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
address: string;
|
||||||
|
contactPhone: string;
|
||||||
|
contactEmail: string;
|
||||||
|
principalName: string | null;
|
||||||
|
gradeCount: number;
|
||||||
|
classCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
teacherCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 年级视图模型 */
|
||||||
|
export interface GradeViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schoolId: string;
|
||||||
|
schoolName: string;
|
||||||
|
directorName: string | null;
|
||||||
|
classCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 年级概览统计 */
|
||||||
|
export interface GradeOverviewStats {
|
||||||
|
gradeId: string;
|
||||||
|
gradeName: string;
|
||||||
|
classCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
teacherCount: number;
|
||||||
|
avgScore: number;
|
||||||
|
homeworkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 年级洞察视图模型 */
|
||||||
|
export interface GradeInsightsViewModel {
|
||||||
|
gradeId: string;
|
||||||
|
gradeName: string;
|
||||||
|
schoolWideSummary: {
|
||||||
|
totalStudents: number;
|
||||||
|
totalClasses: number;
|
||||||
|
avgScore: number;
|
||||||
|
passingRate: number;
|
||||||
|
};
|
||||||
|
homeworkTimeline: Array<{
|
||||||
|
homeworkId: string;
|
||||||
|
title: string;
|
||||||
|
className: string;
|
||||||
|
avgScore: number;
|
||||||
|
submittedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
dueDate: string;
|
||||||
|
}>;
|
||||||
|
classRankings: Array<{
|
||||||
|
classId: string;
|
||||||
|
className: string;
|
||||||
|
avgScore: number;
|
||||||
|
rank: number;
|
||||||
|
delta: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 部门/教研组视图模型 */
|
||||||
|
export interface DepartmentViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schoolId: string;
|
||||||
|
schoolName: string;
|
||||||
|
leaderName: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
subject: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学年/学期视图模型 */
|
||||||
|
export interface AcademicYearViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
firstSemesterStart: string;
|
||||||
|
firstSemesterEnd: string;
|
||||||
|
secondSemesterStart: string;
|
||||||
|
secondSemesterEnd: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 审计日志子模块 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 审计总览统计 */
|
||||||
|
export interface AuditOverviewStats {
|
||||||
|
totalEvents: number;
|
||||||
|
todayEvents: number;
|
||||||
|
uniqueActors: number;
|
||||||
|
failedActions: number;
|
||||||
|
topActions: Array<{ action: string; count: number }>;
|
||||||
|
topResources: Array<{ resource: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计趋势数据点 */
|
||||||
|
export interface AuditTrendPoint {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数据变更日志视图模型 */
|
||||||
|
export interface DataChangeLogViewModel {
|
||||||
|
id: string;
|
||||||
|
occurredAt: number;
|
||||||
|
actorUserId: string;
|
||||||
|
actorName: string;
|
||||||
|
tableName: string;
|
||||||
|
action: "create" | "update" | "delete";
|
||||||
|
recordId: string;
|
||||||
|
beforeState: string | null;
|
||||||
|
afterState: string | null;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录日志视图模型 */
|
||||||
|
export interface LoginLogViewModel {
|
||||||
|
id: string;
|
||||||
|
occurredAt: number;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
action: "signin" | "signout" | "signup";
|
||||||
|
status: "success" | "failure";
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
failureReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 公告管理 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 公告视图模型 */
|
||||||
|
export interface AnnouncementViewModel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
status: "draft" | "published" | "archived";
|
||||||
|
audience: string[];
|
||||||
|
authorName: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
readCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
publishedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 邀请码管理 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 邀请码视图模型 */
|
||||||
|
export interface InvitationCodeViewModel {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: "unused" | "used" | "expired";
|
||||||
|
roleId: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
classId: string | null;
|
||||||
|
className: string | null;
|
||||||
|
email: string | null;
|
||||||
|
batchId: string;
|
||||||
|
usedBy: string | null;
|
||||||
|
usedByName: string | null;
|
||||||
|
usedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 文件管理 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 文件视图模型 */
|
||||||
|
export interface FileViewModel {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
uploadedByName: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件统计 */
|
||||||
|
export interface FileStats {
|
||||||
|
totalFiles: number;
|
||||||
|
totalSize: number;
|
||||||
|
byMimeType: Array<{ mimeType: string; count: number; size: number }>;
|
||||||
|
byEntityType: Array<{ entityType: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI 配置 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** AI Provider 视图模型 */
|
||||||
|
export interface AiProviderViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
model: string;
|
||||||
|
apiBase: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 用量视图模型 */
|
||||||
|
export interface AiUsageViewModel {
|
||||||
|
totalRequests: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
byProvider: Array<{
|
||||||
|
providerName: string;
|
||||||
|
requests: number;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}>;
|
||||||
|
byDay: Array<{
|
||||||
|
date: string;
|
||||||
|
requests: number;
|
||||||
|
tokens: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 增强系统设置 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 安全策略配置 */
|
||||||
|
export interface SecurityPolicy {
|
||||||
|
passwordMinLength: number;
|
||||||
|
passwordRequireSpecial: boolean;
|
||||||
|
passwordRequireUppercase: boolean;
|
||||||
|
sessionTimeoutMinutes: number;
|
||||||
|
forcePasswordChange: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件上传配置 */
|
||||||
|
export interface FileUploadConfig {
|
||||||
|
maxFileSize: number;
|
||||||
|
allowedTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知配置 */
|
||||||
|
export interface NotificationConfig {
|
||||||
|
notifyNewUser: boolean;
|
||||||
|
notifyScheduleChange: boolean;
|
||||||
|
notifyAnnouncement: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 品牌配置 */
|
||||||
|
export interface BrandConfig {
|
||||||
|
logoUrl: string;
|
||||||
|
primaryColor: string;
|
||||||
|
schoolMotto: string;
|
||||||
|
}
|
||||||
|
|||||||
47
apps/parent-portal/.dockerignore
Normal file
47
apps/parent-portal/.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 依赖目录(构建时重新安装)
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# 测试与开发工具
|
||||||
|
.turbo
|
||||||
|
.cache
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# 环境变量与本地配置
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE 与编辑器
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# 平台与临时文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# TypeScript 增量构建缓存
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 测试覆盖率报告
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
@@ -12,13 +12,15 @@ NEXT_PUBLIC_MF_ENABLED=disabled
|
|||||||
|
|
||||||
# ===== GraphQL(F9 裁决)=====
|
# ===== GraphQL(F9 裁决)=====
|
||||||
# parent-bff GraphQL 端点(经 api-gateway 代理)
|
# parent-bff GraphQL 端点(经 api-gateway 代理)
|
||||||
|
# 双 /v1 前缀(ARB-022 §24.4 ISSUE-003 方案 A):/api/v1/parent/v1/graphql
|
||||||
# 批次 3(P4)parent-bff 就绪前用 MSW mock
|
# 批次 3(P4)parent-bff 就绪前用 MSW mock
|
||||||
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/graphql
|
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/v1/graphql
|
||||||
|
|
||||||
# ===== Mock =====
|
# ===== Mock =====
|
||||||
# MSW mock 开关:enabled 启用 mock,disabled 调真实后端
|
# MSW mock 开关:enabled 启用 mock,disabled 调真实后端
|
||||||
# 依据:workline.md §4.3 全并行 Mock 策略
|
# 依据:workline.md §4.3 全并行 Mock 策略
|
||||||
NEXT_PUBLIC_API_MOCKING=enabled
|
# 默认禁用:parent-portal 已切换至真实 API 集成(详见 docs/nextstep.md)
|
||||||
|
NEXT_PUBLIC_API_MOCKING=disabled
|
||||||
|
|
||||||
# ===== 推送(P5)=====
|
# ===== 推送(P5)=====
|
||||||
# push-gateway WebSocket 地址
|
# push-gateway WebSocket 地址
|
||||||
@@ -28,9 +30,21 @@ NEXT_PUBLIC_PUSH_GATEWAY_WS_URL=ws://localhost:8081/ws
|
|||||||
# 应用端口(004 §1.2 强制 4 端 4000-4003)
|
# 应用端口(004 §1.2 强制 4 端 4000-4003)
|
||||||
PORT=4002
|
PORT=4002
|
||||||
|
|
||||||
# ===== 可观测性(P6)=====
|
# ===== 可观测性(P6-1)=====
|
||||||
# OTel collector 端点
|
# OTel 开关:true 启用 browser SDK(需安装 @opentelemetry/* 包)
|
||||||
|
NEXT_PUBLIC_OTEL_ENABLED=false
|
||||||
|
|
||||||
|
# OTel collector 端点(OTLP/HTTP)
|
||||||
NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=
|
NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
|
||||||
# Web Vitals 上报端点
|
# Web Vitals 上报端点(默认 /api/v1/parent/v1/web-vitals,双 /v1 前缀 ARB-022 §24.4)
|
||||||
NEXT_PUBLIC_WEB_VITALS_ENDPOINT=/api/v1/admin/web-vitals
|
NEXT_PUBLIC_WEB_VITALS_ENDPOINT=/api/v1/parent/v1/web-vitals
|
||||||
|
|
||||||
|
# Sentry DSN(可选,未配置则禁用 Sentry)
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
|
||||||
|
# Sentry release 版本号(可选)
|
||||||
|
NEXT_PUBLIC_SENTRY_RELEASE=
|
||||||
|
|
||||||
|
# Sentry 采样率(0~1,默认 0.1)
|
||||||
|
NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
# 多阶段构建:Next.js 生产镜像(parent-portal MF Remote)
|
# 多阶段构建:Next.js 生产镜像(parent-portal,standalone 模式)
|
||||||
# 用法:docker build -t edu/parent-portal:latest -f apps/parent-portal/Dockerfile .
|
# 用法:docker build -t edu/parent-portal:latest -f apps/parent-portal/Dockerfile .
|
||||||
# 依据:02-architecture-design.md §16 部署、G1 裁决(首次即多阶段)
|
# 依据:02-architecture-design.md §16 部署、G1 裁决(首次即多阶段)
|
||||||
# 端口:4002(4 端 portal 强制 4000-4003)
|
# 端口:4002(4 端 portal 强制 4000-4003)
|
||||||
|
|
||||||
# ============ Builder ============
|
# ============ Builder ============
|
||||||
FROM node:20-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 启用 pnpm
|
# 启用 pnpm
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||||
|
|
||||||
# 先拷依赖清单,利用缓存
|
# 先拷依赖清单,利用缓存
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||||
|
COPY tsconfig.base.json ./tsconfig.base.json
|
||||||
COPY apps/parent-portal/package.json ./apps/parent-portal/
|
COPY apps/parent-portal/package.json ./apps/parent-portal/
|
||||||
# 安装依赖(含 devDependencies,构建需要)
|
# 安装依赖(含 devDependencies,构建需要)
|
||||||
RUN pnpm install --filter @edu/parent-portal... --frozen-lockfile || pnpm install --filter @edu/parent-portal...
|
RUN pnpm install --filter @edu/parent-portal... --frozen-lockfile || pnpm install --filter @edu/parent-portal...
|
||||||
@@ -19,34 +20,36 @@ RUN pnpm install --filter @edu/parent-portal... --frozen-lockfile || pnpm instal
|
|||||||
# 拷源码
|
# 拷源码
|
||||||
COPY apps/parent-portal ./apps/parent-portal
|
COPY apps/parent-portal ./apps/parent-portal
|
||||||
|
|
||||||
# 构建(禁用 telemetry,生产模式)
|
# 构建(禁用 telemetry,生产模式,standalone 输出)
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
|
||||||
RUN pnpm --filter @edu/parent-portal run build
|
RUN pnpm --filter @edu/parent-portal run build
|
||||||
|
|
||||||
# ============ Runtime ============
|
# ============ Runtime ============
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV PORT=4002
|
ENV PORT=4002
|
||||||
|
# standalone server.js 默认绑定 HOSTNAME(Docker 中为容器 ID),导致 localhost 不可达
|
||||||
|
# 强制绑定 0.0.0.0 让健康检查 wget localhost 能连通
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# 非 root 用户运行
|
# 非 root 用户运行
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||||
|
|
||||||
# 拷构建产物与必要清单
|
# 拷 standalone 产物(已含 node_modules 和 server.js,自包含)
|
||||||
COPY --from=builder /app/apps/parent-portal/package.json ./package.json
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/parent-portal/.next/standalone ./
|
||||||
COPY --from=builder /app/apps/parent-portal/.next ./.next
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/parent-portal/.next/static ./.next/static
|
||||||
COPY --from=builder /app/apps/parent-portal/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/parent-portal/public ./public
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/apps/parent-portal/next.config.js ./next.config.js
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 4002
|
EXPOSE 4002
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查(使用 127.0.0.1 而非 localhost,避免容器内 hosts 解析问题)
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
CMD wget --quiet --spider http://localhost:4002/api/health || exit 1
|
CMD wget --quiet --spider http://127.0.0.1:4002/api/health || exit 1
|
||||||
|
|
||||||
CMD ["node_modules/.bin/next", "start", "-p", "4002"]
|
# standalone 模式下直接用 node server.js 启动(已自包含所有依赖)
|
||||||
|
WORKDIR /app/apps/parent-portal
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -61,11 +61,13 @@
|
|||||||
|
|
||||||
### 3.1 消费的后端 API(经 api-gateway 代理)
|
### 3.1 消费的后端 API(经 api-gateway 代理)
|
||||||
|
|
||||||
| 路径前缀 | 下游 BFF/服务 | 关键端点 |
|
> **路径前缀说明**(ARB-022 §24.4 ISSUE-003 方案 A):所有 API 路径采用双 /v1 前缀(gateway /api/v1 + 服务 /v1)。下方表格保留初版理解的结构,实际实现以 [02-architecture-design.md](./02-architecture-design.md) §F9 GraphQL 裁决 + ARB-022 §24.4 双 /v1 为准。
|
||||||
| ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `/api/v1/parent/*` | parent-bff | `GET /parent/viewports`、`GET /parent/dashboard`、`GET /parent/children`、`POST /parent/children/:childId/select`、`GET /parent/children/:childId/exams`、`/homework`、`/grades`、`/analytics/trend`、`/analytics/weakness`、`GET /parent/notifications`、`PUT /parent/notification-preferences` |
|
| 路径前缀 | 下游 BFF/服务 | 关键端点(实际实现见 02 §F9) |
|
||||||
| `/api/v1/iam/*` | iam | `POST /iam/login`、`GET /iam/me`、`GET /iam/effective-permissions`、`GET /iam/children`(**P0 阻塞**,待 ai02 补全) |
|
| ---------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/api/v1/notifications/*` | msg | 通知中心(P5) |
|
| `/api/v1/parent/v1/*` | parent-bff | GraphQL `POST /api/v1/parent/v1/graphql`(F9 裁决,非 REST);初版理解含 `GET /parent/dashboard` 等 REST 端点已被 F9 取代 |
|
||||||
|
| `/api/v1/iam/v1/*` | iam | `POST /iam/v1/login`、`GET /iam/v1/me`、`GET /iam/v1/permissions/effective`、`GET /iam/v1/children`(**P0 阻塞**,待 ai06 补全) |
|
||||||
|
| `/api/v1/notifications/v1/*` | msg | 通知中心(P5) |
|
||||||
|
|
||||||
> parent-bff 聚合 iam + core-edu + data-ana + msg,对外暴露家长场景的统一接口(子女关联、视口、学情聚合)。
|
> parent-bff 聚合 iam + core-edu + data-ana + msg,对外暴露家长场景的统一接口(子女关联、视口、学情聚合)。
|
||||||
> **P0 阻塞项**(来自 parent-bff §7.1):iam 缺失"家长-学生关联查询"接口(`GetChildrenByParent` proto + `GET /iam/children` REST + `iam_student_guardians` 表三缺失)。在 ai02 补全前,parent-portal 的多子女场景无法落地,仅能假设单子女硬编码 childId 进行开发调试。
|
> **P0 阻塞项**(来自 parent-bff §7.1):iam 缺失"家长-学生关联查询"接口(`GetChildrenByParent` proto + `GET /iam/children` REST + `iam_student_guardians` 表三缺失)。在 ai02 补全前,parent-portal 的多子女场景无法落地,仅能假设单子女硬编码 childId 进行开发调试。
|
||||||
@@ -211,6 +213,7 @@ apps/parent-portal/
|
|||||||
| `/parent/children` | 子女列表 | `PARENT_CHILDREN_VIEW` |
|
| `/parent/children` | 子女列表 | `PARENT_CHILDREN_VIEW` |
|
||||||
| `/parent/grades` | 子女成绩 | `GRADES_READ_CHILD` |
|
| `/parent/grades` | 子女成绩 | `GRADES_READ_CHILD` |
|
||||||
| `/parent/homework` | 子女作业 | `HOMEWORK_READ_CHILD` |
|
| `/parent/homework` | 子女作业 | `HOMEWORK_READ_CHILD` |
|
||||||
|
| `/parent/trend` | 学习趋势 | `CHILD_TREND_VIEW` |
|
||||||
| `/parent/notifications` | 通知中心(P5) | `NOTIFICATION_READ_OWN` |
|
| `/parent/notifications` | 通知中心(P5) | `NOTIFICATION_READ_OWN` |
|
||||||
| `/parent/preferences` | 通知偏好 | `PARENT_PREFERENCES_UPDATE` |
|
| `/parent/preferences` | 通知偏好 | `PARENT_PREFERENCES_UPDATE` |
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
> **总裁裁决回写**(2026-07-10,依据 [president-final-rulings.md](../../../docs/architecture/president-final-rulings.md) + [coord-final-decisions.md](../../../docs/architecture/coord-final-decisions.md)):
|
> **总裁裁决回写**(2026-07-10,依据 [president-final-rulings.md](../../../docs/architecture/president-final-rulings.md) + [coord-final-decisions.md](../../../docs/architecture/coord-final-decisions.md)):
|
||||||
>
|
>
|
||||||
> 1. **F9 GraphQL 裁决**:删除"P4 用 REST 后续切 GraphQL"过渡方案描述(ISSUE-034-ai15 / ISSUE-032-ai15)。parent-portal **首次实现即用 GraphQL(urql)**,不再使用 REST 消费 parent-bff。登录端点 `POST /api/v1/iam/login` 是唯一走 REST 的端点(ISSUE-004)。
|
> 1. **F9 GraphQL 裁决**:删除"P4 用 REST 后续切 GraphQL"过渡方案描述(ISSUE-034-ai15 / ISSUE-032-ai15)。parent-portal **首次实现即用 GraphQL(urql)**,不再使用 REST 消费 parent-bff。登录端点 `POST /api/v1/iam/v1/login`(双 /v1 前缀,ARB-022 §24.4 ISSUE-003 方案 A)是唯一走 REST 的端点(ISSUE-004)。
|
||||||
> 2. **F7 权限点命名**:统一 `<RESOURCE>_<ACTION>[_<SCOPE>]`,数据范围后缀 `_OWN`/`_CHILD`。原文档权限点已对齐(如 `PARENT_DASHBOARD_VIEW`、`GRADE_READ_CHILD`)。本文档原 `GRADES_READ_CHILD` 改为 `GRADE_READ_CHILD`,`HOMEWORK_READ_CHILD` 改为 `HOMEWORK_READ_CHILD`(单数资源)。
|
> 2. **F7 权限点命名**:统一 `<RESOURCE>_<ACTION>[_<SCOPE>]`,数据范围后缀 `_OWN`/`_CHILD`。原文档权限点已对齐(如 `PARENT_DASHBOARD_VIEW`、`GRADE_READ_CHILD`)。本文档原 `GRADES_READ_CHILD` 改为 `GRADE_READ_CHILD`,`HOMEWORK_READ_CHILD` 改为 `HOMEWORK_READ_CHILD`(单数资源)。
|
||||||
> 3. **F4 i18n key 命名**:统一 `error.<service>.<code_snake>`(如 `error.iam.invalid_credentials`、`error.bffParent.validation_error`)。
|
> 3. **F4 i18n key 命名**:统一 `error.<service>.<code_snake>`(如 `error.iam.invalid_credentials`、`error.bffParent.validation_error`)。
|
||||||
> 4. **F5 错误码子前缀**:删除 `GRADES_`/`HOMEWORK_` 子前缀,统一用 `CORE_EDU_*`。
|
> 4. **F5 错误码子前缀**:删除 `GRADES_`/`HOMEWORK_` 子前缀,统一用 `CORE_EDU_*`。
|
||||||
|
|||||||
198
apps/parent-portal/docs/nextstep-v2.md
Normal file
198
apps/parent-portal/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# parent-portal 下游待办事项 v2(核查结果)
|
||||||
|
|
||||||
|
> 版本:v2
|
||||||
|
> 日期:2026-07-14
|
||||||
|
> 用途:核查 v1 记录的下游工作完成情况,记录仍未完成项与新发现的问题
|
||||||
|
> 前置文档:[nextstep.md](./nextstep.md)(v1,2026-07-13)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核查结论
|
||||||
|
|
||||||
|
v1 记录的 5 项下游待办中:
|
||||||
|
|
||||||
|
- ✅ **1 项已完成**:parent-bff pnpm-lock.yaml 已添加
|
||||||
|
- ❌ **4 项仍未完成**:端点路径、Docker 构建、schema 扩充、api-gateway 镜像
|
||||||
|
|
||||||
|
parent-portal 自身经验证仍完全就绪:Docker 镜像构建成功(standalone 模式,25 路由),容器 healthy,`/api/health` 200,`/login` 200,typecheck + lint 零错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核查结果对照表
|
||||||
|
|
||||||
|
| 项目 | v1 状态 | v2 核查 | 说明 |
|
||||||
|
| -------------------------------- | -------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| parent-portal Docker 镜像 | ✅ 已就绪 | ✅ 仍就绪 | `edu-parent-portal:test`,standalone 模式,25 路由,容器 healthy |
|
||||||
|
| parent-portal 容器健康检查 | ✅ 通过 | ✅ 仍通过 | `/api/health` 200,`/login` 200,容器状态 healthy |
|
||||||
|
| Mock 数据 | ✅ 已禁用 | ✅ 仍禁用 | `NEXT_PUBLIC_API_MOCKING=disabled` |
|
||||||
|
| GraphQL 端点配置 | ✅ 已配置 | ✅ 仍配置 | `NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/v1/graphql` |
|
||||||
|
| parent-bff pnpm-lock.yaml | ❌ 缺失 | ✅ **已完成** | 241KB,可被 Docker 使用 |
|
||||||
|
| parent-bff 端点路径 | ❌ 错配 | ❌ **仍未修复** | controller 仍为 `/graphql`,应为 `/v1/parent/v1/graphql` |
|
||||||
|
| parent-bff GraphQL schema | ❌ 未扩充 | ❌ **仍未扩充** | 仍为 10Q+3M,前端需 32Q+6M |
|
||||||
|
| parent-bff extended-resolvers.ts | — | ❌ **未集成(新发现)** | 文件存在(990+ 行)但未被 `resolvers/index.ts` 导入,且引用 27+ 个 types.ts 中不存在的类型 |
|
||||||
|
| parent-bff Docker 构建 | ❌ 缺 lockfile | ✅ **依赖统一已修复** | pnpm@11.13.0 + `--ignore-scripts` 解决 `ERR_PNPM_IGNORED_BUILDS`(build 仍失败为预存 TS 错误) |
|
||||||
|
| api-gateway Docker 构建 | ❌ 缺基础镜像 | ✅ **依赖统一已修复** | 升级 `golang:1.25-alpine` + 修复 go.mod shared-go 依赖 + Dockerfile 用 repo 根作 context,构建成功 + /healthz 200 |
|
||||||
|
| iam.GetChildrenByParent | ✅ 已就绪 | ✅ 仍就绪 | gRPC + HTTP 均已实现 |
|
||||||
|
| push-gateway /ws | ✅ 已就绪 | ✅ 仍就绪 | 完整 WebSocket 实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 仍未完成的 P0 阻塞项
|
||||||
|
|
||||||
|
### 3.1 parent-bff 端点路径错配(仍未修复)
|
||||||
|
|
||||||
|
**核查证据**:
|
||||||
|
|
||||||
|
- `services/parent-bff/src/entry/graphql.controller.ts:13` → `@Controller("graphql")`
|
||||||
|
- `services/parent-bff/src/graphql/yoga.ts:77` → `graphqlEndpoint: "/graphql"`
|
||||||
|
- `services/parent-bff/src/graphql/graphql.module.ts:66` → `forRoutes("graphql")`
|
||||||
|
|
||||||
|
**修复位置**:
|
||||||
|
|
||||||
|
| 文件 | 当前 | 应改为 |
|
||||||
|
| -------------------------------------------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts:13` | `@Controller("graphql")` | `@Controller("v1/parent/v1/graphql")` |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts:77` | `graphqlEndpoint: "/graphql"` | `graphqlEndpoint: "/v1/parent/v1/graphql"` |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts:66` | `forRoutes("graphql")` | `forRoutes("v1/parent/v1/graphql")` |
|
||||||
|
|
||||||
|
### 3.2 parent-bff Docker 构建失败(✅ 依赖统一已修复)
|
||||||
|
|
||||||
|
> **2026-07-14 更新**:统一依赖管理迁移已解决此问题。Dockerfile 升级为 `corepack pnpm@11.13.0` + `--ignore-scripts`,`pnpm install --frozen-lockfile` 成功。`pnpm build` 仍失败为预存 TS 错误(extended-resolvers.ts 49 个类型错误),与依赖管理无关。
|
||||||
|
|
||||||
|
**历史记录**(已过时):
|
||||||
|
|
||||||
|
```
|
||||||
|
#10 19.19 [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @nestjs/core@10.4.22, protobufjs@7.6.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 parent-bff GraphQL Schema 未扩充 + extended-resolvers 未集成(新发现)
|
||||||
|
|
||||||
|
**核查证据**:
|
||||||
|
|
||||||
|
1. **schema 文件未扩充**:`packages/shared-ts/contracts/graphql/parent-bff.graphql` 仍只有 10Q+3M
|
||||||
|
2. **extended-resolvers.ts 未集成**:`services/parent-bff/src/graphql/resolvers/index.ts` 未导入 `extended-resolvers.ts`
|
||||||
|
3. **extended-resolvers.ts 引用不存在的类型**:引用 27+ 个类型(如 `AcademicYearType`、`AttendanceRecordType`、`ChildBriefType` 等),但 `services/parent-bff/src/graphql/types.ts` 中只有 24 个原始类型定义,这些新类型不存在
|
||||||
|
4. **graphql.module.ts 未引用 extended-resolvers**:`buildResolvers(deps)` 调用的仍是原始 resolver
|
||||||
|
|
||||||
|
**修复步骤**:
|
||||||
|
|
||||||
|
1. 扩充 `services/parent-bff/src/graphql/types.ts`:新增 extended-resolvers.ts 引用的 27+ 个类型定义
|
||||||
|
2. 扩充 schema:更新 `packages/shared-ts/contracts/graphql/parent-bff.graphql` 至 32Q+6M
|
||||||
|
3. 集成 extended-resolvers:在 `services/parent-bff/src/graphql/resolvers/index.ts` 中导入并合并
|
||||||
|
4. 验证编译:`pnpm build` 通过
|
||||||
|
|
||||||
|
**缺失的 Query(22 个)**:
|
||||||
|
|
||||||
|
| 前端 operation | 涉及下游服务 |
|
||||||
|
| --------------------------- | -------------------------------------------- |
|
||||||
|
| `currentUser` | 命名不一致(后端 `me`) |
|
||||||
|
| `myChildren` | 命名不一致(后端 `children`) |
|
||||||
|
| `childSummary` | 完全缺失 |
|
||||||
|
| `childAttendance` | core-edu |
|
||||||
|
| `childWeakness` | data-ana |
|
||||||
|
| `childTrend` | data-ana |
|
||||||
|
| `childExamResult` | core-edu |
|
||||||
|
| `childClasses` | classes |
|
||||||
|
| `childLearningPath` | data-ana |
|
||||||
|
| `myNotifications` | 命名不一致(后端 `notifications`) |
|
||||||
|
| `myNotificationPreferences` | 命名不一致(后端 `notificationPreferences`) |
|
||||||
|
| `childLeaveRequests` | core-edu |
|
||||||
|
| `academicYears` | core-edu |
|
||||||
|
| `childReportCard` | core-edu |
|
||||||
|
| `childErrorBookStats` | data-ana |
|
||||||
|
| `childTopWrongQuestions` | data-ana |
|
||||||
|
| `childWeakKps` | data-ana |
|
||||||
|
| `childMasterySummary` | data-ana |
|
||||||
|
| `childDiagnosticReports` | data-ana |
|
||||||
|
| `childPracticeStats` | data-ana |
|
||||||
|
| `childPracticeSessions` | data-ana |
|
||||||
|
| `childCoursePlans` | core-edu |
|
||||||
|
| `childCoursePlanDetail` | content |
|
||||||
|
| `childLessonPlans` | core-edu |
|
||||||
|
| `childLessonPlanDetail` | content |
|
||||||
|
| `childElective` | content |
|
||||||
|
| `childDetail` | data-ana |
|
||||||
|
| `childGrowthArchive` | data-ana |
|
||||||
|
|
||||||
|
**缺失的 Mutation(3 个)**:
|
||||||
|
|
||||||
|
| 前端 operation | 涉及下游服务 |
|
||||||
|
| -------------------- | ----------------------------------------- |
|
||||||
|
| `markAsRead` | 命名不一致(后端 `markNotificationRead`) |
|
||||||
|
| `markAllAsRead` | parent-bff(新增) |
|
||||||
|
| `switchChild` | 命名不一致(后端 `selectChild`) |
|
||||||
|
| `createLeaveRequest` | core-edu(新增) |
|
||||||
|
| `exportChildGrades` | core-edu(新增) |
|
||||||
|
|
||||||
|
### 3.4 api-gateway Docker 构建失败(仍失败)
|
||||||
|
|
||||||
|
**核查证据**(Docker 构建日志):
|
||||||
|
|
||||||
|
```
|
||||||
|
#3 ERROR: failed to authorize: failed to fetch oauth token:
|
||||||
|
Post "https://auth.docker.io/token": dial tcp 157.240.20.18:443: connectex: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
本地镜像:
|
||||||
|
|
||||||
|
```
|
||||||
|
golang 1.25-alpine 56961d79ea81 6 days ago 329MB
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复方案**(三选一):
|
||||||
|
|
||||||
|
- 方案 A(推荐):修改 Dockerfile 使用本地已有镜像 `FROM golang:1.25-alpine AS builder`
|
||||||
|
- 方案 B:通过 daocloud 拉取 `FROM docker.m.daocloud.io/library/golang:1.22-alpine AS builder`
|
||||||
|
- 方案 C:提前手动拉取 `docker pull golang:1.22-alpine`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. v1 → v2 变更记录
|
||||||
|
|
||||||
|
| 项目 | v1 状态 | v2 核查结果 |
|
||||||
|
| ------------------------- | -------------- | ------------------------------------------------------ |
|
||||||
|
| parent-bff pnpm-lock.yaml | ❌ 缺失 | ✅ **已完成** |
|
||||||
|
| parent-bff 端点路径 | ❌ 错配 | ❌ 仍未修复 |
|
||||||
|
| parent-bff GraphQL schema | ❌ 未扩充 | ❌ 仍未扩充,且发现 extended-resolvers.ts 存在但未集成 |
|
||||||
|
| parent-bff Docker 构建 | ❌ 缺 lockfile | ❌ 新失败原因:pnpm 11.x `ERR_PNPM_IGNORED_BUILDS` |
|
||||||
|
| api-gateway Docker 构建 | ❌ 缺基础镜像 | ❌ 仍失败:`golang:1.22-alpine` 无法拉取 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验证 Checklist(下游修复后执行)
|
||||||
|
|
||||||
|
- [ ] parent-bff Dockerfile 固定 pnpm 版本为 9.12.0
|
||||||
|
- [ ] parent-bff GraphQL 端点路径改为 `/v1/parent/v1/graphql`(3 处)
|
||||||
|
- [ ] parent-bff types.ts 新增 27+ 个类型定义
|
||||||
|
- [ ] parent-bff schema 扩充至 32Q+6M
|
||||||
|
- [ ] parent-bff resolvers/index.ts 集成 extended-resolvers.ts
|
||||||
|
- [ ] parent-bff `pnpm build` 编译通过
|
||||||
|
- [ ] parent-bff Docker 镜像构建成功
|
||||||
|
- [ ] api-gateway Dockerfile 改用 `golang:1.25-alpine` 或提前拉取 `golang:1.22-alpine`
|
||||||
|
- [ ] api-gateway Docker 镜像构建成功
|
||||||
|
- [ ] 在 Docker Desktop 启动完整服务栈
|
||||||
|
- [ ] parent-portal 端到端联调:登录 → /parent/dashboard → 各功能页面
|
||||||
|
- [ ] 验证 25 个路由全部可访问(无 500 错误)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键文件路径
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ----------------------------------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `apps/parent-portal/Dockerfile` | parent-portal Docker 构建(已就绪,standalone 模式) |
|
||||||
|
| `apps/parent-portal/next.config.js` | Next.js 配置(已就绪,output: standalone) |
|
||||||
|
| `apps/parent-portal/src/lib/graphql/operations.ts` | 前端所有 GraphQL operations(32Q+6M) |
|
||||||
|
| `services/parent-bff/Dockerfile` | parent-bff Docker 构建(待修复:pnpm 版本) |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts:13` | GraphQL 端点路径(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts:77` | GraphQL Yoga 配置(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts:66` | GraphQL 模块路由(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/index.ts` | resolver 入口(待集成 extended-resolvers) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/extended-resolvers.ts` | 扩展 resolver(已写但未集成) |
|
||||||
|
| `services/parent-bff/src/graphql/types.ts` | 类型定义(待扩充 27+ 个类型) |
|
||||||
|
| `packages/shared-ts/contracts/graphql/parent-bff.graphql` | GraphQL schema 契约(待扩充至 32Q+6M) |
|
||||||
|
| `services/api-gateway/Dockerfile:5` | api-gateway Docker 构建(待修复:基础镜像) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**:parent-portal 前端已完全就绪(Docker 镜像构建成功、容器 healthy、25 路由可用、mock 已禁用、typecheck + lint 零错误)。v2 核查发现下游 parent-bff 仍有 4 项 P0 阻塞未解决(端点路径、Docker 构建、schema 扩充、extended-resolvers 集成),api-gateway 仍有 1 项 P0 阻塞(基础镜像)。需下游修复后才能完成端到端真实 API 联调。
|
||||||
210
apps/parent-portal/docs/nextstep.md
Normal file
210
apps/parent-portal/docs/nextstep.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# parent-portal 下游待办事项(Next Steps)
|
||||||
|
|
||||||
|
> 版本:v1
|
||||||
|
> 日期:2026-07-13
|
||||||
|
> 用途:记录 parent-portal 切换至真实 API 集成所需的下游模块工作
|
||||||
|
> 背景:parent-portal 已完成 Docker Desktop 验证(容器健康、25 路由生成、/login 200、/api/health 200),现需下游服务就绪才能完成端到端真实 API 联调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 当前状态摘要
|
||||||
|
|
||||||
|
| 项目 | 状态 | 说明 |
|
||||||
|
| -------------------------- | ----------------- | ---------------------------------------------------------------------- |
|
||||||
|
| parent-portal Docker 镜像 | ✅ 已就绪 | `edu-parent-portal:test`,standalone 模式,25 路由 |
|
||||||
|
| parent-portal 容器健康检查 | ✅ 通过 | `/api/health` 200,`/login` 200,`/parent/dashboard` 307 auth redirect |
|
||||||
|
| Mock 数据 | ✅ 已禁用 | `NEXT_PUBLIC_API_MOCKING=disabled` |
|
||||||
|
| GraphQL 端点配置 | ✅ 已配置 | `NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/v1/graphql` |
|
||||||
|
| parent-bff 服务 | ❌ 未就绪 | 端点路径错配 + 27 query / 5 mutation 缺失 |
|
||||||
|
| api-gateway 路由 | ⚠️ 已配置但未验证 | `/api/v1/parent/*` → parent-bff:3010,但 parent-bff 未运行 |
|
||||||
|
| iam GetChildrenByParent | ✅ 已就绪 | gRPC + HTTP 均已实现 |
|
||||||
|
| push-gateway /ws | ✅ 已就绪 | 完整 WebSocket 实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P0 阻塞项:parent-bff 端点路径错配
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
前端调用 GraphQL 端点:`/api/v1/parent/v1/graphql`(ARB-022 §24.4 ISSUE-003 方案 A 双 /v1 前缀)
|
||||||
|
|
||||||
|
api-gateway 代理路径转换:
|
||||||
|
|
||||||
|
- 入站:`/api/v1/parent/v1/graphql`
|
||||||
|
- 剥离 `/api` 后转发:`/v1/parent/v1/graphql` → parent-bff:3010
|
||||||
|
|
||||||
|
但 parent-bff 当前 controller 仅注册 `/graphql`,导致 `/v1/parent/v1/graphql` 在 parent-bff 找不到匹配的 handler,返回 404。
|
||||||
|
|
||||||
|
### 修复位置(parent-bff 模块)
|
||||||
|
|
||||||
|
| 文件 | 当前 | 应改为 |
|
||||||
|
| ----------------------------------------------------- | ----------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts` | `@Controller("graphql")` | `@Controller("v1/parent/v1/graphql")` 或使用 `setGlobalPrefix` |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts` | `graphqlEndpoint: "/graphql"` | `graphqlEndpoint: "/v1/parent/v1/graphql"` |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts` | `forRoutes("graphql")` | `forRoutes("v1/parent/v1/graphql")` |
|
||||||
|
|
||||||
|
### 验证步骤
|
||||||
|
|
||||||
|
1. 修改后构建 parent-bff 镜像
|
||||||
|
2. 启动 parent-bff 容器,验证 `http://parent-bff:3010/v1/parent/v1/graphql` 返回 200
|
||||||
|
3. 经 api-gateway 代理访问 `http://api-gateway:8080/api/v1/parent/v1/graphql` 返回 200
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P0 阻塞项:parent-bff 缺失 pnpm-lock.yaml
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
`services/parent-bff/Dockerfile` 第 6 行 `RUN pnpm install --frozen-lockfile` 失败,因为 `services/parent-bff/` 目录下没有 `pnpm-lock.yaml` 文件。
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
在 `services/parent-bff/` 目录执行 `pnpm install` 生成 lockfile,并提交到仓库。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/parent-bff
|
||||||
|
pnpm install
|
||||||
|
git add pnpm-lock.yaml
|
||||||
|
git commit -m "chore(parent-bff): add pnpm-lock.yaml for Docker build"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P0 阻塞项:parent-bff GraphQL Schema 严重不对齐
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
| 类别 | 前端定义 | 后端实现 | 差距 |
|
||||||
|
| -------- | -------- | -------- | --------------------------------------------------------------- |
|
||||||
|
| Query | 32 个 | 11 个 | 21 个缺失(含 6 个命名不一致 + 15 个完全缺失 + 3 个签名不一致) |
|
||||||
|
| Mutation | 6 个 | 3 个 | 3 个缺失(含 2 个命名不一致 + 1 个完全缺失 + 1 个签名不一致) |
|
||||||
|
|
||||||
|
### 4.1 命名不一致的 Query(6 个)
|
||||||
|
|
||||||
|
| 前端 operation | 后端 operation | 修复方式 |
|
||||||
|
| --------------------------- | ------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `currentUser` | `me` | 二选一统一命名(建议后端改 `me` → `currentUser`) |
|
||||||
|
| `myChildren` | `children` | 二选一统一命名(建议后端改 `children` → `myChildren`) |
|
||||||
|
| `myNotifications` | `notifications` | 二选一统一命名(建议后端改 `notifications` → `myNotifications`) |
|
||||||
|
| `myNotificationPreferences` | `notificationPreferences` | 二选一统一命名 |
|
||||||
|
| `childSummary` | `child` | 字段不同,需明确语义 |
|
||||||
|
| `childAnalytics` | (前端未使用) | 后端已有但前端未对接,保留 |
|
||||||
|
|
||||||
|
### 4.2 完全缺失的 Query(15 个,后端需新增)
|
||||||
|
|
||||||
|
| 前端 operation | 涉及下游服务 |
|
||||||
|
| ------------------------ | ------------ |
|
||||||
|
| `childAttendance` | core-edu |
|
||||||
|
| `childWeakness` | data-ana |
|
||||||
|
| `childTrend` | data-ana |
|
||||||
|
| `childExamResult` | core-edu |
|
||||||
|
| `childClasses` | classes |
|
||||||
|
| `childLearningPath` | data-ana |
|
||||||
|
| `childLeaveRequests` | core-edu |
|
||||||
|
| `academicYears` | core-edu |
|
||||||
|
| `childReportCard` | core-edu |
|
||||||
|
| `childErrorBookStats` | data-ana |
|
||||||
|
| `childTopWrongQuestions` | data-ana |
|
||||||
|
| `childWeakKps` | data-ana |
|
||||||
|
| `childMasterySummary` | data-ana |
|
||||||
|
| `childDiagnosticReports` | data-ana |
|
||||||
|
| `childPracticeStats` | data-ana |
|
||||||
|
| `childPracticeSessions` | data-ana |
|
||||||
|
| `childCoursePlans` | core-edu |
|
||||||
|
| `childCoursePlanDetail` | content |
|
||||||
|
| `childLessonPlans` | core-edu |
|
||||||
|
| `childLessonPlanDetail` | content |
|
||||||
|
| `childElective` | content |
|
||||||
|
| `childDetail` | data-ana |
|
||||||
|
| `childGrowthArchive` | data-ana |
|
||||||
|
|
||||||
|
### 4.3 签名不一致的 Query(3 个)
|
||||||
|
|
||||||
|
| 前端 operation | 不一致点 |
|
||||||
|
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `childGrades` | 前端 `(childId, subject, limit)` vs 后端 `(childId, page, pageSize, subject)`;返回字段不同(前端 `examName/examDate/studentScore/classMax/classMin/gradeLevel` vs 后端 `examId/examTitle/score/rank/gradedAt`) |
|
||||||
|
| `childHomework` | 前端有 `status` 过滤参数,后端无;字段差异大(前端 `subject/className/assignedDate/score/maxScore/feedback` vs 后端 `title/classId/dueDate/status`) |
|
||||||
|
| `childExams` | 字段差异大(前端 `name/subject/startsAt/expiresAt/durationSeconds/questionCount/totalScore` vs 后端 `title/examDate/publishedAt`) |
|
||||||
|
|
||||||
|
### 4.4 缺失的 Mutation(3 个)
|
||||||
|
|
||||||
|
| 前端 operation | 涉及下游服务 |
|
||||||
|
| -------------------- | ---------------------------------------------------------- |
|
||||||
|
| `markAsRead` | parent-bff(重命名 `markNotificationRead` → `markAsRead`) |
|
||||||
|
| `markAllAsRead` | parent-bff(新增) |
|
||||||
|
| `switchChild` | parent-bff(重命名 `selectChild` → `switchChild`) |
|
||||||
|
| `createLeaveRequest` | core-edu(新增) |
|
||||||
|
| `exportChildGrades` | core-edu(新增) |
|
||||||
|
|
||||||
|
### 4.5 签名不一致的 Mutation(1 个)
|
||||||
|
|
||||||
|
| 前端 operation | 不一致点 |
|
||||||
|
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `updateNotificationPreferences` | 前端 `(parentId, preferences: JSON, defaults: JSON)` vs 后端 `(input: UpdateNotificationPreferencesInput)` |
|
||||||
|
|
||||||
|
### 修复步骤
|
||||||
|
|
||||||
|
1. **契约先行**:更新 `packages/shared-ts/contracts/graphql/parent-bff.graphql`,扩充至前端 32 query + 6 mutation
|
||||||
|
2. **resolver 实现**:在 `services/parent-bff/src/graphql/resolvers/` 新增对应 resolver
|
||||||
|
3. **下游 gRPC client**:在 `services/parent-bff/src/clients/` 新增/对接 core-edu / data-ana / content / classes 的 gRPC client
|
||||||
|
4. **测试**:parent-bff 单测 + parent-portal 端到端联调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. P1 阻塞项:api-gateway 镜像构建问题
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
`services/api-gateway/Dockerfile` 使用 `golang:1.22-alpine` 和 `alpine:3.20` 基础镜像,但本地 Docker Desktop 未拉取这两个镜像,且 daocloud 镜像源返回 401。
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
1. 提前在服务器执行:`docker pull golang:1.22-alpine && docker pull alpine:3.20`
|
||||||
|
2. 或在 Dockerfile 中改用本地已有的镜像源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 已就绪的下游服务(无需等待)
|
||||||
|
|
||||||
|
| 服务 | 状态 | 验证位置 |
|
||||||
|
| ----------------------- | ----------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| iam.GetChildrenByParent | ✅ gRPC + HTTP 均已实现 | `services/iam/src/iam/iam.grpc.controller.ts:116`、`services/iam/src/iam/iam.controller.ts:116` |
|
||||||
|
| push-gateway /ws | ✅ 完整实现 | `services/push-gateway/internal/ws/handler.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验证 Checklist(下游就绪后执行)
|
||||||
|
|
||||||
|
- [ ] parent-bff 添加 pnpm-lock.yaml
|
||||||
|
- [ ] parent-bff GraphQL 端点路径改为 `/v1/parent/v1/graphql`
|
||||||
|
- [ ] parent-bff schema 扩充至 32 query + 6 mutation
|
||||||
|
- [ ] parent-bff 所有 resolver 实现完成
|
||||||
|
- [ ] api-gateway 镜像可构建
|
||||||
|
- [ ] 在 Docker Desktop 启动完整服务栈:api-gateway + parent-bff + iam + core-edu + data-ana + content + classes + push-gateway
|
||||||
|
- [ ] parent-portal 端到端联调:登录 → /parent/dashboard → 各功能页面
|
||||||
|
- [ ] 验证 WebSocket 推送:通知功能
|
||||||
|
- [ ] 验证 25 个路由全部可访问(无 500 错误)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关键文件路径
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| --------------------------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `apps/parent-portal/Dockerfile` | parent-portal Docker 构建(已修复,standalone 模式) |
|
||||||
|
| `apps/parent-portal/next.config.js` | Next.js 配置(已添加 `output: "standalone"`) |
|
||||||
|
| `apps/parent-portal/src/lib/graphql-client.ts` | GraphQL 客户端配置 |
|
||||||
|
| `apps/parent-portal/src/lib/graphql/operations.ts` | 前端所有 GraphQL operations 定义 |
|
||||||
|
| `apps/parent-portal/.env.example` | 环境变量模板(`NEXT_PUBLIC_API_MOCKING=disabled`) |
|
||||||
|
| `services/parent-bff/Dockerfile` | parent-bff Docker 构建(待修复:缺 pnpm-lock.yaml) |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts` | GraphQL 端点路径(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts` | GraphQL Yoga 配置(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts` | GraphQL 模块路由(待修复) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/index.ts` | resolver 实现(待扩充) |
|
||||||
|
| `packages/shared-ts/contracts/graphql/parent-bff.graphql` | GraphQL schema 契约(待扩充) |
|
||||||
|
| `services/api-gateway/main.go:86` | parent 代理注册 |
|
||||||
|
| `services/api-gateway/Dockerfile` | api-gateway Docker 构建(待修复:基础镜像缺失) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**:parent-portal 前端已完全就绪(Docker 镜像构建成功、容器健康、25 路由可用、mock 已禁用),等待下游 parent-bff 完成端点路径修复 + schema 扩充 + resolver 实现后,即可完成端到端真实 API 联调。
|
||||||
2
apps/parent-portal/next-env.d.ts
vendored
2
apps/parent-portal/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
// - F10 MF 暴露粒度(AppShell 整体)
|
// - F10 MF 暴露粒度(AppShell 整体)
|
||||||
// - F9 GraphQL(urql)
|
// - F9 GraphQL(urql)
|
||||||
|
|
||||||
|
// Bundle 分析(P6-3 性能优化,ANALYZE=true 时启用)
|
||||||
|
const withBundleAnalyzer =
|
||||||
|
process.env.ANALYZE === "true"
|
||||||
|
? require("@next/bundle-analyzer")({ enabled: true })
|
||||||
|
: (config) => config;
|
||||||
|
|
||||||
const NextFederationPlugin =
|
const NextFederationPlugin =
|
||||||
process.env.NEXT_PUBLIC_MF_ENABLED === "enabled"
|
process.env.NEXT_PUBLIC_MF_ENABLED === "enabled"
|
||||||
? require("@module-federation/nextjs-mf")
|
? require("@module-federation/nextjs-mf")
|
||||||
@@ -20,6 +26,8 @@ const remotes = (isServer) => ({
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// standalone 输出模式:自包含 server.js + 精简 node_modules(适合 Docker 部署)
|
||||||
|
output: "standalone",
|
||||||
// ESM 兼容(MF 2.0 需要)
|
// ESM 兼容(MF 2.0 需要)
|
||||||
experimental: {
|
experimental: {
|
||||||
externalDir: true,
|
externalDir: true,
|
||||||
@@ -84,4 +92,4 @@ if (NextFederationPlugin) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = withBundleAnalyzer(nextConfig);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@module-federation/nextjs-mf": "^8.3.0",
|
"@module-federation/nextjs-mf": "^8.3.0",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.0",
|
"@axe-core/playwright": "^4.10.0",
|
||||||
|
"@next/bundle-analyzer": "^14.2.0",
|
||||||
"@playwright/test": "^1.47.0",
|
"@playwright/test": "^1.47.0",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
@@ -51,5 +53,15 @@
|
|||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@opentelemetry/context-zone": "^1.27.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
|
||||||
|
"@opentelemetry/instrumentation": "^0.54.0",
|
||||||
|
"@opentelemetry/instrumentation-document-load": "^0.12.0",
|
||||||
|
"@opentelemetry/instrumentation-fetch": "^0.54.0",
|
||||||
|
"@opentelemetry/instrumentation-xml-http-request": "^0.54.0",
|
||||||
|
"@opentelemetry/sdk-trace-web": "^1.27.0",
|
||||||
|
"web-vitals": "^4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
115
apps/parent-portal/public/sw.js
Normal file
115
apps/parent-portal/public/sw.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Service Worker — parent-portal PWA 离线缓存
|
||||||
|
// 依据:01-understanding.md §18.4 Service Worker 策略
|
||||||
|
// 缓存策略:
|
||||||
|
// 静态资源 → Cache First + 网络更新(TTL 24h)
|
||||||
|
// 子女列表 → Stale While Revalidate(TTL 5min)
|
||||||
|
// 子女成绩 → Network First,失败回退缓存(TTL 30s)
|
||||||
|
// 通知/偏好 → Network Only
|
||||||
|
// API 401 → 不缓存
|
||||||
|
|
||||||
|
const CACHE_VERSION = "parent-portal-v1";
|
||||||
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
|
const DATA_CACHE = `${CACHE_VERSION}-data`;
|
||||||
|
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
"/",
|
||||||
|
"/parent/dashboard",
|
||||||
|
"/login",
|
||||||
|
"/manifest.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 安装:预缓存静态资源
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 激活:清理旧缓存
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => !key.startsWith(CACHE_VERSION))
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截:按资源类型选择缓存策略
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// 仅处理同源请求
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// 非 GET 请求直接透传
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
// API 请求:Network First(数据类)或 Network Only(通知/偏好)
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
// 通知和偏好设置不缓存
|
||||||
|
if (url.pathname.includes("/notifications") || url.pathname.includes("/preferences")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// GraphQL 和其他 API:Network First + 回退缓存
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok && response.status !== 401) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(DATA_CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态资源:Cache First + 网络更新
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
const fetchPromise = fetch(request).then((response) => {
|
||||||
|
if (response && response.ok && response.status === 200) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => cached);
|
||||||
|
return cached || fetchPromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 推送通知(P5+ 预留)
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
const data = event.data.json();
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || "Edu 家长端", {
|
||||||
|
body: data.body || "",
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
data: { url: data.url || "/parent/dashboard" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击通知跳转
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = event.notification.data?.url || "/parent/dashboard";
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window" }).then((clients) => {
|
||||||
|
const existing = clients.find((c) => c.url.includes(targetUrl));
|
||||||
|
if (existing) {
|
||||||
|
return existing.focus();
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
*
|
*
|
||||||
* Layer 1 Primitive:原始色板/字号/间距/阴影(业务代码不直接引用)
|
* Layer 1 Primitive:原始色板/字号/间距/阴影(业务代码不直接引用)
|
||||||
* Layer 2 Semantic:语义令牌(light/dark),业务代码唯一引用入口
|
* Layer 2 Semantic:语义令牌(light/dark),业务代码唯一引用入口
|
||||||
* Layer 3 Tailwind Theme:@theme inline 暴露为 bg-*/text-*/font-* 类
|
* Layer 3 Tailwind Theme:@theme inline 暴露为 bg, text, font 等类
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -59,10 +59,10 @@
|
|||||||
--radius-md: 0.5rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.75rem;
|
--radius-lg: 0.75rem;
|
||||||
|
|
||||||
/* 阴影 */
|
/* 阴影(使用 rgba 旧语法,避免 cssnano-simple 无法解析 rgb(/ 语法)*/
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
/* ===== Layer 2 Semantic(light)===== */
|
/* ===== Layer 2 Semantic(light)===== */
|
||||||
--paper: var(--color-paper-50);
|
--paper: var(--color-paper-50);
|
||||||
@@ -155,6 +155,24 @@ h6 {
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 屏幕阅读器专用文本(a11y,WCAG 2.2 AA)*/
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 安全的 col-span-2(替代 Tailwind col-span-2,避免 cssnano-simple 无法解析 grid-column: span 2 / span 2 语法)*/
|
||||||
|
.col-span-2-safe {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
/* 骨架屏动画 */
|
/* 骨架屏动画 */
|
||||||
@keyframes skeleton-pulse {
|
@keyframes skeleton-pulse {
|
||||||
0%,
|
0%,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe("LoginPage 集成测试", () => {
|
|||||||
it("登录中显示加载态", async () => {
|
it("登录中显示加载态", async () => {
|
||||||
// 使用延迟响应以观察加载态
|
// 使用延迟响应以观察加载态
|
||||||
server.use(
|
server.use(
|
||||||
http.post("/api/v1/iam/login", async () => {
|
http.post("/api/v1/iam/v1/login", async () => {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
return HttpResponse.json({ success: true, data: mockLoginResponse });
|
return HttpResponse.json({ success: true, data: mockLoginResponse });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
// 考勤页面
|
// 考勤页面
|
||||||
// 依据:02-architecture-design.md §3.1 路由结构
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
// 路由:/parent/attendance
|
// 路由:/parent/attendance
|
||||||
|
// - 月份导航(前一月/后一月切换)
|
||||||
|
// - 聚合出勤率卡片 + 异常预警横幅
|
||||||
|
// - 月度考勤日历
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import { useChildAttendance } from "@/hooks/useChildAttendance";
|
import { useChildAttendance } from "@/hooks/useChildAttendance";
|
||||||
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
|
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
|
||||||
|
import { AttendanceRateCard } from "@/components/AttendanceRateCard";
|
||||||
|
import { AttendanceWarningBanner } from "@/components/AttendanceWarningBanner";
|
||||||
|
|
||||||
|
function getCurrentMonth(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AttendancePage() {
|
export default function AttendancePage() {
|
||||||
const { currentChild } = useChildSwitcher();
|
const { currentChild } = useChildSwitcher();
|
||||||
const { attendance, loading, error } = useChildAttendance();
|
const [currentMonth, setCurrentMonth] = useState<string>(getCurrentMonth);
|
||||||
|
const { attendance, loading, error } = useChildAttendance({
|
||||||
|
month: currentMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthLabel = useMemo(() => {
|
||||||
|
const [year, month] = currentMonth.split("-");
|
||||||
|
return `${year ?? ""}年${parseInt(month ?? "1", 10)}月`;
|
||||||
|
}, [currentMonth]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <span className="skeleton h-64 w-full rounded" />;
|
return <span className="skeleton h-64 w-full rounded" />;
|
||||||
@@ -27,7 +46,20 @@ export default function AttendancePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="font-serif text-2xl">{currentChild?.name}的考勤</h1>
|
<h1 className="font-serif text-2xl">{currentChild?.name}的考勤</h1>
|
||||||
<AttendanceCalendar records={attendance} />
|
|
||||||
|
<AttendanceRateCard records={attendance} />
|
||||||
|
|
||||||
|
<AttendanceWarningBanner records={attendance} />
|
||||||
|
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={attendance}
|
||||||
|
month={currentMonth}
|
||||||
|
onMonthChange={setCurrentMonth}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
当前查看:{monthLabel}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
apps/parent-portal/src/app/parent/children/[studentId]/page.tsx
Normal file
131
apps/parent-portal/src/app/parent/children/[studentId]/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// 子女详情聚合页
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/children/[studentId]
|
||||||
|
// 头部:面包屑 + 学生信息;主体:Tab 面板(nuqs 管理 ?tab=)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useQueryState, parseAsString } from "nuqs";
|
||||||
|
import { useChildDetail } from "@/hooks/useChildDetail";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import {
|
||||||
|
ChildDetailPanel,
|
||||||
|
type DetailTab,
|
||||||
|
} from "@/components/ChildDetailPanel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const VALID_TABS: DetailTab[] = [
|
||||||
|
"overview",
|
||||||
|
"homework",
|
||||||
|
"grades",
|
||||||
|
"exams",
|
||||||
|
"schedule",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isDetailTab(value: string): value is DetailTab {
|
||||||
|
return (VALID_TABS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChildDetailPage(): JSX.Element {
|
||||||
|
const params = useParams();
|
||||||
|
const studentId =
|
||||||
|
typeof params?.studentId === "string"
|
||||||
|
? params.studentId
|
||||||
|
: Array.isArray(params?.studentId)
|
||||||
|
? (params.studentId[0] ?? "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const { children } = useChildSwitcher();
|
||||||
|
const { childDetail, loading, error } = useChildDetail(studentId);
|
||||||
|
|
||||||
|
// nuqs 管理 ?tab= 参数
|
||||||
|
const [tabStr, setTabStr] = useQueryState(
|
||||||
|
"tab",
|
||||||
|
parseAsString.withDefault("overview"),
|
||||||
|
);
|
||||||
|
const activeTab: DetailTab = isDetailTab(tabStr) ? tabStr : "overview";
|
||||||
|
|
||||||
|
// 兄弟姐妹(除当前子女外)
|
||||||
|
const siblings = children.filter((c) => c.id !== studentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<nav aria-label="面包屑" className="text-xs text-ink-muted">
|
||||||
|
<ol className="flex flex-wrap gap-1">
|
||||||
|
<li>
|
||||||
|
<Link href="/parent/dashboard" className="hover:text-ink">
|
||||||
|
首页
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true">/</li>
|
||||||
|
<li aria-current="page">子女详情</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 头部:学生信息 */}
|
||||||
|
<header className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
{loading ? (
|
||||||
|
<span className="skeleton h-16 w-full rounded" />
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : !childDetail ? (
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无子女详情数据
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full bg-accent-soft font-serif text-lg font-medium text-accent"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{childDetail.basicInfo.name.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="font-serif text-2xl">
|
||||||
|
{childDetail.basicInfo.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
{childDetail.basicInfo.className} · {childDetail.basicInfo.schoolName} · 关系:{childDetail.basicInfo.relation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 兄弟姐妹切换 */}
|
||||||
|
{siblings.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-ink-muted">其他子女:</span>
|
||||||
|
{siblings.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/parent/children/${s.id}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded border border-rule px-3 py-1 text-sm transition-colors hover:bg-paper-elevated",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 面板 */}
|
||||||
|
{childDetail && (
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={childDetail}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={(t) => setTabStr(t)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/parent-portal/src/app/parent/classes/page.tsx
Normal file
89
apps/parent-portal/src/app/parent/classes/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// 班级列表页面(家长查看子女班级)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/classes
|
||||||
|
// 对标 student-portal /my-classes(家长只读视角)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildClasses } from "@/hooks/useChildClasses";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import type { ClassInfo } from "@/types";
|
||||||
|
|
||||||
|
export default function ClassesPage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { classes, loading, error } = useChildClasses();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">加载班级列表中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的班级</h1>
|
||||||
|
|
||||||
|
{classes.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">暂未分配到班级</p>
|
||||||
|
) : (
|
||||||
|
<section
|
||||||
|
aria-label="班级列表"
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
{classes.map((cls) => (
|
||||||
|
<ClassCard key={cls.id} cls={cls} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<article className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
{cls.grade} · {cls.year} 学年
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 font-serif text-lg">{cls.name}</h2>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 rounded bg-accent-soft px-2 py-1 text-xs text-ink"
|
||||||
|
aria-label={`共 ${cls.studentCount} 名学生`}
|
||||||
|
>
|
||||||
|
{cls.studentCount} 人
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 border-t border-rule pt-3">
|
||||||
|
<dl className="space-y-1 text-sm">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<dt className="text-xs text-ink-muted">班主任</dt>
|
||||||
|
<dd>{cls.homeroomTeacher}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/parent-portal/src/app/parent/course-plans/[id]/page.tsx
Normal file
76
apps/parent-portal/src/app/parent/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 课程计划详情页面(家长查看子女课程计划详情)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/course-plans/[id]
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useChildCoursePlanDetail } from "@/hooks/useChildCoursePlanDetail";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { CoursePlanDetail } from "@/components/CoursePlanDetail";
|
||||||
|
|
||||||
|
export default function CoursePlanDetailPage(): JSX.Element {
|
||||||
|
const params = useParams();
|
||||||
|
const planId =
|
||||||
|
typeof params?.id === "string"
|
||||||
|
? params.id
|
||||||
|
: Array.isArray(params?.id)
|
||||||
|
? (params.id[0] ?? "")
|
||||||
|
: "";
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { coursePlan, loading, error } = useChildCoursePlanDetail(planId);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载课程计划详情中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coursePlan) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/parent/course-plans"
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← 返回课程计划
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无课程计划数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的课程计划</h1>
|
||||||
|
<Link
|
||||||
|
href="/parent/course-plans"
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← 返回课程计划
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CoursePlanDetail coursePlan={coursePlan} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/parent-portal/src/app/parent/course-plans/page.tsx
Normal file
46
apps/parent-portal/src/app/parent/course-plans/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// 课程计划列表页面(家长查看子女课程计划)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/course-plans
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildCoursePlans } from "@/hooks/useChildCoursePlans";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { CoursePlanList } from "@/components/CoursePlanList";
|
||||||
|
|
||||||
|
export default function CoursePlansPage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { coursePlans, loading, error } = useChildCoursePlans();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">加载课程计划中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的课程计划</h1>
|
||||||
|
<CoursePlanList coursePlans={coursePlans} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,143 @@
|
|||||||
// 仪表盘页面
|
// 仪表盘页面
|
||||||
// 依据:02-architecture-design.md §3.1 路由结构
|
// 依据:02-architecture-design.md §3.1 路由结构、§15 仪表盘设计
|
||||||
// 路由:/parent/dashboard
|
// 路由:/parent/dashboard
|
||||||
|
// - 多子女并列展示 ChildSummaryCard
|
||||||
|
// - 顶部关注横幅(逾期作业 / 出勤率低预警)
|
||||||
|
// - 当前选中子女的考勤日历
|
||||||
|
|
||||||
import { ParentDashboard } from "@/components/ParentDashboard";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { useChildSummary } from "@/hooks/useChildSummary";
|
||||||
|
import { useChildAttendance } from "@/hooks/useChildAttendance";
|
||||||
|
import { ChildSummaryCard } from "@/components/ChildSummaryCard";
|
||||||
|
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
|
||||||
|
import { ParentAttentionBanner } from "@/components/ParentAttentionBanner";
|
||||||
|
import type { ChildInfo, ChildSummary } from "@/types";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return <ParentDashboard />;
|
const { children, currentChild, loading } = useChildSwitcher();
|
||||||
|
const { attendance, loading: attLoading } = useChildAttendance();
|
||||||
|
const [summaries, setSummaries] = useState<Record<string, ChildSummary>>({});
|
||||||
|
|
||||||
|
const handleSummaryLoaded = useCallback(
|
||||||
|
(childId: string, summary: ChildSummary) => {
|
||||||
|
setSummaries((prev) => {
|
||||||
|
if (prev[childId]?.avgScore === summary.avgScore) return prev;
|
||||||
|
return { ...prev, [childId]: summary };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<span className="skeleton h-8 w-full rounded" />
|
||||||
|
<span className="skeleton h-32 w-full rounded" />
|
||||||
|
<span className="skeleton h-48 w-full rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
|
||||||
|
暂无绑定子女
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attentionItems = children.map((c) => ({
|
||||||
|
childId: c.id,
|
||||||
|
childName: c.name,
|
||||||
|
pendingHomeworkCount: summaries[c.id]?.pendingHomeworkCount ?? 0,
|
||||||
|
attendanceRate: summaries[c.id]?.attendanceRate ?? 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ParentAttentionBanner items={attentionItems} />
|
||||||
|
|
||||||
|
<h1 className="font-serif text-2xl">学习概览</h1>
|
||||||
|
|
||||||
|
{/* 多子女并列卡片 */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{children.map((child) => (
|
||||||
|
<ChildDashboardCard
|
||||||
|
key={child.id}
|
||||||
|
child={child}
|
||||||
|
onSummaryLoaded={handleSummaryLoaded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前选中子女的考勤日历 */}
|
||||||
|
{currentChild && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 font-serif text-lg">{currentChild.name}的考勤</h2>
|
||||||
|
{attLoading ? (
|
||||||
|
<span className="skeleton h-64 w-full rounded" />
|
||||||
|
) : (
|
||||||
|
<AttendanceCalendar records={attendance} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子女仪表盘卡片:独立获取每个子女的 summary
|
||||||
|
function ChildDashboardCard({
|
||||||
|
child,
|
||||||
|
onSummaryLoaded,
|
||||||
|
}: {
|
||||||
|
child: ChildInfo;
|
||||||
|
onSummaryLoaded: (childId: string, summary: ChildSummary) => void;
|
||||||
|
}) {
|
||||||
|
const { summary, loading, error } = useChildSummary(child.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (summary) {
|
||||||
|
onSummaryLoaded(child.id, summary);
|
||||||
|
}
|
||||||
|
}, [summary, child.id, onSummaryLoaded]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-serif text-lg">{child.name}</h2>
|
||||||
|
<span className="skeleton h-32 w-full rounded" />
|
||||||
|
<span className="skeleton h-48 w-full rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 font-serif text-lg">{child.name}</h2>
|
||||||
|
<div className="rounded border border-danger p-4 text-sm text-danger">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 font-serif text-lg">{child.name}</h2>
|
||||||
|
<p className="text-sm text-ink-muted">暂无数据</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 font-serif text-lg">{child.name}</h2>
|
||||||
|
<ChildSummaryCard summary={summary} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
apps/parent-portal/src/app/parent/diagnostic/page.tsx
Normal file
60
apps/parent-portal/src/app/parent/diagnostic/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// 诊断报告页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/diagnostic
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildDiagnostic } from "@/hooks/useChildDiagnostic";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { MasterySummaryCard } from "@/components/MasterySummaryCard";
|
||||||
|
import { DiagnosticReportList } from "@/components/DiagnosticReportList";
|
||||||
|
|
||||||
|
export default function DiagnosticPage() {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { masterySummary, reports, loading, error } = useChildDiagnostic();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-48 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载诊断报告中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = masterySummary !== null || reports.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的诊断报告</h1>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
|
||||||
|
暂无诊断数据
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{masterySummary && <MasterySummaryCard summary={masterySummary} />}
|
||||||
|
|
||||||
|
<section className="space-y-3" aria-label="已发布诊断报告">
|
||||||
|
<h2 className="font-serif text-lg">已发布诊断报告</h2>
|
||||||
|
<DiagnosticReportList reports={reports} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/parent-portal/src/app/parent/elective/page.tsx
Normal file
43
apps/parent-portal/src/app/parent/elective/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 选修课页面(家长查看子女选修课)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/elective
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildElective } from "@/hooks/useChildElective";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { ElectiveSelectionList } from "@/components/ElectiveSelectionList";
|
||||||
|
|
||||||
|
export default function ElectivePage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { electiveSelections, loading, error } = useChildElective();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-32 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-32 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载选修课中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的选修课</h1>
|
||||||
|
<ElectiveSelectionList electiveSelections={electiveSelections} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
apps/parent-portal/src/app/parent/error-book/page.tsx
Normal file
127
apps/parent-portal/src/app/parent/error-book/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// 错题本页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/error-book
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildErrorBook } from "@/hooks/useChildErrorBook";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { ErrorBookStatsCard } from "@/components/ErrorBookStatsCard";
|
||||||
|
import { TopWrongQuestionList } from "@/components/TopWrongQuestionList";
|
||||||
|
import { cn, formatPercent } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ErrorBookPage() {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { stats, topWrongQuestions, weakKps, loading, error } =
|
||||||
|
useChildErrorBook();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载错题本中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData =
|
||||||
|
stats !== null ||
|
||||||
|
topWrongQuestions.length > 0 ||
|
||||||
|
weakKps.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的错题本</h1>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
|
||||||
|
暂无错题数据
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{stats && <ErrorBookStatsCard stats={stats} />}
|
||||||
|
|
||||||
|
<section className="space-y-3" aria-label="高频错题">
|
||||||
|
<h2 className="font-serif text-lg">高频错题</h2>
|
||||||
|
<TopWrongQuestionList questions={topWrongQuestions} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3" aria-label="薄弱知识点">
|
||||||
|
<h2 className="font-serif text-lg">薄弱知识点</h2>
|
||||||
|
{weakKps.length === 0 ? (
|
||||||
|
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
|
||||||
|
暂无薄弱知识点
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{weakKps.map((kp) => {
|
||||||
|
const masteryPercent = Math.round(kp.masteryRate * 100);
|
||||||
|
const isWeak = kp.masteryRate < 0.6;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${kp.subject}-${kp.knowledgePointName}`}
|
||||||
|
className="rounded border border-rule bg-paper-elevated p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-base">
|
||||||
|
{kp.knowledgePointName}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
{kp.subject}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono text-sm text-danger">
|
||||||
|
错 {kp.errorCount} 次
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 font-mono text-sm",
|
||||||
|
isWeak ? "text-danger" : "text-success",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPercent(kp.masteryRate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-3 h-2 overflow-hidden rounded bg-rule"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={masteryPercent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`${kp.knowledgePointName}掌握率`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded",
|
||||||
|
isWeak ? "bg-danger" : "bg-success",
|
||||||
|
)}
|
||||||
|
style={{ width: `${masteryPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
apps/parent-portal/src/app/parent/exams/[id]/result/page.tsx
Normal file
245
apps/parent-portal/src/app/parent/exams/[id]/result/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// 考试结果详情页面(家长查看子女考试结果)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/exams/[id]/result
|
||||||
|
// 对标 student-portal /my-exams/[id]/result(家长只读视角)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useChildExamResult } from "@/hooks/useChildExamResult";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
AnswerInput,
|
||||||
|
ExamResultSummary,
|
||||||
|
QuestionResult,
|
||||||
|
QuestionType,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
|
||||||
|
"single-choice": "单选题",
|
||||||
|
"multiple-choice": "多选题",
|
||||||
|
"fill-blank": "填空题",
|
||||||
|
"short-answer": "简答题",
|
||||||
|
essay: "论述题",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAnswer(answer: AnswerInput | null): string {
|
||||||
|
if (!answer) return "未作答";
|
||||||
|
switch (answer.type) {
|
||||||
|
case "single-choice":
|
||||||
|
return `选项 ${answer.optionId.toUpperCase()}`;
|
||||||
|
case "multiple-choice":
|
||||||
|
return answer.optionIds.map((id) => id.toUpperCase()).join("、");
|
||||||
|
case "fill-blank":
|
||||||
|
return answer.values.join("、");
|
||||||
|
case "short-answer":
|
||||||
|
case "essay":
|
||||||
|
return answer.text || "(空)";
|
||||||
|
default:
|
||||||
|
return "未知格式";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSummary(results: QuestionResult[]): ExamResultSummary {
|
||||||
|
const totalQuestions = results.length;
|
||||||
|
const correctCount = results.filter((r) => r.isCorrect).length;
|
||||||
|
const unansweredCount = results.filter((r) => !r.isAnswered).length;
|
||||||
|
const wrongCount = totalQuestions - correctCount - unansweredCount;
|
||||||
|
return { totalQuestions, correctCount, wrongCount, unansweredCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExamResultPage(): JSX.Element {
|
||||||
|
const params = useParams();
|
||||||
|
const examId =
|
||||||
|
typeof params?.id === "string"
|
||||||
|
? params.id
|
||||||
|
: Array.isArray(params?.id)
|
||||||
|
? (params.id[0] ?? "")
|
||||||
|
: "";
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { result, loading, error } = useChildExamResult(examId);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-64 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载考试结果中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无考试结果数据
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = computeSummary(result.questionResults);
|
||||||
|
const scorePercentage = Math.round((result.score / result.maxScore) * 100);
|
||||||
|
const scoreTone =
|
||||||
|
scorePercentage >= 80
|
||||||
|
? "text-success"
|
||||||
|
: scorePercentage >= 60
|
||||||
|
? "text-warning"
|
||||||
|
: "text-danger";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的考试结果</h1>
|
||||||
|
|
||||||
|
{/* 结果概要 */}
|
||||||
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-base">{result.examName}</h2>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
提交时间:{new Date(result.submittedAt).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
用时:{formatDuration(result.durationSeconds)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={cn("font-mono text-3xl", scoreTone)}>
|
||||||
|
{result.score}
|
||||||
|
<span className="text-base text-ink-muted">
|
||||||
|
/{result.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn("mt-1 text-sm font-medium", scoreTone)}>
|
||||||
|
{result.grade} · {scorePercentage}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 答题摘要 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div className="rounded border border-rule p-3 text-center">
|
||||||
|
<div className="font-mono text-xl">{summary.totalQuestions}</div>
|
||||||
|
<div className="text-xs text-ink-muted">总题数</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-rule p-3 text-center">
|
||||||
|
<div className="font-mono text-xl text-success">
|
||||||
|
{summary.correctCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">答对</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-rule p-3 text-center">
|
||||||
|
<div className="font-mono text-xl text-danger">
|
||||||
|
{summary.wrongCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">答错</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-rule p-3 text-center">
|
||||||
|
<div className="font-mono text-xl text-ink-muted">
|
||||||
|
{summary.unansweredCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">未答</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 逐题回顾 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-base">逐题回顾</h2>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{result.questionResults.map((qr, idx) => (
|
||||||
|
<QuestionReview key={qr.questionId} qr={qr} index={idx} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionReview({
|
||||||
|
qr,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
qr: QuestionResult;
|
||||||
|
index: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const statusLabel = qr.isCorrect ? "正确" : qr.isAnswered ? "错误" : "未答";
|
||||||
|
const statusTone = qr.isCorrect
|
||||||
|
? "text-success"
|
||||||
|
: qr.isAnswered
|
||||||
|
? "text-danger"
|
||||||
|
: "text-ink-muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-ink-muted">
|
||||||
|
第 {index + 1} 题
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
{QUESTION_TYPE_LABELS[qr.questionType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
得分:{qr.earnedScore}/{qr.maxScore}
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-xs font-medium", statusTone)}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm">{qr.questionContent}</p>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="min-w-fit text-xs text-ink-muted">你的答案:</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
qr.isCorrect ? "text-success" : "text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatAnswer(qr.studentAnswer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{qr.correctAnswer && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="min-w-fit text-xs text-ink-muted">正确答案:</span>
|
||||||
|
<span className="text-sm text-success">
|
||||||
|
{formatAnswer(qr.correctAnswer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qr.explanation && (
|
||||||
|
<div className="mt-2 rounded border border-rule bg-accent-soft p-2">
|
||||||
|
<p className="text-xs text-ink-muted">解析</p>
|
||||||
|
<p className="mt-1 text-sm">{qr.explanation}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/parent-portal/src/app/parent/exams/page.tsx
Normal file
176
apps/parent-portal/src/app/parent/exams/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// 考试列表页面(家长查看子女考试)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/exams
|
||||||
|
// 对标 student-portal /my-exams(家长只读视角)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useChildExams } from "@/hooks/useChildExams";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { formatDate, cn } from "@/lib/utils";
|
||||||
|
import type { ExamListItem, ExamStatus } from "@/types";
|
||||||
|
|
||||||
|
const STATUS_GROUPS: Array<{
|
||||||
|
key: ExamStatus;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "in_progress", label: "进行中", description: "子女正在作答" },
|
||||||
|
{ key: "not_started", label: "未开始", description: "等待开考" },
|
||||||
|
{ key: "submitted", label: "已提交", description: "等待批改" },
|
||||||
|
{ key: "graded", label: "已批改", description: "可查看成绩" },
|
||||||
|
{ key: "expired", label: "已结束", description: "考试已结束" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_BADGE_STYLES: Record<ExamStatus, string> = {
|
||||||
|
not_started: "text-ink-muted",
|
||||||
|
in_progress: "text-warning",
|
||||||
|
submitted: "text-ink-muted",
|
||||||
|
graded: "text-success",
|
||||||
|
expired: "text-ink-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<ExamStatus, string> = {
|
||||||
|
not_started: "未开始",
|
||||||
|
in_progress: "进行中",
|
||||||
|
submitted: "已提交",
|
||||||
|
graded: "已批改",
|
||||||
|
expired: "已结束",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
return `${Math.round(seconds / 60)} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExamsPage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { exams, loading, error } = useChildExams();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载考试列表中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = STATUS_GROUPS.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: exams.filter((e) => e.status === g.key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的考试</h1>
|
||||||
|
|
||||||
|
{exams.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">暂无考试安排</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{grouped.map((group) =>
|
||||||
|
group.items.length > 0 ? (
|
||||||
|
<section key={group.key} aria-label={group.label}>
|
||||||
|
<div className="mb-3 flex items-baseline gap-3">
|
||||||
|
<h2 className="font-serif text-base">{group.label}</h2>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
{group.items.length} 场 · {group.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{group.items.map((exam) => (
|
||||||
|
<li key={exam.id}>
|
||||||
|
<ExamCard exam={exam} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExamCard({ exam }: { exam: ExamListItem }): JSX.Element {
|
||||||
|
const isResult = exam.status === "submitted" || exam.status === "graded";
|
||||||
|
const linkHref = isResult ? `/parent/exams/${exam.id}/result` : null;
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded border border-rule bg-paper-elevated p-4",
|
||||||
|
linkHref && "transition-shadow hover:shadow-md",
|
||||||
|
)}
|
||||||
|
aria-label={`${exam.name} - ${STATUS_LABELS[exam.status]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-ink-muted">{exam.subject}</p>
|
||||||
|
<h3 className="mt-1 font-serif text-base">{exam.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 text-xs font-medium",
|
||||||
|
STATUS_BADGE_STYLES[exam.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[exam.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 border-t border-rule pt-3">
|
||||||
|
<dl className="grid grid-cols-2 gap-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink-muted">开考时间</dt>
|
||||||
|
<dd className="mt-1">{formatDate(exam.startsAt)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink-muted">截止时间</dt>
|
||||||
|
<dd className="mt-1">{formatDate(exam.expiresAt)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink-muted">时长</dt>
|
||||||
|
<dd className="mt-1">{formatDuration(exam.durationSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink-muted">题量</dt>
|
||||||
|
<dd className="mt-1">{exam.questionCount} 题</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink-muted">满分</dt>
|
||||||
|
<dd className="mt-1">{exam.totalScore} 分</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkHref && <p className="mt-3 text-xs text-accent">查看结果 →</p>}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!linkHref) return inner;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={linkHref}
|
||||||
|
className="block h-full focus-visible:outline-2"
|
||||||
|
aria-label={`${exam.name} - 查看结果`}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,42 @@
|
|||||||
// 成绩页面
|
// 成绩页面
|
||||||
// 依据:02-architecture-design.md §3.1 路由结构
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
// 路由:/parent/grades
|
// 路由:/parent/grades
|
||||||
|
// - 顶部导出按钮
|
||||||
|
// - 成绩对比图(学生分数柱 + 班级均虚线)
|
||||||
|
// - 成绩明细表
|
||||||
|
// - 底部成长档案折线图(学生 vs 班级均)
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useChildGrades } from "@/hooks/useChildGrades";
|
import { useChildGrades } from "@/hooks/useChildGrades";
|
||||||
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { useChildGrowthArchive } from "@/hooks/useChildGrowthArchive";
|
||||||
import { ChildGradeChart } from "@/components/ChildGradeChart";
|
import { ChildGradeChart } from "@/components/ChildGradeChart";
|
||||||
|
import { GrowthArchiveChart } from "@/components/GrowthArchiveChart";
|
||||||
|
import { ExportGradesButton } from "@/components/ExportGradesButton";
|
||||||
import { formatDate, scoreToGrade, gradeToChinese, cn } from "@/lib/utils";
|
import { formatDate, scoreToGrade, gradeToChinese, cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function GradesPage() {
|
export default function GradesPage() {
|
||||||
const { currentChild } = useChildSwitcher();
|
const { currentChild, currentChildId } = useChildSwitcher();
|
||||||
const { grades, loading, error } = useChildGrades();
|
const { grades, loading, error } = useChildGrades();
|
||||||
|
const { growthArchive, loading: archiveLoading } = useChildGrowthArchive();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
<span className="skeleton h-8 w-48 rounded" />
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
<span className="skeleton h-72 w-full rounded" />
|
<span className="skeleton h-72 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载成绩中...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-danger p-4 text-sm text-danger">
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
加载失败:{error.message}
|
加载失败:{error.message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -32,13 +44,24 @@ export default function GradesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="font-serif text-2xl">{currentChild?.name}的成绩</h1>
|
<h1 className="font-serif text-2xl">{currentChild?.name}的成绩</h1>
|
||||||
|
{currentChildId && (
|
||||||
|
<ExportGradesButton
|
||||||
|
childId={currentChildId}
|
||||||
|
childName={currentChild?.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{grades.length > 0 && <ChildGradeChart grades={grades} />}
|
{grades.length > 0 && <ChildGradeChart grades={grades} />}
|
||||||
|
|
||||||
<div className="rounded border border-rule bg-paper-elevated p-4">
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
<h3 className="font-serif text-base">成绩明细</h3>
|
<h3 className="font-serif text-base">成绩明细</h3>
|
||||||
<table className="mt-3 w-full text-sm">
|
<table
|
||||||
|
className="mt-3 w-full text-sm"
|
||||||
|
aria-label={`${currentChild?.name ?? ""}成绩明细表`}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-left text-xs text-ink-muted">
|
<tr className="border-b border-rule text-left text-xs text-ink-muted">
|
||||||
<th className="pb-2">科目</th>
|
<th className="pb-2">科目</th>
|
||||||
@@ -80,6 +103,14 @@ export default function GradesPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{archiveLoading && (
|
||||||
|
<span className="skeleton h-72 w-full rounded" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!archiveLoading && growthArchive && growthArchive.dataPoints.length > 0 && (
|
||||||
|
<GrowthArchiveChart archive={growthArchive} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
106
apps/parent-portal/src/app/parent/grades/report-card/page.tsx
Normal file
106
apps/parent-portal/src/app/parent/grades/report-card/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// 报告卡页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/grades/report-card
|
||||||
|
// 顶部:学年/学期筛选 + 打印按钮;主体:报告卡视图
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryState, parseAsString } from "nuqs";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { useAcademicYears } from "@/hooks/useAcademicYears";
|
||||||
|
import { useReportCard } from "@/hooks/useReportCard";
|
||||||
|
import { ReportCardView } from "@/components/ReportCardView";
|
||||||
|
|
||||||
|
export default function ReportCardPage(): JSX.Element {
|
||||||
|
const { currentChildId, currentChild } = useChildSwitcher();
|
||||||
|
const { academicYears, loading: yearsLoading } = useAcademicYears();
|
||||||
|
|
||||||
|
// nuqs 管理学年/学期 URL 状态(可刷新恢复)
|
||||||
|
const [academicYearId, setAcademicYearId] = useQueryState(
|
||||||
|
"academicYearId",
|
||||||
|
parseAsString.withDefault(""),
|
||||||
|
);
|
||||||
|
const [semesterStr, setSemesterStr] = useQueryState(
|
||||||
|
"semester",
|
||||||
|
parseAsString.withDefault("2"),
|
||||||
|
);
|
||||||
|
const semester = Number(semesterStr) === 1 ? 1 : 2;
|
||||||
|
|
||||||
|
// URL 为空时回退到第一个学年
|
||||||
|
const effectiveYearId = academicYearId || academicYears[0]?.id || null;
|
||||||
|
|
||||||
|
const { reportCard, loading, error } = useReportCard(
|
||||||
|
currentChildId,
|
||||||
|
effectiveYearId,
|
||||||
|
semester,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">
|
||||||
|
{currentChild?.name ? `${currentChild.name}的报告卡` : "报告卡"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex flex-wrap items-end gap-4 rounded border border-rule bg-paper-elevated p-4 print:hidden">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="report-year"
|
||||||
|
className="text-xs text-ink-muted"
|
||||||
|
>
|
||||||
|
学年
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="report-year"
|
||||||
|
value={effectiveYearId ?? ""}
|
||||||
|
onChange={(e) => setAcademicYearId(e.target.value)}
|
||||||
|
disabled={yearsLoading}
|
||||||
|
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{academicYears.map((y) => (
|
||||||
|
<option key={y.id} value={y.id}>
|
||||||
|
{y.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="report-semester"
|
||||||
|
className="text-xs text-ink-muted"
|
||||||
|
>
|
||||||
|
学期
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="report-semester"
|
||||||
|
value={String(semester)}
|
||||||
|
onChange={(e) => setSemesterStr(e.target.value)}
|
||||||
|
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="1">第 1 学期</option>
|
||||||
|
<option value="2">第 2 学期</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主体 */}
|
||||||
|
{loading ? (
|
||||||
|
<span className="skeleton h-96 w-full rounded" />
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : !reportCard ? (
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无报告卡数据
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ReportCardView reportCard={reportCard} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/parent-portal/src/app/parent/learning-path/page.tsx
Normal file
142
apps/parent-portal/src/app/parent/learning-path/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// 学习路径页面(家长查看子女知识点掌握)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/learning-path
|
||||||
|
// 对标 student-portal /learning-path(家长只读视角)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildLearningPath } from "@/hooks/useChildLearningPath";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { LearningPathNode, NodeStatus } from "@/types";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<NodeStatus, string> = {
|
||||||
|
locked: "未解锁",
|
||||||
|
available: "可学习",
|
||||||
|
"in-progress": "学习中",
|
||||||
|
completed: "已完成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES: Record<NodeStatus, string> = {
|
||||||
|
locked: "text-ink-muted",
|
||||||
|
available: "text-accent",
|
||||||
|
"in-progress": "text-warning",
|
||||||
|
completed: "text-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROGRESS_TONES: Record<NodeStatus, string> = {
|
||||||
|
locked: "bg-ink-muted",
|
||||||
|
available: "bg-accent",
|
||||||
|
"in-progress": "bg-warning",
|
||||||
|
completed: "bg-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
function masteryPercent(node: LearningPathNode): number {
|
||||||
|
return Math.round(Math.max(0, Math.min(1, node.mastery)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LearningPathPage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { nodes, loading, error } = useChildLearningPath();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-24 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-24 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载学习路径中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的学习路径</h1>
|
||||||
|
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">暂无学习路径数据</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-3" aria-label="学习路径节点列表">
|
||||||
|
{nodes.map((node, index) => (
|
||||||
|
<li key={node.id}>
|
||||||
|
<LearningPathCard node={node} index={index} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LearningPathCard({
|
||||||
|
node,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
node: LearningPathNode;
|
||||||
|
index: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const pct = masteryPercent(node);
|
||||||
|
const step = node.recommendedOrder || index + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-ink-muted">第 {step} 步</span>
|
||||||
|
<span
|
||||||
|
className={cn("text-xs font-medium", STATUS_TONES[node.status])}
|
||||||
|
aria-label={`状态:${STATUS_LABELS[node.status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[node.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 font-serif text-base">{node.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-ink-muted">掌握度</p>
|
||||||
|
<p
|
||||||
|
className={cn("font-serif text-lg", STATUS_TONES[node.status])}
|
||||||
|
aria-label={`掌握度 ${pct}%`}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 掌握度进度条 */}
|
||||||
|
<div
|
||||||
|
className="mt-3 h-2 w-full overflow-hidden rounded bg-rule"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`${node.name} 掌握度进度条`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded", PROGRESS_TONES[node.status])}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.recommendation && (
|
||||||
|
<p className="mt-3 border-t border-rule pt-2 text-sm mark-left">
|
||||||
|
<span className="text-ink-muted">推荐:</span>
|
||||||
|
{node.recommendation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/parent-portal/src/app/parent/leave/page.tsx
Normal file
44
apps/parent-portal/src/app/parent/leave/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// 请假页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/leave
|
||||||
|
// 顶部:请假申请表单;底部:历史请假列表
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildLeaveRequests } from "@/hooks/useChildLeaveRequests";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { LeaveRequestForm } from "@/components/LeaveRequestForm";
|
||||||
|
import { LeaveRequestList } from "@/components/LeaveRequestList";
|
||||||
|
|
||||||
|
export default function LeavePage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { leaveRequests, loading, error, refetch } = useChildLeaveRequests();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">
|
||||||
|
{currentChild?.name ? `${currentChild.name}的请假` : "请假管理"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<LeaveRequestForm onSuccess={refetch} />
|
||||||
|
|
||||||
|
<section aria-label="历史请假记录">
|
||||||
|
<h2 className="font-serif text-base">历史请假</h2>
|
||||||
|
<div className="mt-3">
|
||||||
|
{loading ? (
|
||||||
|
<span className="skeleton h-40 w-full rounded" />
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LeaveRequestList leaveRequests={leaveRequests} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 备课只读详情页面(家长查看子女备课详情)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/lesson-plans/[planId]/view
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useChildLessonPlanDetail } from "@/hooks/useChildLessonPlanDetail";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { LessonPlanReadonlyView } from "@/components/LessonPlanReadonlyView";
|
||||||
|
|
||||||
|
export default function LessonPlanViewPage(): JSX.Element {
|
||||||
|
const params = useParams();
|
||||||
|
const planId =
|
||||||
|
typeof params?.planId === "string"
|
||||||
|
? params.planId
|
||||||
|
: Array.isArray(params?.planId)
|
||||||
|
? (params.planId[0] ?? "")
|
||||||
|
: "";
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { lessonPlan, loading, error } = useChildLessonPlanDetail(planId);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载备课详情中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lessonPlan) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/parent/lesson-plans"
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← 返回备课列表
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无备课数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的备课</h1>
|
||||||
|
<Link
|
||||||
|
href="/parent/lesson-plans"
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← 返回备课列表
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<LessonPlanReadonlyView lessonPlan={lessonPlan} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/parent-portal/src/app/parent/lesson-plans/page.tsx
Normal file
81
apps/parent-portal/src/app/parent/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// 备课列表页面(家长查看子女备课,只读,含学科筛选)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/lesson-plans
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryState, parseAsString } from "nuqs";
|
||||||
|
import { useChildLessonPlans } from "@/hooks/useChildLessonPlans";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { LessonPlanList } from "@/components/LessonPlanList";
|
||||||
|
|
||||||
|
export default function LessonPlansPage(): JSX.Element {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const [subject, setSubject] = useQueryState(
|
||||||
|
"subject",
|
||||||
|
parseAsString.withDefault(""),
|
||||||
|
);
|
||||||
|
const { lessonPlans, loading, error } = useChildLessonPlans(
|
||||||
|
subject || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只显示已发布备课(display rule,防御性过滤)
|
||||||
|
const publishedPlans = lessonPlans.filter((p) => p.status === "published");
|
||||||
|
// 学科选项来自已发布备课
|
||||||
|
const subjects = Array.from(new Set(publishedPlans.map((p) => p.subject)));
|
||||||
|
// 学科筛选(nuqs + 客户端兜底,兼容 mock 不按 subject 过滤)
|
||||||
|
const visiblePlans = subject
|
||||||
|
? publishedPlans.filter((p) => p.subject === subject)
|
||||||
|
: publishedPlans;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-10 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载备课列表中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的备课</h1>
|
||||||
|
|
||||||
|
{/* 学科筛选 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="lesson-subject" className="text-xs text-ink-muted">
|
||||||
|
学科筛选
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="lesson-subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value || null)}
|
||||||
|
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全部学科</option>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LessonPlanList lessonPlans={visiblePlans} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/parent-portal/src/app/parent/practice/page.tsx
Normal file
60
apps/parent-portal/src/app/parent/practice/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// 练习统计页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/practice
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChildPractice } from "@/hooks/useChildPractice";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { PracticeStatsCard } from "@/components/PracticeStatsCard";
|
||||||
|
import { PracticeSessionList } from "@/components/PracticeSessionList";
|
||||||
|
|
||||||
|
export default function PracticePage() {
|
||||||
|
const { currentChild } = useChildSwitcher();
|
||||||
|
const { stats, sessions, loading, error } = useChildPractice();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-32 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="skeleton h-64 w-full rounded" aria-hidden="true" />
|
||||||
|
<span className="sr-only">加载练习统计中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = stats !== null || sessions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">{currentChild?.name}的练习统计</h1>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
|
||||||
|
暂无练习记录
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{stats && <PracticeStatsCard stats={stats} />}
|
||||||
|
|
||||||
|
<section className="space-y-3" aria-label="练习历史">
|
||||||
|
<h2 className="font-serif text-lg">练习历史</h2>
|
||||||
|
<PracticeSessionList sessions={sessions} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/parent-portal/src/app/parent/settings/page.tsx
Normal file
94
apps/parent-portal/src/app/parent/settings/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// 家长个人设置页面(只读)
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/settings
|
||||||
|
// 对标 teacher-portal /settings(家长只读视角)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "urql";
|
||||||
|
import { CURRENT_PARENT_DETAIL } from "@/lib/graphql/operations";
|
||||||
|
import type { UserSession } from "@/types";
|
||||||
|
|
||||||
|
interface CurrentParentDetailResponse {
|
||||||
|
currentUser: UserSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage(): JSX.Element {
|
||||||
|
const [result] = useQuery<CurrentParentDetailResponse>({
|
||||||
|
query: CURRENT_PARENT_DETAIL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.fetching) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||||||
|
<span
|
||||||
|
className="skeleton h-64 w-full max-w-2xl rounded"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">加载个人信息中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded border border-danger p-4 text-sm text-danger"
|
||||||
|
>
|
||||||
|
加载失败:{result.error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.data?.currentUser;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-ink-muted" role="status">
|
||||||
|
暂无用户信息
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="font-serif text-2xl">个人设置</h1>
|
||||||
|
|
||||||
|
<section className="max-w-2xl">
|
||||||
|
<h2 className="font-serif text-base">基本信息</h2>
|
||||||
|
<div className="mt-3 rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 items-baseline gap-4">
|
||||||
|
<dt className="text-xs text-ink-muted">用户 ID</dt>
|
||||||
|
<dd className="col-span-2-safe font-mono text-sm">{user.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 items-baseline gap-4">
|
||||||
|
<dt className="text-xs text-ink-muted">姓名</dt>
|
||||||
|
<dd className="col-span-2-safe text-sm">{user.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 items-baseline gap-4">
|
||||||
|
<dt className="text-xs text-ink-muted">邮箱</dt>
|
||||||
|
<dd className="col-span-2-safe text-sm">{user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 items-baseline gap-4">
|
||||||
|
<dt className="text-xs text-ink-muted">角色</dt>
|
||||||
|
<dd className="col-span-2-safe text-sm">
|
||||||
|
{user.roles.join(", ") || "无角色"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 items-baseline gap-4">
|
||||||
|
<dt className="text-xs text-ink-muted">数据范围</dt>
|
||||||
|
<dd className="col-span-2-safe text-sm">{user.dataScope}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-ink-muted">
|
||||||
|
个人信息为只读展示,如需修改请联系学校管理员。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
apps/parent-portal/src/app/parent/trend/page.tsx
Normal file
273
apps/parent-portal/src/app/parent/trend/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// 学习趋势页面
|
||||||
|
// 依据:02-architecture-design.md §3.1 路由结构
|
||||||
|
// 路由:/parent/trend
|
||||||
|
// 对标 student-portal /dashboard/trend
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { useChildTrend, type TrendPeriod } from "@/hooks/useChildTrend";
|
||||||
|
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||||||
|
import { PERMISSIONS } from "@/lib/permissions";
|
||||||
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
|
|
||||||
|
const PERIOD_LABELS: Record<TrendPeriod, string> = {
|
||||||
|
week: "本周",
|
||||||
|
month: "本月",
|
||||||
|
semester: "本学期",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: TrendPeriod[] = ["week", "month", "semester"];
|
||||||
|
|
||||||
|
const TREND_LABELS: Record<string, string> = {
|
||||||
|
up: "上升",
|
||||||
|
down: "下降",
|
||||||
|
stable: "平稳",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TREND_COLORS: Record<string, string> = {
|
||||||
|
up: "hsl(var(--success))",
|
||||||
|
down: "hsl(var(--danger))",
|
||||||
|
stable: "hsl(var(--ink-muted))",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendPage() {
|
||||||
|
const [period, setPeriod] = useState<TrendPeriod>("month");
|
||||||
|
const {
|
||||||
|
dataPoints,
|
||||||
|
avgScore,
|
||||||
|
maxScore,
|
||||||
|
minScore,
|
||||||
|
trendDirection,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useChildTrend(period);
|
||||||
|
const { children: childList, currentChildId } = useChildSwitcher();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
|
||||||
|
if (!hasPermission(PERMISSIONS.CHILD_TREND_VIEW)) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<p className="text-ink-muted">无权限访问此页面</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childList.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<p className="text-ink-muted">未绑定子女,无法查看学习趋势</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentChild = childList.find((c) => c.id === currentChildId);
|
||||||
|
|
||||||
|
// 按科目分组
|
||||||
|
const subjects = [
|
||||||
|
...new Set(dataPoints.map((d) => d.subject).filter(Boolean)),
|
||||||
|
];
|
||||||
|
const chartData = dataPoints.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
score: d.score,
|
||||||
|
subject: d.subject ?? "综合",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-2xl">学习趋势</h1>
|
||||||
|
{currentChild && (
|
||||||
|
<p className="mt-1 text-sm text-ink-muted">
|
||||||
|
{currentChild.name} · {currentChild.grade}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 周期选择器 */}
|
||||||
|
<div
|
||||||
|
className="flex gap-1 rounded-lg border border-rule p-1"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={period === p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={
|
||||||
|
period === p
|
||||||
|
? "rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-paper"
|
||||||
|
: "rounded-md px-3 py-1.5 text-sm text-ink-muted hover:text-ink"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PERIOD_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4" role="status" aria-live="polite">
|
||||||
|
<div className="skeleton h-64 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-danger p-4 text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p>加载失败:{error.message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-2 text-sm underline"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : dataPoints.length === 0 ? (
|
||||||
|
<div className="flex min-h-[300px] items-center justify-center rounded-lg border border-rule">
|
||||||
|
<p className="text-ink-muted">暂无趋势数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 汇总统计卡片 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<div className="rounded-lg border border-rule p-4">
|
||||||
|
<p className="text-sm text-ink-muted">平均分</p>
|
||||||
|
<p className="mt-1 text-2xl font-serif">{avgScore}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-rule p-4">
|
||||||
|
<p className="text-sm text-ink-muted">最高分</p>
|
||||||
|
<p className="mt-1 text-2xl font-serif text-success">
|
||||||
|
{maxScore}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-rule p-4">
|
||||||
|
<p className="text-sm text-ink-muted">最低分</p>
|
||||||
|
<p className="mt-1 text-2xl font-serif text-danger">{minScore}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-rule p-4">
|
||||||
|
<p className="text-sm text-ink-muted">趋势</p>
|
||||||
|
<p
|
||||||
|
className="mt-1 text-2xl font-serif"
|
||||||
|
style={{ color: TREND_COLORS[trendDirection] }}
|
||||||
|
>
|
||||||
|
{TREND_LABELS[trendDirection]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势折线图 */}
|
||||||
|
<div className="rounded-lg border border-rule p-4">
|
||||||
|
<h2 className="mb-4 font-serif text-lg">成绩变化趋势</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 8, right: 16, bottom: 8, left: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--rule))"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--ink-muted))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--ink-muted))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--paper-elevated))",
|
||||||
|
border: "1px solid hsl(var(--rule))",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "hsl(var(--ink))" }}
|
||||||
|
/>
|
||||||
|
{subjects.length > 1 ? (
|
||||||
|
subjects.map((subject) => (
|
||||||
|
<Line
|
||||||
|
key={subject}
|
||||||
|
type="monotone"
|
||||||
|
dataKey="score"
|
||||||
|
data={chartData.filter((d) => d.subject === subject)}
|
||||||
|
name={subject}
|
||||||
|
stroke="hsl(var(--accent))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: "hsl(var(--accent))" }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="score"
|
||||||
|
stroke="hsl(var(--accent))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: "hsl(var(--accent))" }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{subjects.length > 1 && <Legend />}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 明细列表 */}
|
||||||
|
<div className="rounded-lg border border-rule">
|
||||||
|
<div className="border-b border-rule px-4 py-3">
|
||||||
|
<h2 className="font-serif text-lg">详细数据</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-rule text-left text-ink-muted">
|
||||||
|
<th className="px-4 py-2 font-normal">日期</th>
|
||||||
|
<th className="px-4 py-2 font-normal">科目</th>
|
||||||
|
<th className="px-4 py-2 font-normal text-right">分数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dataPoints.map((d, i) => (
|
||||||
|
<tr key={i} className="border-b border-rule last:border-0">
|
||||||
|
<td className="px-4 py-2">{formatDate(d.date)}</td>
|
||||||
|
<td className="px-4 py-2">{d.subject ?? "综合"}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono">
|
||||||
|
{d.score}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
// 客户端 Providers:QueryClient + Urql + MSW 初始化
|
// 客户端 Providers:QueryClient + Urql + MSW + ErrorBoundary + SW + OTel + WebVitals
|
||||||
// 依据:02-architecture-design.md §4.3 状态管理分层、§8 MSW
|
// 依据:02-architecture-design.md §4.3 状态管理分层、§8 MSW、§12 可观测性
|
||||||
// - NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
|
// - NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
|
||||||
// - QueryClient 单例(useState 保持稳定)
|
// - QueryClient 单例(useState 保持稳定)
|
||||||
// - Urql client 单例
|
// - Urql client 单例
|
||||||
|
// - ErrorBoundary 包裹全局渲染异常兜底
|
||||||
|
// - Service Worker 注册(PWA 离线缓存,P6-5)
|
||||||
|
// - OTel browser SDK 初始化(P6-1,条件启用)
|
||||||
|
// - Web Vitals 采集初始化(P6-1,生产环境)
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
@@ -11,6 +15,8 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { Provider as UrqlProvider } from "urql";
|
import { Provider as UrqlProvider } from "urql";
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
import { getGraphQLClient } from "@/lib/graphql-client";
|
import { getGraphQLClient } from "@/lib/graphql-client";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
import { WebVitalsInitializer } from "@/components/WebVitalsInitializer";
|
||||||
|
|
||||||
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
|
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
|
||||||
|
|
||||||
@@ -48,6 +54,33 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Service Worker 注册(PWA,P6-5)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
const registerSW = async () => {
|
||||||
|
try {
|
||||||
|
// MSW 启用时不注册 PWA Service Worker(避免冲突)
|
||||||
|
if (isMockingEnabled) return;
|
||||||
|
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||||
|
} catch {
|
||||||
|
// SW 注册失败不阻塞应用
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerSW();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// OTel browser SDK 初始化(P6-1,条件启用:NEXT_PUBLIC_OTEL_ENABLED=true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
|
void import("@/lib/observability/otel").then(({ initOTel }) => {
|
||||||
|
void initOTel();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// MSW 未就绪时显示加载态(避免 mock 数据未注入时闪烁)
|
// MSW 未就绪时显示加载态(避免 mock 数据未注入时闪烁)
|
||||||
if (!mswReady) {
|
if (!mswReady) {
|
||||||
return (
|
return (
|
||||||
@@ -62,8 +95,13 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<UrqlProvider value={urqlClient}>{children}</UrqlProvider>
|
<UrqlProvider value={urqlClient}>
|
||||||
|
<WebVitalsInitializer />
|
||||||
|
{children}
|
||||||
|
</UrqlProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
214
apps/parent-portal/src/components/AppShell.test.tsx
Normal file
214
apps/parent-portal/src/components/AppShell.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// AppShell 组件单测
|
||||||
|
// 依据:02-architecture-design.md §6 应用外壳、§15 AppShell 设计
|
||||||
|
// 覆盖:导航项按权限过滤 / 当前路径高亮 / 登出按钮 / 用户名显示
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { AppShell } from "./AppShell";
|
||||||
|
import type { UserSession } from "@/types";
|
||||||
|
|
||||||
|
// mock next/navigation 的 usePathname
|
||||||
|
const mockUsePathname = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => mockUsePathname(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock next/link 为普通 <a>
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock ChildSwitcher(已在其他测试文件覆盖)
|
||||||
|
vi.mock("./ChildSwitcher", () => ({
|
||||||
|
ChildSwitcher: () => <div data-testid="child-switcher" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock usePermission hook
|
||||||
|
const mockHasPermission = vi.fn();
|
||||||
|
vi.mock("@/hooks/usePermission", () => ({
|
||||||
|
usePermission: () => ({
|
||||||
|
hasPermission: mockHasPermission,
|
||||||
|
permissions: [],
|
||||||
|
hasAnyPermission: vi.fn(),
|
||||||
|
hasAllPermissions: vi.fn(),
|
||||||
|
isAuthenticated: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock auth:getUser + logout
|
||||||
|
const mockGetUser = vi.fn();
|
||||||
|
const mockLogout = vi.fn();
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getUser: () => mockGetUser(),
|
||||||
|
logout: () => mockLogout(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUser: UserSession = {
|
||||||
|
id: "parent-001",
|
||||||
|
email: "parent@example.com",
|
||||||
|
name: "张父",
|
||||||
|
roles: ["parent"],
|
||||||
|
permissions: ["DASHBOARD_VIEW"],
|
||||||
|
dataScope: "CHILDREN",
|
||||||
|
schoolId: "school-001",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUsePathname.mockReturnValue("/parent/dashboard");
|
||||||
|
mockGetUser.mockReturnValue(mockUser);
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
mockLogout.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppShell 布局", () => {
|
||||||
|
it("渲染头部标题 Edu 家长端", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByText("Edu 家长端")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 ChildSwitcher", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByTestId("child-switcher")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染用户名", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByText("张父")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染子内容", () => {
|
||||||
|
render(
|
||||||
|
<AppShell>
|
||||||
|
<div>测试内容</div>
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("测试内容")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 main 区域(id=main-content)", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 nav 导航(aria-label=主导航)", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("navigation", { name: "主导航" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppShell 导航项过滤", () => {
|
||||||
|
it("所有权限通过时渲染全部 12 个导航项", () => {
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
// 限定在 nav 导航区域内计数(排除 header 中的 logo 链接)
|
||||||
|
const navList = screen.getByRole("navigation", { name: "主导航" });
|
||||||
|
const navItems = navList.querySelectorAll("a");
|
||||||
|
expect(navItems.length).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("权限全部拒绝时不渲染任何导航项", () => {
|
||||||
|
mockHasPermission.mockReturnValue(false);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const navList = screen.getByRole("navigation", { name: "主导航" });
|
||||||
|
const navItems = navList.querySelectorAll("a");
|
||||||
|
expect(navItems.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("仅 DASHBOARD_VIEW 权限通过时只渲染仪表盘", () => {
|
||||||
|
mockHasPermission.mockImplementation(
|
||||||
|
(perm: string) => perm === "DASHBOARD_VIEW",
|
||||||
|
);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const navList = screen.getByRole("navigation", { name: "主导航" });
|
||||||
|
const navItems = navList.querySelectorAll("a");
|
||||||
|
expect(navItems.length).toBe(1);
|
||||||
|
expect(navItems[0]).toHaveTextContent("仪表盘");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("导航项标签包含所有预期项", () => {
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const labels = [
|
||||||
|
"仪表盘",
|
||||||
|
"成绩",
|
||||||
|
"考试",
|
||||||
|
"考勤",
|
||||||
|
"班级",
|
||||||
|
"作业",
|
||||||
|
"学情",
|
||||||
|
"学习路径",
|
||||||
|
"趋势",
|
||||||
|
"通知",
|
||||||
|
"偏好",
|
||||||
|
"设置",
|
||||||
|
];
|
||||||
|
for (const label of labels) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppShell 当前路径高亮", () => {
|
||||||
|
it("当前路径 /parent/dashboard 的导航项 aria-current=page", () => {
|
||||||
|
mockUsePathname.mockReturnValue("/parent/dashboard");
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const activeLink = screen.getByRole("link", { name: "仪表盘" });
|
||||||
|
expect(activeLink).toHaveAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非当前路径的导航项 aria-current 为 undefined", () => {
|
||||||
|
mockUsePathname.mockReturnValue("/parent/dashboard");
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const inactiveLink = screen.getByRole("link", { name: "成绩" });
|
||||||
|
expect(inactiveLink).not.toHaveAttribute("aria-current");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("切换到 /parent/grades 时成绩导航项高亮", () => {
|
||||||
|
mockUsePathname.mockReturnValue("/parent/grades");
|
||||||
|
mockHasPermission.mockReturnValue(true);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
const gradesLink = screen.getByRole("link", { name: "成绩" });
|
||||||
|
expect(gradesLink).toHaveAttribute("aria-current", "page");
|
||||||
|
const dashboardLink = screen.getByRole("link", { name: "仪表盘" });
|
||||||
|
expect(dashboardLink).not.toHaveAttribute("aria-current");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppShell 登出", () => {
|
||||||
|
it("渲染退出登录按钮(aria-label)", () => {
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByLabelText("退出登录")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点击退出按钮调用 logout", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
await user.click(screen.getByLabelText("退出登录"));
|
||||||
|
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppShell 用户信息缺失", () => {
|
||||||
|
it("getUser 返回 null 时不崩溃(用户名不显示)", () => {
|
||||||
|
mockGetUser.mockReturnValue(null);
|
||||||
|
render(<AppShell>content</AppShell>);
|
||||||
|
expect(screen.getByText("Edu 家长端")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,11 +32,21 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
href: "/parent/grades",
|
href: "/parent/grades",
|
||||||
permission: PERMISSIONS.CHILD_GRADE_VIEW,
|
permission: PERMISSIONS.CHILD_GRADE_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "考试",
|
||||||
|
href: "/parent/exams",
|
||||||
|
permission: PERMISSIONS.CHILD_EXAM_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "考勤",
|
label: "考勤",
|
||||||
href: "/parent/attendance",
|
href: "/parent/attendance",
|
||||||
permission: PERMISSIONS.CHILD_ATTENDANCE_VIEW,
|
permission: PERMISSIONS.CHILD_ATTENDANCE_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "班级",
|
||||||
|
href: "/parent/classes",
|
||||||
|
permission: PERMISSIONS.CHILD_CLASS_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "作业",
|
label: "作业",
|
||||||
href: "/parent/homework",
|
href: "/parent/homework",
|
||||||
@@ -47,6 +57,16 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
href: "/parent/weakness",
|
href: "/parent/weakness",
|
||||||
permission: PERMISSIONS.CHILD_WEAKNESS_VIEW,
|
permission: PERMISSIONS.CHILD_WEAKNESS_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "学习路径",
|
||||||
|
href: "/parent/learning-path",
|
||||||
|
permission: PERMISSIONS.CHILD_LEARNING_PATH_VIEW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "趋势",
|
||||||
|
href: "/parent/trend",
|
||||||
|
permission: PERMISSIONS.CHILD_TREND_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "通知",
|
label: "通知",
|
||||||
href: "/parent/notifications",
|
href: "/parent/notifications",
|
||||||
@@ -57,6 +77,11 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
href: "/parent/preferences",
|
href: "/parent/preferences",
|
||||||
permission: PERMISSIONS.NOTIFICATION_PREFERENCE_UPDATE,
|
permission: PERMISSIONS.NOTIFICATION_PREFERENCE_UPDATE,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "设置",
|
||||||
|
href: "/parent/settings",
|
||||||
|
permission: PERMISSIONS.PROFILE_VIEW,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
@@ -79,6 +104,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
aria-label="退出登录"
|
||||||
className="text-sm text-ink-muted hover:text-danger"
|
className="text-sm text-ink-muted hover:text-danger"
|
||||||
>
|
>
|
||||||
退出
|
退出
|
||||||
|
|||||||
245
apps/parent-portal/src/components/AttendanceCalendar.test.tsx
Normal file
245
apps/parent-portal/src/components/AttendanceCalendar.test.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// AttendanceCalendar 组件单测
|
||||||
|
// 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计
|
||||||
|
// 覆盖:日历网格渲染 / 状态颜色标记 / 统计 / 空数据 / 月份标题 / 月份导航
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { AttendanceCalendar } from "./AttendanceCalendar";
|
||||||
|
import type { AttendanceRecord } from "@/types";
|
||||||
|
|
||||||
|
// 固定当前时间为 2026-06-15(6 月有 30 天,1 日是周一)
|
||||||
|
const FIXED_DATE = new Date("2026-06-15T12:00:00Z");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(FIXED_DATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 2026-06 的考勤记录
|
||||||
|
function makeJuneRecords(
|
||||||
|
overrides: Partial<AttendanceRecord>[] = [],
|
||||||
|
): AttendanceRecord[] {
|
||||||
|
const records: AttendanceRecord[] = [];
|
||||||
|
// 6/1 ~ 6/5 出勤
|
||||||
|
for (let d = 1; d <= 5; d++) {
|
||||||
|
records.push({
|
||||||
|
id: `att-${d}`,
|
||||||
|
date: `2026-06-${String(d).padStart(2, "0")}`,
|
||||||
|
status: "present",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 6/8 迟到
|
||||||
|
records.push({
|
||||||
|
id: "att-late",
|
||||||
|
date: "2026-06-08",
|
||||||
|
status: "late",
|
||||||
|
});
|
||||||
|
// 6/10 缺勤
|
||||||
|
records.push({
|
||||||
|
id: "att-absent",
|
||||||
|
date: "2026-06-10",
|
||||||
|
status: "absent",
|
||||||
|
});
|
||||||
|
// 6/12 请假
|
||||||
|
records.push({
|
||||||
|
id: "att-leave",
|
||||||
|
date: "2026-06-12",
|
||||||
|
status: "leave",
|
||||||
|
});
|
||||||
|
// 应用覆盖
|
||||||
|
for (const o of overrides) {
|
||||||
|
const idx = records.findIndex((r) => r.date === o.date);
|
||||||
|
if (idx >= 0) {
|
||||||
|
records[idx] = { ...records[idx]!, ...o };
|
||||||
|
} else {
|
||||||
|
records.push(o as AttendanceRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AttendanceCalendar 标题与图例", () => {
|
||||||
|
it("渲染当前年月标题(2026年6月)", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 4 种状态图例标签", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
// 图例 + 统计行均含这些标签,使用 getAllByText 验证至少存在
|
||||||
|
expect(screen.getAllByText("出勤").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("迟到").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("缺勤").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("请假").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染星期表头(日一二三四五六)", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
for (const w of ["日", "一", "二", "三", "四", "五", "六"]) {
|
||||||
|
expect(screen.getByText(w)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceCalendar 日历网格", () => {
|
||||||
|
it("渲染 6 月所有日期 1-30", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
for (let d = 1; d <= 30; d++) {
|
||||||
|
expect(screen.getByText(String(d))).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空记录数组时所有日期无状态标记点", () => {
|
||||||
|
const { container } = render(<AttendanceCalendar records={[]} />);
|
||||||
|
// 有记录时日期单元格内会有一个小圆点 span(mt-0.5 + rounded-full)
|
||||||
|
const dots = container.querySelectorAll(".mt-0\\.5");
|
||||||
|
expect(dots.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有记录的日期显示状态标记点", () => {
|
||||||
|
const records = makeJuneRecords();
|
||||||
|
const { container } = render(<AttendanceCalendar records={records} />);
|
||||||
|
const dots = container.querySelectorAll(".mt-0\\.5");
|
||||||
|
// 5(出勤) + 1(迟到) + 1(缺勤) + 1(请假) = 8 个标记点
|
||||||
|
expect(dots.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有记录的日期单元格 title 属性显示状态标签", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "a", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "b", date: "2026-06-02", status: "late" },
|
||||||
|
];
|
||||||
|
const { container } = render(<AttendanceCalendar records={records} />);
|
||||||
|
// 直接通过 title 属性定位日期单元格(避免与统计数字冲突)
|
||||||
|
const presentCell = container.querySelector('[title="出勤"]');
|
||||||
|
expect(presentCell).not.toBeNull();
|
||||||
|
expect(presentCell).toHaveTextContent("1");
|
||||||
|
const lateCell = container.querySelector('[title="迟到"]');
|
||||||
|
expect(lateCell).not.toBeNull();
|
||||||
|
expect(lateCell).toHaveTextContent("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceCalendar 统计", () => {
|
||||||
|
it("统计各状态数量", () => {
|
||||||
|
const records = makeJuneRecords();
|
||||||
|
render(<AttendanceCalendar records={records} />);
|
||||||
|
// 出勤 5 / 迟到 1 / 缺勤 1 / 请假 1
|
||||||
|
const presentStat = screen.getByText("5", { selector: ".text-success" });
|
||||||
|
expect(presentStat).toBeInTheDocument();
|
||||||
|
const lateStat = screen.getByText("1", { selector: ".text-warning" });
|
||||||
|
expect(lateStat).toBeInTheDocument();
|
||||||
|
const absentStat = screen.getByText("1", { selector: ".text-danger" });
|
||||||
|
expect(absentStat).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空记录时所有统计为 0", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
const stats = screen.getAllByText("0");
|
||||||
|
expect(stats.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染统计标签文案", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
// 统计行:出勤 / 迟到 / 缺勤 / 请假
|
||||||
|
const labels = screen.getAllByText(/出勤|迟到|缺勤|请假/);
|
||||||
|
// 至少 4 个统计标签(图例也有这些标签,所以会更多)
|
||||||
|
expect(labels.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceCalendar 空数据", () => {
|
||||||
|
it("空记录数组时仍渲染日历骨架", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
|
||||||
|
// 日期 1-30 仍然渲染
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceCalendar 月份导航", () => {
|
||||||
|
it("未传 onMonthChange 时不渲染导航按钮(向后兼容)", () => {
|
||||||
|
render(<AttendanceCalendar records={[]} />);
|
||||||
|
expect(screen.queryByRole("button", { name: "上一月" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "下一月" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("传入 onMonthChange 时渲染上一月/下一月按钮", () => {
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-06"
|
||||||
|
onMonthChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "上一月" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "下一月" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("传入 month prop 时渲染对应月份标题", () => {
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-05"
|
||||||
|
onMonthChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/2026年5月考勤/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点击上一月按钮调用 onMonthChange 传 2026-05", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-06"
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith("2026-05");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点击下一月按钮调用 onMonthChange 传 2026-07", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-06"
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith("2026-07");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跨年导航:2026-01 上一月为 2025-12", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-01"
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith("2025-12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跨年导航:2026-12 下一月为 2027-01", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<AttendanceCalendar
|
||||||
|
records={[]}
|
||||||
|
month="2026-12"
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith("2027-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计
|
// 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计
|
||||||
// - 月历视图,每天用颜色点标记考勤状态
|
// - 月历视图,每天用颜色点标记考勤状态
|
||||||
// - present=success / late=warning / absent=danger / leave=ink-subtle
|
// - present=success / late=warning / absent=danger / leave=ink-subtle
|
||||||
|
// - 支持月份导航(可选 month + onMonthChange props)
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
@@ -11,6 +12,10 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface AttendanceCalendarProps {
|
interface AttendanceCalendarProps {
|
||||||
records: AttendanceRecord[];
|
records: AttendanceRecord[];
|
||||||
|
/** YYYY-MM 格式,未传则使用当前月 */
|
||||||
|
month?: string;
|
||||||
|
/** 月份切换回调,传入则显示导航按钮 */
|
||||||
|
onMonthChange?: (month: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
|
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
|
||||||
@@ -29,11 +34,41 @@ const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
|
|||||||
|
|
||||||
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
|
||||||
export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
function parseMonth(month: string): { year: number; month: number } {
|
||||||
const { year, month, days, recordMap } = useMemo(() => {
|
const [yearStr, monthStr] = month.split("-");
|
||||||
|
return {
|
||||||
|
year: parseInt(yearStr ?? "0", 10),
|
||||||
|
month: parseInt(monthStr ?? "1", 10) - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(year: number, month: number): string {
|
||||||
|
return `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(month: string, delta: number): string {
|
||||||
|
const { year, month: m } = parseMonth(month);
|
||||||
|
const date = new Date(year, m + delta, 1);
|
||||||
|
return formatMonth(date.getFullYear(), date.getMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceCalendar({
|
||||||
|
records,
|
||||||
|
month,
|
||||||
|
onMonthChange,
|
||||||
|
}: AttendanceCalendarProps) {
|
||||||
|
const {
|
||||||
|
year,
|
||||||
|
month: monthLabel,
|
||||||
|
days,
|
||||||
|
recordMap,
|
||||||
|
} = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = now.getFullYear();
|
const target = month
|
||||||
const m = now.getMonth();
|
? parseMonth(month)
|
||||||
|
: { year: now.getFullYear(), month: now.getMonth() };
|
||||||
|
const y = target.year;
|
||||||
|
const m = target.month;
|
||||||
const firstDay = new Date(y, m, 1);
|
const firstDay = new Date(y, m, 1);
|
||||||
const lastDay = new Date(y, m + 1, 0);
|
const lastDay = new Date(y, m + 1, 0);
|
||||||
const startWeekday = firstDay.getDay();
|
const startWeekday = firstDay.getDay();
|
||||||
@@ -50,7 +85,7 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { year: y, month: m, days: dayCells, recordMap: map };
|
return { year: y, month: m, days: dayCells, recordMap: map };
|
||||||
}, [records]);
|
}, [records, month]);
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -64,9 +99,35 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded border border-rule bg-paper-elevated p-4">
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onMonthChange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onMonthChange(shiftMonth(formatMonth(year, monthLabel), -1))
|
||||||
|
}
|
||||||
|
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||||||
|
aria-label="上一月"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<h3 className="font-serif text-base">
|
<h3 className="font-serif text-base">
|
||||||
{year}年{month + 1}月考勤
|
{year}年{monthLabel + 1}月考勤
|
||||||
</h3>
|
</h3>
|
||||||
|
{onMonthChange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onMonthChange(shiftMonth(formatMonth(year, monthLabel), 1))
|
||||||
|
}
|
||||||
|
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||||||
|
aria-label="下一月"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-3 text-xs">
|
<div className="flex gap-3 text-xs">
|
||||||
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
|
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
|
||||||
(s) => (
|
(s) => (
|
||||||
@@ -96,14 +157,15 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
|
|||||||
<div className="mt-1 grid grid-cols-7 gap-1">
|
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||||
{days.map((day, idx) => {
|
{days.map((day, idx) => {
|
||||||
if (day === null) {
|
if (day === null) {
|
||||||
return <div key={idx} className="aspect-square" />;
|
return <div key={idx} style={{ aspectRatio: "1 / 1" }} />;
|
||||||
}
|
}
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
const dateStr = `${year}-${String(monthLabel + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
const record = recordMap.get(dateStr);
|
const record = recordMap.get(dateStr);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="flex aspect-square flex-col items-center justify-center rounded text-sm"
|
className="flex flex-col items-center justify-center rounded text-sm"
|
||||||
|
style={{ aspectRatio: "1 / 1" }}
|
||||||
title={record ? STATUS_LABELS[record.status] : undefined}
|
title={record ? STATUS_LABELS[record.status] : undefined}
|
||||||
>
|
>
|
||||||
<span className="text-xs">{day}</span>
|
<span className="text-xs">{day}</span>
|
||||||
|
|||||||
152
apps/parent-portal/src/components/AttendanceRateCard.test.tsx
Normal file
152
apps/parent-portal/src/components/AttendanceRateCard.test.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// AttendanceRateCard 组件单测
|
||||||
|
// 依据:参考项目差距补齐 - 考勤聚合统计
|
||||||
|
// 覆盖:四指标渲染 / 出勤率颜色分级 / 空数据 / 仅缺勤 / 仅迟到
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { AttendanceRateCard } from "./AttendanceRateCard";
|
||||||
|
import type { AttendanceRecord } from "@/types";
|
||||||
|
|
||||||
|
function makeRecords(
|
||||||
|
overrides: Partial<AttendanceRecord>[] = [],
|
||||||
|
): AttendanceRecord[] {
|
||||||
|
const records: AttendanceRecord[] = [];
|
||||||
|
for (let d = 1; d <= 10; d++) {
|
||||||
|
records.push({
|
||||||
|
id: `att-${d}`,
|
||||||
|
date: `2026-06-${String(d).padStart(2, "0")}`,
|
||||||
|
status: "present",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const o of overrides) {
|
||||||
|
records.push(o as AttendanceRecord);
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AttendanceRateCard 四指标渲染", () => {
|
||||||
|
it("渲染总出勤率 / 出勤天数 / 缺勤天数 / 迟到天数 标签", () => {
|
||||||
|
render(<AttendanceRateCard records={makeRecords()} />);
|
||||||
|
expect(screen.getByText("总出勤率")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("出勤天数")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("缺勤天数")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("迟到天数")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("全勤场景渲染正确的出勤天数(10)", () => {
|
||||||
|
render(<AttendanceRateCard records={makeRecords()} />);
|
||||||
|
// 10 天出勤 → 出勤天数 10
|
||||||
|
expect(screen.getByText("10")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染总出勤率百分比", () => {
|
||||||
|
render(<AttendanceRateCard records={makeRecords()} />);
|
||||||
|
// 10 天全勤 → 100%
|
||||||
|
expect(screen.getByText(/100/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceRateCard 出勤率颜色分级", () => {
|
||||||
|
it("出勤率≥95% 总出勤率显示成功色(text-success)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceRateCard records={makeRecords()} />,
|
||||||
|
);
|
||||||
|
// 100% ≥ 95%,text-success 应出现在总出勤率数值上
|
||||||
|
const successEls = container.querySelectorAll(".text-success");
|
||||||
|
expect(successEls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("出勤率 90-95% 总出勤率显示警告色(text-warning)", () => {
|
||||||
|
// 10 条记录,1 条缺勤 → rate = 9/10 = 90%(边界,落入 warning 区间)
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "present" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "present" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "present" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "present" },
|
||||||
|
{ id: "6", date: "2026-06-06", status: "present" },
|
||||||
|
{ id: "7", date: "2026-06-07", status: "present" },
|
||||||
|
{ id: "8", date: "2026-06-08", status: "present" },
|
||||||
|
{ id: "9", date: "2026-06-09", status: "present" },
|
||||||
|
{ id: "10", date: "2026-06-10", status: "absent" },
|
||||||
|
];
|
||||||
|
const { container } = render(<AttendanceRateCard records={records} />);
|
||||||
|
// rate=90% 落入 [0.9, 0.95) 区间 → text-warning
|
||||||
|
const warningEls = container.querySelectorAll(".text-warning");
|
||||||
|
expect(warningEls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("出勤率<90% 总出勤率显示危险色(text-danger)", () => {
|
||||||
|
// 5 条记录,2 条缺勤 → rate = 3/5 = 60% < 90%
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "present" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "present" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "absent" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "absent" },
|
||||||
|
];
|
||||||
|
const { container } = render(<AttendanceRateCard records={records} />);
|
||||||
|
// rate=60% < 90% → text-danger
|
||||||
|
const dangerEls = container.querySelectorAll(".text-danger");
|
||||||
|
expect(dangerEls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceRateCard 空数据", () => {
|
||||||
|
it("空记录数组时总出勤率显示 100%(默认值)", () => {
|
||||||
|
render(<AttendanceRateCard records={[]} />);
|
||||||
|
// 空数组时 rate=1 → 100%
|
||||||
|
expect(screen.getByText(/100/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空记录数组时所有天数为 0", () => {
|
||||||
|
render(<AttendanceRateCard records={[]} />);
|
||||||
|
// 出勤/缺勤/迟到 天数均为 0
|
||||||
|
const zeros = screen.getAllByText("0");
|
||||||
|
expect(zeros.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceRateCard 状态统计", () => {
|
||||||
|
it("统计缺勤天数", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "absent" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "absent" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "present" },
|
||||||
|
];
|
||||||
|
render(<AttendanceRateCard records={records} />);
|
||||||
|
// 缺勤 2 天(用 text-danger 选择器区分出勤天数 2)
|
||||||
|
expect(screen.getByText("2", { selector: ".text-danger" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("统计迟到天数", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "late" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "late" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "late" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "present" },
|
||||||
|
];
|
||||||
|
render(<AttendanceRateCard records={records} />);
|
||||||
|
// 迟到 3 天
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("请假不计入出勤率(present + late 计入,absent + leave 不计入)", () => {
|
||||||
|
// 4 条记录:2 present + 1 late + 1 leave
|
||||||
|
// attended = present(2) + late(1) = 3,total = 4,rate = 3/4 = 75%
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "present" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "late" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "leave" },
|
||||||
|
];
|
||||||
|
const { container } = render(<AttendanceRateCard records={records} />);
|
||||||
|
// rate=75% < 90% → 总出勤率使用 text-danger
|
||||||
|
const dangerEls = container.querySelectorAll(".text-danger");
|
||||||
|
expect(dangerEls.length).toBeGreaterThan(0);
|
||||||
|
// 验证出勤天数 = 2(present)
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
apps/parent-portal/src/components/AttendanceRateCard.tsx
Normal file
68
apps/parent-portal/src/components/AttendanceRateCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// AttendanceRateCard:聚合出勤率卡片
|
||||||
|
// 依据:参考项目差距补齐 - 考勤聚合统计
|
||||||
|
// - 展示:总出勤率 / 出勤天数 / 缺勤天数 / 迟到天数
|
||||||
|
// - 出勤率颜色:≥95% 绿色 / 90-95% 黄色 / <90% 红色
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { AttendanceRecord } from "@/types";
|
||||||
|
import { cn, formatPercent, formatNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AttendanceRateCardProps {
|
||||||
|
records: AttendanceRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceRateCard({
|
||||||
|
records,
|
||||||
|
}: AttendanceRateCardProps): JSX.Element {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
|
||||||
|
for (const r of records) {
|
||||||
|
counts[r.status]++;
|
||||||
|
}
|
||||||
|
const total = records.length;
|
||||||
|
const attended = counts.present + counts.late;
|
||||||
|
const rate = total > 0 ? attended / total : 1;
|
||||||
|
return { ...counts, total, rate };
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const rateColor = cn(
|
||||||
|
stats.rate >= 0.95 && "text-success",
|
||||||
|
stats.rate >= 0.9 && stats.rate < 0.95 && "text-warning",
|
||||||
|
stats.rate < 0.9 && "text-danger",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-ink-muted">总出勤率</p>
|
||||||
|
<p className={cn("mt-1 font-serif text-2xl", rateColor)}>
|
||||||
|
{formatPercent(stats.rate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-ink-muted">出勤天数</p>
|
||||||
|
<p className="mt-1 font-serif text-2xl text-success">
|
||||||
|
{formatNumber(stats.present)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-ink-muted">缺勤天数</p>
|
||||||
|
<p className="mt-1 font-serif text-2xl text-danger">
|
||||||
|
{formatNumber(stats.absent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-ink-muted">迟到天数</p>
|
||||||
|
<p className="mt-1 font-serif text-2xl text-warning">
|
||||||
|
{formatNumber(stats.late)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttendanceRateCard;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// AttendanceWarningBanner 组件单测
|
||||||
|
// 依据:参考项目差距补齐 - 考勤异常预警
|
||||||
|
// 覆盖:无预警不渲染 / 缺勤高危 / 迟到中危 / 出勤率低高危 / 关闭
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { AttendanceWarningBanner } from "./AttendanceWarningBanner";
|
||||||
|
import type { AttendanceRecord } from "@/types";
|
||||||
|
|
||||||
|
function makeRecords(
|
||||||
|
overrides: Partial<AttendanceRecord>[] = [],
|
||||||
|
): AttendanceRecord[] {
|
||||||
|
const records: AttendanceRecord[] = [];
|
||||||
|
for (let d = 1; d <= 10; d++) {
|
||||||
|
records.push({
|
||||||
|
id: `att-${d}`,
|
||||||
|
date: `2026-06-${String(d).padStart(2, "0")}`,
|
||||||
|
status: "present",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const o of overrides) {
|
||||||
|
records.push(o as AttendanceRecord);
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AttendanceWarningBanner 无预警", () => {
|
||||||
|
it("所有指标正常时不渲染", () => {
|
||||||
|
const records = makeRecords();
|
||||||
|
const { container } = render(<AttendanceWarningBanner records={records} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空记录不渲染", () => {
|
||||||
|
const { container } = render(<AttendanceWarningBanner records={[]} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceWarningBanner 缺勤预警", () => {
|
||||||
|
it("缺勤≥3 时显示高危预警", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "absent" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "absent" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "absent" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "present" },
|
||||||
|
];
|
||||||
|
render(<AttendanceWarningBanner records={records} />);
|
||||||
|
expect(screen.getByText(/缺勤 3 次/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("考勤异常预警")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺勤<3 时不显示缺勤预警", () => {
|
||||||
|
// 9 present + 1 absent = 10 条,rate = 9/10 = 90% ≥ 90% 不触发出勤率预警
|
||||||
|
// absent=1 < 3 不触发缺勤预警,late=0 < 3 不触发迟到预警
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "present" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "present" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "present" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "present" },
|
||||||
|
{ id: "6", date: "2026-06-06", status: "present" },
|
||||||
|
{ id: "7", date: "2026-06-07", status: "present" },
|
||||||
|
{ id: "8", date: "2026-06-08", status: "present" },
|
||||||
|
{ id: "9", date: "2026-06-09", status: "present" },
|
||||||
|
{ id: "10", date: "2026-06-10", status: "absent" },
|
||||||
|
];
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceWarningBanner records={records} />,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceWarningBanner 迟到预警", () => {
|
||||||
|
it("迟到≥3 时显示中危预警", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "late" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "late" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "late" },
|
||||||
|
{ id: "5", date: "2026-06-05", status: "present" },
|
||||||
|
{ id: "6", date: "2026-06-06", status: "present" },
|
||||||
|
{ id: "7", date: "2026-06-07", status: "present" },
|
||||||
|
];
|
||||||
|
render(<AttendanceWarningBanner records={records} />);
|
||||||
|
expect(screen.getByText(/迟到 3 次/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("考勤提醒")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceWarningBanner 出勤率低预警", () => {
|
||||||
|
it("出勤率<90% 时显示高危预警", () => {
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "present" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "absent" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "absent" },
|
||||||
|
];
|
||||||
|
render(<AttendanceWarningBanner records={records} />);
|
||||||
|
// rate = 2/4 = 50% < 90%
|
||||||
|
expect(screen.getByText(/出勤率偏低/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/50/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceWarningBanner 关闭功能", () => {
|
||||||
|
it("点击关闭按钮后横幅消失", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const records: AttendanceRecord[] = [
|
||||||
|
{ id: "1", date: "2026-06-01", status: "present" },
|
||||||
|
{ id: "2", date: "2026-06-02", status: "absent" },
|
||||||
|
{ id: "3", date: "2026-06-03", status: "absent" },
|
||||||
|
{ id: "4", date: "2026-06-04", status: "absent" },
|
||||||
|
];
|
||||||
|
render(<AttendanceWarningBanner records={records} />);
|
||||||
|
expect(screen.getByText(/缺勤/)).toBeInTheDocument();
|
||||||
|
const closeBtn = screen.getByRole("button", { name: "关闭预警" });
|
||||||
|
await user.click(closeBtn);
|
||||||
|
expect(screen.queryByText(/缺勤/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/parent-portal/src/components/AttendanceWarningBanner.tsx
Normal file
128
apps/parent-portal/src/components/AttendanceWarningBanner.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// AttendanceWarningBanner:考勤异常预警横幅
|
||||||
|
// 依据:参考项目差距补齐 - 考勤异常预警
|
||||||
|
// - 缺勤≥3 高危(红色)
|
||||||
|
// - 迟到≥3 中危(黄色)
|
||||||
|
// - 出勤率<90% 高危(红色)
|
||||||
|
// - 可关闭(本地 state,不持久化)
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import type { AttendanceRecord } from "@/types";
|
||||||
|
import { cn, formatPercent } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AttendanceWarningBannerProps {
|
||||||
|
records: AttendanceRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttendanceStats {
|
||||||
|
present: number;
|
||||||
|
late: number;
|
||||||
|
absent: number;
|
||||||
|
leave: number;
|
||||||
|
total: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateStats(records: AttendanceRecord[]): AttendanceStats {
|
||||||
|
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
|
||||||
|
for (const r of records) {
|
||||||
|
counts[r.status]++;
|
||||||
|
}
|
||||||
|
const total = records.length;
|
||||||
|
const attended = counts.present + counts.late;
|
||||||
|
const rate = total > 0 ? attended / total : 1;
|
||||||
|
return { ...counts, total, rate };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningItem {
|
||||||
|
message: string;
|
||||||
|
level: "warning" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceWarningBanner({
|
||||||
|
records,
|
||||||
|
}: AttendanceWarningBannerProps): JSX.Element | null {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
const warnings = useMemo<WarningItem[]>(() => {
|
||||||
|
const stats = calculateStats(records);
|
||||||
|
const items: WarningItem[] = [];
|
||||||
|
|
||||||
|
if (stats.absent >= 3) {
|
||||||
|
items.push({
|
||||||
|
message: `本月缺勤 ${stats.absent} 次,超过预警阈值(3 次)`,
|
||||||
|
level: "danger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.late >= 3) {
|
||||||
|
items.push({
|
||||||
|
message: `本月迟到 ${stats.late} 次,超过预警阈值(3 次)`,
|
||||||
|
level: "warning",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.total > 0 && stats.rate < 0.9) {
|
||||||
|
items.push({
|
||||||
|
message: `本月出勤率偏低(${formatPercent(stats.rate)}),低于 90%`,
|
||||||
|
level: "danger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
if (dismissed || warnings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDanger = warnings.some((w) => w.level === "danger");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"rounded border p-4",
|
||||||
|
hasDanger ? "border-danger" : "border-warning",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: hasDanger
|
||||||
|
? "hsl(var(--danger) / 0.1)"
|
||||||
|
: "hsl(var(--warning) / 0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="font-serif text-base">
|
||||||
|
{hasDanger ? "考勤异常预警" : "考勤提醒"}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{warnings.map((w, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
w.level === "danger" ? "text-danger" : "text-warning",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="flex-shrink-0 rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||||||
|
aria-label="关闭预警"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttendanceWarningBanner;
|
||||||
|
|
||||||
|
export { calculateStats };
|
||||||
416
apps/parent-portal/src/components/ChildDetailPanel.test.tsx
Normal file
416
apps/parent-portal/src/components/ChildDetailPanel.test.tsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
// ChildDetailPanel 组件单测
|
||||||
|
// 依据:02-architecture-design.md §15 组件设计
|
||||||
|
// 覆盖:Tab 导航 / Tab 切换 / overview / homework / grades / exams / schedule 五个视图
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ChildDetailPanel } from "./ChildDetailPanel";
|
||||||
|
import type { ChildDetail } from "@/types";
|
||||||
|
|
||||||
|
// mock next/link 为普通 <a>,便于断言
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockChildDetail: ChildDetail = {
|
||||||
|
childId: "student-001",
|
||||||
|
basicInfo: {
|
||||||
|
name: "张小明",
|
||||||
|
avatar: "https://example.com/avatar.png",
|
||||||
|
grade: "grade.7",
|
||||||
|
className: "初一(1)班",
|
||||||
|
schoolName: "实验中学",
|
||||||
|
relation: "父亲",
|
||||||
|
},
|
||||||
|
todaySchedule: [
|
||||||
|
{
|
||||||
|
id: "sch-today-1",
|
||||||
|
dayOfWeek: 1,
|
||||||
|
period: 1,
|
||||||
|
subject: "数学",
|
||||||
|
teacherName: "王老师",
|
||||||
|
classroom: "初一(1)班教室",
|
||||||
|
startTime: "08:00",
|
||||||
|
endTime: "08:45",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sch-today-2",
|
||||||
|
dayOfWeek: 1,
|
||||||
|
period: 2,
|
||||||
|
subject: "语文",
|
||||||
|
teacherName: "李老师",
|
||||||
|
classroom: "初一(1)班教室",
|
||||||
|
startTime: "08:55",
|
||||||
|
endTime: "09:40",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
weeklySchedule: [
|
||||||
|
{
|
||||||
|
id: "sch-w-1",
|
||||||
|
dayOfWeek: 1,
|
||||||
|
period: 1,
|
||||||
|
subject: "数学",
|
||||||
|
teacherName: "王老师",
|
||||||
|
classroom: "初一(1)班教室",
|
||||||
|
startTime: "08:00",
|
||||||
|
endTime: "08:45",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sch-w-2",
|
||||||
|
dayOfWeek: 2,
|
||||||
|
period: 1,
|
||||||
|
subject: "英语",
|
||||||
|
teacherName: "张老师",
|
||||||
|
classroom: "初一(1)班教室",
|
||||||
|
startTime: "08:00",
|
||||||
|
endTime: "08:45",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
homeworkSummary: {
|
||||||
|
pendingCount: 3,
|
||||||
|
overdueCount: 1,
|
||||||
|
submittedCount: 5,
|
||||||
|
gradedCount: 8,
|
||||||
|
},
|
||||||
|
gradeSummary: {
|
||||||
|
avgScore: 90,
|
||||||
|
classRank: 5,
|
||||||
|
classSize: 45,
|
||||||
|
trend: "up",
|
||||||
|
},
|
||||||
|
examResults: {
|
||||||
|
upcoming: 2,
|
||||||
|
completed: 4,
|
||||||
|
avgScore: 88,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ChildDetailPanel Tab 导航", () => {
|
||||||
|
it("渲染 5 个 Tab(概览/作业/成绩/考试/课表)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tablist", { name: "子女详情视图" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "概览" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "作业" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "成绩" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "考试" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "课表" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activeTab=overview 时概览 tab 选中", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tab", { name: "概览" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("tab", { name: "作业" })).toHaveAttribute("aria-selected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点击 Tab 调用 onTabChange", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTabChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("tab", { name: "作业" }));
|
||||||
|
expect(onTabChange).toHaveBeenCalledWith("homework");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点击成绩 Tab 调用 onTabChange 传 grades", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTabChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("tab", { name: "成绩" }));
|
||||||
|
expect(onTabChange).toHaveBeenCalledWith("grades");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel overview Tab", () => {
|
||||||
|
it("渲染三个 Section(作业摘要/成绩摘要/今日课表)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("作业摘要")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("今日课表")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染作业摘要数据(待完成/已逾期/已提交/已评分)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("待完成")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已逾期")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已提交")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已评分")).toBeInTheDocument();
|
||||||
|
// 验证数值(3/1/5/8)
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("5")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("8")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染成绩摘要数据(平均分/班级排名/趋势)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("平均分")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("班级排名")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("趋势")).toBeInTheDocument();
|
||||||
|
// 趋势 up → "↑ 上升"
|
||||||
|
expect(screen.getByText("↑ 上升")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染今日课表数据", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("数学")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("语文")).toBeInTheDocument();
|
||||||
|
// 教师姓名与教室渲染在同一 span(如 "王老师 · 初一(1)班教室"),用 regex 验证
|
||||||
|
expect(screen.getByText(/王老师/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/李老师/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel homework Tab", () => {
|
||||||
|
it("渲染作业摘要 Section", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="homework"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("作业摘要")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染查看全部作业链接", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="homework"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole("link", { name: /查看全部作业/ });
|
||||||
|
expect(link).toHaveAttribute("href", "/parent/homework");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel grades Tab", () => {
|
||||||
|
it("渲染成绩摘要 Section", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="grades"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染查看全部成绩链接", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="grades"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole("link", { name: /查看全部成绩/ });
|
||||||
|
expect(link).toHaveAttribute("href", "/parent/grades");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel exams Tab", () => {
|
||||||
|
it("渲染考试摘要 Section", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="exams"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("考试摘要")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染考试统计数据(待考/已完成/平均分)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="exams"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("待考")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已完成")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("平均分")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染查看全部考试链接", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="exams"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole("link", { name: /查看全部考试/ });
|
||||||
|
expect(link).toHaveAttribute("href", "/parent/exams");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel schedule Tab", () => {
|
||||||
|
it("渲染今日课表 Section", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="schedule"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/今日课表/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染周课表 Section", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="schedule"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("周课表")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染周课表分组(周一/周二)", () => {
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={mockChildDetail}
|
||||||
|
activeTab="schedule"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 周课表含 dayOfWeek 1(周一)和 2(周二)
|
||||||
|
expect(screen.getByText("周一")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("周二")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel 空态", () => {
|
||||||
|
it("今日课表为空时显示今日无课程", () => {
|
||||||
|
const emptySchedule: ChildDetail = {
|
||||||
|
...mockChildDetail,
|
||||||
|
todaySchedule: [],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={emptySchedule}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("今日无课程")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("周课表为空时显示暂无周课表", () => {
|
||||||
|
const emptyWeekly: ChildDetail = {
|
||||||
|
...mockChildDetail,
|
||||||
|
weeklySchedule: [],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={emptyWeekly}
|
||||||
|
activeTab="schedule"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("暂无周课表")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildDetailPanel 趋势显示", () => {
|
||||||
|
it("gradeSummary.trend=down 时显示 ↓ 下降", () => {
|
||||||
|
const downTrend: ChildDetail = {
|
||||||
|
...mockChildDetail,
|
||||||
|
gradeSummary: {
|
||||||
|
...mockChildDetail.gradeSummary,
|
||||||
|
trend: "down",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={downTrend}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("↓ 下降")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gradeSummary.trend=stable 时显示 → 平稳", () => {
|
||||||
|
const stableTrend: ChildDetail = {
|
||||||
|
...mockChildDetail,
|
||||||
|
gradeSummary: {
|
||||||
|
...mockChildDetail.gradeSummary,
|
||||||
|
trend: "stable",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ChildDetailPanel
|
||||||
|
childDetail={stableTrend}
|
||||||
|
activeTab="overview"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("→ 平稳")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
324
apps/parent-portal/src/components/ChildDetailPanel.tsx
Normal file
324
apps/parent-portal/src/components/ChildDetailPanel.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
// ChildDetailPanel:子女详情多 Tab 面板
|
||||||
|
// 依据:02-architecture-design.md §15 组件设计
|
||||||
|
// - Tab:overview / homework / grades / exams / schedule
|
||||||
|
// - 受控组件:由父级(页面)管理 activeTab 与 URL 状态
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ChildDetail, ScheduleItem } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type DetailTab = "overview" | "homework" | "grades" | "exams" | "schedule";
|
||||||
|
|
||||||
|
const TABS: { value: DetailTab; label: string }[] = [
|
||||||
|
{ value: "overview", label: "概览" },
|
||||||
|
{ value: "homework", label: "作业" },
|
||||||
|
{ value: "grades", label: "成绩" },
|
||||||
|
{ value: "exams", label: "考试" },
|
||||||
|
{ value: "schedule", label: "课表" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||||
|
|
||||||
|
interface ChildDetailPanelProps {
|
||||||
|
childDetail: ChildDetail;
|
||||||
|
activeTab: DetailTab;
|
||||||
|
onTabChange: (tab: DetailTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChildDetailPanel({
|
||||||
|
childDetail,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
}: ChildDetailPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tab 导航 */}
|
||||||
|
<div
|
||||||
|
className="flex gap-1 border-b border-rule"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="子女详情视图"
|
||||||
|
>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === t.value}
|
||||||
|
onClick={() => onTabChange(t.value)}
|
||||||
|
className={cn(
|
||||||
|
"border-b-2 px-4 py-2 text-sm transition-colors",
|
||||||
|
activeTab === t.value
|
||||||
|
? "border-accent font-medium"
|
||||||
|
: "border-transparent text-ink-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<div role="tabpanel">
|
||||||
|
{activeTab === "overview" && <OverviewTab childDetail={childDetail} />}
|
||||||
|
{activeTab === "homework" && <HomeworkTab childDetail={childDetail} />}
|
||||||
|
{activeTab === "grades" && <GradesTab childDetail={childDetail} />}
|
||||||
|
{activeTab === "exams" && <ExamsTab childDetail={childDetail} />}
|
||||||
|
{activeTab === "schedule" && <ScheduleTab childDetail={childDetail} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
|
<h3 className="font-serif text-base">{title}</h3>
|
||||||
|
<div className="mt-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ childDetail }: { childDetail: ChildDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<SectionCard title="作业摘要">
|
||||||
|
<HomeworkSummary detail={childDetail} />
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="成绩摘要">
|
||||||
|
<GradeSummary detail={childDetail} />
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="今日课表">
|
||||||
|
<ScheduleList schedule={childDetail.todaySchedule} />
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeworkTab({ childDetail }: { childDetail: ChildDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionCard title="作业摘要">
|
||||||
|
<HomeworkSummary detail={childDetail} />
|
||||||
|
</SectionCard>
|
||||||
|
<Link
|
||||||
|
href="/parent/homework"
|
||||||
|
className="inline-block text-sm font-medium text-accent hover:underline"
|
||||||
|
>
|
||||||
|
查看全部作业 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradesTab({ childDetail }: { childDetail: ChildDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionCard title="成绩摘要">
|
||||||
|
<GradeSummary detail={childDetail} />
|
||||||
|
</SectionCard>
|
||||||
|
<Link
|
||||||
|
href="/parent/grades"
|
||||||
|
className="inline-block text-sm font-medium text-accent hover:underline"
|
||||||
|
>
|
||||||
|
查看全部成绩 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExamsTab({ childDetail }: { childDetail: ChildDetail }) {
|
||||||
|
const { examResults } = childDetail;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionCard title="考试摘要">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xl">{examResults.upcoming}</div>
|
||||||
|
<div className="text-xs text-ink-muted">待考</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xl">{examResults.completed}</div>
|
||||||
|
<div className="text-xs text-ink-muted">已完成</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xl">{examResults.avgScore}</div>
|
||||||
|
<div className="text-xs text-ink-muted">平均分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<Link
|
||||||
|
href="/parent/exams"
|
||||||
|
className="inline-block text-sm font-medium text-accent hover:underline"
|
||||||
|
>
|
||||||
|
查看全部考试 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleTab({ childDetail }: { childDetail: ChildDetail }) {
|
||||||
|
const today = new Date().getDay();
|
||||||
|
const todayLabel = DAY_LABELS[today] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionCard title={`今日课表(${todayLabel})`}>
|
||||||
|
<ScheduleList schedule={childDetail.todaySchedule} />
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="周课表">
|
||||||
|
<WeeklySchedule weeklySchedule={childDetail.weeklySchedule} />
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeworkSummary({ detail }: { detail: ChildDetail }) {
|
||||||
|
const { homeworkSummary } = detail;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">{homeworkSummary.pendingCount}</div>
|
||||||
|
<div className="text-xs text-ink-muted">待完成</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">{homeworkSummary.overdueCount}</div>
|
||||||
|
<div className="text-xs text-ink-muted">已逾期</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">{homeworkSummary.submittedCount}</div>
|
||||||
|
<div className="text-xs text-ink-muted">已提交</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">{homeworkSummary.gradedCount}</div>
|
||||||
|
<div className="text-xs text-ink-muted">已评分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradeSummary({ detail }: { detail: ChildDetail }) {
|
||||||
|
const { gradeSummary } = detail;
|
||||||
|
const trendLabel =
|
||||||
|
gradeSummary.trend === "up"
|
||||||
|
? "↑ 上升"
|
||||||
|
: gradeSummary.trend === "down"
|
||||||
|
? "↓ 下降"
|
||||||
|
: "→ 平稳";
|
||||||
|
const trendTone =
|
||||||
|
gradeSummary.trend === "up"
|
||||||
|
? "text-success"
|
||||||
|
: gradeSummary.trend === "down"
|
||||||
|
? "text-danger"
|
||||||
|
: "text-ink-muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">{gradeSummary.avgScore}</div>
|
||||||
|
<div className="text-xs text-ink-muted">平均分</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-lg">
|
||||||
|
{gradeSummary.classRank}/{gradeSummary.classSize}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">班级排名</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={cn("font-mono text-lg", trendTone)}>{trendLabel}</div>
|
||||||
|
<div className="text-xs text-ink-muted">趋势</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleList({ schedule }: { schedule: ScheduleItem[] }) {
|
||||||
|
if (schedule.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-ink-muted">今日无课程</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{schedule.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between border-b border-rule pb-2 last:border-b-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{item.subject}</span>
|
||||||
|
<span className="ml-2 text-xs text-ink-muted">
|
||||||
|
{item.teacherName} · {item.classroom}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-ink-muted">
|
||||||
|
第 {item.period} 节 · {item.startTime}-{item.endTime}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeeklySchedule({
|
||||||
|
weeklySchedule,
|
||||||
|
}: {
|
||||||
|
weeklySchedule: ScheduleItem[];
|
||||||
|
}) {
|
||||||
|
// 按星期分组:1-5(周一到周五)
|
||||||
|
const grouped = new Map<number, ScheduleItem[]>();
|
||||||
|
for (const item of weeklySchedule) {
|
||||||
|
const list = grouped.get(item.dayOfWeek) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
grouped.set(item.dayOfWeek, list);
|
||||||
|
}
|
||||||
|
const days = Array.from(grouped.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
return <p className="text-sm text-ink-muted">暂无周课表</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{days.map((day) => {
|
||||||
|
const items = grouped.get(day) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={day}>
|
||||||
|
<h4 className="text-xs font-medium text-ink-muted">
|
||||||
|
{DAY_LABELS[day] ?? ""}
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-1 space-y-1 text-sm">
|
||||||
|
{items
|
||||||
|
.sort((a, b) => a.period - b.period)
|
||||||
|
.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{item.subject}</span>
|
||||||
|
<span className="ml-2 text-xs text-ink-muted">
|
||||||
|
{item.teacherName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-ink-muted">
|
||||||
|
第 {item.period} 节 · {item.startTime}-{item.endTime}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChildDetailPanel;
|
||||||
155
apps/parent-portal/src/components/ChildGradeChart.test.tsx
Normal file
155
apps/parent-portal/src/components/ChildGradeChart.test.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// ChildGradeChart 组件单测(mock recharts)
|
||||||
|
// 依据:02-architecture-design.md §15.6 成绩图表设计
|
||||||
|
// 覆盖:渲染图表标题 / 数据映射 / 班级均对比线(虚线)/ classAverage 缺失时默认 0
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { GradeDataPoint } from "@/types";
|
||||||
|
|
||||||
|
// mock recharts:渲染简单 div 包裹子元素,便于断言
|
||||||
|
vi.mock("recharts", () => ({
|
||||||
|
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="responsive-container">{children}</div>
|
||||||
|
),
|
||||||
|
ComposedChart: ({
|
||||||
|
children,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<div data-testid="composed-chart" data-length={data.length}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Bar: ({ dataKey, fill }: { dataKey: string; fill: string }) => (
|
||||||
|
<div data-testid="bar" data-key={dataKey} data-fill={fill} />
|
||||||
|
),
|
||||||
|
Line: ({
|
||||||
|
dataKey,
|
||||||
|
stroke,
|
||||||
|
strokeDasharray,
|
||||||
|
}: {
|
||||||
|
dataKey: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeDasharray?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="line"
|
||||||
|
data-key={dataKey}
|
||||||
|
data-stroke={stroke}
|
||||||
|
data-dash={strokeDasharray}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
XAxis: (_props: unknown) => <div data-testid="x-axis" />,
|
||||||
|
YAxis: (_props: unknown) => <div data-testid="y-axis" />,
|
||||||
|
CartesianGrid: (_props: unknown) => <div data-testid="cartesian-grid" />,
|
||||||
|
Tooltip: (_props: unknown) => <div data-testid="tooltip" />,
|
||||||
|
Legend: (_props: unknown) => <div data-testid="legend" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChildGradeChart } from "./ChildGradeChart";
|
||||||
|
|
||||||
|
const mockGrades: GradeDataPoint[] = [
|
||||||
|
{
|
||||||
|
examId: "exam-001",
|
||||||
|
examName: "期中数学",
|
||||||
|
examDate: "2026-03-15T09:00:00Z",
|
||||||
|
subject: "数学",
|
||||||
|
studentScore: 92,
|
||||||
|
classAverage: 78,
|
||||||
|
gradeLevel: "A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
examId: "exam-002",
|
||||||
|
examName: "期中语文",
|
||||||
|
examDate: "2026-03-16T09:00:00Z",
|
||||||
|
subject: "语文",
|
||||||
|
studentScore: 85,
|
||||||
|
classAverage: 75,
|
||||||
|
gradeLevel: "B",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("ChildGradeChart 渲染", () => {
|
||||||
|
it("渲染标题成绩对比", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
expect(screen.getByText("成绩对比")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 ResponsiveContainer", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 ComposedChart 并传入数据", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
const chart = screen.getByTestId("composed-chart");
|
||||||
|
expect(chart).toHaveAttribute("data-length", "2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 Bar(学生分数)和 Line(班级平均)", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
const bars = screen.getAllByTestId("bar");
|
||||||
|
const lines = screen.getAllByTestId("line");
|
||||||
|
expect(bars).toHaveLength(1);
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(bars[0]).toHaveAttribute("data-key", "学生分数");
|
||||||
|
expect(lines[0]).toHaveAttribute("data-key", "班级平均");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("班级平均线为虚线(strokeDasharray=5 5)", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
const line = screen.getByTestId("line");
|
||||||
|
expect(line).toHaveAttribute("data-dash", "5 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染 XAxis / YAxis / CartesianGrid / Tooltip / Legend", () => {
|
||||||
|
render(<ChildGradeChart grades={mockGrades} />);
|
||||||
|
expect(screen.getByTestId("x-axis")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("y-axis")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("cartesian-grid")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("legend")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildGradeChart 数据映射", () => {
|
||||||
|
it("空 grades 数组渲染 0 条数据", () => {
|
||||||
|
render(<ChildGradeChart grades={[]} />);
|
||||||
|
const chart = screen.getByTestId("composed-chart");
|
||||||
|
expect(chart).toHaveAttribute("data-length", "0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classAverage 缺失时默认为 0", () => {
|
||||||
|
const gradesWithoutAvg: GradeDataPoint[] = [
|
||||||
|
{
|
||||||
|
examId: "exam-003",
|
||||||
|
examName: "测验",
|
||||||
|
examDate: "2026-04-01T09:00:00Z",
|
||||||
|
subject: "英语",
|
||||||
|
studentScore: 88,
|
||||||
|
gradeLevel: "B",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<ChildGradeChart grades={gradesWithoutAvg} />);
|
||||||
|
// 仅验证不崩溃且渲染 1 条数据
|
||||||
|
const chart = screen.getByTestId("composed-chart");
|
||||||
|
expect(chart).toHaveAttribute("data-length", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多条成绩数据正确映射", () => {
|
||||||
|
const manyGrades: GradeDataPoint[] = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
examId: `exam-${i}`,
|
||||||
|
examName: `测验${i}`,
|
||||||
|
examDate: "2026-03-15T09:00:00Z",
|
||||||
|
subject: `科目${i}`,
|
||||||
|
studentScore: 80 + i,
|
||||||
|
classAverage: 70,
|
||||||
|
gradeLevel: "B" as const,
|
||||||
|
}));
|
||||||
|
render(<ChildGradeChart grades={manyGrades} />);
|
||||||
|
const chart = screen.getByTestId("composed-chart");
|
||||||
|
expect(chart).toHaveAttribute("data-length", "5");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
// ChildGradeChart:子女成绩图表(recharts)
|
// ChildGradeChart:子女成绩图表(recharts)
|
||||||
// 依据:02-architecture-design.md §15.6 成绩图表设计
|
// 依据:02-architecture-design.md §15.6 成绩图表设计
|
||||||
// - 柱状图:学生分数 vs 班级平均
|
// - 柱状图:学生分数
|
||||||
|
// - 折线图:班级平均(虚线对比)
|
||||||
// - 响应式容器
|
// - 响应式容器
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BarChart,
|
ComposedChart,
|
||||||
Bar,
|
Bar,
|
||||||
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -33,7 +35,7 @@ export function ChildGradeChart({ grades }: ChildGradeChartProps) {
|
|||||||
<h3 className="font-serif text-base">成绩对比</h3>
|
<h3 className="font-serif text-base">成绩对比</h3>
|
||||||
<div className="mt-4 h-72">
|
<div className="mt-4 h-72">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<ComposedChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 8, right: 8, bottom: 8, left: 0 }}
|
margin={{ top: 8, right: 8, bottom: 8, left: 0 }}
|
||||||
>
|
>
|
||||||
@@ -57,12 +59,14 @@ export function ChildGradeChart({ grades }: ChildGradeChartProps) {
|
|||||||
fill="hsl(var(--accent))"
|
fill="hsl(var(--accent))"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Line
|
||||||
dataKey="班级平均"
|
dataKey="班级平均"
|
||||||
fill="hsl(var(--ink-subtle))"
|
stroke="hsl(var(--warning))"
|
||||||
radius={[4, 4, 0, 0]}
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={{ r: 3, fill: "hsl(var(--warning))" }}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
280
apps/parent-portal/src/components/ChildSummaryCard.test.tsx
Normal file
280
apps/parent-portal/src/components/ChildSummaryCard.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// ChildSummaryCard 组件单测
|
||||||
|
// 依据:02-architecture-design.md §15.4 ChildSummaryCard 设计
|
||||||
|
// 覆盖:指标网格 / 成绩趋势 / 即将到来事件 / 趋势标签 fallback
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ChildSummaryCard } from "./ChildSummaryCard";
|
||||||
|
import type { ChildSummary } from "@/types";
|
||||||
|
|
||||||
|
// mock next/link 为普通 <a>
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeSummary(overrides: Partial<ChildSummary> = {}): ChildSummary {
|
||||||
|
return {
|
||||||
|
childId: "student-001",
|
||||||
|
avgScore: 90,
|
||||||
|
classRank: 5,
|
||||||
|
classSize: 45,
|
||||||
|
attendanceRate: 0.96,
|
||||||
|
pendingHomeworkCount: 3,
|
||||||
|
recentGradeTrend: "up",
|
||||||
|
recentScores: [
|
||||||
|
{
|
||||||
|
examId: "exam-001",
|
||||||
|
examName: "期中数学",
|
||||||
|
examDate: "2026-03-15T09:00:00Z",
|
||||||
|
subject: "数学",
|
||||||
|
studentScore: 92,
|
||||||
|
classAverage: 78,
|
||||||
|
gradeLevel: "A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
examId: "exam-002",
|
||||||
|
examName: "期中语文",
|
||||||
|
examDate: "2026-03-16T09:00:00Z",
|
||||||
|
subject: "语文",
|
||||||
|
studentScore: 85,
|
||||||
|
gradeLevel: "B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
upcomingEvents: [
|
||||||
|
{
|
||||||
|
id: "event-001",
|
||||||
|
type: "exam",
|
||||||
|
title: "期末数学考试",
|
||||||
|
dueDate: "2026-07-20T09:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 指标网格", () => {
|
||||||
|
it("渲染平均分", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("平均分")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("90")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染班级排名(rank / size)", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("班级排名")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("5 / 45")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染出勤率(百分比)", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("出勤率")).toBeInTheDocument();
|
||||||
|
// 0.96 → 96%
|
||||||
|
expect(screen.getByText(/96/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染待完成作业数", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 3 })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("待完成作业")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("待完成作业 > 0 时指标卡片高亮(border-warning)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 2 })} />,
|
||||||
|
);
|
||||||
|
const metricCards = container.querySelectorAll(".border-warning");
|
||||||
|
expect(metricCards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("待完成作业 = 0 时指标卡片不高亮", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 0 })} />,
|
||||||
|
);
|
||||||
|
const metricCards = container.querySelectorAll(".border-warning");
|
||||||
|
expect(metricCards.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 成绩趋势", () => {
|
||||||
|
it("渲染成绩趋势标题", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("成绩趋势")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend=up 显示上升", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "up" })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("上升")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend=down 显示下降", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "down" })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("下降")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend=stable 显示稳定", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard
|
||||||
|
summary={makeSummary({ recentGradeTrend: "stable" })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("稳定")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend 为未知值时回退到默认稳定(防御性兜底,类型断言仅用于测试)", () => {
|
||||||
|
// 测试中用 as 模拟脏数据触发 DEFAULT_TREND 兜底分支
|
||||||
|
const summary = makeSummary({
|
||||||
|
recentGradeTrend: "unknown" as ChildSummary["recentGradeTrend"],
|
||||||
|
});
|
||||||
|
render(<ChildSummaryCard summary={summary} />);
|
||||||
|
expect(screen.getByText("稳定")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染最近成绩列表(含 subject 与 examName)", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("数学")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("期中数学")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("语文")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染学生分数", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("92")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("85")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有 classAverage 时显示班均", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText(/班均/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/班均.*78/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("最多显示 5 条最近成绩", () => {
|
||||||
|
const sixScores = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
examId: `exam-${i}`,
|
||||||
|
examName: `测验${i}`,
|
||||||
|
examDate: "2026-03-15T09:00:00Z",
|
||||||
|
subject: `科目${i}`,
|
||||||
|
studentScore: 80 + i,
|
||||||
|
gradeLevel: "B" as const,
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ recentScores: sixScores })} />,
|
||||||
|
);
|
||||||
|
// 第 0..4 条显示,第 5 条不显示
|
||||||
|
expect(screen.getByText("科目0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("科目4")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("科目5")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 即将到来事件", () => {
|
||||||
|
it("有 upcomingEvents 时渲染即将到来区域", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
expect(screen.getByText("即将到来")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("期末数学考试")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upcomingEvents 为空数组时不渲染即将到来区域", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary({ upcomingEvents: [] })} />);
|
||||||
|
expect(screen.queryByText("即将到来")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upcomingEvents 为 undefined 时不渲染即将到来区域", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ upcomingEvents: undefined })} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("即将到来")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染多个即将到来事件", () => {
|
||||||
|
const summary = makeSummary({
|
||||||
|
upcomingEvents: [
|
||||||
|
{
|
||||||
|
id: "event-001",
|
||||||
|
type: "exam",
|
||||||
|
title: "期末数学考试",
|
||||||
|
dueDate: "2026-07-20T09:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-002",
|
||||||
|
type: "homework",
|
||||||
|
title: "物理实验报告",
|
||||||
|
dueDate: "2026-07-12T23:59:59Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ChildSummaryCard summary={summary} />);
|
||||||
|
expect(screen.getByText("期末数学考试")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("物理实验报告")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 趋势图标", () => {
|
||||||
|
it("trend=up 显示上升箭头", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "up" })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("↑")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("上升")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend=down 显示下降箭头", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "down" })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("↓")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("下降")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trend=stable 显示稳定箭头", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard
|
||||||
|
summary={makeSummary({ recentGradeTrend: "stable" })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("→")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("稳定")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 逾期作业徽标", () => {
|
||||||
|
it("pendingHomeworkCount > 0 时显示待办徽标", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 3 })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/3 份待办/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pendingHomeworkCount = 0 时不显示待办徽标", () => {
|
||||||
|
render(
|
||||||
|
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 0 })} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/份待办/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChildSummaryCard 跳转链接", () => {
|
||||||
|
it("卡片渲染为链接,指向子女详情页", () => {
|
||||||
|
render(<ChildSummaryCard summary={makeSummary()} />);
|
||||||
|
const link = screen.getByRole("link");
|
||||||
|
expect(link).toHaveAttribute("href", "/parent/children/student-001");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
// ChildSummaryCard:子女概览卡片
|
// ChildSummaryCard:子女概览卡片
|
||||||
// 依据:02-architecture-design.md §15.4 ChildSummaryCard 设计
|
// 依据:02-architecture-design.md §15.4 ChildSummaryCard 设计
|
||||||
// - 平均分 / 班级排名 / 出勤率 / 待完成作业
|
// - 平均分 / 班级排名 / 出勤率 / 待完成作业
|
||||||
// - 成绩趋势指示
|
// - 成绩趋势指示(图标 + 文案)
|
||||||
|
// - 逾期作业高亮徽标
|
||||||
// - 即将到来的事件
|
// - 即将到来的事件
|
||||||
|
// - 点击跳转子女详情
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import type { ChildSummary } from "@/types";
|
import type { ChildSummary } from "@/types";
|
||||||
import { formatPercent, formatNumber, formatDate } from "@/lib/utils";
|
import { formatPercent, formatNumber, formatDate } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,17 +18,23 @@ interface ChildSummaryCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TREND_LABELS = {
|
const TREND_LABELS = {
|
||||||
up: { text: "上升", color: "text-success" },
|
up: { text: "上升", color: "text-success", icon: "↑" },
|
||||||
down: { text: "下降", color: "text-danger" },
|
down: { text: "下降", color: "text-danger", icon: "↓" },
|
||||||
stable: { text: "稳定", color: "text-ink-muted" },
|
stable: { text: "稳定", color: "text-ink-muted", icon: "→" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted" };
|
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted", icon: "→" };
|
||||||
|
|
||||||
export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
|
export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
|
||||||
const trend = TREND_LABELS[summary.recentGradeTrend] ?? DEFAULT_TREND;
|
const trend = TREND_LABELS[summary.recentGradeTrend] ?? DEFAULT_TREND;
|
||||||
|
const hasPending = summary.pendingHomeworkCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/parent/children/${summary.childId}`}
|
||||||
|
className="block focus-visible:outline-2"
|
||||||
|
aria-label={`查看 ${summary.childId} 详情`}
|
||||||
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 指标网格 */}
|
{/* 指标网格 */}
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
@@ -38,7 +47,8 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
|
|||||||
<Metric
|
<Metric
|
||||||
label="待完成作业"
|
label="待完成作业"
|
||||||
value={formatNumber(summary.pendingHomeworkCount)}
|
value={formatNumber(summary.pendingHomeworkCount)}
|
||||||
highlight={summary.pendingHomeworkCount > 0}
|
highlight={hasPending}
|
||||||
|
badge={hasPending ? `${summary.pendingHomeworkCount} 份待办` : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,7 +56,10 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
|
|||||||
<div className="rounded border border-rule bg-paper-elevated p-4">
|
<div className="rounded border border-rule bg-paper-elevated p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-serif text-base">成绩趋势</h3>
|
<h3 className="font-serif text-base">成绩趋势</h3>
|
||||||
<span className={cn("text-sm", trend.color)}>{trend.text}</span>
|
<span className={cn("flex items-center gap-1 text-sm", trend.color)}>
|
||||||
|
<span aria-hidden="true">{trend.icon}</span>
|
||||||
|
{trend.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{summary.recentScores.slice(0, 5).map((score) => (
|
{summary.recentScores.slice(0, 5).map((score) => (
|
||||||
@@ -91,6 +104,7 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,20 +112,27 @@ function Metric({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
highlight,
|
highlight,
|
||||||
|
badge,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
|
badge?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded border border-rule bg-paper-elevated p-4",
|
"relative rounded border border-rule bg-paper-elevated p-4",
|
||||||
highlight && "border-warning",
|
highlight && "border-warning",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-ink-muted">{label}</p>
|
<p className="text-xs text-ink-muted">{label}</p>
|
||||||
<p className="mt-1 font-serif text-2xl">{value}</p>
|
<p className="mt-1 font-serif text-2xl">{value}</p>
|
||||||
|
{badge && (
|
||||||
|
<span className="absolute right-2 top-2 rounded bg-warning px-1.5 py-0.5 text-xs text-paper">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
115
apps/parent-portal/src/components/CoursePlanDetail.test.tsx
Normal file
115
apps/parent-portal/src/components/CoursePlanDetail.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// CoursePlanDetail 组件单测
|
||||||
|
// 依据:02-architecture-design.md §15 组件设计
|
||||||
|
// 覆盖:基本信息 / 章节列表(按 sortOrder 排序)/ 状态徽标 / 章节课时与学时
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { CoursePlanDetail } from "./CoursePlanDetail";
|
||||||
|
import type { CoursePlanDetail as CoursePlanDetailType } from "@/types";
|
||||||
|
|
||||||
|
const mockDetail: CoursePlanDetailType = {
|
||||||
|
id: "cp-001",
|
||||||
|
title: "初一数学下学期课程计划",
|
||||||
|
description: "涵盖一元二次方程、不等式组、函数与图像等核心章节",
|
||||||
|
subject: "数学",
|
||||||
|
className: "初一(1)班",
|
||||||
|
teacherName: "王老师",
|
||||||
|
startDate: "2026-02-15",
|
||||||
|
endDate: "2026-07-10",
|
||||||
|
status: "active",
|
||||||
|
chapterCount: 8,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: "ch-02",
|
||||||
|
title: "第二章 不等式与不等式组",
|
||||||
|
description: "掌握一元一次不等式及不等式组的解法",
|
||||||
|
sortOrder: 2,
|
||||||
|
lessonCount: 6,
|
||||||
|
estimatedHours: 4.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-01",
|
||||||
|
title: "第一章 一元二次方程",
|
||||||
|
description: "学习一元二次方程的概念、解法及其应用",
|
||||||
|
sortOrder: 1,
|
||||||
|
lessonCount: 8,
|
||||||
|
estimatedHours: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-03",
|
||||||
|
title: "第三章 函数与图像",
|
||||||
|
description: "理解函数概念,掌握一次函数与反比例函数",
|
||||||
|
sortOrder: 3,
|
||||||
|
lessonCount: 10,
|
||||||
|
estimatedHours: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CoursePlanDetail 基本信息", () => {
|
||||||
|
it("渲染标题与描述", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("初一数学下学期课程计划"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"涵盖一元二次方程、不等式组、函数与图像等核心章节",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染学科标签与状态徽标", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(screen.getByText("数学")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("进行中")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染班级、教师、章节数等基本信息", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(screen.getByText("初一(1)班")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("王老师")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("8")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CoursePlanDetail 章节列表", () => {
|
||||||
|
it("渲染全部章节并按 sortOrder 升序排列", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CoursePlanDetail coursePlan={mockDetail} />,
|
||||||
|
);
|
||||||
|
const chapterHeadings = container.querySelectorAll("h4");
|
||||||
|
expect(chapterHeadings).toHaveLength(3);
|
||||||
|
// sortOrder: 1, 2, 3
|
||||||
|
expect(chapterHeadings[0]!.textContent).toBe("第一章 一元二次方程");
|
||||||
|
expect(chapterHeadings[1]!.textContent).toBe("第二章 不等式与不等式组");
|
||||||
|
expect(chapterHeadings[2]!.textContent).toBe("第三章 函数与图像");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染章节序号", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(screen.getByText("第 1 章")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("第 2 章")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("第 3 章")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染章节课时数与预计学时", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(screen.getByText("8 课时")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("6 课时")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10 课时")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("预计 6 学时")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("预计 4.5 学时")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("预计 8 学时")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("渲染章节描述", () => {
|
||||||
|
render(<CoursePlanDetail coursePlan={mockDetail} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("学习一元二次方程的概念、解法及其应用"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("掌握一元一次不等式及不等式组的解法"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
125
apps/parent-portal/src/components/CoursePlanDetail.tsx
Normal file
125
apps/parent-portal/src/components/CoursePlanDetail.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// CoursePlanDetail:课程计划详情(基本信息 + 章节列表)
|
||||||
|
// 依据:02-architecture-design.md §15 组件设计
|
||||||
|
// 章节:标题/描述/排序/课时数/预计学时
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CoursePlanDetail } from "@/types";
|
||||||
|
import { cn, formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CoursePlanDetailProps {
|
||||||
|
coursePlan: CoursePlanDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoursePlanStatus = CoursePlanDetail["status"];
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<CoursePlanStatus, string> = {
|
||||||
|
active: "进行中",
|
||||||
|
completed: "已完成",
|
||||||
|
archived: "已归档",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<CoursePlanStatus, string> = {
|
||||||
|
active: "text-success",
|
||||||
|
completed: "text-ink-muted",
|
||||||
|
archived: "text-ink-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CoursePlanDetail({
|
||||||
|
coursePlan,
|
||||||
|
}: CoursePlanDetailProps): JSX.Element {
|
||||||
|
const chapters = [...coursePlan.chapters].sort(
|
||||||
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="space-y-6">
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<section
|
||||||
|
aria-label="课程计划基本信息"
|
||||||
|
className="rounded border border-rule bg-paper-elevated p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
|
||||||
|
{coursePlan.subject}
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-2 font-serif text-xl">{coursePlan.title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-muted">
|
||||||
|
{coursePlan.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 text-xs font-medium",
|
||||||
|
STATUS_STYLES[coursePlan.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[coursePlan.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-rule pt-3">
|
||||||
|
<dl className="grid grid-cols-2 gap-y-3 text-sm sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-ink-muted">班级</dt>
|
||||||
|
<dd className="mt-1">{coursePlan.className}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-ink-muted">教师</dt>
|
||||||
|
<dd className="mt-1">{coursePlan.teacherName}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-ink-muted">起止日期</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{formatDate(coursePlan.startDate)} ~{" "}
|
||||||
|
{formatDate(coursePlan.endDate)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-ink-muted">章节数</dt>
|
||||||
|
<dd className="mt-1 font-mono">{coursePlan.chapterCount}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 章节列表 */}
|
||||||
|
<section aria-label="章节列表" className="space-y-3">
|
||||||
|
<h3 className="font-serif text-base">章节安排</h3>
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{chapters.map((chapter) => (
|
||||||
|
<li
|
||||||
|
key={chapter.id}
|
||||||
|
className="rounded border border-rule bg-paper-elevated p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-ink-muted">
|
||||||
|
第 {chapter.sortOrder} 章
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-1 font-serif text-base">{chapter.title}</h4>
|
||||||
|
<p className="mt-1 text-sm text-ink-muted">
|
||||||
|
{chapter.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
<div className="font-mono text-sm">
|
||||||
|
{chapter.lessonCount} 课时
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-ink-muted">
|
||||||
|
预计 {chapter.estimatedHours} 学时
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoursePlanDetail;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user