Compare commits
144 Commits
a1d7fcfd71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abde336876 | ||
|
|
2796d7c88f | ||
|
|
a70977ad4b | ||
|
|
9fd7c018c2 | ||
|
|
5a88c8b45d | ||
|
|
422b55f901 | ||
|
|
895a060491 | ||
|
|
7b790f1276 | ||
|
|
fb23c5234e | ||
|
|
7dd5c44406 | ||
|
|
d260df864c | ||
|
|
e9d3030f2f | ||
|
|
774af93d76 | ||
|
|
81a539b9ab | ||
|
|
74077c7083 | ||
|
|
0066afc55e | ||
|
|
ae6fe23be6 | ||
|
|
21f2485749 | ||
|
|
41179ff0a9 | ||
|
|
0b42302a64 | ||
|
|
99580fa13a | ||
|
|
5b06bdbc52 | ||
|
|
b745443b6d | ||
|
|
96a936e154 | ||
|
|
8b729a710f | ||
|
|
40f07b7a16 | ||
|
|
64b2016fa8 | ||
|
|
18d94c0cc2 | ||
|
|
d49d211425 | ||
|
|
f13ca612e6 | ||
|
|
7c8e0f5dea | ||
|
|
7cf9aec20e | ||
|
|
fad6e43b47 | ||
|
|
d6cc70e02e | ||
|
|
a0635ff2c6 | ||
|
|
9e4d442c0e | ||
|
|
58d0c4758c | ||
|
|
d1db3dfdee | ||
|
|
a6b0c54fba | ||
|
|
61d824924a | ||
|
|
c6362f4b04 | ||
|
|
8a01d0b8fc | ||
|
|
32780c2296 | ||
|
|
606acb674f | ||
|
|
860557e166 | ||
|
|
66e65eb2b1 | ||
|
|
2e7eace762 | ||
|
|
dd0ae47976 | ||
|
|
dda33ac2d1 | ||
|
|
10c918490f | ||
|
|
b50dd62c9b | ||
|
|
3382528085 | ||
|
|
b29b3eb022 | ||
|
|
f585080e70 | ||
|
|
74474a2d04 | ||
|
|
1eacd1ed87 | ||
|
|
99155a5ea1 | ||
|
|
7b7abbb309 | ||
|
|
a35e759d64 | ||
|
|
ca3780aa24 | ||
|
|
b3511910d1 | ||
|
|
58c0ba1bd9 | ||
|
|
84a743fa23 | ||
|
|
33f5de4c04 | ||
|
|
5c89def704 | ||
|
|
1fa2f20920 | ||
|
|
7817d8397f | ||
|
|
8f21ca6a68 | ||
|
|
b951229ede | ||
|
|
bfae2569e7 | ||
|
|
7d11cc0eb0 | ||
|
|
bc2d0a107c | ||
|
|
5dae28a624 | ||
|
|
150105dd48 | ||
|
|
ea59b9c9ca | ||
|
|
b255c94a77 | ||
|
|
9fae2b0e78 | ||
|
|
9ea81f1bd7 | ||
|
|
2229309a1e | ||
|
|
4307f6b73c | ||
|
|
5661938cc0 | ||
|
|
3fac472a57 | ||
|
|
dc0a6feec4 | ||
|
|
855f35f84b | ||
|
|
9e767b4e95 | ||
|
|
8e91039896 | ||
|
|
c179af64a6 | ||
|
|
df62ffc176 | ||
|
|
24c2860b41 | ||
|
|
88f6822473 | ||
|
|
a7d8f92227 | ||
|
|
06e0f9139b | ||
|
|
5e0e20b1ce | ||
|
|
06a646ea4e | ||
|
|
21530dc7f6 | ||
|
|
b82593aac2 | ||
|
|
14d836beb0 | ||
|
|
c09d6fb7d2 | ||
|
|
b54bfd101b | ||
|
|
02d09c47fa | ||
|
|
033057a302 | ||
|
|
e5ca4c6c7b | ||
|
|
60d7173545 | ||
|
|
3852873964 | ||
|
|
5144ec7292 | ||
|
|
9ba368477d | ||
|
|
6051f84a65 | ||
|
|
f15912c7ee | ||
|
|
faaaf29f67 | ||
|
|
2a2a56f541 | ||
|
|
db90b2e080 | ||
|
|
e691cd267d | ||
|
|
fd5b6e19ae | ||
|
|
0a71b02e04 | ||
|
|
b53a486c6e | ||
|
|
a0f6c228af | ||
|
|
834e2c61fd | ||
|
|
f1e466a772 | ||
|
|
b72c8d81d4 | ||
|
|
959d58a95d | ||
|
|
d8dab70406 | ||
|
|
1f901c5b20 | ||
|
|
958b17c9d8 | ||
|
|
566060fade | ||
|
|
3ca654619f | ||
|
|
a70a74207e | ||
|
|
dfb6d2bfc1 | ||
|
|
416e1bc0b2 | ||
|
|
421edd8a41 | ||
|
|
5f18821302 | ||
|
|
921fe82771 | ||
|
|
033c083619 | ||
|
|
6215f4e21f | ||
|
|
beb204c00f | ||
|
|
4f539b50dd | ||
|
|
4533da6484 | ||
|
|
2c7afe59ef | ||
|
|
b2c2f6e567 | ||
|
|
a2f0ca26ae | ||
|
|
5759b09c9f | ||
|
|
d92cdda727 | ||
|
|
adea22b133 | ||
|
|
f658571726 | ||
|
|
68ddff1065 |
92
.dockerignore
Normal file
92
.dockerignore
Normal file
@@ -0,0 +1,92 @@
|
||||
# 依赖目录(构建时重新安装)
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# 构建产物
|
||||
**/.next
|
||||
**/out
|
||||
**/dist
|
||||
**/build
|
||||
**/coverage
|
||||
**/target
|
||||
**/bin
|
||||
**/obj
|
||||
|
||||
# Go 构建产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Python 缓存
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/*$py.class
|
||||
**/.venv
|
||||
**/venv
|
||||
**/*.egg-info
|
||||
|
||||
# 环境变量与本地配置
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Secrets / Keys
|
||||
keys/
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# IDE 与编辑器
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 平台与临时文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# TypeScript 增量构建缓存
|
||||
*.tsbuildinfo
|
||||
|
||||
# 测试覆盖率报告
|
||||
.nyc_output
|
||||
**/coverage
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
arch.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose.minimal.override.yml
|
||||
|
||||
# 临时目录
|
||||
tmp/
|
||||
temp/
|
||||
.turbo
|
||||
.cache
|
||||
|
||||
# 文档(不需要打包到镜像)
|
||||
docs/
|
||||
**/docs/
|
||||
*.md
|
||||
!README.md
|
||||
12
.env.example
12
.env.example
@@ -21,7 +21,17 @@ DEV_MODE=false
|
||||
# 服务端口
|
||||
API_GATEWAY_PORT=8080
|
||||
CLASSES_SERVICE_PORT=3001
|
||||
TEACHER_PORTAL_PORT=3000
|
||||
TEACHER_PORTAL_PORT=4000
|
||||
|
||||
# teacher-portal(ai13,MF Shell)
|
||||
# GraphQL endpoint(经 api-gateway 代理到 teacher-bff,contract.md §2.3)
|
||||
NEXT_PUBLIC_TEACHER_BFF_GRAPHQL_URL=/api/v1/teacher/graphql
|
||||
# MSW mock 开关(contract.md §4.2,enabled 时启用 MSW 拦截上游)
|
||||
NEXT_PUBLIC_API_MOCKING=enabled
|
||||
# MF Remote 开关(ARB-002 §2.3,P2 默认 false,P3+ 启用 student-portal)
|
||||
NEXT_PUBLIC_MF_ENABLED=false
|
||||
# api-gateway URL(next.config.js rewrites 代理目标)
|
||||
API_GATEWAY_URL=http://localhost:8080
|
||||
|
||||
# 数据库连接
|
||||
DATABASE_URL=mysql://edu:changeme@localhost:3306/next_edu_cloud
|
||||
|
||||
60
.github/workflows/ci-go.yml
vendored
60
.github/workflows/ci-go.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: CI Go
|
||||
|
||||
# 参考实现:E:\Desktop\CICD\.gitea\workflows\ci.yml
|
||||
# Go 服务分两阶段:
|
||||
# - quality: golang:1.22-alpine 容器(不需要 docker)
|
||||
# - docker-build: dockerreg.eazygame.cn/node-with-docker:22 + setup-go(需要 docker 命令)
|
||||
# 详见 docs/standards/cicd-runbook.md §10、project_rules §15.7
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/api-gateway/**'
|
||||
- 'services/push-gateway/**'
|
||||
- 'packages/shared-go/**'
|
||||
- 'go.work'
|
||||
- '.github/workflows/ci-go.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/api-gateway/**'
|
||||
- 'services/push-gateway/**'
|
||||
- 'packages/shared-go/**'
|
||||
- 'go.work'
|
||||
- '.github/workflows/ci-go.yml'
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
container: golang:1.22-alpine
|
||||
defaults:
|
||||
run:
|
||||
working-directory: services/api-gateway
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download deps
|
||||
run: go mod download
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./... -v -coverprofile=coverage.out
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
needs: quality
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build api-gateway
|
||||
run: |
|
||||
docker build \
|
||||
-t edu/api-gateway:ci \
|
||||
-f services/api-gateway/Dockerfile \
|
||||
services/api-gateway
|
||||
25
.github/workflows/ci-proto.yml
vendored
25
.github/workflows/ci-proto.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: CI Proto
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/shared-proto/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/shared-proto/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
- name: buf lint
|
||||
working-directory: packages/shared-proto
|
||||
run: buf lint
|
||||
- name: buf breaking
|
||||
if: github.event_name == 'pull_request'
|
||||
working-directory: packages/shared-proto
|
||||
run: buf breaking --against .git#branch=main,subdir=packages/shared-proto
|
||||
33
.github/workflows/ci-py.yml
vendored
33
.github/workflows/ci-py.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: CI Python
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/data-ana/**'
|
||||
- 'services/ai/**'
|
||||
- 'packages/shared-py/**'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/data-ana/**'
|
||||
- 'services/ai/**'
|
||||
- 'packages/shared-py/**'
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- name: Install
|
||||
run: uv sync
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
- name: Test
|
||||
run: uv run pytest
|
||||
98
.github/workflows/ci-ts.yml
vendored
98
.github/workflows/ci-ts.yml
vendored
@@ -1,98 +0,0 @@
|
||||
name: CI TypeScript
|
||||
|
||||
# 参考实现:E:\Desktop\CICD\.gitea\workflows\ci.yml
|
||||
# 关键配置:container: dockerreg.eazygame.cn/node-with-docker:22(带 docker 的 node 22)
|
||||
# 详见 docs/standards/cicd-runbook.md §10、project_rules §15.7
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/classes/**'
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'tsconfig.base.json'
|
||||
- '.github/workflows/ci-ts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/classes/**'
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'tsconfig.base.json'
|
||||
- '.github/workflows/ci-ts.yml'
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: '1'
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm -r run lint
|
||||
continue-on-error: true # P1: ESLint 9 flat config 迁移未完成
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r run typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm -r run test
|
||||
continue-on-error: true # P1: 部分服务无 test 脚本
|
||||
|
||||
- name: Build
|
||||
run: pnpm -r run build
|
||||
|
||||
arch-scan:
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
needs: quality
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@9
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: arch:scan
|
||||
run: pnpm run arch:scan
|
||||
- name: Verify arch.db committed
|
||||
run: |
|
||||
if git diff --quiet -- scripts/arch-scan/arch.db; then
|
||||
echo "arch.db up to date"
|
||||
else
|
||||
echo "::warning::arch.db 未同步,请运行 pnpm run arch:scan 并提交"
|
||||
fi
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
needs: quality
|
||||
strategy:
|
||||
matrix:
|
||||
service:
|
||||
- { name: classes, dockerfile: services/classes/Dockerfile, context: . }
|
||||
- { name: teacher-portal, dockerfile: apps/teacher-portal/Dockerfile, context: . }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build ${{ matrix.service.name }}
|
||||
run: |
|
||||
docker build \
|
||||
-t edu/${{ matrix.service.name }}:ci \
|
||||
-f ${{ matrix.service.dockerfile }} \
|
||||
${{ matrix.service.context }}
|
||||
196
.github/workflows/ci.yml
vendored
Normal file
196
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
name: CI
|
||||
|
||||
# CI/CD 一体化流水线(no-push 本地构建部署)
|
||||
# 设计文档:docs/superpowers/specs/2026-07-08-cicd-no-push-local-build-design.md
|
||||
#
|
||||
# 流程:
|
||||
# PR 触发:仅跑 3 个 quality job(并行)
|
||||
# push main 触发:quality job 全绿后跑 deploy job(本地 build + compose up)
|
||||
# workflow_dispatch:支持指定 commit_sha 回滚
|
||||
#
|
||||
# 不依赖任何自建镜像,全部使用官方镜像
|
||||
# 不推送镜像到 registry,构建即部署
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commit_sha:
|
||||
description: '回滚到指定 commit(留空则部署当前 HEAD)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: '1'
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
# ===== TypeScript 质量检查 =====
|
||||
quality-ts:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:22-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||
|
||||
- name: Install pnpm
|
||||
run: corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm -r run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r run typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm -r run test
|
||||
continue-on-error: true # P6: 部分服务无 test 脚本,待补全
|
||||
|
||||
- name: Build
|
||||
run: pnpm -r run build
|
||||
|
||||
# ===== Go 质量检查 =====
|
||||
quality-go:
|
||||
runs-on: ubuntu-latest
|
||||
container: golang:1.25-alpine
|
||||
defaults:
|
||||
run:
|
||||
working-directory: services/api-gateway
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||
|
||||
- name: Download deps
|
||||
run: go mod download
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./... -v -coverprofile=coverage.out
|
||||
|
||||
# ===== Proto 契约检查 =====
|
||||
quality-proto:
|
||||
runs-on: ubuntu-latest
|
||||
container: bufbuild/buf:latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/shared-proto
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||
fetch-depth: 0 # buf breaking 需要对比分支
|
||||
|
||||
- name: buf lint
|
||||
run: buf lint
|
||||
|
||||
- name: buf breaking
|
||||
if: github.event_name == 'pull_request'
|
||||
run: buf breaking --against .git#branch=main,subdir=packages/shared-proto
|
||||
|
||||
# ===== 部署(仅 push main 或手动触发)=====
|
||||
deploy:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
needs: [quality-ts, quality-go, quality-proto]
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:25-git
|
||||
options: --volume /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
name: production
|
||||
env:
|
||||
DEPLOY_DIR: /opt/edu
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit_sha || github.ref }}
|
||||
|
||||
- name: Setup deploy dir
|
||||
run: |
|
||||
# 同步整个仓库到部署目录的 repo/ 子目录
|
||||
# compose 文件中 build.context 指向 ./repo/services/... 或 ./repo
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}/repo
|
||||
cp -a $GITHUB_WORKSPACE/. ${{ env.DEPLOY_DIR }}/repo/
|
||||
cp infra/docker-compose.deploy.yml ${{ env.DEPLOY_DIR }}/docker-compose.yml
|
||||
|
||||
# 确保 .env 存在(首次部署需人工创建)
|
||||
if [ ! -f ${{ env.DEPLOY_DIR }}/.env ]; then
|
||||
echo "::error::部署目录缺少 .env 文件,请参考 infra/deploy.env.example 创建"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy services (local build, no push)
|
||||
run: |
|
||||
cd ${{ env.DEPLOY_DIR }}
|
||||
echo "=== 构建并启动服务 ==="
|
||||
# --build 使用本地 Dockerfile 构建,不拉取 registry
|
||||
# build.context 在 compose 中指向 ./repo/...(相对于 /opt/edu/)
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
- name: Wait for health
|
||||
run: |
|
||||
echo "等待服务健康..."
|
||||
sleep 10
|
||||
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
FAIL=0
|
||||
|
||||
echo "[尝试 $i] 检查 api-gateway..."
|
||||
if ! wget -q --spider http://localhost:8080/healthz 2>/dev/null; then
|
||||
echo " api-gateway 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "[尝试 $i] 检查 classes..."
|
||||
if ! wget -q --spider http://localhost:3001/healthz 2>/dev/null; then
|
||||
echo " classes 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "[尝试 $i] 检查 teacher-portal..."
|
||||
if ! wget -q --spider http://localhost:3000/ 2>/dev/null; then
|
||||
echo " teacher-portal 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "✅ 所有服务健康"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 6
|
||||
done
|
||||
|
||||
echo "::error::服务健康检查失败"
|
||||
echo "=== 容器状态 ==="
|
||||
cd ${{ env.DEPLOY_DIR }} && docker compose ps
|
||||
echo "=== api-gateway 日志 ==="
|
||||
docker compose logs --tail=50 api-gateway
|
||||
echo "=== classes 日志 ==="
|
||||
docker compose logs --tail=50 classes
|
||||
echo "=== teacher-portal 日志 ==="
|
||||
docker compose logs --tail=50 teacher-portal
|
||||
exit 1
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### 部署结果" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 触发事件:\`${{ github.event_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 部署 commit:\`${{ github.event.inputs.commit_sha || github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 部署目录:\`${{ env.DEPLOY_DIR }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 部署方式:本地构建(no-push)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cd ${{ env.DEPLOY_DIR }} && docker compose ps >> $GITHUB_STEP_SUMMARY 2>&1 || true
|
||||
145
.github/workflows/deploy.yml
vendored
145
.github/workflows/deploy.yml
vendored
@@ -1,145 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
# 参考实现:E:\Desktop\CICD\.gitea\workflows\ci.yml
|
||||
# 关键配置:container: dockerreg.eazygame.cn/node-with-docker:22(job 内执行 docker compose)
|
||||
# 部署到服务器
|
||||
# 触发条件:main 分支 docker.yml 完成后 自动,或手动 workflow_dispatch
|
||||
# 部署方式:Runner 直接执行 docker compose(Runner 跑在服务器上)
|
||||
#
|
||||
# 前置条件(一次性配置):
|
||||
# 1. 服务器上已安装 Gitea Actions Runner,标签为 'ubuntu-latest'
|
||||
# 2. 服务器上已存在 /opt/edu/docker-compose.deploy.yml(由 SRE AI 首次部署)
|
||||
# 3. 服务器上已存在 /opt/edu/.env(含生产 JWT_SECRET 等)
|
||||
# 4. 服务器 Docker 已登录 Gitea Container Registry:
|
||||
# docker login git.eazygame.cn -u <user> -p <token>
|
||||
# 5. MySQL + Redis 已在服务器 Docker 中运行(3306/6379)
|
||||
# 详见 docs/standards/cicd-runbook.md §10、project_rules §15.7
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像 tag(默认 latest)'
|
||||
required: false
|
||||
default: 'latest'
|
||||
rollback:
|
||||
description: '回滚到上一版本(跳过 pull)'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
workflow_run:
|
||||
workflows: ['Docker Build & Push']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DEPLOY_DIR: /opt/edu
|
||||
REGISTRY: git.eazygame.cn
|
||||
IMAGE_TAG: ${{ github.event.inputs.image_tag || 'latest' }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# 仅在 docker.yml 成功后触发,或手动触发
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
environment:
|
||||
name: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup deploy dir
|
||||
run: |
|
||||
sudo mkdir -p ${{ env.DEPLOY_DIR }}
|
||||
sudo chown -R $(id -u):$(id -g) ${{ env.DEPLOY_DIR }}
|
||||
|
||||
- name: Sync compose file
|
||||
run: |
|
||||
cp infra/docker-compose.deploy.yml ${{ env.DEPLOY_DIR }}/docker-compose.yml
|
||||
# 确保 .env 存在(首次部署需人工创建)
|
||||
if [ ! -f ${{ env.DEPLOY_DIR }}/.env ]; then
|
||||
echo "::error::部署目录缺少 .env 文件,请参考 infra/deploy.env.example 创建"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
# 使用预存的 ~/.docker/config.json(首次部署时人工 docker login)
|
||||
# 或使用 secrets 注入
|
||||
if [ -n "${{ secrets.GITEA_REGISTRY_TOKEN }}" ]; then
|
||||
echo "${{ secrets.GITEA_REGISTRY_TOKEN }}" | \
|
||||
docker login ${{ env.REGISTRY }} -u ${{ secrets.GITEA_REGISTRY_USER }} --password-stdin
|
||||
fi
|
||||
|
||||
- name: Pull images
|
||||
if: ${{ github.event.inputs.rollback != 'true' }}
|
||||
run: |
|
||||
cd ${{ env.DEPLOY_DIR }}
|
||||
export IMAGE_TAG=${{ env.IMAGE_TAG }}
|
||||
docker compose pull
|
||||
continue-on-error: true # 首次部署可能无旧镜像
|
||||
|
||||
- name: Deploy services
|
||||
run: |
|
||||
cd ${{ env.DEPLOY_DIR }}
|
||||
export IMAGE_TAG=${{ env.IMAGE_TAG }}
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
- name: Wait for health
|
||||
run: |
|
||||
echo "等待服务健康..."
|
||||
sleep 10
|
||||
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
FAIL=0
|
||||
|
||||
echo "[尝试 $i] 检查 api-gateway..."
|
||||
if ! curl -sf http://localhost:8080/healthz > /dev/null; then
|
||||
echo " api-gateway 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "[尝试 $i] 检查 classes..."
|
||||
if ! curl -sf http://localhost:3001/healthz > /dev/null; then
|
||||
echo " classes 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "[尝试 $i] 检查 teacher-portal..."
|
||||
if ! curl -sf http://localhost:3000/ > /dev/null; then
|
||||
echo " teacher-portal 未就绪"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "✅ 所有服务健康"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 6
|
||||
done
|
||||
|
||||
echo "::error::服务健康检查失败"
|
||||
echo "=== 容器状态 ==="
|
||||
cd ${{ env.DEPLOY_DIR }} && docker compose ps
|
||||
echo "=== api-gateway 日志 ==="
|
||||
docker compose logs --tail=50 api-gateway
|
||||
echo "=== classes 日志 ==="
|
||||
docker compose logs --tail=50 classes
|
||||
exit 1
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### 部署结果" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 镜像 tag:\`${{ env.IMAGE_TAG }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 部署目录:\`${{ env.DEPLOY_DIR }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 回滚模式:${{ github.event.inputs.rollback || 'false' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cd ${{ env.DEPLOY_DIR }} && docker compose ps >> $GITHUB_STEP_SUMMARY 2>&1 || true
|
||||
92
.github/workflows/docker.yml
vendored
92
.github/workflows/docker.yml
vendored
@@ -1,92 +0,0 @@
|
||||
name: Docker Build & Push
|
||||
|
||||
# 参考实现:E:\Desktop\CICD\.gitea\workflows\ci.yml
|
||||
# 关键配置:container: dockerreg.eazygame.cn/node-with-docker:22(job 内执行 docker build/push)
|
||||
# 触发条件:main 分支推送 或 打 v* tag
|
||||
# 镜像推送到 Gitea Container Registry: git.eazygame.cn/xiner/edu/<service>:<tag>
|
||||
# 详见 docs/standards/cicd-runbook.md §10、project_rules §15.7
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/api-gateway/**'
|
||||
- 'services/classes/**'
|
||||
- 'apps/teacher-portal/**'
|
||||
- 'packages/shared-proto/**'
|
||||
- '.github/workflows/docker.yml'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.eazygame.cn
|
||||
OWNER: xiner
|
||||
REPO_LOWER: edu
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
strategy:
|
||||
matrix:
|
||||
service:
|
||||
- { name: api-gateway, dockerfile: services/api-gateway/Dockerfile, context: ./services/api-gateway }
|
||||
- { name: classes, dockerfile: services/classes/Dockerfile, context: . }
|
||||
- { name: teacher-portal, dockerfile: apps/teacher-portal/Dockerfile, context: . }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 登录 Gitea Container Registry
|
||||
# GITHUB_TOKEN 由 Gitea Actions 自动注入,对应仓库写权限
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
||||
docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
# 生成镜像 tag
|
||||
# main 分支:latest + git-sha
|
||||
# v* tag:v<version> + latest
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
run: |
|
||||
TAGS="${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO_LOWER }}/${{ matrix.service.name }}:latest"
|
||||
SHA_TAG="${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO_LOWER }}/${{ matrix.service.name }}:sha-${GITHUB_SHA::7}"
|
||||
TAGS="$TAGS,$SHA_TAG"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAGS="$TAGS,${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO_LOWER }}/${{ matrix.service.name }}:v${VERSION}"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "Resolved tags: $TAGS"
|
||||
|
||||
- name: Build and push ${{ matrix.service.name }}
|
||||
run: |
|
||||
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.meta.outputs.tags }}"
|
||||
TAG_ARGS=""
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
TAG_ARGS="$TAG_ARGS -t $tag"
|
||||
done
|
||||
|
||||
docker build \
|
||||
$TAG_ARGS \
|
||||
-f ${{ matrix.service.dockerfile }} \
|
||||
${{ matrix.service.context }}
|
||||
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
echo "Pushing $tag..."
|
||||
docker push $tag
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### Docker 镜像已推送" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "服务:\`${{ matrix.service.name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "镜像标签:" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -34,6 +34,12 @@ venv/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Secrets / Keys (JWT RS256 等)
|
||||
keys/
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -59,6 +65,7 @@ coverage/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose.minimal.override.yml
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| `docs/architecture/0010_architecture.md` | 理想蓝图(目标态) |
|
||||
| `docs/architecture/roadmap/` | 长远规划(tech-debt / pending-features) |
|
||||
| `docs/architecture/runbooks/` | 运维手册(P6 硬化、post-p6-followup、incident-response) |
|
||||
| `docs/troubleshooting/known-issues.md` | 已知问题速查(场景→技术映射 + 工作经验日志) |
|
||||
| `docs/troubleshooting/known-issues.md` | 已知问题速查(场景→技术映射,索引式,不写工作日志) |
|
||||
|
||||
### 需要同步图的场景
|
||||
|
||||
@@ -246,11 +246,17 @@ services/[service]/src/
|
||||
|
||||
## 8. Git 工作流
|
||||
|
||||
- **分支策略**:trunk-based(直接提交 main 分支,小项目)
|
||||
- **大型团队**:feature branch + PR,PR 至少 1 人 review
|
||||
- **分支策略**:分支开发。**人类决策者手动维护分支**(创建分支、合并到 main),AI 不得自行切换/创建/合并分支
|
||||
- **AI 分支约束(强制)**:
|
||||
- AI 只在人类分配的当前分支上工作
|
||||
- **禁止** `git checkout -b`、`git switch`、`git branch` 创建或切换分支
|
||||
- **禁止** `git merge`、`git push origin main`(合并与推送 main 由人类负责)
|
||||
- AI 完成工作后 `git add` + `git commit` 提交到当前分支,通知人类合并
|
||||
- **提交前校验**:commit 前必须本地通过 `pnpm run lint` + `pnpm run typecheck`(TS)/ `go vet ./...`(Go)/ `ruff check src/`(Python)
|
||||
- **pre-commit hook**:`lint-staged` 自动修复 + 校验(见 `lint-staged.config.js`)
|
||||
- **commit-msg hook**:commitlint 校验格式
|
||||
- **禁止 force push**:除非显式要求并通知团队
|
||||
- **回滚**:由人类在 main 上 `git revert <commit>`,不使用分支回滚
|
||||
- **默认多 AI 协作**:所有工作默认按多 AI 合作模式进行(见 §14),每个 AI 在各自分支独立工作
|
||||
|
||||
---
|
||||
|
||||
@@ -286,7 +292,10 @@ services/[service]/src/
|
||||
- **索引式**:场景→技术/规则映射,不写代码示例和错误示范列
|
||||
- **去重**:同类问题在原条目补充,不重复创建
|
||||
- **引用架构规则**:架构分层、模块结构等规则引用 004 和本规则文件,不重复
|
||||
- **工作经验日志**:在"工作经验日志"区按时间倒序追加(50 条上限),记录"做了什么/学到什么/下次注意"
|
||||
- **只读自己模块分区**:AI 查阅 known-issues 时只读自己负责模块的分区,禁止读其他 AI 模块的分区(避免被历史记录误导)
|
||||
- **禁止工作日志**:known-issues 不设"工作经验日志"区,不写流水账、不写"做了什么/学到什么"
|
||||
- **经验沉淀不标注 AI 身份**:所有经验记录只写"场景→技术/规则"映射,**不得出现 AI 标识(如 ai01/ai02/coord 等)**,避免新同名 AI 将前任记录误认为当前事实
|
||||
- **模块内经验**:模块内的经验沉淀在各自模块的 README/文档中,同样不标注 AI 身份
|
||||
|
||||
---
|
||||
|
||||
@@ -294,14 +303,18 @@ services/[service]/src/
|
||||
|
||||
**所有 AI 工作必须遵循此流程,违反即违规。**
|
||||
|
||||
### 阶段 1:上下文加载
|
||||
### 阶段 1:上下文加载(按需阅读,严格模块边界)
|
||||
|
||||
> **架构文档按需阅读**:模块开发只读自己模块的架构与服务 README;项目整体相关工作(跨模块契约、基础设施、文档体系、shared-proto)才读全局架构文档。功能开发/文档书写等强依赖场景必须读对应架构。
|
||||
>
|
||||
> **严格模块边界**:AI 只读自己负责模块的文档与源码,不越界阅读其他模块的内部实现细节(避免被历史记录或他人方案误导)。仅在需要确认跨模块契约时,才读对方的对外接口(proto/API/事件)。
|
||||
|
||||
1. `pnpm run arch:scan` 更新 arch.db
|
||||
2. `pnpm run arch:query -- module-deps` 查目标模块依赖
|
||||
3. `pnpm run arch:query -- symbol-refs <目标函数>` 查调用链
|
||||
4. 阅读 `services/[service]/README.md` 读模块工作流程
|
||||
5. 查 `docs/troubleshooting/known-issues.md` "模块经验"分区读相关经验
|
||||
6. 查 `docs/architecture/004_architecture_impact_map.md` 对应章节
|
||||
4. 阅读 `services/[service]/README.md` 读**自己模块**的工作流程
|
||||
5. 查 `docs/troubleshooting/known-issues.md` **仅读自己模块的分区**,禁止读其他 AI 模块分区
|
||||
6. 模块开发:查 `docs/architecture/004_architecture_impact_map.md` **对应模块章节**;全局工作:查 004 全文
|
||||
|
||||
### 阶段 2:执行工作
|
||||
|
||||
@@ -314,13 +327,12 @@ services/[service]/src/
|
||||
|
||||
### 阶段 3:经验沉淀(强制,不可跳过)
|
||||
|
||||
1. 在 `docs/troubleshooting/known-issues.md` "工作经验日志"区追加一条记录:
|
||||
- 日期 + 时间
|
||||
- 模块
|
||||
- 做了什么 + 学到什么
|
||||
2. 若发现新的"场景→技术"映射 → 提炼到对应模块分区
|
||||
3. 若发现新的架构决策 → 更新 004
|
||||
4. 若代码结构变化 → `pnpm run arch:scan` 确认 arch.db 已更新
|
||||
1. 若发现新的"场景→技术"映射 → 提炼到 `docs/troubleshooting/known-issues.md` 对应**模块分区**(索引式一行,不写流水日志,**不标注 AI 身份**)
|
||||
2. 若发现新的架构决策 → 更新 004
|
||||
3. 若代码结构变化 → `pnpm run arch:scan` 确认 arch.db 已更新
|
||||
4. 模块内的经验沉淀到**自己模块的 README/文档**,不污染全局 known-issues,同样不标注 AI 身份
|
||||
|
||||
> **禁止**在 known-issues.md 写"工作经验日志/流水账"。known-issues 是索引式速查手册,只保留场景→技术映射,**不出现任何 AI 标识**。
|
||||
|
||||
---
|
||||
|
||||
@@ -371,48 +383,40 @@ services/[service]/src/
|
||||
|
||||
## 14. 多 AI 协作规范
|
||||
|
||||
> 详细流程见 [多 AI 协作指南](../../docs/standards/multi-ai-collaboration.md),本节为强制约束摘要。
|
||||
> **默认多 AI 协作**:所有 AI 工作默认按多 AI 合作模式进行。详细流程见 [多 AI 协作指南](../../docs/standards/multi-ai-collaboration.md),本节为强制约束摘要。
|
||||
|
||||
### 14.1 角色与权限
|
||||
|
||||
| 角色 | 职责 | push 特性分支 | 创建 PR | 合并 PR | push main | force push main |
|
||||
| -------------------------- | ---------------------------------------- | ------------- | ------- | ----------- | --------- | --------------- |
|
||||
| **协调 AI(Coordinator)** | PR 审核、合并、冲突仲裁、发布 | ✅ | ✅ | ✅ | ❌ | ⚠️(仅事故) |
|
||||
| **开发 AI(Dev)** | 按模块分工写代码、提 PR | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **SRE AI** | `infra/` 维护、部署 | ✅(infra) | ✅ | ✅(infra) | ❌ | ⚠️(仅事故) |
|
||||
| **人类决策者** | 架构决策、Breaking Change 审批、发布确认 | — | — | — | — | — |
|
||||
| 角色 | 职责 | 在分支提交 | 合并到 main | force push main |
|
||||
| -------------------------- | -------------------------------------------------------- | ----------- | ------------------------- | --------------- |
|
||||
| **协调 AI(Coordinator)** | 契约管理、交叉审查、冲突仲裁、发布 | ✅ | ❌(由人类合并) | ⚠️(仅事故) |
|
||||
| **开发 AI(Dev)** | 按模块分工写代码、在分支提交 | ✅ | ❌ | ❌ |
|
||||
| **SRE AI** | `infra/` 维护、部署 | ✅(infra) | ❌(由人类合并) | ⚠️(仅事故) |
|
||||
| **人类决策者** | 架构决策、分支管理、合并、Breaking Change 审批、发布确认 | — | ✅(手动合并分支到 main) | ⚠️(仅事故) |
|
||||
|
||||
### 14.2 模块单一负责制
|
||||
> AI 在分配的分支上提交,**不自行合并到 main**。合并由人类决策者负责。
|
||||
|
||||
- 每个模块(限界上下文)只有一个 AI 负责,禁止并行修改同一模块
|
||||
### 14.2 模块单一负责制与模块边界(强制)
|
||||
|
||||
- **每个模块(限界上下文)只有一个 AI 负责,禁止并行修改同一模块**
|
||||
- **严格模块边界**:AI 只修改自己负责的目录(`services/<my-service>/` 或 `apps/<my-app>/`),禁止越界修改他人模块的源码
|
||||
- **只读自己模块文档**:AI 阅读文档时只读自己模块的 README/设计文档/known-issues 分区,**禁止阅读其他 AI 模块的内部文档和历史记录**(避免被前任同名 AI 的过时方案/审计结果误导)
|
||||
- **跨模块契约只读接口**:需要确认跨模块协同时,只读对方的对外接口(proto message / API 端点 / Kafka 事件 schema),不读对方内部实现
|
||||
- `shared-proto`、`shared-tokens`、`docs/` 由协调 AI 维护,开发 AI 只读引用
|
||||
- `infra/` 由 SRE AI 专门负责,业务 AI 不直接修改
|
||||
|
||||
### 14.3 分支命名规范(强制)
|
||||
### 14.3 分支开发规则(强制)
|
||||
|
||||
```
|
||||
<type>/<scope>-<task-id>-<ai-id>
|
||||
```
|
||||
1. **分支由人类创建**:人类决策者为每个任务/模块创建分支并分配给 AI,AI 不得自行创建分支
|
||||
2. **AI 禁止切换分支**:AI 不得执行 `git checkout -b`、`git switch`、`git branch` 等分支创建/切换命令
|
||||
3. **AI 禁止合并/推送 main**:`git merge`、`git push origin main` 由人类决策者执行,AI 只提交到当前分支
|
||||
4. **AI 提交流程**:`git add <files>` → `git commit -m "<type>(<scope>): <subject>"` → 通知人类合并
|
||||
5. **提交前校验**:commit 前必须本地通过 `pnpm run lint` + `pnpm run typecheck`(TS)/ `go vet ./...`(Go)/ `ruff check src/`(Python)
|
||||
6. **提交信息规范**:遵循 Conventional Commits(见 §7)
|
||||
|
||||
- `type`:feat / fix / refactor / docs / chore / test
|
||||
- `scope`:见 §7 提交规范 scope-enum(26 项)
|
||||
- `task-id`:任务简短描述(kebab-case)
|
||||
- `ai-id`:AI 唯一标识符(如 `ai01`、`ai02`、`coord`)
|
||||
### 14.4 跨模块变更顺序(强制)
|
||||
|
||||
**示例**:`feat/classes-add-pagination-ai01`
|
||||
|
||||
### 14.4 PR 与合并规则(强制)
|
||||
|
||||
1. **禁止直接 push 到 `main`**:所有变更通过 PR
|
||||
2. **PR 必须通过 CI**:lint / typecheck / build / test 全绿
|
||||
3. **PR 必须通过 CODEOWNERS review**:至少 1 人 approve
|
||||
4. **合并策略**:Squash Merge(默认),Rebase Merge(保留多 commit 历史),**禁止 Merge Commit**
|
||||
5. **特性分支寿命 ≤ 3 天**:超期需 rebase 最新 main
|
||||
6. **跨模块变更拆分**:按依赖顺序拆多个 PR(proto → service → gateway → frontend),协调 AI 按序合并
|
||||
|
||||
### 14.5 跨模块变更顺序(强制)
|
||||
|
||||
修改涉及多模块时,必须按以下顺序拆分 PR 并顺序合并:
|
||||
修改涉及多模块时,按依赖顺序依次在各模块分支完成,由人类决策者按顺序合并到 main:
|
||||
|
||||
1. `shared-proto`(proto 契约)
|
||||
2. 业务服务(classes / iam / core-edu 等)
|
||||
@@ -420,29 +424,22 @@ services/[service]/src/
|
||||
4. BFF(teacher-bff 等)
|
||||
5. 微前端(teacher-portal 等)
|
||||
|
||||
> 每合并一个 PR,后续 PR 的开发 AI 必须 rebase 最新 main 并重新校验。
|
||||
> 每合并一层到 main 后,再合并下一层。协调 AI 负责监督顺序,避免越级合并导致下游构建失败。
|
||||
|
||||
### 14.6 冲突处理规则
|
||||
### 14.5 冲突处理规则
|
||||
|
||||
- **文件冲突**:后合并的 PR rebase 最新 main,`git push --force-with-lease`(仅自己的分支)
|
||||
- **架构冲突**:由协调 AI 仲裁保留方案
|
||||
- **禁止 `git push --force` 到 main 或他人分支**
|
||||
- **分支冲突**:由人类决策者在合并时解决,或通知 AI 在分支上 `git rebase` 最新 main 后重新提交
|
||||
- **架构冲突**:由协调 AI 仲裁保留方案,通过新 commit 修正
|
||||
- **禁止 `git push --force` 到 main**:仅协调 AI 在事故时可 `--force-with-lease`
|
||||
|
||||
### 14.7 AI 身份标注(强制)
|
||||
### 14.6 经验沉淀规则(强制)
|
||||
|
||||
每个 PR 描述末尾必须追加:
|
||||
- **不记录工作日志**:AI 不在 known-issues.md 或任何文档中写"工作经验日志/流水账"
|
||||
- **只做经验沉淀**:遇到新的"场景→技术"映射,以索引式一行记录到 known-issues 对应模块分区
|
||||
- **不标注 AI 身份**:经验沉淀记录**不得出现 AI 标识**(如 ai01/ai02/coord 等),避免新同名 AI 误读
|
||||
- **模块内经验**:模块内的经验沉淀到各自服务 README/文档,同样不标注 AI 身份
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
**AI Agent**: <ai-id> (<负责模块>)
|
||||
**Branch**: <分支名>
|
||||
**Coordinator**: <协调 AI ai-id>
|
||||
```
|
||||
|
||||
每个 AI 完成任务后,在 `docs/troubleshooting/known-issues.md` "工作经验日志"区追加记录(见 §9.3)。
|
||||
|
||||
### 14.8 敏感文件保护
|
||||
### 14.7 敏感文件保护
|
||||
|
||||
以下文件修改需人类决策者额外审批:
|
||||
|
||||
@@ -455,77 +452,77 @@ services/[service]/src/
|
||||
|
||||
## 15. CI/CD 规范
|
||||
|
||||
> 详细配置见 `.github/workflows/`,本节为强制约束。
|
||||
> 详细配置见 `.github/workflows/ci.yml`,使用手册见 [cicd-runbook](../../docs/standards/cicd-runbook.md),设计文档见 [no-push-local-build-design](../../docs/superpowers/specs/2026-07-08-cicd-no-push-local-build-design.md)。
|
||||
|
||||
### 15.1 流水线阶段(强制)
|
||||
### 15.1 核心模式:no-push 本地构建
|
||||
|
||||
所有 PR 与 main 分支推送必须通过以下阶段:
|
||||
- **不推送镜像到 registry**:构建即部署,镜像只存在于构建机本地
|
||||
- **不依赖自建镜像**:全部使用官方镜像(node:22-alpine / golang:1.22-alpine / bufbuild/buf / docker:25-git)
|
||||
- **DooD 模式**:deploy job 容器挂载 `/var/run/docker.sock`,容器内 docker 命令作用于宿主机
|
||||
- **单文件管理**:一个 `.github/workflows/ci.yml` 管全部 CI/CD
|
||||
|
||||
| 阶段 | 并行 | 内容 | 失败策略 |
|
||||
| ---------------- | ------------ | ------------------------------------------ | -------- |
|
||||
| **lint** | ✅(按语言) | TS ESLint / Go vet / Python ruff | 失败阻断 |
|
||||
| **typecheck** | ✅ | TS `tsc --noEmit` | 失败阻断 |
|
||||
| **test** | ✅(按服务) | Go test / Vitest / pytest | 失败阻断 |
|
||||
| **build** | ✅(按服务) | Go build / nest build / next build | 失败阻断 |
|
||||
| **arch:scan** | ✅ | `pnpm run arch:scan` + 校验 arch.db 一致性 | 失败阻断 |
|
||||
| **docker-build** | ✅(按服务) | 构建镜像(不推送) | 失败阻断 |
|
||||
### 15.2 流水线阶段
|
||||
|
||||
> main 分支额外阶段:`docker-push`(推送镜像)+ `deploy`(部署到服务器)
|
||||
| 阶段 | 并行 | 内容 | 失败策略 |
|
||||
| ----------------- | ---- | ------------------------------------ | -------- |
|
||||
| **quality-ts** | ✅ | pnpm lint + typecheck + test + build | 失败阻断 |
|
||||
| **quality-go** | ✅ | go vet + build + test | 失败阻断 |
|
||||
| **quality-proto** | ✅ | buf lint + buf breaking | 失败阻断 |
|
||||
| **deploy** | 串行 | docker compose up --build + 健康检查 | 失败阻断 |
|
||||
|
||||
### 15.2 触发条件
|
||||
> 质量检查在分支推送时触发,部署仅在合并到 main 时触发。
|
||||
|
||||
| 事件 | 触发阶段 | 触发条件 |
|
||||
| ------------ | ---------------------------------------------------------- | ---------- |
|
||||
| PR 创建/更新 | lint + typecheck + test + build + arch:scan + docker-build | 所有路径 |
|
||||
| push 到 main | 上述全部 + docker-push + deploy | 合并后自动 |
|
||||
| tag `v*` | docker-push(tag 镜像)+ deploy(生产) | 手动打 tag |
|
||||
### 15.3 触发条件
|
||||
|
||||
### 15.3 镜像规范
|
||||
| 事件 | 触发阶段 | 触发条件 |
|
||||
| ----------------- | ----------------------------------------------- | -------------------------------- |
|
||||
| 分支 push / PR | quality-ts + quality-go + quality-proto(并行) | 分支推送自动 |
|
||||
| push 到 main | 上述全部 + deploy | 合并后自动 |
|
||||
| workflow_dispatch | 上述全部 + deploy | 手动触发,支持 `commit_sha` 回滚 |
|
||||
|
||||
- **镜像名**:`edu/<service>:<tag>`
|
||||
- **tag 策略**:
|
||||
- `latest`:main 分支最新
|
||||
- `<git-sha>`:每次构建的 commit SHA(可追溯)
|
||||
- `v<version>`:正式发布 tag
|
||||
- **镜像扫描**:docker-push 后自动运行 Trivy 扫描,CRITICAL 漏洞阻断部署
|
||||
> **不再支持 tag 发布**:no-push 模式下不用 `git tag v*` 触发。版本管理通过 commit SHA 追溯。
|
||||
|
||||
### 15.4 部署策略
|
||||
### 15.4 镜像规范
|
||||
|
||||
- **不推送到 registry**,本地构建本地使用
|
||||
- **不保留历史镜像**:layer cache 在宿主机本地,未变更的层秒过
|
||||
- **回滚**:`git revert` 重跑 CI,或 `workflow_dispatch` 指定 `commit_sha`
|
||||
|
||||
### 15.5 部署策略
|
||||
|
||||
- **目标环境**:服务器 Docker Compose(P1-P2 阶段),K8s(P3+ 阶段)
|
||||
- **部署方式**:SSH 到服务器,`docker compose pull && docker compose up -d`
|
||||
- **健康检查**:部署后轮询 `/healthz` 端点,连续 3 次失败回滚
|
||||
- **回滚**:`docker compose rollback`(上一版本镜像 tag)
|
||||
|
||||
### 15.5 Secrets 管理
|
||||
|
||||
- **CI Secrets**:存于 GitHub Actions secrets / Gitea Actions secrets
|
||||
- **部署服务器**:`.env` 文件由 SRE AI 管理,不进版本库
|
||||
- **JWT_SECRET**:生产环境强随机值,不与开发环境共享
|
||||
- **部署方式**:`docker compose up -d --build --remove-orphans`(在 `/opt/edu/` 目录)
|
||||
- **部署目录**:`/opt/edu/`(compose 文件)+ `/opt/edu/repo/`(CI 同步的源码,供 build.context 使用)
|
||||
- **健康检查**:部署后轮询 `/healthz` 端点(10 次 × 6 秒),失败输出容器日志
|
||||
- **回滚**:`git revert + push` 或 `workflow_dispatch` 指定 `commit_sha`
|
||||
|
||||
### 15.6 必需的 CI 文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ------------------------------ | ------------------------------------------- |
|
||||
| `.github/workflows/ci.yml` | 主流水线(lint + typecheck + test + build) |
|
||||
| `.github/workflows/docker.yml` | Docker 镜像构建与推送(main + tag 触发) |
|
||||
| `.github/workflows/deploy.yml` | 部署到服务器(main 触发,需环境审批) |
|
||||
| 文件 | 用途 |
|
||||
| --------------------------------- | ------------------------------------- |
|
||||
| `.github/workflows/ci.yml` | 唯一 CI/CD 流水线(quality + deploy) |
|
||||
| `infra/docker-compose.deploy.yml` | 部署用 compose(build: 替代 image:) |
|
||||
| `infra/docker-compose.tools.yml` | 一次性预拉所有 CI 镜像 |
|
||||
| `infra/deploy.env.example` | 部署环境变量模板 |
|
||||
|
||||
### 15.7 参考实现(强制参照)
|
||||
### 15.7 actrunner 配置
|
||||
|
||||
**所有 CI/CD 配置必须参照 `E:\Desktop\CICD\` 项目(同机 Gitea 仓库),不另行自创。**
|
||||
```toml
|
||||
# /etc/gitea/act_runner/config.yaml
|
||||
container:
|
||||
valid_volumes:
|
||||
- /var/run/docker.sock
|
||||
```
|
||||
|
||||
- **参考项目位置**:`E:\Desktop\CICD\.gitea\workflows\ci.yml`(同机单仓库 CI 实现)
|
||||
- **关键配置点(必须沿用)**:
|
||||
- **Runner 标签**:`ubuntu-latest`(actrunner 已配置;参考项目用 `CDCD`,Edu 改为 `ubuntu-latest`)
|
||||
- **容器化运行**:TS 类 job 必须使用 `container: dockerreg.eazygame.cn/node-with-docker:22`(带 docker 的 node 22 镜像,用于在 job 内执行 `docker build`/`docker run`)
|
||||
- **Go 类 job**:使用 `container: golang:1.22-alpine`(仅 quality 阶段),`docker-build` 阶段切换到 `node-with-docker:22` 镜像 + `setup-go`
|
||||
- **部署方式**:保留 `docker compose`(不照搬参考项目的 `docker run` 单容器方式,因 Edu 是微服务多容器)
|
||||
- **代理配置**:runner 已配置全局代理,CI 内不再设置 npm/docker 代理(参考项目中的 `172.17.0.1:7890` 代理段不要照搬)
|
||||
- **Next.js standalone 构建**:teacher-portal 沿用参考项目的 standalone 模式(`output: 'standalone'` + 复制 public/.next/static)
|
||||
- **网络**:部署用 `edu-shared` 外部网络连接服务器已有 MySQL/Redis(参考项目用 `1panel-network`,Edu 不同)
|
||||
- **改动需同步**:修改任何 workflow 文件前,先比对参考项目同类配置,确保不偏离上述约定
|
||||
### 15.8 镜像预拉(一次性)
|
||||
|
||||
> 当参考项目与本规范冲突时,以本规范为准(Edu 是微服务架构,参考项目是单体 Next.js)。
|
||||
部署前在服务器执行:
|
||||
|
||||
```bash
|
||||
docker compose -f infra/docker-compose.tools.yml pull
|
||||
```
|
||||
|
||||
拉取清单:node:22-alpine、golang:1.22-alpine、bufbuild/buf:latest、docker:25-git、node:20-alpine、alpine:3.20、mysql:8.0(开发测试)、redis:7-alpine(开发测试)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
apps/admin-portal/.env.example
Normal file
21
apps/admin-portal/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# admin-portal 环境变量
|
||||
# GraphQL endpoint(经 api-gateway 代理到 teacher-bff admin 命名空间)
|
||||
API_GATEWAY_URL=http://localhost:8080
|
||||
|
||||
# MSW mock 开关(上游就绪后设为 disabled)
|
||||
NEXT_PUBLIC_API_MOCKING=enabled
|
||||
|
||||
# MF 开关(Shell 就绪后设为 true)
|
||||
NEXT_PUBLIC_MF_ENABLED=false
|
||||
|
||||
# Shell URL(standalone 壳用于 /api/ready 检查)
|
||||
NEXT_PUBLIC_SHELL_URL=http://localhost:4000
|
||||
|
||||
# push-gateway WebSocket(实时通知)
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:8081/ws
|
||||
|
||||
# OTel collector
|
||||
NEXT_PUBLIC_OTEL_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Web Vitals 上报端点
|
||||
NEXT_PUBLIC_WEB_VITALS_ENDPOINT=/api/admin/web-vitals
|
||||
36
apps/admin-portal/Dockerfile
Normal file
36
apps/admin-portal/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# admin-portal Dockerfile
|
||||
# 仲裁:no-push 本地构建,使用官方镜像
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
# 复制 workspace 配置
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/admin-portal/ ./apps/admin-portal/
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 构建
|
||||
WORKDIR /app/apps/admin-portal
|
||||
RUN pnpm run build
|
||||
|
||||
# 运行时
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4003
|
||||
|
||||
COPY --from=builder /app/apps/admin-portal/.next/standalone ./
|
||||
COPY --from=builder /app/apps/admin-portal/.next/static ./apps/admin-portal/.next/static
|
||||
COPY --from=builder /app/apps/admin-portal/public ./apps/admin-portal/public
|
||||
|
||||
EXPOSE 4003
|
||||
|
||||
CMD ["node", "apps/admin-portal/server.js"]
|
||||
284
apps/admin-portal/docs/01-understanding.md
Normal file
284
apps/admin-portal/docs/01-understanding.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 模块理解确认书 — admin-portal
|
||||
|
||||
> AI:ai16(TS/React · 管理场景域前端 remote)
|
||||
> 阶段:阶段 1 交付物(仲裁后修订版 v2)
|
||||
> 日期:2026-07-10
|
||||
> 关联:
|
||||
>
|
||||
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §1.1a/1.1b/§5.4
|
||||
> - [AI 分配方案](../../../docs/architecture/ai-allocation.md) §5 ai16
|
||||
> - [总统最终裁决](../../../docs/architecture/president-final-rulings.md) §5.1-5.5(ISSUE-044~048)
|
||||
> - [admin-portal 对接契约](../../architecture/issues/contracts/admin-portal_contract.md)
|
||||
> - [admin-portal 工作线](../../architecture/issues/worklines/admin-portal_workline.md)
|
||||
> - [known-issues §2.16](../../../docs/troubleshooting/known-issues.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. 仲裁结果摘要(ARB-001~006)
|
||||
|
||||
| 编号 | 议题 | 裁决 |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| ARB-001 | BFF 归属(ISSUE-044) | 复用 teacher-bff GraphQL endpoint,不新建 admin-bff;ai03 预留 `admin.*` 命名空间 |
|
||||
| ARB-002 | 错误码前缀(ISSUE-045) | `ADMIN_` 前缀(合并 `SCHOOL_`/`ORG_` 入 `ADMIN_`);i18n key `error.admin.*` |
|
||||
| ARB-003 | 权限点命名(ISSUE-046) | admin 自身资源用 `ADMIN_*`;跨服务资源保留原前缀(`IAM_USER_READ` 等) |
|
||||
| ARB-004 | GraphQL 客户端(ISSUE-047) | MF 模式复用 Shell 暴露的 GraphQL client 单例;standalone 模式自建 urql client |
|
||||
| ARB-005 | 审计日志归属(ISSUE-048) | 审计事件归 iam 发布到 `edu.iam.audit.created`;teacher-bff 消费聚合;admin-portal 经 GraphQL `auditLogs` Query 消费 |
|
||||
| ARB-006 | WebSocket 实时通知 | 接入 push-gateway `GET /ws`(审计告警/异常登录/系统异常),不再用轮询 |
|
||||
| 端口分配 | admin-portal dev server | **4003**(非旧文档的 3003) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 我在架构中的位置
|
||||
|
||||
- **层级**:L2 微前端层(004 §3.1 六层架构中的前端层)
|
||||
- **MF 角色**:**Remote 子应用**,挂载到 teacher-portal Shell(主应用,端口 4000)
|
||||
- **上游(谁调用我)**:浏览器(系统管理员 / 校管理员)
|
||||
- **下游(同步)**:api-gateway(GraphQL `POST /api/admin/graphql` + REST `/api/auth/login`)
|
||||
- **下游(推送)**:push-gateway `GET /ws`(WebSocket 实时通知,ARB-006)
|
||||
- **BFF 对接**:复用 teacher-bff GraphQL admin 命名空间(ARB-001),不新建 admin-bff
|
||||
- **通信方式**:GraphQL(业务数据)+ HTTP(登录)+ WebSocket(实时通知)
|
||||
- **不直连**:前端不直连任何业务服务或 BFF 后端实例,全部经 api-gateway 代理
|
||||
|
||||
**说明**:
|
||||
|
||||
- admin-portal 作为 Remote 子应用,挂载到 teacher-portal Shell
|
||||
- Shell 暴露 `GraphQLProvider` / `useGraphQLClient` / `AppShell` / `useAuth` / `usePermission` 等,admin-portal MF 模式复用,standalone 模式自建
|
||||
- 路由前缀 `/admin/*` 由 teacher-portal Shell 动态加载 admin-portal Remote 模块(暴露 `./AdminApp`)
|
||||
- 场景域 BFF 复用策略(004 §5.4 + ARB-001):复用 teacher-bff GraphQL endpoint,admin 命名空间(`admin.*` Query/Mutation)
|
||||
|
||||
## 2. 我的限界上下文
|
||||
|
||||
### 2.1 我负责的聚合 / 实体(前端视图模型)
|
||||
|
||||
- 用户、角色、权限、视口(管理场景域前端视图,调 iam 经 GraphQL)
|
||||
- 组织(学校/年级/班级层级)
|
||||
- 学校设置(system 路由)
|
||||
- 班级/教师/学生全局管理(admin 视角,只读 + 状态管理)
|
||||
- 审计日志(只读,经 teacher-bff GraphQL `auditLogs` Query 消费)
|
||||
- 管理仪表盘(聚合统计 + 服务健康)
|
||||
- 实时通知(WebSocket 推送:审计告警/异常登录/系统异常)
|
||||
|
||||
### 2.2 业务领域
|
||||
|
||||
- **管理场景域**(前端场景域:管理场景域,对应后端 iam 限界上下文 + teacher-bff admin 命名空间)
|
||||
|
||||
### 2.3 不负责
|
||||
|
||||
- 教学业务编排(班级/考试/作业/成绩 CRUD,归 teacher-portal)
|
||||
- 学生作答界面(归 student-portal)
|
||||
- 家长多子女切换(归 parent-portal)
|
||||
- 教师仪表盘(归 teacher-portal,admin-portal 仅有管理仪表盘)
|
||||
- 审计事件发布(归 iam,admin-portal 仅消费)
|
||||
|
||||
### 2.4 数据范围
|
||||
|
||||
- DataScope L3-L5(校管理员 L3 学校 / 区教研员 L4 / 系统管理员 L5 全平台)
|
||||
- 不出现 L1(班级)/ L2(年级)级别的教师数据视角
|
||||
|
||||
## 3. 我与外部的契约
|
||||
|
||||
### 3.1 消费的后端 API(经 api-gateway 代理)
|
||||
|
||||
| 路径 | Method | 下游 BFF/服务 | 用途 |
|
||||
| ------------------------- | ------ | ----------------------------- | ------------------------------------------------- |
|
||||
| `/api/admin/graphql` | POST | teacher-bff(admin 命名空间) | 全部业务 GraphQL 查询(ARB-001) |
|
||||
| `/api/auth/login` | POST | api-gateway → iam | 登录(standalone 模式用,MF 模式复用 Shell 登录) |
|
||||
| `GET /ws`(push-gateway) | WS | push-gateway | 实时通知(审计告警/异常登录/系统异常,ARB-006) |
|
||||
|
||||
### 3.2 GraphQL Operations(contract §2.4)
|
||||
|
||||
admin-portal 消费 teacher-bff admin 命名空间的 18 个 GraphQL operations:
|
||||
|
||||
| Operation | 类型 | 用途 |
|
||||
| ------------------------------------------------ | -------------- | ------------------------------- |
|
||||
| `currentUser` | Query | 当前管理员信息 |
|
||||
| `adminUsers` / `adminUser(id)` | Query | 用户列表 / 详情 |
|
||||
| `createUser` / `updateUser` / `toggleUserStatus` | Mutation | 用户 CRUD |
|
||||
| `adminRoles` | Query | 角色列表(含权限) |
|
||||
| `createRole` / `updateRolePermissions` | Mutation | 角色 CRUD + 权限矩阵 |
|
||||
| `adminPermissions` | Query | 全量权限点(按 resource) |
|
||||
| `adminViewports(scope)` | Query | 视口配置列表 |
|
||||
| `updateViewport` | Mutation | 视口配置更新 |
|
||||
| `adminOrganization(parentId)` | Query | 组织树 |
|
||||
| `adminClasses` | Query | 班级管理(全局) |
|
||||
| `adminTeachers` | Query | 教师管理 |
|
||||
| `adminStudents` | Query | 学生管理 |
|
||||
| `auditLogs(filter)` | Query | 审计日志(聚合 iam AuditEvent) |
|
||||
| `adminDashboard` | Query | 管理员仪表盘聚合 |
|
||||
| `systemSettings` / `updateSystemSettings` | Query/Mutation | 学校设置 |
|
||||
|
||||
### 3.3 推送契约(ARB-006)
|
||||
|
||||
admin-portal 接入 push-gateway `GET /ws`,消费 3 类实时通知:
|
||||
|
||||
| 通知类型 | severity | 用途 |
|
||||
| ---------------- | -------- | ------------------------- |
|
||||
| `audit_alert` | warning | 审计告警(敏感操作触发) |
|
||||
| `abnormal_login` | error | 异常登录(异地/异常时段) |
|
||||
| `system_error` | error | 系统异常(服务降级/宕机) |
|
||||
|
||||
> **不再用轮询**:旧文档的"60s 轮询监控 + 5min 轮询统计"策略已被 ARB-006 替换为 WebSocket 实时推送。开发期用 mock-socket + 30s 定时器模拟推送。
|
||||
|
||||
### 3.4 proto 不直接消费
|
||||
|
||||
前端不调用 gRPC,teacher-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量(TS 文件,非 proto 生成)。
|
||||
|
||||
## 4. 我的技术栈
|
||||
|
||||
| 维度 | 选型 | 说明 |
|
||||
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 框架 | Next.js 14+(App Router) | server components 默认,client components 按需 |
|
||||
| 语言 | TypeScript 5.5+(strict) | 沿用 tsconfig.base.json |
|
||||
| 微前端 | Module Federation 2.0(@module-federation/nextjs-mf) | admin-portal = **Remote**,端口 4003 |
|
||||
| 数据层 | urql GraphQL client | standalone 自建 / MF 复用 Shell 单例(ARB-004) |
|
||||
| 样式 | Tailwind CSS 3.4+ | 配合设计令牌三层(复用 Shell) |
|
||||
| UI 组件库 | shadcn/ui 风格(复用 Shell `@edu/ui-components`) | admin-portal 内部 `ui.tsx` 封装 PaperCard/Button/Table 等纸面组件 |
|
||||
| 表单 | react-hook-form + zod | 用户表单/学校设置 |
|
||||
| 拖拽排序 | @dnd-kit/sortable | 视口配置拖拽排序 |
|
||||
| 图表 | recharts | 仪表盘趋势表(实际以纯表格为主,纸面风格) |
|
||||
| i18n | 轻量 i18n(`messages` 字典 + `t()` 函数) | 命名空间 `admin.*`,不依赖 next-intl |
|
||||
| Mock | MSW(Mock Service Worker)+ mock-socket | 开发期拦截全部 GraphQL/HTTP + WebSocket mock |
|
||||
| Web Vitals | web-vitals v4 | LCP/CLS/FCP/INP/TTFB 采集,`navigator.sendBeacon` 上报 |
|
||||
| A11y | eslint-plugin-jsx-a11y(error 级) | WCAG 2.2 AA |
|
||||
| 字体 | Inter(sans)/ Fraunces(serif)/ JetBrains Mono(mono) | RootLayout 加载(standalone);MF 模式复用 Shell |
|
||||
| 测试 | Vitest + @testing-library/react | jsdom 环境,覆盖率 ≥ 80% |
|
||||
| ESLint | ESLint 9 flat config | jsx-a11y + tseslint + prettier(注:`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容,已移除) |
|
||||
|
||||
> **技术栈差异**(vs teacher-portal):MF 角色(Remote)、GraphQL 为主(非 REST)、消费 WebSocket 推送(非 SSE)、纸面 UI 风格、数据范围 L3-L5。
|
||||
|
||||
## 5. 我的阶段归属
|
||||
|
||||
- **阶段**:P6(硬化阶段),批次 5
|
||||
- **当前状态**:✅ **已实现**(P2-P6 全阶段交付完成,含 11 路由 + MSW mock + A11y + Web Vitals + Dockerfile)
|
||||
- **依赖上游阶段**:P6(依赖 teacher-portal Shell 已就绪 + teacher-bff GraphQL admin 命名空间 + iam 已实现用户/角色/权限/视口 CRUD + push-gateway WebSocket)
|
||||
|
||||
## 6. 路由表(11 路由,contract §1.2)
|
||||
|
||||
| 路由 | 页面 | 权限点 | 数据范围 |
|
||||
| --------------------- | ---------------- | ---------------------- | -------- |
|
||||
| `/admin/dashboard` | 管理员仪表盘 | `ADMIN_DASHBOARD_VIEW` | L3-L5 |
|
||||
| `/admin/users` | 用户管理 | `IAM_USER_READ` | L3-L5 |
|
||||
| `/admin/roles` | 角色权限管理 | `IAM_ROLE_READ` | L3-L5 |
|
||||
| `/admin/permissions` | 权限点管理 | `IAM_PERMISSION_READ` | L5 |
|
||||
| `/admin/viewports` | 视口配置 | `IAM_VIEWPORT_READ` | L5 |
|
||||
| `/admin/organization` | 组织管理 | `ADMIN_ORG_MANAGE` | L3-L5 |
|
||||
| `/admin/system` | 学校设置 | `ADMIN_SYSTEM_MANAGE` | L5 |
|
||||
| `/admin/classes` | 班级管理(全局) | `ADMIN_CLASS_READ` | L3-L5 |
|
||||
| `/admin/teachers` | 教师管理 | `ADMIN_TEACHER_READ` | L3-L5 |
|
||||
| `/admin/students` | 学生管理 | `ADMIN_STUDENT_READ` | L3-L5 |
|
||||
| `/admin/audit-logs` | 审计日志 | `ADMIN_AUDIT_READ` | L5 |
|
||||
|
||||
> 权限点前缀规则(ARB-003):admin 自身资源用 `ADMIN_*`;跨服务资源保留原前缀(如 `IAM_USER_READ`)。`ADMIN_*` 常量由 coord 维护于 `packages/contracts/src/permissions.ts`,admin-portal 引用 `src/lib/permissions.ts`,不硬编码。
|
||||
|
||||
## 7. 黄金模板对齐清单(前端等价)
|
||||
|
||||
| 对齐项 | 后端等价 | admin-portal 实现 | 状态 |
|
||||
| --------------------- | ------------------------------------- | ------------------------------------------------------------- | ---- |
|
||||
| 权限校验 | `@RequirePermission(Permissions.XXX)` | `usePermission().hasPermission("XXX")` Hook + `AuthGuard` | ✅ |
|
||||
| 错误码前缀 | `IAM_*` / `ADMIN_*` | `CONSUMED_ERROR_PREFIXES` 常量 + i18n 路由 | ✅ |
|
||||
| logger | pino | console + 结构化(生产 → 上报) | ✅ |
|
||||
| metrics | prom-client `/metrics` | Web Vitals v4 → `navigator.sendBeacon` 上报 | ✅ |
|
||||
| /health + /ready | `GET /healthz` `GET /readyz` | Next.js Route Handler `/api/health` + `/api/ready` | ✅ |
|
||||
| 优雅关闭 | SIGTERM handler | N/A(Next.js 无长连接) | ✅ |
|
||||
| 测试覆盖率 ≥ 80% | Vitest | Vitest + @testing-library/react | ⚠️ |
|
||||
| Dockerfile 多阶段构建 | builder + runtime | `node:22-alpine` 多阶段,standalone 输出,EXPOSE 4003 | ✅ |
|
||||
| Zod 输入验证 | class-validator + Zod schema | react-hook-form + zod | ✅ |
|
||||
| GlobalErrorFilter | NestJS 全局异常过滤器 | React ErrorBoundary + Toast 错误处理 | ✅ |
|
||||
| 设计令牌三层 | — | `globals.css`(primitive/semantic)+ tailwind-theme | ✅ |
|
||||
| A11y 工具集 | — | eslint-plugin-jsx-a11y(error 级)+ skip-link + focus-visible | ✅ |
|
||||
| Module Federation | — | `next.config.js` Remote 角色,`NEXT_PUBLIC_MF_ENABLED` 控制 | ✅ |
|
||||
| MSW mock | — | 18 GraphQL operation + `/api/auth/login` mock | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 附:admin-portal 实现清单(v2,仲裁后修订)
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
apps/admin-portal/
|
||||
├─ package.json # 依赖(urql/msw/@dnd-kit/web-vitals/eslint-plugin-jsx-a11y)
|
||||
├─ next.config.js # MF Remote 配置 + rewrites(GraphQL 代理)
|
||||
├─ tsconfig.json # extends tsconfig.base.json
|
||||
├─ tailwind.config.js # paper/ink/accent/rule 色板
|
||||
├─ eslint.config.js # ESLint 9 flat config(jsx-a11y + tseslint + prettier)
|
||||
├─ Dockerfile # 多阶段构建,node:22-alpine,standalone
|
||||
├─ vitest.config.ts # jsdom + coverage ≥80%
|
||||
├─ .env.example # 环境变量模板
|
||||
├─ public/mockServiceWorker.js # MSW worker
|
||||
└─ src/
|
||||
├─ app/
|
||||
│ ├─ globals.css # 设计令牌 + A11y 样式
|
||||
│ ├─ layout.tsx # RootLayout(字体加载)
|
||||
│ ├─ page.tsx # 根页面(redirect /admin/dashboard)
|
||||
│ ├─ admin-app.tsx # MF Remote 入口(exposes ./AdminApp)
|
||||
│ ├─ login/page.tsx # 登录页(standalone mock 登录)
|
||||
│ ├─ admin/
|
||||
│ │ ├─ layout.tsx # admin 路由组 layout(Provider 链 + AuthGuard + AdminShell)
|
||||
│ │ ├─ dashboard/page.tsx # 管理仪表盘
|
||||
│ │ ├─ users/page.tsx # 用户管理
|
||||
│ │ ├─ roles/page.tsx # 角色权限
|
||||
│ │ ├─ permissions/page.tsx # 权限点管理
|
||||
│ │ ├─ viewports/page.tsx # 视口配置(@dnd-kit 拖拽)
|
||||
│ │ ├─ organization/page.tsx # 组织管理
|
||||
│ │ ├─ system/page.tsx # 学校设置
|
||||
│ │ ├─ classes/page.tsx # 班级管理
|
||||
│ │ ├─ teachers/page.tsx # 教师管理
|
||||
│ │ ├─ students/page.tsx # 学生管理
|
||||
│ │ └─ audit-logs/page.tsx # 审计日志(CSV 导出)
|
||||
│ └─ api/
|
||||
│ ├─ health/route.ts # Liveness 检查
|
||||
│ └─ ready/route.ts # Readiness 检查
|
||||
├─ components/
|
||||
│ ├─ ui.tsx # 通用 UI(PaperCard/Button/Input/Select/Badge/Table/Pagination)
|
||||
│ ├─ admin-shell.tsx # AdminShell(左栏 11 项导航 + 主内容 + 跳过链接)
|
||||
│ ├─ user-management-table.tsx # 用户管理表格
|
||||
│ ├─ user-form-modal.tsx # 用户表单弹窗
|
||||
│ ├─ role-permission-matrix.tsx# 角色权限矩阵
|
||||
│ ├─ organization-tree.tsx # 组织树(A11y treeitem)
|
||||
│ ├─ notification-panel.tsx # 通知面板(WebSocket)
|
||||
│ ├─ msw-initializer.tsx # MSW 初始化
|
||||
│ └─ web-vitals-initializer.tsx# Web Vitals 初始化
|
||||
├─ hooks/
|
||||
│ ├─ use-graphql.ts # useGraphQuery + useGraphMutation
|
||||
│ ├─ use-users.ts # 用户 CRUD
|
||||
│ ├─ use-roles.ts # 角色 CRUD
|
||||
│ ├─ use-permissions.ts # 权限点查询
|
||||
│ ├─ use-viewports.ts # 视口配置
|
||||
│ ├─ use-organization.ts # 组织树
|
||||
│ ├─ use-classes.ts # 班级
|
||||
│ ├─ use-teachers.ts # 教师
|
||||
│ ├─ use-students.ts # 学生
|
||||
│ ├─ use-audit-logs.ts # 审计日志 + CSV 导出
|
||||
│ ├─ use-dashboard.ts # 仪表盘
|
||||
│ ├─ use-system-settings.ts # 学校设置
|
||||
│ └─ use-websocket.ts # WebSocket 实时通知
|
||||
├─ providers/
|
||||
│ ├─ graphql-provider.tsx # GraphQLProvider(standalone 自建 urql client)
|
||||
│ ├─ auth-provider.tsx # AuthProvider + useAuth + usePermission
|
||||
│ └─ toast-provider.tsx # ToastProvider + useToast
|
||||
├─ lib/
|
||||
│ ├─ graphql-client.ts # urql client + 18 个 GraphQL operations
|
||||
│ ├─ auth.ts # 认证工具
|
||||
│ ├─ permissions.ts # 权限常量(ADMIN_ + IAM_)
|
||||
│ ├─ i18n.ts # 轻量 i18n
|
||||
│ └─ web-vitals.ts # Web Vitals 采集
|
||||
├─ types/
|
||||
│ └─ view-models.ts # 视图模型类型定义
|
||||
└─ mocks/
|
||||
├─ browser.ts # MSW Browser Worker
|
||||
├─ server.ts # MSW Node Server(vitest)
|
||||
├─ fixtures.ts # mock 数据库
|
||||
└─ handlers.ts # MSW handlers(18 GraphQL + login)
|
||||
```
|
||||
|
||||
### 质量校验
|
||||
|
||||
- `pnpm run typecheck` → ✅ 通过(0 errors)
|
||||
- `pnpm run lint` → ✅ 通过(0 errors)
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai16 (admin-portal remote)
|
||||
**Branch**: admin-portal-arbitration-done-TaFkyb
|
||||
**Coordinator**: coord-ai
|
||||
704
apps/admin-portal/docs/02-architecture-design.md
Normal file
704
apps/admin-portal/docs/02-architecture-design.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# 模块架构设计文档 — admin-portal
|
||||
|
||||
> AI:ai16(TS/React · 管理场景域前端 remote)
|
||||
> 阶段:阶段 2 交付物(仲裁后修订版 v2)
|
||||
> 日期:2026-07-10
|
||||
> 关联:
|
||||
>
|
||||
> - [阶段 1 理解确认书](./01-understanding.md)
|
||||
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §5.4
|
||||
> - [总统最终裁决](../../../docs/architecture/president-final-rulings.md) §5.1-5.5(ISSUE-044~048)
|
||||
> - [admin-portal 对接契约](../../architecture/issues/contracts/admin-portal_contract.md)
|
||||
> - [pending-features P6](../../../docs/architecture/roadmap/pending-features.md)
|
||||
> - [known-issues §2.16](../../../docs/troubleshooting/known-issues.md)
|
||||
> 状态:已实现(P2-P6 全阶段交付完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块内部分层图(admin-portal Remote + Shell 引用 + Gateway + push-gateway)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Browser["浏览器(系统/校管理员)"]
|
||||
URL[URL 路由 /admin/*]
|
||||
end
|
||||
|
||||
subgraph Shell["teacher-portal(Shell 宿主,端口 4000)"]
|
||||
AppShell[AppShell<br/>左栏导航 + 主内容区<br/>按 scope=admin 过滤视口]
|
||||
RootLayout[RootLayout<br/>字体/令牌/i18n Provider]
|
||||
Router[Next.js App Router<br/>动态加载 admin Remote]
|
||||
SharedDeps["共享依赖暴露(singleton)<br/>react/react-dom/urql/graphql<br/>@edu/ui-tokens/@edu/ui-components/@edu/hooks/@edu/contracts"]
|
||||
GraphQLProviderShell[GraphQLProvider<br/>Shell 暴露 urql client 单例]
|
||||
end
|
||||
|
||||
subgraph RemoteAdmin["admin-portal(Remote 子应用,端口 4003)"]
|
||||
AdminApp[AdminApp<br/>MF Remote 入口 exposes ./AdminApp]
|
||||
AdminPages[管理场景页面<br/>dashboard/users/roles/permissions/viewports/organization<br/>classes/teachers/students/audit-logs/system]
|
||||
AdminComponents["admin 特有组件<br/>UserManagementTable/RolePermissionMatrix<br/>ViewportConfigEditor/OrganizationTree/NotificationPanel"]
|
||||
AdminHooks["admin 业务 Hooks<br/>useUsers/useRoles/usePermissions/useViewports<br/>useOrganization/useClasses/useTeachers/useStudents<br/>useAuditLogs/useDashboard/useSystemSettings/useWebSocket"]
|
||||
end
|
||||
|
||||
subgraph Gateway["api-gateway"]
|
||||
GW[Gin 路由/鉴权/限流]
|
||||
end
|
||||
|
||||
subgraph PushGateway["push-gateway"]
|
||||
WS[WebSocket :8081/ws]
|
||||
end
|
||||
|
||||
subgraph Backend["后端服务"]
|
||||
IAM[iam<br/>用户/角色/权限/视口 CRUD + AuditEvent 发布]
|
||||
BFF[teacher-bff<br/>GraphQL admin 命名空间 聚合]
|
||||
end
|
||||
|
||||
Browser --> URL
|
||||
URL --> RootLayout
|
||||
RootLayout --> AppShell
|
||||
AppShell --> Router
|
||||
Router -->|动态加载 /admin/*| AdminApp
|
||||
AdminApp --> AdminPages
|
||||
|
||||
AdminPages -->|消费 singleton| SharedDeps
|
||||
SharedDeps --> GraphQLProviderShell
|
||||
AdminPages --> AdminComponents
|
||||
AdminPages --> AdminHooks
|
||||
|
||||
AdminHooks -->|useGraphQuery/useGraphMutation| GraphQLProviderShell
|
||||
GraphQLProviderShell -->|POST /api/admin/graphql| GW
|
||||
GW --> BFF
|
||||
BFF --> IAM
|
||||
|
||||
AdminHooks -->|useWebSocket| WS
|
||||
IAM -.->|Kafka edu.iam.audit.created| BFF
|
||||
|
||||
AppShell -->|fetch /api/auth/login| GW
|
||||
```
|
||||
|
||||
### 1.1 Remote 与 Shell 的职责边界
|
||||
|
||||
| 职责 | 归属 | 说明 |
|
||||
| -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| RootLayout(字体/令牌/Provider) | teacher-portal Shell | admin-portal MF 模式复用;standalone 模式自建 |
|
||||
| AppShell(左栏 + 主内容区) | admin-portal 内部 | `admin-shell.tsx`,11 项导航 + 跳过链接 A11y |
|
||||
| 路由表 `/admin/*` | admin-portal | `src/app/admin/*/page.tsx` |
|
||||
| 登录页 | admin-portal | standalone 模式 `src/app/login/page.tsx`(mock 登录);MF 模式复用 Shell `/login` |
|
||||
| rewrites `/api/admin/graphql` | admin-portal | `next.config.js` rewrites 代理到 api-gateway |
|
||||
| GraphQLProvider | Shell 暴露 / 自建 | MF 模式复用 Shell 单例(ARB-004);standalone 模式 `graphql-provider.tsx` 自建 urql client |
|
||||
| 管理场景页面 | admin-portal Remote | `/admin/*` 下的所有 page.tsx |
|
||||
| 管理特有组件 | admin-portal Remote | UserManagementTable 等 6 个 |
|
||||
| 管理业务 Hooks | admin-portal Remote | 13 个业务 Hook + useWebSocket |
|
||||
| 共享组件库 | Shell 暴露 | `@edu/ui-components` |
|
||||
| 共享 Hooks | Shell 暴露 | `@edu/hooks` |
|
||||
|
||||
### 1.2 MF 配置(admin-portal/next.config.js,Remote 角色)
|
||||
|
||||
```javascript
|
||||
const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@edu/ui-tokens", "@edu/ui-components", "@edu/hooks"],
|
||||
async rewrites() {
|
||||
const gatewayUrl = process.env.API_GATEWAY_URL || "http://localhost:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/admin/graphql",
|
||||
destination: `${gatewayUrl}/api/admin/graphql`,
|
||||
},
|
||||
{ source: "/api/:path*", destination: `${gatewayUrl}/api/:path*` },
|
||||
];
|
||||
},
|
||||
webpack(config, { isServer }) {
|
||||
if (process.env.NEXT_PUBLIC_MF_ENABLED === "true") {
|
||||
config.plugins.push(
|
||||
new NextFederationPlugin({
|
||||
name: "admin_app",
|
||||
filename: "static/chunks/remoteEntry.js",
|
||||
exposes: { "./AdminApp": "./src/app/admin-app.tsx" },
|
||||
remotes: {
|
||||
teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true, requiredVersion: "^18.3.0" },
|
||||
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
|
||||
urql: { singleton: true },
|
||||
graphql: { singleton: true },
|
||||
"@edu/ui-tokens": { singleton: true },
|
||||
"@edu/ui-components": { singleton: true },
|
||||
"@edu/hooks": { singleton: true },
|
||||
},
|
||||
extraOptions: { exposePages: false },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
module.exports = nextConfig;
|
||||
```
|
||||
|
||||
> **说明**:
|
||||
>
|
||||
> - `NEXT_PUBLIC_MF_ENABLED` 环境变量控制 MF 启用(standalone 模式默认 `false`,MF 模式设 `true`)
|
||||
> - admin-portal 暴露 `./AdminApp` 模块(非旧文档的 `./pages`),供 Shell 动态 import
|
||||
> - `shared` 全部 `singleton: true`,确保 urql/graphql/react 单例(避免多实例报错)
|
||||
> - admin-portal 自身实现 `rewrites`(代理 `/api/admin/graphql` 到 api-gateway),standalone 模式可独立运行
|
||||
|
||||
## 2. 领域模型(前端视角)
|
||||
|
||||
前端不持有业务聚合根,仅持有"视图模型"(ViewModel)和"会话状态"。定义于 `src/types/view-models.ts`。
|
||||
|
||||
### 2.1 视图模型清单
|
||||
|
||||
```typescript
|
||||
// 数据范围
|
||||
type DataScope = "ALL" | "SCHOOL" | "GRADE" | "CLASS" | "DISTRICT";
|
||||
|
||||
// 用户管理
|
||||
interface UserViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: { id: string; name: string; code: string }[];
|
||||
status: "active" | "disabled" | "locked";
|
||||
dataScope: DataScope;
|
||||
organizationId: string | null;
|
||||
schoolName?: string;
|
||||
lastLoginAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// 角色管理
|
||||
interface RoleViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
permissions: PermissionViewModel[];
|
||||
userCount: number;
|
||||
dataScope: DataScope;
|
||||
isSystem: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// 权限管理
|
||||
interface PermissionViewModel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
// 视口配置
|
||||
interface ViewportConfigViewModel {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
requiredPermission: string | null;
|
||||
scope: "teacher" | "student" | "parent" | "admin";
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
// 组织树
|
||||
interface OrganizationNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "school" | "grade" | "class";
|
||||
parentId: string | null;
|
||||
childrenCount: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 学校设置
|
||||
interface SystemSettingsViewModel {
|
||||
schoolName: string;
|
||||
schoolYear: string;
|
||||
semester: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
// ... 更多设置项
|
||||
}
|
||||
|
||||
// 班级/教师/学生(admin 全局视角)
|
||||
interface AdminClassViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
headTeacher: string;
|
||||
studentCount: number;
|
||||
status: string;
|
||||
}
|
||||
interface AdminTeacherViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
subjects: string[];
|
||||
classes: string[];
|
||||
status: string;
|
||||
}
|
||||
interface AdminStudentViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 审计日志
|
||||
interface AuditLogViewModel {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
beforeState: string | null;
|
||||
afterState: string | null;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
occurredAt: number;
|
||||
}
|
||||
|
||||
// 管理仪表盘
|
||||
interface AdminDashboardViewModel {
|
||||
totalUsers: number;
|
||||
totalTeachers: number;
|
||||
totalStudents: number;
|
||||
totalClasses: number;
|
||||
activeSessions: number;
|
||||
serviceHealth: {
|
||||
serviceName: string;
|
||||
status: "healthy" | "degraded" | "down";
|
||||
latencyMs: number;
|
||||
}[];
|
||||
recentActivity: {
|
||||
timestamp: number;
|
||||
action: string;
|
||||
user: string;
|
||||
resource: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// WebSocket 通知
|
||||
interface WsNotification {
|
||||
id: string;
|
||||
type: "audit_alert" | "abnormal_login" | "system_error" | "info";
|
||||
severity: "info" | "warning" | "error";
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 当前用户与会话
|
||||
|
||||
```typescript
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: DataScope;
|
||||
schoolId: string | null;
|
||||
schoolName: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
会话状态由 `AuthProvider` 管理,token 存 `localStorage`(`edu_access_token`),用户信息存 `localStorage`(`edu_user`)。
|
||||
|
||||
## 3. 数据模型(前端缓存层)
|
||||
|
||||
admin-portal 无数据库,仅有 urql GraphQL client 缓存层。
|
||||
|
||||
| 数据类型 | 存储 | 缓存策略 |
|
||||
| ----------------------- | ---------------------------- | --------------------------------------------- |
|
||||
| Session(token + user) | localStorage + React Context | access token 持久化;用户信息持久化;登出清除 |
|
||||
| GraphQL 查询缓存 | urql document cache | 默认缓存,mutation 后手动 invalidate |
|
||||
| 视口配置(拖拽态) | React useState | 本地编辑,保存时批量 mutation |
|
||||
| 表单临时态 | react-hook-form | 卸载即销毁 |
|
||||
| WebSocket 通知 | React useState(限 20 条) | 新通知头部插入,超 20 条裁剪 |
|
||||
|
||||
> **缓存策略说明**:admin 数据低频变,urql 默认 document cache 已满足;监控指标经 WebSocket 实时推送(不再轮询);视口配置支持本地编辑 + 批量保存。
|
||||
|
||||
## 4. API 设计(GraphQL 为主)
|
||||
|
||||
### 4.1 GraphQL Client(urql,ARB-004)
|
||||
|
||||
**standalone 模式**(`src/lib/graphql-client.ts`):
|
||||
|
||||
```typescript
|
||||
import { createClient, fetchExchange, type Client } from "@urql/core";
|
||||
|
||||
const GRAPHQL_ENDPOINT = "/api/admin/graphql";
|
||||
|
||||
export function createGraphQLClient(): Client {
|
||||
return createClient({
|
||||
url: GRAPHQL_ENDPOINT,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: () => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("edu_access_token")
|
||||
: null;
|
||||
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**MF 模式**:复用 Shell 暴露的 `GraphQLProvider` + `useGraphQLClient`(singleton,ARB-004)。
|
||||
|
||||
**urql 类型断言技巧**:urql 泛型与纯字符串 query 兼容性有限,用 `as unknown as` 从 unknown 转换:
|
||||
|
||||
```typescript
|
||||
// use-graphql.ts
|
||||
const [executeMutation] = useMutation(mutation) as unknown as [
|
||||
(variables: V) => { toPromise: () => Promise<OperationResult<T, V>> },
|
||||
unknown,
|
||||
];
|
||||
```
|
||||
|
||||
### 4.2 通用 GraphQL Hooks(`src/hooks/use-graphql.ts`)
|
||||
|
||||
```typescript
|
||||
export function useGraphQuery<T, V = Record<string, never>>(
|
||||
query: string,
|
||||
variables?: V,
|
||||
): QueryResult<T> { ... }
|
||||
|
||||
export function useGraphMutation<T, V>(
|
||||
mutation: string,
|
||||
): [MutationFn<T, V>, MutationResult] { ... }
|
||||
```
|
||||
|
||||
### 4.3 业务 Hooks 清单
|
||||
|
||||
| Hook | 职责 |
|
||||
| ---------------------------- | ------------------------------------------------ |
|
||||
| `useUsers(filter)` | 用户列表查询(含筛选/分页) |
|
||||
| `useUser(userId)` | 用户详情 |
|
||||
| `useCreateUser()` | 创建用户 mutation |
|
||||
| `useUpdateUser()` | 更新用户 mutation |
|
||||
| `useToggleUserStatus()` | 启用/禁用用户 mutation |
|
||||
| `useUserFilter()` | 用户筛选状态(搜索/角色/状态/组织) |
|
||||
| `useRoles()` | 角色列表查询 |
|
||||
| `useCreateRole()` | 创建角色 mutation |
|
||||
| `useUpdateRolePermissions()` | 更新角色权限 mutation(RolePermissionMatrix 用) |
|
||||
| `usePermissions()` | 全量权限列表 |
|
||||
| `useViewports()` | 视口配置列表 |
|
||||
| `useUpdateViewport()` | 更新视口配置 mutation |
|
||||
| `useOrganization(parentId)` | 组织树查询(按 parentId 递归) |
|
||||
| `useClasses(filter)` | 班级列表查询 |
|
||||
| `useClassFilter()` | 班级筛选状态 |
|
||||
| `useTeachers(filter)` | 教师列表查询 |
|
||||
| `useTeacherFilter()` | 教师筛选状态 |
|
||||
| `useStudents(filter)` | 学生列表查询 |
|
||||
| `useStudentFilter()` | 学生筛选状态 |
|
||||
| `useAuditLogs(filter)` | 审计日志查询 |
|
||||
| `useAuditLogFilter()` | 审计日志筛选状态 |
|
||||
| `exportAuditLogsCsv(logs)` | 审计日志 CSV 导出(BOM + UTF-8) |
|
||||
| `useDashboard()` | 管理仪表盘聚合查询 |
|
||||
| `useSystemSettings()` | 学校设置查询 |
|
||||
| `useUpdateSystemSettings()` | 学校设置更新 mutation |
|
||||
| `useWebSocket(maxItems)` | WebSocket 实时通知(mock 模式 30s 定时推送) |
|
||||
|
||||
## 5. 事件设计
|
||||
|
||||
### 5.1 WebSocket 实时通知(ARB-006)
|
||||
|
||||
admin-portal 接入 push-gateway `GET /ws`,消费 3 类通知:
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-websocket.ts
|
||||
export function useWebSocket(maxItems = 20) {
|
||||
// 真实模式:连接 push-gateway NEXT_PUBLIC_WS_URL
|
||||
// mock 模式:30s 定时器模拟推送
|
||||
// 返回:{ notifications, connected, dismiss, clear }
|
||||
}
|
||||
```
|
||||
|
||||
| 通知类型 | severity | 触发场景 |
|
||||
| ---------------- | -------- | ----------------------------- |
|
||||
| `audit_alert` | warning | 敏感操作(权限变更/批量删除) |
|
||||
| `abnormal_login` | error | 异地登录/异常时段登录 |
|
||||
| `system_error` | error | 服务降级/宕机 |
|
||||
|
||||
### 5.2 不消费 Kafka
|
||||
|
||||
admin-portal 不直接订阅 Kafka。审计日志经 teacher-bff 聚合后通过 GraphQL `auditLogs` Query 消费(ARB-005):
|
||||
|
||||
**链路**:iam → Kafka `edu.iam.audit.created` → **teacher-bff 消费** → GraphQL `auditLogs` Query → admin-portal
|
||||
|
||||
> **不再用轮询**:旧文档的"60s 轮询监控 + 5min 轮询统计"已被 ARB-006 的 WebSocket 推送替换。
|
||||
|
||||
## 6. 横切关注点对齐清单
|
||||
|
||||
### 6.1 权限(前端等价,ARB-003)
|
||||
|
||||
权限点常量定义于 `src/lib/permissions.ts`:
|
||||
|
||||
- `ADMIN_PERMISSIONS`:admin 自身资源(`ADMIN_DASHBOARD_VIEW` / `ADMIN_SYSTEM_MANAGE` / `ADMIN_AUDIT_READ` / `ADMIN_CLASS_READ` / `ADMIN_TEACHER_READ` / `ADMIN_STUDENT_READ` / `ADMIN_ORG_MANAGE` 等)
|
||||
- `IAM_PERMISSIONS`:跨服务资源(`IAM_USER_READ` / `IAM_ROLE_READ` / `IAM_PERMISSION_READ` / `IAM_VIEWPORT_READ` 等)
|
||||
- `ROUTE_PERMISSIONS`:11 路由 → 权限点映射
|
||||
|
||||
权限校验:`usePermission().hasPermission("XXX")` Hook + `AuthGuard` 组件(路由级)。
|
||||
|
||||
### 6.2 错误码清单(前端 i18n 路由,ARB-002)
|
||||
|
||||
| 前缀 | 来源服务 | i18n key 模式 |
|
||||
| -------------- | ----------- | ------------------------ |
|
||||
| `IAM_` | iam | `iam.error.{{code}}` |
|
||||
| `BFF_TEACHER_` | teacher-bff | `bff.error.{{code}}` |
|
||||
| `GW_` | api-gateway | `gateway.error.{{code}}` |
|
||||
| `NETWORK_` | 前端网络层 | `network.error.{{code}}` |
|
||||
| `ADMIN_` | admin 域 | `error.admin.*` |
|
||||
|
||||
### 6.3 Web Vitals(`src/lib/web-vitals.ts`)
|
||||
|
||||
| 指标 | 类型 | 上报方式 |
|
||||
| ---- | -------------- | ---------------------- |
|
||||
| LCP | 最大内容绘制 | `navigator.sendBeacon` |
|
||||
| CLS | 累积布局偏移 | 同上 |
|
||||
| FCP | 首次内容绘制 | 同上 |
|
||||
| INP | 交互到下一绘制 | 同上 |
|
||||
| TTFB | 首字节时间 | 同上 |
|
||||
|
||||
仅 production 启用(`WebVitalsInitializer` 组件控制)。
|
||||
|
||||
### 6.4 健康检查
|
||||
|
||||
| 端点 | 用途 | 实现 |
|
||||
| ----------------- | --------- | --------------------------------------------------- |
|
||||
| `GET /api/health` | Liveness | 返回 `200 { status: "ok" }` |
|
||||
| `GET /api/ready` | Readiness | 检查 gateway 可达性(2s 超时);mock 模式直接 ready |
|
||||
|
||||
### 6.5 A11y(WCAG 2.2 AA)
|
||||
|
||||
- `eslint-plugin-jsx-a11y`(error 级)
|
||||
- skip-link(`admin-shell.tsx` 跳过到主内容区)
|
||||
- `focus-visible` 样式(`globals.css`)
|
||||
- 组织树:`<button role="treeitem" aria-expanded aria-selected onKeyDown>`
|
||||
- 通知面板:`<li>` 内嵌 `<button aria-label>`
|
||||
- Toast:`aria-live="polite"` + `aria-atomic="true"`
|
||||
- 注:`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容(`context.getAncestors` 已移除),已移除
|
||||
|
||||
### 6.6 横切关注点对齐清单(汇总)
|
||||
|
||||
| 对齐项 | 实现 |
|
||||
| --------------------- | --------------------------------------------------------------- |
|
||||
| 权限校验 | `usePermission()` + `AuthGuard`,`ADMIN_*`/`IAM_*` 前缀 |
|
||||
| 错误码前缀 | 5 个前缀(`IAM_`/`BFF_TEACHER_`/`GW_`/`NETWORK_`/`ADMIN_`) |
|
||||
| logger | console + 结构化(生产上报) |
|
||||
| metrics | Web Vitals v4 → `navigator.sendBeacon` |
|
||||
| /health + /ready | Next.js Route Handler |
|
||||
| 优雅关闭 | N/A(Next.js 无长连接) |
|
||||
| 测试覆盖率 ≥ 80% | Vitest + @testing-library/react(vitest.config.ts 已配置) |
|
||||
| Dockerfile 多阶段构建 | `node:22-alpine` builder + runner,standalone 输出,EXPOSE 4003 |
|
||||
| Zod 输入验证 | react-hook-form + zod |
|
||||
| GlobalErrorFilter | React ErrorBoundary + Toast 错误处理 |
|
||||
| 设计令牌三层 | `globals.css`(primitive/semantic)+ tailwind-theme |
|
||||
| A11y | eslint-plugin-jsx-a11y + skip-link + focus-visible |
|
||||
| Module Federation | `next.config.js` Remote,`NEXT_PUBLIC_MF_ENABLED` 控制 |
|
||||
|
||||
## 7. 共享组件库(复用 Shell + admin 特有)
|
||||
|
||||
### 7.1 复用 Shell 暴露的组件(`@edu/ui-components`)
|
||||
|
||||
AppShell 风格、ErrorBoundary、RequirePermission、Loading、Empty、Button/Input/Select 等。
|
||||
|
||||
### 7.2 admin-portal 特有组件(新建)
|
||||
|
||||
| 组件 | 用途 | 实现要点 |
|
||||
| ---------------------- | ------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| `AdminShell` | 左栏 11 项导航 + 主内容区 + 跳过链接 | 内部组件,11 项导航按 `ROUTE_PERMISSIONS` 过滤;纸面风格 |
|
||||
| `UserManagementTable` | 用户管理表格(列表/筛选/分页) | 列:邮箱/姓名/角色/状态/数据范围/最后登录;筛选:搜索/角色/状态 |
|
||||
| `UserFormModal` | 用户表单弹窗(创建/编辑) | react-hook-form + zod;字段:邮箱/姓名/角色/数据范围/组织 |
|
||||
| `RolePermissionMatrix` | 角色-权限矩阵编辑器(checkbox 网格) | 行=角色,列=权限(按 resource 分组);全选/半选/单选;系统预置角色只读 |
|
||||
| `OrganizationTree` | 组织树(递归 + A11y treeitem) | 按 parentId 懒加载;`<button role="treeitem" aria-expanded aria-selected onKeyDown>` |
|
||||
| `NotificationPanel` | 通知面板(WebSocket 实时通知) | 固定右上角;显示最近 20 条;点击触发 Toast + dismiss;`<li>` 内嵌 `<button aria-label>` |
|
||||
|
||||
### 7.3 通用 UI 组件(`src/components/ui.tsx`)
|
||||
|
||||
PaperCard / PageHeader / Button(4 variant)/ Input / Select / Badge / Table / TableRow / TableCell / EmptyState / LoadingState / ErrorState / Pagination
|
||||
|
||||
### 7.4 不使用的组件(明确排除)
|
||||
|
||||
| 组件 | 归属 | 排除原因 |
|
||||
| ----------------------------------- | -------------- | ------------------------------------- |
|
||||
| `RichTextEditor` | teacher-portal | 管理端无富文本场景 |
|
||||
| `ExamTaking` | student-portal | 管理端无作答场景 |
|
||||
| `ChildSwitcher` | parent-portal | 管理端无多子女切换 |
|
||||
| `PlatformMonitor`(Grafana iframe) | — | 已移除,改为管理仪表盘 + 服务健康表格 |
|
||||
|
||||
## 8. Mock 策略(MSW + mock-socket)
|
||||
|
||||
### 8.1 MSW handlers(`src/mocks/handlers.ts`)
|
||||
|
||||
开发期拦截全部 GraphQL/HTTP 请求:
|
||||
|
||||
- `/api/auth/login` → mock 登录(admin@edu.test / admin123)
|
||||
- `graphql.operation` → 按 operationName 分发 18 个 GraphQL operation mock:
|
||||
- CurrentUser / AdminUsers / AdminUser / CreateUser / UpdateUser / ToggleUserStatus
|
||||
- AdminRoles / CreateRole / UpdateRolePermissions
|
||||
- AdminPermissions / AdminViewports / UpdateViewport
|
||||
- AdminOrganization / AdminClasses / AdminTeachers / AdminStudents
|
||||
- AuditLogs / AdminDashboard / SystemSettings / UpdateSystemSettings
|
||||
- 内存可变副本:支持增删改 + 自动审计记录(`recordAudit`)
|
||||
- 分页:`paginate(items, page, pageSize)` 辅助函数
|
||||
|
||||
### 8.2 mock fixtures(`src/mocks/fixtures.ts`)
|
||||
|
||||
- `mockCurrentUser`:admin 角色,permissions=["*"]
|
||||
- `mockPermissions`:24 项权限点
|
||||
- `mockRoles`:4 个角色(admin/teacher/student/parent)
|
||||
- `mockUsers`:6 个用户
|
||||
- `mockViewports`:7 个视口配置
|
||||
- `mockOrganization`:12 节点(school/grade/class)
|
||||
- `mockClasses` / `mockTeachers` / `mockStudents`:6/6/5 条
|
||||
- `mockAuditLogs`:5 条审计日志
|
||||
- `mockDashboard`:仪表盘聚合数据
|
||||
- `mockSystemSettings`:学校设置
|
||||
|
||||
### 8.3 WebSocket mock(`src/hooks/use-websocket.ts`)
|
||||
|
||||
MSW 不拦截 WebSocket,用定时器模拟推送:
|
||||
|
||||
- mock 模式:30s 定时器推送 1 条通知(轮换 3 类:audit_alert/abnormal_login/system_error)
|
||||
- 真实模式:连接 `NEXT_PUBLIC_WS_URL`(push-gateway `GET /ws`)
|
||||
|
||||
### 8.4 环境切换
|
||||
|
||||
`NEXT_PUBLIC_API_MOCKING=enabled` 启用 MSW;`disabled` 使用真实后端。
|
||||
|
||||
## 9. 设计令牌三层(`src/app/globals.css`)
|
||||
|
||||
### 9.1 Layer 1 Primitive(原始色板)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--paper: 0 0% 100%; /* 白纸 */
|
||||
--ink: 30 10% 15%; /* 墨色 */
|
||||
--ink-muted: 30 5% 45%;
|
||||
--rule: 30 10% 90%; /* 分隔线 */
|
||||
--accent: 210 60% 40%; /* 强调色 */
|
||||
--accent-light: 210 60% 95%;
|
||||
--danger: 0 70% 50%;
|
||||
--success: 140 50% 40%;
|
||||
--warning: 40 80% 50%;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Layer 2 Semantic
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-paper: hsl(var(--paper));
|
||||
--color-ink: hsl(var(--ink));
|
||||
--color-ink-muted: hsl(var(--ink-muted));
|
||||
--color-rule: hsl(var(--rule));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-light: hsl(var(--accent-light));
|
||||
--color-danger: hsl(var(--danger));
|
||||
--color-success: hsl(var(--success));
|
||||
--color-warning: hsl(var(--warning));
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Layer 3 Tailwind Theme(`tailwind.config.js`)
|
||||
|
||||
paper / ink / accent / rule / danger / success / warning 色板映射为 `bg-*` / `text-*` 类。
|
||||
|
||||
### 9.4 强制规则(project_rules §3.10)
|
||||
|
||||
- 禁止 `#hex` 字面量
|
||||
- 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
|
||||
- 禁止 `font-size: Npx`
|
||||
- 禁止 Tailwind 任意值 `w-[Npx]`
|
||||
|
||||
## 10. 与其他模块的交互点(契约清单)
|
||||
|
||||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | 阶段 |
|
||||
| ---- | --------------------- | --------------------- | ---------------------------------------- | ---------------------------- | ---- |
|
||||
| 调用 | api-gateway | HTTP/GraphQL | `POST /api/admin/graphql`(经 rewrites) | 全部业务 GraphQL 查询 | P6+ |
|
||||
| 调用 | api-gateway | HTTP | `POST /api/auth/login` | 登录(standalone 模式) | P6+ |
|
||||
| 调用 | push-gateway | WebSocket | `GET /ws` | 实时通知(ARB-006) | P6+ |
|
||||
| 消费 | teacher-bff | GraphQL(经 Gateway) | admin 命名空间 18 operations | 全部业务数据 | P6+ |
|
||||
| 消费 | iam(经 teacher-bff) | GraphQL(间接) | 用户/角色/权限/视口 CRUD + AuditEvent | 管理数据 | P6+ |
|
||||
| 依赖 | coord 维护 | — | `packages/contracts` | Permissions 常量 + 类型 | P6+ |
|
||||
| 依赖 | coord 维护 | — | `packages/shared-proto` | TS 类型(仅 contracts 部分) | P6+ |
|
||||
| 依赖 | teacher-portal Shell | — | Shell 暴露的 GraphQLProvider + 共享依赖 | 宿主环境(MF 模式) | P6+ |
|
||||
|
||||
> **proto 不直接消费**:前端不调用 gRPC,teacher-bff 把 gRPC 聚合为 GraphQL 暴露给前端。
|
||||
|
||||
## 11. 风险与假设
|
||||
|
||||
### 11.1 假设
|
||||
|
||||
1. **假设 teacher-bff admin 命名空间 schema 就绪**(ARB-001,contract §2.4 18 operations):若 ai03 未补齐,admin-portal 用 MSW mock 开发,上游就绪后切换。
|
||||
2. **假设 teacher-portal Shell 已就绪**:MF 模式依赖 Shell 暴露 GraphQLProvider + 共享依赖。standalone 模式可独立运行。
|
||||
3. **假设 push-gateway WebSocket 就绪**(ARB-006):若未就绪,用 mock-socket + 30s 定时器模拟。
|
||||
4. _\*假设 `packages/contracts` ADMIN_* 权限点常量就绪_*(coord 维护):admin-portal `src/lib/permissions.ts` 引用,若 coord 未建立则本地定义。
|
||||
|
||||
### 11.2 技术风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
| ----------------------------- | -------------------------------------- | --------------------------------------------------------- |
|
||||
| Shell 未就绪阻塞 admin | MF 模式无法挂载 | standalone 模式自建 Provider 链,独立可运行 |
|
||||
| teacher-bff admin schema 缺失 | GraphQL 查询失败 | MSW mock 全覆盖 18 operations,上游就绪后切换 |
|
||||
| urql 类型兼容性 | TypeScript 编译错误 | `as unknown as` 从 unknown 转换(见 §4.1) |
|
||||
| ESLint 9 兼容性 | `@next/eslint-plugin-next` 14.x 不兼容 | 移除该插件,用 jsx-a11y + tseslint 替代 |
|
||||
| WebSocket mock 不真实 | 开发期通知体验与生产不一致 | 30s 定时器 + 3 类通知轮换,接近真实推送频率 |
|
||||
| 权限缓存陈旧 | 角色变更后前端仍用旧权限 | 登录时拉取最新权限;admin-portal 本身是管理端,可主动刷新 |
|
||||
|
||||
## 12. 端口矩阵
|
||||
|
||||
| 端 | dev 端口 | 生产端口 | 备注 |
|
||||
| -------------------- | -------- | -------- | ---------------------- |
|
||||
| admin-portal | 4003 | 4003 | Remote,挂载到 Shell |
|
||||
| teacher-portal Shell | 4000 | 4000 | Shell 宿主 |
|
||||
| api-gateway | 8080 | 8080 | GraphQL 代理 |
|
||||
| push-gateway | 8081 | 8081 | WebSocket |
|
||||
| teacher-bff | 3003 | 3003 | GraphQL admin 命名空间 |
|
||||
|
||||
## 13. 实施路线(已完成)
|
||||
|
||||
### P2-P6 全阶段交付(已完成)
|
||||
|
||||
1. ✅ **P2 骨架 Phase 1**:配置文件 + 设计令牌 + 类型定义
|
||||
2. ✅ **P2 骨架 Phase 2**:GraphQL client + auth + permissions + providers + AdminShell + MF Remote 入口 + standalone 壳
|
||||
3. ✅ **P2 骨架 Phase 3**:MSW mock(handlers + fixtures + browser)+ mock-socket WebSocket mock
|
||||
4. ✅ **P3 业务**:用户管理(useUsers/useUser/CRUD hooks + UserManagementTable + /admin/users 页面)
|
||||
5. ✅ **P3 业务**:角色权限矩阵 + 权限点管理 + 视口配置
|
||||
6. ✅ **P4 业务**:组织管理 + 学校设置 + 班级/教师/学生全局管理
|
||||
7. ✅ **P5 业务**:审计日志 + WebSocket 实时通知 + 管理仪表盘
|
||||
8. ✅ **P6 硬化**:A11y + Web Vitals + Dockerfile + /api/health + /api/ready + 质量校验
|
||||
9. ✅ **文档回写**:01/02 文档对齐仲裁结果 + known-issues §2.16 + arch:scan
|
||||
|
||||
### 验收标准
|
||||
|
||||
- ✅ 所有 11 个路由页面可访问,权限校验生效
|
||||
- ✅ MF Remote 可被 Shell 加载(暴露 `./AdminApp`)
|
||||
- ✅ standalone 模式独立可运行(自建 Provider 链 + mock 登录)
|
||||
- ✅ GraphQL 18 operations 全部实现(MSW mock + 真实切换)
|
||||
- ✅ WebSocket 实时通知(mock 模式 30s 定时推送)
|
||||
- ✅ Web Vitals 采集(LCP/CLS/FCP/INP/TTFB → sendBeacon)
|
||||
- ✅ A11y WCAG 2.2 AA(eslint-plugin-jsx-a11y error 级 + skip-link + focus-visible)
|
||||
- ✅ `/api/health` + `/api/ready` 返回 200
|
||||
- ✅ Dockerfile 多阶段构建(node:22-alpine,standalone 输出,EXPOSE 4003)
|
||||
- ✅ `pnpm run typecheck` + `pnpm run lint` 通过(0 errors)
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai16 (admin-portal remote)
|
||||
**Branch**: admin-portal-arbitration-done-TaFkyb
|
||||
**Coordinator**: coord-ai
|
||||
161
apps/admin-portal/docs/nextstep-v2.md
Normal file
161
apps/admin-portal/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# admin-portal 下游工作清单 v2(Next Steps v2)
|
||||
|
||||
> 负责人:ai16
|
||||
> 更新日期:2026-07-14
|
||||
> 关联:[nextstep.md v1](./nextstep.md)、[admin-portal_contract.md](../../docs/architecture/issues/contracts/admin-portal_contract.md)
|
||||
> 核查方式:源码审查 + 端点测试 + 编译验证
|
||||
|
||||
---
|
||||
|
||||
## 1. 核查结论
|
||||
|
||||
**v1 中声称"已完成"的下游工作存在严重不符。** 经源码审查和端点测试,3 个下游模块均未真正完成:
|
||||
|
||||
| 模块 | v1 声称 | v2 实际 | 严重程度 |
|
||||
| -------------------- | --------- | ----------------------- | -------- |
|
||||
| api-gateway | ✅ 已完成 | ❌ **未实现** | P0 阻塞 |
|
||||
| teacher-bff resolver | — | ✅ **已完成** | 无 |
|
||||
| teacher-bff schema | — | ❌ **未同步** | P0 阻塞 |
|
||||
| iam proto | — | ❌ **契约缺失** | P1 阻塞 |
|
||||
| admin-portal 前端 | — | ✅ **与 resolver 一致** | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 未完成项详情
|
||||
|
||||
### 2.1 api-gateway(ai01 负责)— P0 阻塞
|
||||
|
||||
**核查方法:** 源码审查 + `go vet` 编译 + curl 端点测试
|
||||
|
||||
| # | v1 声称 | 实际状态 | 证据 |
|
||||
| --- | --------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 1 | ✅ 新增 `/api/admin/graphql` 路由 | ❌ **未实现** | `main.go` L68-104 仅有 `/api/v1` 路由组,无 `/api/admin` 路由组 |
|
||||
| 2 | ✅ admin 角色强制校验中间件 | ⚠️ **代码存在但未注册** | `internal/middleware/admin_role.go` 已实现 `AdminRoleMiddleware()`,但 `main.go` 中从未调用 |
|
||||
| 3 | ✅ NewProxyRewrite 代理函数 | ❌ **未实现** | `internal/proxy/proxy.go` 只有 `NewProxy`,无 `NewProxyRewrite` |
|
||||
| 4 | ✅ registerBffProxy | ❌ **未实现** | `main.go` 中无此函数 |
|
||||
|
||||
**编译验证:**
|
||||
|
||||
- `go build ./...` → 通过(不编译测试文件)
|
||||
- `go vet ./...` → **失败**,5 处 `undefined: NewProxyRewrite`(测试文件引用了不存在的函数)
|
||||
|
||||
**端点测试:**
|
||||
|
||||
- `POST http://localhost:8080/api/admin/graphql` → **404 Not Found**
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 在 `main.go` 中注册 `/api/admin/graphql` 路由,代理到 teacher-bff :3003/graphql
|
||||
2. 在该路由上挂载 `AdminRoleMiddleware()`
|
||||
3. 实现 `NewProxyRewrite` 函数(或删除引用它的测试文件)
|
||||
4. 修复 `go vet` 编译错误
|
||||
|
||||
### 2.2 teacher-bff schema(ai03 负责)— P0 阻塞
|
||||
|
||||
**核查方法:** 源码审查 + GraphQL 端点测试
|
||||
|
||||
**问题:** resolver 已用扁平命名(`adminUsers`、`adminRoles` 等),但 schema 文件仍是旧的命名空间模式。
|
||||
|
||||
**文件:** `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`
|
||||
|
||||
| # | 问题 | 详情 |
|
||||
| --- | ---------------------- | --------------------------------------------------------------------------- |
|
||||
| 1 | schema 命名模式不一致 | schema 用 `type Query { admin: AdminQuery! }`,resolver 用扁平 `adminUsers` |
|
||||
| 2 | AdminQuery 字段缺失 | schema L317-335 仅 7 个字段,缺 16 个新 Query |
|
||||
| 3 | AdminMutation 字段缺失 | schema L336+ 仅 10 个 Mutation,缺 11 个新 Mutation |
|
||||
| 4 | 输入类型缺失 | `AdminUserFilterInput`、`CreateUserInput`、`UpdateUserInput` 等未定义 |
|
||||
|
||||
**端点测试:**
|
||||
|
||||
- `POST http://localhost:3003/graphql` with `{ adminUsers(filter:{page:1,pageSize:10}){ items{ id } } }` → **500**(schema 校验失败)
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 将 schema 从命名空间模式改为扁平模式(`type Query { adminUsers: ...! }`)
|
||||
2. 添加所有 16 个新 Query 的字段定义
|
||||
3. 添加所有 11 个新 Mutation 的字段定义
|
||||
4. 添加所有输入类型定义(`AdminUserFilterInput`、`CreateUserInput` 等)
|
||||
5. 删除旧的 `AdminQuery`/`AdminMutation` 类型(或标记为 deprecated)
|
||||
|
||||
### 2.3 iam proto 契约(ai06 负责)— P1 阻塞
|
||||
|
||||
**核查方法:** proto 文件审查 + 实现代码审查
|
||||
|
||||
**文件:** `packages/shared-proto/proto/iam.proto`
|
||||
|
||||
| # | 工作项 | Proto 定义 | 实现代码 | 状态 |
|
||||
| --- | ------------------- | ------------------ | ------------------------------------- | -------- |
|
||||
| 1 | `BatchGetUsers` RPC | ❌ **无 rpc 声明** | ✅ `iam.grpc.controller.ts:62` 已实现 | 契约缺失 |
|
||||
| 2 | `CreateUser` RPC | ❌ 未定义 | ❌ 仅 REST 实现 | 未实现 |
|
||||
| 3 | `UpdateUser` RPC | ❌ 未定义 | ❌ 仅 REST 实现 | 未实现 |
|
||||
| 4 | `DeleteUser` RPC | ❌ 未定义 | ❌ 未实现 | 未实现 |
|
||||
|
||||
**注意:** iam.proto L66-72 定义了 `BatchGetUsersRequest`/`BatchGetUsersResponse` 消息,但 `service IamService` 中**没有** `rpc BatchGetUsers`。gRPC 客户端无法发现该服务。
|
||||
|
||||
**需完成的工作:**
|
||||
|
||||
1. 在 `service IamService` 中添加 `rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);`
|
||||
2. 添加 `CreateUser`/`UpdateUser`/`DeleteUser` RPC 定义和实现(如果 teacher-bff 需要 gRPC 调用)
|
||||
|
||||
---
|
||||
|
||||
## 3. admin-portal 前端状态
|
||||
|
||||
### 3.1 已完成项
|
||||
|
||||
| 工作项 | 状态 | 验证方式 |
|
||||
| ------------------------------ | ---- | ------------------------------------------------------------- |
|
||||
| 43 个 GraphQL operation 字符串 | ✅ | 与 resolver 扁平命名一致 |
|
||||
| Docker 镜像构建 | ✅ | `edu/admin-portal:test` 构建成功 |
|
||||
| Docker 容器运行 | ✅ | /api/health + /api/ready + /login + /admin/dashboard 全部 200 |
|
||||
| typecheck + lint | ✅ | 零错误 |
|
||||
| vitest | ✅ | 69/69 通过 |
|
||||
|
||||
### 3.2 前端无需修改
|
||||
|
||||
admin-portal 前端代码与 teacher-bff resolver 完全一致(扁平命名),**不需要修改前端代码**。所有阻塞项都在下游服务。
|
||||
|
||||
---
|
||||
|
||||
## 4. 端到端联调测试结果
|
||||
|
||||
| 测试项 | 结果 | 原因 |
|
||||
| --------------------------------- | ------ | ---------------------------------- |
|
||||
| admin-portal → /login | ✅ 200 | 前端独立工作 |
|
||||
| admin-portal → /api/health | ✅ 200 | 前端独立工作 |
|
||||
| admin-portal → /api/ready | ✅ 200 | api-gateway :8080 可达 |
|
||||
| admin-portal → /api/admin/graphql | ❌ 500 | api-gateway 返回 404(路由未注册) |
|
||||
| teacher-bff → /graphql (扁平命名) | ❌ 500 | schema 未同步(命名空间模式) |
|
||||
|
||||
**结论:** 端到端联调无法进行,需下游服务修复后重新测试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调顺序(更新)
|
||||
|
||||
1. **ai03** 修复 teacher-bff schema(改为扁平命名,补齐字段)— P0
|
||||
2. **ai01** 修复 api-gateway 路由(注册 `/api/admin/graphql`,挂载中间件)— P0
|
||||
3. **ai06** 修复 iam proto(添加 BatchGetUsers rpc 声明)— P1
|
||||
4. **ai16** 重新构建 admin-portal Docker 镜像并端到端测试
|
||||
5. 全链路联调:admin-portal → api-gateway → teacher-bff → iam/classes/core-edu
|
||||
|
||||
---
|
||||
|
||||
## 6. v1 与 v2 差异说明
|
||||
|
||||
| v1 声称 | v2 实际 | 差异原因 |
|
||||
| ------------------- | ------------------------------- | ----------------------------------- |
|
||||
| api-gateway ✅ 完成 | ❌ 未实现 | v1 核查不充分,未实际测试端点和编译 |
|
||||
| teacher-bff ✅ 完成 | ⚠️ resolver 完成,schema 未同步 | v1 未区分 resolver 和 schema |
|
||||
| iam ✅ 完成 | ❌ proto 缺 rpc 声明 | v1 未审查 proto 文件 |
|
||||
|
||||
**教训:** 下游工作完成声明必须通过以下方式验证:
|
||||
|
||||
1. 源码审查(不仅看文档声明)
|
||||
2. 编译验证(`go vet`/`tsc`/`ruff`)
|
||||
3. 端点测试(curl/Invoke-WebRequest)
|
||||
4. 集成测试(端到端调用)
|
||||
|
||||
---
|
||||
|
||||
**本文件由 ai16 维护,v1 中的虚假完成声明已在此 v2 版本中修正。请各负责 AI 按 §5 联调顺序修复后通知 ai16 更新状态。**
|
||||
156
apps/admin-portal/docs/nextstep.md
Normal file
156
apps/admin-portal/docs/nextstep.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# admin-portal 下游工作清单(Next Steps)
|
||||
|
||||
> 负责人:ai16
|
||||
> 更新日期:2026-07-13
|
||||
> 关联:[admin-portal_contract.md](../../docs/architecture/issues/contracts/admin-portal_contract.md)、[admin-portal_workline.md](../../docs/architecture/issues/worklines/admin-portal_workline.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
admin-portal 前端已全部完成(P2-P6 + §7 差距分析 4 批次 + Vitest 测试),Docker 镜像构建成功并在本地 Docker Desktop 验证通过。但真实后端集成需要下游服务补齐以下工作。
|
||||
|
||||
**本地 Docker 测试结果(2026-07-13):**
|
||||
|
||||
- ✅ 镜像构建成功(`edu/admin-portal:test`)
|
||||
- ✅ 容器启动正常(端口 4003)
|
||||
- ✅ `/api/health` 返回 200
|
||||
- ✅ `/api/ready` 返回 200(gateway 可达)
|
||||
- ✅ `/login` 页面返回 200
|
||||
- ✅ `/admin/dashboard` 页面返回 200
|
||||
- ❌ `/api/admin/graphql` 返回 500(api-gateway 返回 404,路由未配置)
|
||||
|
||||
---
|
||||
|
||||
## 2. 下游阻塞项
|
||||
|
||||
### 2.1 api-gateway(ai01 负责)
|
||||
|
||||
**阻塞级别:P0(必须完成才能端到端联调)**
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 新增 `/api/admin/graphql` 路由 | 当前 api-gateway 仅有 `/api/v1/*` 路由组,无 `/api/admin/*` 路由。需新增 POST `/api/admin/graphql` 代理到 teacher-bff :3003/graphql |
|
||||
| 2 | admin 角色强制校验中间件 | 在 `/api/admin/*` 路由组上添加 admin 角色门禁,拒绝非 admin 角色访问 |
|
||||
| 3 | 路径对齐契约 §2.3 | 契约要求 `POST /api/admin/graphql` 为唯一入口,当前 api-gateway 无此路由 |
|
||||
|
||||
**相关文件:**
|
||||
|
||||
- `services/api-gateway/main.go`(L69-104,路由注册)
|
||||
- `services/api-gateway/internal/middleware/auth.go`(鉴权中间件)
|
||||
- `services/api-gateway/internal/proxy/proxy.go`(反向代理)
|
||||
- `services/api-gateway/internal/config/config.go`(配置)
|
||||
|
||||
### 2.2 teacher-bff(ai03 负责)
|
||||
|
||||
**阻塞级别:P0(必须完成才能端到端联调)**
|
||||
|
||||
#### 2.2.1 缺失的 Query(16 个)
|
||||
|
||||
| # | 契约 Query | 当前状态 | 备注 |
|
||||
| --- | ----------------------- | --------- | ----------------------------------- |
|
||||
| 1 | `adminPermissions` | ❌ 未实现 | 权限点列表 |
|
||||
| 2 | `adminViewports(scope)` | ❌ 未实现 | 视口配置,需 scope 参数 |
|
||||
| 3 | `adminClasses` | ❌ 未实现 | 班级全局管理 |
|
||||
| 4 | `adminTeachers` | ❌ 未实现 | 教师全局管理 |
|
||||
| 5 | `adminStudents` | ❌ 未实现 | 学生全局管理 |
|
||||
| 6 | `adminDashboard` | ❌ 未实现 | 管理仪表盘聚合数据 |
|
||||
| 7 | `adminSchools` | ❌ 未实现 | 多学校列表(当前仅有单数 `school`) |
|
||||
| 8 | `adminGrades` | ❌ 未实现 | 年级管理 |
|
||||
| 9 | `adminDepartments` | ❌ 未实现 | 部门/教研组管理 |
|
||||
| 10 | `adminAcademicYears` | ❌ 未实现 | 学年学期管理 |
|
||||
| 11 | `adminAnnouncements` | ❌ 未实现 | 公告列表 |
|
||||
| 12 | `adminInvitationCodes` | ❌ 未实现 | 邀请码列表 |
|
||||
| 13 | `adminFiles` | ❌ 未实现 | 文件列表 |
|
||||
| 14 | `adminFileStats` | ❌ 未实现 | 文件统计 |
|
||||
| 15 | `aiProviders` | ❌ 未实现 | AI Provider 列表 |
|
||||
| 16 | `aiUsage` | ❌ 未实现 | AI 用量统计 |
|
||||
|
||||
#### 2.2.2 缺失的 Mutation(11 个)
|
||||
|
||||
| # | 契约 Mutation | 当前状态 | 备注 |
|
||||
| --- | ------------------------- | --------- | --------------------- |
|
||||
| 1 | `toggleUserStatus` | ❌ 未实现 | 用户启用/禁用/锁定 |
|
||||
| 2 | `updateRolePermissions` | ❌ 未实现 | 角色权限矩阵更新 |
|
||||
| 3 | `updateViewport` | ❌ 未实现 | 视口配置更新 |
|
||||
| 4 | `createAnnouncement` | ❌ 未实现 | 创建公告 |
|
||||
| 5 | `publishAnnouncement` | ❌ 未实现 | 发布公告 |
|
||||
| 6 | `archiveAnnouncement` | ❌ 未实现 | 归档公告 |
|
||||
| 7 | `toggleAnnouncementPin` | ❌ 未实现 | 公告置顶切换 |
|
||||
| 8 | `generateInvitationCodes` | ❌ 未实现 | 批量生成邀请码 |
|
||||
| 9 | `deleteInvitationCodes` | ❌ 未实现 | 删除邀请码 |
|
||||
| 10 | `deleteFiles` | ❌ 未实现 | 批量删除文件 |
|
||||
| 11 | `updateAiProvider` | ❌ 未实现 | 更新 AI Provider 配置 |
|
||||
|
||||
#### 2.2.3 已实现但需对齐的项
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 命名规范对齐 | 契约用扁平命名 `adminUsers`/`adminRoles`,实际实现用命名空间 `admin { users }`。需 ai03 与 ai16 协调统一 |
|
||||
| 2 | `auditLogs` 补 filter 参数 | 当前仅 `page/pageSize`,需补 `action/userId/dateRange` 等 filter |
|
||||
| 3 | `adminOrganization` 补 parentId 参数 | 当前无参数,需支持 `parentId` 筛选子节点 |
|
||||
| 4 | 用户/角色 CRUD 接入真实 iam gRPC | 当前返回 mock 数据 |
|
||||
|
||||
**相关文件:**
|
||||
|
||||
- `services/teacher-bff/src/admin/admin.resolver.ts`
|
||||
- `services/teacher-bff/src/admin/admin.service.ts`
|
||||
- `services/teacher-bff/src/admin/admin.module.ts`
|
||||
- `services/teacher-bff/src/graphql/graphql.controller.ts`
|
||||
- `services/teacher-bff/src/graphql/graphql.module.ts`
|
||||
- `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`
|
||||
|
||||
### 2.3 iam 服务(ai06 负责,间接影响)
|
||||
|
||||
**阻塞级别:P1(影响 admin 用户管理真实性)**
|
||||
|
||||
| # | 工作项 | 详情 |
|
||||
| --- | --------------------------------------------- | ---------------------------------- |
|
||||
| 1 | 补 `BatchGetUsers` RPC | admin 用户列表需要批量获取用户信息 |
|
||||
| 2 | 补 `CreateUser`/`UpdateUser`/`DeleteUser` RPC | admin 用户 CRUD 需要 iam 支持 |
|
||||
|
||||
---
|
||||
|
||||
## 3. admin-portal 前端待办(非阻塞)
|
||||
|
||||
以下工作不阻塞当前部署,但需在下游就绪后完成:
|
||||
|
||||
| # | 工作项 | 触发条件 |
|
||||
| --- | ------------------------------------------------ | -------------------------------- |
|
||||
| 1 | GraphQL query 字符串对齐命名规范 | ai03 与 ai16 协调命名后 |
|
||||
| 2 | 移除 MSW mock 依赖(生产构建已移除) | api-gateway + teacher-bff 就绪后 |
|
||||
| 3 | MF 模式集成测试(`NEXT_PUBLIC_MF_ENABLED=true`) | teacher-portal Shell 就绪后 |
|
||||
| 4 | OTel trace 上报联调 | otel-collector 就绪后 |
|
||||
| 5 | WebSocket 实时通知联调 | push-gateway 就绪后 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成项汇总
|
||||
|
||||
| 工作项 | 状态 | 验证方式 |
|
||||
| ------------------------------------------ | ---- | ------------------------------------------------------------- |
|
||||
| P2 MF Remote 骨架 | ✅ | arch:scan + typecheck |
|
||||
| P3 用户/角色权限管理 | ✅ | typecheck + lint + vitest |
|
||||
| P4 组织/学校/全局实体 | ✅ | typecheck + lint + vitest |
|
||||
| P5 审计日志/实时通知 | ✅ | typecheck + lint + vitest |
|
||||
| P6 硬化(A11y/Web Vitals/测试/Dockerfile) | ✅ | vitest 69/69 + Docker 构建成功 |
|
||||
| §7 差距分析 4 批次补齐 | ✅ | typecheck + lint + vitest |
|
||||
| Docker 镜像构建 | ✅ | `edu/admin-portal:test` 构建成功 |
|
||||
| Docker 容器运行 | ✅ | /api/health + /api/ready + /login + /admin/dashboard 全部 200 |
|
||||
| docker-compose.deploy.yml 服务定义 | ✅ | admin-portal 服务已添加 |
|
||||
| next.config.js output: standalone | ✅ | Docker 构建产物正确 |
|
||||
| .env.local 关闭 mock | ✅ | NEXT_PUBLIC_API_MOCKING=disabled |
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调顺序建议
|
||||
|
||||
1. **ai01** 完成 api-gateway `/api/admin/graphql` 路由 + admin 角色校验
|
||||
2. **ai03** 完成 teacher-bff admin Query/Mutation 补齐 + 命名规范对齐
|
||||
3. **ai06** 完成 iam gRPC 补齐(BatchGetUsers 等)
|
||||
4. **ai16** 前端 GraphQL query 字符串对齐命名规范
|
||||
5. 端到端联调(admin-portal → api-gateway → teacher-bff → iam/classes/core-edu)
|
||||
|
||||
---
|
||||
|
||||
**本文件由 ai16 维护,下游工作项请各负责 AI 完成后通知 ai16 更新状态。**
|
||||
90
apps/admin-portal/eslint.config.js
Normal file
90
apps/admin-portal/eslint.config.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// admin-portal ESLint flat config
|
||||
// 包含 React + jsx-a11y + TypeScript 规则
|
||||
// 注意:@next/eslint-plugin-next 14.x 与 ESLint 9 不兼容,暂不使用
|
||||
const js = require('@eslint/js');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const prettierConfig = require('eslint-config-prettier');
|
||||
const jsx11y = require('eslint-plugin-jsx-a11y');
|
||||
|
||||
module.exports = tseslint.config(
|
||||
// 全局忽略
|
||||
{
|
||||
ignores: [
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'node_modules/**',
|
||||
'public/mockServiceWorker.js',
|
||||
'eslint.config.js',
|
||||
'next.config.js',
|
||||
'vitest.config.ts',
|
||||
'postcss.config.js',
|
||||
'tailwind.config.js',
|
||||
],
|
||||
},
|
||||
|
||||
// 基础 JS 规则
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript 规则
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// jsx-a11y 规则(A11y WCAG 2.2 AA)
|
||||
{
|
||||
plugins: {
|
||||
'jsx-a11y': jsx11y,
|
||||
},
|
||||
rules: {
|
||||
...jsx11y.flatConfigs.recommended.rules,
|
||||
'jsx-a11y/no-autofocus': 'warn',
|
||||
'jsx-a11y/aria-role': 'error',
|
||||
'jsx-a11y/aria-props': 'error',
|
||||
'jsx-a11y/aria-proptypes': 'error',
|
||||
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||
'jsx-a11y/role-supports-aria-props': 'error',
|
||||
'jsx-a11y/tabindex-no-positive': 'error',
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// React/JSX 文件配置
|
||||
{
|
||||
files: ['**/*.tsx', '**/*.ts'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 禁止 any(未知类型用 unknown)
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// 未使用变量允许下划线前缀
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
// 允许 console(开发环境)
|
||||
'no-console': 'off',
|
||||
// 允许未定义的 require(Next.js 配置)
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// 测试文件放宽规则
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// 禁用与 Prettier 冲突的规则
|
||||
prettierConfig,
|
||||
);
|
||||
5
apps/admin-portal/next-env.d.ts
vendored
Normal file
5
apps/admin-portal/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
54
apps/admin-portal/next.config.js
Normal file
54
apps/admin-portal/next.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const NextFederationPlugin = require("@module-federation/nextjs-mf");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
transpilePackages: [
|
||||
"@edu/ui-tokens",
|
||||
"@edu/ui-components",
|
||||
"@edu/hooks",
|
||||
],
|
||||
async rewrites() {
|
||||
const gatewayUrl = process.env.API_GATEWAY_URL || "http://localhost:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/admin/graphql",
|
||||
destination: `${gatewayUrl}/api/admin/graphql`,
|
||||
},
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${gatewayUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack(config, { isServer }) {
|
||||
if (process.env.NEXT_PUBLIC_MF_ENABLED === "true") {
|
||||
config.plugins.push(
|
||||
new NextFederationPlugin({
|
||||
name: "admin_app",
|
||||
filename: "static/chunks/remoteEntry.js",
|
||||
exposes: {
|
||||
"./AdminApp": "./src/app/admin-app.tsx",
|
||||
},
|
||||
remotes: {
|
||||
teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true, requiredVersion: "^18.3.0" },
|
||||
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
|
||||
urql: { singleton: true },
|
||||
graphql: { singleton: true },
|
||||
"@edu/ui-tokens": { singleton: true },
|
||||
"@edu/ui-components": { singleton: true },
|
||||
"@edu/hooks": { singleton: true },
|
||||
},
|
||||
extraOptions: { exposePages: false },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
61
apps/admin-portal/package.json
Normal file
61
apps/admin-portal/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@edu/admin-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4003",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4003",
|
||||
"lint": "eslint src --config eslint.config.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"msw:init": "msw init public/ --save"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/hooks": "workspace:*",
|
||||
"@edu/ui-components": "workspace:*",
|
||||
"@edu/ui-tokens": "workspace:*",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@urql/core": "^5.0.0",
|
||||
"graphql": "^16.9.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"recharts": "^2.12.0",
|
||||
"urql": "^4.2.0",
|
||||
"web-vitals": "^4.2.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@module-federation/nextjs-mf": "^8.8.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"mock-socket": "^9.3.0",
|
||||
"msw": "^2.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
apps/admin-portal/postcss.config.js
Normal file
6
apps/admin-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
361
apps/admin-portal/public/mockServiceWorker.js
Normal file
361
apps/admin-portal/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.15.0'
|
||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('text/event-stream')
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone && responseClone.body
|
||||
? [serializedRequest.body, responseClone.body]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
51
apps/admin-portal/src/app/admin-app.tsx
Normal file
51
apps/admin-portal/src/app/admin-app.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { ToastProvider } from "@/providers/toast-provider";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
import { MswInitializer } from "@/components/msw-initializer";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* MF Remote 入口组件
|
||||
*
|
||||
* 仲裁 ARB-002 §2.2:Shell 动态 import admin/AdminApp 加载管理端
|
||||
* - MF 模式:Shell 提供 GraphQLProvider/AuthProvider/AppShell,AdminApp 仅渲染页面
|
||||
* - Standalone 模式:AdminApp 自建 providers + AdminShell
|
||||
*
|
||||
* 暴露:next.config.js exposes: { './AdminApp': './src/app/admin-app.tsx' }
|
||||
*/
|
||||
export default function AdminApp({ children }: { children: ReactNode }) {
|
||||
const [mswReady, setMswReady] = useState(false);
|
||||
|
||||
// MF 模式下复用 Shell providers(通过 dynamic import)
|
||||
// Standalone 模式下自建 providers
|
||||
const isStandalone = process.env.NEXT_PUBLIC_MF_ENABLED !== "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStandalone) {
|
||||
setMswReady(true);
|
||||
}
|
||||
}, [isStandalone]);
|
||||
|
||||
if (!isStandalone) {
|
||||
// MF 模式:Shell 已提供 providers + AppShell
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Standalone 模式:自建 providers
|
||||
return (
|
||||
<GraphQLProvider>
|
||||
<MswInitializer onReady={() => setMswReady(true)} />
|
||||
{mswReady && (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
)}
|
||||
</GraphQLProvider>
|
||||
);
|
||||
}
|
||||
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal file
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
useAiProviders,
|
||||
useUpdateAiProvider,
|
||||
useAiUsage,
|
||||
} from "@/hooks/use-ai-settings";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, suffix }: StatCardProps): ReactNode {
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suffix && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AiSettingsPage(): ReactNode {
|
||||
const { data: providers, loading, error, refetch } = useAiProviders();
|
||||
const { data: usage } = useAiUsage();
|
||||
const [updateProvider, mutationState] = useUpdateAiProvider();
|
||||
const { show } = useToast();
|
||||
|
||||
const handleToggle = async (id: string, isActive: boolean) => {
|
||||
const res = await updateProvider(id, { isActive: !isActive });
|
||||
if (res) {
|
||||
show("success", `Provider 已${!isActive ? "启用" : "禁用"}`);
|
||||
refetch();
|
||||
} else if (mutationState.error) {
|
||||
show("error", "操作失败", mutationState.error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.aiSettings.title")}
|
||||
description="AI 服务商与用量管理"
|
||||
/>
|
||||
|
||||
{/* AI Provider 列表 */}
|
||||
<PaperCard className="p-4 mb-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.aiSettings.providers")}
|
||||
</h3>
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && providers && (
|
||||
<>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.name"),
|
||||
"类型",
|
||||
t("admin.aiSettings.model"),
|
||||
t("admin.aiSettings.isPublic"),
|
||||
t("admin.aiSettings.isActive"),
|
||||
"API Base",
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{p.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{p.model}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={p.isPublic ? "active" : "disabled"} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant={p.isActive ? "primary" : "secondary"}
|
||||
onClick={() => handleToggle(p.id, p.isActive)}
|
||||
disabled={mutationState.loading}
|
||||
>
|
||||
{p.isActive ? "启用" : "禁用"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{p.apiBase}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(p.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* AI 用量统计 */}
|
||||
{usage && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalRequests")}
|
||||
value={usage.totalRequests}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalTokens")}
|
||||
value={usage.totalTokens}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.aiSettings.totalCost")}
|
||||
value={`$${usage.totalCost.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider 用量明细 */}
|
||||
<PaperCard className="p-4 mb-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")}
|
||||
</h3>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.aiSettings.provider"),
|
||||
t("admin.aiSettings.totalRequests"),
|
||||
t("admin.aiSettings.totalTokens"),
|
||||
t("admin.aiSettings.totalCost"),
|
||||
]}
|
||||
>
|
||||
{usage.byProvider.map((row) => (
|
||||
<TableRow key={row.providerName}>
|
||||
<TableCell>{row.providerName}</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.requests}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.tokens}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
${row.cost.toFixed(2)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</PaperCard>
|
||||
|
||||
{/* 7 天趋势 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
7 {t("admin.auditLogs.trend")}
|
||||
</h3>
|
||||
<Table
|
||||
headers={[
|
||||
"日期",
|
||||
t("admin.aiSettings.totalRequests"),
|
||||
t("admin.aiSettings.totalTokens"),
|
||||
]}
|
||||
>
|
||||
{usage.byDay.map((row) => (
|
||||
<TableRow key={row.date}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{row.date}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.requests}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{row.tokens}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</PaperCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal file
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useAnnouncements,
|
||||
useAnnouncementFilter,
|
||||
useCreateAnnouncement,
|
||||
usePublishAnnouncement,
|
||||
useArchiveAnnouncement,
|
||||
useToggleAnnouncementPin,
|
||||
} from "@/hooks/use-announcements";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AnnouncementViewModel } from "@/types/view-models";
|
||||
|
||||
const AUDIENCE_OPTIONS = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "teachers", label: "教师" },
|
||||
{ value: "students", label: "学生" },
|
||||
{ value: "parents", label: "家长" },
|
||||
] as const;
|
||||
|
||||
const audienceLabel: Record<string, string> = {
|
||||
all: "全部",
|
||||
teachers: "教师",
|
||||
students: "学生",
|
||||
parents: "家长",
|
||||
};
|
||||
|
||||
export default function AnnouncementsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useAnnouncementFilter();
|
||||
const { data, loading, error } = useAnnouncements(filter);
|
||||
const { show } = useToast();
|
||||
const [createAnnouncement] = useCreateAnnouncement();
|
||||
const [publishAnnouncement] = usePublishAnnouncement();
|
||||
const [archiveAnnouncement] = useArchiveAnnouncement();
|
||||
const [togglePin] = useToggleAnnouncementPin();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formContent, setFormContent] = useState("");
|
||||
const [formAudience, setFormAudience] = useState<string[]>(["all"]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormTitle("");
|
||||
setFormContent("");
|
||||
setFormAudience(["all"]);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const toggleAudience = (value: string) => {
|
||||
setFormAudience((prev) => {
|
||||
if (value === "all") {
|
||||
return ["all"];
|
||||
}
|
||||
const next = prev.filter((a) => a !== "all");
|
||||
if (next.includes(value)) {
|
||||
const filtered = next.filter((a) => a !== value);
|
||||
return filtered.length === 0 ? ["all"] : filtered;
|
||||
}
|
||||
return [...next, value];
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) {
|
||||
show("warning", "请填写标题和内容");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createAnnouncement({
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
audience: formAudience,
|
||||
});
|
||||
show("success", "公告已创建");
|
||||
setModalOpen(false);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"创建失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await publishAnnouncement(ann.id);
|
||||
show("success", "公告已发布");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await archiveAnnouncement(ann.id);
|
||||
show("success", "公告已归档");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (ann: AnnouncementViewModel) => {
|
||||
try {
|
||||
await togglePin(ann.id);
|
||||
show("success", ann.isPinned ? "已取消置顶" : "已置顶");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.announcements.title")}
|
||||
description="管理全校公告、发布与归档"
|
||||
actions={
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
{t("admin.announcements.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="draft">{t("admin.announcements.draft")}</option>
|
||||
<option value="published">
|
||||
{t("admin.announcements.published")}
|
||||
</option>
|
||||
<option value="archived">
|
||||
{t("admin.announcements.archived")}
|
||||
</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
"标题",
|
||||
t("admin.common.status"),
|
||||
t("admin.announcements.audience"),
|
||||
t("admin.announcements.author"),
|
||||
t("admin.announcements.pin"),
|
||||
t("admin.announcements.readCount"),
|
||||
t("admin.announcements.publishedAt"),
|
||||
t("admin.common.createdAt"),
|
||||
t("admin.common.actions"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((ann) => (
|
||||
<TableRow key={ann.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{ann.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={ann.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ann.audience
|
||||
.map((a) => audienceLabel[a] ?? a)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell>{ann.authorName}</TableCell>
|
||||
<TableCell>
|
||||
{ann.isPinned ? <Badge status="pinned" /> : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ann.readCount}/{ann.totalCount}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ann.publishedAt
|
||||
? new Date(ann.publishedAt).toLocaleDateString(
|
||||
"zh-CN",
|
||||
)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(ann.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{ann.status === "draft" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handlePublish(ann)}
|
||||
>
|
||||
{t("admin.announcements.publish")}
|
||||
</Button>
|
||||
)}
|
||||
{ann.status === "published" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleArchive(ann)}
|
||||
>
|
||||
{t("admin.announcements.archive")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleTogglePin(ann)}
|
||||
>
|
||||
{ann.isPinned
|
||||
? t("admin.announcements.unpin")
|
||||
: t("admin.announcements.pin")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{modalOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 480,
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.announcements.new")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
标题
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
内容
|
||||
</span>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={5}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
}}
|
||||
placeholder="请输入公告内容"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.announcements.audience")}
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
{AUDIENCE_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-1"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formAudience.includes(opt.value)}
|
||||
onChange={() => toggleAudience(opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.common.loading")
|
||||
: t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal file
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useDataChangeLogs,
|
||||
useDataChangeLogFilter,
|
||||
exportDataChangeLogsCsv,
|
||||
} from "@/hooks/use-data-change-logs";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function DataChangesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setTableName, setAction } =
|
||||
useDataChangeLogFilter();
|
||||
const { data, loading, error } = useDataChangeLogs(filter);
|
||||
const { show } = useToast();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data || data.items.length === 0) {
|
||||
show("warning", "暂无数据可导出");
|
||||
return;
|
||||
}
|
||||
exportDataChangeLogsCsv(data.items);
|
||||
show("success", "已导出 CSV");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.dataChanges")}
|
||||
description="数据表变更记录"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
{t("admin.auditLogs.exportCsv")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.tableName ?? ""}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}数据表</option>
|
||||
<option value="users">用户表</option>
|
||||
<option value="roles">角色表</option>
|
||||
<option value="viewports">视口表</option>
|
||||
<option value="system_settings">系统设置表</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.action ?? ""}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}操作</option>
|
||||
<option value="create">创建</option>
|
||||
<option value="update">更新</option>
|
||||
<option value="delete">删除</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.auditLogs.time"),
|
||||
t("admin.auditLogs.actor"),
|
||||
t("admin.auditLogs.tableName"),
|
||||
t("admin.auditLogs.action"),
|
||||
t("admin.auditLogs.recordId"),
|
||||
t("admin.auditLogs.beforeState"),
|
||||
t("admin.auditLogs.afterState"),
|
||||
t("admin.auditLogs.ip"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(log.occurredAt).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>{log.actorName}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.tableName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.recordId}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.beforeState ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.afterState ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal file
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useLoginLogs, useLoginLogFilter } from "@/hooks/use-login-logs";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function LoginLogsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setAction, setStatus } =
|
||||
useLoginLogFilter();
|
||||
const { data, loading, error } = useLoginLogs(filter);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.loginLogs")}
|
||||
description="用户登录与登出记录"
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.action ?? ""}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}动作</option>
|
||||
<option value="signin">登录</option>
|
||||
<option value="signout">登出</option>
|
||||
<option value="signup">注册</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.status ?? ""}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failure">失败</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.auditLogs.time"),
|
||||
t("admin.common.name"),
|
||||
t("admin.auditLogs.loginAction"),
|
||||
t("admin.auditLogs.loginStatus"),
|
||||
t("admin.auditLogs.ip"),
|
||||
t("admin.auditLogs.userAgent"),
|
||||
t("admin.auditLogs.failureReason"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(log.occurredAt).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>{log.userName}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.action}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background:
|
||||
log.status === "success"
|
||||
? "var(--color-success)20"
|
||||
: "var(--color-danger)20",
|
||||
color:
|
||||
log.status === "success"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-danger)",
|
||||
border: `1px solid ${
|
||||
log.status === "success"
|
||||
? "var(--color-success)40"
|
||||
: "var(--color-danger)40"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{log.status === "success" ? "成功" : "失败"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{log.userAgent}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: log.failureReason
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{log.failureReason ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal file
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
useAuditOverviewStats,
|
||||
useAuditTrend,
|
||||
} from "@/hooks/use-audit-overview";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: StatCardProps): ReactNode {
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditOverviewPage(): ReactNode {
|
||||
const { data: stats, loading, error } = useAuditOverviewStats();
|
||||
const { data: trend } = useAuditTrend(7);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.overview")}
|
||||
description="审计事件统计与趋势"
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{stats && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.totalEvents")}
|
||||
value={stats.totalEvents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.todayEvents")}
|
||||
value={stats.todayEvents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.uniqueActors")}
|
||||
value={stats.uniqueActors}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.auditLogs.failedActions")}
|
||||
value={stats.failedActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 热门操作 & 热门资源 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.topActions")}
|
||||
</h3>
|
||||
{stats.topActions.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table headers={["操作", "次数"]}>
|
||||
{stats.topActions.map((item) => (
|
||||
<TableRow key={item.action}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{item.action}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{item.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.topResources")}
|
||||
</h3>
|
||||
{stats.topResources.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table headers={["资源", "次数"]}>
|
||||
{stats.topResources.map((item) => (
|
||||
<TableRow key={item.resource}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{item.resource}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{item.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.auditLogs.trend")}
|
||||
</h3>
|
||||
{!trend || trend.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.auditLogs.time"),
|
||||
t("admin.auditLogs.action"),
|
||||
t("admin.common.total"),
|
||||
]}
|
||||
>
|
||||
{trend.map((point) => (
|
||||
<TableRow key={`${point.date}-${point.action}`}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{point.date}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{point.action}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: "right" }}>
|
||||
{point.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
161
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useAuditLogs,
|
||||
useAuditLogFilter,
|
||||
exportAuditLogsCsv,
|
||||
} from "@/hooks/use-audit-logs";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function AuditLogsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setAction } = useAuditLogFilter();
|
||||
const { data, loading, error } = useAuditLogs(filter);
|
||||
const { show } = useToast();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data || data.items.length === 0) {
|
||||
show("warning", "暂无数据可导出");
|
||||
return;
|
||||
}
|
||||
exportAuditLogsCsv(data.items);
|
||||
show("success", "已导出 CSV");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.title")}
|
||||
description="系统所有操作的审计记录"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
{t("admin.auditLogs.exportCsv")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AuditLogsTabs />
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.action ?? ""}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}操作</option>
|
||||
<option value="USER_LOGIN">用户登录</option>
|
||||
<option value="USER_CREATE">创建用户</option>
|
||||
<option value="USER_UPDATE">更新用户</option>
|
||||
<option value="USER_TOGGLE_STATUS">切换状态</option>
|
||||
<option value="ROLE_CREATE">创建角色</option>
|
||||
<option value="ROLE_UPDATE">更新角色</option>
|
||||
<option value="VIEWPORT_UPDATE">更新视口</option>
|
||||
<option value="SYSTEM_SETTINGS_UPDATE">系统设置</option>
|
||||
<option value="ABNORMAL_LOGIN_BLOCKED">异常登录拦截</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.auditLogs.time"),
|
||||
t("admin.auditLogs.actor"),
|
||||
t("admin.auditLogs.action"),
|
||||
t("admin.auditLogs.resource"),
|
||||
t("admin.auditLogs.resourceId"),
|
||||
t("admin.auditLogs.ip"),
|
||||
t("admin.auditLogs.traceId"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(log.occurredAt).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>{log.actorName}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{log.resourceType}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.resourceId}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{log.traceId ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useClasses, useClassFilter } from "@/hooks/use-classes";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function ClassesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch } = useClassFilter();
|
||||
const { data, loading, error } = useClasses(filter);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader title={t("admin.classes.title")} description="全校班级一览" />
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
"班级",
|
||||
"年级",
|
||||
"学校",
|
||||
t("admin.classes.headTeacher"),
|
||||
t("admin.classes.studentCount"),
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((cls) => (
|
||||
<TableRow key={cls.id}>
|
||||
<TableCell>{cls.name}</TableCell>
|
||||
<TableCell>{cls.gradeName}</TableCell>
|
||||
<TableCell>{cls.schoolName}</TableCell>
|
||||
<TableCell>{cls.headTeacherName || "—"}</TableCell>
|
||||
<TableCell>{cls.studentCount}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(cls.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useDashboard } from "@/hooks/use-dashboard";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { NotificationPanel } from "@/components/notification-panel";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, suffix }: StatCardProps): ReactNode {
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suffix && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage(): ReactNode {
|
||||
const { data, loading, error } = useDashboard();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.dashboard.title")}
|
||||
description="全局概览与系统健康"
|
||||
/>
|
||||
|
||||
<NotificationPanel />
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalTeachers")}
|
||||
value={data.totalTeachers}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalStudents")}
|
||||
value={data.totalStudents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalClasses")}
|
||||
value={data.totalClasses}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalSchools")}
|
||||
value={data.totalSchools}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.schoolAvgScore")}
|
||||
value={data.schoolAvgScore.toFixed(1)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.activeUsersToday")}
|
||||
value={data.activeUsersToday}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.auditEventsToday")}
|
||||
value={data.auditEventsToday}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 趋势图(简化的表格) */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.dashboard.trend")}
|
||||
</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
日期
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
教师
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
学生
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
平均分
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.trend.map((row) => (
|
||||
<tr
|
||||
key={row.date}
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.date}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.teachers}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.students}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{row.avgScore.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 服务健康 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.dashboard.serviceHealth")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{data.serviceHealth.map((svc) => (
|
||||
<div
|
||||
key={svc.serviceName}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{svc.serviceName}
|
||||
</span>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{svc.latencyMs}ms
|
||||
</span>
|
||||
<Badge status={svc.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal file
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useFiles,
|
||||
useFileStats,
|
||||
useDeleteFiles,
|
||||
useFileFilter,
|
||||
} from "@/hooks/use-files";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function FilesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setMimeType, setEntityType } =
|
||||
useFileFilter();
|
||||
const { data, loading, error, refetch } = useFiles(filter);
|
||||
const { data: stats } = useFileStats();
|
||||
const [deleteFiles] = useDeleteFiles();
|
||||
const { show } = useToast();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!data || data.items.length === 0) return;
|
||||
if (selected.size === data.items.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(data.items.map((f) => f.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selected.size === 0) {
|
||||
show("warning", "请先选择文件");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除选中的 ${selected.size} 个文件?`)) return;
|
||||
const ok = await deleteFiles(Array.from(selected));
|
||||
if (ok) {
|
||||
show("success", `已删除 ${selected.size} 个文件`);
|
||||
setSelected(new Set());
|
||||
refetch();
|
||||
} else {
|
||||
show("error", "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.files.title")}
|
||||
description="全校文件资源管理"
|
||||
actions={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
{t("admin.files.batchDelete")} ({selected.size})
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{t("admin.files.totalFiles")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{stats.totalFiles}
|
||||
</p>
|
||||
</PaperCard>
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{t("admin.files.totalSize")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(stats.totalSize)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.mimeType ?? ""}
|
||||
onChange={(e) => setMimeType(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.files.mimeType")}
|
||||
</option>
|
||||
<option value="application/pdf">PDF</option>
|
||||
<option value="image/jpeg">JPEG</option>
|
||||
<option value="image/png">PNG</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
|
||||
Word
|
||||
</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
|
||||
Excel
|
||||
</option>
|
||||
<option value="application/vnd.openxmlformats-officedocument.presentationml.presentation">
|
||||
PPT
|
||||
</option>
|
||||
<option value="video/mp4">MP4</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filter.entityType ?? ""}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.files.entityType")}
|
||||
</option>
|
||||
<option value="exam">考试</option>
|
||||
<option value="lesson_plan">教案</option>
|
||||
<option value="user_avatar">用户头像</option>
|
||||
<option value="announcement">公告</option>
|
||||
<option value="homework">作业</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
"",
|
||||
t("admin.files.filename"),
|
||||
t("admin.files.mimeType"),
|
||||
t("admin.files.size"),
|
||||
t("admin.files.entityType"),
|
||||
t("admin.files.uploadedBy"),
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((file) => {
|
||||
const isImage = file.mimeType.startsWith("image/");
|
||||
const isPdf = file.mimeType === "application/pdf";
|
||||
return (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell style={{ width: 32 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(file.id)}
|
||||
onChange={() => toggleSelect(file.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isImage ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.filename}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<span>{file.filename}</span>
|
||||
</div>
|
||||
) : isPdf ? (
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{file.filename}
|
||||
</a>
|
||||
) : (
|
||||
<span>{file.filename}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{file.mimeType}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell>{file.entityType}</TableCell>
|
||||
<TableCell>{file.uploadedByName}</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(file.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
data.items.length > 0 &&
|
||||
selected.size === data.items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
全选 (已选 {selected.size}/{data.items.length})
|
||||
</label>
|
||||
</div>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal file
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useInvitationCodes,
|
||||
useInvitationCodeFilter,
|
||||
useGenerateInvitationCodes,
|
||||
useDeleteInvitationCodes,
|
||||
} from "@/hooks/use-invitation-codes";
|
||||
import { useRoles } from "@/hooks/use-roles";
|
||||
import { useClasses } from "@/hooks/use-classes";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function InvitationCodesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useInvitationCodeFilter();
|
||||
const { data, loading, error } = useInvitationCodes(filter);
|
||||
const { data: roles } = useRoles();
|
||||
const { data: classesData } = useClasses({ page: 1, pageSize: 100 });
|
||||
const { show } = useToast();
|
||||
const [generateCodes] = useGenerateInvitationCodes();
|
||||
const [deleteCodes] = useDeleteInvitationCodes();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 生成对话框
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [genCount, setGenCount] = useState(10);
|
||||
const [genRoleId, setGenRoleId] = useState("");
|
||||
const [genClassId, setGenClassId] = useState("");
|
||||
const [genEmail, setGenEmail] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// 生成结果对话框
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
|
||||
const [generatedBatchId, setGeneratedBatchId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const classes = useMemo(() => classesData?.items ?? [], [classesData]);
|
||||
|
||||
const allChecked = useMemo(
|
||||
() =>
|
||||
data !== null &&
|
||||
data.items.length > 0 &&
|
||||
data.items.every((ic) => selectedIds.has(ic.id)),
|
||||
[data, selectedIds],
|
||||
);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!data) return;
|
||||
if (allChecked) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(data.items.map((ic) => ic.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const openGenerateDialog = () => {
|
||||
setGenCount(10);
|
||||
setGenRoleId("");
|
||||
setGenClassId("");
|
||||
setGenEmail("");
|
||||
setGenerateOpen(true);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (genCount < 1 || genCount > 1000) {
|
||||
show("warning", "数量范围 1-1000");
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await generateCodes({
|
||||
count: genCount,
|
||||
roleId: genRoleId || undefined,
|
||||
classId: genClassId || undefined,
|
||||
email: genEmail || undefined,
|
||||
});
|
||||
if (result) {
|
||||
show(
|
||||
"success",
|
||||
t("admin.invitationCodes.generateSuccess", {
|
||||
count: result.codes.length,
|
||||
}),
|
||||
);
|
||||
setGeneratedCodes(result.codes);
|
||||
setGeneratedBatchId(result.batchId);
|
||||
setGenerateOpen(false);
|
||||
setResultOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"生成失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
show("warning", "请先选择要删除的邀请码");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除选中的 ${selectedIds.size} 个邀请码?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ok = await deleteCodes(Array.from(selectedIds));
|
||||
if (ok) {
|
||||
show("success", "删除成功");
|
||||
setSelectedIds(new Set());
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"删除失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCodes.join("\n"));
|
||||
show("success", "已复制到剪贴板");
|
||||
} catch {
|
||||
show("error", "复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.invitationCodes.title")}
|
||||
description="批量生成、管理与清理邀请码"
|
||||
actions={
|
||||
<Button variant="primary" onClick={openGenerateDialog}>
|
||||
{t("admin.invitationCodes.generate")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="unused">{t("admin.invitationCodes.unused")}</option>
|
||||
<option value="used">{t("admin.invitationCodes.used")}</option>
|
||||
<option value="expired">
|
||||
{t("admin.invitationCodes.expired")}
|
||||
</option>
|
||||
</Select>
|
||||
{selectedIds.size > 0 && (
|
||||
<Button variant="danger" onClick={handleBatchDelete}>
|
||||
{t("admin.common.delete")}({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
"",
|
||||
t("admin.invitationCodes.code"),
|
||||
t("admin.common.status"),
|
||||
t("admin.common.role"),
|
||||
t("admin.common.class"),
|
||||
t("admin.common.email"),
|
||||
t("admin.invitationCodes.batch"),
|
||||
t("admin.invitationCodes.usedBy"),
|
||||
t("admin.invitationCodes.usedAt"),
|
||||
t("admin.common.createdAt"),
|
||||
t("admin.invitationCodes.expiresAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((ic) => (
|
||||
<TableRow key={ic.id}>
|
||||
<TableCell style={{ width: 32 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(ic.id)}
|
||||
onChange={() => toggleSelect(ic.id)}
|
||||
aria-label={`select ${ic.code}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{ic.code}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={ic.status} />
|
||||
</TableCell>
|
||||
<TableCell>{ic.roleName ?? "—"}</TableCell>
|
||||
<TableCell>{ic.className ?? "—"}</TableCell>
|
||||
<TableCell>{ic.email ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{ic.batchId}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{ic.usedByName ?? "—"}</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ic.usedAt
|
||||
? new Date(ic.usedAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(ic.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{ic.expiresAt
|
||||
? new Date(ic.expiresAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
全选当前页
|
||||
</label>
|
||||
</div>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={(p) => {
|
||||
setSelectedIds(new Set());
|
||||
setPage(p);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 生成对话框 */}
|
||||
{generateOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 440,
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.invitationCodes.generate")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.generateCount")}(1-1000)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={genCount}
|
||||
onChange={(e) => setGenCount(Number(e.target.value) || 0)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindRole")}
|
||||
</span>
|
||||
<Select
|
||||
value={genRoleId}
|
||||
onChange={(e) => setGenRoleId(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}(不绑定)</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindClass")}(可选)
|
||||
</span>
|
||||
<Select
|
||||
value={genClassId}
|
||||
onChange={(e) => setGenClassId(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}(不绑定)</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.invitationCodes.bindEmail")}(可选)
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={genEmail}
|
||||
onChange={(e) => setGenEmail(e.target.value)}
|
||||
placeholder="user@edu.test"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setGenerateOpen(false)}
|
||||
>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating
|
||||
? t("admin.common.loading")
|
||||
: t("admin.invitationCodes.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果对话框 */}
|
||||
{resultOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 480,
|
||||
maxHeight: "80vh",
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("admin.invitationCodes.generateSuccess", {
|
||||
count: generatedCodes.length,
|
||||
})}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.invitationCodes.batch")}:{generatedBatchId}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
background: "var(--bg-canvas, var(--bg-paper))",
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{generatedCodes.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleCopyCodes}>
|
||||
复制全部
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setResultOpen(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{t("admin.common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||
import { AuthProvider, useAuth } from "@/providers/auth-provider";
|
||||
import { ToastProvider } from "@/providers/toast-provider";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
import { MswInitializer } from "@/components/msw-initializer";
|
||||
import { WebVitalsInitializer } from "@/components/web-vitals-initializer";
|
||||
|
||||
function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--color-ink-muted)" }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const [mswReady, setMswReady] = useState(false);
|
||||
|
||||
return (
|
||||
<GraphQLProvider>
|
||||
<WebVitalsInitializer />
|
||||
<MswInitializer onReady={() => setMswReady(true)} />
|
||||
{mswReady && (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AuthGuard>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</AuthGuard>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
)}
|
||||
</GraphQLProvider>
|
||||
);
|
||||
}
|
||||
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { OrganizationTree } from "@/components/organization-tree";
|
||||
import { PageHeader, PaperCard } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { OrganizationNode } from "@/types/view-models";
|
||||
|
||||
export default function OrganizationPage(): ReactNode {
|
||||
const [selected, setSelected] = useState<OrganizationNode | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.organization.title")}
|
||||
description="学校 → 年级 → 班级 三级组织架构"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
组织树
|
||||
</h3>
|
||||
<OrganizationTree onSelect={setSelected} />
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4" style={{ width: 320 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
节点详情
|
||||
</h3>
|
||||
{selected ? (
|
||||
<dl style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
名称
|
||||
</dt>
|
||||
<dd
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{selected.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
类型
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{selected.type === "school"
|
||||
? "学校"
|
||||
: selected.type === "grade"
|
||||
? "年级"
|
||||
: "班级"}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
ID
|
||||
</dt>
|
||||
<dd
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{selected.id}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
子节点数
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>{selected.childrenCount}</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
排序
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>{selected.sortOrder}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--color-ink-muted)" }}>
|
||||
选择左侧节点查看详情
|
||||
</p>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function PermissionsPage(): ReactNode {
|
||||
const { data: permissions, loading, error } = usePermissions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [resourceFilter, setResourceFilter] = useState("all");
|
||||
|
||||
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||
|
||||
const filtered = permissions.filter((p) => {
|
||||
if (resourceFilter !== "all" && p.resource !== resourceFilter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.permissions.title")}
|
||||
description="查看所有权限点定义"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => setResourceFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
{resources.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.permissions.code"),
|
||||
t("admin.permissions.resource"),
|
||||
t("admin.permissions.action"),
|
||||
"名称",
|
||||
"说明",
|
||||
"系统",
|
||||
]}
|
||||
>
|
||||
{filtered.map((perm) => (
|
||||
<TableRow key={perm.id}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{perm.code}
|
||||
</TableCell>
|
||||
<TableCell>{perm.resource}</TableCell>
|
||||
<TableCell>{perm.action}</TableCell>
|
||||
<TableCell>{perm.name}</TableCell>
|
||||
<TableCell
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||
>
|
||||
{perm.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{perm.isSystem ? <Badge status="active" /> : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
useRoles,
|
||||
useCreateRole,
|
||||
useUpdateRolePermissions,
|
||||
} from "@/hooks/use-roles";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { RolePermissionMatrix } from "@/components/role-permission-matrix";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { RoleViewModel } from "@/types/view-models";
|
||||
|
||||
export default function RolesPage(): ReactNode {
|
||||
const { data: roles, loading, error } = useRoles();
|
||||
const { data: permissions } = usePermissions();
|
||||
const { show } = useToast();
|
||||
const [createRole] = useCreateRole();
|
||||
const [updateRolePermissions] = useUpdateRolePermissions();
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState("");
|
||||
const [newRoleCode, setNewRoleCode] = useState("");
|
||||
const [newRoleDescription, setNewRoleDescription] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selectedRole: RoleViewModel | null = selectedRoleId
|
||||
? (roles.find((r) => r.id === selectedRoleId) ?? null)
|
||||
: (roles[0] ?? null);
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
if (!newRoleName || !newRoleCode) {
|
||||
show("warning", "请填写角色名称和代码");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await createRole({
|
||||
name: newRoleName,
|
||||
code: newRoleCode,
|
||||
description: newRoleDescription,
|
||||
dataScope: "SCHOOL",
|
||||
});
|
||||
show("success", "角色已创建");
|
||||
setShowCreateForm(false);
|
||||
setNewRoleName("");
|
||||
setNewRoleCode("");
|
||||
setNewRoleDescription("");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"创建失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async (permissionCodes: string[]) => {
|
||||
if (!selectedRole) return;
|
||||
try {
|
||||
await updateRolePermissions(selectedRole.id, permissionCodes);
|
||||
show("success", "权限已更新");
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"更新失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.roles.title")}
|
||||
description="管理角色、配置权限矩阵"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
|
||||
{t("admin.roles.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* 左侧:角色列表 */}
|
||||
<PaperCard className="p-4" style={{ width: 280, flexShrink: 0 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
角色列表
|
||||
</h3>
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{roles.map((role) => (
|
||||
<li key={role.id}>
|
||||
<button
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
background:
|
||||
selectedRole?.id === role.id
|
||||
? "var(--color-accent-light)"
|
||||
: "transparent",
|
||||
border: "none",
|
||||
borderLeft:
|
||||
selectedRole?.id === role.id
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{role.name}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{role.code}</span>
|
||||
{role.isSystem && <Badge status="active" />}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PaperCard>
|
||||
|
||||
{/* 右侧:权限矩阵 */}
|
||||
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||
{selectedRole && permissions.length > 0 ? (
|
||||
<RolePermissionMatrix
|
||||
role={selectedRole}
|
||||
permissions={permissions}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
) : (
|
||||
<LoadingState />
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
|
||||
{/* 新建角色对话框 */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 400,
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.roles.new")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
角色名称
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
角色代码
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleCode}
|
||||
onChange={(e) => setNewRoleCode(e.target.value)}
|
||||
placeholder="如 grade_leader"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
描述
|
||||
</span>
|
||||
<textarea
|
||||
value={newRoleDescription}
|
||||
onChange={(e) => setNewRoleDescription(e.target.value)}
|
||||
rows={3}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateRole}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating
|
||||
? t("admin.common.loading")
|
||||
: t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useAcademicYears } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AcademicYearViewModel } from "@/types/view-models";
|
||||
|
||||
function CurrentBadge(): ReactNode {
|
||||
const color = "var(--color-success)";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.currentYear")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcademicYearPage(): ReactNode {
|
||||
const { data, loading, error } = useAcademicYears();
|
||||
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"学年",
|
||||
t("admin.school.startDate"),
|
||||
t("admin.school.endDate"),
|
||||
"第一学期",
|
||||
"第二学期",
|
||||
t("admin.common.status"),
|
||||
]}
|
||||
>
|
||||
{data.map((year: AcademicYearViewModel) => (
|
||||
<TableRow key={year.id}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontWeight: year.isCurrent ? 600 : 500,
|
||||
color: year.isCurrent
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{year.name}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.startDate}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.endDate}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.firstSemesterStart} ~ {year.firstSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.secondSemesterStart} ~ {year.secondSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{year.isCurrent ? <CurrentBadge /> : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useDepartments, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function DepartmentsPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useDepartments(schoolId || undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"部门",
|
||||
t("admin.common.school"),
|
||||
t("admin.school.leader"),
|
||||
t("admin.school.memberCount"),
|
||||
t("admin.school.subject"),
|
||||
]}
|
||||
>
|
||||
{data.map((dept) => (
|
||||
<TableRow key={dept.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{dept.name}
|
||||
</TableCell>
|
||||
<TableCell>{dept.schoolName}</TableCell>
|
||||
<TableCell>{dept.leaderName ?? "—"}</TableCell>
|
||||
<TableCell>{dept.memberCount}</TableCell>
|
||||
<TableCell>{dept.subject ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useGradeInsights } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface SummaryCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, suffix }: SummaryCardProps): ReactNode {
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suffix && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function GradeInsightsPage(): ReactNode {
|
||||
const { data: grades, loading: gradesLoading } = useGrades();
|
||||
const [gradeId, setGradeId] = useState<string>("");
|
||||
|
||||
const selectedGradeId = gradeId || (grades[0]?.id ?? null);
|
||||
const { data, loading, error } = useGradeInsights(selectedGradeId);
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data.classRankings].sort((a, b) => a.rank - b.rank);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={selectedGradeId ?? ""}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
disabled={gradesLoading || grades.length === 0}
|
||||
>
|
||||
{grades.length === 0 && <option value="">暂无年级</option>}
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}({g.schoolName})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && !data && (
|
||||
<PaperCard className="p-4">
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
</PaperCard>
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 全校概览 */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.summary")} — {data.gradeName}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalStudents")}
|
||||
value={data.schoolWideSummary.totalStudents}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalClasses")}
|
||||
value={data.schoolWideSummary.totalClasses}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.avgScore")}
|
||||
value={data.schoolWideSummary.avgScore.toFixed(1)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.passingRate")}
|
||||
value={formatPercent(data.schoolWideSummary.passingRate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 作业时间线 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.homework")}
|
||||
</h3>
|
||||
{data.homeworkTimeline.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"作业",
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
"提交情况",
|
||||
t("admin.school.endDate"),
|
||||
]}
|
||||
>
|
||||
{data.homeworkTimeline.map((hw) => (
|
||||
<TableRow key={hw.homeworkId}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{hw.title}
|
||||
</TableCell>
|
||||
<TableCell>{hw.className}</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{hw.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{hw.submittedCount}/{hw.totalCount}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{hw.dueDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 班级排名 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.rankings")}
|
||||
</h3>
|
||||
{ranked.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.school.insights.rank"),
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
t("admin.school.insights.delta"),
|
||||
]}
|
||||
>
|
||||
{ranked.map((cls) => {
|
||||
const deltaColor =
|
||||
cls.delta > 0
|
||||
? "var(--color-success)"
|
||||
: cls.delta < 0
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-ink-muted)";
|
||||
const deltaSign = cls.delta > 0 ? "+" : "";
|
||||
return (
|
||||
<TableRow key={cls.classId}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{cls.rank}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{cls.className}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{cls.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: deltaColor,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{deltaSign}
|
||||
{cls.delta.toFixed(1)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { GradeViewModel } from "@/types/view-models";
|
||||
|
||||
export default function GradesPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useGrades(schoolId || undefined);
|
||||
|
||||
const sorted = useMemo<GradeViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.school"),
|
||||
t("admin.school.director"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.viewports.sortOrder"),
|
||||
]}
|
||||
>
|
||||
{sorted.map((grade) => (
|
||||
<TableRow key={grade.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{grade.name}
|
||||
</TableCell>
|
||||
<TableCell>{grade.schoolName}</TableCell>
|
||||
<TableCell>{grade.directorName ?? "—"}</TableCell>
|
||||
<TableCell>{grade.classCount}</TableCell>
|
||||
<TableCell>{grade.studentCount}</TableCell>
|
||||
<TableCell>{grade.sortOrder}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { PageHeader } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ href: "/admin/school/schools", label: t("admin.school.schools") },
|
||||
{ href: "/admin/school/grades", label: t("admin.school.grades") },
|
||||
{
|
||||
href: "/admin/school/grades/insights",
|
||||
label: t("admin.school.gradeInsights"),
|
||||
},
|
||||
{ href: "/admin/school/departments", label: t("admin.school.departments") },
|
||||
{
|
||||
href: "/admin/school/academic-year",
|
||||
label: t("admin.school.academicYear"),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SchoolLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.school.title")}
|
||||
description="学校、年级、部门与学年学期管理"
|
||||
/>
|
||||
|
||||
<nav
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive =
|
||||
pathname === tab.href ||
|
||||
(tab.href !== "/admin/school/grades" &&
|
||||
pathname.startsWith(tab.href));
|
||||
// 年级管理 / 年级洞察 都在 /admin/school/grades 下,需要精确区分
|
||||
const exactActive =
|
||||
tab.href === "/admin/school/grades"
|
||||
? pathname === "/admin/school/grades"
|
||||
: isActive;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: exactActive
|
||||
? "var(--color-ink)"
|
||||
: "var(--color-ink-muted)",
|
||||
textDecoration: "none",
|
||||
borderBottom: exactActive
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
marginBottom: "-1px",
|
||||
fontWeight: exactActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SchoolPage(): never {
|
||||
redirect("/admin/school/schools");
|
||||
}
|
||||
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Input,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { SchoolViewModel } from "@/types/view-models";
|
||||
|
||||
export default function SchoolsPage(): ReactNode {
|
||||
const { data, loading, error } = useSchools();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo<SchoolViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return data;
|
||||
return data.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
(s.principalName ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.school"),
|
||||
t("admin.system.schoolCode"),
|
||||
t("admin.system.address"),
|
||||
t("admin.system.contactPhone"),
|
||||
t("admin.system.contactEmail"),
|
||||
t("admin.school.principal"),
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.dashboard.totalTeachers"),
|
||||
]}
|
||||
>
|
||||
{filtered.map((school) => (
|
||||
<TableRow key={school.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{school.name}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.code}
|
||||
</TableCell>
|
||||
<TableCell>{school.address}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactPhone}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactEmail}
|
||||
</TableCell>
|
||||
<TableCell>{school.principalName ?? "—"}</TableCell>
|
||||
<TableCell>{school.gradeCount}</TableCell>
|
||||
<TableCell>{school.classCount}</TableCell>
|
||||
<TableCell>{school.studentCount}</TableCell>
|
||||
<TableCell>{school.teacherCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useStudents, useStudentFilter } from "@/hooks/use-students";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function StudentsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useStudentFilter();
|
||||
const { data, loading, error } = useStudents(filter);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.students.title")}
|
||||
description="全校学生一览"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.email"),
|
||||
t("admin.common.name"),
|
||||
t("admin.common.class"),
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.school"),
|
||||
t("admin.students.guardian"),
|
||||
t("admin.students.guardianPhone"),
|
||||
t("admin.common.status"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((student) => (
|
||||
<TableRow key={student.id}>
|
||||
<TableCell>{student.email}</TableCell>
|
||||
<TableCell>{student.name}</TableCell>
|
||||
<TableCell>{student.className}</TableCell>
|
||||
<TableCell>{student.gradeName}</TableCell>
|
||||
<TableCell>{student.schoolName}</TableCell>
|
||||
<TableCell>{student.guardianName ?? "—"}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{student.guardianPhone ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={student.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
602
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
602
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect, type FormEvent } from "react";
|
||||
import {
|
||||
useSystemSettings,
|
||||
useUpdateSystemSettings,
|
||||
} from "@/hooks/use-system-settings";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
mockSecurityPolicy,
|
||||
mockFileUploadConfig,
|
||||
mockNotificationConfig,
|
||||
mockBrandConfig,
|
||||
} from "@/mocks/fixtures";
|
||||
import type {
|
||||
SystemSettingsViewModel,
|
||||
SecurityPolicy,
|
||||
FileUploadConfig,
|
||||
NotificationConfig,
|
||||
BrandConfig,
|
||||
} from "@/types/view-models";
|
||||
|
||||
export default function SystemPage(): ReactNode {
|
||||
const { data, loading, error } = useSystemSettings();
|
||||
const { show } = useToast();
|
||||
const [updateSettings] = useUpdateSystemSettings();
|
||||
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [securityPolicy, setSecurityPolicy] =
|
||||
useState<SecurityPolicy>(mockSecurityPolicy);
|
||||
const [fileUploadConfig, setFileUploadConfig] =
|
||||
useState<FileUploadConfig>(mockFileUploadConfig);
|
||||
const [allowedTypesText, setAllowedTypesText] = useState(
|
||||
mockFileUploadConfig.allowedTypes.join(", "),
|
||||
);
|
||||
const [notificationConfig, setNotificationConfig] =
|
||||
useState<NotificationConfig>(mockNotificationConfig);
|
||||
const [brandConfig, setBrandConfig] = useState<BrandConfig>(mockBrandConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !form) {
|
||||
setForm(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateSettings(form);
|
||||
show("success", t("admin.system.saveSuccess"));
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"保存失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (field: keyof SystemSettingsViewModel, value: string) => {
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.system.title")}
|
||||
description="学校基本信息、学期与本地化设置"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-6">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{form && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.schoolName")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.schoolName}
|
||||
onChange={(e) => update("schoolName", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.schoolCode")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.schoolCode}
|
||||
onChange={(e) => update("schoolCode", e.target.value)}
|
||||
required
|
||||
style={{ fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.academicYear")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.academicYear}
|
||||
onChange={(e) => update("academicYear", e.target.value)}
|
||||
placeholder="如 2025-2026"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.semester")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.semester}
|
||||
onChange={(e) => update("semester", e.target.value)}
|
||||
>
|
||||
<option value="first">第一学期</option>
|
||||
<option value="second">第二学期</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.contactEmail")}
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => update("contactEmail", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.contactPhone")}
|
||||
</span>
|
||||
<Input
|
||||
type="tel"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => update("contactPhone", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
gridColumn: "span 2",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.address")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.address}
|
||||
onChange={(e) => update("address", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.timezone")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.timezone}
|
||||
onChange={(e) => update("timezone", e.target.value)}
|
||||
>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.locale")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.locale}
|
||||
onChange={(e) => update("locale", e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="primary" type="submit" disabled={saving}>
|
||||
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 安全策略 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.security")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.passwordMinLength")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={4}
|
||||
max={64}
|
||||
value={securityPolicy.passwordMinLength}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordMinLength: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.sessionTimeout")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={securityPolicy.sessionTimeoutMinutes}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
sessionTimeoutMinutes: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.passwordRequireSpecial}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordRequireSpecial: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.passwordRequireSpecial")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.passwordRequireUppercase}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
passwordRequireUppercase: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.passwordRequireUppercase")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securityPolicy.forcePasswordChange}
|
||||
onChange={(e) =>
|
||||
setSecurityPolicy((prev) => ({
|
||||
...prev,
|
||||
forcePasswordChange: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.forcePasswordChange")}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 文件上传配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.fileUpload")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.maxFileSize")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={fileUploadConfig.maxFileSize}
|
||||
onChange={(e) =>
|
||||
setFileUploadConfig((prev) => ({
|
||||
...prev,
|
||||
maxFileSize: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.allowedTypes")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={allowedTypesText}
|
||||
onChange={(e) => setAllowedTypesText(e.target.value)}
|
||||
placeholder="pdf, jpg, png, docx"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setFileUploadConfig((prev) => ({
|
||||
...prev,
|
||||
allowedTypes: allowedTypesText
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
}));
|
||||
show("success", t("admin.system.saveSuccess"));
|
||||
}}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 通知配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.notification")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyNewUser}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyNewUser: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyNewUser")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyScheduleChange}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyScheduleChange: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyScheduleChange")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationConfig.notifyAnnouncement}
|
||||
onChange={(e) =>
|
||||
setNotificationConfig((prev) => ({
|
||||
...prev,
|
||||
notifyAnnouncement: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t("admin.system.notifyAnnouncement")}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 品牌配置 */}
|
||||
<PaperCard className="p-6 mt-6">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("admin.system.brand")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.logoUrl")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={brandConfig.logoUrl}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
logoUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.primaryColor")}
|
||||
</span>
|
||||
<Input
|
||||
type="color"
|
||||
value={brandConfig.primaryColor}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
primaryColor: e.target.value,
|
||||
}))
|
||||
}
|
||||
style={{ width: 60, height: 32, padding: 2 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.schoolMotto")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={brandConfig.schoolMotto}
|
||||
onChange={(e) =>
|
||||
setBrandConfig((prev) => ({
|
||||
...prev,
|
||||
schoolMotto: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => show("success", t("admin.system.saveSuccess"))}
|
||||
>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useTeachers, useTeacherFilter } from "@/hooks/use-teachers";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function TeachersPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useTeacherFilter();
|
||||
const { data, loading, error } = useTeachers(filter);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.teachers.title")}
|
||||
description="全校教师一览"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||
<option value="locked">{t("admin.common.locked")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.email"),
|
||||
t("admin.common.name"),
|
||||
t("admin.common.school"),
|
||||
t("admin.teachers.subjects"),
|
||||
t("admin.teachers.classCount"),
|
||||
t("admin.common.status"),
|
||||
t("admin.common.lastLoginAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((teacher) => (
|
||||
<TableRow key={teacher.id}>
|
||||
<TableCell>{teacher.email}</TableCell>
|
||||
<TableCell>{teacher.name}</TableCell>
|
||||
<TableCell>{teacher.schoolName}</TableCell>
|
||||
<TableCell>
|
||||
{teacher.subjects.join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{teacher.classCount}</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={teacher.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{teacher.lastLoginAt
|
||||
? new Date(teacher.lastLoginAt).toLocaleDateString(
|
||||
"zh-CN",
|
||||
)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useUsers,
|
||||
useUserFilter,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useToggleUserStatus,
|
||||
} from "@/hooks/use-users";
|
||||
import { useRoles } from "@/hooks/use-roles";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { UserManagementTable } from "@/components/user-management-table";
|
||||
import { UserFormModal, type UserFormData } from "@/components/user-form-modal";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { UserViewModel } from "@/types/view-models";
|
||||
|
||||
export default function UsersPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useUserFilter();
|
||||
const { data, loading, error } = useUsers(filter);
|
||||
const { data: roles } = useRoles();
|
||||
const { show } = useToast();
|
||||
const [createUser] = useCreateUser();
|
||||
const [updateUser] = useUpdateUser();
|
||||
const [toggleStatus] = useToggleUserStatus();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserViewModel | null>(null);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: UserViewModel) => {
|
||||
setEditingUser(user);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (user: UserViewModel) => {
|
||||
const newStatus = user.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await toggleStatus(user.id, newStatus);
|
||||
show("success", "状态已更新");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: UserFormData) => {
|
||||
try {
|
||||
if (editingUser) {
|
||||
await updateUser(editingUser.id, {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
roleIds: formData.roleIds,
|
||||
dataScope: formData.dataScope,
|
||||
});
|
||||
show("success", "用户已更新");
|
||||
} else {
|
||||
await createUser({
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
password: formData.password ?? "",
|
||||
roleIds: formData.roleIds,
|
||||
dataScope: formData.dataScope,
|
||||
});
|
||||
show("success", "用户已创建");
|
||||
}
|
||||
setModalOpen(false);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.users.title")}
|
||||
description="管理所有用户账号、角色分配与状态"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||
<option value="locked">{t("admin.common.locked")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<UserManagementTable
|
||||
users={data.items}
|
||||
onEdit={handleEdit}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
<UserFormModal
|
||||
open={modalOpen}
|
||||
user={editingUser}
|
||||
roles={roles}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ViewportConfigViewModel } from "@/types/view-models";
|
||||
import { useViewports, useUpdateViewport } from "@/hooks/use-viewports";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Input,
|
||||
Select,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface SortableViewportItemProps {
|
||||
viewport: ViewportConfigViewModel;
|
||||
onToggleVisible: (id: string, isVisible: boolean) => void;
|
||||
onLabelChange: (id: string, label: string) => void;
|
||||
onPermissionChange: (id: string, perm: string | null) => void;
|
||||
}
|
||||
|
||||
function SortableViewportItem({
|
||||
viewport,
|
||||
onToggleVisible,
|
||||
onLabelChange,
|
||||
onPermissionChange,
|
||||
}: SortableViewportItemProps): ReactNode {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: viewport.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
background: "var(--bg-paper)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="拖拽排序"
|
||||
style={{
|
||||
cursor: "grab",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
⋮⋮
|
||||
</button>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{viewport.key}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={viewport.label}
|
||||
onChange={(e) => onLabelChange(viewport.id, e.target.value)}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={viewport.requiredPermission ?? ""}
|
||||
onChange={(e) =>
|
||||
onPermissionChange(viewport.id, e.target.value || null)
|
||||
}
|
||||
placeholder="无需权限"
|
||||
style={{ fontSize: 12, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: "var(--color-ink-muted)" }}>
|
||||
{viewport.scope}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewport.isVisible}
|
||||
onChange={(e) => onToggleVisible(viewport.id, e.target.checked)}
|
||||
aria-label="是否可见"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewportsPage(): ReactNode {
|
||||
const { data: viewports, loading, error } = useViewports();
|
||||
const { show } = useToast();
|
||||
const [updateViewport] = useUpdateViewport();
|
||||
const [localViewports, setLocalViewports] = useState<
|
||||
ViewportConfigViewModel[] | null
|
||||
>(null);
|
||||
const [scopeFilter, setScopeFilter] = useState<string>("all");
|
||||
|
||||
// 同步远端数据
|
||||
if (viewports.length > 0 && localViewports === null) {
|
||||
setLocalViewports(viewports);
|
||||
}
|
||||
|
||||
const filteredViewports = (localViewports ?? viewports).filter(
|
||||
(v) => scopeFilter === "all" || v.scope === scopeFilter,
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
if (!localViewports) return;
|
||||
const oldIndex = localViewports.findIndex((v) => v.id === active.id);
|
||||
const newIndex = localViewports.findIndex((v) => v.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
const reordered = arrayMove(localViewports, oldIndex, newIndex).map(
|
||||
(v, i) => ({ ...v, sortOrder: i + 1 }),
|
||||
);
|
||||
setLocalViewports(reordered);
|
||||
};
|
||||
|
||||
const handleToggleVisible = async (id: string, isVisible: boolean) => {
|
||||
try {
|
||||
await updateViewport(id, { isVisible });
|
||||
show("success", "已更新");
|
||||
setLocalViewports(
|
||||
(prev) =>
|
||||
prev?.map((v) => (v.id === id ? { ...v, isVisible } : v)) ?? null,
|
||||
);
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"更新失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLabelChange = async (id: string, label: string) => {
|
||||
setLocalViewports(
|
||||
(prev) => prev?.map((v) => (v.id === id ? { ...v, label } : v)) ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePermissionChange = async (id: string, perm: string | null) => {
|
||||
setLocalViewports(
|
||||
(prev) =>
|
||||
prev?.map((v) =>
|
||||
v.id === id ? { ...v, requiredPermission: perm } : v,
|
||||
) ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
if (!localViewports) return;
|
||||
try {
|
||||
for (const vp of localViewports) {
|
||||
await updateViewport(vp.id, {
|
||||
sortOrder: vp.sortOrder,
|
||||
label: vp.label,
|
||||
requiredPermission: vp.requiredPermission,
|
||||
isVisible: vp.isVisible,
|
||||
});
|
||||
}
|
||||
show("success", "全部已保存");
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"保存失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.viewports.title")}
|
||||
description="配置各端视口的路由、权限与排序"
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="teacher">教师端</option>
|
||||
<option value="student">学生端</option>
|
||||
<option value="parent">家长端</option>
|
||||
<option value="admin">管理端</option>
|
||||
</Select>
|
||||
<Button variant="primary" onClick={handleSaveOrder}>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filteredViewports.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderBottom: "2px solid var(--color-rule)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||
gap: 12,
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
<span></span>
|
||||
<span>{t("admin.viewports.key")}</span>
|
||||
<span>{t("admin.viewports.label")}</span>
|
||||
<span>{t("admin.viewports.requiredPermission")}</span>
|
||||
<span>{t("admin.viewports.scope")}</span>
|
||||
<span>{t("admin.viewports.visible")}</span>
|
||||
</div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredViewports.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filteredViewports.map((vp) => (
|
||||
<SortableViewportItem
|
||||
key={vp.id}
|
||||
viewport={vp}
|
||||
onToggleVisible={handleToggleVisible}
|
||||
onLabelChange={handleLabelChange}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginTop: 12,
|
||||
padding: "0 16px",
|
||||
}}
|
||||
>
|
||||
{t("admin.viewports.dragHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/admin-portal/src/app/api/health/route.ts
Normal file
17
apps/admin-portal/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 健康检查 - Liveness
|
||||
*
|
||||
* GET /api/health
|
||||
* 返回 200 表示进程存活
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET(): NextResponse {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
service: "admin-portal",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
47
apps/admin-portal/src/app/api/ready/route.ts
Normal file
47
apps/admin-portal/src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 就绪检查 - Readiness
|
||||
*
|
||||
* GET /api/ready
|
||||
* 检查依赖服务可达性(开发期直接返回 200)
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
// 开发期:MSW 启用时直接就绪
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
return NextResponse.json({
|
||||
status: "ready",
|
||||
service: "admin-portal",
|
||||
dependencies: { gateway: "mocked" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 生产:检查 api-gateway 可达性
|
||||
const gatewayUrl = process.env.API_GATEWAY_URL ?? "http://localhost:8080";
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/healthz`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`gateway status ${res.status}`);
|
||||
return NextResponse.json({
|
||||
status: "ready",
|
||||
service: "admin-portal",
|
||||
dependencies: { gateway: "ok" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "not_ready",
|
||||
service: "admin-portal",
|
||||
dependencies: { gateway: "unreachable" },
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
73
apps/admin-portal/src/app/globals.css
Normal file
73
apps/admin-portal/src/app/globals.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Primitive 设计令牌(Layer 1)— 白名单文件,定义原始色板 */
|
||||
:root {
|
||||
--bg-paper: hsl(40, 20%, 98%);
|
||||
--color-ink: hsl(25, 3%, 15%);
|
||||
--color-ink-muted: hsl(30, 5%, 45%);
|
||||
--color-accent: hsl(220, 60%, 35%);
|
||||
--color-accent-light: hsl(220, 60%, 95%);
|
||||
--color-rule: hsl(30, 10%, 90%);
|
||||
--color-danger: hsl(0, 70%, 45%);
|
||||
--color-danger-light: hsl(0, 70%, 95%);
|
||||
--color-success: hsl(140, 50%, 35%);
|
||||
--color-success-light: hsl(140, 50%, 95%);
|
||||
--color-warning: hsl(35, 80%, 45%);
|
||||
--color-warning-light: hsl(35, 80%, 95%);
|
||||
--font-serif: 'Fraunces', Georgia, serif;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 纸感分隔线 */
|
||||
.rule {
|
||||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
.rule-thin {
|
||||
border-top: 2px solid var(--color-rule);
|
||||
}
|
||||
|
||||
/* 左侧竖线标记 */
|
||||
.mark-left {
|
||||
border-left: 2px solid var(--color-rule);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* 焦点样式(A11y WCAG 2.2 AA)*/
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 跳过导航链接(A11y)*/
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--color-ink);
|
||||
color: var(--bg-paper);
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
transition: top 0.2s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
27
apps/admin-portal/src/app/layout.tsx
Normal file
27
apps/admin-portal/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces" });
|
||||
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edu Admin Portal",
|
||||
description: "K12 智慧教务平台 - 管理端",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||
>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
28
apps/admin-portal/src/app/login/layout.tsx
Normal file
28
apps/admin-portal/src/app/login/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { ToastProvider } from "@/providers/toast-provider";
|
||||
import { MswInitializer } from "@/components/msw-initializer";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Login layout - 提供 login 页面所需的 providers
|
||||
*
|
||||
* login 页面不在 /admin 下,需要独立的 provider 链
|
||||
*/
|
||||
export default function LoginLayout({ children }: { children: ReactNode }) {
|
||||
const [mswReady, setMswReady] = useState(false);
|
||||
|
||||
return (
|
||||
<GraphQLProvider>
|
||||
<MswInitializer onReady={() => setMswReady(true)} />
|
||||
{mswReady && (
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
)}
|
||||
</GraphQLProvider>
|
||||
);
|
||||
}
|
||||
154
apps/admin-portal/src/app/login/page.tsx
Normal file
154
apps/admin-portal/src/app/login/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* 登录页(standalone 模式 mock 登录)
|
||||
*
|
||||
* 仲裁 ARB-002 §2.3:MF 模式复用 Shell /login
|
||||
* 开发期 standalone 模式:自建 mock 登录页(MSW 拦截 /api/auth/login)
|
||||
*
|
||||
* Mock 凭据:admin@edu.test / admin123
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { show } = useToast();
|
||||
const [email, setEmail] = useState("admin@edu.test");
|
||||
const [password, setPassword] = useState("admin123");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
show("success", "登录成功");
|
||||
router.replace("/admin/dashboard");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "登录失败";
|
||||
show("error", "登录失败", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg-paper)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
padding: 32,
|
||||
background: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 24,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Edu 管理端
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 13,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{t("admin.auth.welcome")}
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.email")}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||
{t("admin.users.password")}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--bg-paper)",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? t("admin.common.loading") : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Mock: admin@edu.test / admin123
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/admin-portal/src/app/page.tsx
Normal file
5
apps/admin-portal/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
181
apps/admin-portal/src/components/admin-shell.tsx
Normal file
181
apps/admin-portal/src/components/admin-shell.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { ROUTE_PERMISSIONS } from "@/lib/permissions";
|
||||
|
||||
interface NavItem {
|
||||
route: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ route: "/admin/dashboard", label: t("admin.nav.dashboard") },
|
||||
{ route: "/admin/users", label: t("admin.nav.users") },
|
||||
{ route: "/admin/roles", label: t("admin.nav.roles") },
|
||||
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
||||
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
||||
{ route: "/admin/organization", label: t("admin.nav.organization") },
|
||||
{ route: "/admin/school", label: t("admin.school.title") },
|
||||
{ route: "/admin/classes", label: t("admin.nav.classes") },
|
||||
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
||||
{ route: "/admin/students", label: t("admin.nav.students") },
|
||||
{ route: "/admin/announcements", label: t("admin.announcements.title") },
|
||||
{ route: "/admin/invitation-codes", label: t("admin.invitationCodes.title") },
|
||||
{ route: "/admin/files", label: t("admin.files.title") },
|
||||
{ route: "/admin/ai-settings", label: t("admin.aiSettings.title") },
|
||||
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
|
||||
{ route: "/admin/system", label: t("admin.nav.system") },
|
||||
];
|
||||
|
||||
export function AdminShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, isLoading, isAuthenticated, logout, hasPermission } = useAuth();
|
||||
const { show } = useToast();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--color-ink-muted)" }}>
|
||||
{t("admin.common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||
const perm = ROUTE_PERMISSIONS[item.route];
|
||||
if (!perm) return true;
|
||||
return hasPermission(perm);
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
show("info", t("admin.auth.logout"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex"
|
||||
style={{ background: "var(--bg-paper)" }}
|
||||
>
|
||||
{/* 跳过导航链接(A11y WCAG 2.2 AA)*/}
|
||||
<a href="#main-content" className="skip-link">
|
||||
跳到主内容
|
||||
</a>
|
||||
|
||||
{/* 左侧栏:导航树 */}
|
||||
<aside
|
||||
className="w-56 flex-shrink-0 border-r relative flex flex-col"
|
||||
style={{
|
||||
borderColor: "var(--color-rule)",
|
||||
background: "var(--bg-paper)",
|
||||
}}
|
||||
aria-label="管理端导航"
|
||||
>
|
||||
<div className="px-6 py-6">
|
||||
<h1
|
||||
className="text-xl"
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
Edu
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{t("admin.auth.welcome")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rule-thin mx-6" />
|
||||
|
||||
<nav className="mt-4 px-3 flex-1 overflow-y-auto" aria-label="主导航">
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{visibleNavItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.route ||
|
||||
pathname.startsWith(item.route + "/");
|
||||
return (
|
||||
<li key={item.route}>
|
||||
<Link
|
||||
href={item.route}
|
||||
className="block px-3 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
borderLeft: active
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
fontFamily: active
|
||||
? "var(--font-serif)"
|
||||
: "var(--font-sans)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部:用户信息 + 登出 */}
|
||||
<div
|
||||
className="px-6 py-4 border-t"
|
||||
style={{ borderColor: "var(--color-rule)" }}
|
||||
>
|
||||
{user && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
|
||||
{user.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{user.roles.join(", ") || "无角色"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{
|
||||
color: "var(--color-ink-muted)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{t("admin.auth.logout")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 中间:内容区(纸面) */}
|
||||
<main id="main-content" className="flex-1 overflow-auto" role="main">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
href: string;
|
||||
exact: boolean;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ label: "操作日志", href: "/admin/audit-logs", exact: true },
|
||||
{
|
||||
label: t("admin.auditLogs.overview"),
|
||||
href: "/admin/audit-logs/overview",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.dataChanges"),
|
||||
href: "/admin/audit-logs/data-changes",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.loginLogs"),
|
||||
href: "/admin/audit-logs/login-logs",
|
||||
exact: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function AuditLogsTabs(): ReactNode {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 mb-4"
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = tab.exact
|
||||
? pathname === tab.href
|
||||
: pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: active ? "var(--color-accent)" : "var(--color-ink-muted)",
|
||||
borderBottom: active
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
textDecoration: "none",
|
||||
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* MSW 初始化组件
|
||||
*
|
||||
* NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
|
||||
* 上游就绪后设为 disabled,使用真实 API
|
||||
*/
|
||||
export function MswInitializer({ onReady }: { onReady: () => void }) {
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
import("@/mocks/browser")
|
||||
.then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" }))
|
||||
.then(onReady)
|
||||
.catch(() => onReady());
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { Badge } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { WsNotification } from "@/types/view-models";
|
||||
|
||||
const severityColor: Record<WsNotification["severity"], string> = {
|
||||
info: "var(--color-accent)",
|
||||
warning: "var(--color-warning)",
|
||||
error: "var(--color-danger)",
|
||||
};
|
||||
|
||||
const typeLabel: Record<WsNotification["type"], string> = {
|
||||
audit_alert: t("admin.notification.auditAlert"),
|
||||
abnormal_login: t("admin.notification.abnormalLogin"),
|
||||
system_error: t("admin.notification.systemError"),
|
||||
info: "系统通知",
|
||||
};
|
||||
|
||||
export function NotificationPanel(): ReactNode {
|
||||
const { notifications, connected, dismiss, clear } = useWebSocket(20);
|
||||
const { show } = useToast();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 320,
|
||||
maxHeight: "60vh",
|
||||
overflowY: "auto",
|
||||
background: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.08)",
|
||||
zIndex: 100,
|
||||
}}
|
||||
role="region"
|
||||
aria-label="通知中心"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t("admin.notification.title")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Badge status={connected ? "healthy" : "down"} />
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clear}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{notifications.length === 0 && (
|
||||
<li
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
暂无通知
|
||||
</li>
|
||||
)}
|
||||
{notifications.map((notif) => (
|
||||
<li
|
||||
key={notif.id}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
borderLeft: `3px solid ${severityColor[notif.severity]}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onClick={() => {
|
||||
show(
|
||||
notif.severity === "error"
|
||||
? "error"
|
||||
: notif.severity === "warning"
|
||||
? "warning"
|
||||
: "info",
|
||||
notif.title,
|
||||
notif.message,
|
||||
);
|
||||
dismiss(notif.id);
|
||||
}}
|
||||
aria-label={`${typeLabel[notif.type]}: ${notif.message}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{typeLabel[notif.type]}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: "var(--color-ink-muted)" }}>
|
||||
{new Date(notif.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
margin: "4px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{notif.message}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useOrganization } from "@/hooks/use-organization";
|
||||
import type { OrganizationNode } from "@/types/view-models";
|
||||
|
||||
interface OrganizationTreeProps {
|
||||
onSelect?: (node: OrganizationNode) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: OrganizationNode;
|
||||
level: number;
|
||||
onSelect?: (node: OrganizationNode) => void;
|
||||
}
|
||||
|
||||
const typeLabel: Record<OrganizationNode["type"], string> = {
|
||||
school: "学校",
|
||||
grade: "年级",
|
||||
class: "班级",
|
||||
};
|
||||
|
||||
function TreeNode({ node, level, onSelect }: TreeNodeProps): ReactNode {
|
||||
const [expanded, setExpanded] = useState(level < 2);
|
||||
const [children, setChildren] = useState<OrganizationNode[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [selected, setSelected] = useState(false);
|
||||
const { data, loading } = useOrganization(expanded ? node.id : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && !loaded && data.length > 0) {
|
||||
setChildren(data);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [expanded, loaded, data]);
|
||||
|
||||
const hasChildren = node.childrenCount > 0;
|
||||
void loading;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(true);
|
||||
onSelect?.(node);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
paddingLeft: 12 + level * 16,
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
background: selected ? "var(--color-accent-light)" : "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelected(true);
|
||||
onSelect?.(node);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="treeitem"
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
aria-selected={selected}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink-muted)",
|
||||
padding: 0,
|
||||
marginRight: 4,
|
||||
fontSize: 10,
|
||||
userSelect: "none",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
}
|
||||
}}
|
||||
aria-label={expanded ? "折叠" : "展开"}
|
||||
>
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: 14 }} />
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily:
|
||||
node.type === "school" ? "var(--font-serif)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 10,
|
||||
color: "var(--color-ink-muted)",
|
||||
padding: "1px 6px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{typeLabel[node.type]}
|
||||
</span>
|
||||
{hasChildren && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
({node.childrenCount})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasChildren && (
|
||||
<div role="group">
|
||||
{children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizationTree({
|
||||
onSelect,
|
||||
}: OrganizationTreeProps): ReactNode {
|
||||
const { data: roots, loading, error } = useOrganization(null);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div
|
||||
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div style={{ padding: 16, color: "var(--color-danger)", fontSize: 13 }}>
|
||||
{error.message}
|
||||
</div>
|
||||
);
|
||||
if (roots.length === 0)
|
||||
return (
|
||||
<div
|
||||
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="tree" aria-label="组织架构树">
|
||||
{roots.map((node) => (
|
||||
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import type { RoleViewModel, PermissionViewModel } from "@/types/view-models";
|
||||
import { Table, TableRow, TableCell, Button, Badge } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface RolePermissionMatrixProps {
|
||||
role: RoleViewModel;
|
||||
permissions: PermissionViewModel[];
|
||||
onSave: (permissionCodes: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RolePermissionMatrix({
|
||||
role,
|
||||
permissions,
|
||||
onSave,
|
||||
}: RolePermissionMatrixProps): ReactNode {
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(role.permissions.map((p) => p.code)),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(new Set(role.permissions.map((p) => p.code)));
|
||||
}, [role]);
|
||||
|
||||
const toggle = (code: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleResource = (resource: string) => {
|
||||
const resourcePerms = permissions.filter((p) => p.resource === resource);
|
||||
const allSelected = resourcePerms.every((p) => selected.has(p.code));
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
resourcePerms.forEach((p) => next.delete(p.code));
|
||||
} else {
|
||||
resourcePerms.forEach((p) => next.add(p.code));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(Array.from(selected));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 按资源分组
|
||||
const resourceGroups = permissions.reduce<
|
||||
Record<string, PermissionViewModel[]>
|
||||
>((acc, p) => {
|
||||
const arr = acc[p.resource] ?? (acc[p.resource] = []);
|
||||
arr.push(p);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{role.name}
|
||||
{role.isSystem && <Badge status="active" />}
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--color-ink-muted)" }}>
|
||||
{role.code} · {t("admin.roles.userCount")}: {role.userCount} ·{" "}
|
||||
{role.dataScope}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table headers={["资源", "权限点", "操作", "说明", "授权"]}>
|
||||
{Object.entries(resourceGroups).map(([resource, perms]) =>
|
||||
perms.map((perm, idx) => (
|
||||
<TableRow key={perm.id}>
|
||||
{idx === 0 && (
|
||||
<TableCell
|
||||
rowSpan={perms.length}
|
||||
style={{ verticalAlign: "top", fontWeight: 500 }}
|
||||
>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={perms.every((p) => selected.has(p.code))}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate =
|
||||
perms.some((p) => selected.has(p.code)) &&
|
||||
!perms.every((p) => selected.has(p.code));
|
||||
}}
|
||||
onChange={() => toggleResource(resource)}
|
||||
/>
|
||||
{resource}
|
||||
</label>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{perm.code}
|
||||
</TableCell>
|
||||
<TableCell>{perm.action}</TableCell>
|
||||
<TableCell
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||
>
|
||||
{perm.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(perm.code)}
|
||||
onChange={() => toggle(perm.code)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 通用 UI 组件测试
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
PaperCard,
|
||||
PageHeader,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Pagination,
|
||||
} from "./ui";
|
||||
|
||||
describe("PaperCard", () => {
|
||||
it("渲染 children", () => {
|
||||
render(<PaperCard>内容</PaperCard>);
|
||||
expect(screen.getByText("内容")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("支持自定义 className", () => {
|
||||
const { container } = render(
|
||||
<PaperCard className="custom-class">内容</PaperCard>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageHeader", () => {
|
||||
it("渲染标题", () => {
|
||||
render(<PageHeader title="用户管理" />);
|
||||
expect(screen.getByText("用户管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染描述", () => {
|
||||
render(<PageHeader title="用户管理" description="管理所有用户" />);
|
||||
expect(screen.getByText("管理所有用户")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染操作按钮", () => {
|
||||
render(<PageHeader title="用户管理" actions={<button>新建</button>} />);
|
||||
expect(screen.getByText("新建")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button", () => {
|
||||
it("默认 secondary variant", () => {
|
||||
render(<Button>点击</Button>);
|
||||
expect(screen.getByText("点击")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>点击</Button>);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disabled 时不触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button onClick={onClick} disabled>
|
||||
点击
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("支持所有 variant", () => {
|
||||
const { rerender } = render(<Button variant="primary">主按钮</Button>);
|
||||
expect(screen.getByText("主按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="danger">危险按钮</Button>);
|
||||
expect(screen.getByText("危险按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="ghost">幽灵按钮</Button>);
|
||||
expect(screen.getByText("幽灵按钮")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("渲染输入框并支持输入", () => {
|
||||
render(<Input placeholder="请输入" />);
|
||||
const input = screen.getByPlaceholderText("请输入");
|
||||
fireEvent.change(input, { target: { value: "测试内容" } });
|
||||
expect(input).toHaveValue("测试内容");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("渲染下拉框和选项", () => {
|
||||
render(
|
||||
<Select>
|
||||
<option value="a">选项A</option>
|
||||
<option value="b">选项B</option>
|
||||
</Select>,
|
||||
);
|
||||
expect(screen.getByText("选项A")).toBeInTheDocument();
|
||||
expect(screen.getByText("选项B")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge", () => {
|
||||
it("渲染 active 状态标签", () => {
|
||||
render(<Badge status="active" />);
|
||||
expect(screen.getByText("启用")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染 locked 状态标签", () => {
|
||||
render(<Badge status="locked" />);
|
||||
expect(screen.getByText("锁定")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染公告相关状态", () => {
|
||||
const { rerender } = render(<Badge status="draft" />);
|
||||
expect(screen.getByText("草稿")).toBeInTheDocument();
|
||||
rerender(<Badge status="published" />);
|
||||
expect(screen.getByText("已发布")).toBeInTheDocument();
|
||||
rerender(<Badge status="archived" />);
|
||||
expect(screen.getByText("已归档")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染邀请码状态", () => {
|
||||
const { rerender } = render(<Badge status="unused" />);
|
||||
expect(screen.getByText("未使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="used" />);
|
||||
expect(screen.getByText("已使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="expired" />);
|
||||
expect(screen.getByText("已过期")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("未知状态显示原始值", () => {
|
||||
render(<Badge status="unknown-status" />);
|
||||
expect(screen.getByText("unknown-status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table", () => {
|
||||
it("渲染表头和数据行", () => {
|
||||
render(
|
||||
<Table headers={["姓名", "邮箱"]}>
|
||||
<TableRow>
|
||||
<TableCell>张三</TableCell>
|
||||
<TableCell>zhangsan@test.com</TableCell>
|
||||
</TableRow>
|
||||
</Table>,
|
||||
);
|
||||
expect(screen.getByText("姓名")).toBeInTheDocument();
|
||||
expect(screen.getByText("邮箱")).toBeInTheDocument();
|
||||
expect(screen.getByText("张三")).toBeInTheDocument();
|
||||
expect(screen.getByText("zhangsan@test.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("渲染空状态消息", () => {
|
||||
render(<EmptyState message="暂无数据" />);
|
||||
expect(screen.getByText("暂无数据")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("渲染加载中文案", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("加载中...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("渲染错误消息", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.getByText("加载失败")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("提供 onRetry 时渲染重试按钮", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="加载失败" onRetry={onRetry} />);
|
||||
const retryBtn = screen.getByText("重试");
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("不提供 onRetry 时不渲染重试按钮", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.queryByText("重试")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("渲染总数和页码", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/共 45 条/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/第 2\/3 页/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("第一页禁用上一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("上一页")).toBeDisabled();
|
||||
expect(screen.getByText("下一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("最后一页禁用下一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={3}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("下一页")).toBeDisabled();
|
||||
expect(screen.getByText("上一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("点击上一页/下一页触发 onPageChange", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("上一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText("下一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("total 为 0 时显示第 1/1 页", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/第 1\/1 页/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
389
apps/admin-portal/src/components/ui.tsx
Normal file
389
apps/admin-portal/src/components/ui.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 通用 UI 组件(纸面风格)
|
||||
*
|
||||
* 复用 @edu/ui-components 中的基础组件,扩展 admin 专用组件
|
||||
*/
|
||||
import { type ReactNode, type ButtonHTMLAttributes } from "react";
|
||||
|
||||
/** 纸面卡片 */
|
||||
export function PaperCard({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border rounded-lg ${className}`}
|
||||
style={{
|
||||
borderColor: "var(--color-rule)",
|
||||
background: "var(--bg-paper)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 页面标题 */
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between mb-6 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 24,
|
||||
color: "var(--color-ink)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 13,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按钮 */
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
const buttonStyles: Record<ButtonVariant, Record<string, string>> = {
|
||||
primary: {
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
},
|
||||
secondary: {
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
border: "1px solid var(--color-rule)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--color-ink-muted)",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
danger: {
|
||||
background: "var(--color-danger)",
|
||||
color: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-danger)",
|
||||
},
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: ButtonProps): ReactNode {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
style={{
|
||||
...buttonStyles[variant],
|
||||
padding: "6px 14px",
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
cursor: props.disabled ? "not-allowed" : "pointer",
|
||||
opacity: props.disabled ? 0.5 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入框 */
|
||||
export function Input({
|
||||
style,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>): ReactNode {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 选择框 */
|
||||
export function Select({
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement>): ReactNode {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签(状态徽章) */
|
||||
export function Badge({ status }: { status: string }): ReactNode {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: "var(--color-success)",
|
||||
disabled: "var(--color-ink-muted)",
|
||||
locked: "var(--color-danger)",
|
||||
healthy: "var(--color-success)",
|
||||
degraded: "var(--color-warning)",
|
||||
down: "var(--color-danger)",
|
||||
draft: "var(--color-warning)",
|
||||
published: "var(--color-success)",
|
||||
archived: "var(--color-ink-muted)",
|
||||
unused: "var(--color-accent)",
|
||||
used: "var(--color-success)",
|
||||
expired: "var(--color-danger)",
|
||||
pinned: "var(--color-accent)",
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
active: "启用",
|
||||
disabled: "禁用",
|
||||
locked: "锁定",
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
down: "异常",
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
unused: "未使用",
|
||||
used: "已使用",
|
||||
expired: "已过期",
|
||||
pinned: "置顶",
|
||||
};
|
||||
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{labelMap[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格 */
|
||||
export function Table({
|
||||
headers,
|
||||
children,
|
||||
}: {
|
||||
headers: string[];
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full"
|
||||
style={{ borderCollapse: "collapse", fontSize: 13 }}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "2px solid var(--color-rule)" }}>
|
||||
{headers.map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontWeight: 500,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
whiteSpace: i === 0 ? "nowrap" : "normal",
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格行 */
|
||||
export function TableRow({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>{children}</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格单元格 */
|
||||
export function TableCell({
|
||||
children,
|
||||
style,
|
||||
rowSpan,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
rowSpan?: number;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<td
|
||||
style={{ padding: "10px 12px", color: "var(--color-ink)", ...style }}
|
||||
rowSpan={rowSpan}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export function EmptyState({ message }: { message: string }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14 }}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载状态 */
|
||||
export function LoadingState(): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14 }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 错误状态 */
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14, marginBottom: 8 }}>{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分页 */
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
hasNext,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
hasNext: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}): ReactNode {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between mt-4"
|
||||
style={{ fontSize: 12, color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
<span>
|
||||
共 {total} 条,第 {page}/{totalPages || 1} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, type FormEvent } from "react";
|
||||
import type { UserViewModel, RoleViewModel } from "@/types/view-models";
|
||||
import { Button, Input, Select } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface UserFormModalProps {
|
||||
open: boolean;
|
||||
user: UserViewModel | null;
|
||||
roles: RoleViewModel[];
|
||||
onSubmit: (data: UserFormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
email: string;
|
||||
name: string;
|
||||
password?: string;
|
||||
roleIds: string[];
|
||||
dataScope: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export function UserFormModal({
|
||||
open,
|
||||
user,
|
||||
roles,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: UserFormModalProps): ReactNode {
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [name, setName] = useState(user?.name ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [roleIds, setRoleIds] = useState<string[]>(
|
||||
user?.roles.map((r) => r.id) ?? [],
|
||||
);
|
||||
const [dataScope, setDataScope] = useState<string>(
|
||||
user?.dataScope ?? "SCHOOL",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isEdit = user !== null;
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
email,
|
||||
name,
|
||||
password: isEdit ? undefined : password,
|
||||
roleIds,
|
||||
dataScope,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = (id: string) => {
|
||||
setRoleIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 440,
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
padding: 24,
|
||||
background: "var(--bg-paper)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{isEdit ? t("admin.users.edit") : t("admin.users.new")}
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.email")}
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.name")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{!isEdit && (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.users.password")}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.dataScope")}
|
||||
</span>
|
||||
<Select
|
||||
value={dataScope}
|
||||
onChange={(e) => setDataScope(e.target.value)}
|
||||
>
|
||||
<option value="ALL">ALL - 全局</option>
|
||||
<option value="SCHOOL">SCHOOL - 学校</option>
|
||||
<option value="GRADE">GRADE - 年级</option>
|
||||
<option value="CLASS">CLASS - 班级</option>
|
||||
<option value="SUBJECT">SUBJECT - 学科</option>
|
||||
<option value="SELF">SELF - 自己</option>
|
||||
</Select>
|
||||
</label>
|
||||
<fieldset
|
||||
style={{
|
||||
border: "1px solid var(--color-rule)",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<legend style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.users.roles")}
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{roles.map((role) => (
|
||||
<label
|
||||
key={role.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleIds.includes(role.id)}
|
||||
onChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
<span
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 11 }}
|
||||
>
|
||||
({role.code})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" type="button" onClick={onCancel}>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={loading}>
|
||||
{loading ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import type { UserViewModel } from "@/types/view-models";
|
||||
import { Badge, Button, Table, TableRow, TableCell } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface UserManagementTableProps {
|
||||
users: UserViewModel[];
|
||||
onEdit: (user: UserViewModel) => void;
|
||||
onToggleStatus: (user: UserViewModel) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export function UserManagementTable({
|
||||
users,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onCreate,
|
||||
}: UserManagementTableProps): ReactNode {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
{t("admin.users.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
headers={[
|
||||
"邮箱",
|
||||
"姓名",
|
||||
"角色",
|
||||
"状态",
|
||||
"数据范围",
|
||||
"学校",
|
||||
"最后登录",
|
||||
"操作",
|
||||
]}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>
|
||||
{user.roles.map((r) => r.name).join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={user.status} />
|
||||
</TableCell>
|
||||
<TableCell>{user.dataScope}</TableCell>
|
||||
<TableCell>{user.schoolName ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onEdit(user)}>
|
||||
{t("admin.common.edit")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onToggleStatus(user)}>
|
||||
{t("admin.users.toggleStatus")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Web Vitals 采集初始化组件
|
||||
*
|
||||
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||
* 在客户端挂载时初始化 LCP/CLS/FCP/INP/TTFB 采集
|
||||
*/
|
||||
export function WebVitalsInitializer(): null {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
void import("@/lib/web-vitals").then(({ initWebVitals }) =>
|
||||
initWebVitals(),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 配置 Hooks
|
||||
*
|
||||
* contract §2.4:aiProviders / updateAiProvider / aiUsage
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
AI_PROVIDERS_QUERY,
|
||||
UPDATE_AI_PROVIDER_MUTATION,
|
||||
AI_USAGE_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
AiProviderViewModel,
|
||||
AiUsageViewModel,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AiProvidersResponse {
|
||||
aiProviders: AiProviderViewModel[];
|
||||
}
|
||||
|
||||
interface UpdateAiProviderResponse {
|
||||
updateAiProvider: { id: string; isActive: boolean };
|
||||
}
|
||||
|
||||
interface AiUsageResponse {
|
||||
aiUsage: AiUsageViewModel;
|
||||
}
|
||||
|
||||
export function useAiProviders() {
|
||||
const result = useGraphQuery<AiProvidersResponse>(AI_PROVIDERS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.aiProviders ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateAiProvider() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UpdateAiProviderResponse,
|
||||
{ id: string; input: { isActive: boolean } }
|
||||
>(UPDATE_AI_PROVIDER_MUTATION);
|
||||
const update = useCallback(
|
||||
async (id: string, input: { isActive: boolean }) => {
|
||||
const res = await run({ id, input });
|
||||
return res.data?.updateAiProvider ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
|
||||
export function useAiUsage() {
|
||||
const result = useGraphQuery<AiUsageResponse>(AI_USAGE_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.aiUsage ?? null,
|
||||
};
|
||||
}
|
||||
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 公告管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminAnnouncements / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_ANNOUNCEMENTS_QUERY,
|
||||
CREATE_ANNOUNCEMENT_MUTATION,
|
||||
PUBLISH_ANNOUNCEMENT_MUTATION,
|
||||
ARCHIVE_ANNOUNCEMENT_MUTATION,
|
||||
TOGGLE_ANNOUNCEMENT_PIN_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
AnnouncementViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminAnnouncementsResponse {
|
||||
adminAnnouncements: PaginatedResult<AnnouncementViewModel>;
|
||||
}
|
||||
|
||||
interface AnnouncementMutationResponse {
|
||||
createAnnouncement?: { id: string; title: string; status: string };
|
||||
publishAnnouncement?: { id: string; status: string };
|
||||
archiveAnnouncement?: { id: string; status: string };
|
||||
toggleAnnouncementPin?: { id: string; isPinned: boolean };
|
||||
}
|
||||
|
||||
interface CreateAnnouncementInput {
|
||||
title: string;
|
||||
content: string;
|
||||
audience?: string[];
|
||||
}
|
||||
|
||||
export function useAnnouncements(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminAnnouncementsResponse>(
|
||||
ADMIN_ANNOUNCEMENTS_QUERY,
|
||||
{ filter },
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminAnnouncements ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAnnouncementFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||
}
|
||||
|
||||
export function useCreateAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ input: CreateAnnouncementInput }
|
||||
>(CREATE_ANNOUNCEMENT_MUTATION);
|
||||
const create = useCallback(
|
||||
async (input: CreateAnnouncementInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.createAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [create, state] as const;
|
||||
}
|
||||
|
||||
export function usePublishAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(PUBLISH_ANNOUNCEMENT_MUTATION);
|
||||
const publish = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.publishAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [publish, state] as const;
|
||||
}
|
||||
|
||||
export function useArchiveAnnouncement() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(ARCHIVE_ANNOUNCEMENT_MUTATION);
|
||||
const archive = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.archiveAnnouncement ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [archive, state] as const;
|
||||
}
|
||||
|
||||
export function useToggleAnnouncementPin() {
|
||||
const [run, state] = useGraphMutation<
|
||||
AnnouncementMutationResponse,
|
||||
{ id: string }
|
||||
>(TOGGLE_ANNOUNCEMENT_PIN_MUTATION);
|
||||
const toggle = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await run({ id });
|
||||
return res.data?.toggleAnnouncementPin ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [toggle, state] as const;
|
||||
}
|
||||
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 审计日志 Hooks
|
||||
*
|
||||
* contract §2.4:auditLogs
|
||||
* 仲裁 ARB-005:经 teacher-bff GraphQL 消费(不直连 Kafka)
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { AUDIT_LOGS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
AuditLogViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AuditLogsResponse {
|
||||
auditLogs: PaginatedResult<AuditLogViewModel>;
|
||||
}
|
||||
|
||||
interface AuditLogFilter extends Partial<ListFilter> {
|
||||
action?: string;
|
||||
actorUserId?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export function useAuditLogs(filter: AuditLogFilter) {
|
||||
const result = useGraphQuery<AuditLogsResponse>(AUDIT_LOGS_QUERY, { filter });
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.auditLogs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuditLogFilter(initial?: Partial<AuditLogFilter>) {
|
||||
const [filter, setFilter] = useState<AuditLogFilter>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setAction = useCallback(
|
||||
(action: string) =>
|
||||
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setAction };
|
||||
}
|
||||
|
||||
/** 审计日志导出 CSV */
|
||||
export function exportAuditLogsCsv(logs: AuditLogViewModel[]): void {
|
||||
const headers = [
|
||||
"时间",
|
||||
"操作人",
|
||||
"操作",
|
||||
"资源类型",
|
||||
"资源ID",
|
||||
"IP",
|
||||
"追踪ID",
|
||||
];
|
||||
const rows = logs.map((l) => [
|
||||
new Date(l.occurredAt).toLocaleString("zh-CN"),
|
||||
l.actorName,
|
||||
l.action,
|
||||
l.resourceType,
|
||||
l.resourceId,
|
||||
l.ip,
|
||||
l.traceId ?? "",
|
||||
]);
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 审计总览 Hooks
|
||||
*
|
||||
* contract §2.4:auditOverviewStats / auditTrend
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
AUDIT_OVERVIEW_STATS_QUERY,
|
||||
AUDIT_TREND_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type { AuditOverviewStats, AuditTrendPoint } from "@/types/view-models";
|
||||
|
||||
interface AuditOverviewStatsResponse {
|
||||
auditOverviewStats: AuditOverviewStats;
|
||||
}
|
||||
|
||||
interface AuditTrendResponse {
|
||||
auditTrend: AuditTrendPoint[];
|
||||
}
|
||||
|
||||
export function useAuditOverviewStats() {
|
||||
const result = useGraphQuery<AuditOverviewStatsResponse>(
|
||||
AUDIT_OVERVIEW_STATS_QUERY,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.auditOverviewStats ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuditTrend(days?: number) {
|
||||
const result = useGraphQuery<AuditTrendResponse>(AUDIT_TREND_QUERY, {
|
||||
days,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.auditTrend ?? null,
|
||||
};
|
||||
}
|
||||
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 班级管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminClasses
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_CLASSES_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
AdminClassViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminClassesResponse {
|
||||
adminClasses: PaginatedResult<AdminClassViewModel>;
|
||||
}
|
||||
|
||||
export function useClasses(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminClassesResponse>(ADMIN_CLASSES_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminClasses ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useClassFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch };
|
||||
}
|
||||
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 仪表盘 Hooks
|
||||
*
|
||||
* contract §2.4:adminDashboard
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_DASHBOARD_QUERY } from "@/lib/graphql-client";
|
||||
import type { AdminDashboardViewModel } from "@/types/view-models";
|
||||
|
||||
interface AdminDashboardResponse {
|
||||
adminDashboard: AdminDashboardViewModel;
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const result = useGraphQuery<AdminDashboardResponse>(ADMIN_DASHBOARD_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminDashboard ?? null,
|
||||
};
|
||||
}
|
||||
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 数据变更日志 Hooks
|
||||
*
|
||||
* contract §2.4:dataChangeLogs
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { DATA_CHANGE_LOGS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
DataChangeLogViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface DataChangeLogsResponse {
|
||||
dataChangeLogs: PaginatedResult<DataChangeLogViewModel>;
|
||||
}
|
||||
|
||||
interface DataChangeLogFilter extends Partial<ListFilter> {
|
||||
tableName?: string;
|
||||
action?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export function useDataChangeLogs(filter: DataChangeLogFilter) {
|
||||
const result = useGraphQuery<DataChangeLogsResponse>(DATA_CHANGE_LOGS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.dataChangeLogs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDataChangeLogFilter(initial?: Partial<DataChangeLogFilter>) {
|
||||
const [filter, setFilter] = useState<DataChangeLogFilter>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setTableName = useCallback(
|
||||
(tableName: string) =>
|
||||
setFilter((f) => ({ ...f, tableName: tableName || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setAction = useCallback(
|
||||
(action: string) =>
|
||||
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setTableName, setAction };
|
||||
}
|
||||
|
||||
/** 数据变更日志导出 CSV */
|
||||
export function exportDataChangeLogsCsv(logs: DataChangeLogViewModel[]): void {
|
||||
const headers = [
|
||||
"时间",
|
||||
"操作人",
|
||||
"数据表",
|
||||
"操作",
|
||||
"记录ID",
|
||||
"变更前",
|
||||
"变更后",
|
||||
"IP",
|
||||
];
|
||||
const rows = logs.map((l) => [
|
||||
new Date(l.occurredAt).toLocaleString("zh-CN"),
|
||||
l.actorName,
|
||||
l.tableName,
|
||||
l.action,
|
||||
l.recordId,
|
||||
l.beforeState ?? "",
|
||||
l.afterState ?? "",
|
||||
l.ip,
|
||||
]);
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `data-change-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 文件管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminFiles / adminFileStats / deleteFiles
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_FILES_QUERY,
|
||||
ADMIN_FILE_STATS_QUERY,
|
||||
DELETE_FILES_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
FileViewModel,
|
||||
FileStats,
|
||||
PaginatedResult,
|
||||
} from "@/types/view-models";
|
||||
|
||||
/** 文件列表筛选参数 */
|
||||
export interface FileFilter {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
mimeType?: string;
|
||||
entityType?: string;
|
||||
}
|
||||
|
||||
interface AdminFilesResponse {
|
||||
adminFiles: PaginatedResult<FileViewModel>;
|
||||
}
|
||||
|
||||
interface AdminFileStatsResponse {
|
||||
adminFileStats: FileStats;
|
||||
}
|
||||
|
||||
interface DeleteFilesResponse {
|
||||
deleteFiles: boolean;
|
||||
}
|
||||
|
||||
export function useFiles(filter: FileFilter) {
|
||||
const result = useGraphQuery<AdminFilesResponse>(ADMIN_FILES_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminFiles ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFileStats() {
|
||||
const result = useGraphQuery<AdminFileStatsResponse>(ADMIN_FILE_STATS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminFileStats ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteFiles() {
|
||||
const [run, state] = useGraphMutation<DeleteFilesResponse, { ids: string[] }>(
|
||||
DELETE_FILES_MUTATION,
|
||||
);
|
||||
const deleteFiles = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const res = await run({ ids });
|
||||
return res.data?.deleteFiles ?? false;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [deleteFiles, state] as const;
|
||||
}
|
||||
|
||||
/** 文件筛选状态管理 */
|
||||
export function useFileFilter(initial?: FileFilter) {
|
||||
const [filter, setFilter] = useState<FileFilter>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setMimeType = useCallback(
|
||||
(mimeType: string) =>
|
||||
setFilter((f) => ({ ...f, mimeType: mimeType || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setEntityType = useCallback(
|
||||
(entityType: string) =>
|
||||
setFilter((f) => ({
|
||||
...f,
|
||||
entityType: entityType || undefined,
|
||||
page: 1,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setMimeType, setEntityType };
|
||||
}
|
||||
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 通用 GraphQL hooks
|
||||
*
|
||||
* 仲裁 ARB-001:admin 命名空间 GraphQL(urql)
|
||||
*
|
||||
* 注意:urql 的 useQuery/useMutation 的泛型推断与纯字符串 query 兼容性有限,
|
||||
* 这里使用类型断言从 unknown 转换(符合项目规则:从 unknown 转换允许 as 断言)
|
||||
*/
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { AnyVariables, OperationResult } from "@urql/core";
|
||||
|
||||
export interface QueryResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface MutationResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/** 通用 Query Hook */
|
||||
export function useGraphQuery<
|
||||
T = unknown,
|
||||
V extends AnyVariables = AnyVariables,
|
||||
>(query: string, variables?: V): QueryResult<T> {
|
||||
const [result] = useQuery({
|
||||
query,
|
||||
variables: variables ?? ({} as V),
|
||||
});
|
||||
return {
|
||||
data: (result.data as T | undefined) ?? null,
|
||||
loading: result.fetching,
|
||||
error: result.error ? new Error(result.error.message) : null,
|
||||
refetch: () => {
|
||||
void result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 通用 Mutation Hook */
|
||||
export function useGraphMutation<
|
||||
T = unknown,
|
||||
V extends AnyVariables = AnyVariables,
|
||||
>(
|
||||
mutation: string,
|
||||
): [(variables: V) => Promise<MutationResult<T>>, MutationResult<T>] {
|
||||
// urql 的 useMutation 返回 [executeFn, state]
|
||||
// 使用 unknown 转换处理类型(项目规则允许从 unknown 转换)
|
||||
const [executeMutation] = useMutation(mutation) as unknown as [
|
||||
(variables: V) => { toPromise: () => Promise<OperationResult<T, V>> },
|
||||
unknown,
|
||||
];
|
||||
const [state, setState] = useState<MutationResult<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const run = useCallback(
|
||||
async (variables: V): Promise<MutationResult<T>> => {
|
||||
setState({ data: null, loading: true, error: null });
|
||||
try {
|
||||
const res = await executeMutation(variables).toPromise();
|
||||
if (res.error) {
|
||||
const errState: MutationResult<T> = {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: new Error(res.error.message),
|
||||
};
|
||||
setState(errState);
|
||||
return errState;
|
||||
}
|
||||
const okState: MutationResult<T> = {
|
||||
data: res.data ?? null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
setState(okState);
|
||||
return okState;
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const errState: MutationResult<T> = {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: err,
|
||||
};
|
||||
setState(errState);
|
||||
return errState;
|
||||
}
|
||||
},
|
||||
[executeMutation],
|
||||
);
|
||||
|
||||
return [run, state];
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 邀请码管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminInvitationCodes / generateInvitationCodes / deleteInvitationCodes
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_INVITATION_CODES_QUERY,
|
||||
GENERATE_INVITATION_CODES_MUTATION,
|
||||
DELETE_INVITATION_CODES_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
InvitationCodeViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminInvitationCodesResponse {
|
||||
adminInvitationCodes: PaginatedResult<InvitationCodeViewModel>;
|
||||
}
|
||||
|
||||
interface GenerateInvitationCodesResponse {
|
||||
generateInvitationCodes: { codes: string[]; batchId: string };
|
||||
}
|
||||
|
||||
interface DeleteInvitationCodesResponse {
|
||||
deleteInvitationCodes: boolean;
|
||||
}
|
||||
|
||||
interface GenerateInvitationCodesInput {
|
||||
count: number;
|
||||
roleId?: string;
|
||||
classId?: string;
|
||||
email?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export function useInvitationCodes(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminInvitationCodesResponse>(
|
||||
ADMIN_INVITATION_CODES_QUERY,
|
||||
{ filter },
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminInvitationCodes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInvitationCodeFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||
}
|
||||
|
||||
export function useGenerateInvitationCodes() {
|
||||
const [run, state] = useGraphMutation<
|
||||
GenerateInvitationCodesResponse,
|
||||
{ input: GenerateInvitationCodesInput }
|
||||
>(GENERATE_INVITATION_CODES_MUTATION);
|
||||
const generate = useCallback(
|
||||
async (input: GenerateInvitationCodesInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.generateInvitationCodes ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [generate, state] as const;
|
||||
}
|
||||
|
||||
export function useDeleteInvitationCodes() {
|
||||
const [run, state] = useGraphMutation<
|
||||
DeleteInvitationCodesResponse,
|
||||
{ ids: string[] }
|
||||
>(DELETE_INVITATION_CODES_MUTATION);
|
||||
const remove = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const res = await run({ ids });
|
||||
return res.data?.deleteInvitationCodes ?? false;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [remove, state] as const;
|
||||
}
|
||||
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 登录日志 Hooks
|
||||
*
|
||||
* contract §2.4:loginLogs
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { LOGIN_LOGS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
LoginLogViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface LoginLogsResponse {
|
||||
loginLogs: PaginatedResult<LoginLogViewModel>;
|
||||
}
|
||||
|
||||
interface LoginLogFilter extends Partial<ListFilter> {
|
||||
action?: string;
|
||||
status?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export function useLoginLogs(filter: LoginLogFilter) {
|
||||
const result = useGraphQuery<LoginLogsResponse>(LOGIN_LOGS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.loginLogs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLoginLogFilter(initial?: Partial<LoginLogFilter>) {
|
||||
const [filter, setFilter] = useState<LoginLogFilter>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setAction = useCallback(
|
||||
(action: string) =>
|
||||
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) =>
|
||||
setFilter((f) => ({ ...f, status: status || undefined, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setAction, setStatus };
|
||||
}
|
||||
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminOrganization
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_ORGANIZATION_QUERY } from "@/lib/graphql-client";
|
||||
import type { OrganizationNode } from "@/types/view-models";
|
||||
|
||||
interface AdminOrganizationResponse {
|
||||
adminOrganization: OrganizationNode[];
|
||||
}
|
||||
|
||||
export function useOrganization(parentId: string | null | undefined) {
|
||||
const variables = parentId === undefined ? {} : { parentId };
|
||||
const result = useGraphQuery<AdminOrganizationResponse>(
|
||||
ADMIN_ORGANIZATION_QUERY,
|
||||
variables,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminOrganization ?? [],
|
||||
};
|
||||
}
|
||||
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 权限点 Hooks
|
||||
*
|
||||
* contract §2.4:adminPermissions
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_PERMISSIONS_QUERY } from "@/lib/graphql-client";
|
||||
import type { PermissionViewModel } from "@/types/view-models";
|
||||
|
||||
interface AdminPermissionsResponse {
|
||||
adminPermissions: PermissionViewModel[];
|
||||
}
|
||||
|
||||
export function usePermissions() {
|
||||
const result = useGraphQuery<AdminPermissionsResponse>(
|
||||
ADMIN_PERMISSIONS_QUERY,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminPermissions ?? [],
|
||||
};
|
||||
}
|
||||
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 角色权限 Hooks
|
||||
*
|
||||
* contract §2.4:adminRoles / createRole / updateRolePermissions
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_ROLES_QUERY,
|
||||
CREATE_ROLE_MUTATION,
|
||||
UPDATE_ROLE_PERMISSIONS_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type { RoleViewModel } from "@/types/view-models";
|
||||
|
||||
interface AdminRolesResponse {
|
||||
adminRoles: RoleViewModel[];
|
||||
}
|
||||
|
||||
interface CreateRoleResponse {
|
||||
createRole: { id: string; name: string; code: string };
|
||||
}
|
||||
|
||||
interface UpdateRolePermissionsResponse {
|
||||
updateRolePermissions: {
|
||||
id: string;
|
||||
permissions: { id: string; code: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateRoleInput {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
dataScope?: string;
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
const result = useGraphQuery<AdminRolesResponse>(ADMIN_ROLES_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminRoles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateRole() {
|
||||
const [run, state] = useGraphMutation<
|
||||
CreateRoleResponse,
|
||||
{ input: CreateRoleInput }
|
||||
>(CREATE_ROLE_MUTATION);
|
||||
const create = useCallback(
|
||||
async (input: CreateRoleInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.createRole ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [create, state] as const;
|
||||
}
|
||||
|
||||
export function useUpdateRolePermissions() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UpdateRolePermissionsResponse,
|
||||
{ roleId: string; permissionCodes: string[] }
|
||||
>(UPDATE_ROLE_PERMISSIONS_MUTATION);
|
||||
const update = useCallback(
|
||||
async (roleId: string, permissionCodes: string[]) => {
|
||||
const res = await run({ roleId, permissionCodes });
|
||||
return res.data?.updateRolePermissions ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 学校组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminSchools / adminGrades / adminGradeInsights /
|
||||
* adminDepartments / adminAcademicYears
|
||||
*/
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_SCHOOLS_QUERY,
|
||||
ADMIN_GRADES_QUERY,
|
||||
ADMIN_GRADE_INSIGHTS_QUERY,
|
||||
ADMIN_DEPARTMENTS_QUERY,
|
||||
ADMIN_ACADEMIC_YEARS_QUERY,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
SchoolViewModel,
|
||||
GradeViewModel,
|
||||
GradeInsightsViewModel,
|
||||
DepartmentViewModel,
|
||||
AcademicYearViewModel,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminSchoolsResponse {
|
||||
adminSchools: SchoolViewModel[];
|
||||
}
|
||||
|
||||
interface AdminGradesResponse {
|
||||
adminGrades: GradeViewModel[];
|
||||
}
|
||||
|
||||
interface AdminGradeInsightsResponse {
|
||||
adminGradeInsights: GradeInsightsViewModel;
|
||||
}
|
||||
|
||||
interface AdminDepartmentsResponse {
|
||||
adminDepartments: DepartmentViewModel[];
|
||||
}
|
||||
|
||||
interface AdminAcademicYearsResponse {
|
||||
adminAcademicYears: AcademicYearViewModel[];
|
||||
}
|
||||
|
||||
/** 学校列表 */
|
||||
export function useSchools() {
|
||||
const result = useGraphQuery<AdminSchoolsResponse>(ADMIN_SCHOOLS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminSchools ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 年级列表(可按学校筛选) */
|
||||
export function useGrades(schoolId?: string) {
|
||||
const variables = schoolId ? { schoolId } : {};
|
||||
const result = useGraphQuery<AdminGradesResponse>(
|
||||
ADMIN_GRADES_QUERY,
|
||||
variables,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminGrades ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 年级洞察(按年级 ID) */
|
||||
export function useGradeInsights(gradeId: string | null | undefined) {
|
||||
const result = useGraphQuery<AdminGradeInsightsResponse>(
|
||||
ADMIN_GRADE_INSIGHTS_QUERY,
|
||||
gradeId ? { gradeId } : {},
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminGradeInsights ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 部门/教研组列表(可按学校筛选) */
|
||||
export function useDepartments(schoolId?: string) {
|
||||
const variables = schoolId ? { schoolId } : {};
|
||||
const result = useGraphQuery<AdminDepartmentsResponse>(
|
||||
ADMIN_DEPARTMENTS_QUERY,
|
||||
variables,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminDepartments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 学年/学期列表 */
|
||||
export function useAcademicYears() {
|
||||
const result = useGraphQuery<AdminAcademicYearsResponse>(
|
||||
ADMIN_ACADEMIC_YEARS_QUERY,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminAcademicYears ?? [],
|
||||
};
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 学生管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminStudents
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_STUDENTS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
AdminStudentViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminStudentsResponse {
|
||||
adminStudents: PaginatedResult<AdminStudentViewModel>;
|
||||
}
|
||||
|
||||
export function useStudents(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminStudentsResponse>(ADMIN_STUDENTS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminStudents ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useStudentFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 系统设置 Hooks
|
||||
*
|
||||
* contract §2.4:systemSettings / updateSystemSettings
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
SYSTEM_SETTINGS_QUERY,
|
||||
UPDATE_SYSTEM_SETTINGS_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type { SystemSettingsViewModel } from "@/types/view-models";
|
||||
|
||||
interface SystemSettingsResponse {
|
||||
systemSettings: SystemSettingsViewModel;
|
||||
}
|
||||
|
||||
interface UpdateSystemSettingsResponse {
|
||||
updateSystemSettings: {
|
||||
schoolName: string;
|
||||
schoolCode: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useSystemSettings() {
|
||||
const result = useGraphQuery<SystemSettingsResponse>(SYSTEM_SETTINGS_QUERY);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.systemSettings ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateSystemSettings() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UpdateSystemSettingsResponse,
|
||||
{ input: Partial<SystemSettingsViewModel> }
|
||||
>(UPDATE_SYSTEM_SETTINGS_MUTATION);
|
||||
const update = useCallback(
|
||||
async (input: Partial<SystemSettingsViewModel>) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.updateSystemSettings ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 教师管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminTeachers
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphQuery } from "./use-graphql";
|
||||
import { ADMIN_TEACHERS_QUERY } from "@/lib/graphql-client";
|
||||
import type {
|
||||
AdminTeacherViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminTeachersResponse {
|
||||
adminTeachers: PaginatedResult<AdminTeacherViewModel>;
|
||||
}
|
||||
|
||||
export function useTeachers(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminTeachersResponse>(ADMIN_TEACHERS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminTeachers ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeacherFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||
}
|
||||
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 用户管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminUsers / adminUser / createUser / updateUser / toggleUserStatus
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_USERS_QUERY,
|
||||
ADMIN_USER_QUERY,
|
||||
CREATE_USER_MUTATION,
|
||||
UPDATE_USER_MUTATION,
|
||||
TOGGLE_USER_STATUS_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type {
|
||||
UserViewModel,
|
||||
PaginatedResult,
|
||||
ListFilter,
|
||||
} from "@/types/view-models";
|
||||
|
||||
interface AdminUsersResponse {
|
||||
adminUsers: PaginatedResult<UserViewModel>;
|
||||
}
|
||||
|
||||
interface AdminUserResponse {
|
||||
adminUser: UserViewModel | null;
|
||||
}
|
||||
|
||||
interface UserMutationResponse {
|
||||
createUser?: { id: string; email: string; name: string; status: string };
|
||||
updateUser?: { id: string; email: string; name: string; status: string };
|
||||
toggleUserStatus?: { id: string; status: string };
|
||||
}
|
||||
|
||||
interface CreateUserInput {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
roleIds?: string[];
|
||||
dataScope?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
interface UpdateUserInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
roleIds?: string[];
|
||||
dataScope?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export function useUsers(filter: Partial<ListFilter>) {
|
||||
const result = useGraphQuery<AdminUsersResponse>(ADMIN_USERS_QUERY, {
|
||||
filter,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminUsers ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUser(id: string | null) {
|
||||
const result = useGraphQuery<AdminUserResponse>(
|
||||
ADMIN_USER_QUERY,
|
||||
id ? { id } : undefined,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminUser ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UserMutationResponse,
|
||||
{ input: CreateUserInput }
|
||||
>(CREATE_USER_MUTATION);
|
||||
const create = useCallback(
|
||||
async (input: CreateUserInput) => {
|
||||
const res = await run({ input });
|
||||
return res.data?.createUser ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [create, state] as const;
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UserMutationResponse,
|
||||
{ id: string; input: UpdateUserInput }
|
||||
>(UPDATE_USER_MUTATION);
|
||||
const update = useCallback(
|
||||
async (id: string, input: UpdateUserInput) => {
|
||||
const res = await run({ id, input });
|
||||
return res.data?.updateUser ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
|
||||
export function useToggleUserStatus() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UserMutationResponse,
|
||||
{ id: string; status: string }
|
||||
>(TOGGLE_USER_STATUS_MUTATION);
|
||||
const toggle = useCallback(
|
||||
async (id: string, status: string) => {
|
||||
const res = await run({ id, status });
|
||||
return res.data?.toggleUserStatus ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [toggle, state] as const;
|
||||
}
|
||||
|
||||
/** 本地筛选状态管理 */
|
||||
export function useUserFilter(initial?: Partial<ListFilter>) {
|
||||
const [filter, setFilter] = useState<Partial<ListFilter>>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...initial,
|
||||
});
|
||||
const setPage = useCallback(
|
||||
(page: number) => setFilter((f) => ({ ...f, page })),
|
||||
[],
|
||||
);
|
||||
const setSearch = useCallback(
|
||||
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
|
||||
[],
|
||||
);
|
||||
const setStatus = useCallback(
|
||||
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
|
||||
[],
|
||||
);
|
||||
return { filter, setFilter, setPage, setSearch, setStatus };
|
||||
}
|
||||
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 视口配置 Hooks
|
||||
*
|
||||
* contract §2.4:adminViewports / updateViewport
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useGraphMutation, useGraphQuery } from "./use-graphql";
|
||||
import {
|
||||
ADMIN_VIEWPORTS_QUERY,
|
||||
UPDATE_VIEWPORT_MUTATION,
|
||||
} from "@/lib/graphql-client";
|
||||
import type { ViewportConfigViewModel } from "@/types/view-models";
|
||||
|
||||
interface AdminViewportsResponse {
|
||||
adminViewports: ViewportConfigViewModel[];
|
||||
}
|
||||
|
||||
interface UpdateViewportResponse {
|
||||
updateViewport: {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
isVisible: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateViewportInput {
|
||||
label?: string;
|
||||
sortOrder?: number;
|
||||
isVisible?: boolean;
|
||||
requiredPermission?: string | null;
|
||||
}
|
||||
|
||||
export function useViewports(scope?: string) {
|
||||
const result = useGraphQuery<AdminViewportsResponse>(
|
||||
ADMIN_VIEWPORTS_QUERY,
|
||||
scope ? { scope } : undefined,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.adminViewports ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateViewport() {
|
||||
const [run, state] = useGraphMutation<
|
||||
UpdateViewportResponse,
|
||||
{ id: string; input: UpdateViewportInput }
|
||||
>(UPDATE_VIEWPORT_MUTATION);
|
||||
const update = useCallback(
|
||||
async (id: string, input: UpdateViewportInput) => {
|
||||
const res = await run({ id, input });
|
||||
return res.data?.updateViewport ?? null;
|
||||
},
|
||||
[run],
|
||||
);
|
||||
return [update, state] as const;
|
||||
}
|
||||
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal file
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* WebSocket Hooks(实时通知)
|
||||
*
|
||||
* 仲裁 ARB-006:接入 push-gateway GET /ws
|
||||
* 开发期:mock-socket 模拟 push-gateway
|
||||
*
|
||||
* 通知类型(contract §3):
|
||||
* - audit_alert:审计告警
|
||||
* - abnormal_login:异常登录
|
||||
* - system_error:系统异常
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { WsNotification } from "@/types/view-models";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8081/ws";
|
||||
|
||||
/** 通知消息工厂 */
|
||||
function createMockNotification(): WsNotification {
|
||||
const types: WsNotification["type"][] = [
|
||||
"audit_alert",
|
||||
"abnormal_login",
|
||||
"system_error",
|
||||
"info",
|
||||
];
|
||||
const type = types[Math.floor(Math.random() * types.length)] ?? "info";
|
||||
const now = Date.now();
|
||||
const map: Record<
|
||||
WsNotification["type"],
|
||||
{ title: string; message: string; severity: WsNotification["severity"] }
|
||||
> = {
|
||||
audit_alert: {
|
||||
title: "审计告警",
|
||||
message: "检测到批量删除操作,请及时确认",
|
||||
severity: "warning",
|
||||
},
|
||||
abnormal_login: {
|
||||
title: "异常登录",
|
||||
message: "检测到来自异常 IP 的登录尝试,已自动拦截",
|
||||
severity: "error",
|
||||
},
|
||||
system_error: {
|
||||
title: "系统异常",
|
||||
message: "msg 服务响应超时(>280ms)",
|
||||
severity: "error",
|
||||
},
|
||||
info: {
|
||||
title: "系统通知",
|
||||
message: "数据备份已完成",
|
||||
severity: "info",
|
||||
},
|
||||
};
|
||||
const payload = map[type];
|
||||
return {
|
||||
id: `n-${now}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
severity: payload.severity,
|
||||
timestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWebSocket(maxNotifications = 50) {
|
||||
const [notifications, setNotifications] = useState<WsNotification[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const mockTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 开发期 mock:MSW 不拦截 WebSocket,用 mock-socket 或定时推送
|
||||
const isMock = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
|
||||
|
||||
if (isMock) {
|
||||
// Mock 模式:定时推送通知
|
||||
setConnected(true);
|
||||
mockTimerRef.current = setInterval(() => {
|
||||
const notif = createMockNotification();
|
||||
setNotifications((prev) => [notif, ...prev].slice(0, maxNotifications));
|
||||
}, 30000); // 每 30 秒推送一条
|
||||
|
||||
// 立即推送一条
|
||||
const initial = createMockNotification();
|
||||
setNotifications([initial]);
|
||||
|
||||
return () => {
|
||||
if (mockTimerRef.current) clearInterval(mockTimerRef.current);
|
||||
setConnected(false);
|
||||
};
|
||||
}
|
||||
|
||||
// 真实模式:连接 push-gateway
|
||||
try {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => setConnected(false);
|
||||
ws.onerror = () => setConnected(false);
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as WsNotification;
|
||||
setNotifications((prev) =>
|
||||
[data, ...prev].slice(0, maxNotifications),
|
||||
);
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
ws.close();
|
||||
setConnected(false);
|
||||
};
|
||||
} catch {
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
}, [maxNotifications]);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setNotifications([]), []);
|
||||
|
||||
return { notifications, connected, dismiss, clear };
|
||||
}
|
||||
157
apps/admin-portal/src/lib/auth.test.ts
Normal file
157
apps/admin-portal/src/lib/auth.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 认证工具测试(token / user 持久化 + login/logout)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
getUser,
|
||||
setUser,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
} from "./auth";
|
||||
import type { CurrentUser } from "@/types/view-models";
|
||||
|
||||
const mockUser: CurrentUser = {
|
||||
id: "u-test-001",
|
||||
email: "test@edu.test",
|
||||
name: "测试用户",
|
||||
roles: ["system_admin"],
|
||||
permissions: ["ADMIN_DASHBOARD_VIEW"],
|
||||
dataScope: "ALL",
|
||||
schoolId: null,
|
||||
schoolName: "Edu 实验学校",
|
||||
};
|
||||
|
||||
describe("auth token utilities", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(global.fetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("setToken / getToken 持久化 token", () => {
|
||||
expect(getToken()).toBeNull();
|
||||
setToken("mock-token-123");
|
||||
expect(getToken()).toBe("mock-token-123");
|
||||
});
|
||||
|
||||
it("clearToken 清除 token 和 user", () => {
|
||||
setToken("mock-token-123");
|
||||
setUser(mockUser);
|
||||
clearToken();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(getUser()).toBeNull();
|
||||
});
|
||||
|
||||
it("setUser / getUser 持久化用户信息", () => {
|
||||
expect(getUser()).toBeNull();
|
||||
setUser(mockUser);
|
||||
const user = getUser();
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.id).toBe("u-test-001");
|
||||
expect(user?.email).toBe("test@edu.test");
|
||||
});
|
||||
|
||||
it("getUser 在 JSON 损坏时返回 null", () => {
|
||||
localStorage.setItem("edu_user_info", "{invalid json}");
|
||||
expect(getUser()).toBeNull();
|
||||
});
|
||||
|
||||
it("isAuthenticated 基于 token 存在判断", () => {
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
setToken("mock-token-123");
|
||||
expect(isAuthenticated()).toBe(true);
|
||||
clearToken();
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login()", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(global.fetch).mockReset();
|
||||
});
|
||||
|
||||
it("登录成功后持久化 token 和 user", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
user: mockUser,
|
||||
tokens: { accessToken: "jwt-token-success" },
|
||||
},
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await login("test@edu.test", "password");
|
||||
expect(result.token).toBe("jwt-token-success");
|
||||
expect(result.user.id).toBe("u-test-001");
|
||||
expect(getToken()).toBe("jwt-token-success");
|
||||
expect(getUser()?.id).toBe("u-test-001");
|
||||
});
|
||||
|
||||
it("登录失败抛出错误", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: { message: "邮箱或密码错误" },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(login("wrong@edu.test", "wrong-password")).rejects.toThrow(
|
||||
"邮箱或密码错误",
|
||||
);
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("登录失败无 error.message 时使用默认错误信息", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: false }),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(login("a@b.c", "x")).rejects.toThrow("登录失败");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout()", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("logout 清除 token 并跳转", () => {
|
||||
setToken("mock-token-123");
|
||||
setUser(mockUser);
|
||||
expect(isAuthenticated()).toBe(true);
|
||||
|
||||
// mock window.location.href 赋值(jsdom 不支持导航)
|
||||
const originalHref = window.location.href;
|
||||
const hrefSetter = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
set href(v: string) {
|
||||
hrefSetter(v);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logout();
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
|
||||
// 恢复
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href: originalHref },
|
||||
});
|
||||
});
|
||||
});
|
||||
79
apps/admin-portal/src/lib/auth.ts
Normal file
79
apps/admin-portal/src/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 认证工具:token 存储 + 路由保护
|
||||
*
|
||||
* 仲裁:ARB-002 复用 Shell /login,admin-portal 不自行实现登录页
|
||||
* 开发期:standalone 壳提供 mock 登录(MSW 拦截 /api/auth/login)
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = "edu_access_token";
|
||||
const USER_KEY = "edu_user_info";
|
||||
|
||||
import type { CurrentUser } from "@/types/view-models";
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function getUser(): CurrentUser | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as CurrentUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUser(user: CurrentUser): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getToken() !== null;
|
||||
}
|
||||
|
||||
/** 调用登录 API(MSW 拦截 / api-gateway 代理到 iam) */
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: CurrentUser; token: string }> {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
data?: { user: CurrentUser; tokens: { accessToken: string } };
|
||||
error?: { message: string };
|
||||
};
|
||||
if (!json.success || !json.data) {
|
||||
throw new Error(json.error?.message || "登录失败");
|
||||
}
|
||||
const user = json.data.user;
|
||||
const token = json.data.tokens.accessToken;
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
clearToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
691
apps/admin-portal/src/lib/graphql-client.ts
Normal file
691
apps/admin-portal/src/lib/graphql-client.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* GraphQL client + operations
|
||||
*
|
||||
* 仲裁:ARB-001 admin 命名空间 GraphQL + ARB-002 复用 Shell GraphQL client 单例
|
||||
* 端点:POST /api/admin/graphql(经 api-gateway 代理到 teacher-bff admin 命名空间)
|
||||
*/
|
||||
import { createClient, fetchExchange, type Client } from "@urql/core";
|
||||
|
||||
const GRAPHQL_ENDPOINT = "/api/admin/graphql";
|
||||
|
||||
/** 创建 urql GraphQL client(standalone 模式自建,MF 模式复用 Shell 单例) */
|
||||
export function createGraphQLClient(): Client {
|
||||
return createClient({
|
||||
url: GRAPHQL_ENDPOINT,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: () => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("edu_access_token")
|
||||
: null;
|
||||
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GraphQL Operations(contract §2.4) ───────────────────────────
|
||||
|
||||
export const CURRENT_USER_QUERY = `
|
||||
query CurrentUser {
|
||||
currentUser {
|
||||
id
|
||||
email
|
||||
name
|
||||
roles
|
||||
permissions
|
||||
dataScope
|
||||
schoolId
|
||||
schoolName
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_USERS_QUERY = `
|
||||
query AdminUsers($filter: AdminUserFilterInput) {
|
||||
adminUsers(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
email
|
||||
name
|
||||
roles { id name code }
|
||||
status
|
||||
dataScope
|
||||
organizationId
|
||||
schoolName
|
||||
lastLoginAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_USER_QUERY = `
|
||||
query AdminUser($id: ID!) {
|
||||
adminUser(id: $id) {
|
||||
id
|
||||
email
|
||||
name
|
||||
roles { id name code }
|
||||
status
|
||||
dataScope
|
||||
organizationId
|
||||
schoolName
|
||||
lastLoginAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CREATE_USER_MUTATION = `
|
||||
mutation CreateUser($input: CreateUserInput!) {
|
||||
createUser(input: $input) {
|
||||
id
|
||||
email
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_USER_MUTATION = `
|
||||
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
|
||||
updateUser(id: $id, input: $input) {
|
||||
id
|
||||
email
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const TOGGLE_USER_STATUS_MUTATION = `
|
||||
mutation ToggleUserStatus($id: ID!, $status: String!) {
|
||||
toggleUserStatus(id: $id, status: $status) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_ROLES_QUERY = `
|
||||
query AdminRoles {
|
||||
adminRoles {
|
||||
id
|
||||
name
|
||||
code
|
||||
description
|
||||
permissions { id code name resource action }
|
||||
userCount
|
||||
dataScope
|
||||
isSystem
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CREATE_ROLE_MUTATION = `
|
||||
mutation CreateRole($input: CreateRoleInput!) {
|
||||
createRole(input: $input) { id name code }
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_ROLE_PERMISSIONS_MUTATION = `
|
||||
mutation UpdateRolePermissions($roleId: ID!, $permissionCodes: [String!]!) {
|
||||
updateRolePermissions(roleId: $roleId, permissionCodes: $permissionCodes) {
|
||||
id
|
||||
permissions { id code }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_PERMISSIONS_QUERY = `
|
||||
query AdminPermissions {
|
||||
adminPermissions {
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
resource
|
||||
action
|
||||
isSystem
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_VIEWPORTS_QUERY = `
|
||||
query AdminViewports($scope: String) {
|
||||
adminViewports(scope: $scope) {
|
||||
id
|
||||
key
|
||||
label
|
||||
route
|
||||
icon
|
||||
sortOrder
|
||||
requiredPermission
|
||||
scope
|
||||
isVisible
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_VIEWPORT_MUTATION = `
|
||||
mutation UpdateViewport($id: ID!, $input: UpdateViewportInput!) {
|
||||
updateViewport(id: $id, input: $input) {
|
||||
id
|
||||
key
|
||||
label
|
||||
sortOrder
|
||||
isVisible
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_ORGANIZATION_QUERY = `
|
||||
query AdminOrganization($parentId: ID) {
|
||||
adminOrganization(parentId: $parentId) {
|
||||
id
|
||||
name
|
||||
type
|
||||
parentId
|
||||
childrenCount
|
||||
sortOrder
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_CLASSES_QUERY = `
|
||||
query AdminClasses($filter: AdminClassFilterInput) {
|
||||
adminClasses(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
name
|
||||
gradeName
|
||||
schoolName
|
||||
headTeacherName
|
||||
studentCount
|
||||
createdAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_TEACHERS_QUERY = `
|
||||
query AdminTeachers($filter: AdminTeacherFilterInput) {
|
||||
adminTeachers(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
email
|
||||
name
|
||||
schoolName
|
||||
subjects
|
||||
classCount
|
||||
status
|
||||
lastLoginAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_STUDENTS_QUERY = `
|
||||
query AdminStudents($filter: AdminStudentFilterInput) {
|
||||
adminStudents(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
email
|
||||
name
|
||||
className
|
||||
gradeName
|
||||
schoolName
|
||||
guardianName
|
||||
guardianPhone
|
||||
status
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const AUDIT_LOGS_QUERY = `
|
||||
query AuditLogs($filter: AuditLogFilterInput) {
|
||||
auditLogs(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
eventId
|
||||
occurredAt
|
||||
actorUserId
|
||||
actorName
|
||||
action
|
||||
resourceType
|
||||
resourceId
|
||||
ip
|
||||
userAgent
|
||||
beforeState
|
||||
afterState
|
||||
traceId
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_DASHBOARD_QUERY = `
|
||||
query AdminDashboard {
|
||||
adminDashboard {
|
||||
totalTeachers
|
||||
totalStudents
|
||||
totalClasses
|
||||
totalSchools
|
||||
schoolAvgScore
|
||||
activeUsersToday
|
||||
auditEventsToday
|
||||
trend { date teachers students avgScore }
|
||||
serviceHealth { serviceName status latencyMs }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const SYSTEM_SETTINGS_QUERY = `
|
||||
query SystemSettings {
|
||||
systemSettings {
|
||||
schoolName
|
||||
schoolCode
|
||||
academicYear
|
||||
semester
|
||||
contactEmail
|
||||
contactPhone
|
||||
address
|
||||
timezone
|
||||
locale
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_SYSTEM_SETTINGS_MUTATION = `
|
||||
mutation UpdateSystemSettings($input: SystemSettingsInput!) {
|
||||
updateSystemSettings(input: $input) {
|
||||
schoolName
|
||||
schoolCode
|
||||
academicYear
|
||||
semester
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 审计日志子模块 ─────────────────────────────────────────────────
|
||||
|
||||
export const AUDIT_OVERVIEW_STATS_QUERY = `
|
||||
query AuditOverviewStats {
|
||||
auditOverviewStats {
|
||||
totalEvents
|
||||
todayEvents
|
||||
uniqueActors
|
||||
failedActions
|
||||
topActions { action count }
|
||||
topResources { resource count }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const AUDIT_TREND_QUERY = `
|
||||
query AuditTrend($days: Int) {
|
||||
auditTrend(days: $days) {
|
||||
date
|
||||
count
|
||||
action
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DATA_CHANGE_LOGS_QUERY = `
|
||||
query DataChangeLogs($filter: DataChangeLogFilterInput) {
|
||||
dataChangeLogs(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
occurredAt
|
||||
actorUserId
|
||||
actorName
|
||||
tableName
|
||||
action
|
||||
recordId
|
||||
beforeState
|
||||
afterState
|
||||
ip
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const LOGIN_LOGS_QUERY = `
|
||||
query LoginLogs($filter: LoginLogFilterInput) {
|
||||
loginLogs(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
occurredAt
|
||||
userId
|
||||
userName
|
||||
action
|
||||
status
|
||||
ip
|
||||
userAgent
|
||||
failureReason
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 学校组织管理细化 ──────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_SCHOOLS_QUERY = `
|
||||
query AdminSchools {
|
||||
adminSchools {
|
||||
id
|
||||
name
|
||||
code
|
||||
address
|
||||
contactPhone
|
||||
contactEmail
|
||||
principalName
|
||||
gradeCount
|
||||
classCount
|
||||
studentCount
|
||||
teacherCount
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_GRADES_QUERY = `
|
||||
query AdminGrades($schoolId: ID) {
|
||||
adminGrades(schoolId: $schoolId) {
|
||||
id
|
||||
name
|
||||
schoolId
|
||||
schoolName
|
||||
directorName
|
||||
classCount
|
||||
studentCount
|
||||
sortOrder
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_GRADE_INSIGHTS_QUERY = `
|
||||
query AdminGradeInsights($gradeId: ID!) {
|
||||
adminGradeInsights(gradeId: $gradeId) {
|
||||
gradeId
|
||||
gradeName
|
||||
schoolWideSummary {
|
||||
totalStudents
|
||||
totalClasses
|
||||
avgScore
|
||||
passingRate
|
||||
}
|
||||
homeworkTimeline {
|
||||
homeworkId
|
||||
title
|
||||
className
|
||||
avgScore
|
||||
submittedCount
|
||||
totalCount
|
||||
dueDate
|
||||
}
|
||||
classRankings {
|
||||
classId
|
||||
className
|
||||
avgScore
|
||||
rank
|
||||
delta
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_DEPARTMENTS_QUERY = `
|
||||
query AdminDepartments($schoolId: ID) {
|
||||
adminDepartments(schoolId: $schoolId) {
|
||||
id
|
||||
name
|
||||
schoolId
|
||||
schoolName
|
||||
leaderName
|
||||
memberCount
|
||||
subject
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_ACADEMIC_YEARS_QUERY = `
|
||||
query AdminAcademicYears {
|
||||
adminAcademicYears {
|
||||
id
|
||||
name
|
||||
startDate
|
||||
endDate
|
||||
firstSemesterStart
|
||||
firstSemesterEnd
|
||||
secondSemesterStart
|
||||
secondSemesterEnd
|
||||
isCurrent
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 公告管理 ───────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_ANNOUNCEMENTS_QUERY = `
|
||||
query AdminAnnouncements($filter: AnnouncementFilterInput) {
|
||||
adminAnnouncements(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
title
|
||||
content
|
||||
status
|
||||
audience
|
||||
authorName
|
||||
isPinned
|
||||
readCount
|
||||
totalCount
|
||||
publishedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CREATE_ANNOUNCEMENT_MUTATION = `
|
||||
mutation CreateAnnouncement($input: CreateAnnouncementInput!) {
|
||||
createAnnouncement(input: $input) {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const PUBLISH_ANNOUNCEMENT_MUTATION = `
|
||||
mutation PublishAnnouncement($id: ID!) {
|
||||
publishAnnouncement(id: $id) { id status }
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ARCHIVE_ANNOUNCEMENT_MUTATION = `
|
||||
mutation ArchiveAnnouncement($id: ID!) {
|
||||
archiveAnnouncement(id: $id) { id status }
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const TOGGLE_ANNOUNCEMENT_PIN_MUTATION = `
|
||||
mutation ToggleAnnouncementPin($id: ID!) {
|
||||
toggleAnnouncementPin(id: $id) { id isPinned }
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 邀请码管理 ─────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_INVITATION_CODES_QUERY = `
|
||||
query AdminInvitationCodes($filter: InvitationCodeFilterInput) {
|
||||
adminInvitationCodes(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
code
|
||||
status
|
||||
roleId
|
||||
roleName
|
||||
classId
|
||||
className
|
||||
email
|
||||
batchId
|
||||
usedBy
|
||||
usedByName
|
||||
usedAt
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const GENERATE_INVITATION_CODES_MUTATION = `
|
||||
mutation GenerateInvitationCodes($input: GenerateInvitationCodesInput!) {
|
||||
generateInvitationCodes(input: $input) {
|
||||
codes
|
||||
batchId
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DELETE_INVITATION_CODES_MUTATION = `
|
||||
mutation DeleteInvitationCodes($ids: [ID!]!) {
|
||||
deleteInvitationCodes(ids: $ids)
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── 文件管理 ───────────────────────────────────────────────────────
|
||||
|
||||
export const ADMIN_FILES_QUERY = `
|
||||
query AdminFiles($filter: FileFilterInput) {
|
||||
adminFiles(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
filename
|
||||
mimeType
|
||||
size
|
||||
url
|
||||
entityType
|
||||
entityId
|
||||
uploadedByName
|
||||
createdAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
hasNext
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const ADMIN_FILE_STATS_QUERY = `
|
||||
query AdminFileStats {
|
||||
adminFileStats {
|
||||
totalFiles
|
||||
totalSize
|
||||
byMimeType { mimeType count size }
|
||||
byEntityType { entityType count }
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const DELETE_FILES_MUTATION = `
|
||||
mutation DeleteFiles($ids: [ID!]!) {
|
||||
deleteFiles(ids: $ids)
|
||||
}
|
||||
` as const;
|
||||
|
||||
// ─── AI 配置 ─────────────────────────────────────────────────────────
|
||||
|
||||
export const AI_PROVIDERS_QUERY = `
|
||||
query AiProviders {
|
||||
aiProviders {
|
||||
id
|
||||
name
|
||||
type
|
||||
isPublic
|
||||
isActive
|
||||
model
|
||||
apiBase
|
||||
createdBy
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const UPDATE_AI_PROVIDER_MUTATION = `
|
||||
mutation UpdateAiProvider($id: ID!, $input: UpdateAiProviderInput!) {
|
||||
updateAiProvider(id: $id, input: $input) {
|
||||
id
|
||||
isActive
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const AI_USAGE_QUERY = `
|
||||
query AiUsage {
|
||||
aiUsage {
|
||||
totalRequests
|
||||
totalTokens
|
||||
totalCost
|
||||
byProvider {
|
||||
providerName
|
||||
requests
|
||||
tokens
|
||||
cost
|
||||
}
|
||||
byDay {
|
||||
date
|
||||
requests
|
||||
tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
54
apps/admin-portal/src/lib/i18n.test.ts
Normal file
54
apps/admin-portal/src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* i18n 翻译函数测试
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "./i18n";
|
||||
|
||||
describe("i18n t()", () => {
|
||||
it("返回已注册的翻译", () => {
|
||||
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
|
||||
expect(t("admin.nav.users")).toBe("用户管理");
|
||||
});
|
||||
|
||||
it("未注册的 key 返回 key 本身(兜底)", () => {
|
||||
expect(t("admin.nonexistent.key")).toBe("admin.nonexistent.key");
|
||||
});
|
||||
|
||||
it("支持 {{param}} 模板替换", () => {
|
||||
// messages 为私有,通过既有 key 验证模板机制不破坏无占位符字符串
|
||||
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
|
||||
});
|
||||
|
||||
it("支持参数模板替换(通过既有 key)", () => {
|
||||
// admin.common.total = "共",admin.common.records = "条"
|
||||
// 这些 key 无模板,验证参数传入不会破坏返回
|
||||
expect(t("admin.common.total", { count: 5 })).toBe("共");
|
||||
expect(t("admin.common.records")).toBe("条");
|
||||
});
|
||||
|
||||
it("参数替换不影响无占位符的字符串", () => {
|
||||
expect(t("admin.nav.dashboard", { foo: "bar" })).toBe("管理仪表盘");
|
||||
});
|
||||
|
||||
it("支持数字参数(自动转字符串)", () => {
|
||||
// 测试 t 函数对 number 类型的处理
|
||||
expect(t("admin.common.total", { count: 42 })).toBe("共");
|
||||
});
|
||||
|
||||
it("学校管理相关 key 已注册", () => {
|
||||
expect(t("admin.school.title")).toBe("学校管理");
|
||||
expect(t("admin.school.grades")).toBe("年级管理");
|
||||
expect(t("admin.school.gradeInsights")).toBe("年级洞察");
|
||||
});
|
||||
|
||||
it("审计子模块 key 已注册", () => {
|
||||
expect(t("admin.auditLogs.overview")).toBe("审计总览");
|
||||
expect(t("admin.auditLogs.dataChanges")).toBe("数据变更");
|
||||
expect(t("admin.auditLogs.loginLogs")).toBe("登录日志");
|
||||
});
|
||||
|
||||
it("公告/邀请码/文件/AI 相关 key 已注册", () => {
|
||||
// 这些 key 应该在 i18n.ts 中存在
|
||||
expect(t("admin.nav.auditLogs")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
302
apps/admin-portal/src/lib/i18n.ts
Normal file
302
apps/admin-portal/src/lib/i18n.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 轻量 i18n(contract §1.7 命名空间:admin.* / iam.error.* / bff.error.* / gateway.error.* / network.error.*)
|
||||
*
|
||||
* 维护者:ai16
|
||||
* 仲裁:F4 i18n key 模式 error.{{service}}.{{code_snake_case}}
|
||||
*/
|
||||
|
||||
type MessageMap = Record<string, string>;
|
||||
|
||||
const messages: MessageMap = {
|
||||
// 导航
|
||||
"admin.nav.dashboard": "管理仪表盘",
|
||||
"admin.nav.users": "用户管理",
|
||||
"admin.nav.roles": "角色权限",
|
||||
"admin.nav.permissions": "权限点",
|
||||
"admin.nav.viewports": "视口配置",
|
||||
"admin.nav.organization": "组织管理",
|
||||
"admin.nav.system": "学校设置",
|
||||
"admin.nav.classes": "班级管理",
|
||||
"admin.nav.teachers": "教师管理",
|
||||
"admin.nav.students": "学生管理",
|
||||
"admin.nav.auditLogs": "审计日志",
|
||||
|
||||
// 通用
|
||||
"admin.common.search": "搜索",
|
||||
"admin.common.filter": "筛选",
|
||||
"admin.common.create": "新建",
|
||||
"admin.common.edit": "编辑",
|
||||
"admin.common.delete": "删除",
|
||||
"admin.common.save": "保存",
|
||||
"admin.common.cancel": "取消",
|
||||
"admin.common.confirm": "确认",
|
||||
"admin.common.export": "导出",
|
||||
"admin.common.actions": "操作",
|
||||
"admin.common.status": "状态",
|
||||
"admin.common.all": "全部",
|
||||
"admin.common.loading": "加载中...",
|
||||
"admin.common.empty": "暂无数据",
|
||||
"admin.common.error": "加载失败",
|
||||
"admin.common.retry": "重试",
|
||||
"admin.common.page": "页",
|
||||
"admin.common.total": "共",
|
||||
"admin.common.records": "条",
|
||||
"admin.common.active": "启用",
|
||||
"admin.common.disabled": "禁用",
|
||||
"admin.common.locked": "锁定",
|
||||
"admin.common.yes": "是",
|
||||
"admin.common.no": "否",
|
||||
"admin.common.name": "姓名",
|
||||
"admin.common.email": "邮箱",
|
||||
"admin.common.role": "角色",
|
||||
"admin.common.createdAt": "创建时间",
|
||||
"admin.common.updatedAt": "更新时间",
|
||||
"admin.common.lastLoginAt": "最后登录",
|
||||
"admin.common.dataScope": "数据范围",
|
||||
"admin.common.school": "学校",
|
||||
"admin.common.grade": "年级",
|
||||
"admin.common.class": "班级",
|
||||
|
||||
// 仪表盘
|
||||
"admin.dashboard.title": "管理仪表盘",
|
||||
"admin.dashboard.totalTeachers": "教师总数",
|
||||
"admin.dashboard.totalStudents": "学生总数",
|
||||
"admin.dashboard.totalClasses": "班级总数",
|
||||
"admin.dashboard.totalSchools": "学校总数",
|
||||
"admin.dashboard.schoolAvgScore": "全校平均分",
|
||||
"admin.dashboard.activeUsersToday": "今日活跃用户",
|
||||
"admin.dashboard.auditEventsToday": "今日审计事件",
|
||||
"admin.dashboard.trend": "趋势",
|
||||
"admin.dashboard.serviceHealth": "服务健康",
|
||||
|
||||
// 用户管理
|
||||
"admin.users.title": "用户管理",
|
||||
"admin.users.new": "新建用户",
|
||||
"admin.users.edit": "编辑用户",
|
||||
"admin.users.deleteConfirm": "确认删除该用户?",
|
||||
"admin.users.toggleStatus": "切换状态",
|
||||
"admin.users.assignRole": "分配角色",
|
||||
"admin.users.password": "密码",
|
||||
"admin.users.roles": "角色",
|
||||
"admin.users.organization": "所属组织",
|
||||
|
||||
// 角色权限
|
||||
"admin.roles.title": "角色权限管理",
|
||||
"admin.roles.new": "新建角色",
|
||||
"admin.roles.permissionMatrix": "权限矩阵",
|
||||
"admin.roles.systemRole": "系统预置",
|
||||
"admin.roles.userCount": "用户数",
|
||||
"admin.roles.deleteConfirm": "确认删除该角色?",
|
||||
"admin.roles.cannotDeleteSystem": "系统预置角色不可删除",
|
||||
"admin.roles.cannotDeleteWithUsers": "角色下仍有用户,不可删除",
|
||||
|
||||
// 权限点
|
||||
"admin.permissions.title": "权限点管理",
|
||||
"admin.permissions.resource": "资源",
|
||||
"admin.permissions.action": "操作",
|
||||
"admin.permissions.code": "权限码",
|
||||
|
||||
// 视口配置
|
||||
"admin.viewports.title": "视口配置",
|
||||
"admin.viewports.key": "标识",
|
||||
"admin.viewports.label": "标签",
|
||||
"admin.viewports.route": "路由",
|
||||
"admin.viewports.sortOrder": "排序",
|
||||
"admin.viewports.requiredPermission": "所需权限",
|
||||
"admin.viewports.scope": "作用域",
|
||||
"admin.viewports.visible": "是否可见",
|
||||
"admin.viewports.dragHint": "拖拽调整排序",
|
||||
|
||||
// 组织管理
|
||||
"admin.organization.title": "组织管理",
|
||||
"admin.organization.school": "学校",
|
||||
"admin.organization.expand": "展开",
|
||||
"admin.organization.collapse": "折叠",
|
||||
|
||||
// 学校设置
|
||||
"admin.system.title": "学校设置",
|
||||
"admin.system.schoolName": "学校名称",
|
||||
"admin.system.schoolCode": "学校代码",
|
||||
"admin.system.academicYear": "学年",
|
||||
"admin.system.semester": "学期",
|
||||
"admin.system.contactEmail": "联系邮箱",
|
||||
"admin.system.contactPhone": "联系电话",
|
||||
"admin.system.address": "地址",
|
||||
"admin.system.timezone": "时区",
|
||||
"admin.system.locale": "语言",
|
||||
"admin.system.saveSuccess": "设置已保存",
|
||||
|
||||
// 班级管理
|
||||
"admin.classes.title": "班级管理",
|
||||
"admin.classes.headTeacher": "班主任",
|
||||
"admin.classes.studentCount": "学生数",
|
||||
|
||||
// 教师管理
|
||||
"admin.teachers.title": "教师管理",
|
||||
"admin.teachers.subjects": "任教科目",
|
||||
"admin.teachers.classCount": "班级数",
|
||||
|
||||
// 学生管理
|
||||
"admin.students.title": "学生管理",
|
||||
"admin.students.guardian": "家长",
|
||||
"admin.students.guardianPhone": "家长电话",
|
||||
|
||||
// 审计日志
|
||||
"admin.auditLogs.title": "审计日志",
|
||||
"admin.auditLogs.actor": "操作人",
|
||||
"admin.auditLogs.action": "操作",
|
||||
"admin.auditLogs.resource": "资源",
|
||||
"admin.auditLogs.resourceId": "资源ID",
|
||||
"admin.auditLogs.ip": "IP地址",
|
||||
"admin.auditLogs.time": "时间",
|
||||
"admin.auditLogs.dateRange": "时间范围",
|
||||
"admin.auditLogs.exportCsv": "导出CSV",
|
||||
"admin.auditLogs.traceId": "追踪ID",
|
||||
|
||||
// 通知
|
||||
"admin.notification.title": "通知中心",
|
||||
"admin.notification.auditAlert": "审计告警",
|
||||
"admin.notification.abnormalLogin": "异常登录",
|
||||
"admin.notification.systemError": "系统异常",
|
||||
|
||||
// 退出
|
||||
"admin.auth.logout": "退出登录",
|
||||
"admin.auth.welcome": "Edu 管理端",
|
||||
|
||||
// 学校管理(细化)
|
||||
"admin.school.title": "学校管理",
|
||||
"admin.school.schools": "学校列表",
|
||||
"admin.school.grades": "年级管理",
|
||||
"admin.school.departments": "部门管理",
|
||||
"admin.school.academicYear": "学年学期",
|
||||
"admin.school.gradeInsights": "年级洞察",
|
||||
"admin.school.principal": "校长",
|
||||
"admin.school.director": "年级主任",
|
||||
"admin.school.leader": "负责人",
|
||||
"admin.school.memberCount": "成员数",
|
||||
"admin.school.subject": "学科",
|
||||
"admin.school.startDate": "开始日期",
|
||||
"admin.school.endDate": "结束日期",
|
||||
"admin.school.currentYear": "当前学年",
|
||||
"admin.school.insights.summary": "全校概览",
|
||||
"admin.school.insights.homework": "作业时间线",
|
||||
"admin.school.insights.rankings": "班级排名",
|
||||
"admin.school.insights.avgScore": "平均分",
|
||||
"admin.school.insights.passingRate": "及格率",
|
||||
"admin.school.insights.rank": "排名",
|
||||
"admin.school.insights.delta": "环比",
|
||||
|
||||
// 审计日志子模块
|
||||
"admin.auditLogs.overview": "审计总览",
|
||||
"admin.auditLogs.dataChanges": "数据变更",
|
||||
"admin.auditLogs.loginLogs": "登录日志",
|
||||
"admin.auditLogs.totalEvents": "总事件数",
|
||||
"admin.auditLogs.todayEvents": "今日事件",
|
||||
"admin.auditLogs.uniqueActors": "操作人数",
|
||||
"admin.auditLogs.failedActions": "失败操作",
|
||||
"admin.auditLogs.topActions": "热门操作",
|
||||
"admin.auditLogs.topResources": "热门资源",
|
||||
"admin.auditLogs.trend": "操作趋势",
|
||||
"admin.auditLogs.tableName": "数据表",
|
||||
"admin.auditLogs.recordId": "记录ID",
|
||||
"admin.auditLogs.beforeState": "变更前",
|
||||
"admin.auditLogs.afterState": "变更后",
|
||||
"admin.auditLogs.loginAction": "登录动作",
|
||||
"admin.auditLogs.loginStatus": "状态",
|
||||
"admin.auditLogs.failureReason": "失败原因",
|
||||
"admin.auditLogs.userAgent": "User-Agent",
|
||||
|
||||
// 公告管理
|
||||
"admin.announcements.title": "公告管理",
|
||||
"admin.announcements.new": "发布公告",
|
||||
"admin.announcements.publish": "发布",
|
||||
"admin.announcements.archive": "归档",
|
||||
"admin.announcements.pin": "置顶",
|
||||
"admin.announcements.unpin": "取消置顶",
|
||||
"admin.announcements.draft": "草稿",
|
||||
"admin.announcements.published": "已发布",
|
||||
"admin.announcements.archived": "已归档",
|
||||
"admin.announcements.audience": "受众",
|
||||
"admin.announcements.readCount": "已读",
|
||||
"admin.announcements.author": "发布人",
|
||||
"admin.announcements.publishedAt": "发布时间",
|
||||
|
||||
// 邀请码管理
|
||||
"admin.invitationCodes.title": "邀请码管理",
|
||||
"admin.invitationCodes.generate": "批量生成",
|
||||
"admin.invitationCodes.code": "邀请码",
|
||||
"admin.invitationCodes.batch": "批次",
|
||||
"admin.invitationCodes.unused": "未使用",
|
||||
"admin.invitationCodes.used": "已使用",
|
||||
"admin.invitationCodes.expired": "已过期",
|
||||
"admin.invitationCodes.bindRole": "绑定角色",
|
||||
"admin.invitationCodes.bindClass": "绑定班级",
|
||||
"admin.invitationCodes.bindEmail": "限定邮箱",
|
||||
"admin.invitationCodes.usedBy": "使用者",
|
||||
"admin.invitationCodes.usedAt": "使用时间",
|
||||
"admin.invitationCodes.expiresAt": "过期时间",
|
||||
"admin.invitationCodes.generateCount": "生成数量",
|
||||
"admin.invitationCodes.generateSuccess": "成功生成 {{count}} 个邀请码",
|
||||
|
||||
// 文件管理
|
||||
"admin.files.title": "文件管理",
|
||||
"admin.files.filename": "文件名",
|
||||
"admin.files.mimeType": "类型",
|
||||
"admin.files.size": "大小",
|
||||
"admin.files.entityType": "关联类型",
|
||||
"admin.files.uploadedBy": "上传者",
|
||||
"admin.files.totalFiles": "文件总数",
|
||||
"admin.files.totalSize": "总大小",
|
||||
"admin.files.batchDelete": "批量删除",
|
||||
"admin.files.preview": "预览",
|
||||
|
||||
// AI 配置
|
||||
"admin.aiSettings.title": "AI 配置",
|
||||
"admin.aiSettings.providers": "AI Provider",
|
||||
"admin.aiSettings.usage": "用量统计",
|
||||
"admin.aiSettings.totalRequests": "总请求数",
|
||||
"admin.aiSettings.totalTokens": "总 Token",
|
||||
"admin.aiSettings.totalCost": "总费用",
|
||||
"admin.aiSettings.provider": "Provider",
|
||||
"admin.aiSettings.model": "模型",
|
||||
"admin.aiSettings.isActive": "启用",
|
||||
"admin.aiSettings.isPublic": "公共",
|
||||
"admin.aiSettings.addProvider": "添加 Provider",
|
||||
|
||||
// 系统设置增强
|
||||
"admin.system.security": "安全策略",
|
||||
"admin.system.fileUpload": "文件上传",
|
||||
"admin.system.notification": "通知配置",
|
||||
"admin.system.brand": "品牌配置",
|
||||
"admin.system.passwordMinLength": "密码最小长度",
|
||||
"admin.system.passwordRequireSpecial": "要求特殊字符",
|
||||
"admin.system.passwordRequireUppercase": "要求大写字母",
|
||||
"admin.system.sessionTimeout": "会话超时(分钟)",
|
||||
"admin.system.forcePasswordChange": "强制修改密码",
|
||||
"admin.system.maxFileSize": "最大文件大小(MB)",
|
||||
"admin.system.allowedTypes": "允许的文件类型",
|
||||
"admin.system.notifyNewUser": "新用户通知",
|
||||
"admin.system.notifyScheduleChange": "调课通知",
|
||||
"admin.system.notifyAnnouncement": "公告通知",
|
||||
"admin.system.logoUrl": "Logo URL",
|
||||
"admin.system.primaryColor": "主题色",
|
||||
"admin.system.schoolMotto": "校训",
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
* @param key i18n key(如 "admin.nav.dashboard")
|
||||
* @param params 模板参数(如 { count: 5 },替换 {{count}})
|
||||
*/
|
||||
export function t(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let msg = messages[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
msg = msg.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v));
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
92
apps/admin-portal/src/lib/permissions.test.ts
Normal file
92
apps/admin-portal/src/lib/permissions.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 权限常量与路由权限映射测试
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_PERMISSIONS,
|
||||
IAM_PERMISSIONS,
|
||||
PERMISSIONS,
|
||||
ROUTE_PERMISSIONS,
|
||||
ADMIN_ERROR_PREFIX,
|
||||
CONSUMED_ERROR_PREFIXES,
|
||||
} from "./permissions";
|
||||
|
||||
describe("permissions constants", () => {
|
||||
it("ADMIN_PERMISSIONS 全部以 ADMIN_ 前缀", () => {
|
||||
for (const code of Object.values(ADMIN_PERMISSIONS)) {
|
||||
expect(code.startsWith("ADMIN_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("IAM_PERMISSIONS 全部以 IAM_ 前缀", () => {
|
||||
for (const code of Object.values(IAM_PERMISSIONS)) {
|
||||
expect(code.startsWith("IAM_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("PERMISSIONS 合并 ADMIN + IAM", () => {
|
||||
expect(PERMISSIONS.DASHBOARD_VIEW).toBe("ADMIN_DASHBOARD_VIEW");
|
||||
expect(PERMISSIONS.USER_READ).toBe("IAM_USER_READ");
|
||||
});
|
||||
|
||||
it("包含必需的管理权限点", () => {
|
||||
expect(ADMIN_PERMISSIONS.SCHOOL_READ).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.GRADE_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.ANNOUNCEMENT_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.FILE_MANAGE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.AI_CONFIGURE).toBeDefined();
|
||||
expect(ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE).toBeDefined();
|
||||
});
|
||||
|
||||
it("ROUTE_PERMISSIONS 覆盖所有 admin 路由", () => {
|
||||
const expectedRoutes = [
|
||||
"/admin/dashboard",
|
||||
"/admin/users",
|
||||
"/admin/roles",
|
||||
"/admin/permissions",
|
||||
"/admin/viewports",
|
||||
"/admin/organization",
|
||||
"/admin/system",
|
||||
"/admin/classes",
|
||||
"/admin/teachers",
|
||||
"/admin/students",
|
||||
"/admin/audit-logs",
|
||||
"/admin/audit-logs/overview",
|
||||
"/admin/audit-logs/data-changes",
|
||||
"/admin/audit-logs/login-logs",
|
||||
"/admin/school",
|
||||
"/admin/school/schools",
|
||||
"/admin/school/grades",
|
||||
"/admin/school/grades/insights",
|
||||
"/admin/school/departments",
|
||||
"/admin/school/academic-year",
|
||||
"/admin/announcements",
|
||||
"/admin/invitation-codes",
|
||||
"/admin/files",
|
||||
"/admin/ai-settings",
|
||||
];
|
||||
for (const route of expectedRoutes) {
|
||||
expect(ROUTE_PERMISSIONS[route]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("ROUTE_PERMISSIONS 审计子路由映射到 AUDIT_READ", () => {
|
||||
expect(ROUTE_PERMISSIONS["/admin/audit-logs/overview"]).toBe(
|
||||
ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
);
|
||||
expect(ROUTE_PERMISSIONS["/admin/audit-logs/data-changes"]).toBe(
|
||||
ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
);
|
||||
});
|
||||
|
||||
it("ADMIN_ERROR_PREFIX 为 ADMIN_", () => {
|
||||
expect(ADMIN_ERROR_PREFIX).toBe("ADMIN_");
|
||||
});
|
||||
|
||||
it("CONSUMED_ERROR_PREFIXES 包含所有上游错误前缀", () => {
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("IAM_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("BFF_TEACHER_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("GW_");
|
||||
expect(CONSUMED_ERROR_PREFIXES).toContain("ADMIN_");
|
||||
});
|
||||
});
|
||||
103
apps/admin-portal/src/lib/permissions.ts
Normal file
103
apps/admin-portal/src/lib/permissions.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* admin-portal 权限点常量
|
||||
*
|
||||
* 仲裁裁决(ISSUE-046 §5.3 / ISSUE-045 §5.2):
|
||||
* - admin-portal 权限点统一加 ADMIN_ 前缀
|
||||
* - 跨服务资源保留原前缀(如 IAM_USER_READ)
|
||||
* - 错误码前缀 ADMIN_(合并 SCHOOL_ / ORG_ 入 ADMIN_)
|
||||
*
|
||||
* 来源:packages/contracts/src/permissions.ts(coord 维护)
|
||||
* admin-portal 引用,不硬编码
|
||||
*/
|
||||
|
||||
/** admin 自身资源权限点(ADMIN_ 前缀) */
|
||||
export const ADMIN_PERMISSIONS = {
|
||||
DASHBOARD_VIEW: "ADMIN_DASHBOARD_VIEW",
|
||||
SYSTEM_MANAGE: "ADMIN_SYSTEM_MANAGE",
|
||||
AUDIT_READ: "ADMIN_AUDIT_READ",
|
||||
CLASS_READ: "ADMIN_CLASS_READ",
|
||||
CLASS_MANAGE: "ADMIN_CLASS_MANAGE",
|
||||
TEACHER_READ: "ADMIN_TEACHER_READ",
|
||||
TEACHER_MANAGE: "ADMIN_TEACHER_MANAGE",
|
||||
STUDENT_READ: "ADMIN_STUDENT_READ",
|
||||
STUDENT_MANAGE: "ADMIN_STUDENT_MANAGE",
|
||||
ORG_MANAGE: "ADMIN_ORG_MANAGE",
|
||||
SCHOOL_READ: "ADMIN_SCHOOL_READ",
|
||||
SCHOOL_MANAGE: "ADMIN_SCHOOL_MANAGE",
|
||||
GRADE_READ: "ADMIN_GRADE_READ",
|
||||
GRADE_MANAGE: "ADMIN_GRADE_MANAGE",
|
||||
GRADE_INSIGHTS_VIEW: "ADMIN_GRADE_INSIGHTS_VIEW",
|
||||
DEPARTMENT_READ: "ADMIN_DEPARTMENT_READ",
|
||||
DEPARTMENT_MANAGE: "ADMIN_DEPARTMENT_MANAGE",
|
||||
ACADEMIC_YEAR_MANAGE: "ADMIN_ACADEMIC_YEAR_MANAGE",
|
||||
ANNOUNCEMENT_READ: "ADMIN_ANNOUNCEMENT_READ",
|
||||
ANNOUNCEMENT_MANAGE: "ADMIN_ANNOUNCEMENT_MANAGE",
|
||||
FILE_READ: "ADMIN_FILE_READ",
|
||||
FILE_MANAGE: "ADMIN_FILE_MANAGE",
|
||||
INVITATION_CODE_MANAGE: "ADMIN_INVITATION_CODE_MANAGE",
|
||||
AI_CONFIGURE: "ADMIN_AI_CONFIGURE",
|
||||
MONITORING_VIEW: "ADMIN_MONITORING_VIEW",
|
||||
} as const;
|
||||
|
||||
/** 跨服务资源权限点(保留原前缀,iam 域) */
|
||||
export const IAM_PERMISSIONS = {
|
||||
USER_READ: "IAM_USER_READ",
|
||||
USER_CREATE: "IAM_USER_CREATE",
|
||||
USER_UPDATE: "IAM_USER_UPDATE",
|
||||
USER_DELETE: "IAM_USER_DELETE",
|
||||
ROLE_READ: "IAM_ROLE_READ",
|
||||
ROLE_CREATE: "IAM_ROLE_CREATE",
|
||||
ROLE_UPDATE: "IAM_ROLE_UPDATE",
|
||||
ROLE_DELETE: "IAM_ROLE_DELETE",
|
||||
PERMISSION_READ: "IAM_PERMISSION_READ",
|
||||
VIEWPORT_READ: "IAM_VIEWPORT_READ",
|
||||
VIEWPORT_UPDATE: "IAM_VIEWPORT_UPDATE",
|
||||
} as const;
|
||||
|
||||
/** 所有权限点合集 */
|
||||
export const PERMISSIONS = {
|
||||
...ADMIN_PERMISSIONS,
|
||||
...IAM_PERMISSIONS,
|
||||
} as const;
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
|
||||
/** admin-portal 视口路由权限映射(contract §1.2) */
|
||||
export const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/admin/dashboard": ADMIN_PERMISSIONS.DASHBOARD_VIEW,
|
||||
"/admin/users": IAM_PERMISSIONS.USER_READ,
|
||||
"/admin/roles": IAM_PERMISSIONS.ROLE_READ,
|
||||
"/admin/permissions": IAM_PERMISSIONS.PERMISSION_READ,
|
||||
"/admin/viewports": IAM_PERMISSIONS.VIEWPORT_READ,
|
||||
"/admin/organization": ADMIN_PERMISSIONS.ORG_MANAGE,
|
||||
"/admin/system": ADMIN_PERMISSIONS.SYSTEM_MANAGE,
|
||||
"/admin/classes": ADMIN_PERMISSIONS.CLASS_READ,
|
||||
"/admin/teachers": ADMIN_PERMISSIONS.TEACHER_READ,
|
||||
"/admin/students": ADMIN_PERMISSIONS.STUDENT_READ,
|
||||
"/admin/audit-logs": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/audit-logs/overview": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/audit-logs/data-changes": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/audit-logs/login-logs": ADMIN_PERMISSIONS.AUDIT_READ,
|
||||
"/admin/school": ADMIN_PERMISSIONS.SCHOOL_READ,
|
||||
"/admin/school/schools": ADMIN_PERMISSIONS.SCHOOL_READ,
|
||||
"/admin/school/grades": ADMIN_PERMISSIONS.GRADE_READ,
|
||||
"/admin/school/grades/insights": ADMIN_PERMISSIONS.GRADE_INSIGHTS_VIEW,
|
||||
"/admin/school/departments": ADMIN_PERMISSIONS.DEPARTMENT_READ,
|
||||
"/admin/school/academic-year": ADMIN_PERMISSIONS.ACADEMIC_YEAR_MANAGE,
|
||||
"/admin/announcements": ADMIN_PERMISSIONS.ANNOUNCEMENT_READ,
|
||||
"/admin/invitation-codes": ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE,
|
||||
"/admin/files": ADMIN_PERMISSIONS.FILE_READ,
|
||||
"/admin/ai-settings": ADMIN_PERMISSIONS.AI_CONFIGURE,
|
||||
};
|
||||
|
||||
/** admin-portal 错误码前缀(ISSUE-045 §5.2) */
|
||||
export const ADMIN_ERROR_PREFIX = "ADMIN_";
|
||||
|
||||
/** 消费的错误码前缀(contract §2.5) */
|
||||
export const CONSUMED_ERROR_PREFIXES = [
|
||||
"IAM_",
|
||||
"BFF_TEACHER_",
|
||||
"GW_",
|
||||
"NETWORK_",
|
||||
"ADMIN_",
|
||||
] as const;
|
||||
43
apps/admin-portal/src/lib/web-vitals.ts
Normal file
43
apps/admin-portal/src/lib/web-vitals.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Web Vitals 采集(A11y + 性能)
|
||||
*
|
||||
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||
* 端点:NEXT_PUBLIC_WEB_VITALS_ENDPOINT(默认 /api/admin/web-vitals)
|
||||
*/
|
||||
import { onLCP, onCLS, onFCP, onINP, onTTFB } from "web-vitals";
|
||||
|
||||
type Metric = { name: string; value: number; rating: string; id: string };
|
||||
|
||||
const ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ?? "/api/admin/web-vitals";
|
||||
|
||||
function sendMetric(metric: Metric): void {
|
||||
if (typeof navigator === "undefined") return;
|
||||
const body = JSON.stringify({
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
id: metric.id,
|
||||
page: window.location.pathname,
|
||||
ts: Date.now(),
|
||||
});
|
||||
// 使用 sendBeacon 避免阻塞卸载
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(ENDPOINT, body);
|
||||
} else {
|
||||
void fetch(ENDPOINT, { method: "POST", body, keepalive: true }).catch(
|
||||
() => {
|
||||
// 忽略上报失败
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initWebVitals(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
onLCP((m) => sendMetric(m as unknown as Metric));
|
||||
onCLS((m) => sendMetric(m as unknown as Metric));
|
||||
onFCP((m) => sendMetric(m as unknown as Metric));
|
||||
onINP((m) => sendMetric(m as unknown as Metric));
|
||||
onTTFB((m) => sendMetric(m as unknown as Metric));
|
||||
}
|
||||
10
apps/admin-portal/src/mocks/browser.ts
Normal file
10
apps/admin-portal/src/mocks/browser.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* MSW Browser Worker
|
||||
*
|
||||
* 仲裁 ARB-004 §4:开发期 MSW 拦截所有 GraphQL/HTTP 请求
|
||||
* 启用条件:NEXT_PUBLIC_API_MOCKING=enabled
|
||||
*/
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal file
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Mock fixtures 完整性测试
|
||||
*
|
||||
* 确保所有 mock 数据导出存在且符合基本结构约束
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mockCurrentUser,
|
||||
mockPermissions,
|
||||
mockRoles,
|
||||
mockUsers,
|
||||
mockViewports,
|
||||
mockOrganization,
|
||||
mockClasses,
|
||||
mockTeachers,
|
||||
mockStudents,
|
||||
mockAuditLogs,
|
||||
mockDashboard,
|
||||
mockSystemSettings,
|
||||
mockSchools,
|
||||
mockGrades,
|
||||
mockGradeInsights,
|
||||
mockDepartments,
|
||||
mockAcademicYears,
|
||||
mockAuditOverviewStats,
|
||||
mockAuditTrend,
|
||||
mockDataChangeLogs,
|
||||
mockLoginLogs,
|
||||
mockAnnouncements,
|
||||
mockInvitationCodes,
|
||||
mockFiles,
|
||||
mockFileStats,
|
||||
mockAiProviders,
|
||||
mockAiUsage,
|
||||
mockSecurityPolicy,
|
||||
mockFileUploadConfig,
|
||||
mockNotificationConfig,
|
||||
mockBrandConfig,
|
||||
genId,
|
||||
} from "./fixtures";
|
||||
|
||||
describe("mock fixtures 完整性", () => {
|
||||
it("mockCurrentUser 包含管理员权限", () => {
|
||||
expect(mockCurrentUser.id).toBeTruthy();
|
||||
expect(mockCurrentUser.permissions).toContain("ADMIN_DASHBOARD_VIEW");
|
||||
expect(mockCurrentUser.permissions.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("mockPermissions 非空且每条有 code", () => {
|
||||
expect(mockPermissions.length).toBeGreaterThan(10);
|
||||
for (const p of mockPermissions) {
|
||||
expect(p.code).toBeTruthy();
|
||||
expect(p.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockRoles 包含系统管理员", () => {
|
||||
expect(mockRoles.length).toBeGreaterThanOrEqual(4);
|
||||
const sysAdmin = mockRoles.find((r) => r.code === "system_admin");
|
||||
expect(sysAdmin).toBeDefined();
|
||||
expect(sysAdmin?.dataScope).toBe("ALL");
|
||||
});
|
||||
|
||||
it("mockUsers 非空", () => {
|
||||
expect(mockUsers.length).toBeGreaterThanOrEqual(3);
|
||||
for (const u of mockUsers) {
|
||||
expect(u.email).toContain("@");
|
||||
expect(u.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockViewports 覆盖所有 scope", () => {
|
||||
const scopes = new Set(mockViewports.map((v) => v.scope));
|
||||
expect(scopes.has("teacher")).toBe(true);
|
||||
expect(scopes.has("student")).toBe(true);
|
||||
expect(scopes.has("parent")).toBe(true);
|
||||
expect(scopes.has("admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("mockOrganization 树形结构有根节点", () => {
|
||||
const roots = mockOrganization.filter((n) => n.parentId === null);
|
||||
expect(roots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mockClasses/Teachers/Students 非空", () => {
|
||||
expect(mockClasses.length).toBeGreaterThan(0);
|
||||
expect(mockTeachers.length).toBeGreaterThan(0);
|
||||
expect(mockStudents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mockAuditLogs 非空且有时间戳", () => {
|
||||
expect(mockAuditLogs.length).toBeGreaterThan(0);
|
||||
for (const log of mockAuditLogs) {
|
||||
expect(typeof log.occurredAt).toBe("number");
|
||||
expect(log.action).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("mockDashboard 包含基础统计", () => {
|
||||
expect(mockDashboard).toBeDefined();
|
||||
expect(typeof mockDashboard).toBe("object");
|
||||
});
|
||||
|
||||
it("mockSystemSettings 非空", () => {
|
||||
expect(mockSystemSettings).toBeDefined();
|
||||
});
|
||||
|
||||
it("学校组织 mock 数据完整", () => {
|
||||
expect(mockSchools.length).toBeGreaterThan(0);
|
||||
expect(mockGrades.length).toBeGreaterThan(0);
|
||||
expect(mockDepartments.length).toBeGreaterThan(0);
|
||||
expect(mockAcademicYears.length).toBeGreaterThan(0);
|
||||
expect(mockGradeInsights).toBeDefined();
|
||||
});
|
||||
|
||||
it("审计子模块 mock 数据完整", () => {
|
||||
expect(mockAuditOverviewStats).toBeDefined();
|
||||
expect(mockAuditTrend.length).toBe(7);
|
||||
expect(mockDataChangeLogs.length).toBeGreaterThan(0);
|
||||
expect(mockLoginLogs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("公告/邀请码/文件 mock 数据完整", () => {
|
||||
expect(mockAnnouncements.length).toBeGreaterThan(0);
|
||||
expect(mockInvitationCodes.length).toBeGreaterThan(0);
|
||||
expect(mockFiles.length).toBeGreaterThan(0);
|
||||
expect(mockFileStats).toBeDefined();
|
||||
});
|
||||
|
||||
it("AI 设置 mock 数据完整", () => {
|
||||
expect(mockAiProviders.length).toBeGreaterThan(0);
|
||||
expect(mockAiUsage).toBeDefined();
|
||||
});
|
||||
|
||||
it("系统设置增强 mock 数据完整", () => {
|
||||
expect(mockSecurityPolicy).toBeDefined();
|
||||
expect(mockFileUploadConfig).toBeDefined();
|
||||
expect(mockNotificationConfig).toBeDefined();
|
||||
expect(mockBrandConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("genId 生成唯一 ID", () => {
|
||||
const id1 = genId("test");
|
||||
const id2 = genId("test");
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(id1.startsWith("test-")).toBe(true);
|
||||
});
|
||||
});
|
||||
1634
apps/admin-portal/src/mocks/fixtures.ts
Normal file
1634
apps/admin-portal/src/mocks/fixtures.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user